fix some for iam

This commit is contained in:
Montech
2026-10-07 16:45:36 +03:30
parent a72fa607a5
commit 5223de313a
25 changed files with 823 additions and 38 deletions
+155
View File
@@ -1,3 +1,4 @@
import { createHash, createHmac, randomBytes } from 'node:crypto';
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
@@ -9,6 +10,7 @@ import {
type IamIdentity,
} from '../iam/iam.service';
import { AdminSessionService } from '../sessions/admin-session.service';
import { envList, envOr, envOrNull, pickExactUri } from '../../common/env';
import type { LoginDto } from './dto/login.dto';
@Injectable()
@@ -73,6 +75,130 @@ export class AuthService {
});
}
const bootstrap = this.iam.browserBootstrapUrl(verified.identity?.browserTicket);
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 })),
...(bootstrap ? { iamBootstrapUrl: bootstrap } : {}),
};
}
beginBrowserSso(requestedRedirect?: string): { authorizeUrl: string; cookie: string } | null {
if (!this.iam.enabled) return null;
const redirectUri = pickExactUri(requestedRedirect, [
envOrNull('IAM_ADMIN_REDIRECT_URI'),
...envList('IAM_ADMIN_REDIRECT_URIS'),
]);
const clientId = envOrNull('IAM_CLIENT_ID');
if (!redirectUri || !clientId) return null;
const state = randomBytes(24).toString('base64url');
const nonce = randomBytes(24).toString('base64url');
const verifier = randomBytes(48).toString('base64url');
const challenge = createHash('sha256').update(verifier).digest('base64url');
const payload = Buffer.from(JSON.stringify({ state, nonce, verifier, redirectUri })).toString('base64url');
const sig = createHmac('sha256', this.ssoSecret()).update(payload).digest('base64url');
const secure = redirectUri.startsWith('https:');
const cookie =
`gb_admin_iam_sso=${payload}.${sig}; HttpOnly; Path=/; Max-Age=180; SameSite=Lax` +
(secure ? '; Secure' : '');
const url = new URL(`${this.iam.issuer}/oauth/authorize`);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', clientId);
url.searchParams.set('redirect_uri', redirectUri);
url.searchParams.set('scope', 'openid profile email');
url.searchParams.set('state', state);
url.searchParams.set('nonce', nonce);
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');
url.searchParams.set('prompt', 'none');
return { authorizeUrl: url.toString(), cookie };
}
async completeBrowserSso(cookieValue: string | null, code: string, state: string, ip?: string, userAgent?: string) {
const pending = this.readSsoCookie(cookieValue);
if (!pending || pending.state !== state) {
throw new UnauthorizedException('درخواست ورود مشترک معتبر نیست');
}
const claims = await this.iam.exchangeAuthorizationCode(code, pending.verifier, pending.redirectUri);
if (!claims.phoneNumber) {
throw new UnauthorizedException('هویت مشترک شماره موبایل ندارد');
}
const user = await this.prisma.adminUser.findFirst({
where: { mobile: { in: mobileVariants(claims.phoneNumber) }, isActive: true },
include: { userType: { include: { permissions: true } } },
});
if (!user) {
throw new UnauthorizedException('حساب مدیری با این شماره در این سامانه نیست');
}
const identity: IamIdentity = {
sub: claims.sub,
email: null,
phoneNumber: claims.phoneNumber,
firstName: null,
lastName: null,
sessionId: claims.sessionId,
browserTicket: null,
};
return this.issueAdminToken(user, identity, ip, userAgent);
}
centralLogoutUrl(requestedReturn?: string): string | null {
if (!this.iam.enabled) return null;
const redirect = pickExactUri(requestedReturn, [
envOrNull('IAM_ADMIN_POST_LOGOUT_REDIRECT_URI'),
envOrNull('IAM_ADMIN_BROWSER_RETURN_URL'),
...envList('IAM_ADMIN_ALLOWED_RETURN_URLS'),
]);
const clientId = envOrNull('IAM_CLIENT_ID');
if (!redirect || !clientId) return null;
const url = new URL(`${this.iam.issuer}/oauth/logout`);
url.searchParams.set('client_id', clientId);
url.searchParams.set('post_logout_redirect_uri', redirect);
return url.toString();
}
private async issueAdminToken(
user: {
id: string;
fullName: string;
mobile: string;
role: string;
userTypeId: number | null;
branchId: string | null;
userType: { permissions: { rid: string }[] } | null;
},
identity: IamIdentity | undefined,
ip?: string,
userAgent?: string,
) {
const jti = this.sessions.newTokenId();
const apiToken = await this.jwt.signAsync({
sub: user.id,
mobile: user.mobile,
...(jti ? { jti } : {}),
});
if (jti) {
await this.sessions.record({
jti,
adminUserId: user.id,
iamSessionId: identity?.sessionId ?? null,
iamSub: identity?.sub ?? null,
ipAddress: ip ?? null,
userAgent: userAgent ?? null,
});
}
return {
id: user.id,
name: user.fullName,
@@ -86,6 +212,27 @@ export class AuthService {
};
}
private ssoSecret(): string {
return envOrNull('JWT_SECRET') || envOr('IAM_CLIENT_SECRET', 'iam-sso');
}
private readSsoCookie(value: string | null): { state: string; verifier: string; redirectUri: string } | null {
if (!value || !value.includes('.')) return null;
const split = value.lastIndexOf('.');
const payload = value.slice(0, split);
const sig = value.slice(split + 1);
const expected = createHmac('sha256', this.ssoSecret()).update(payload).digest('base64url');
if (expected.length !== sig.length) return null;
let same = 0;
for (let i = 0; i < expected.length; i++) same |= expected.charCodeAt(i) ^ sig.charCodeAt(i);
if (same !== 0) return null;
try {
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
} catch {
return null;
}
}
/**
* Password check, optionally delegated to the central identity service.
*
@@ -168,3 +315,11 @@ export class AuthService {
};
}
}
function mobileVariants(raw: string): string[] {
const digits = raw.replace(/\D/g, '');
const variants = new Set<string>([raw.trim(), digits]);
if (digits.startsWith('98') && digits.length >= 12) variants.add(`0${digits.slice(2)}`);
if (digits.startsWith('0') && digits.length === 11) variants.add(`98${digits.slice(1)}`);
return [...variants].filter((value) => value !== '');
}