fix(auth): LOGIN 能力禁用后强制下线已登录用户

## 问题
管理员在后台禁用用户的 LOGIN 能力后,该用户仍然可以正常使用 mining-app。
原因是 LOGIN 检查只在登录/刷新 token 时执行,已持有有效 JWT(7天有效期)
的用户不会被影响,直到 token 过期才会被拦截。

## 修复

### 后端 - JwtAuthGuard (auth-service)
- 在 JWT 验证通过后,增加 LOGIN 能力实时检查
- 调用 CapabilityService.isCapabilityEnabled() 查询 Redis 缓存
- LOGIN 被禁用时返回 403 ForbiddenException("您的账户已被限制登录")
- 采用 fail-open 策略:Redis/DB 查询失败时放行,不影响正常用户
- 每次认证请求多一次 Redis GET(<1ms),对当前用户规模无性能影响

### 前端 - mining-app API Client
- 新增 onLoginDisabled 全局回调(类似现有的 onUnauthorized)
- Dio 拦截器检测 403 响应中包含"限制登录"关键词时触发回调
- 回调执行:清除用户状态 + 跳转登录页(与 401 处理一致)

## 影响范围
- 所有使用 @UseGuards(JwtAuthGuard) 的端点都会实时检查 LOGIN 能力
- 管理员禁用 LOGIN 后,用户下一次 API 请求即被拦截并强制下线
- 不影响公开端点(登录、注册等不经过 JwtAuthGuard 的接口)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-28 05:01:37 -08:00
parent a7f2008bc2
commit 97f8b7339f
3 changed files with 49 additions and 0 deletions

View File

@ -3,15 +3,22 @@ import {
CanActivate,
ExecutionContext,
UnauthorizedException,
ForbiddenException,
Logger,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { CapabilityService } from '@/application/services/capability.service';
import { Capability } from '@/domain/value-objects/capability.vo';
@Injectable()
export class JwtAuthGuard implements CanActivate {
private readonly logger = new Logger(JwtAuthGuard.name);
constructor(
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
private readonly capabilityService: CapabilityService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
@ -34,8 +41,29 @@ export class JwtAuthGuard implements CanActivate {
source: payload.source,
};
// 检查 LOGIN 能力 — 禁用后立即阻断所有已认证请求(强制下线)
try {
const loginEnabled = await this.capabilityService.isCapabilityEnabled(
payload.sub,
Capability.LOGIN,
);
if (!loginEnabled) {
throw new ForbiddenException('您的账户已被限制登录,请联系客服');
}
} catch (error) {
// 如果是我们主动抛出的 ForbiddenException直接向上传递
if (error instanceof ForbiddenException) {
throw error;
}
// 能力查询失败时放行fail-open避免影响正常用户
this.logger.warn(`LOGIN 能力检查失败 (${payload.sub}): ${error?.message}`);
}
return true;
} catch (error) {
if (error instanceof ForbiddenException) {
throw error;
}
throw new UnauthorizedException('访问令牌无效或已过期');
}
}

View File

@ -12,6 +12,8 @@ class ApiClient {
// 401
static void Function()? onUnauthorized;
// 403 LOGIN 线
static void Function(String message)? onLoginDisabled;
ApiClient({required this.dio}) {
dio.options = BaseOptions(
@ -40,6 +42,15 @@ class ApiClient {
if (error.response?.statusCode == 401) {
onUnauthorized?.call();
}
// 403 LOGIN 线
if (error.response?.statusCode == 403) {
final data = error.response?.data;
final errorObj = data is Map ? data['error'] : null;
final errorMsg = errorObj is Map ? errorObj['message'] : null;
if (errorMsg is String && errorMsg.contains('限制登录')) {
onLoginDisabled?.call(errorMsg);
}
}
handler.next(error);
},
));

View File

@ -38,6 +38,8 @@ class _MiningAppState extends ConsumerState<MiningApp> {
super.initState();
// 401
ApiClient.onUnauthorized = _handleUnauthorized;
// 403 LOGIN 线
ApiClient.onLoginDisabled = _handleLoginDisabled;
}
void _handleUnauthorized() {
@ -48,6 +50,14 @@ class _MiningAppState extends ConsumerState<MiningApp> {
router.go(Routes.login);
}
void _handleLoginDisabled(String message) {
// 401 线
ref.read(userNotifierProvider.notifier).logout();
//
final router = ref.read(appRouterProvider);
router.go(Routes.login);
}
@override
Widget build(BuildContext context) {
final router = ref.watch(appRouterProvider);