Feat add iam auth
This commit is contained in:
@@ -3,6 +3,13 @@ import { JwtService } from '@nestjs/jwt';
|
||||
import { AdminRole, CustomerKind, MessageAudience, Prisma } from '@prisma/client';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import {
|
||||
IamAccessDeniedError,
|
||||
IamService,
|
||||
IamUserUnknownError,
|
||||
type IamIdentity,
|
||||
} from '../iam/iam.service';
|
||||
import { AppSessionService } from '../sessions/app-session.service';
|
||||
import { envOr, envOrNull } from '../../common/env';
|
||||
import { CUSTOMER_TOKEN_TYPE, EXPERT_TOKEN_TYPE, type CustomerJwtPayload } from './customer-jwt.strategy';
|
||||
import { faToEn } from './digits';
|
||||
@@ -47,6 +54,17 @@ export function masterOtpEnabled(): boolean {
|
||||
return masterOtpCode() !== null;
|
||||
}
|
||||
|
||||
/** اطلاعات جانبی صدور توکن: هویت تأییدشده در IdP و IP کاربر */
|
||||
interface IssueContext {
|
||||
identity?: IamIdentity | null;
|
||||
ip?: string;
|
||||
}
|
||||
|
||||
/** شماره ماسکشده برای لاگ امنیتی IdP — بدون افشای کامل شماره */
|
||||
function maskMobile(mobile: string): string | null {
|
||||
return mobile.length < 7 ? null : `${mobile.slice(0, 4)}***${mobile.slice(-4)}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CustomerAuthService {
|
||||
private readonly logger = new Logger(CustomerAuthService.name);
|
||||
@@ -55,8 +73,41 @@ export class CustomerAuthService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly sms: SmsService,
|
||||
private readonly iam: IamService,
|
||||
private readonly sessions: AppSessionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Tell the central identity service about a code this app just verified.
|
||||
*
|
||||
* The app's login has no password, so this is the only way an OTP login can
|
||||
* reach the identity service at all. Deliberate asymmetry in how failures are
|
||||
* treated, mirroring the admin path:
|
||||
*
|
||||
* - unknown user / service down / rate limited → local login continues
|
||||
* (an IAM problem must never lock customers out of the app);
|
||||
* - locked, deactivated or two-factor there → the login is refused,
|
||||
* because that is a decision, not a gap.
|
||||
*/
|
||||
private async attestToIam(mobile: string, ip: string | undefined): Promise<IamIdentity | null> {
|
||||
if (!this.iam.enabled) return null;
|
||||
|
||||
try {
|
||||
return await this.iam.attestOtpLogin(mobile, ip, maskMobile(mobile));
|
||||
} catch (error) {
|
||||
if (error instanceof IamAccessDeniedError) {
|
||||
this.logger.warn(`identity service refused ${mobile}: ${error.reason}`);
|
||||
throw new UnauthorizedException('ورود این حساب در حال حاضر مجاز نیست');
|
||||
}
|
||||
|
||||
if (!(error instanceof IamUserUnknownError)) {
|
||||
this.logger.warn(`OTP attestation skipped: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1 — port of ApiMobileController::userAuth (henza_server:39-49).
|
||||
*
|
||||
@@ -262,8 +313,10 @@ export class CustomerAuthService {
|
||||
this.logger.warn(
|
||||
`OTP_MASTER_CODE used to sign in SALES EXPERT ${expert.adminUserId} (mobile ${mobile})`,
|
||||
);
|
||||
// کد اضطراری تحویل، عامل واقعی نیست؛ عمداً به IdP اعلام نمیشود تا
|
||||
// نشست مرکزی روی چیزی که کاربر واقعاً نداشته ساخته نشود.
|
||||
return {
|
||||
token: await this.issueExpertToken(expert.adminUserId, mobile),
|
||||
token: await this.issueExpertToken(expert.adminUserId, mobile, { ip }),
|
||||
customer: { id: expert.tradingCustomerId, mobile },
|
||||
};
|
||||
}
|
||||
@@ -281,7 +334,7 @@ export class CustomerAuthService {
|
||||
);
|
||||
|
||||
return {
|
||||
token: await this.issueToken(customer.id, customer.mobile),
|
||||
token: await this.issueToken(customer.id, customer.mobile, { ip }),
|
||||
customer: { id: customer.id, mobile: customer.mobile },
|
||||
};
|
||||
}
|
||||
@@ -327,16 +380,21 @@ export class CustomerAuthService {
|
||||
// D21 — the challenge hangs off the expert's trading Customer row, so a
|
||||
// correct code here may belong to either principal. Resolved from the
|
||||
// mobile, never from anything the client sent.
|
||||
// کد درست بود؛ حالا همین را به مرجع هویت اعلام میکنیم تا نشست مرکزی،
|
||||
// لاگ امنیتی و خروج سراسری شامل این ورود هم بشود. در حالت خاموش یا خرابی
|
||||
// IdP، هیچ چیزی تغییر نمیکند و ورود مثل همیشه ادامه مییابد.
|
||||
const identity = await this.attestToIam(mobile, ip);
|
||||
|
||||
const expert = await this.resolveExpertPrincipal(mobile);
|
||||
if (expert) {
|
||||
return {
|
||||
token: await this.issueExpertToken(expert.adminUserId, mobile),
|
||||
token: await this.issueExpertToken(expert.adminUserId, mobile, { identity, ip }),
|
||||
customer: { id: expert.tradingCustomerId, mobile },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
token: await this.issueToken(challenge.customer.id, challenge.customer.mobile),
|
||||
token: await this.issueToken(challenge.customer.id, challenge.customer.mobile, { identity, ip }),
|
||||
customer: { id: challenge.customer.id, mobile: challenge.customer.mobile },
|
||||
};
|
||||
}
|
||||
@@ -379,16 +437,37 @@ export class CustomerAuthService {
|
||||
* NOT an admin-panel token: JwtStrategy requires the admin type and refuses
|
||||
* this one on every admin route.
|
||||
*/
|
||||
private issueExpertToken(adminUserId: string, mobile: string): Promise<string> {
|
||||
private async issueExpertToken(
|
||||
adminUserId: string,
|
||||
mobile: string,
|
||||
context: IssueContext = {},
|
||||
): Promise<string> {
|
||||
const jti = this.sessions.newTokenId();
|
||||
const payload: CustomerJwtPayload = {
|
||||
sub: adminUserId,
|
||||
mobile,
|
||||
typ: EXPERT_TOKEN_TYPE,
|
||||
...(jti ? { jti } : {}),
|
||||
};
|
||||
return this.jwt.signAsync(payload, {
|
||||
|
||||
const token = await this.jwt.signAsync(payload, {
|
||||
secret: customerJwtSecret(),
|
||||
expiresIn: envOr('JWT_CUSTOMER_EXPIRES_IN', '30d') as `${number}d`,
|
||||
});
|
||||
|
||||
if (jti) {
|
||||
await this.sessions.record({
|
||||
jti,
|
||||
subjectId: adminUserId,
|
||||
subjectType: 'sales-expert',
|
||||
mobile,
|
||||
iamSessionId: context.identity?.sessionId ?? null,
|
||||
iamSub: context.identity?.sub ?? null,
|
||||
ipAddress: context.ip ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -399,18 +478,40 @@ export class CustomerAuthService {
|
||||
* Reference reused api_token forever once set (:60-62); a fresh token is
|
||||
* issued on every verify here (T1).
|
||||
*/
|
||||
private issueToken(customerId: string, mobile: string): Promise<string> {
|
||||
private async issueToken(
|
||||
customerId: string,
|
||||
mobile: string,
|
||||
context: IssueContext = {},
|
||||
): Promise<string> {
|
||||
// null while session tracking is off ⇒ همان توکن قبلی، بدون ادعای اضافه
|
||||
const jti = this.sessions.newTokenId();
|
||||
const payload: CustomerJwtPayload = {
|
||||
sub: customerId,
|
||||
mobile,
|
||||
typ: CUSTOMER_TOKEN_TYPE,
|
||||
...(jti ? { jti } : {}),
|
||||
};
|
||||
return this.jwt.signAsync(payload, {
|
||||
|
||||
const token = await this.jwt.signAsync(payload, {
|
||||
secret: customerJwtSecret(),
|
||||
// jsonwebtoken types expiresIn as a template-literal union, which a plain
|
||||
// string from process.env does not satisfy — same cast AuthModule uses.
|
||||
expiresIn: envOr('JWT_CUSTOMER_EXPIRES_IN', '30d') as `${number}d`,
|
||||
});
|
||||
|
||||
if (jti) {
|
||||
await this.sessions.record({
|
||||
jti,
|
||||
subjectId: customerId,
|
||||
subjectType: 'customer',
|
||||
mobile,
|
||||
iamSessionId: context.identity?.sessionId ?? null,
|
||||
iamSub: context.identity?.sub ?? null,
|
||||
ipAddress: context.ip ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user