refactor: strengthen client boundaries and quality gates
This commit is contained in:
@@ -20,19 +20,29 @@ type CookieResponse = {
|
||||
@ApiTags('auth')
|
||||
@Controller()
|
||||
export class AuthController {
|
||||
constructor(private readonly auth: AuthService, private readonly users: UsersService, private readonly sessions: SessionService, private readonly prisma: PrismaService, private readonly security: SecurityDetectionService) {}
|
||||
constructor(
|
||||
private readonly auth: AuthService,
|
||||
private readonly users: UsersService,
|
||||
private readonly sessions: SessionService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly security: SecurityDetectionService,
|
||||
) {}
|
||||
|
||||
@Get('admin/auth/captcha')
|
||||
adminCaptcha() {
|
||||
return this.auth.createCaptcha();
|
||||
adminCaptcha(@Req() request: SessionRequest) {
|
||||
return this.auth.createCaptcha(this.sourceIp(request));
|
||||
}
|
||||
|
||||
@Post('admin/auth/login')
|
||||
@UsePipes(strictValidationPipe)
|
||||
async adminLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
|
||||
async adminLogin(
|
||||
@Body() body: LoginDto,
|
||||
@Req() request: SessionRequest,
|
||||
@Res({ passthrough: true }) response: CookieResponse,
|
||||
) {
|
||||
let result: Awaited<ReturnType<AuthService['login']>>;
|
||||
try {
|
||||
result = await this.auth.login(body, 'admin');
|
||||
result = await this.auth.login(body, 'admin', this.sourceIp(request));
|
||||
} catch (error) {
|
||||
await this.recordLoginFailure('admin_login_failure', body.login, request).catch(() => undefined);
|
||||
throw error;
|
||||
@@ -41,16 +51,20 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Get('client/auth/captcha')
|
||||
clientCaptcha() {
|
||||
return this.auth.createCaptcha();
|
||||
clientCaptcha(@Req() request: SessionRequest) {
|
||||
return this.auth.createCaptcha(this.sourceIp(request));
|
||||
}
|
||||
|
||||
@Post('client/auth/login')
|
||||
@UsePipes(strictValidationPipe)
|
||||
async clientLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
|
||||
async clientLogin(
|
||||
@Body() body: LoginDto,
|
||||
@Req() request: SessionRequest,
|
||||
@Res({ passthrough: true }) response: CookieResponse,
|
||||
) {
|
||||
let result: Awaited<ReturnType<AuthService['login']>>;
|
||||
try {
|
||||
result = await this.auth.login(body, 'client');
|
||||
result = await this.auth.login(body, 'client', this.sourceIp(request));
|
||||
} catch (error) {
|
||||
await this.recordLoginFailure('client_login_failure', body.login, request).catch(() => undefined);
|
||||
throw error;
|
||||
@@ -94,17 +108,23 @@ export class AuthController {
|
||||
async lock(@Req() request: SessionRequest) {
|
||||
this.assertSession(request);
|
||||
const record = await this.sessions.lock(request.sessionToken!);
|
||||
if (record) await this.writeLog(request, 'auth.session_locked', { portal: record.portal, reason: 'client_idle_timer' });
|
||||
if (record)
|
||||
await this.writeLog(request, 'auth.session_locked', { portal: record.portal, reason: 'client_idle_timer' });
|
||||
return { locked: Boolean(record) };
|
||||
}
|
||||
|
||||
@Post(['admin/auth/session/unlock', 'client/auth/session/unlock'])
|
||||
@UsePipes(strictValidationPipe)
|
||||
async unlock(@Req() request: SessionRequest, @Body() body: PasswordVerificationDto, @Res({ passthrough: true }) response: CookieResponse) {
|
||||
async unlock(
|
||||
@Req() request: SessionRequest,
|
||||
@Body() body: PasswordVerificationDto,
|
||||
@Res({ passthrough: true }) response: CookieResponse,
|
||||
) {
|
||||
const { password } = body;
|
||||
this.assertSession(request);
|
||||
const result = await this.auth.unlock(request.sessionToken!, request.sessionUserId!, password);
|
||||
if (result.status !== 'active' || !('token' in result)) throw new UnauthorizedException({ code: 'SESSION_LOCK_TIMEOUT', message: '锁定时间过长,请重新登录' });
|
||||
if (result.status !== 'active' || !('token' in result))
|
||||
throw new UnauthorizedException({ code: 'SESSION_LOCK_TIMEOUT', message: '锁定时间过长,请重新登录' });
|
||||
this.setCookie(response, result.record.portal, result.token);
|
||||
await this.writeLog(request, 'auth.session_unlocked', { portal: result.record.portal });
|
||||
return this.sessions.publicSession(result.record);
|
||||
@@ -124,7 +144,8 @@ export class AuthController {
|
||||
@Post(['admin/auth/logout', 'client/auth/logout'])
|
||||
async logout(@Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
|
||||
if (request.sessionToken) await this.sessions.remove(request.sessionToken);
|
||||
if (request.sessionUserId) await this.writeLog(request, 'auth.session_logged_out', { portal: request.authSession?.portal });
|
||||
if (request.sessionUserId)
|
||||
await this.writeLog(request, 'auth.session_logged_out', { portal: request.authSession?.portal });
|
||||
this.clearCookie(response, request.authSession?.portal);
|
||||
return { success: true };
|
||||
}
|
||||
@@ -136,17 +157,32 @@ export class AuthController {
|
||||
return this.users.changeOwnPassword(userId, body.currentPassword, body.password);
|
||||
}
|
||||
|
||||
private async finishLogin(result: Awaited<ReturnType<AuthService['login']>>, request: SessionRequest, response: CookieResponse) {
|
||||
private async finishLogin(
|
||||
result: Awaited<ReturnType<AuthService['login']>>,
|
||||
request: SessionRequest,
|
||||
response: CookieResponse,
|
||||
) {
|
||||
this.setCookie(response, result.portal, result.sessionToken);
|
||||
this.clearLegacyCookie(response);
|
||||
await this.prisma.operationLog.create({
|
||||
data: { userId: result.user.id, tenantId: result.user.tenantId, action: 'auth.session_created', resource: 'auth_session', userAgent: request.header('user-agent'), detail: { portal: result.portal } },
|
||||
data: {
|
||||
userId: result.user.id,
|
||||
tenantId: result.user.tenantId,
|
||||
action: 'auth.session_created',
|
||||
resource: 'auth_session',
|
||||
userAgent: request.header('user-agent'),
|
||||
detail: { portal: result.portal },
|
||||
},
|
||||
});
|
||||
const { sessionToken: _, ...publicResult } = result;
|
||||
return publicResult;
|
||||
}
|
||||
|
||||
private recordLoginFailure(ruleCode: 'admin_login_failure' | 'client_login_failure', account: string, request: SessionRequest) {
|
||||
private recordLoginFailure(
|
||||
ruleCode: 'admin_login_failure' | 'client_login_failure',
|
||||
account: string,
|
||||
request: SessionRequest,
|
||||
) {
|
||||
return this.security.recordEvent({
|
||||
ruleCode,
|
||||
sourceIp: requestContext.getStore()?.ipAddress ?? '127.0.0.1',
|
||||
@@ -157,9 +193,19 @@ export class AuthController {
|
||||
});
|
||||
}
|
||||
|
||||
private sourceIp(request: SessionRequest) {
|
||||
return requestContext.getStore()?.ipAddress ?? '127.0.0.1';
|
||||
}
|
||||
|
||||
private writeLog(request: SessionRequest, action: string, detail: Record<string, unknown>) {
|
||||
return this.prisma.operationLog.create({
|
||||
data: { userId: request.sessionUserId, action, resource: 'auth_session', userAgent: request.header('user-agent'), detail: JSON.parse(JSON.stringify(detail)) as Prisma.InputJsonValue },
|
||||
data: {
|
||||
userId: request.sessionUserId,
|
||||
action,
|
||||
resource: 'auth_session',
|
||||
userAgent: request.header('user-agent'),
|
||||
detail: JSON.parse(JSON.stringify(detail)) as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ function createUsersMock(roleCode: string, overrides: Record<string, unknown> =
|
||||
verifyLoginPassword: jest.fn(async (_id: string, password: string) => password === 'secret1'),
|
||||
recordLoginSuccess: jest.fn(),
|
||||
recordLoginFailure: jest.fn(),
|
||||
verifyCurrentPassword: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,54 +31,154 @@ function createSessionsMock() {
|
||||
const captchas = new Map<string, string>();
|
||||
const failures = new Map<string, number>();
|
||||
const record = {
|
||||
userId: 'user-1', portal: 'admin', sessionVersion: 0, createdAt: 1, lastActivityAt: 1,
|
||||
lastAuthenticatedAt: 1, absoluteExpiresAt: Date.now() + 1000,
|
||||
userId: 'user-1',
|
||||
portal: 'admin',
|
||||
sessionVersion: 0,
|
||||
createdAt: 1,
|
||||
lastActivityAt: 1,
|
||||
lastAuthenticatedAt: 1,
|
||||
absoluteExpiresAt: Date.now() + 1000,
|
||||
};
|
||||
return {
|
||||
storeCaptcha: jest.fn(async (id: string, answer: string) => { captchas.set(id, answer); }),
|
||||
consumeCaptcha: jest.fn(async (id: string) => { const answer = captchas.get(id) ?? null; captchas.delete(id); return answer; }),
|
||||
isAnonymousLoginLocked: jest.fn(async (login: string) => (failures.get(login) ?? 0) >= 5),
|
||||
recordAnonymousLoginFailure: jest.fn(async (login: string) => { const count = (failures.get(login) ?? 0) + 1; failures.set(login, count); return count; }),
|
||||
clearAnonymousLoginFailures: jest.fn(async (login: string) => { failures.delete(login); }),
|
||||
storeCaptcha: jest.fn(async (id: string, answer: string) => {
|
||||
captchas.set(id, answer);
|
||||
}),
|
||||
consumeCaptcha: jest.fn(async (id: string) => {
|
||||
const answer = captchas.get(id) ?? null;
|
||||
captchas.delete(id);
|
||||
return answer;
|
||||
}),
|
||||
assertCaptchaRequestAllowed: jest.fn().mockResolvedValue(true),
|
||||
anonymousLoginLockScope: jest.fn(async (login: string) => ((failures.get(login) ?? 0) >= 5 ? 'account' : null)),
|
||||
recordAnonymousLoginFailure: jest.fn(async (login: string) => {
|
||||
const count = (failures.get(login) ?? 0) + 1;
|
||||
failures.set(login, count);
|
||||
return [count, count, count];
|
||||
}),
|
||||
clearAnonymousLoginFailures: jest.fn(async (login: string) => {
|
||||
failures.delete(login);
|
||||
}),
|
||||
create: jest.fn().mockResolvedValue({ token: 'opaque-session-token', record }),
|
||||
publicSession: jest.fn().mockReturnValue({ idleTimeoutSeconds: 3600, absoluteExpiresAt: new Date(record.absoluteExpiresAt).toISOString() }),
|
||||
publicSession: jest
|
||||
.fn()
|
||||
.mockReturnValue({
|
||||
idleTimeoutSeconds: 3600,
|
||||
absoluteExpiresAt: new Date(record.absoluteExpiresAt).toISOString(),
|
||||
}),
|
||||
unlock: jest.fn().mockResolvedValue({ status: 'active' }),
|
||||
markReauthenticated: jest.fn().mockResolvedValue({ status: 'active' }),
|
||||
};
|
||||
}
|
||||
|
||||
function createMetricsMock() {
|
||||
return { recordAuthProtectionResult: jest.fn() };
|
||||
}
|
||||
|
||||
async function loginWithCaptcha(service: AuthService, portal: 'admin' | 'client', password = 'secret1') {
|
||||
const captcha = await service.createCaptcha();
|
||||
const answer = captcha.challenge.split('=')[0].split('+').map((part) => Number(part.trim())).reduce((sum, value) => sum + value, 0);
|
||||
return service.login({
|
||||
login: 'user@example.com',
|
||||
password,
|
||||
captchaId: captcha.captchaId,
|
||||
captchaText: String(answer),
|
||||
}, portal);
|
||||
const captcha = await service.createCaptcha('203.0.113.10');
|
||||
const answer = captcha.challenge
|
||||
.split('=')[0]
|
||||
.split('+')
|
||||
.map((part) => Number(part.trim()))
|
||||
.reduce((sum, value) => sum + value, 0);
|
||||
return service.login(
|
||||
{
|
||||
login: 'user@example.com',
|
||||
password,
|
||||
captchaId: captcha.captchaId,
|
||||
captchaText: String(answer),
|
||||
},
|
||||
portal,
|
||||
'203.0.113.10',
|
||||
);
|
||||
}
|
||||
|
||||
describe('AuthService', () => {
|
||||
it('allows platform admins to login admin portal', async () => {
|
||||
const users = createUsersMock('platform_admin');
|
||||
const sessions = createSessionsMock();
|
||||
const service = new AuthService(users as never, sessions as never);
|
||||
await expect(loginWithCaptcha(service, 'admin')).resolves.toEqual(expect.objectContaining({ portal: 'admin', sessionToken: 'opaque-session-token' }));
|
||||
const service = new AuthService(users as never, sessions as never, createMetricsMock() as never);
|
||||
await expect(loginWithCaptcha(service, 'admin')).resolves.toEqual(
|
||||
expect.objectContaining({ portal: 'admin', sessionToken: 'opaque-session-token' }),
|
||||
);
|
||||
expect(users.recordLoginSuccess).toHaveBeenCalledWith('user-1');
|
||||
expect(sessions.create).toHaveBeenCalledWith('user-1', 'admin', 0);
|
||||
});
|
||||
|
||||
it('rejects enterprise admins on admin portal', async () => {
|
||||
const users = createUsersMock('enterprise_admin');
|
||||
const service = new AuthService(users as never, createSessionsMock() as never);
|
||||
const service = new AuthService(users as never, createSessionsMock() as never, createMetricsMock() as never);
|
||||
await expect(loginWithCaptcha(service, 'admin')).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(users.recordLoginFailure).toHaveBeenCalledWith('user-1');
|
||||
});
|
||||
|
||||
it('locks user after five failed password attempts', async () => {
|
||||
const users = createUsersMock('platform_admin');
|
||||
const service = new AuthService(users as never, createSessionsMock() as never);
|
||||
const service = new AuthService(users as never, createSessionsMock() as never, createMetricsMock() as never);
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await expect(loginWithCaptcha(service, 'admin', 'bad-password')).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
}
|
||||
expect(users.recordLoginFailure).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('rejects captcha bursts before allocating a captcha', async () => {
|
||||
const sessions = createSessionsMock();
|
||||
sessions.assertCaptchaRequestAllowed.mockResolvedValue(false);
|
||||
const metrics = createMetricsMock();
|
||||
const service = new AuthService(createUsersMock('platform_admin') as never, sessions as never, metrics as never);
|
||||
await expect(service.createCaptcha('203.0.113.10')).rejects.toMatchObject({ status: 429 });
|
||||
expect(sessions.storeCaptcha).not.toHaveBeenCalled();
|
||||
expect(metrics.recordAuthProtectionResult).toHaveBeenCalledWith('captcha_rejected');
|
||||
});
|
||||
|
||||
it('allows a tenant-bound enterprise admin to login to the client portal', async () => {
|
||||
const service = new AuthService(
|
||||
createUsersMock('enterprise_admin') as never,
|
||||
createSessionsMock() as never,
|
||||
createMetricsMock() as never,
|
||||
);
|
||||
await expect(loginWithCaptcha(service, 'client')).resolves.toEqual(expect.objectContaining({ portal: 'client' }));
|
||||
});
|
||||
|
||||
it('records anonymous failures without disclosing whether an account exists', async () => {
|
||||
const users = createUsersMock('platform_admin');
|
||||
users.findByLogin.mockResolvedValue(null);
|
||||
const sessions = createSessionsMock();
|
||||
const service = new AuthService(users as never, sessions as never, createMetricsMock() as never);
|
||||
await expect(loginWithCaptcha(service, 'admin')).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(sessions.recordAnonymousLoginFailure).toHaveBeenCalledWith('user@example.com', '203.0.113.10');
|
||||
});
|
||||
|
||||
it('rejects expired and incorrect one-time captchas', async () => {
|
||||
const sessions = createSessionsMock();
|
||||
const service = new AuthService(
|
||||
createUsersMock('platform_admin') as never,
|
||||
sessions as never,
|
||||
createMetricsMock() as never,
|
||||
);
|
||||
await expect(
|
||||
service.login(
|
||||
{ login: 'user', password: 'secret1', captchaId: 'missing', captchaText: '1' },
|
||||
'admin',
|
||||
'203.0.113.10',
|
||||
),
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
await sessions.storeCaptcha('captcha-wrong', '7');
|
||||
await expect(
|
||||
service.login(
|
||||
{ login: 'user', password: 'secret1', captchaId: 'captcha-wrong', captchaText: '8' },
|
||||
'admin',
|
||||
'203.0.113.10',
|
||||
),
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
});
|
||||
|
||||
it('delegates unlock and recent reauthentication to password and session services', async () => {
|
||||
const users = createUsersMock('platform_admin');
|
||||
const sessions = createSessionsMock();
|
||||
const service = new AuthService(users as never, sessions as never, createMetricsMock() as never);
|
||||
await expect(service.unlock('token', 'user-1', 'secret1')).resolves.toEqual({ status: 'active' });
|
||||
await expect(service.reauthenticate('token', 'user-1', 'secret1')).resolves.toEqual({ status: 'active' });
|
||||
expect(users.verifyCurrentPassword).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { BadRequestException, HttpException, HttpStatus, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import type { LoginDto } from './auth.dto';
|
||||
import { SessionService } from './session.service';
|
||||
import { MetricsService } from '../metrics/metrics.service';
|
||||
|
||||
type LoginPortal = 'admin' | 'client';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(private readonly users: UsersService, private readonly sessions: SessionService) {}
|
||||
constructor(
|
||||
private readonly users: UsersService,
|
||||
private readonly sessions: SessionService,
|
||||
private readonly metrics: MetricsService,
|
||||
) {}
|
||||
|
||||
async createCaptcha() {
|
||||
async createCaptcha(sourceIp: string) {
|
||||
if (!(await this.sessions.assertCaptchaRequestAllowed(sourceIp))) {
|
||||
this.metrics.recordAuthProtectionResult('captcha_rejected');
|
||||
throw new HttpException('验证码请求过于频繁,请稍后再试', HttpStatus.TOO_MANY_REQUESTS);
|
||||
}
|
||||
this.metrics.recordAuthProtectionResult('captcha_allowed');
|
||||
const left = Math.floor(10 + Math.random() * 40);
|
||||
const right = Math.floor(1 + Math.random() * 9);
|
||||
const captchaId = randomUUID();
|
||||
@@ -22,43 +32,48 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
async login(data: LoginDto, portal: LoginPortal) {
|
||||
async login(data: LoginDto, portal: LoginPortal, sourceIp: string) {
|
||||
const login = data.login?.trim();
|
||||
if (!login || !data.password) {
|
||||
throw new BadRequestException('login and password are required');
|
||||
}
|
||||
await this.verifyCaptcha(data.captchaId, data.captchaText);
|
||||
await this.assertAnonymousNotLocked(login);
|
||||
await this.assertAnonymousNotLocked(login, sourceIp);
|
||||
|
||||
const user = await this.users.findByLogin(login);
|
||||
if (!user) {
|
||||
await this.sessions.recordAnonymousLoginFailure(login);
|
||||
await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
|
||||
throw new UnauthorizedException('Invalid login or password');
|
||||
}
|
||||
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
|
||||
await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
|
||||
throw new UnauthorizedException('User is locked for 24 hours after repeated failures');
|
||||
}
|
||||
if (user.status !== 'active' || user.deletedAt) {
|
||||
await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
|
||||
await this.users.recordLoginFailure(user.id);
|
||||
throw new UnauthorizedException('User is disabled or deleted');
|
||||
}
|
||||
if (!await this.users.verifyLoginPassword(user.id, data.password, user.passwordHash)) {
|
||||
if (!(await this.users.verifyLoginPassword(user.id, data.password, user.passwordHash))) {
|
||||
await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
|
||||
await this.users.recordLoginFailure(user.id);
|
||||
throw new UnauthorizedException('Invalid login or password');
|
||||
}
|
||||
|
||||
const roleCodes = user.roles.map((item) => item.role.code);
|
||||
if (portal === 'admin' && !roleCodes.includes('platform_admin')) {
|
||||
await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
|
||||
await this.users.recordLoginFailure(user.id);
|
||||
throw new UnauthorizedException('Only platform admins can login to admin portal');
|
||||
}
|
||||
if (portal === 'client' && (!roleCodes.includes('enterprise_admin') || !user.tenantId)) {
|
||||
await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
|
||||
await this.users.recordLoginFailure(user.id);
|
||||
throw new UnauthorizedException('Only enterprise admins linked to a tenant can login to client portal');
|
||||
}
|
||||
|
||||
await this.users.recordLoginSuccess(user.id);
|
||||
await this.sessions.clearAnonymousLoginFailures(login);
|
||||
await this.sessions.clearAnonymousLoginFailures(login, sourceIp);
|
||||
|
||||
const { token, record } = await this.sessions.create(user.id, portal, user.sessionVersion ?? 0);
|
||||
return {
|
||||
@@ -98,9 +113,15 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
private async assertAnonymousNotLocked(login: string) {
|
||||
if (await this.sessions.isAnonymousLoginLocked(login)) {
|
||||
throw new UnauthorizedException('User is locked for 24 hours after repeated failures');
|
||||
private async assertAnonymousNotLocked(login: string, sourceIp: string) {
|
||||
const scope = await this.sessions.anonymousLoginLockScope(login, sourceIp);
|
||||
if (scope) {
|
||||
this.metrics.recordAuthProtectionResult('login_locked', scope);
|
||||
throw new UnauthorizedException(
|
||||
scope === 'account'
|
||||
? 'User is locked for 24 hours after repeated failures'
|
||||
: 'Too many login attempts from this source, try again later',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,36 @@
|
||||
const values = new Map<string, string>();
|
||||
const redis = {
|
||||
get: jest.fn((key: string) => Promise.resolve(values.get(key) ?? null)),
|
||||
set: jest.fn((key: string, value: string) => { values.set(key, value); return Promise.resolve('OK'); }),
|
||||
del: jest.fn((key: string) => { values.delete(key); return Promise.resolve(1); }),
|
||||
getdel: jest.fn((key: string) => {
|
||||
const value = values.get(key) ?? null;
|
||||
values.delete(key);
|
||||
return Promise.resolve(value);
|
||||
}),
|
||||
mget: jest.fn((...keys: string[]) => Promise.resolve(keys.map((key) => values.get(key) ?? null))),
|
||||
set: jest.fn((key: string, value: string) => {
|
||||
values.set(key, value);
|
||||
return Promise.resolve('OK');
|
||||
}),
|
||||
del: jest.fn((...keys: string[]) => {
|
||||
keys.forEach((key) => values.delete(key));
|
||||
return Promise.resolve(keys.length);
|
||||
}),
|
||||
eval: jest.fn((_script: string, keyCount: number, ...parts: Array<string | number>) => {
|
||||
const keys = parts.slice(0, keyCount).map(String);
|
||||
const args = parts.slice(keyCount).map(Number);
|
||||
if (keyCount === 1) {
|
||||
const count = Number(values.get(keys[0]) ?? 0) + 1;
|
||||
values.set(keys[0], String(count));
|
||||
return Promise.resolve(count);
|
||||
}
|
||||
const counts = keys.slice(0, 3).map((key, index) => {
|
||||
const count = Number(values.get(key) ?? 0) + 1;
|
||||
values.set(key, String(count));
|
||||
if (count >= args[index + 3]) values.set(keys[index + 3], '1');
|
||||
return count;
|
||||
});
|
||||
return Promise.resolve(counts);
|
||||
}),
|
||||
disconnect: jest.fn(),
|
||||
};
|
||||
|
||||
@@ -47,7 +75,9 @@ describe('SessionService', () => {
|
||||
const service = new SessionService();
|
||||
const created = await service.create('user-1', 'client', 2);
|
||||
now += 90 * 60 * 1000;
|
||||
await expect(service.validate(created.token, false)).resolves.toEqual(expect.objectContaining({ status: 'active' }));
|
||||
await expect(service.validate(created.token, false)).resolves.toEqual(
|
||||
expect.objectContaining({ status: 'active' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('requires a full login after a session stays locked for four hours', async () => {
|
||||
@@ -55,7 +85,10 @@ describe('SessionService', () => {
|
||||
const created = await service.create('user-1', 'admin', 2);
|
||||
await service.lock(created.token);
|
||||
now += 4 * 60 * 60 * 1000 + 1;
|
||||
await expect(service.validate(created.token, false)).resolves.toEqual({ status: 'expired', code: 'SESSION_LOCK_TIMEOUT' });
|
||||
await expect(service.validate(created.token, false)).resolves.toEqual({
|
||||
status: 'expired',
|
||||
code: 'SESSION_LOCK_TIMEOUT',
|
||||
});
|
||||
});
|
||||
|
||||
it('rotates the opaque token when a password unlock succeeds', async () => {
|
||||
@@ -66,8 +99,13 @@ describe('SessionService', () => {
|
||||
expect(result.status).toBe('active');
|
||||
if (result.status === 'active' && 'token' in result) {
|
||||
expect(result.token).not.toBe(created.token);
|
||||
await expect(service.validate(created.token, false)).resolves.toEqual({ status: 'expired', code: 'SESSION_INVALID' });
|
||||
await expect(service.validate(result.token, false)).resolves.toEqual(expect.objectContaining({ status: 'active' }));
|
||||
await expect(service.validate(created.token, false)).resolves.toEqual({
|
||||
status: 'expired',
|
||||
code: 'SESSION_INVALID',
|
||||
});
|
||||
await expect(service.validate(result.token, false)).resolves.toEqual(
|
||||
expect.objectContaining({ status: 'active' }),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -78,4 +116,63 @@ describe('SessionService', () => {
|
||||
expect(service.cookieName('client')).toBe('cmpp_client_session');
|
||||
delete process.env.SESSION_COOKIE_SECURE;
|
||||
});
|
||||
|
||||
it('stores and consumes a captcha exactly once', async () => {
|
||||
const service = new SessionService();
|
||||
await service.storeCaptcha('captcha-1', '9', 300);
|
||||
await expect(service.consumeCaptcha('captcha-1')).resolves.toBe('9');
|
||||
await expect(service.consumeCaptcha('captcha-1')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('expires an absolute session and supports touch plus recent authentication', async () => {
|
||||
const service = new SessionService();
|
||||
const expired = await service.create('expired-user', 'admin', 1);
|
||||
now += 12 * 60 * 60 * 1000 + 1;
|
||||
await expect(service.validate(expired.token, false)).resolves.toEqual({
|
||||
status: 'expired',
|
||||
code: 'SESSION_ABSOLUTE_TIMEOUT',
|
||||
});
|
||||
|
||||
now = 1_700_000_000_000;
|
||||
const active = await service.create('active-user', 'client', 1);
|
||||
now += 31_000;
|
||||
const touched = await service.touch(active.token);
|
||||
expect(touched.status).toBe('active');
|
||||
const reauthenticated = await service.markReauthenticated(active.token);
|
||||
expect(reauthenticated.status).toBe('active');
|
||||
if (reauthenticated.status === 'active') {
|
||||
expect(service.isRecentlyAuthenticated(reauthenticated.record)).toBe(true);
|
||||
expect(service.publicSession(reauthenticated.record)).toEqual(
|
||||
expect.objectContaining({ idleTimeoutSeconds: 7200 }),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses host-prefixed cookie names when secure cookies are enabled', () => {
|
||||
process.env.SESSION_COOKIE_SECURE = 'true';
|
||||
const service = new SessionService();
|
||||
expect(service.cookieName('admin')).toBe('__Host-cmpp_admin_session');
|
||||
expect(service.cookieName('client')).toBe('__Host-cmpp_client_session');
|
||||
delete process.env.SESSION_COOKIE_SECURE;
|
||||
});
|
||||
|
||||
it('rate limits captcha allocation by hashed source IP', async () => {
|
||||
const service = new SessionService();
|
||||
for (let index = 0; index < 30; index += 1) {
|
||||
await expect(service.assertCaptchaRequestAllowed('203.0.113.10')).resolves.toBe(true);
|
||||
}
|
||||
await expect(service.assertCaptchaRequestAllowed('203.0.113.10')).resolves.toBe(false);
|
||||
expect([...values.keys()].some((key) => key.includes('203.0.113.10'))).toBe(false);
|
||||
});
|
||||
|
||||
it('locks anonymous failures independently by account, IP and account-IP pair', async () => {
|
||||
const service = new SessionService();
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await service.recordAnonymousLoginFailure('user@example.com', '203.0.113.10');
|
||||
}
|
||||
await expect(service.anonymousLoginLockScope('user@example.com', '198.51.100.7')).resolves.toBe('account');
|
||||
await expect(service.anonymousLoginLockScope('other@example.com', '203.0.113.10')).resolves.toBeNull();
|
||||
await service.clearAnonymousLoginFailures('user@example.com', '203.0.113.10');
|
||||
await expect(service.anonymousLoginLockScope('user@example.com', '203.0.113.10')).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,8 +22,13 @@ export type SessionValidationResult =
|
||||
|
||||
const SESSION_PREFIX = 'cmpp:auth:session:';
|
||||
const CAPTCHA_PREFIX = 'cmpp:auth:captcha:';
|
||||
const CAPTCHA_RATE_PREFIX = 'cmpp:auth:captcha-rate:ip:';
|
||||
const ANONYMOUS_FAILURE_PREFIX = 'cmpp:auth:failure:';
|
||||
const ANONYMOUS_LOCK_PREFIX = 'cmpp:auth:lock:';
|
||||
const ANONYMOUS_IP_FAILURE_PREFIX = 'cmpp:auth:failure:ip:';
|
||||
const ANONYMOUS_IP_LOCK_PREFIX = 'cmpp:auth:lock:ip:';
|
||||
const ANONYMOUS_PAIR_FAILURE_PREFIX = 'cmpp:auth:failure:pair:';
|
||||
const ANONYMOUS_PAIR_LOCK_PREFIX = 'cmpp:auth:lock:pair:';
|
||||
export const SESSION_COOKIE_NAME = '__Host-cmpp_session';
|
||||
export const DEVELOPMENT_SESSION_COOKIE_NAME = 'cmpp_session';
|
||||
export const ADMIN_SESSION_COOKIE_NAME = '__Host-cmpp_admin_session';
|
||||
@@ -142,40 +147,87 @@ export class SessionService implements OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
async isAnonymousLoginLocked(login: string) {
|
||||
async assertCaptchaRequestAllowed(sourceIp: string) {
|
||||
const key = `${CAPTCHA_RATE_PREFIX}${this.valueDigest(sourceIp)}`;
|
||||
try {
|
||||
return Boolean(await this.client.exists(`${ANONYMOUS_LOCK_PREFIX}${this.loginDigest(login)}`));
|
||||
const count = Number(
|
||||
await this.client.eval(
|
||||
`local count = redis.call('INCR', KEYS[1])
|
||||
if count == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
|
||||
return count`,
|
||||
1,
|
||||
key,
|
||||
5 * 60,
|
||||
),
|
||||
);
|
||||
return count <= 30;
|
||||
} catch {
|
||||
throw new ServiceUnavailableException('验证码服务暂不可用');
|
||||
}
|
||||
}
|
||||
|
||||
async anonymousLoginLockScope(login: string, sourceIp: string) {
|
||||
const accountDigest = this.loginDigest(login);
|
||||
const ipDigest = this.valueDigest(sourceIp);
|
||||
const pairDigest = this.valueDigest(`${accountDigest}:${ipDigest}`);
|
||||
try {
|
||||
const locks = await this.client.mget(
|
||||
`${ANONYMOUS_LOCK_PREFIX}${accountDigest}`,
|
||||
`${ANONYMOUS_IP_LOCK_PREFIX}${ipDigest}`,
|
||||
`${ANONYMOUS_PAIR_LOCK_PREFIX}${pairDigest}`,
|
||||
);
|
||||
if (locks[0]) return 'account' as const;
|
||||
if (locks[1]) return 'ip' as const;
|
||||
if (locks[2]) return 'pair' as const;
|
||||
return null;
|
||||
} catch {
|
||||
throw new ServiceUnavailableException('登录保护服务暂不可用');
|
||||
}
|
||||
}
|
||||
|
||||
async recordAnonymousLoginFailure(login: string) {
|
||||
const digest = this.loginDigest(login);
|
||||
const failureKey = `${ANONYMOUS_FAILURE_PREFIX}${digest}`;
|
||||
const lockKey = `${ANONYMOUS_LOCK_PREFIX}${digest}`;
|
||||
async recordAnonymousLoginFailure(login: string, sourceIp: string) {
|
||||
const accountDigest = this.loginDigest(login);
|
||||
const ipDigest = this.valueDigest(sourceIp);
|
||||
const pairDigest = this.valueDigest(`${accountDigest}:${ipDigest}`);
|
||||
try {
|
||||
const count = Number(await this.client.eval(
|
||||
`local count = redis.call('INCR', KEYS[1])
|
||||
if count == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
|
||||
if count >= tonumber(ARGV[2]) then redis.call('SET', KEYS[2], '1', 'EX', ARGV[1]) end
|
||||
return count`,
|
||||
2,
|
||||
failureKey,
|
||||
lockKey,
|
||||
const result = await this.client.eval(
|
||||
`local counts = {}
|
||||
for i = 1, 3 do
|
||||
counts[i] = redis.call('INCR', KEYS[i])
|
||||
if counts[i] == 1 then redis.call('EXPIRE', KEYS[i], ARGV[i]) end
|
||||
if counts[i] >= tonumber(ARGV[i + 3]) then redis.call('SET', KEYS[i + 3], '1', 'EX', ARGV[i]) end
|
||||
end
|
||||
return counts`,
|
||||
6,
|
||||
`${ANONYMOUS_FAILURE_PREFIX}${accountDigest}`,
|
||||
`${ANONYMOUS_IP_FAILURE_PREFIX}${ipDigest}`,
|
||||
`${ANONYMOUS_PAIR_FAILURE_PREFIX}${pairDigest}`,
|
||||
`${ANONYMOUS_LOCK_PREFIX}${accountDigest}`,
|
||||
`${ANONYMOUS_IP_LOCK_PREFIX}${ipDigest}`,
|
||||
`${ANONYMOUS_PAIR_LOCK_PREFIX}${pairDigest}`,
|
||||
24 * 60 * 60,
|
||||
15 * 60,
|
||||
24 * 60 * 60,
|
||||
5,
|
||||
));
|
||||
return count;
|
||||
30,
|
||||
5,
|
||||
);
|
||||
return (result as number[]).map(Number);
|
||||
} catch {
|
||||
throw new ServiceUnavailableException('登录保护服务暂不可用');
|
||||
}
|
||||
}
|
||||
|
||||
async clearAnonymousLoginFailures(login: string) {
|
||||
const digest = this.loginDigest(login);
|
||||
async clearAnonymousLoginFailures(login: string, sourceIp: string) {
|
||||
const accountDigest = this.loginDigest(login);
|
||||
const pairDigest = this.valueDigest(`${accountDigest}:${this.valueDigest(sourceIp)}`);
|
||||
try {
|
||||
await this.client.del(`${ANONYMOUS_FAILURE_PREFIX}${digest}`, `${ANONYMOUS_LOCK_PREFIX}${digest}`);
|
||||
await this.client.del(
|
||||
`${ANONYMOUS_FAILURE_PREFIX}${accountDigest}`,
|
||||
`${ANONYMOUS_LOCK_PREFIX}${accountDigest}`,
|
||||
`${ANONYMOUS_PAIR_FAILURE_PREFIX}${pairDigest}`,
|
||||
`${ANONYMOUS_PAIR_LOCK_PREFIX}${pairDigest}`,
|
||||
);
|
||||
} catch {
|
||||
throw new ServiceUnavailableException('登录保护服务暂不可用');
|
||||
}
|
||||
@@ -196,7 +248,10 @@ export class SessionService implements OnModuleDestroy {
|
||||
}
|
||||
|
||||
get cookieSecure() {
|
||||
return process.env.SESSION_COOKIE_SECURE === 'true' || (process.env.NODE_ENV === 'production' && process.env.SESSION_COOKIE_SECURE !== 'false');
|
||||
return (
|
||||
process.env.SESSION_COOKIE_SECURE === 'true' ||
|
||||
(process.env.NODE_ENV === 'production' && process.env.SESSION_COOKIE_SECURE !== 'false')
|
||||
);
|
||||
}
|
||||
|
||||
cookieName(portal: SessionPortal) {
|
||||
@@ -230,7 +285,7 @@ export class SessionService implements OnModuleDestroy {
|
||||
private async read(token: string): Promise<AuthSessionRecord | null> {
|
||||
try {
|
||||
const value = await this.client.get(this.key(token));
|
||||
return value ? JSON.parse(value) as AuthSessionRecord : null;
|
||||
return value ? (JSON.parse(value) as AuthSessionRecord) : null;
|
||||
} catch {
|
||||
throw new ServiceUnavailableException('登录会话服务暂不可用');
|
||||
}
|
||||
@@ -254,7 +309,11 @@ export class SessionService implements OnModuleDestroy {
|
||||
}
|
||||
|
||||
private loginDigest(login: string) {
|
||||
return createHash('sha256').update(login.trim().toLocaleLowerCase('en-US')).digest('hex');
|
||||
return this.valueDigest(login.trim().toLocaleLowerCase('en-US'));
|
||||
}
|
||||
|
||||
private valueDigest(value: string) {
|
||||
return createHash('sha256').update(value.trim()).digest('hex');
|
||||
}
|
||||
|
||||
private get client() {
|
||||
|
||||
Reference in New Issue
Block a user