Feat add iam auth
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user