feat(auth): 登录按用户名自动探测租户 + 多租户选择

- LoginDto tenantCode 改为可选,缺省按用户名跨租户探测
- 单匹配自动登录,多匹配返回候选租户
- 前端登录页新增租户选择弹窗

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-11 16:41:19 +08:00
parent 6578db51c7
commit 6553b4c5c7
2 changed files with 52 additions and 15 deletions

View File

@ -80,33 +80,70 @@ export class AuthService {
dto: LoginDto, dto: LoginDto,
ip?: string, ip?: string,
userAgent?: string, userAgent?: string,
): Promise<
| { accessToken: string; refreshToken: string; expiresIn: number }
| { needSelectTenant: true; tenants: { tenantCode: string; tenantName: string }[] }
> {
if (dto.tenantCode) {
return this.loginTenant(dto.tenantCode, dto.username, dto.password, ip, userAgent);
}
// 未指定租户:按用户名探测(一个用户名可存在于多个租户)
const users = await this.prisma.client.user.findMany({
where: { username: dto.username, deletedAt: null },
select: { tenantCode: true, passwordHash: true, status: true },
});
const matched: { tenantCode: string; tenantName: string }[] = [];
for (const u of users) {
if (u.status !== 'enabled') continue;
const tenant = await this.prisma.client.tenant.findUnique({ where: { code: u.tenantCode } });
if (!tenant || tenant.status !== 'enabled') continue;
if (await bcrypt.compare(dto.password, u.passwordHash)) {
matched.push({ tenantCode: u.tenantCode, tenantName: tenant.name });
}
}
if (matched.length === 0) {
await this.recordLoginLog('', dto.username, 'fail', '账号或密码错误', ip, userAgent);
throw new UnauthorizedException('账号或密码错误');
}
if (matched.length === 1) {
return this.loginTenant(matched[0].tenantCode, dto.username, dto.password, ip, userAgent);
}
return { needSelectTenant: true, tenants: matched };
}
/** 指定租户登录(单租户逻辑)。 */
private async loginTenant(
tenantCode: string,
username: string,
password: string,
ip?: string,
userAgent?: string,
): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> { ): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> {
const tenant = await this.prisma.client.tenant.findUnique({ where: { code: dto.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(dto.tenantCode, dto.username, 'fail', '租户不存在或已停用', ip, userAgent); await this.recordLoginLog(tenantCode, username, 'fail', '租户不存在或已停用', ip, userAgent);
throw new UnauthorizedException('租户不存在或已停用'); throw new UnauthorizedException('租户不存在或已停用');
} }
const user = await this.prisma.client.user.findFirst({ const user = await this.prisma.client.user.findFirst({
where: { tenantCode: dto.tenantCode, username: dto.username, deletedAt: null }, where: { tenantCode, username, deletedAt: null },
}); });
if (!user) { if (!user) {
await this.recordLoginLog(dto.tenantCode, dto.username, 'fail', '账号或密码错误', ip, userAgent); await this.recordLoginLog(tenantCode, username, 'fail', '账号或密码错误', ip, userAgent);
throw new UnauthorizedException('账号或密码错误'); throw new UnauthorizedException('账号或密码错误');
} }
if (user.status !== 'enabled') { if (user.status !== 'enabled') {
await this.recordLoginLog(dto.tenantCode, dto.username, 'fail', '账号已停用或锁定', ip, userAgent); await this.recordLoginLog(tenantCode, username, 'fail', '账号已停用或锁定', ip, userAgent);
throw new UnauthorizedException('账号已停用或锁定'); throw new UnauthorizedException('账号已停用或锁定');
} }
const ok = await bcrypt.compare(dto.password, user.passwordHash); const ok = await bcrypt.compare(password, user.passwordHash);
if (!ok) { if (!ok) {
await this.recordLoginLog(dto.tenantCode, dto.username, 'fail', '账号或密码错误', ip, userAgent); await this.recordLoginLog(tenantCode, username, 'fail', '账号或密码错误', ip, userAgent);
throw new UnauthorizedException('账号或密码错误'); throw new UnauthorizedException('账号或密码错误');
} }
await this.writePermissionCache(tenantCode, user.id);
await this.writePermissionCache(dto.tenantCode, user.id); await this.recordLoginLog(tenantCode, username, 'success', null, ip, userAgent);
await this.recordLoginLog(dto.tenantCode, dto.username, 'success', null, ip, userAgent);
await this.recordAudit({ await this.recordAudit({
tenantCode: dto.tenantCode, tenantCode,
userId: user.id, userId: user.id,
username: user.username, username: user.username,
module: 'auth', module: 'auth',
@ -115,7 +152,7 @@ export class AuthService {
ip: ip ?? null, ip: ip ?? null,
userAgent: userAgent ?? null, userAgent: userAgent ?? null,
}); });
return this.issue(dto.tenantCode, user.id, user.username, user.tokenVersion); return this.issue(tenantCode, user.id, user.username, user.tokenVersion);
} }
/** 注册:在指定租户下创建普通用户(无角色,状态启用)。 */ /** 注册:在指定租户下创建普通用户(无角色,状态启用)。 */

View File

@ -2,10 +2,10 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class LoginDto { export class LoginDto {
@ApiProperty({ description: '租户编码' }) @ApiPropertyOptional({ description: '租户编码(多租户二选一时传入;缺省按用户名自动探测)' })
@IsOptional()
@IsString() @IsString()
@IsNotEmpty() tenantCode?: string;
tenantCode!: string;
@ApiProperty({ description: '用户名' }) @ApiProperty({ description: '用户名' })
@IsString() @IsString()