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
UNSPLASH_ACCESS_KEY=your_unsplash_access_key_here
PORT=8080
UNSPLASH_ACCESS_KEY=your_unsplash_access_key_here

5
.gitignore vendored
View file

@ -40,4 +40,7 @@ coverage/
.temp/
# db
*.db
*.db
# auth private key
auth_key.pem

2
.vscode/launch.json vendored
View file

@ -11,8 +11,6 @@
],
"cwd": "${workspaceFolder}",
"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
### 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

16
package-lock.json generated
View file

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

View file

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

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

View file

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

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 {
id: number;
text: string;

View file

@ -8,6 +8,7 @@
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src"
},