import { BadRequestException, ConflictException, HttpStatus, Injectable, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import bcrypt from 'bcryptjs'; import { createHash, randomUUID } from 'node:crypto'; import { PrismaService } from '../prisma/prisma.service.js'; import { RedisService } from '../common/redis/redis.service.js'; import { LoginDto, RefreshDto, RegisterDto, ChangePasswordDto } from './dto.js'; import { resolveDataScope, resolveVisibleOrgs } from '../common/data-scope.js'; import type { DataScope } from '../common/enums.js'; import type { RequestContext } from '../common/context/request-context.js'; import { AuditLogsService } from '../audit-logs/audit-logs.service.js'; import { LoginLogsService } from '../login-logs/login-logs.service.js'; import { PLATFORM_ROLE_CODE as SUPER_ROLE, PLATFORM_TENANT_CODE, LOGIN_FAIL_PREFIX, permCacheKey, sessionRevokedKey, userStateCacheKey } from '../common/constants.js'; import { BusinessException } from '../common/business-exception.js'; import { ErrorCodes } from '../common/error-codes.js'; const REFRESH_TTL = Number(process.env.JWT_REFRESH_TTL ?? 604800); const ACCESS_TTL = Number(process.env.JWT_ACCESS_TTL ?? 7200); const MAX_LOGIN_FAILS = 5; const MAX_GLOBAL_LOGIN_FAILS = 20; const LOGIN_LOCK_SECONDS = 900; export interface PermissionSet { super: boolean; permissions: string[]; } interface MenuRow { id: string; code: string; name: string; parentCode: string | null; type: string; path: string | null; component: string | null; icon: string | null; sort: number; } export interface MenuNode extends MenuRow { children: MenuNode[]; } const MENU_SELECT = { id: true, code: true, name: true, parentCode: true, type: true, path: true, component: true, icon: true, sort: true, } as const; /** 由扁平菜单行构建树(按 sort + code 升序)。 */ function buildMenuTree(rows: MenuRow[]): MenuNode[] { const map = new Map(); for (const r of rows) map.set(r.code, { ...r, children: [] }); const roots: MenuNode[] = []; for (const node of map.values()) { const parent = node.parentCode ? map.get(node.parentCode) : undefined; if (parent) parent.children.push(node); else roots.push(node); } const sortRec = (nodes: MenuNode[]): void => { nodes.sort((a, b) => a.sort - b.sort || a.code.localeCompare(b.code)); for (const n of nodes) sortRec(n.children); }; sortRec(roots); return roots; } @Injectable() export class AuthService { constructor( private readonly prisma: PrismaService, private readonly jwt: JwtService, private readonly redis: RedisService, private readonly auditLogs: AuditLogsService, private readonly loginLogs: LoginLogsService, ) {} async login( dto: LoginDto, ip?: string, userAgent?: string, ): Promise< | { accessToken: string; refreshToken: string; expiresIn: number; mustChangePassword: boolean } | { needSelectTenant: true; tenants: { tenantCode: string; tenantName: string }[] } > { await this.assertNotLocked(ip, dto.username); if (dto.tenantCode) { try { const result = await this.loginTenant(dto.tenantCode, dto.username, dto.password, ip, userAgent); await this.clearLoginFail(ip, dto.username); return result; } catch (e) { if (e instanceof UnauthorizedException) await this.recordLoginFail(ip, dto.username); throw e; } } // 未指定租户:按用户名探测(一个用户名可存在于多个租户) const users = await this.prisma.client.user.findMany({ where: { username: dto.username, deletedAt: null }, select: { tenantCode: true, passwordHash: true, status: true, lockedAt: true }, }); const matched: { tenantCode: string; tenantName: string }[] = []; for (const u of users) { if (u.status !== 'enabled' || u.lockedAt) continue; const tenant = await this.prisma.client.tenant.findUnique({ where: { code: u.tenantCode } }); if (!tenant || tenant.status !== 'enabled') continue; if (await bcrypt.compare(dto.password, u.passwordHash)) { matched.push({ tenantCode: u.tenantCode, tenantName: tenant.name }); } } if (matched.length === 0) { await this.recordLoginFail(ip, dto.username); await this.recordLoginLog('', dto.username, 'fail', '账号或密码错误', ip, userAgent); throw new BusinessException(ErrorCodes.AUTH_CREDENTIALS, '账号或密码错误', HttpStatus.UNAUTHORIZED); } await this.clearLoginFail(ip, dto.username); if (matched.length === 1) { return this.loginTenant(matched[0].tenantCode, dto.username, dto.password, ip, userAgent); } return { needSelectTenant: true, tenants: matched }; } /** 登录失败锁定:双层阈值(IP+用户名 低阈值防单点爆破,用户名全局 高阈值防换 IP 爆破)。 */ private async assertNotLocked(ip: string | undefined, username: string): Promise { const perIp = (await this.redis.getJson(this.lockKey(ip, username))) ?? 0; const global = (await this.redis.getJson(this.globalLockKey(username))) ?? 0; if (perIp >= MAX_LOGIN_FAILS || global >= MAX_GLOBAL_LOGIN_FAILS) { throw new UnauthorizedException('登录失败次数过多,请稍后再试'); } } private async recordLoginFail(ip: string | undefined, username: string): Promise { const perIpKey = this.lockKey(ip, username); const globalKey = this.globalLockKey(username); await this.redis.setJson(perIpKey, ((await this.redis.getJson(perIpKey)) ?? 0) + 1, LOGIN_LOCK_SECONDS); await this.redis.setJson(globalKey, ((await this.redis.getJson(globalKey)) ?? 0) + 1, LOGIN_LOCK_SECONDS); } private async clearLoginFail(ip: string | undefined, username: string): Promise { await this.redis.del(this.lockKey(ip, username)); await this.redis.del(this.globalLockKey(username)); } private lockKey(ip: string | undefined, username: string): string { return `${LOGIN_FAIL_PREFIX}${ip ?? 'unknown'}:${username}`; } private globalLockKey(username: string): string { return `${LOGIN_FAIL_PREFIX}global:${username}`; } /** 指定租户登录(单租户逻辑)。 */ private async loginTenant( tenantCode: string, username: string, password: string, ip?: string, userAgent?: string, ): Promise<{ accessToken: string; refreshToken: string; expiresIn: number; mustChangePassword: boolean }> { const tenant = await this.prisma.client.tenant.findUnique({ where: { code: tenantCode } }); if (!tenant || tenant.status !== 'enabled') { await this.recordLoginLog(tenantCode, username, 'fail', '租户不存在或已停用', ip, userAgent); throw new BusinessException(ErrorCodes.TENANT_DISABLED, '租户不存在或已停用', HttpStatus.UNAUTHORIZED); } const user = await this.prisma.client.user.findFirst({ where: { tenantCode, username, deletedAt: null }, }); if (!user) { await this.recordLoginLog(tenantCode, username, 'fail', '账号或密码错误', ip, userAgent); throw new BusinessException(ErrorCodes.AUTH_CREDENTIALS, '账号或密码错误', HttpStatus.UNAUTHORIZED); } if (user.lockedAt) { await this.recordLoginLog(tenantCode, username, 'fail', '账号已锁定', ip, userAgent); throw new BusinessException(ErrorCodes.AUTH_LOCKED, '账号已锁定', HttpStatus.UNAUTHORIZED); } if (user.status !== 'enabled') { await this.recordLoginLog(tenantCode, username, 'fail', '账号已停用', ip, userAgent); throw new BusinessException(ErrorCodes.AUTH_DISABLED, '账号已停用', HttpStatus.UNAUTHORIZED); } const ok = await bcrypt.compare(password, user.passwordHash); if (!ok) { await this.recordLoginLog(tenantCode, username, 'fail', '账号或密码错误', ip, userAgent); throw new BusinessException(ErrorCodes.AUTH_CREDENTIALS, '账号或密码错误', HttpStatus.UNAUTHORIZED); } await this.writePermissionCache(tenantCode, user.id); await this.recordLoginLog(tenantCode, username, 'success', null, ip, userAgent); await this.recordAudit({ tenantCode, userId: user.id, username: user.username, module: 'auth', action: 'login', status: 'success', ip: ip ?? null, userAgent: userAgent ?? null, }); const tokens = await this.issue(tenantCode, user.id, user.username, user.tokenVersion, { sid: randomUUID(), loginAt: new Date(), ip: ip ?? null, userAgent: userAgent ?? null, }); return { ...tokens, mustChangePassword: user.mustChangePassword }; } /** 注册:在指定租户下创建普通用户(无角色,状态启用);禁止注册到平台租户。 */ async register(dto: RegisterDto): Promise<{ id: string; username: string }> { if (dto.tenantCode === PLATFORM_TENANT_CODE) { throw new BadRequestException('平台租户不允许注册'); } const tenant = await this.prisma.client.tenant.findUnique({ where: { code: dto.tenantCode } }); if (!tenant || tenant.status !== 'enabled') throw new BadRequestException('租户不存在或已停用'); const exists = await this.prisma.client.user.findFirst({ where: { tenantCode: dto.tenantCode, username: dto.username, deletedAt: null }, }); if (exists) throw new BusinessException(ErrorCodes.USER_USERNAME_EXISTS, '用户名已存在', HttpStatus.CONFLICT); const passwordHash = await bcrypt.hash(dto.password, 10); const user = await this.prisma.client.user.create({ data: { tenantCode: dto.tenantCode, username: dto.username, passwordHash, email: dto.email ?? null, phone: dto.phone ?? null, status: 'enabled', }, }); return { id: user.id, username: user.username }; } /** 修改本人密码:校验原密码,更新后吊销其它会话。 */ async changePassword(tenantCode: string, userId: string, dto: ChangePasswordDto): Promise { const user = await this.prisma.client.user.findFirst({ where: { tenantCode, id: userId, deletedAt: null }, }); if (!user) throw new UnauthorizedException('账号不存在'); const ok = await bcrypt.compare(dto.oldPassword, user.passwordHash); if (!ok) throw new BusinessException(ErrorCodes.AUTH_OLD_PWD_WRONG, '原密码错误', HttpStatus.BAD_REQUEST); const passwordHash = await bcrypt.hash(dto.newPassword, 10); await this.prisma.client.user.updateMany({ where: { tenantCode, id: userId }, data: { passwordHash, mustChangePassword: false, tokenVersion: { increment: 1 } }, }); await this.prisma.client.refreshToken.deleteMany({ where: { tenantCode, userId } }); await this.redis.del(permCacheKey(tenantCode, userId)); await this.redis.del(userStateCacheKey(tenantCode, userId)); return null; } async refresh(dto: RefreshDto): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> { const record = await this.prisma.client.refreshToken.findUnique({ where: { jti: dto.refreshToken } }); const tokenHash = createHash('sha256').update(dto.refreshToken).digest('hex'); if (!record || record.tokenHash !== tokenHash || record.revokedAt || record.expireAt.getTime() < Date.now()) { throw new UnauthorizedException('refresh token 无效或已过期'); } const user = await this.prisma.client.user.findFirst({ where: { tenantCode: record.tenantCode, id: record.userId, deletedAt: null }, }); if (!user || user.status !== 'enabled' || user.lockedAt) throw new UnauthorizedException('账号已失效'); // 旋转:作废旧 refresh,并重聚合权限集(权限变更在刷新时生效) await this.prisma.client.refreshToken.update({ where: { jti: dto.refreshToken }, data: { revokedAt: new Date() }, }); await this.writePermissionCache(record.tenantCode, user.id); return this.issue(record.tenantCode, user.id, user.username, user.tokenVersion, { sid: record.sid ?? randomUUID(), loginAt: record.loginAt, ip: record.ip, userAgent: record.userAgent, }); } async logout(dto: RefreshDto, ip?: string, userAgent?: string): Promise { const record = await this.prisma.client.refreshToken.findUnique({ where: { jti: dto.refreshToken } }); if (record) { await this.prisma.client.refreshToken.update({ where: { jti: dto.refreshToken }, data: { revokedAt: new Date() }, }); await this.redis.del(this.permKey(record.tenantCode, record.userId)); const user = await this.prisma.client.user.findUnique({ where: { id: record.userId } }); await this.recordAudit({ tenantCode: record.tenantCode, userId: record.userId, username: user?.username ?? null, module: 'auth', action: 'logout', status: 'success', ip: ip ?? null, userAgent: userAgent ?? null, }); } return null; } /** 读取用户权限集;缓存缺失时重新聚合并回填(Redis 重启自愈)。 */ async getPermissionSet(tenantCode: string, userId: string): Promise { const cached = await this.redis.getJson(this.permKey(tenantCode, userId)); if (cached) return cached; return this.writePermissionCache(tenantCode, userId); } /** 当前用户的菜单树(超管返回全部启用菜单,否则按角色可见菜单 + 补祖先)。 */ async getMenus(tenantCode: string, userId: string): Promise { const roleRows = await this.prisma.client.userRole.findMany({ where: { tenantCode, userId }, select: { roleCode: true }, }); const roleCodes = roleRows.map((r) => r.roleCode); // 过滤已删除角色(与 resolveAuth 保持一致) const activeRoles = roleCodes.length ? await this.prisma.client.role.findMany({ where: { tenantCode, code: { in: roleCodes }, deletedAt: null }, select: { code: true }, }) : []; const activeRoleCodes = activeRoles.map((r) => r.code); const isSuper = activeRoleCodes.includes(SUPER_ROLE); const rows = isSuper ? await this.prisma.client.resource.findMany({ where: { tenantCode, type: 'menu', status: 'enabled', deletedAt: null }, orderBy: { sort: 'asc' }, select: MENU_SELECT, }) : await this.menusForRoles(tenantCode, activeRoleCodes); return buildMenuTree(rows as MenuRow[]); } /** 非超管:角色可访问的菜单 + 其祖先(保证目录可达)。 */ private async menusForRoles(tenantCode: string, roleCodes: string[]): Promise { if (!roleCodes.length) return []; const roleResourceRows = await this.prisma.client.roleResource.findMany({ where: { tenantCode, roleCode: { in: roleCodes } }, select: { resourceCode: true }, }); const codes = [...new Set(roleResourceRows.map((r) => r.resourceCode))]; if (!codes.length) return []; const accessible = await this.prisma.client.resource.findMany({ where: { tenantCode, code: { in: codes }, type: 'menu', status: 'enabled', deletedAt: null }, select: MENU_SELECT, }); const all = await this.prisma.client.resource.findMany({ where: { tenantCode, type: 'menu', deletedAt: null }, select: MENU_SELECT, }); const byCode = new Map(all.map((r) => [r.code, r])); const needed = new Set(accessible.map((r) => r.code)); for (const r of [...accessible]) { let cur = r.parentCode ? byCode.get(r.parentCode) : undefined; while (cur && !needed.has(cur.code)) { needed.add(cur.code); cur = cur.parentCode ? byCode.get(cur.parentCode) : undefined; } } return all.filter((r) => needed.has(r.code)) as MenuRow[]; } /** 由 RequestContextInterceptor 调用,构建当前请求的租户/用户/数据权限上下文。 */ async buildContext(user: { userId: string; tenantCode: string; username: string }): Promise { const u = await this.prisma.client.user.findFirst({ where: { tenantCode: user.tenantCode, id: user.userId, deletedAt: null }, select: { orgCode: true }, }); const orgCode = u?.orgCode ?? null; const roleRows = await this.prisma.client.userRole.findMany({ where: { tenantCode: user.tenantCode, userId: user.userId }, select: { roleCode: true }, }); const roleCodes = roleRows.map((r) => r.roleCode); const isSuper = roleCodes.includes(SUPER_ROLE); const roles = roleCodes.length ? await this.prisma.client.role.findMany({ where: { tenantCode: user.tenantCode, code: { in: roleCodes }, deletedAt: null }, select: { dataScope: true }, }) : []; const dataScope: DataScope = isSuper ? 'all' : resolveDataScope(roles.map((r) => r.dataScope as DataScope)); const orgCodes = isSuper ? [] : await resolveVisibleOrgs({ prisma: this.prisma.client, tenantCode: user.tenantCode, dataScope, orgCode, roleCodes, }); return { tenantCode: user.tenantCode, userId: user.userId, username: user.username, dataScope, orgCode, orgCodes }; } private permKey(tenantCode: string, userId: string): string { return permCacheKey(tenantCode, userId); } /** 校验用户当前是否可用(status=enabled、未锁定、tokenVersion 一致),并返回是否待改密。 */ async checkUserActive( tenantCode: string, userId: string, tokenVersion: number, ): Promise<{ active: boolean; mustChangePassword: boolean }> { const key = userStateCacheKey(tenantCode, userId); let state = await this.redis.getJson<{ status: string; tokenVersion: number; lockedAt: string | null; mustChangePassword: boolean; }>(key); if (!state) { const user = await this.prisma.client.user.findFirst({ where: { tenantCode, id: userId, deletedAt: null }, select: { status: true, tokenVersion: true, lockedAt: true, mustChangePassword: true }, }); if (!user) return { active: false, mustChangePassword: false }; state = { status: user.status, tokenVersion: user.tokenVersion, lockedAt: user.lockedAt ? user.lockedAt.toISOString() : null, mustChangePassword: user.mustChangePassword, }; await this.redis.setJson(key, state, ACCESS_TTL); } const active = state.status === 'enabled' && state.tokenVersion === tokenVersion && !state.lockedAt; return { active, mustChangePassword: state.mustChangePassword }; } /** 判断会话是否已被强退(sid 黑名单)。 */ async isSessionRevoked(sid: string): Promise { return (await this.redis.getJson(sessionRevokedKey(sid))) != null; } /** 当前租户品牌(登录后前端拉取应用)。 */ async getBrand(tenantCode: string): Promise<{ name: string; brandName: string | null; logoUrl: string | null; themeColor: string | null; copyright: string | null; }> { const tenant = await this.prisma.client.tenant.findUnique({ where: { code: tenantCode } }); if (!tenant) throw new UnauthorizedException('租户不存在'); return { name: tenant.name, brandName: tenant.brandName, logoUrl: tenant.logoUrl, themeColor: tenant.themeColor, copyright: tenant.copyright, }; } /** 聚合权限码与超管标志。 */ private async resolveAuth(tenantCode: string, userId: string): Promise { const roleRows = await this.prisma.client.userRole.findMany({ where: { tenantCode, userId }, select: { roleCode: true }, }); const roleCodes = roleRows.map((r) => r.roleCode); // 过滤已删除角色(软删角色不参与权限聚合) const activeRoles = roleCodes.length ? await this.prisma.client.role.findMany({ where: { tenantCode, code: { in: roleCodes }, deletedAt: null }, select: { code: true }, }) : []; const activeRoleCodes = activeRoles.map((r) => r.code); const isSuper = activeRoleCodes.includes(SUPER_ROLE); const resourceRows = activeRoleCodes.length ? await this.prisma.client.roleResource.findMany({ where: { tenantCode, roleCode: { in: activeRoleCodes } }, select: { resourceCode: true }, }) : []; const resourceCodes = [...new Set(resourceRows.map((r) => r.resourceCode))]; const permissions: string[] = resourceCodes.length ? ( await this.prisma.client.resource.findMany({ where: { tenantCode, code: { in: resourceCodes }, type: { in: ['button', 'api'] }, status: 'enabled', deletedAt: null }, select: { code: true }, }) ).map((r) => r.code) : []; return { super: isSuper, permissions }; } /** 聚合并写入权限缓存(登录/刷新时调用)。 */ private async writePermissionCache(tenantCode: string, userId: string): Promise { const set = await this.resolveAuth(tenantCode, userId); await this.redis.setJson(this.permKey(tenantCode, userId), set, REFRESH_TTL); return set; } private async recordLoginLog( tenantCode: string, username: string, status: string, message: string | null, ip?: string, userAgent?: string, ): Promise { await this.loginLogs.record({ tenantCode, username, status, message, ip: ip ?? null, userAgent: userAgent ?? null, }); } private async recordAudit(entry: { tenantCode: string; userId: string | null; username: string | null; module: string; action: string; status: string; ip?: string | null; userAgent?: string | null; }): Promise { await this.auditLogs.record(entry); } private async issue( tenantCode: string, userId: string, username: string, tokenVersion: number, session: { sid: string; loginAt: Date; ip?: string | null; userAgent?: string | null }, ): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> { const accessToken = this.jwt.sign({ sub: userId, tenantCode, username, ver: tokenVersion, sid: session.sid }); const jti = randomUUID(); await this.prisma.client.refreshToken.create({ data: { tenantCode, userId, sid: session.sid, jti, tokenHash: createHash('sha256').update(jti).digest('hex'), ip: session.ip ?? null, userAgent: session.userAgent ?? null, loginAt: session.loginAt, expireAt: new Date(Date.now() + REFRESH_TTL * 1000), }, }); return { accessToken, refreshToken: jti, expiresIn: ACCESS_TTL }; } }