Skip to content

Commit a0c6e61

Browse files
committed
Secure backend data with signed auth tokens and RBAC
1 parent 0c92939 commit a0c6e61

3 files changed

Lines changed: 127 additions & 20 deletions

File tree

backend/README.md

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,20 @@ Server starts at `http://localhost:4000` by default.
3333
- `LOGIN_RATE_LIMIT_WINDOW_MS`
3434
- `LOGIN_RATE_LIMIT_BLOCK_MS`
3535

36+
### Issue #30: Secure backend data access with signed auth tokens + authorization
37+
38+
- Login now returns an HMAC-signed bearer token (replacing predictable demo tokens).
39+
- Tokens include user id, role, and expiry, and are validated with constant-time signature checks.
40+
- Data endpoints now require `Authorization: Bearer <token>` and enforce role access:
41+
- `GET /api/orders` → users can only read their own orders; admins can read all.
42+
- `POST /api/orders` → users can create only for themselves; admins can create for any user.
43+
- `GET /api/orders/:id` → users can read only their own order; admins can read any order.
44+
- `GET /api/bills/:spotId` and `DELETE /api/users/:userId` → admin only.
45+
- Configure via env vars:
46+
- `AUTH_TOKEN_SECRET`
47+
- `AUTH_TOKEN_TTL_SECONDS`
48+
- `CORS_ALLOW_ORIGIN`
49+
3650

3751
## Available endpoints
3852

@@ -41,10 +55,11 @@ Server starts at `http://localhost:4000` by default.
4155
- `GET /api/catalog`
4256
- `GET /api/catalog/:category` (`drinks`, `food`, `cigarettes`)
4357
- `GET /api/spots`
44-
- `GET /api/orders?spotId=...&userId=...`
45-
- `POST /api/orders`
46-
- `GET /api/bills/:spotId`
47-
- `DELETE /api/users/:userId` (removes the user and all related records)
58+
- `GET /api/orders?spotId=...&userId=...` (auth required)
59+
- `GET /api/orders/:id` (auth required)
60+
- `POST /api/orders` (auth required)
61+
- `GET /api/bills/:spotId` (admin only)
62+
- `DELETE /api/users/:userId` (admin only; removes the user and all related records)
4863

4964
## Example login payload
5065

backend/env.js

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,30 @@
1-
const dotenv = require("dotenv");
2-
const { z } = require("zod");
1+
import dotenv from 'dotenv';
2+
import { z } from 'zod';
33

44
dotenv.config();
55

66
const envSchema = z.object({
77
VITE_SUPABASE_URL: z.string().url(),
88
VITE_SUPABASE_ANON_KEY: z.string().min(10),
9-
PORT: z.string().optional()
9+
PORT: z.string().optional(),
10+
AUTH_TOKEN_SECRET: z.string().min(16).optional(),
11+
AUTH_TOKEN_TTL_SECONDS: z.string().regex(/^\d+$/).optional(),
12+
CORS_ALLOW_ORIGIN: z.string().optional(),
13+
LOGIN_RATE_LIMIT_MAX_ATTEMPTS: z.string().regex(/^\d+$/).optional(),
14+
LOGIN_RATE_LIMIT_WINDOW_MS: z.string().regex(/^\d+$/).optional(),
15+
LOGIN_RATE_LIMIT_BLOCK_MS: z.string().regex(/^\d+$/).optional(),
1016
});
1117

1218
const result = envSchema.safeParse(process.env);
1319

1420
if (!result.success) {
15-
console.error("\n❌ Invalid environment configuration:\n");
21+
console.error('\n❌ Invalid environment configuration:\n');
1622

1723
result.error.errors.forEach((err) => {
18-
console.error(`- ${err.path.join(".")}: ${err.message}`);
24+
console.error(`- ${err.path.join('.')}: ${err.message}`);
1925
});
2026

21-
process.exit(1); // 🔥 FAIL FAST
27+
process.exit(1);
2228
}
2329

24-
module.exports = result.data;
30+
export default result.data;

backend/server.js

Lines changed: 95 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import { createServer } from 'node:http';
2+
import { createHmac, timingSafeEqual } from 'node:crypto';
23
import { URL } from 'node:url';
34
import { database, dbPath } from './db.js';
4-
require("./env");
5+
import "./env.js";
56

67
const port = Number(process.env.PORT || 4000);
78

89
const LOGIN_RATE_LIMIT_MAX_ATTEMPTS = Number(process.env.LOGIN_RATE_LIMIT_MAX_ATTEMPTS || 5);
910
const LOGIN_RATE_LIMIT_WINDOW_MS = Number(process.env.LOGIN_RATE_LIMIT_WINDOW_MS || 15 * 60 * 1000);
1011
const LOGIN_RATE_LIMIT_BLOCK_MS = Number(process.env.LOGIN_RATE_LIMIT_BLOCK_MS || 15 * 60 * 1000);
12+
const AUTH_TOKEN_SECRET = process.env.AUTH_TOKEN_SECRET || 'brocode-dev-secret-change-me';
13+
const AUTH_TOKEN_TTL_SECONDS = Number(process.env.AUTH_TOKEN_TTL_SECONDS || 60 * 60 * 12);
14+
const CORS_ALLOW_ORIGIN = process.env.CORS_ALLOW_ORIGIN || '*';
1115
const loginAttempts = new Map();
1216

1317
const getLoginKey = (req, username) => {
@@ -62,18 +66,64 @@ const parseBearerToken = (authHeader) => {
6266
return token;
6367
};
6468

69+
const toBase64Url = (value) => Buffer.from(value).toString('base64url');
70+
71+
const signToken = (payload) =>
72+
createHmac('sha256', AUTH_TOKEN_SECRET).update(payload).digest('base64url');
73+
74+
const generateAuthToken = (user) => {
75+
const payload = {
76+
sub: user.id,
77+
role: user.role,
78+
exp: Math.floor(Date.now() / 1000) + AUTH_TOKEN_TTL_SECONDS,
79+
};
80+
81+
const payloadPart = toBase64Url(JSON.stringify(payload));
82+
const signature = signToken(payloadPart);
83+
return `${payloadPart}.${signature}`;
84+
};
85+
86+
const verifyAuthToken = (token) => {
87+
const [payloadPart, signaturePart] = token.split('.');
88+
if (!payloadPart || !signaturePart) {
89+
return null;
90+
}
91+
92+
const expectedSignature = signToken(payloadPart);
93+
const providedSignatureBuffer = Buffer.from(signaturePart, 'base64url');
94+
const expectedSignatureBuffer = Buffer.from(expectedSignature, 'base64url');
95+
if (providedSignatureBuffer.length !== expectedSignatureBuffer.length) {
96+
return null;
97+
}
98+
99+
if (!timingSafeEqual(providedSignatureBuffer, expectedSignatureBuffer)) {
100+
return null;
101+
}
102+
103+
try {
104+
const payload = JSON.parse(Buffer.from(payloadPart, 'base64url').toString('utf-8'));
105+
if (!payload.sub || !payload.exp || payload.exp < Math.floor(Date.now() / 1000)) {
106+
return null;
107+
}
108+
109+
return payload;
110+
} catch {
111+
return null;
112+
}
113+
};
114+
65115
const getUserFromAuthHeader = (authHeader) => {
66116
const token = parseBearerToken(authHeader);
67-
if (!token || !token.startsWith('demo-token-')) {
117+
if (!token) {
68118
return null;
69119
}
70120

71-
const userId = token.slice('demo-token-'.length);
72-
if (!userId) {
121+
const verifiedPayload = verifyAuthToken(token);
122+
if (!verifiedPayload) {
73123
return null;
74124
}
75125

76-
return database.getUserById(userId);
126+
return database.getUserById(verifiedPayload.sub);
77127
};
78128

79129
const recordFailedLoginAttempt = (key) => {
@@ -89,8 +139,8 @@ const recordFailedLoginAttempt = (key) => {
89139
const sendJson = (res, statusCode, body) => {
90140
res.writeHead(statusCode, {
91141
'Content-Type': 'application/json',
92-
'Access-Control-Allow-Origin': '*',
93-
'Access-Control-Allow-Headers': 'Content-Type',
142+
'Access-Control-Allow-Origin': CORS_ALLOW_ORIGIN,
143+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
94144
'Access-Control-Allow-Methods': 'GET,POST,DELETE,OPTIONS',
95145
});
96146
res.end(JSON.stringify(body));
@@ -166,7 +216,7 @@ const server = createServer(async (req, res) => {
166216

167217
clearRateLimitState(loginKey);
168218

169-
sendJson(res, 200, { token: `demo-token-${user.id}`, user });
219+
sendJson(res, 200, { token: generateAuthToken(user), user });
170220
return;
171221
} catch (error) {
172222
sendJson(res, 400, { error: error.message });
@@ -199,10 +249,23 @@ const server = createServer(async (req, res) => {
199249
}
200250

201251
if (method === 'GET' && path === '/api/orders') {
252+
const authedUser = getUserFromAuthHeader(req.headers.authorization);
253+
if (!authedUser) {
254+
sendJson(res, 401, { error: 'Unauthorized' });
255+
return;
256+
}
257+
202258
const spotId = parsedUrl.searchParams.get('spotId');
203259
const userId = parsedUrl.searchParams.get('userId');
204260

205-
const orders = database.getOrders({ spotId, userId });
261+
if (authedUser.role !== 'admin' && userId && userId !== authedUser.id) {
262+
sendJson(res, 403, { error: 'Forbidden' });
263+
return;
264+
}
265+
266+
const effectiveUserId = authedUser.role === 'admin' ? userId : authedUser.id;
267+
268+
const orders = database.getOrders({ spotId, userId: effectiveUserId });
206269
sendJson(res, 200, orders);
207270
return;
208271
}
@@ -234,13 +297,24 @@ const server = createServer(async (req, res) => {
234297

235298
if (method === 'POST' && path === '/api/orders') {
236299
try {
300+
const authedUser = getUserFromAuthHeader(req.headers.authorization);
301+
if (!authedUser) {
302+
sendJson(res, 401, { error: 'Unauthorized' });
303+
return;
304+
}
305+
237306
const { spotId, userId, items } = await readBody(req);
238307

239308
if (!spotId || !userId || !Array.isArray(items) || items.length === 0) {
240309
sendJson(res, 400, { error: 'spotId, userId and at least one order item are required' });
241310
return;
242311
}
243312

313+
if (authedUser.role !== 'admin' && userId !== authedUser.id) {
314+
sendJson(res, 403, { error: 'Forbidden' });
315+
return;
316+
}
317+
244318
if (!database.userExists(userId)) {
245319
sendJson(res, 404, { error: `Unknown userId: ${userId}` });
246320
return;
@@ -265,13 +339,25 @@ const server = createServer(async (req, res) => {
265339
}
266340

267341
if (method === 'GET' && path.startsWith('/api/bills/')) {
342+
const authedUser = getUserFromAuthHeader(req.headers.authorization);
343+
if (!authedUser || authedUser.role !== 'admin') {
344+
sendJson(res, 403, { error: 'Forbidden' });
345+
return;
346+
}
347+
268348
const spotId = path.replace('/api/bills/', '');
269349
const bill = database.getBillBySpotId(spotId);
270350
sendJson(res, 200, bill);
271351
return;
272352
}
273353

274354
if (method === 'DELETE' && path.startsWith('/api/users/')) {
355+
const authedUser = getUserFromAuthHeader(req.headers.authorization);
356+
if (!authedUser || authedUser.role !== 'admin') {
357+
sendJson(res, 403, { error: 'Forbidden' });
358+
return;
359+
}
360+
275361
const userId = path.replace('/api/users/', '');
276362

277363
if (!userId) {

0 commit comments

Comments
 (0)