Files
tehran_taj/apps/api/src/modules/auth/auth.service.ts
T

171 lines
6.1 KiB
TypeScript
Raw Normal View History

2026-09-25 19:04:48 +03:30
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../../prisma/prisma.service';
2026-09-25 19:04:48 +03:30
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 {
2026-09-25 19:04:48 +03:30
private readonly logger = new Logger(AuthService.name);
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
2026-09-25 19:04:48 +03:30
private readonly iam: IamService,
private readonly sessions: AdminSessionService,
) {}
/**
* Port of legacy AdminController::login (henza_server).
*
* Legacy issued a random 60-char string as `api_token` and persisted it on the
* user row; this issues a signed JWT instead. The response keeps the legacy
* shape the admin panel's auth store reads:
* - `api_token` -> stored in localStorage under token/branch_token
* - `roles` -> [{ rid }], mapped to rids by the frontend then expanded
* through checkParent()
*
* Deliberate deviation from legacy: a bad phone and a bad password both return
* 401 here. Legacy returned 404 (firstOrFail) for an unknown phone and 422 for
* a wrong password, which let anyone enumerate valid admin phone numbers.
*/
2026-09-25 19:04:48 +03:30
async login(dto: LoginDto, clientIp?: string, userAgent?: string) {
const user = await this.prisma.adminUser.findUnique({
where: { mobile: dto.phone },
include: { userType: { include: { permissions: true } } },
});
if (!user || !user.isActive) {
throw new UnauthorizedException('نام کاربری یا رمز عبور اشتباه است');
}
2026-09-25 19:04:48 +03:30
const verified = await this.verifyPassword(dto, user.passwordHash, clientIp);
if (!verified.ok) {
throw new UnauthorizedException('نام کاربری یا رمز عبور اشتباه است');
}
2026-09-25 19:04:48 +03:30
// 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,
name: user.fullName,
fullName: user.fullName,
mobile: user.mobile,
role: user.role,
ut: user.userTypeId,
branch_id: user.branchId,
api_token: apiToken,
roles: (user.userType?.permissions ?? []).map((p) => ({ rid: p.rid })),
};
}
2026-09-25 19:04:48 +03:30
/**
* 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:
*
* $user['permission'] = Permission::where('ut',$user->ut)->select('rid')->get()->pluck('rid');
*
* Returns raw (unexpanded) rids — the frontend runs checkParent() on them, as in
* legacy. The guard's own checks use the pre-expanded list from JwtStrategy.
*/
async showData(adminId: string) {
const user = await this.prisma.adminUser.findUnique({
where: { id: adminId },
include: { userType: { include: { permissions: true } } },
});
if (!user) {
throw new UnauthorizedException();
}
return {
id: user.id,
name: user.fullName,
fullName: user.fullName,
mobile: user.mobile,
role: user.role,
ut: user.userTypeId,
branch_id: user.branchId,
user_type: user.userType ? { id: user.userType.id, title: user.userType.title } : null,
permission: (user.userType?.permissions ?? []).map((p) => p.rid),
};
}
}