Skip to content
Merged
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
7 changes: 5 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ NODE_ENV=development
FRONTEND_URL=http://localhost:3000

# JWT Configuration
JWT_SECRET=your-super-secret-jwt-key-change-in-production
JWT_REFRESH_SECRET=your-super-secret-refresh-key-change-in-production
# Required. Must be at least 32 characters (256 bits). The app will refuse to
# start if either secret is missing or too short. Generate strong values with:
# openssl rand -hex 32
JWT_SECRET=your-super-secret-jwt-key-change-in-production-min-32-chars
JWT_REFRESH_SECRET=your-super-secret-refresh-key-change-in-production-min-32c
JWT_ACCESS_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d

Expand Down
8 changes: 4 additions & 4 deletions src/auth/auth.service.captcha.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ describe('AuthService – CAPTCHA failure lockout', () => {
const configService = {
get: jest.fn((key: string) => {
const config: Record<string, string> = {
JWT_SECRET: 'test-secret',
JWT_REFRESH_SECRET: 'test-refresh-secret',
JWT_SECRET: 'test-secret-at-least-32-characters-long',
JWT_REFRESH_SECRET: 'test-refresh-secret-at-least-32-characters-long',
JWT_ACCESS_EXPIRES_IN: '15m',
JWT_REFRESH_EXPIRES_IN: '7d',
BCRYPT_ROUNDS: '10',
Expand Down Expand Up @@ -85,8 +85,8 @@ describe('AuthService – CAPTCHA failure lockout', () => {
const captchaConfig: Record<string, string> = {
RECAPTCHA_SECRET: 'some-secret',
CAPTCHA_THRESHOLD: '3',
JWT_SECRET: 'test-secret',
JWT_REFRESH_SECRET: 'test-refresh-secret',
JWT_SECRET: 'test-secret-at-least-32-characters-long',
JWT_REFRESH_SECRET: 'test-refresh-secret-at-least-32-characters-long',
JWT_ACCESS_EXPIRES_IN: '15m',
JWT_REFRESH_EXPIRES_IN: '7d',
BCRYPT_ROUNDS: '10',
Expand Down
20 changes: 17 additions & 3 deletions src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ import { LoginRateLimitService } from './login-rate-limit.service';
import { UserRole } from '../types/prisma.types';
import { FraudService } from '../fraud/fraud.service';

const MIN_JWT_SECRET_LENGTH = 32;

type JwtPayload = {
sub: string;
email: string;
Expand Down Expand Up @@ -89,9 +91,21 @@ export class AuthService {
private readonly rateLimitService: LoginRateLimitService,
private readonly fraudService: FraudService,
) {
this.jwtSecret = this.configService.get<string>('JWT_SECRET') ?? 'propchain-access-secret';
this.jwtRefreshSecret =
this.configService.get<string>('JWT_REFRESH_SECRET') ?? 'propchain-refresh-secret';
const jwtSecret = this.configService.get<string>('JWT_SECRET');
if (!jwtSecret || jwtSecret.length < MIN_JWT_SECRET_LENGTH) {
throw new Error(
`JWT_SECRET must be set and at least ${MIN_JWT_SECRET_LENGTH} characters (256 bits) long`,
);
}
this.jwtSecret = jwtSecret;

const jwtRefreshSecret = this.configService.get<string>('JWT_REFRESH_SECRET');
if (!jwtRefreshSecret || jwtRefreshSecret.length < MIN_JWT_SECRET_LENGTH) {
throw new Error(
`JWT_REFRESH_SECRET must be set and at least ${MIN_JWT_SECRET_LENGTH} characters (256 bits) long`,
);
}
this.jwtRefreshSecret = jwtRefreshSecret;
this.accessTokenTtlSeconds = parseDuration(
this.configService.get<string>('JWT_ACCESS_EXPIRES_IN') ?? '15m',
15 * 60,
Expand Down
28 changes: 25 additions & 3 deletions src/utils/validate-env.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,40 @@
const REQUIRED_ENV_VARS = ['DATABASE_URL', 'JWT_SECRET', 'JWT_REFRESH_SECRET'] as const;
const JWT_SECRET_VARS = ['JWT_SECRET', 'JWT_REFRESH_SECRET'] as const;
const MIN_JWT_SECRET_LENGTH = 32;

export function validateEnvironment(): void {
const MISSING: string[] = [];
const WEAK: string[] = [];

for (const key of REQUIRED_ENV_VARS) {
if (!process.env[key]) {
MISSING.push(key);
}
}

if (MISSING.length > 0) {
for (const key of JWT_SECRET_VARS) {
const value = process.env[key];
if (value && value.length < MIN_JWT_SECRET_LENGTH) {
WEAK.push(`${key} (found ${value.length} chars, need at least ${MIN_JWT_SECRET_LENGTH})`);
}
}

if (MISSING.length > 0 || WEAK.length > 0) {
const sections: string[] = [];
if (MISSING.length > 0) {
sections.push(
`Missing required environment variables:\n` + MISSING.map((k) => ` - ${k}`).join('\n'),
);
}
if (WEAK.length > 0) {
sections.push(
`Environment variables below the minimum required length (256 bits / ${MIN_JWT_SECRET_LENGTH} chars):\n` +
WEAK.map((k) => ` - ${k}`).join('\n'),
);
}
console.error(
`\n Fatal: Missing required environment variables:\n` +
MISSING.map((k) => ` - ${k}`).join('\n') +
`\n Fatal:\n ` +
sections.join('\n\n ') +
`\n\n Please set them in .env or .env.local before starting the application.\n`,
);
process.exit(1);
Expand Down
83 changes: 54 additions & 29 deletions test/e2e/fraud-auto-block.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import { ConfigService } from '@nestjs/config';
import { createSha256, hashPassword } from '../../src/auth/security.utils';
import * as jwt from 'jsonwebtoken';

const ACCESS_SECRET = 'test-access-secret';
const REFRESH_SECRET = 'test-refresh-secret';
const ACCESS_SECRET = 'test-access-secret-at-least-32-characters-long';
const REFRESH_SECRET = 'test-refresh-secret-at-least-32-characters-long';

describe('Fraud alert auto-block e2e', () => {
let app: INestApplication;
Expand Down Expand Up @@ -65,12 +65,14 @@ describe('Fraud alert auto-block e2e', () => {
},
findFirst: async ({ where }: any) => {
if (!where) return null;
return Array.from(users.values()).find((u) => {
for (const k of Object.keys(where)) {
if (u[k] !== where[k]) return false;
}
return true;
}) ?? null;
return (
Array.from(users.values()).find((u) => {
for (const k of Object.keys(where)) {
if (u[k] !== where[k]) return false;
}
return true;
}) ?? null
);
},
update: async ({ where, data }: any) => {
const user = users.get(where.id);
Expand Down Expand Up @@ -111,7 +113,10 @@ describe('Fraud alert auto-block e2e', () => {
},
update: async ({ where, data }: any) => {
const existing = blacklistedTokens.get(where.jti);
if (existing) { Object.assign(existing, data); return existing; }
if (existing) {
Object.assign(existing, data);
return existing;
}
return data;
},
count: async () => blacklistedTokens.size,
Expand All @@ -131,13 +136,25 @@ describe('Fraud alert auto-block e2e', () => {
findUnique: async ({ where }: any) => fraudAlerts.get(where.id) ?? null,
create: async ({ data }: any) => {
const id = nid();
const record = { id, ...data, occurrenceCount: 1, lastDetectedAt: new Date(), status: 'OPEN', autoBlocked: data.autoBlocked ?? false, createdAt: new Date(), updatedAt: new Date() };
const record = {
id,
...data,
occurrenceCount: 1,
lastDetectedAt: new Date(),
status: 'OPEN',
autoBlocked: data.autoBlocked ?? false,
createdAt: new Date(),
updatedAt: new Date(),
};
fraudAlerts.set(id, record);
return record;
},
update: async ({ where, data }: any) => {
const existing = fraudAlerts.get(where.id);
if (existing) { Object.assign(existing, data); return existing; }
if (existing) {
Object.assign(existing, data);
return existing;
}
return data;
},
findMany: async () => Array.from(fraudAlerts.values()),
Expand Down Expand Up @@ -211,7 +228,8 @@ describe('Fraud alert auto-block e2e', () => {
if (where?.OR) {
match = false;
for (const cond of where.OR) {
if (cond.refreshTokenJti && s.refreshTokenJti === cond.refreshTokenJti) match = true;
if (cond.refreshTokenJti && s.refreshTokenJti === cond.refreshTokenJti)
match = true;
if (cond.accessTokenJti && s.accessTokenJti === cond.accessTokenJti) match = true;
}
}
Expand All @@ -220,12 +238,17 @@ describe('Fraud alert auto-block e2e', () => {
return null;
},
findMany: async ({ where }: any) => {
return Array.from(sessions.values()).filter((s) => !where?.userId || s.userId === where.userId);
return Array.from(sessions.values()).filter(
(s) => !where?.userId || s.userId === where.userId,
);
},
updateMany: async ({ where, data }: any) => {
let count = 0;
for (const s of sessions.values()) {
if (where?.userId && s.userId === where.userId) { Object.assign(s, data); count++; }
if (where?.userId && s.userId === where.userId) {
Object.assign(s, data);
count++;
}
}
return { count };
},
Expand Down Expand Up @@ -304,21 +327,23 @@ describe('Fraud alert auto-block e2e', () => {
{
provide: FraudService,
useValue: {
handleTokenReuse: jest.fn().mockImplementation(async (userId: string, jti: string, ip: string) => {
await prisma.user.update({ where: { id: userId }, data: { isBlocked: true } });
await prisma.fraudAlert.create({
data: {
userId,
pattern: 'TOKEN_REUSE',
severity: 'CRITICAL',
status: 'OPEN',
description: `Token reuse detected for user ${userId}`,
ipAddress: ip,
evidence: { jti },
autoBlocked: true,
},
});
}),
handleTokenReuse: jest
.fn()
.mockImplementation(async (userId: string, jti: string, ip: string) => {
await prisma.user.update({ where: { id: userId }, data: { isBlocked: true } });
await prisma.fraudAlert.create({
data: {
userId,
pattern: 'TOKEN_REUSE',
severity: 'CRITICAL',
status: 'OPEN',
description: `Token reuse detected for user ${userId}`,
ipAddress: ip,
evidence: { jti },
autoBlocked: true,
},
});
}),
evaluateFailedLogin: jest.fn().mockResolvedValue(null),
evaluateSuccessfulLogin: jest.fn().mockResolvedValue([]),
},
Expand Down
25 changes: 16 additions & 9 deletions test/e2e/rate-limit-burst.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,14 @@ describe('Rate-limit guard e2e – burst traffic', () => {
},
findFirst: async ({ where }: any) => {
if (!where) return null;
return Array.from(users.values()).find((u) => {
for (const k of Object.keys(where)) {
if (u[k] !== where[k]) return false;
}
return true;
}) ?? null;
return (
Array.from(users.values()).find((u) => {
for (const k of Object.keys(where)) {
if (u[k] !== where[k]) return false;
}
return true;
}) ?? null
);
},
update: async ({ where, data }: any) => {
const user = users.get(where.id);
Expand Down Expand Up @@ -122,7 +124,12 @@ describe('Rate-limit guard e2e – burst traffic', () => {
fraudAlert: {
findFirst: async () => null,
findUnique: async () => null,
create: async ({ data }: any) => ({ id: nid(), ...data, occurrenceCount: 1, status: 'OPEN' }),
create: async ({ data }: any) => ({
id: nid(),
...data,
occurrenceCount: 1,
status: 'OPEN',
}),
update: async ({ data }: any) => data,
findMany: async () => [],
count: async () => 0,
Expand Down Expand Up @@ -208,8 +215,8 @@ describe('Rate-limit guard e2e – burst traffic', () => {
useValue: {
get: (key: string) => {
const cfg: Record<string, string> = {
JWT_SECRET: 'test-access-secret',
JWT_REFRESH_SECRET: 'test-refresh-secret',
JWT_SECRET: 'test-access-secret-at-least-32-characters-long',
JWT_REFRESH_SECRET: 'test-refresh-secret-at-least-32-characters-long',
JWT_ACCESS_EXPIRES_IN: '15m',
JWT_REFRESH_EXPIRES_IN: '7d',
BCRYPT_ROUNDS: '4',
Expand Down
36 changes: 21 additions & 15 deletions test/unit/auth-refresh-token-reuse.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import { LoginRateLimitService } from '../../src/auth/login-rate-limit.service';
import { FraudService } from '../../src/fraud/fraud.service';
import { createSha256 } from '../../src/auth/security.utils';

const ACCESS_SECRET = 'test-access-secret';
const REFRESH_SECRET = 'test-refresh-secret';
const ACCESS_SECRET = 'test-access-secret-at-least-32-characters-long';
const REFRESH_SECRET = 'test-refresh-secret-at-least-32-characters-long';

function signRefresh(payload: Record<string, any>) {
const { exp, ...rest } = payload;
Expand Down Expand Up @@ -184,9 +184,9 @@ describe('AuthService.refreshToken – token-reuse attack', () => {
isDeactivated: false,
});

await expect(
service.refreshToken({ refreshToken: token }, '1.2.3.4', 'UA'),
).rejects.toThrow('blocked');
await expect(service.refreshToken({ refreshToken: token }, '1.2.3.4', 'UA')).rejects.toThrow(
'blocked',
);
});

it('rejects refresh if user is deactivated', async () => {
Expand All @@ -200,9 +200,9 @@ describe('AuthService.refreshToken – token-reuse attack', () => {
isDeactivated: true,
});

await expect(
service.refreshToken({ refreshToken: token }, '1.2.3.4', 'UA'),
).rejects.toThrow('deactivated');
await expect(service.refreshToken({ refreshToken: token }, '1.2.3.4', 'UA')).rejects.toThrow(
'deactivated',
);
});

it('rejects refresh if user no longer exists', async () => {
Expand All @@ -212,21 +212,27 @@ describe('AuthService.refreshToken – token-reuse attack', () => {
mockPrisma.blacklistedToken.findUnique.mockResolvedValue(null);
mockPrisma.user.findUnique.mockResolvedValue(null);

await expect(
service.refreshToken({ refreshToken: token }, '1.2.3.4', 'UA'),
).rejects.toThrow('no longer exists');
await expect(service.refreshToken({ refreshToken: token }, '1.2.3.4', 'UA')).rejects.toThrow(
'no longer exists',
);
});

it('rejects a token that is not a refresh token', async () => {
const accessPayload = { sub: 'user-1', email: 'user@example.com', role: 'USER', type: 'access', jti: 'access-jti' };
const accessPayload = {
sub: 'user-1',
email: 'user@example.com',
role: 'USER',
type: 'access',
jti: 'access-jti',
};
const token = jwt.sign(accessPayload, REFRESH_SECRET, {
expiresIn: '15m',
issuer: 'PropChain',
});

await expect(
service.refreshToken({ refreshToken: token }, '1.2.3.4', 'UA'),
).rejects.toThrow('Invalid refresh token');
await expect(service.refreshToken({ refreshToken: token }, '1.2.3.4', 'UA')).rejects.toThrow(
'Invalid refresh token',
);
});

it('rejects an invalid or expired token', async () => {
Expand Down
10 changes: 7 additions & 3 deletions test/unit/password-reset-token-validation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,11 @@ describe('AuthService.resetPassword – password-reset token validation', () =>
return passwordHistory.filter((h) => h.userId === where.userId);
}),
create: jest.fn(async ({ data }: any) => {
const record = { id: Math.random().toString(36).slice(2, 8), ...data, createdAt: new Date() };
const record = {
id: Math.random().toString(36).slice(2, 8),
...data,
createdAt: new Date(),
};
passwordHistory.push(record);
return record;
}),
Expand Down Expand Up @@ -89,8 +93,8 @@ describe('AuthService.resetPassword – password-reset token validation', () =>
const mockConfigService = {
get: jest.fn((key: string) => {
const config: Record<string, string> = {
JWT_SECRET: 'test-access-secret',
JWT_REFRESH_SECRET: 'test-refresh-secret',
JWT_SECRET: 'test-access-secret-at-least-32-characters-long',
JWT_REFRESH_SECRET: 'test-refresh-secret-at-least-32-characters-long',
JWT_ACCESS_EXPIRES_IN: '15m',
JWT_REFRESH_EXPIRES_IN: '7d',
BCRYPT_ROUNDS: '4',
Expand Down
Loading