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:
parent
a7f2008bc2
commit
97f8b7339f
|
|
@ -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('访问令牌无效或已过期');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue