sysmgr-nest/src/auth/auth.service.ts
liuyun 606585c3bd feat(security): 登录失败锁定 + 密码长度策略 + refresh token 哈希校验
- 登录连续失败 5 次锁定 15 分钟(Redis 计数)
- 密码字段 @MinLength(6)
- refresh 校验 tokenHash

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 16:46:13 +08:00

477 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<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 }
| { 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<void> {
const count = (await this.redis.getJson<number>(`${LOGIN_FAIL_PREFIX}${username}`)) ?? 0;
if (count >= MAX_LOGIN_FAILS) {
throw new UnauthorizedException('登录失败次数过多,请稍后再试');
}
}
private async recordLoginFail(username: string): Promise<void> {
const key = `${LOGIN_FAIL_PREFIX}${username}`;
const count = ((await this.redis.getJson<number>(key)) ?? 0) + 1;
await this.redis.setJson(key, count, LOGIN_LOCK_SECONDS);
}
private async clearLoginFail(username: string): Promise<void> {
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<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 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<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);
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<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<boolean> {
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<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,
): 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 };
}
}