From 9869974d4c53a7e1062c80b35d90c7a29d8c632a Mon Sep 17 00:00:00 2001 From: liuyun Date: Sun, 11 Oct 2026 21:54:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E9=A6=96=E7=99=BB=E5=BC=BA?= =?UTF-8?q?=E5=88=B6=E6=94=B9=E5=AF=86=20+=20=E5=BE=85=E6=94=B9=E5=AF=86?= =?UTF-8?q?=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - User 新增 must_change_password;创建(默认强制)/重置/createTenant 置 true - 登录响应返回 mustChangePassword,改密成功清除 - JwtAuthGuard 待改密门禁:仅放行 PATCH /auth/me/password Co-Authored-By: Claude Opus 4.8 --- .../migration.sql | 2 ++ prisma/schema.prisma | 35 ++++++++++--------- src/auth/auth.service.ts | 32 +++++++++++------ src/auth/jwt-auth.guard.ts | 16 +++++++-- src/common/tenant-template.ts | 6 ++-- src/tenants/tenants.service.ts | 1 + src/users/dto.ts | 7 +++- src/users/users.service.ts | 6 ++-- 8 files changed, 69 insertions(+), 36 deletions(-) create mode 100644 prisma/migrations/20261011134951_force_password_change/migration.sql diff --git a/prisma/migrations/20261011134951_force_password_change/migration.sql b/prisma/migrations/20261011134951_force_password_change/migration.sql new file mode 100644 index 0000000..e98a98e --- /dev/null +++ b/prisma/migrations/20261011134951_force_password_change/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE `sys_user` ADD COLUMN `must_change_password` BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 47f6a6a..57b19cd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -26,23 +26,24 @@ model Tenant { /// 用户 model User { - id String @id @default(uuid(7)) @db.VarChar(36) - tenantCode String @map("tenant_code") @db.VarChar(64) - username String @db.VarChar(64) - passwordHash String @map("password_hash") @db.VarChar(255) - nickname String? @db.VarChar(64) - email String? @db.VarChar(128) - phone String? @db.VarChar(32) - avatar String? @db.VarChar(255) - status String @default("enabled") @db.VarChar(16) - tokenVersion Int @default(0) @map("token_version") - lockedAt DateTime? @map("locked_at") - lockReason String? @map("lock_reason") @db.VarChar(255) - orgCode String? @map("org_code") @db.VarChar(64) - createdAt DateTime @default(now()) @map("created_at") - createdBy String? @map("created_by") @db.VarChar(36) - updatedAt DateTime @updatedAt @map("updated_at") - deletedAt DateTime? @map("deleted_at") + id String @id @default(uuid(7)) @db.VarChar(36) + tenantCode String @map("tenant_code") @db.VarChar(64) + username String @db.VarChar(64) + passwordHash String @map("password_hash") @db.VarChar(255) + nickname String? @db.VarChar(64) + email String? @db.VarChar(128) + phone String? @db.VarChar(32) + avatar String? @db.VarChar(255) + status String @default("enabled") @db.VarChar(16) + 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) + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") @@unique([tenantCode, username]) @@index([tenantCode]) diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index a97fe18..c588da7 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -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 { + /** 校验用户当前是否可用(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 黑名单)。 */ diff --git a/src/auth/jwt-auth.guard.ts b/src/auth/jwt-auth.guard.ts index 58606ce..a0b3e47 100644 --- a/src/auth/jwt-auth.guard.ts +++ b/src/auth/jwt-auth.guard.ts @@ -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'); + } } diff --git a/src/common/tenant-template.ts b/src/common/tenant-template.ts index 57cd771..0dc8abc 100644 --- a/src/common/tenant-template.ts +++ b/src/common/tenant-template.ts @@ -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 { - 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 { 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. 绑定管理员到顶层角色 diff --git a/src/tenants/tenants.service.ts b/src/tenants/tenants.service.ts index 087240e..eef6411 100644 --- a/src/tenants/tenants.service.ts +++ b/src/tenants/tenants.service.ts @@ -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 } }); diff --git a/src/users/dto.ts b/src/users/dto.ts index c2aa975..a99c9be 100644 --- a/src/users/dto.ts +++ b/src/users/dto.ts @@ -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() diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 2c4e860..687f4d1 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -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); }