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")
lockedAt DateTime? @map("locked_at")
lockReason String? @map("lock_reason") @db.VarChar(255)
mustChangePassword Boolean @default(false) @map("must_change_password")
orgCode String? @map("org_code") @db.VarChar(64)
createdAt DateTime @default(now()) @map("created_at")
createdBy String? @map("created_by") @db.VarChar(36)

View File

@ -85,7 +85,7 @@ export class AuthService {
ip?: string,
userAgent?: string,
): Promise<
| { accessToken: string; refreshToken: string; expiresIn: number }
| { accessToken: string; refreshToken: string; expiresIn: number; mustChangePassword: boolean }
| { needSelectTenant: true; tenants: { tenantCode: string; tenantName: string }[] }
> {
await this.assertNotLocked(ip, dto.username);
@ -161,7 +161,7 @@ export class AuthService {
password: string,
ip?: 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 } });
if (!tenant || tenant.status !== 'enabled') {
await this.recordLoginLog(tenantCode, username, 'fail', '租户不存在或已停用', ip, userAgent);
@ -199,12 +199,13 @@ export class AuthService {
ip: ip ?? 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(),
loginAt: new Date(),
ip: ip ?? null,
userAgent: userAgent ?? null,
});
return { ...tokens, mustChangePassword: user.mustChangePassword };
}
/** 注册:在指定租户下创建普通用户(无角色,状态启用);禁止注册到平台租户。 */
@ -243,7 +244,7 @@ export class AuthService {
const passwordHash = await bcrypt.hash(dto.newPassword, 10);
await this.prisma.client.user.updateMany({
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.redis.del(permCacheKey(tenantCode, userId));
@ -402,24 +403,35 @@ export class AuthService {
return permCacheKey(tenantCode, userId);
}
/** 校验用户当前是否可用(status=enabled、未锁定且 tokenVersion 与签发时一致)。 */
async checkUserActive(tenantCode: string, userId: string, tokenVersion: number): Promise<boolean> {
/** 校验用户当前是否可用(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 }>(key);
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 },
select: { status: true, tokenVersion: true, lockedAt: true, mustChangePassword: true },
});
if (!user) return false;
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);
}
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 黑名单)。 */

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 { JwtService } from '@nestjs/jwt';
import { IS_PUBLIC_KEY } from './decorators.js';
@ -33,15 +33,18 @@ export class JwtAuthGuard implements CanActivate {
if (!token) throw new UnauthorizedException('未登录');
try {
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 (payload.sid && (await this.auth.isSessionRevoked(payload.sid))) {
throw new UnauthorizedException('会话已失效,请重新登录');
}
if (mustChangePassword && !this.isChangePassword(request)) {
throw new ForbiddenException('请先修改初始密码');
}
request.user = { userId: payload.sub, tenantCode: payload.tenantCode, username: payload.username } satisfies AuthUser;
return true;
} catch (e) {
if (e instanceof UnauthorizedException) throw e;
if (e instanceof UnauthorizedException || e instanceof ForbiddenException) throw e;
throw new UnauthorizedException('token 无效或已过期');
}
}
@ -52,4 +55,11 @@ export class JwtAuthGuard implements CanActivate {
const [type, token] = auth.split(' ');
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;
/** 是否包含平台级权限(tenant:*)——仅平台租户为 true。 */
includePlatformPerms: boolean;
/** 管理员是否需首登强制改密(随机密码场景为 true)。 */
forcePasswordChange?: boolean;
}
export interface SyncTenantResourcesOptions {
@ -179,7 +181,7 @@ export async function syncTenantResources(opts: SyncTenantResourcesOptions): Pro
/** 初始化一个租户:根组织、顶层角色、管理员、全量资源、内置字典、授权(幂等)。 */
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. 根组织
await prisma.org.upsert({
@ -200,7 +202,7 @@ export async function initTenant(opts: InitTenantOptions): Promise<void> {
const admin = await prisma.user.upsert({
where: { tenantCode_username: { tenantCode, username: adminUsername } },
update: {},
create: { tenantCode, username: adminUsername, passwordHash, nickname: '管理员', status: 'enabled', orgCode: tenantCode },
create: { tenantCode, username: adminUsername, passwordHash, nickname: '管理员', status: 'enabled', orgCode: tenantCode, mustChangePassword: forcePasswordChange },
});
// 4. 绑定管理员到顶层角色

View File

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

View File

@ -1,5 +1,5 @@
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 { UserStatus } from '../common/enums.js';
@ -15,6 +15,11 @@ export class CreateUserDto {
@MinLength(6, { message: '密码长度不能少于 6 位' })
password!: string;
@ApiPropertyOptional({ description: '首登强制改密(默认 true)' })
@IsOptional()
@IsBoolean()
mustChangePassword?: boolean;
@ApiPropertyOptional({ description: '昵称' })
@IsOptional()
@IsString()

View File

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