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;
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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 黑名单)。 */
|
||||||
|
|||||||
@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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. 绑定管理员到顶层角色
|
||||||
|
|||||||
@ -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 } });
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user