implement auth
This commit is contained in:
parent
96761cb911
commit
1434756b10
12 changed files with 130 additions and 13 deletions
|
|
@ -1,4 +1,3 @@
|
||||||
# Example environment variables
|
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
UNSPLASH_ACCESS_KEY=your_unsplash_access_key_here
|
|
||||||
PORT=8080
|
PORT=8080
|
||||||
|
UNSPLASH_ACCESS_KEY=your_unsplash_access_key_here
|
||||||
|
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -40,4 +40,7 @@ coverage/
|
||||||
.temp/
|
.temp/
|
||||||
|
|
||||||
# db
|
# db
|
||||||
*.db
|
*.db
|
||||||
|
|
||||||
|
# auth private key
|
||||||
|
auth_key.pem
|
||||||
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
|
|
@ -11,8 +11,6 @@
|
||||||
],
|
],
|
||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
"envFile": "${workspaceFolder}/.env",
|
"envFile": "${workspaceFolder}/.env",
|
||||||
"console": "integratedTerminal",
|
|
||||||
"internalConsoleOptions": "neverOpen"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -5,15 +5,18 @@ A Node.js REST API built with TypeScript and Express.
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Node.js >= 18
|
- Node.js >= 18
|
||||||
- npm
|
- npm
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
1. Copy `.env.example` to `.env` and fill in your secrets:
|
1. Copy `.env.example` to `.env` and fill in your secrets:
|
||||||
```sh
|
```sh
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
|
@ -21,20 +24,24 @@ npm install
|
||||||
2. Set your Unsplash access key and other variables in `.env`.
|
2. Set your Unsplash access key and other variables in `.env`.
|
||||||
|
|
||||||
### Running the Server
|
### Running the Server
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
### Formatting Code
|
### Formatting Code
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm run format
|
npm run format
|
||||||
```
|
```
|
||||||
|
|
||||||
### Recommended VS Code Extensions
|
### Recommended VS Code Extensions
|
||||||
|
|
||||||
- ESLint
|
- ESLint
|
||||||
- Prettier
|
- Prettier
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
kuwot-api-js/
|
kuwot-api-js/
|
||||||
├── src/
|
├── src/
|
||||||
|
|
@ -54,7 +61,9 @@ kuwot-api-js/
|
||||||
```
|
```
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.
|
Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
ISC
|
ISC
|
||||||
|
|
|
||||||
16
package-lock.json
generated
16
package-lock.json
generated
|
|
@ -10,7 +10,8 @@
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^5.1.0"
|
"express": "^5.1.0",
|
||||||
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^5.0.3",
|
"@types/express": "^5.0.3",
|
||||||
|
|
@ -2438,6 +2439,19 @@
|
||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uuid": {
|
||||||
|
"version": "13.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||||
|
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist-node/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/v8-compile-cache-lib": {
|
"node_modules/v8-compile-cache-lib": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,8 @@
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^5.1.0"
|
"express": "^5.1.0",
|
||||||
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^5.0.3",
|
"@types/express": "^5.0.3",
|
||||||
|
|
|
||||||
40
src/auth.ts
Normal file
40
src/auth.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { constants, createPrivateKey, privateDecrypt } from 'crypto';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import { validate as validateUUID } from 'uuid';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple auth implementation using RSA.
|
||||||
|
* Client sends a base64 UUID encrypted with public key, server decrypts with private key and checks validity.
|
||||||
|
*/
|
||||||
|
export class Auth {
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
decryptToken(token: string): string {
|
||||||
|
const keyPem = fs.readFileSync('auth_key.pem', 'utf-8');
|
||||||
|
const privateKey = createPrivateKey({ key: keyPem, format: 'pem' });
|
||||||
|
const encryptedBuffer = Buffer.from(token, 'base64');
|
||||||
|
const decrypted = privateDecrypt(
|
||||||
|
{
|
||||||
|
key: privateKey,
|
||||||
|
padding: constants.RSA_PKCS1_PADDING,
|
||||||
|
},
|
||||||
|
encryptedBuffer
|
||||||
|
);
|
||||||
|
return decrypted.toString('utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
isTokenValid(decryptedToken: string): boolean {
|
||||||
|
return validateUUID(decryptedToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
isTokenExpired(issuedAt: number): boolean {
|
||||||
|
const tokenLifetimeSeconds = 300;
|
||||||
|
const allowedDriftSeconds = 10;
|
||||||
|
const now = Math.floor(Date.now() / 1000); // current unix timestamp in seconds
|
||||||
|
const diff = now - issuedAt;
|
||||||
|
return (
|
||||||
|
diff < -allowedDriftSeconds ||
|
||||||
|
diff > tokenLifetimeSeconds + allowedDriftSeconds
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/authMiddleware.ts
Normal file
40
src/authMiddleware.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
|
import { Auth } from './auth.js';
|
||||||
|
import { AuthPayload } from './types.js';
|
||||||
|
|
||||||
|
const auth = new Auth();
|
||||||
|
|
||||||
|
export function bearerAuthMiddleware(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
const authHeader = req.headers['authorization'];
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return res
|
||||||
|
.status(401)
|
||||||
|
.json({ error: 'Missing or invalid Authorization header' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = authHeader.substring('Bearer '.length);
|
||||||
|
const decrypted = auth.decryptToken(token);
|
||||||
|
if (!decrypted) {
|
||||||
|
return res.status(401).json({ error: 'Invalid token' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: AuthPayload = JSON.parse(decrypted);
|
||||||
|
if (!payload.token || typeof payload.issuedAt !== 'number') {
|
||||||
|
return res.status(401).json({ error: 'Token payload missing fields' });
|
||||||
|
}
|
||||||
|
if (!auth.isTokenValid(payload.token)) {
|
||||||
|
return res.status(401).json({ error: 'Token is not a valid UUID' });
|
||||||
|
}
|
||||||
|
if (auth.isTokenExpired(payload.issuedAt)) {
|
||||||
|
return res.status(401).json({ error: 'Token expired' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(500).json({ error: (e as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import { bearerAuthMiddleware } from './authMiddleware.js';
|
||||||
import {
|
import {
|
||||||
onGetImages,
|
onGetImages,
|
||||||
onGetQuoteById,
|
onGetQuoteById,
|
||||||
|
|
@ -9,6 +10,8 @@ import {
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
|
app.use(bearerAuthMiddleware);
|
||||||
|
|
||||||
app.get('/quotes/random', onGetRandomQuote);
|
app.get('/quotes/random', onGetRandomQuote);
|
||||||
app.get('/quotes/:id', onGetQuoteById);
|
app.get('/quotes/:id', onGetQuoteById);
|
||||||
app.get('/translations', onGetTranslations);
|
app.get('/translations', onGetTranslations);
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,8 @@ function onGetQuoteById(req: Request, res: Response): void {
|
||||||
const id = parseInt(idParam);
|
const id = parseInt(idParam);
|
||||||
const quote = getQuote(id);
|
const quote = getQuote(id);
|
||||||
res.status(200).send(quote);
|
res.status(200).send(quote);
|
||||||
} catch (error: any) {
|
} catch (e) {
|
||||||
res.status(500).send({ error: error.message });
|
res.status(500).send({ error: (e as Error).message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -31,8 +31,8 @@ function onGetTranslations(_req: Request, res: Response): void {
|
||||||
try {
|
try {
|
||||||
const translations = listTranslations();
|
const translations = listTranslations();
|
||||||
res.status(200).send(translations);
|
res.status(200).send(translations);
|
||||||
} catch (error: any) {
|
} catch (e) {
|
||||||
res.status(500).send({ error: error.message });
|
res.status(500).send({ error: (e as Error).message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -40,8 +40,8 @@ async function onGetImages(_req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const images = await listRandomImages();
|
const images = await listRandomImages();
|
||||||
res.status(200).send(images);
|
res.status(200).send(images);
|
||||||
} catch (error: any) {
|
} catch (e) {
|
||||||
res.status(500).send({ error: error.message });
|
res.status(500).send({ error: (e as Error).message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
9
src/types.d.ts
vendored
9
src/types.d.ts
vendored
|
|
@ -1,3 +1,12 @@
|
||||||
|
export interface AuthPayload {
|
||||||
|
// An UUID string, unique per request.
|
||||||
|
token: string;
|
||||||
|
|
||||||
|
// Unix time in seconds. It is the time when the token was issued.
|
||||||
|
// Will expire after a time window.
|
||||||
|
issuedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Quote {
|
export interface Quote {
|
||||||
id: number;
|
id: number;
|
||||||
text: string;
|
text: string;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src"
|
"rootDir": "./src"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue