335 lines
13 KiB
TypeScript
335 lines
13 KiB
TypeScript
import { UnauthorizedException } from '@nestjs/common';
|
|||
|
|
import * as bcrypt from 'bcryptjs';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* `@nestjs/jwt` v12 ships as ESM and this repo's jest does not transform
|
||
|
|
* node_modules, so importing AuthService would fail to load. The real class is
|
||
|
|
* never exercised here (the test injects its own stub), so replacing the module
|
||
|
|
* keeps the suite runnable without touching the shared jest config.
|
||
|
|
*/
|
||
|
|
jest.mock('@nestjs/jwt', () => ({ JwtService: class JwtService {} }));
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The real bcryptjs, with `compare` wrapped so a test can assert it was never
|
||
|
|
* reached. It calls straight through, so every other test still runs against
|
||
|
|
* the real implementation. (A namespace import's properties cannot be
|
||
|
|
* re-defined, so jest.spyOn is not an option here.)
|
||
|
|
*/
|
||
|
|
jest.mock('bcryptjs', () => {
|
||
|
|
const actual = jest.requireActual('bcryptjs');
|
||
|
|
return { ...actual, compare: jest.fn(actual.compare) };
|
||
|
|
});
|
||
|
|
|
||
|
|
import { AuthService, compareLocalHash, OTP_ONLY_PASSWORD_HASH } from './auth.service';
|
||
|
|
import type { AdminSessionService } from '../sessions/admin-session.service';
|
||
|
|
import {
|
||
|
|
IamInvalidCredentialsError,
|
||
|
|
IamMfaRequiredError,
|
||
|
|
IamService,
|
||
|
|
IamUnavailableError,
|
||
|
|
} from '../iam/iam.service';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Admin login with the identity service in the loop.
|
||
|
|
*
|
||
|
|
* The contract being locked down: with the feature off, login behaves exactly
|
||
|
|
* as before (one bcrypt compare, no network). With it on, an IAM problem must
|
||
|
|
* never block an admin who still has a valid local password.
|
||
|
|
*/
|
||
|
|
describe('AuthService login', () => {
|
||
|
|
const PASSWORD = 'Secret123!';
|
||
|
|
let passwordHash: string;
|
||
|
|
|
||
|
|
const adminRow = () => ({
|
||
|
|
id: 'admin-1',
|
||
|
|
mobile: '09120000000',
|
||
|
|
fullName: 'مدیر سامانه',
|
||
|
|
role: 'admin',
|
||
|
|
userTypeId: 'ut-1',
|
||
|
|
branchId: 'br-1',
|
||
|
|
isActive: true,
|
||
|
|
passwordHash,
|
||
|
|
userType: { permissions: [{ rid: 10 }, { rid: 20 }] },
|
||
|
|
});
|
||
|
|
|
||
|
|
/** Session tracking off — the shape of a deployment that does not use it. */
|
||
|
|
const untrackedSessions = (): Partial<AdminSessionService> => ({
|
||
|
|
newTokenId: jest.fn().mockReturnValue(null),
|
||
|
|
record: jest.fn(),
|
||
|
|
});
|
||
|
|
|
||
|
|
const build = (
|
||
|
|
iam: Partial<IamService>,
|
||
|
|
user: unknown = adminRow(),
|
||
|
|
sessions: Partial<AdminSessionService> = untrackedSessions(),
|
||
|
|
) => {
|
||
|
|
const prisma = {
|
||
|
|
adminUser: { findUnique: jest.fn().mockResolvedValue(user) },
|
||
|
|
};
|
||
|
|
const jwt = { signAsync: jest.fn().mockResolvedValue('local.jwt.token') };
|
||
|
|
|
||
|
|
return {
|
||
|
|
service: new AuthService(
|
||
|
|
prisma as never,
|
||
|
|
jwt as never,
|
||
|
|
{ browserBootstrapUrl: () => null, ...iam } as IamService,
|
||
|
|
sessions as AdminSessionService,
|
||
|
|
),
|
||
|
|
prisma,
|
||
|
|
jwt,
|
||
|
|
sessions,
|
||
|
|
};
|
||
|
|
};
|
||
|
|
|
||
|
|
const disabledIam = (): Partial<IamService> => ({
|
||
|
|
enabled: false,
|
||
|
|
allowsLocalFallback: true,
|
||
|
|
verifyCredentials: jest.fn(),
|
||
|
|
});
|
||
|
|
|
||
|
|
beforeAll(async () => {
|
||
|
|
passwordHash = await bcrypt.hash(PASSWORD, 8);
|
||
|
|
});
|
||
|
|
|
||
|
|
/* ─────────────── feature off: unchanged behaviour ─────────────── */
|
||
|
|
|
||
|
|
it('logs in with the local password and never calls the identity service', async () => {
|
||
|
|
const iam = disabledIam();
|
||
|
|
const { service, jwt } = build(iam);
|
||
|
|
|
||
|
|
const result = await service.login({ phone: '09120000000', password: PASSWORD });
|
||
|
|
|
||
|
|
expect(result.api_token).toBe('local.jwt.token');
|
||
|
|
expect(result.roles).toEqual([{ rid: 10 }, { rid: 20 }]);
|
||
|
|
expect(jwt.signAsync).toHaveBeenCalledTimes(1);
|
||
|
|
expect(iam.verifyCredentials).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('still rejects a wrong local password', async () => {
|
||
|
|
const { service } = build(disabledIam());
|
||
|
|
|
||
|
|
await expect(
|
||
|
|
service.login({ phone: '09120000000', password: 'wrong' }),
|
||
|
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('still rejects an inactive admin', async () => {
|
||
|
|
const { service } = build(disabledIam(), { ...adminRow(), isActive: false });
|
||
|
|
|
||
|
|
await expect(
|
||
|
|
service.login({ phone: '09120000000', password: PASSWORD }),
|
||
|
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('still rejects an unknown phone without leaking that it is unknown', async () => {
|
||
|
|
const { service } = build(disabledIam(), null);
|
||
|
|
|
||
|
|
await expect(
|
||
|
|
service.login({ phone: '09129999999', password: PASSWORD }),
|
||
|
|
).rejects.toThrow('نام کاربری یا رمز عبور اشتباه است');
|
||
|
|
});
|
||
|
|
|
||
|
|
/* ─────────────── feature on ─────────────── */
|
||
|
|
|
||
|
|
it('accepts a password the identity service approves', async () => {
|
||
|
|
const verifyCredentials = jest.fn().mockResolvedValue({ sub: 'sub-1' });
|
||
|
|
const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials });
|
||
|
|
|
||
|
|
// Deliberately not the local password: the IAM is the authority here
|
||
|
|
const result = await service.login({ phone: '09120000000', password: 'iam-side-password' });
|
||
|
|
|
||
|
|
expect(result.api_token).toBe('local.jwt.token');
|
||
|
|
expect(verifyCredentials).toHaveBeenCalledWith('09120000000', 'iam-side-password', undefined);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('forwards the end-user IP to the identity service', async () => {
|
||
|
|
const verifyCredentials = jest.fn().mockResolvedValue({ sub: 'sub-1' });
|
||
|
|
const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials });
|
||
|
|
|
||
|
|
await service.login({ phone: '09120000000', password: 'x' }, '203.0.113.5');
|
||
|
|
|
||
|
|
expect(verifyCredentials).toHaveBeenCalledWith('09120000000', 'x', '203.0.113.5');
|
||
|
|
});
|
||
|
|
|
||
|
|
/* ─────────────── the safety net ─────────────── */
|
||
|
|
|
||
|
|
it('falls back to the local password when the account is unknown there', async () => {
|
||
|
|
const verifyCredentials = jest
|
||
|
|
.fn()
|
||
|
|
.mockRejectedValue(new IamInvalidCredentialsError('invalid_credentials'));
|
||
|
|
const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials });
|
||
|
|
|
||
|
|
const result = await service.login({ phone: '09120000000', password: PASSWORD });
|
||
|
|
|
||
|
|
expect(result.api_token).toBe('local.jwt.token');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('falls back to the local password when the identity service is down', async () => {
|
||
|
|
const verifyCredentials = jest.fn().mockRejectedValue(new IamUnavailableError('down'));
|
||
|
|
const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials });
|
||
|
|
|
||
|
|
// The single most important guarantee: an IAM outage cannot lock admins out
|
||
|
|
const result = await service.login({ phone: '09120000000', password: PASSWORD });
|
||
|
|
|
||
|
|
expect(result.api_token).toBe('local.jwt.token');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('rejects a wrong password even when falling back', async () => {
|
||
|
|
const verifyCredentials = jest
|
||
|
|
.fn()
|
||
|
|
.mockRejectedValue(new IamInvalidCredentialsError('invalid_credentials'));
|
||
|
|
const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials });
|
||
|
|
|
||
|
|
await expect(
|
||
|
|
service.login({ phone: '09120000000', password: 'wrong' }),
|
||
|
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
||
|
|
});
|
||
|
|
|
||
|
|
/* ─────────────── exclusive mode ─────────────── */
|
||
|
|
|
||
|
|
it('refuses the local password in exclusive mode', async () => {
|
||
|
|
const verifyCredentials = jest
|
||
|
|
.fn()
|
||
|
|
.mockRejectedValue(new IamInvalidCredentialsError('invalid_credentials'));
|
||
|
|
const { service } = build({ enabled: true, allowsLocalFallback: false, verifyCredentials });
|
||
|
|
|
||
|
|
await expect(
|
||
|
|
service.login({ phone: '09120000000', password: PASSWORD }),
|
||
|
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('fails loudly in exclusive mode when the identity service is down', async () => {
|
||
|
|
const verifyCredentials = jest.fn().mockRejectedValue(new IamUnavailableError('down'));
|
||
|
|
const { service } = build({ enabled: true, allowsLocalFallback: false, verifyCredentials });
|
||
|
|
|
||
|
|
await expect(
|
||
|
|
service.login({ phone: '09120000000', password: PASSWORD }),
|
||
|
|
).rejects.toThrow('سرویس هویت در دسترس نیست');
|
||
|
|
});
|
||
|
|
|
||
|
|
/* ─────────────── revocable sessions ─────────────── */
|
||
|
|
|
||
|
|
it('signs the same token as before while session tracking is off', async () => {
|
||
|
|
const { service, jwt, sessions } = build(disabledIam());
|
||
|
|
|
||
|
|
await service.login({ phone: '09120000000', password: PASSWORD });
|
||
|
|
|
||
|
|
// No jti, no row: the previous behaviour, byte for byte
|
||
|
|
expect(jwt.signAsync).toHaveBeenCalledWith({ sub: 'admin-1', mobile: '09120000000' });
|
||
|
|
expect(sessions.record).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('records the issued token with the IAM session id when tracking is on', async () => {
|
||
|
|
const tracked: Partial<AdminSessionService> = {
|
||
|
|
newTokenId: jest.fn().mockReturnValue('jti-1'),
|
||
|
|
record: jest.fn(),
|
||
|
|
};
|
||
|
|
const verifyCredentials = jest.fn().mockResolvedValue({ sub: 'sub-1', sessionId: 'sid-1' });
|
||
|
|
const { service, jwt } = build(
|
||
|
|
{ enabled: true, allowsLocalFallback: true, verifyCredentials },
|
||
|
|
adminRow(),
|
||
|
|
tracked,
|
||
|
|
);
|
||
|
|
|
||
|
|
await service.login({ phone: '09120000000', password: 'x' }, '203.0.113.5', 'Firefox');
|
||
|
|
|
||
|
|
expect(jwt.signAsync).toHaveBeenCalledWith({
|
||
|
|
sub: 'admin-1',
|
||
|
|
mobile: '09120000000',
|
||
|
|
jti: 'jti-1',
|
||
|
|
});
|
||
|
|
// Without the IAM session id, a back-channel logout could not find this session
|
||
|
|
expect(tracked.record).toHaveBeenCalledWith({
|
||
|
|
jti: 'jti-1',
|
||
|
|
adminUserId: 'admin-1',
|
||
|
|
iamSessionId: 'sid-1',
|
||
|
|
iamSub: 'sub-1',
|
||
|
|
ipAddress: '203.0.113.5',
|
||
|
|
userAgent: 'Firefox',
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('still records a session when the login fell back to the local password', async () => {
|
||
|
|
const tracked: Partial<AdminSessionService> = {
|
||
|
|
newTokenId: jest.fn().mockReturnValue('jti-2'),
|
||
|
|
record: jest.fn(),
|
||
|
|
};
|
||
|
|
const verifyCredentials = jest.fn().mockRejectedValue(new IamUnavailableError('down'));
|
||
|
|
const { service } = build(
|
||
|
|
{ enabled: true, allowsLocalFallback: true, verifyCredentials },
|
||
|
|
adminRow(),
|
||
|
|
tracked,
|
||
|
|
);
|
||
|
|
|
||
|
|
await service.login({ phone: '09120000000', password: PASSWORD });
|
||
|
|
|
||
|
|
// No IAM identity to attach, but the session must still be revocable locally
|
||
|
|
expect(tracked.record).toHaveBeenCalledWith(
|
||
|
|
expect.objectContaining({ jti: 'jti-2', iamSessionId: null, iamSub: null }),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
/* ─────────────── accounts with no usable password ─────────────── */
|
||
|
|
|
||
|
|
describe('accounts with no usable password', () => {
|
||
|
|
const noPassword = ['', OTP_ONLY_PASSWORD_HASH];
|
||
|
|
|
||
|
|
it.each(noPassword)('refuses login for hash %j, whatever is typed', async (hash) => {
|
||
|
|
const { service } = build(disabledIam(), { ...adminRow(), passwordHash: hash });
|
||
|
|
|
||
|
|
// Including the empty string and the sentinel itself: neither may open it
|
||
|
|
for (const typed of ['', 'x', OTP_ONLY_PASSWORD_HASH]) {
|
||
|
|
await expect(
|
||
|
|
service.login({ phone: '09120000000', password: typed }),
|
||
|
|
).rejects.toThrow('نام کاربری یا رمز عبور اشتباه است');
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
it.each(noPassword)('refuses hash %j on the IAM fallback path too', async (hash) => {
|
||
|
|
const verifyCredentials = jest.fn().mockRejectedValue(new IamUnavailableError('down'));
|
||
|
|
const { service } = build(
|
||
|
|
{ enabled: true, allowsLocalFallback: true, verifyCredentials },
|
||
|
|
{ ...adminRow(), passwordHash: hash },
|
||
|
|
);
|
||
|
|
|
||
|
|
await expect(
|
||
|
|
service.login({ phone: '09120000000', password: '' }),
|
||
|
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('never hands a malformed hash to bcrypt', async () => {
|
||
|
|
const compare = bcrypt.compare as unknown as jest.Mock;
|
||
|
|
compare.mockClear();
|
||
|
|
|
||
|
|
expect(await compareLocalHash('x', OTP_ONLY_PASSWORD_HASH)).toBe(false);
|
||
|
|
expect(await compareLocalHash('x', '')).toBe(false);
|
||
|
|
expect(await compareLocalHash('x', null)).toBe(false);
|
||
|
|
expect(compare).not.toHaveBeenCalled();
|
||
|
|
|
||
|
|
// …while a real hash still goes through it
|
||
|
|
await compareLocalHash(PASSWORD, passwordHash);
|
||
|
|
expect(compare).toHaveBeenCalledTimes(1);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("still accepts the legacy server's PHP $2y$ hashes", async () => {
|
||
|
|
// PHP's $2y$ is the same algorithm as $2b$; the legacy passwords
|
||
|
|
// (customers and the sales experts moving to AdminUser) use it.
|
||
|
|
const legacy = '$2y$' + (await bcrypt.hash(PASSWORD, 4)).slice(4);
|
||
|
|
|
||
|
|
expect(await compareLocalHash(PASSWORD, legacy)).toBe(true);
|
||
|
|
expect(await compareLocalHash('wrong', legacy)).toBe(false);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
/* ─────────────── two-factor ─────────────── */
|
||
|
|
|
||
|
|
it('explains that a two-factor account cannot log in through this path', async () => {
|
||
|
|
const verifyCredentials = jest.fn().mockRejectedValue(new IamMfaRequiredError('mfa'));
|
||
|
|
const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials });
|
||
|
|
|
||
|
|
await expect(
|
||
|
|
service.login({ phone: '09120000000', password: PASSWORD }),
|
||
|
|
).rejects.toThrow('دو مرحلهای');
|
||
|
|
});
|
||
|
|
});
|