- error-codes 单一注册表 + BusinessException + 过滤器识别 - 迁移登录/锁定/待改密/用户名冲突/租户/会话等关键错误 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
560 lines
22 KiB
TypeScript
560 lines
22 KiB
TypeScript
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<string, MenuNode>();
|
||
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<void> {
|
||
const perIp = (await this.redis.getJson<number>(this.lockKey(ip, username))) ?? 0;
|
||
const global = (await this.redis.getJson<number>(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<void> {
|
||
const perIpKey = this.lockKey(ip, username);
|
||
const globalKey = this.globalLockKey(username);
|
||
await this.redis.setJson(perIpKey, ((await this.redis.getJson<number>(perIpKey)) ?? 0) + 1, LOGIN_LOCK_SECONDS);
|
||
await this.redis.setJson(globalKey, ((await this.redis.getJson<number>(globalKey)) ?? 0) + 1, LOGIN_LOCK_SECONDS);
|
||
}
|
||
|
||
private async clearLoginFail(ip: string | undefined, username: string): Promise<void> {
|
||
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<null> {
|
||
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<null> {
|
||
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<PermissionSet> {
|
||
const cached = await this.redis.getJson<PermissionSet>(this.permKey(tenantCode, userId));
|
||
if (cached) return cached;
|
||
return this.writePermissionCache(tenantCode, userId);
|
||
}
|
||
|
||
/** 当前用户的菜单树(超管返回全部启用菜单,否则按角色可见菜单 + 补祖先)。 */
|
||
async getMenus(tenantCode: string, userId: string): Promise<MenuNode[]> {
|
||
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<MenuRow[]> {
|
||
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<RequestContext> {
|
||
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<boolean> {
|
||
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<PermissionSet> {
|
||
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<PermissionSet> {
|
||
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<void> {
|
||
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<void> {
|
||
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 };
|
||
}
|
||
}
|