replace auth with rate limiter
This commit is contained in:
parent
1434756b10
commit
3721ea7a6a
7 changed files with 49 additions and 109 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -41,6 +41,3 @@ coverage/
|
||||||
|
|
||||||
# db
|
# db
|
||||||
*.db
|
*.db
|
||||||
|
|
||||||
# auth private key
|
|
||||||
auth_key.pem
|
|
||||||
42
package-lock.json
generated
42
package-lock.json
generated
|
|
@ -11,7 +11,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"uuid": "^13.0.0"
|
"express-rate-limit": "^8.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^5.0.3",
|
"@types/express": "^5.0.3",
|
||||||
|
|
@ -1122,6 +1122,24 @@
|
||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/express-rate-limit": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-4nLnATuKupnmwqiJc27b4dCFmB/T60ExgmtDD7waf4LdrbJ8CPZzZRHYErDYNhoz+ql8fUdYwM/opf90PoPAQA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ip-address": "10.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/express-rate-limit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"express": ">= 4.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
|
|
@ -1462,6 +1480,15 @@
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ip-address": {
|
||||||
|
"version": "10.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
|
||||||
|
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
|
|
@ -2439,19 +2466,6 @@
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"uuid": "^13.0.0"
|
"express-rate-limit": "^8.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^5.0.3",
|
"@types/express": "^5.0.3",
|
||||||
|
|
|
||||||
40
src/auth.ts
40
src/auth.ts
|
|
@ -1,40 +0,0 @@
|
||||||
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
22
src/index.ts
22
src/index.ts
|
|
@ -1,6 +1,6 @@
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { bearerAuthMiddleware } from './authMiddleware.js';
|
import { rateLimit } from 'express-rate-limit';
|
||||||
import {
|
import {
|
||||||
onGetImages,
|
onGetImages,
|
||||||
onGetQuoteById,
|
onGetQuoteById,
|
||||||
|
|
@ -10,7 +10,25 @@ import {
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use(bearerAuthMiddleware);
|
const hourlyLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 60 * 1000,
|
||||||
|
max: 1000,
|
||||||
|
message: 'Too many requests, please try again in an hour',
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const burstLimiter = rateLimit({
|
||||||
|
windowMs: 10 * 1000,
|
||||||
|
max: 50,
|
||||||
|
message: 'Slow down! Too many requests',
|
||||||
|
skipSuccessfulRequests: true,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(hourlyLimiter);
|
||||||
|
app.use(burstLimiter);
|
||||||
|
|
||||||
app.get('/quotes/random', onGetRandomQuote);
|
app.get('/quotes/random', onGetRandomQuote);
|
||||||
app.get('/quotes/:id', onGetQuoteById);
|
app.get('/quotes/:id', onGetQuoteById);
|
||||||
|
|
|
||||||
9
src/types.d.ts
vendored
9
src/types.d.ts
vendored
|
|
@ -1,12 +1,3 @@
|
||||||
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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue