Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.22.1",
"express-rate-limit": "^6.11.2",
"jsonwebtoken": "^9.0.3",
"morgan": "^1.10.0",
"pg": "^8.19.0",
Expand Down
9 changes: 9 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,22 @@ import { SessionRepository } from './db/repositories/sessionRepository';
import { createLogoutRouter } from './auth/logout/logoutRoute';
import { createChangePasswordRouter } from './auth/changePassword/changePasswordRoute';
import { createLoginRouter } from './auth/login/loginRoute';
import { createPasswordResetRouter } from './routes/passwordReset';
import { emailService } from './services/emailService';
import { createHealthRouter } from './routes/health';
import { dbHealth } from './db/client';
import { createOfferingSyncRouter } from './routes/offeringSync';
import { UserRepository } from './db/repositories/userRepository';
import { JwtIssuer, UserRole, UserRepository as IUserRepository, SessionRepository as ISessionRepository } from './auth/login/types';
import { LoginService } from './auth/login/loginService';
import { issueToken } from './lib/jwt';
import { Logger } from './lib/logger';
import { MetricsCollector } from './lib/metrics';
import { RefreshTokenRepositoryAdapter } from './auth/refresh/repositoryAdapter';
import { JwtTokenServiceAdapter } from './auth/refresh/tokenServiceAdapter';
import { RefreshService } from './auth/refresh/refreshService';
import { createRefreshRouter } from './auth/refresh/refreshRoute';
import { errorHandler } from './middleware/errorHandler';

// Adapter to convert database User to login service UserRecord
class UserRepositoryAdapter implements IUserRepository {
Expand Down Expand Up @@ -117,6 +125,7 @@ export function createApp() {
app.use(createRefreshRouter({ refreshService }));
app.use(createLogoutRouter({ requireAuth, sessionRepository }));
app.use(createChangePasswordRouter({ requireAuth, db: pool }));
app.use(createPasswordResetRouter(pool, { emailSender: emailService.sendMail.bind(emailService), appUrl: process.env.APP_URL }));
app.use('/api/v1/health', createHealthRouter(pool, dbHealth, metrics));

// Offering sync routes
Expand Down
2 changes: 1 addition & 1 deletion src/db/migrations/010_create_password_reset_tokens.sql
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ CREATE TABLE IF NOT EXISTS password_reset_tokens (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
used BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Expand Down
14 changes: 14 additions & 0 deletions src/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,17 @@ export class BadRequestError extends AppError {
this.name = 'BadRequestError';
}
}

export class PasswordResetTokenInvalidError extends AppError {
constructor(message: string = 'Invalid or expired password reset token') {
super(ErrorCode.BAD_REQUEST, 400, message);
this.name = 'PasswordResetTokenInvalidError';
}
}

export class PasswordResetRateLimitError extends AppError {
constructor(message: string = 'Too many password reset requests') {
super(ErrorCode.TOO_MANY_REQUESTS, 429, message);
this.name = 'PasswordResetRateLimitError';
}
}
56 changes: 56 additions & 0 deletions src/middleware/passwordResetRateLimiter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Request, Response, NextFunction } from 'express';
import { createPasswordResetRateLimiter } from './passwordResetRateLimiter';
import { PasswordResetRateLimitError } from '../lib/errors';
import { logger } from '../lib/logger';

describe('Password reset rate limiter middleware', () => {
const makeReq = (): Request => ({
ip: '127.0.0.1',
socket: { remoteAddress: '127.0.0.1' },
headers: {},
get: jest.fn().mockReturnValue(undefined),
app: { get: jest.fn().mockReturnValue(false) },
} as unknown as Request);

const makeRes = (): Response => ({
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
setHeader: jest.fn(),
getHeader: jest.fn(),
} as unknown as Response);

beforeEach(() => {
jest.spyOn(logger, 'warn').mockImplementation(() => {});
});

afterEach(() => {
jest.restoreAllMocks();
});

it('calls next for requests under the limit', async () => {
const res = makeRes();
const next: NextFunction = jest.fn();
const limiter = createPasswordResetRateLimiter();

await limiter(makeReq(), res, next);

expect(next).toHaveBeenCalledTimes(1);
});

it('calls next with PasswordResetRateLimitError when the limit is exceeded', async () => {
const res = makeRes();
const next: NextFunction = jest.fn();
const limiter = createPasswordResetRateLimiter();

for (let i = 0; i < 5; i++) {
await limiter(makeReq(), res, next);
}

await limiter(makeReq(), res, next);

expect(next).toHaveBeenLastCalledWith(expect.any(PasswordResetRateLimitError));
expect(logger.warn).toHaveBeenCalledWith('Password reset rate limit exceeded', {
ip: '127.0.0.1',
});
});
});
23 changes: 23 additions & 0 deletions src/middleware/passwordResetRateLimiter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import rateLimit from 'express-rate-limit';
import { Request, Response, NextFunction } from 'express';
import { PasswordResetRateLimitError } from '../lib/errors';
import { logger } from '../lib/logger';

const WINDOW_MS = 15 * 60 * 1000;
const MAX_REQUESTS = 5;

export function createPasswordResetRateLimiter() {
return rateLimit({
windowMs: WINDOW_MS,
max: MAX_REQUESTS,
standardHeaders: true,
legacyHeaders: false,
handler: (req: Request, _res: Response, next: NextFunction) => {
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
logger.warn('Password reset rate limit exceeded', { ip });
next(new PasswordResetRateLimitError());
},
});
}

export const passwordResetRateLimiter = createPasswordResetRateLimiter();
93 changes: 93 additions & 0 deletions src/routes/passwordReset.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import express, { ErrorRequestHandler, NextFunction, Request, Response } from 'express';
import request from 'supertest';
import { createPasswordResetRouter } from './passwordReset';
import { errorHandler } from '../middleware/errorHandler';

describe('Password reset routes', () => {
let app: express.Express;
let mockPool: any;
let emailSender: jest.Mock<Promise<void>, [string, string, string]>;
let mockClient: any;

beforeEach(() => {
mockClient = {
query: jest.fn(async () => ({ rows: [] })),
release: jest.fn(),
};

mockPool = {
query: jest.fn(),
connect: jest.fn(async () => mockClient),
};

emailSender = jest.fn<Promise<void>, [string, string, string]>(async () => {});

app = express();
app.use(express.json());
app.use(createPasswordResetRouter(mockPool, { emailSender, appUrl: 'http://localhost' }));
app.use(((err, req, res, next) => errorHandler(err, req, res, next)) as ErrorRequestHandler);
});

it('returns 200 for an unknown email without exposing user existence', async () => {
mockPool.query.mockResolvedValueOnce({ rows: [] });

const response = await request(app)
.post('/api/auth/forgot-password')
.send({ email: 'unknown@example.com' });

expect(response.status).toBe(200);
expect(response.body).toEqual({
message: 'If the email exists, a password reset link has been sent',
});
expect(emailSender).not.toHaveBeenCalled();
});

it('returns 200 for invalid email format on request password reset', async () => {
const response = await request(app)
.post('/api/auth/forgot-password')
.send({ email: 'invalid-email' });

expect(response.status).toBe(200);
expect(response.body).toEqual({
message: 'If the email exists, a password reset link has been sent',
});
});

it('rate limits repeated password reset requests from the same IP', async () => {
mockPool.query.mockResolvedValue({ rows: [] });

for (let i = 0; i < 5; i++) {
await request(app)
.post('/api/auth/forgot-password')
.send({ email: `test${i}@example.com` });
}

const response = await request(app)
.post('/api/auth/forgot-password')
.send({ email: 'test6@example.com' });

expect(response.status).toBe(429);
expect(response.body).toEqual(expect.objectContaining({
code: 'TOO_MANY_REQUESTS',
}));
});

it('returns 400 for reset-password with unknown token', async () => {
mockPool.connect.mockResolvedValue(mockClient);
mockClient.query.mockImplementation(async (sql: string) => {
if (sql === 'BEGIN' || sql === 'ROLLBACK') {
return { rows: [] };
}
return { rows: [] };
});

const response = await request(app)
.post('/api/auth/reset-password')
.send({ token: 'f'.repeat(64), password: 'StrongPassword123!' });

expect(response.status).toBe(400);
expect(response.body).toEqual(expect.objectContaining({
code: 'BAD_REQUEST',
}));
});
});
110 changes: 55 additions & 55 deletions src/routes/passwordReset.ts
Original file line number Diff line number Diff line change
@@ -1,68 +1,68 @@
import { Router, Request, Response } from 'express';
import { Router, Request, Response, NextFunction } from 'express';
import { Pool } from 'pg';
import { PasswordResetService, PasswordResetRateLimitedError } from '../services/passwordResetService';
import { PasswordResetRateLimiter } from '../services/passwordResetRateLimiter';
import { z } from 'zod';
import { PasswordResetService, EmailSender } from '../services/passwordResetService';
import { createPasswordResetRateLimiter } from '../middleware/passwordResetRateLimiter';
import { Errors } from '../lib/errors';

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const MIN_PASSWORD_LENGTH = 8;
const forgotPasswordSchema = z.object({
email: z.string().email(),
});

export function createPasswordResetRouter(db: Pool): Router {
const resetPasswordSchema = z.object({
token: z.string().length(64).regex(/^[0-9a-fA-F]{64}$/),
password: z.string().min(12),
});

const NEUTRAL_FORGOT_RESPONSE = {
message: 'If the email exists, a password reset link has been sent',
};

export interface PasswordResetRouterOptions {
emailSender?: EmailSender;
appUrl?: string;
}

export function createPasswordResetRouter(db: Pool, opts?: PasswordResetRouterOptions): Router {
const router = Router();
const rateLimiter = new PasswordResetRateLimiter(db, {
maxRequests: 3,
windowMinutes: 60,
blockMinutes: 15,
});
const service = new PasswordResetService(db, {
emailSender: async (to, subject, body) => {
console.log(`[email] to=${to} subject="${subject}" body="${body}"`);
},
rateLimiter,
emailSender: opts?.emailSender,
appUrl: opts?.appUrl,
});

router.post('/api/auth/forgot-password', async (req: Request, res: Response) => {
const { email } = req.body ?? {};
if (typeof email !== 'string' || !EMAIL_RE.test(email)) {
res.status(200).json({
message: 'If the email exists, a password reset link has been sent',
});
return;
}
try {
await service.requestPasswordReset(email);
} catch (err) {
if (err instanceof PasswordResetRateLimitedError) {
res.status(429).json({
error: err.message,
retryAfter: err.retryAfter,
});
return;
router.post(
'/api/auth/forgot-password',
createPasswordResetRateLimiter(),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { email } = forgotPasswordSchema.parse(req.body);
await service.requestPasswordReset(email);
return res.status(200).json(NEUTRAL_FORGOT_RESPONSE);
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(200).json(NEUTRAL_FORGOT_RESPONSE);
}
return next(error);
}
console.error('[password-reset] Error processing request:', err);
}
res.status(200).json({
message: 'If the email exists, a password reset link has been sent',
});
});
},
);

router.post('/api/auth/reset-password', async (req: Request, res: Response) => {
const { token, password } = req.body ?? {};
if (typeof token !== 'string' || typeof password !== 'string' || password.length < MIN_PASSWORD_LENGTH) {
res.status(400).json({ error: 'Invalid token or password' });
return;
}
try {
const ok = await service.resetPassword(token, password);
if (!ok) {
res.status(400).json({ error: 'Invalid or expired token' });
return;
router.post(
'/api/auth/reset-password',
createPasswordResetRateLimiter(),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { token, password } = resetPasswordSchema.parse(req.body);
await service.resetPassword(token, password);
return res.status(200).json({ message: 'Password has been reset' });
} catch (error) {
if (error instanceof z.ZodError) {
return next(Errors.validationError('Invalid password reset payload', error.issues));
}
return next(error);
}
res.status(200).json({ message: 'Password updated' });
} catch (err) {
console.error('[password-reset] Reset password error:', err);
res.status(400).json({ error: 'Invalid or expired token' });
}
});
},
);

return router;
}
Loading