Add auth role to api
This commit is contained in:
1 parent
9a476d9b86
commit
f27d87531c
6 files changed
+364
No files matched your search
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { createAuthMiddleware } from './auth.middleware.ts';
|
||||||
|
|
||||||
|
function createMockResponse() {
|
||||||
|
const res: Partial<Response> = {};
|
||||||
|
const data = { statusCode: 200, jsonPayload: null as any };
|
||||||
|
|
||||||
|
res.status = function (code: number) { data.statusCode = code; return this as Response; };
|
||||||
|
res.json = function (payload: any) { data.jsonPayload = payload; return this as Response; };
|
||||||
|
return { mockRes: res as Response, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockDb(sessionRow: any) {
|
||||||
|
return {
|
||||||
|
query: async (text: string, values: any[]) => {
|
||||||
|
return { rows: sessionRow ? [sessionRow] : [] };
|
||||||
|
}
|
||||||
|
} as unknown as Pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Auth Middleware (TDD)', () => {
|
||||||
|
it('should return 401 Unauthorized if no session cookie is attached to the request', async () => {
|
||||||
|
const mockDb = createMockDb(null);
|
||||||
|
const middleware = createAuthMiddleware(mockDb);
|
||||||
|
|
||||||
|
const mockReq = { headers: {} } as unknown as Request; // Missing header/cookie structures
|
||||||
|
const { mockRes, data } = createMockResponse();
|
||||||
|
let nextCalled = false;
|
||||||
|
|
||||||
|
await middleware(mockReq, mockRes, () => { nextCalled = true; });
|
||||||
|
|
||||||
|
assert.equal(data.statusCode, 401);
|
||||||
|
assert.equal(data.jsonPayload.success, false);
|
||||||
|
assert.equal(nextCalled, false); // Blocked early
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 Unauthorized if the session token has expired', async () => {
|
||||||
|
// Return a mock session row that expired 1 hour ago
|
||||||
|
const pastDate = new Date(Date.now() - 3600000);
|
||||||
|
const mockDb = createMockDb({ user_id: 1, expires_at: pastDate });
|
||||||
|
const middleware = createAuthMiddleware(mockDb);
|
||||||
|
|
||||||
|
// Simulate an Express request passing an expired cookie string
|
||||||
|
const mockReq = {
|
||||||
|
headers: { cookie: 'session_token=expired-token-string' }
|
||||||
|
} as unknown as Request;
|
||||||
|
|
||||||
|
const { mockRes, data } = createMockResponse();
|
||||||
|
let nextCalled = false;
|
||||||
|
|
||||||
|
await middleware(mockReq, mockRes, () => { nextCalled = true; });
|
||||||
|
|
||||||
|
assert.equal(data.statusCode, 401);
|
||||||
|
assert.equal(nextCalled, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
|
||||||
|
// Helper utility to parse cookies manually without adding extra npm packages
|
||||||
|
function parseCookieString(cookieHeader: string | undefined, name: string): string | null {
|
||||||
|
if (!cookieHeader) return null;
|
||||||
|
const match = cookieHeader.match(new RegExp('(^| )' + name + '=([^;]+)'));
|
||||||
|
return match ? match[2] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAuthMiddleware(db: Pool) {
|
||||||
|
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const cookieHeader = req.headers.cookie;
|
||||||
|
const token = parseCookieString(cookieHeader, 'session_token');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
res.status(401).json({ success: false, error: 'Unauthorized. Session cookie missing.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query the database to find an active session matching the token
|
||||||
|
const query = `
|
||||||
|
SELECT user_id, expires_at
|
||||||
|
FROM sessions
|
||||||
|
WHERE id = $1;
|
||||||
|
`;
|
||||||
|
const result = await db.query(query, [token]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
res.status(401).json({ success: false, error: 'Unauthorized. Invalid session token.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = result.rows[0];
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Validate expiration constraint
|
||||||
|
if (new Date(session.expires_at) < now) {
|
||||||
|
res.status(401).json({ success: false, error: 'Unauthorized. Session expired.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject the authenticated identity directly into the Request object for use in down-stream router handlers
|
||||||
|
(req as any).userId = session.user_id;
|
||||||
|
|
||||||
|
next(); // Execution matches, pass cleanly to route target
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auth middleware failure:', error);
|
||||||
|
res.status(500).json({ success: false, error: 'Internal Server Error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createAuthRouter } from './auth.router.ts';
|
||||||
|
import { AuthService } from './auth.service.ts';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
|
||||||
|
function createMockResponse() {
|
||||||
|
const res: Partial<Response> = {};
|
||||||
|
const data = { statusCode: 200, jsonPayload: null as any, cookies: {} as any };
|
||||||
|
|
||||||
|
res.status = function (code: number) { data.statusCode = code; return this as Response; };
|
||||||
|
res.json = function (payload: any) { data.jsonPayload = payload; return this as Response; };
|
||||||
|
res.cookie = function (name: string, val: string, options: any) {
|
||||||
|
data.cookies[name] = { val, options };
|
||||||
|
return this as Response;
|
||||||
|
};
|
||||||
|
return { mockRes: res as Response, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockDb(userRows: any[], sessionRows: any[] = []) {
|
||||||
|
return {
|
||||||
|
query: async (text: string, values: any[]) => {
|
||||||
|
if (text.trim().startsWith('SELECT') && text.includes('FROM users')) {
|
||||||
|
return { rows: userRows };
|
||||||
|
}
|
||||||
|
return { rows: sessionRows };
|
||||||
|
}
|
||||||
|
} as unknown as Pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Auth Router (TDD)', () => {
|
||||||
|
const authService = new AuthService();
|
||||||
|
|
||||||
|
describe('POST /register', () => {
|
||||||
|
it('should block registrations missing username or password with a 400 status', async () => {
|
||||||
|
const mockDb = createMockDb([]);
|
||||||
|
const router = createAuthRouter(mockDb, authService);
|
||||||
|
|
||||||
|
const mockReq = { body: { username: '' } } as unknown as Request;
|
||||||
|
const { mockRes, data } = createMockResponse();
|
||||||
|
|
||||||
|
const handler = router.stack.find((l: any) => l.route.path === '/register').route.stack[0].handle;
|
||||||
|
await handler(mockReq, mockRes);
|
||||||
|
|
||||||
|
assert.equal(data.statusCode, 400);
|
||||||
|
assert.equal(data.jsonPayload.success, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /login', () => {
|
||||||
|
it('should successfully issue a high-entropy cookie on valid credentials', async () => {
|
||||||
|
const password = 'secure-password';
|
||||||
|
const hash = await authService.hashPassword(password);
|
||||||
|
|
||||||
|
// Simulate database finding the registered user
|
||||||
|
const mockDb = createMockDb([{ id: 42, username: 'testuser', password_hash: hash }]);
|
||||||
|
const router = createAuthRouter(mockDb, authService);
|
||||||
|
|
||||||
|
const mockReq = { body: { username: 'testuser', password } } as unknown as Request;
|
||||||
|
const { mockRes, data } = createMockResponse();
|
||||||
|
|
||||||
|
const handler = router.stack.find((l: any) => l.route.path === '/login').route.stack[0].handle;
|
||||||
|
await handler(mockReq, mockRes);
|
||||||
|
|
||||||
|
assert.equal(data.statusCode, 200);
|
||||||
|
assert.equal(data.jsonPayload.success, true);
|
||||||
|
assert.ok(data.cookies['session_token']);
|
||||||
|
assert.equal(data.cookies['session_token'].options.httpOnly, true);
|
||||||
|
assert.equal(data.cookies['session_token'].options.sameSite, 'strict');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid passwords with a 401 status code', async () => {
|
||||||
|
const hash = await authService.hashPassword('real-password');
|
||||||
|
const mockDb = createMockDb([{ id: 42, username: 'testuser', password_hash: hash }]);
|
||||||
|
const router = createAuthRouter(mockDb, authService);
|
||||||
|
|
||||||
|
const mockReq = { body: { username: 'testuser', password: 'wrong-password' } } as unknown as Request;
|
||||||
|
const { mockRes, data } = createMockResponse();
|
||||||
|
|
||||||
|
const handler = router.stack.find((l: any) => l.route.path === '/login').route.stack[0].handle;
|
||||||
|
await handler(mockReq, mockRes);
|
||||||
|
|
||||||
|
assert.equal(data.statusCode, 401);
|
||||||
|
assert.equal(data.jsonPayload.success, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { AuthService } from './auth.service.ts';
|
||||||
|
|
||||||
|
export function createAuthRouter(db: Pool, authService: AuthService): Router {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/auth/register
|
||||||
|
*/
|
||||||
|
router.post('/register', async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { username, password } = req.body;
|
||||||
|
|
||||||
|
if (!username || !password || username.trim() === '' || password.length < 8) {
|
||||||
|
res.status(400).json({ success: false, error: 'Username required, and password must be at least 8 characters long.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user already exists
|
||||||
|
const checkUser = await db.query('SELECT id FROM users WHERE username = $1;', [username]);
|
||||||
|
if (checkUser.rows.length > 0) {
|
||||||
|
res.status(409).json({ success: false, error: 'Username is already taken.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password using Argon2id
|
||||||
|
const hash = await authService.hashPassword(password);
|
||||||
|
|
||||||
|
// Save user
|
||||||
|
await db.query('INSERT INTO users (username, password_hash) VALUES ($1, $2);', [username, hash]);
|
||||||
|
|
||||||
|
res.status(201).json({ success: true, message: 'User registered successfully!' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Registration failure:', error);
|
||||||
|
res.status(500).json({ success: false, error: 'Internal Server Error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/auth/login
|
||||||
|
*/
|
||||||
|
router.post('/login', async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { username, password } = req.body;
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
res.status(400).json({ success: false, error: 'Username and password are required.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query('SELECT id, password_hash FROM users WHERE username = $1;', [username]);
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
res.status(401).json({ success: false, error: 'Invalid username or password.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = result.rows[0];
|
||||||
|
const validPassword = await authService.verifyPassword(password, user.password_hash);
|
||||||
|
|
||||||
|
if (!validPassword) {
|
||||||
|
res.status(401).json({ success: false, error: 'Invalid username or password.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a high-entropy session token
|
||||||
|
const sessionToken = authService.generateSessionToken();
|
||||||
|
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // Expires in 30 days
|
||||||
|
|
||||||
|
// Store the session securely in the database
|
||||||
|
await db.query(
|
||||||
|
'INSERT INTO sessions (id, user_id, expires_at) VALUES ($1, $2, $3);',
|
||||||
|
[sessionToken, user.id, expiresAt]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Issue the secure HttpOnly cookie
|
||||||
|
res.cookie('session_token', sessionToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production', // Requires HTTPS in production
|
||||||
|
sameSite: 'strict',
|
||||||
|
expires: expiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, message: 'Logged in successfully!' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login failure:', error);
|
||||||
|
res.status(500).json({ success: false, error: 'Internal Server Error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { AuthService } from './auth.service.ts';
|
||||||
|
|
||||||
|
describe('AuthService (TDD)', () => {
|
||||||
|
const authService = new AuthService();
|
||||||
|
|
||||||
|
describe('Password Hashing & Verification', () => {
|
||||||
|
it('should hash a raw password string and verify it successfully', async () => {
|
||||||
|
const password = 'my-super-secure-password';
|
||||||
|
const hash = await authService.hashPassword(password);
|
||||||
|
|
||||||
|
// Verify the hash is distinct and obfuscated
|
||||||
|
assert.notEqual(hash, password);
|
||||||
|
assert.ok(hash.startsWith('$argon2id$')); // Confirms it uses the Argon2id standard
|
||||||
|
|
||||||
|
// Verify correct verification resolves true
|
||||||
|
const isValid = await authService.verifyPassword(password, hash);
|
||||||
|
assert.equal(isValid, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject validation if the password string does not match the hash', async () => {
|
||||||
|
const hash = await authService.hashPassword('correct-password');
|
||||||
|
const isValid = await authService.verifyPassword('wrong-password', hash);
|
||||||
|
assert.equal(isValid, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Session Token Generation', () => {
|
||||||
|
it('should generate high-entropy random session tokens', () => {
|
||||||
|
const token1 = authService.generateSessionToken();
|
||||||
|
const token2 = authService.generateSessionToken();
|
||||||
|
|
||||||
|
assert.equal(typeof token1, 'string');
|
||||||
|
assert.equal(token1.length, 64); // Uses a 32-byte hex representation
|
||||||
|
assert.notEqual(token1, token2); // Tokens must never collide
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import argon2 from 'argon2';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
export class AuthService {
|
||||||
|
/**
|
||||||
|
* Hashes a raw password using the Argon2id industry standard.
|
||||||
|
*/
|
||||||
|
async hashPassword(password: string): Promise<string> {
|
||||||
|
return argon2.hash(password, {
|
||||||
|
type: argon2.argon2id, // Strongest configuration variant against timing attacks
|
||||||
|
memoryCost: 2 ** 16, // 64MB memory utilization block
|
||||||
|
timeCost: 3, // 3 computational passes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cryptographically verifies a password against a known hash.
|
||||||
|
*/
|
||||||
|
async verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return await argon2.verify(hash, password);
|
||||||
|
} catch {
|
||||||
|
return false; // Safely catches malformed hashes without crashing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a unique, high-entropy 64-character hex session token.
|
||||||
|
*/
|
||||||
|
generateSessionToken(): string {
|
||||||
|
return crypto.randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user