几十个接口都要先回答”当前用户是谁、有没有权限做这件事”,鉴权逻辑的组织方式直接决定代码会不会失控。起初直接在 Controller 里手写 if-else 判断登录态、判断角色,代码很快就变得又长又碎,更糟的是容易漏校验——有一次联调时发现某个管理接口没做角色校验,普通用户登录后居然能直接调用。当时我把方案对比了一圈:Spring Security 功能全但配置重,默认拦截链对我们这种”登录+角色”两档的中小型平台来说负担过大;Shiro 的注解也够用,但要引入整个权限框架,学习成本不低。最后我选择自研 @AuthCheck 注解配合 AOP 切面,把”当前用户能做什么”集中成一段可复用的横切逻辑,一百多行代码就把问题收干净了。下面把落地思路、关键代码和踩过的坑完整记下来,给同样在做中小型平台鉴权的人一个参考。
一、设计目标
动手写注解之前,我先列了一张”每个接口都在重复写什么”的清单,发现重复逻辑集中在三件事上:① 校验登录态(token 解析后确认用户是否存在);② 校验角色权限(普通用户/管理员/超级管理员);③ 把当前登录用户注入方法参数,让业务代码直接 CurrentUser.get(),省掉每个接口重复 request.getAttribute("user") 的样板代码。设计目标就定为:用一个注解同时覆盖这三件事。
三件事单独拿出来都不难,难点在于组合在一起,而且对业务代码零侵入。我也评估过用拦截器(Interceptor)实现的方案:拦截器能拿到 request 和路径,但角色判断得写一堆路径白名单,新增一个接口忘了加规则就漏了;路径正则写多了之后,谁配了什么规则根本记不住。对比下来,注解方式的收益很直接——权限要求和接口声明放在一起,代码审查时一眼就能看出哪个接口谁能调、谁不能调。
方案上线前我做了一次小验证:拿线上最典型的十个接口,分别用手写校验和注解两种方式实现,对比代码量。手写版平均每个接口多二三十行重复代码,十个接口就多出两百多行;注解版统一收进切面,业务代码零增量。这个数据坚定了我往下做的信心——不止是省事,更是把”会不会漏校验”从人肉保证变成框架保证。
二、注解定义
先定义注解本身,这一步解决”用什么标记一个接口需要鉴权”的问题:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthCheck {
/** 必填角色,可多个 */
String[] mustRole() default {};
}
这里只保留一个属性 mustRole,保持简单。注意它是数组而不是单个字符串,这样能天然表达”任一角色通过即可”的语义,比如 {"admin","superAdmin"} 表示管理员或超管都能调。如果未来需要”必须同时具备多个角色”的语义,再扩展一个 allRole 属性也不迟,当前平台只有 user/admin/superAdmin 三档角色,数组已经够用。
定义阶段还有个容易被忽略的细节:@Retention 必须配成 RUNTIME。早期我写过一次默认的 CLASS 保留策略,切面在运行期拿不到注解信息,拦截直接失效,联调时发现所有接口都放行,排查了半天才定位到这里。这个坑虽小却值得记一笔,AOP 配合自定义注解属于典型的”定义容易、生效难”,四件套(Target、Retention、注解声明、切面签名)少一环都不转。
三、AOP 拦截实现
注解只是声明,真正干活的是切面。这段代码解决”token 解析、角色校验、用户注入如何串成一条完整链路”的问题,骨架如下:
@Around("@annotation(authCheck)")
public Object check(ProceedingJoinPoint pjp, AuthCheck authCheck) throws Throwable {
HttpServletRequest req = ((ServletRequestAttributes)
RequestContextHolder.getRequestAttributes()).getRequest();
String token = req.getHeader("token");
ThrowUtils.throwIf(StringUtils.isBlank(token), ErrorCode.NOT_LOGIN_ERROR);
// 1) 解析 token 拿 userId
long userId;
try { userId = JwtUtil.parse(token); }
catch (Exception e) { throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR, "token失效"); }
// 2) 查用户角色
User user = userService.getById(userId);
ThrowUtils.throwIf(user == null, ErrorCode.NOT_LOGIN_ERROR);
// 3) 校验角色
String need = user.getUserRole();
if (authCheck.mustRole().length > 0) {
boolean ok = Arrays.stream(authCheck.mustRole())
.anyMatch(r -> r.equals(need));
ThrowUtils.throwIf(!ok, ErrorCode.NO_AUTH_ERROR);
}
// 4) 通过 ThreadLocal 注入用户
CurrentUser.set(user);
Object res = pjp.proceed();
CurrentUser.clear();
return res;
}
这段代码的走向很清晰:token 解析失败、用户不存在、角色不匹配分别抛 NOTLOGINERROR、NOTLOGINERROR、NOAUTHERROR,错误码各不相同,前端可以据此给出不同的跳转提示。校验通过后把用户塞进 ThreadLocal,业务方法里直接 CurrentUser.get() 就能拿到登录人。需要提醒一点:CurrentUser.clear() 放在 proceed() 之后,如果业务方法抛异常就会跳过这行,线程被复用时下一个请求会拿到上一个用户的身份,所以生产环境必须用 try-finally 包住 proceed(),保证任何路径都清理。
四、关键设计取舍
方案落地过程中,每个环节都有候选方案,取舍标准只有一个:平台现状下哪种更简单可靠。下面从五个维度记录当时的选择:
| 决策 | 方案 | 为什么这样选 |
|---|---|---|
| 拦截方式 | AOP 注解 | 业务代码零侵入,新增接口只挂注解 |
| 登录态来源 | Header token |
平台统一用 Session+Redis,前端用 Authorization 头携带 |
| 用户上下文 | ThreadLocal | 一个请求一个线程,Controller/Service 层直接 CurrentUser.get() |
| 异常处理 | 抛 BusinessException | 与全局异常处理器联动,鉴权失败返回统一 JSON 格式 |
| 角色粒度 | 枚举字符串 | 避免过度设计,平台只有 user/admin/superAdmin 三档 |
这几项里争议比较大的是”登录态来源”。当时有同事建议把用户对象直接放进 session,靠 Spring 的 HttpSession 拿登录人,简单是简单,但网关做负载均衡后要开 sticky 会话,后续扩展分布式部署会被卡住。现在统一走 token + Redis,网关无状态,加节点不用改任何配置。角色粒度选枚举字符串而不是数据库动态角色表,是因为平台角色固定三档,动态表反而把简单问题复杂化,还多一层缓存同步的麻烦。
五、典型使用
定义和切面都就绪后,业务侧的接入方式非常简单。我们把它总结成三步,新同事照着做就能独立接一个受保护的接口:
- 在方法上加
@AuthCheck,按接口语义填mustRole角色数组; - 方法体里通过
CurrentUser.get()拿当前登录用户,不再直接读 request; - 自测时分别用未登录、无权限、有权限三个账号各调一次,确认三档错误码都正确。
下面这段是实际效果示例,解决”新接口鉴权成本高”的问题:
@PostMapping("/model/add")
@AuthCheck(mustRole = {"admin", "superAdmin"})
public BaseResponse<Long> addModel(@RequestBody ModelAddRequest req) {
// 不用再写 if 校验,业务直接拿当前用户
User u = CurrentUser.get();
return ResultUtils.success(modelService.add(req, u.getId()));
}
一个接口一行注解,controller 里完全看不到鉴权代码。跑通后新接口的接入成本从原来平均半小时降到一分钟以内,代码 review 也轻松很多——先看有没有 @AuthCheck,再确认角色列表对不对,权限缺口基本一眼能发现。对我们团队来说,更大的变化是新人接手接口开发时不用再问”这个接口要不要鉴权”,看注解就知道,规则写在声明里,比任何文档都可靠。
六、踩过的坑
方案上线后,真正的考验才开始。下面这四处坑是平台运行过程中陆续踩到的,每一个都值得记一笔:
- 切面优先级:因为限流注解
@RateLimit也用了 AOP,要把@AuthCheck切面设成更高的优先级(数值更小),先鉴权再限流,否则已鉴权的请求被限流后白白浪费 token 解析。 - ThreadLocal 清理:方法返回后必须
clear(),否则线程被复用时下一个请求会拿到上一个用户的身份,这是这类拦截器特别容易出 bug 的地方。 - 内部方法调用失效:同类内
this.xxx()不走 AOP,注解就失效了,要么改成注入自身 bean 调用,要么把鉴权逻辑下沉到 Service。 - Redis Session 失效:token 解析成功但 Redis 中 session 已过期,应视为”未登录”而不是”无权限”,给前端可重新登录的机会。
这些坑补完之后,鉴权模块基本稳定下来,线上没再出过权限相关的安全事故。回头看,自研注解方案的维护成本比想象中低——核心代码就是切面那一百多行,剩下全是业务侧的零散使用,出了问题翻日志、看错误码就能定位。如果哪天平台角色模型复杂到需要动态权限表,再切换框架也不迟,现有注解层的语义和错误码都可以平滑迁移。
常见问题(FAQ)
Q1:为什么不直接用 Spring Security?
Spring Security 配置复杂、默认拦截链长,对这种只有”登录+角色”两档的中小型平台来说太重,自研注解+ AOP 一百行代码搞定。
Q2:mustRole 是数组的意义?
兼容”任一角色通过即可”的语义,例如 {"admin","superAdmin"} 表示管理员或超管都能调,比写两个注解更直观。
Q3:ThreadLocal 为什么要在 finally 里 clear?
保证异常路径也清理,避免线程复用导致身份串号。生产环境用 try-finally 包裹 proceed() 是必须的。