185 lines
7.3 KiB
TypeScript
185 lines
7.3 KiB
TypeScript
import { UnauthorizedException } from '@nestjs/common';
|
|
import { AuthService } from './auth.service';
|
|
import { legacyHashPassword } from './password-hasher';
|
|
|
|
function createUsersMock(roleCode: string, overrides: Record<string, unknown> = {}) {
|
|
const user = {
|
|
id: 'user-1',
|
|
tenantId: roleCode === 'enterprise_admin' ? 'tenant-1' : null,
|
|
username: 'user',
|
|
email: 'user@example.com',
|
|
phone: '13800000000',
|
|
displayName: '用户',
|
|
passwordHash: legacyHashPassword('secret1'),
|
|
status: 'active',
|
|
deletedAt: null,
|
|
lockedUntil: null,
|
|
tenant: { id: 'tenant-1', name: '企业A' },
|
|
roles: [{ role: { code: roleCode } }],
|
|
...overrides,
|
|
};
|
|
return {
|
|
findByLogin: jest.fn().mockResolvedValue(user),
|
|
verifyLoginPassword: jest.fn(async (_id: string, password: string) => password === 'secret1'),
|
|
recordLoginSuccess: jest.fn(),
|
|
recordLoginFailure: jest.fn(),
|
|
verifyCurrentPassword: jest.fn(),
|
|
};
|
|
}
|
|
|
|
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,
|
|
};
|
|
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;
|
|
}),
|
|
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(),
|
|
}),
|
|
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('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, 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, 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, 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);
|
|
});
|
|
});
|