feat(auth): 首登强制改密 + 待改密门禁

- User 新增 must_change_password;创建(默认强制)/重置/createTenant 置 true
- 登录响应返回 mustChangePassword,改密成功清除
- JwtAuthGuard 待改密门禁:仅放行 PATCH /auth/me/password

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-11 21:54:38 +08:00
parent 86832ce32d
commit 9869974d4c
8 changed files with 69 additions and 36 deletions

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `sys_user` ADD COLUMN `must_change_password` BOOLEAN NOT NULL DEFAULT false;

View File

@ -38,6 +38,7 @@ model User {
tokenVersion Int @default(0) @map("token_version") tokenVersion Int @default(0) @map("token_version")
lockedAt DateTime? @map("locked_at") lockedAt DateTime? @map("locked_at")
lockReason String? @map("lock_reason") @db.VarChar(255) lockReason String? @map("lock_reason") @db.VarChar(255)
mustChangePassword Boolean @default(false) @map("must_change_password")
orgCode String? @map("org_code") @db.VarChar(64) orgCode String? @map("org_code") @db.VarChar(64)
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
createdBy String? @map("created_by") @db.VarChar(36) createdBy String? @map("created_by") @db.VarChar(36)

View File

@ -85,7 +85,7 @@ export class AuthService {
ip?: string, ip?: string,
userAgent?: string, userAgent?: string,
): Promise< ): Promise<
| { accessToken: string; refreshToken: string; expiresIn: number } | { accessToken: string; refreshToken: string; expiresIn: number; mustChangePassword: boolean }
| { needSelectTenant: true; tenants: { tenantCode: string; tenantName: string }[] } | { needSelectTenant: true; tenants: { tenantCode: string; tenantName: string }[] }
> { > {
await this.assertNotLocked(ip, dto.username); await this.assertNotLocked(ip, dto.username);
@ -161,7 +161,7 @@ export class AuthService {
password: string, password: string,
ip?: string, ip?: string,
userAgent?: string, userAgent?: string,
): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> { ): Promise<{ accessToken: string; refreshToken: string; expiresIn: number; mustChangePassword: boolean }> {
const tenant = await this.prisma.client.tenant.findUnique({ where: { code: tenantCode } }); const tenant = await this.prisma.client.tenant.findUnique({ where: { code: tenantCode } });
if (!tenant || tenant.status !== 'enabled') { if (!tenant || tenant.status !== 'enabled') {
await this.recordLoginLog(tenantCode, username, 'fail', '租户不存在或已停用', ip, userAgent); await this.recordLoginLog(tenantCode, username, 'fail', '租户不存在或已停用', ip, userAgent);
@ -199,12 +199,13 @@ export class AuthService {
ip: ip ?? null, ip: ip ?? null,
userAgent: userAgent ?? null, userAgent: userAgent ?? null,
}); });
return this.issue(tenantCode, user.id, user.username, user.tokenVersion, { const tokens = await this.issue(tenantCode, user.id, user.username, user.tokenVersion, {
sid: randomUUID(), sid: randomUUID(),
loginAt: new Date(), loginAt: new Date(),
ip: ip ?? null, ip: ip ?? null,
userAgent: userAgent ?? null, userAgent: userAgent ?? null,
}); });
return { ...tokens, mustChangePassword: user.mustChangePassword };
} }
/** 注册:在指定租户下创建普通用户(无角色,状态启用);禁止注册到平台租户。 */ /** 注册:在指定租户下创建普通用户(无角色,状态启用);禁止注册到平台租户。 */
@ -243,7 +244,7 @@ export class AuthService {
const passwordHash = await bcrypt.hash(dto.newPassword, 10); const passwordHash = await bcrypt.hash(dto.newPassword, 10);
await this.prisma.client.user.updateMany({ await this.prisma.client.user.updateMany({
where: { tenantCode, id: userId }, where: { tenantCode, id: userId },
data: { passwordHash, tokenVersion: { increment: 1 } }, data: { passwordHash, mustChangePassword: false, tokenVersion: { increment: 1 } },
}); });
await this.prisma.client.refreshToken.deleteMany({ where: { tenantCode, userId } }); await this.prisma.client.refreshToken.deleteMany({ where: { tenantCode, userId } });
await this.redis.del(permCacheKey(tenantCode, userId)); await this.redis.del(permCacheKey(tenantCode, userId));
@ -402,24 +403,35 @@ export class AuthService {
return permCacheKey(tenantCode, userId); return permCacheKey(tenantCode, userId);
} }
/** 校验用户当前是否可用(status=enabled、未锁定且 tokenVersion 与签发时一致)。 */ /** 校验用户当前是否可用(status=enabled、未锁定、tokenVersion 一致),并返回是否待改密。 */
async checkUserActive(tenantCode: string, userId: string, tokenVersion: number): Promise<boolean> { async checkUserActive(
tenantCode: string,
userId: string,
tokenVersion: number,
): Promise<{ active: boolean; mustChangePassword: boolean }> {
const key = userStateCacheKey(tenantCode, userId); const key = userStateCacheKey(tenantCode, userId);
let state = await this.redis.getJson<{ status: string; tokenVersion: number; lockedAt: string | null }>(key); let state = await this.redis.getJson<{
status: string;
tokenVersion: number;
lockedAt: string | null;
mustChangePassword: boolean;
}>(key);
if (!state) { if (!state) {
const user = await this.prisma.client.user.findFirst({ const user = await this.prisma.client.user.findFirst({
where: { tenantCode, id: userId, deletedAt: null }, where: { tenantCode, id: userId, deletedAt: null },
select: { status: true, tokenVersion: true, lockedAt: true }, select: { status: true, tokenVersion: true, lockedAt: true, mustChangePassword: true },
}); });
if (!user) return false; if (!user) return { active: false, mustChangePassword: false };
state = { state = {
status: user.status, status: user.status,
tokenVersion: user.tokenVersion, tokenVersion: user.tokenVersion,
lockedAt: user.lockedAt ? user.lockedAt.toISOString() : null, lockedAt: user.lockedAt ? user.lockedAt.toISOString() : null,
mustChangePassword: user.mustChangePassword,
}; };
await this.redis.setJson(key, state, ACCESS_TTL); await this.redis.setJson(key, state, ACCESS_TTL);
} }
return state.status === 'enabled' && state.tokenVersion === tokenVersion && !state.lockedAt; const active = state.status === 'enabled' && state.tokenVersion === tokenVersion && !state.lockedAt;
return { active, mustChangePassword: state.mustChangePassword };
} }
/** 判断会话是否已被强退(sid 黑名单)。 */ /** 判断会话是否已被强退(sid 黑名单)。 */

View File

@ -1,4 +1,4 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; import { CanActivate, ExecutionContext, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { IS_PUBLIC_KEY } from './decorators.js'; import { IS_PUBLIC_KEY } from './decorators.js';
@ -33,15 +33,18 @@ export class JwtAuthGuard implements CanActivate {
if (!token) throw new UnauthorizedException('未登录'); if (!token) throw new UnauthorizedException('未登录');
try { try {
const payload = await this.jwt.verifyAsync<{ sub: string; tenantCode: string; username: string; ver?: number; sid?: string }>(token); const payload = await this.jwt.verifyAsync<{ sub: string; tenantCode: string; username: string; ver?: number; sid?: string }>(token);
const active = await this.auth.checkUserActive(payload.tenantCode, payload.sub, payload.ver ?? 0); const { active, mustChangePassword } = await this.auth.checkUserActive(payload.tenantCode, payload.sub, payload.ver ?? 0);
if (!active) throw new UnauthorizedException('账号已停用或登录已失效'); if (!active) throw new UnauthorizedException('账号已停用或登录已失效');
if (payload.sid && (await this.auth.isSessionRevoked(payload.sid))) { if (payload.sid && (await this.auth.isSessionRevoked(payload.sid))) {
throw new UnauthorizedException('会话已失效,请重新登录'); throw new UnauthorizedException('会话已失效,请重新登录');
} }
if (mustChangePassword && !this.isChangePassword(request)) {
throw new ForbiddenException('请先修改初始密码');
}
request.user = { userId: payload.sub, tenantCode: payload.tenantCode, username: payload.username } satisfies AuthUser; request.user = { userId: payload.sub, tenantCode: payload.tenantCode, username: payload.username } satisfies AuthUser;
return true; return true;
} catch (e) { } catch (e) {
if (e instanceof UnauthorizedException) throw e; if (e instanceof UnauthorizedException || e instanceof ForbiddenException) throw e;
throw new UnauthorizedException('token 无效或已过期'); throw new UnauthorizedException('token 无效或已过期');
} }
} }
@ -52,4 +55,11 @@ export class JwtAuthGuard implements CanActivate {
const [type, token] = auth.split(' '); const [type, token] = auth.split(' ');
return type === 'Bearer' && token ? token : undefined; return type === 'Bearer' && token ? token : undefined;
} }
/** 待改密门禁白名单:仅「修改本人密码」(PATCH /auth/me/password)。 */
private isChangePassword(request: { method?: string; originalUrl?: string; url?: string }): boolean {
if (request.method !== 'PATCH') return false;
const url: string = request.originalUrl ?? request.url ?? '';
return url.split('?')[0].endsWith('/auth/me/password');
}
} }

View File

@ -110,6 +110,8 @@ export interface InitTenantOptions {
adminPassword: string; adminPassword: string;
/** 是否包含平台级权限(tenant:*)——仅平台租户为 true。 */ /** 是否包含平台级权限(tenant:*)——仅平台租户为 true。 */
includePlatformPerms: boolean; includePlatformPerms: boolean;
/** 管理员是否需首登强制改密(随机密码场景为 true)。 */
forcePasswordChange?: boolean;
} }
export interface SyncTenantResourcesOptions { export interface SyncTenantResourcesOptions {
@ -179,7 +181,7 @@ export async function syncTenantResources(opts: SyncTenantResourcesOptions): Pro
/** 初始化一个租户:根组织、顶层角色、管理员、全量资源、内置字典、授权(幂等)。 */ /** 初始化一个租户:根组织、顶层角色、管理员、全量资源、内置字典、授权(幂等)。 */
export async function initTenant(opts: InitTenantOptions): Promise<void> { export async function initTenant(opts: InitTenantOptions): Promise<void> {
const { prisma, tenantCode, tenantName, adminRoleCode, adminRoleName, adminUsername, adminPassword, includePlatformPerms } = opts; const { prisma, tenantCode, tenantName, adminRoleCode, adminRoleName, adminUsername, adminPassword, includePlatformPerms, forcePasswordChange = false } = opts;
// 1. 根组织 // 1. 根组织
await prisma.org.upsert({ await prisma.org.upsert({
@ -200,7 +202,7 @@ export async function initTenant(opts: InitTenantOptions): Promise<void> {
const admin = await prisma.user.upsert({ const admin = await prisma.user.upsert({
where: { tenantCode_username: { tenantCode, username: adminUsername } }, where: { tenantCode_username: { tenantCode, username: adminUsername } },
update: {}, update: {},
create: { tenantCode, username: adminUsername, passwordHash, nickname: '管理员', status: 'enabled', orgCode: tenantCode }, create: { tenantCode, username: adminUsername, passwordHash, nickname: '管理员', status: 'enabled', orgCode: tenantCode, mustChangePassword: forcePasswordChange },
}); });
// 4. 绑定管理员到顶层角色 // 4. 绑定管理员到顶层角色

View File

@ -42,6 +42,7 @@ export class TenantsService extends BaseCrudService {
adminUsername: dto.adminUsername ?? 'admin', adminUsername: dto.adminUsername ?? 'admin',
adminPassword, adminPassword,
includePlatformPerms: false, includePlatformPerms: false,
forcePasswordChange: true,
}); });
}); });
const tenant = await this.db().findUnique({ where: { code } }); const tenant = await this.db().findUnique({ where: { code } });

View File

@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsIn, IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator'; import { IsArray, IsBoolean, IsIn, IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator';
import { PageQuery } from '../common/page.dto.js'; import { PageQuery } from '../common/page.dto.js';
import { UserStatus } from '../common/enums.js'; import { UserStatus } from '../common/enums.js';
@ -15,6 +15,11 @@ export class CreateUserDto {
@MinLength(6, { message: '密码长度不能少于 6 位' }) @MinLength(6, { message: '密码长度不能少于 6 位' })
password!: string; password!: string;
@ApiPropertyOptional({ description: '首登强制改密(默认 true)' })
@IsOptional()
@IsBoolean()
mustChangePassword?: boolean;
@ApiPropertyOptional({ description: '昵称' }) @ApiPropertyOptional({ description: '昵称' })
@IsOptional() @IsOptional()
@IsString() @IsString()

View File

@ -18,8 +18,8 @@ export class UsersService extends BaseCrudService {
async createUser(dto: CreateUserDto) { async createUser(dto: CreateUserDto) {
const passwordHash = await bcrypt.hash(dto.password, 10); const passwordHash = await bcrypt.hash(dto.password, 10);
const { password: _pw, ...rest } = dto; const { password: _pw, mustChangePassword = true, ...rest } = dto;
const created = await this.create({ ...rest, passwordHash }); const created = await this.create({ ...rest, passwordHash, mustChangePassword });
return this.omitPasswordHash(created); return this.omitPasswordHash(created);
} }
@ -70,7 +70,7 @@ export class UsersService extends BaseCrudService {
async resetPassword(id: string, dto: ResetPasswordDto) { async resetPassword(id: string, dto: ResetPasswordDto) {
const passwordHash = await bcrypt.hash(dto.password, 10); const passwordHash = await bcrypt.hash(dto.password, 10);
const updated = await this.update(id, { passwordHash }); const updated = await this.update(id, { passwordHash, mustChangePassword: true });
await this.invalidateSessions(id); await this.invalidateSessions(id);
return this.omitPasswordHash(updated); return this.omitPasswordHash(updated);
} }