import { BadRequestException, ConflictException, 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, permCacheKey, userStateCacheKey } from '../common/constants.js'; const REFRESH_TTL = Number(process.env.JWT_REFRESH_TTL ?? 604800); const ACCESS_TTL = Number(process.env.JWT_ACCESS_TTL ?? 7200); const LOGIN_FAIL_PREFIX = 'loginFail:'; const MAX_LOGIN_FAILS = 5; 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 } | { needSelectTenant: true; tenants: { tenantCode: string; tenantName: string }[] } > { await this.assertNotLocked(dto.username); if (dto.tenantCode) { try { const result = await this.loginTenant(dto.tenantCode, dto.username, dto.password, ip, userAgent); await this.clearLoginFail(dto.username); return result; } catch (e) { if (e instanceof UnauthorizedException) await this.recordLoginFail(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 }, }); const matched: { tenantCode: string; tenantName: string }[] = []; for (const u of users) { if (u.status !== 'enabled') 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(dto.username); await this.recordLoginLog('', dto.username, 'fail', '账号或密码错误', ip, userAgent); throw new UnauthorizedException('账号或密码错误'); } await this.clearLoginFail(dto.username); if (matched.length === 1) { return this.loginTenant(matched[0].tenantCode, dto.username, dto.password, ip, userAgent); } return { needSelectTenant: true, tenants: matched }; } /** 登录失败锁定:连续失败超阈值后拒绝,直到窗口过期。 */ private async assertNotLocked(username: string): Promise { const count = (await this.redis.getJson(`${LOGIN_FAIL_PREFIX}${username}`)) ?? 0; if (count >= MAX_LOGIN_FAILS) { throw new UnauthorizedException('登录失败次数过多,请稍后再试'); } } private async recordLoginFail(username: string): Promise { const key = `${LOGIN_FAIL_PREFIX}${username}`; const count = ((await this.redis.getJson(key)) ?? 0) + 1; await this.redis.setJson(key, count, LOGIN_LOCK_SECONDS); } private async clearLoginFail(username: string): Promise { await this.redis.del(`${LOGIN_FAIL_PREFIX}${username}`); } /** 指定租户登录(单租户逻辑)。 */ private async loginTenant( tenantCode: string, username: string, password: string, ip?: string, userAgent?: string, ): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> { 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 UnauthorizedException('租户不存在或已停用'); } 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 UnauthorizedException('账号或密码错误'); } if (user.status !== 'enabled') { await this.recordLoginLog(tenantCode, username, 'fail', '账号已停用或锁定', ip, userAgent); throw new UnauthorizedException('账号已停用或锁定'); } const ok = await bcrypt.compare(password, user.passwordHash); if (!ok) { await this.recordLoginLog(tenantCode, username, 'fail', '账号或密码错误', ip, userAgent); throw new UnauthorizedException('账号或密码错误'); } 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, }); return this.issue(tenantCode, user.id, user.username, user.tokenVersion); } /** 注册:在指定租户下创建普通用户(无角色,状态启用)。 */ async register(dto: RegisterDto): Promise<{ id: string; username: string }> { 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 ConflictException('用户名已存在'); 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 BadRequestException('原密码错误'); const passwordHash = await bcrypt.hash(dto.newPassword, 10); await this.prisma.client.user.updateMany({ where: { tenantCode, id: userId }, data: { passwordHash, 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') 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); } 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); const isSuper = roleCodes.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, roleCodes); 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 { const key = userStateCacheKey(tenantCode, userId); let state = await this.redis.getJson<{ status: string; tokenVersion: number }>(key); if (!state) { const user = await this.prisma.client.user.findFirst({ where: { tenantCode, id: userId, deletedAt: null }, select: { status: true, tokenVersion: true }, }); if (!user) return false; state = { status: user.status, tokenVersion: user.tokenVersion }; await this.redis.setJson(key, state, ACCESS_TTL); } return state.status === 'enabled' && state.tokenVersion === tokenVersion; } /** 聚合权限码与超管标志。 */ 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, ): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> { const accessToken = this.jwt.sign({ sub: userId, tenantCode, username, ver: tokenVersion }); const jti = randomUUID(); await this.prisma.client.refreshToken.create({ data: { tenantCode, userId, jti, tokenHash: createHash('sha256').update(jti).digest('hex'), expireAt: new Date(Date.now() + REFRESH_TTL * 1000), }, }); return { accessToken, refreshToken: jti, expiresIn: ACCESS_TTL }; } }