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:
parent
86832ce32d
commit
9869974d4c
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `sys_user` ADD COLUMN `must_change_password` BOOLEAN NOT NULL DEFAULT false;
|
||||
@ -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])
|
||||
|
||||
@ -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 黑名单)。 */
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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. 绑定管理员到顶层角色
|
||||
|
||||
@ -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 } });
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user