implement auth

This commit is contained in:
fiatcode 2025-10-18 23:33:22 +07:00
parent 96761cb911
commit 1434756b10
12 changed files with 130 additions and 13 deletions

View file

@ -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

3
.gitignore vendored
View file

@ -41,3 +41,6 @@ coverage/
# db # db
*.db *.db
# auth private key
auth_key.pem

2
.vscode/launch.json vendored
View file

@ -11,8 +11,6 @@
], ],
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}",
"envFile": "${workspaceFolder}/.env", "envFile": "${workspaceFolder}/.env",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
} }
] ]
} }

View file

@ -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
View file

@ -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",

View file

@ -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
View 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
View 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 });
}
}

View file

@ -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);

View file

@ -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
View file

@ -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;

View file

@ -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"
}, },