Feat add iam auth
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, HttpCode, Post } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Headers, HttpCode, Ip, Post } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { CurrentAdmin, Public } from './guards';
|
||||
@@ -19,8 +19,11 @@ export class AuthController {
|
||||
@Public()
|
||||
@Post('login')
|
||||
@HttpCode(200)
|
||||
login(@Body() dto: LoginDto) {
|
||||
return this.authService.login(dto);
|
||||
login(@Body() dto: LoginDto, @Ip() ip: string, @Headers('user-agent') userAgent?: string) {
|
||||
// The IP is forwarded to the identity service (when delegation is on) so its
|
||||
// per-IP rate limit applies to the real admin, not to this API server; both
|
||||
// are also recorded on the session row when session tracking is on.
|
||||
return this.authService.login(dto, ip, userAgent);
|
||||
}
|
||||
|
||||
/** Legacy `users/show` — the router guard's permission refresh. */
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
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 {} }));
|
||||
|
||||
import { AuthService } 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,
|
||||
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 }),
|
||||
);
|
||||
});
|
||||
|
||||
/* ─────────────── 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('دو مرحلهای');
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,25 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import {
|
||||
IamInvalidCredentialsError,
|
||||
IamMfaRequiredError,
|
||||
IamService,
|
||||
type IamIdentity,
|
||||
} from '../iam/iam.service';
|
||||
import { AdminSessionService } from '../sessions/admin-session.service';
|
||||
import type { LoginDto } from './dto/login.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly iam: IamService,
|
||||
private readonly sessions: AdminSessionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -25,7 +36,7 @@ export class AuthService {
|
||||
* 401 here. Legacy returned 404 (firstOrFail) for an unknown phone and 422 for
|
||||
* a wrong password, which let anyone enumerate valid admin phone numbers.
|
||||
*/
|
||||
async login(dto: LoginDto) {
|
||||
async login(dto: LoginDto, clientIp?: string, userAgent?: string) {
|
||||
const user = await this.prisma.adminUser.findUnique({
|
||||
where: { mobile: dto.phone },
|
||||
include: { userType: { include: { permissions: true } } },
|
||||
@@ -35,12 +46,32 @@ export class AuthService {
|
||||
throw new UnauthorizedException('نام کاربری یا رمز عبور اشتباه است');
|
||||
}
|
||||
|
||||
const passwordOk = await bcrypt.compare(dto.password, user.passwordHash);
|
||||
if (!passwordOk) {
|
||||
const verified = await this.verifyPassword(dto, user.passwordHash, clientIp);
|
||||
if (!verified.ok) {
|
||||
throw new UnauthorizedException('نام کاربری یا رمز عبور اشتباه است');
|
||||
}
|
||||
|
||||
const apiToken = await this.jwt.signAsync({ sub: user.id, mobile: user.mobile });
|
||||
// While session tracking is off this is null and the token is signed exactly
|
||||
// as before — same payload, same claims.
|
||||
const jti = this.sessions.newTokenId();
|
||||
const apiToken = await this.jwt.signAsync({
|
||||
sub: user.id,
|
||||
mobile: user.mobile,
|
||||
...(jti ? { jti } : {}),
|
||||
});
|
||||
|
||||
if (jti) {
|
||||
// Recorded so a central sign-out (or an admin revoking the session) can end
|
||||
// this token before it expires — see AdminSessionService.
|
||||
await this.sessions.record({
|
||||
jti,
|
||||
adminUserId: user.id,
|
||||
iamSessionId: verified.identity?.sessionId ?? null,
|
||||
iamSub: verified.identity?.sub ?? null,
|
||||
ipAddress: clientIp ?? null,
|
||||
userAgent: userAgent ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
@@ -55,6 +86,56 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Password check, optionally delegated to the central identity service.
|
||||
*
|
||||
* With `IAM_ENABLED=false` (the default) this is exactly the previous
|
||||
* behaviour: a single bcrypt compare. With it on, the identity service is
|
||||
* asked first and the local hash is only consulted when that check could not
|
||||
* give a definite answer — so an IAM outage never blocks admin login.
|
||||
*/
|
||||
private async verifyPassword(
|
||||
dto: LoginDto,
|
||||
passwordHash: string,
|
||||
clientIp?: string,
|
||||
): Promise<{ ok: boolean; identity?: IamIdentity }> {
|
||||
if (!this.iam.enabled) {
|
||||
return { ok: await bcrypt.compare(dto.password, passwordHash) };
|
||||
}
|
||||
|
||||
try {
|
||||
const identity = await this.iam.verifyCredentials(dto.phone, dto.password, clientIp);
|
||||
|
||||
// The IAM session id travels with the recorded session so a back-channel
|
||||
// logout can find and end exactly this session.
|
||||
return { ok: true, identity };
|
||||
} catch (error) {
|
||||
if (error instanceof IamMfaRequiredError) {
|
||||
// Nowhere to collect the code on this UI-less path; say so plainly
|
||||
// instead of silently falling back to a password the IAM may have retired.
|
||||
throw new UnauthorizedException(
|
||||
'برای این حساب احراز هویت دو مرحلهای فعال است و از این مسیر قابل تکمیل نیست',
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof IamInvalidCredentialsError && !this.iam.allowsLocalFallback) {
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
if (!(error instanceof IamInvalidCredentialsError)) {
|
||||
this.logger.warn(
|
||||
`falling back to the local password check: ${(error as Error).message}`,
|
||||
);
|
||||
if (!this.iam.allowsLocalFallback) {
|
||||
throw new UnauthorizedException('ورود در حال حاضر ممکن نیست؛ سرویس هویت در دسترس نیست');
|
||||
}
|
||||
}
|
||||
|
||||
// fallback mode: the account may not have been migrated to the IAM yet
|
||||
return { ok: await bcrypt.compare(dto.password, passwordHash) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Port of legacy UserController::showData, which the router guard calls on every
|
||||
* cold load to refresh the permission list:
|
||||
|
||||
@@ -3,11 +3,19 @@ import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { envOr } from '../../common/env';
|
||||
import { AdminSessionService } from '../sessions/admin-session.service';
|
||||
import { expandWithParents } from './acl.constants';
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
mobile: string;
|
||||
/**
|
||||
* Present only on tokens issued while session tracking is on. It identifies the
|
||||
* AdminSession row, which is what makes revoking a token before it expires
|
||||
* possible. A token without it predates tracking and stays valid until it
|
||||
* expires, so switching the feature on does not sign everyone out.
|
||||
*/
|
||||
jti?: string;
|
||||
/**
|
||||
* Set on APP tokens only ('customer' or 'sales-expert'). An admin-panel
|
||||
* token has no `typ`. Checked in validate() below — see the note there.
|
||||
@@ -46,7 +54,10 @@ export interface AuthenticatedAdmin {
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
constructor(private readonly prisma: PrismaService) {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly sessions: AdminSessionService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
@@ -60,6 +71,13 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
// Revoked centrally (IAM back-channel logout) or locally? While tracking is
|
||||
// off this returns true without touching the database, so the request costs
|
||||
// exactly what it did before.
|
||||
if (!(await this.sessions.isActive(payload.jti))) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const user = await this.prisma.adminUser.findUnique({
|
||||
where: { id: payload.sub },
|
||||
include: { userType: { include: { permissions: true } } },
|
||||
|
||||
Reference in New Issue
Block a user