From 1434756b1053b8dd477ef8c235d34512c165d622 Mon Sep 17 00:00:00 2001 From: fiatcode Date: Sat, 18 Oct 2025 23:33:22 +0700 Subject: [PATCH] implement auth --- .env.example | 3 +-- .gitignore | 5 ++++- .vscode/launch.json | 2 -- README.md | 9 +++++++++ package-lock.json | 16 +++++++++++++++- package.json | 3 ++- src/auth.ts | 40 ++++++++++++++++++++++++++++++++++++++++ src/authMiddleware.ts | 40 ++++++++++++++++++++++++++++++++++++++++ src/index.ts | 3 +++ src/routes.ts | 12 ++++++------ src/types.d.ts | 9 +++++++++ tsconfig.json | 1 + 12 files changed, 130 insertions(+), 13 deletions(-) create mode 100644 src/auth.ts create mode 100644 src/authMiddleware.ts diff --git a/.env.example b/.env.example index a3c3b53..90bb771 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,3 @@ -# Example environment variables NODE_ENV=development -UNSPLASH_ACCESS_KEY=your_unsplash_access_key_here PORT=8080 +UNSPLASH_ACCESS_KEY=your_unsplash_access_key_here diff --git a/.gitignore b/.gitignore index 47c9c61..b0c5634 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,7 @@ coverage/ .temp/ # db -*.db \ No newline at end of file +*.db + +# auth private key +auth_key.pem \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 25bd04e..e6e6efd 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,8 +11,6 @@ ], "cwd": "${workspaceFolder}", "envFile": "${workspaceFolder}/.env", - "console": "integratedTerminal", - "internalConsoleOptions": "neverOpen" } ] } \ No newline at end of file diff --git a/README.md b/README.md index 97e7c9c..a5d99f9 100644 --- a/README.md +++ b/README.md @@ -5,15 +5,18 @@ A Node.js REST API built with TypeScript and Express. ## Getting Started ### Prerequisites + - Node.js >= 18 - npm ### Installation + ```sh npm install ``` ### Configuration + 1. Copy `.env.example` to `.env` and fill in your secrets: ```sh cp .env.example .env @@ -21,20 +24,24 @@ npm install 2. Set your Unsplash access key and other variables in `.env`. ### Running the Server + ```sh npm start ``` ### Formatting Code + ```sh npm run format ``` ### Recommended VS Code Extensions + - ESLint - Prettier ## Project Structure + ``` kuwot-api-js/ ├── src/ @@ -54,7 +61,9 @@ kuwot-api-js/ ``` ## Contributing + Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. ## License + ISC diff --git a/package-lock.json b/package-lock.json index 85a0994..31cebd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "license": "ISC", "dependencies": { "dotenv": "^17.2.3", - "express": "^5.1.0" + "express": "^5.1.0", + "uuid": "^13.0.0" }, "devDependencies": { "@types/express": "^5.0.3", @@ -2438,6 +2439,19 @@ "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": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", diff --git a/package.json b/package.json index b173665..b9d8d0d 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "license": "ISC", "dependencies": { "dotenv": "^17.2.3", - "express": "^5.1.0" + "express": "^5.1.0", + "uuid": "^13.0.0" }, "devDependencies": { "@types/express": "^5.0.3", diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..9d568ea --- /dev/null +++ b/src/auth.ts @@ -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 + ); + } +} diff --git a/src/authMiddleware.ts b/src/authMiddleware.ts new file mode 100644 index 0000000..b7fb1b0 --- /dev/null +++ b/src/authMiddleware.ts @@ -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 }); + } +} diff --git a/src/index.ts b/src/index.ts index d6eab8e..98a8339 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import 'dotenv/config'; import express from 'express'; +import { bearerAuthMiddleware } from './authMiddleware.js'; import { onGetImages, onGetQuoteById, @@ -9,6 +10,8 @@ import { const app = express(); +app.use(bearerAuthMiddleware); + app.get('/quotes/random', onGetRandomQuote); app.get('/quotes/:id', onGetQuoteById); app.get('/translations', onGetTranslations); diff --git a/src/routes.ts b/src/routes.ts index d5cbf9d..0b7a803 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -22,8 +22,8 @@ function onGetQuoteById(req: Request, res: Response): void { const id = parseInt(idParam); const quote = getQuote(id); res.status(200).send(quote); - } catch (error: any) { - res.status(500).send({ error: error.message }); + } catch (e) { + res.status(500).send({ error: (e as Error).message }); } } @@ -31,8 +31,8 @@ function onGetTranslations(_req: Request, res: Response): void { try { const translations = listTranslations(); res.status(200).send(translations); - } catch (error: any) { - res.status(500).send({ error: error.message }); + } catch (e) { + res.status(500).send({ error: (e as Error).message }); } } @@ -40,8 +40,8 @@ async function onGetImages(_req: Request, res: Response): Promise { try { const images = await listRandomImages(); res.status(200).send(images); - } catch (error: any) { - res.status(500).send({ error: error.message }); + } catch (e) { + res.status(500).send({ error: (e as Error).message }); } } diff --git a/src/types.d.ts b/src/types.d.ts index 00498c0..8698233 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -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 { id: number; text: string; diff --git a/tsconfig.json b/tsconfig.json index 3eea4d8..f83dfdc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ "forceConsistentCasingInFileNames": true, "strict": true, "skipLibCheck": true, + "sourceMap": true, "outDir": "./dist", "rootDir": "./src" },