采云台 · M4 权限(角色鉴权)· 深度分析

对应第三、四部分(完整执行链路 + 为什么这么设计)及第五、六、七部分中 M4 相关内容
依据:backend/cai-yun-tai 实际源码逐行核验;mvn -o -DskipTests compile 退出码 0
一句话定位:M4 的设计骨架(认证在拦截器、授权在切面,两者职责分离)是清晰且站得住的;但它的默认策略是”未标注即放行”,而且所有管理端读接口都没有角色限制——这导致一个具体的信息泄露:采购专员可以拉取全部员工的身份证号。


零、先给出准确的覆盖率数字(不要用旧文档的数字)

我把管理端每个 Controller 的写接口和 @RequireRole 逐条对了一遍:

Controller写接口数(@Post/@Put/@Delete)@RequireRole 数写接口覆盖
GoodsController44✅ 100%
ComboController44✅ 100%
CategoryController44✅ 100%
DepartmentController33(+3 个读接口给 {ADMIN,PURCHASER})✅ 100%
OrderController55✅ 100%
EmployeeController3(不含 login/logout)3✅ 100%
BudgetController22(+1 个读接口给 {ADMIN,PURCHASER})✅ 100%
CommonController11✅ 100%
ShopController11✅ 100%
WorkSpaceController00—
合计2731(含 4 个读接口的保护)写接口 27/27 = 100%

结论修正:仓库里的《Redis 改造复核清单》写的是「27/28 写接口已覆盖(仅 logout 未加,合理)」。我实测是 27/27 全覆盖——logout 不属于需要角色限制的写接口(它只删自己请求头里那个 token)。这个数字要说准确,否则被要求现场核对时会尴尬。

同时给出另一边的事实:管理端 15 个读接口全部无注解(按设计放行),另外 login 通过拦截器 excludePathPatterns 排除。


一、业务场景:为什么需要”角色”而不是”两套账号”

1.1 三种角色的真实职责

角色常量端能做什么不能做什么
普通员工 EMPLOYEERoleConstant.EMPLOYEE员工端 /user/**浏览物资/组合包、加入申领车、提交申领单、查自己的单、催办、确认收货、发起退换登录管理端(被 EmployeeServiceImpl.login 显式拒绝)
采购专员 PURCHASERRoleConstant.PURCHASER管理端 /admin/**审批通过/驳回、取消、发货、核销;查看部门列表、预算列表、申领单维护基础数据(物资、分类、组合包、部门、预算额度、员工账号);上传文件;改采购开关
行政管理员 ADMINRoleConstant.ADMIN管理端 /admin/**全部权限—

1.2 为什么用 role 字段而不是两张表 / 两套账号

【原始方案】员工表 employee 管管理端,用户表 user 管客户端(原苍穹外卖就是这样:employee 是管理端员工,user 是微信用户)。

【原始方案的问题】在采云台的场景下行不通:

  • 采云台的”客户端用户”就是公司员工本人——他既要在员工端提申领单,也可能是采购专员要在管理端审批;
  • 如果用两张表,同一个人要有两个账号、两套密码、两份部门归属,还要处理”这两条记录是同一个人”的映射问题;
  • 权限判断会变成”先看在哪张表,再看角色”,逻辑分叉。

【当前方案】一张 employee 表 + role 字段,两端共用同一套账号密码。user 表保留但不使用(migration_office_supplies.sql 里用注释标记为「【已废弃】原微信用户表,身份已合并至 employee,仅保留历史数据」)。

证据:User 实体类仍然存在于 caiyuntai-pojo,但全项目零引用(无 Mapper、无 Service 使用),是留档性死代码。

1.3 端隔离与角色分离是两件事(这是理解 M4 的关键)

              ┌──────────────────────────────────────────┐
              │          同一张 employee 表               │
              │   username / password / role / dept_id    │
              └──────────────────────────────────────────┘
                                  │
        ┌─────────────────────────┴─────────────────────────┐
        │                                                    │
   【端隔离】认证层                                    【角色分离】授权层
   两套 JWT 密钥 + 两个请求头                           @RequireRole + RoleAspect
        │                                                    │
   ┌────▼─────┐                                        ┌─────▼──────┐
   │ 管理端    │ token 请求头 / adminSecretKey = itcast  │ EMPLOYEE   │ 被 login 拒绝
   │ /admin/** │ 拦截器额外要求 role != EMPLOYEE          │ PURCHASER  │ 只能审批/发货/核销
   └──────────┘                                        │ ADMIN      │ 全部权限
   ┌──────────┐                                        └────────────┘
   │ 员工端    │ authentication 请求头 / userSecretKey = itheima
   │ /user/**  │ 拦截器只要求账号未停用(任何角色都能进)
   └──────────┘

注意一个容易讲错的点:拦截器里的 role != EMPLOYEE 检查属于端隔离(“你这种人根本不该进这个端”),不是功能级授权(“你能不能点这个按钮”)。真正的功能授权在切面里。面试时说清这个区分,比说”拦截器做了权限校验”要准确。


二、技术实现:三个组件

2.1 自定义注解

@Target({ElementType.METHOD, ElementType.TYPE})      // 可标方法、可标类
@Retention(RetentionPolicy.RUNTIME)                  // 运行时可反射读取
public @interface RequireRole {
    String[] value();                                // ★ 无默认值 = 使用时必须传
}

两个设计点:

  1. String[] 而不是单个 String —— 支持”任一满足即放行”。例如部门列表允许 {ADMIN, PURCHASER} 都能看;
  2. @Target 包含 TYPE —— 允许标在类上做”整类默认”,方法上可覆盖。但当前项目没有任何一个类使用类级标注(31 处全部是方法级)。

2.2 切面

@Aspect
@Component
@Slf4j
public class RoleAspect {
 
    @Before("@annotation(com.caiyuntai.annotation.RequireRole) || @within(com.caiyuntai.annotation.RequireRole)")
    public void checkRole(JoinPoint joinPoint) {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
 
        // ① 方法上的注解优先,没有再看类上的
        RequireRole requireRole = method.getAnnotation(RequireRole.class);
        if (requireRole == null) {
            requireRole = method.getDeclaringClass().getAnnotation(RequireRole.class);
        }
 
        // ② ★ 没有注解 → 直接放行(默认放行策略)
        if (requireRole == null) {
            return;
        }
 
        // ③ ★ 注解值为空数组 → 也直接放行(隐蔽陷阱)
        String[] allowed = requireRole.value();
        if (allowed.length == 0) {
            return;
        }
 
        // ④ 取当前角色
        String currentRole = BaseContext.getCurrentRole();
        if (currentRole == null) {
            // 没有角色信息 = 请求没经过登录拦截器 → 按未登录处理
            log.warn("角色校验失败:未获取到登录角色,方法={}", method.getName());
            throw new BaseException(MessageConstant.USER_NOT_LOGIN);
        }
 
        // ⑤ anyMatch:任一角色满足即放行
        boolean pass = Arrays.stream(allowed).anyMatch(role -> role.equals(currentRole));
        if (!pass) {
            log.warn("角色校验失败:当前角色={},允许角色={},方法={}",
                    currentRole, Arrays.toString(allowed), method.getName());
            throw new BaseException(MessageConstant.NO_PERMISSION);
        }
    }
}

切点表达式的含义(要能解释清楚):

表达式含义
@annotation(com.caiyuntai.annotation.RequireRole)匹配执行方法上标注了该注解的连接点
@within(com.caiyuntai.annotation.RequireRole)匹配目标类上标注了该注解的连接点
A || B两者任一命中即触发通知

为什么要合并两个表达式? 因为 @annotation 只管方法级、@within 只管类级。要让”方法级 + 类级”两种用法都生效,必须用 || 组合。这是一个容易被忽略的细节——只写 @annotation 的话,类级标注会静默失效。

2.3 角色上下文(BaseContext 的第二个 ThreadLocal)

public class BaseContext {
    public static ThreadLocal<Long>   threadLocal     = new ThreadLocal<>();  // 当前登录人 id
    public static ThreadLocal<String> roleThreadLocal = new ThreadLocal<>();  // 当前登录人角色
 
    public static void setCurrentRole(String role) { roleThreadLocal.set(role); }
    public static String getCurrentRole()           { return roleThreadLocal.get(); }
 
    public static void clear() {          // ← 拦截器 afterCompletion 调用
        threadLocal.remove();
        roleThreadLocal.remove();
    }
}

为什么角色要单独放一个 ThreadLocal 而不是查出来传参?

  • 与 currentId 同理:避免把登录上下文一路当方法参数往下传(Service、切面、Mapper 切面都要用);
  • 但要意识到代价:ThreadLocal 是隐式参数,让方法的依赖关系变得不可见——RoleAspect 读 BaseContext,但它的方法签名上没有任何提示。这是”M4 用起来干净但排错困难”的根源,也是后文 M4-7(异步线程里失效)的伏笔。

三、完整代码执行链路

★★★ 链路 1:认证与授权的完整顺序(M4 的核心,必须能画出来)

采云台-M4链路1逐步拆解

以”采购专员尝试新增物资(越权)“为例——这是最能体现分层设计的一条链路,因为它会走到授权层才被拒绝:

PUT /admin/goods
Header: token = {采购专员 PURCHASER 的 JWT}
Body: {...}

┌─【第 1 层】Servlet 容器 (Tomcat)
│    解析 HTTP 请求,交给 Spring MVC 的 DispatcherServlet
│
├─【第 2 层】HandlerMapping:找到目标 Handler
│    匹配到 GoodsController.update(GoodsDTO),包装成 HandlerMethod
│
├─【第 3 层】★ HandlerInterceptor.preHandle()  ← 认证(只管"是否登录")
│    执行 JwtTokenAdminInterceptor.preHandle:
│    ├─ handler instanceof HandlerMethod ? → 是
│    ├─ token = request.getHeader("token")            ← 从请求头取
│    ├─ JwtUtil.parseJWT(adminSecretKey="itcast", token)
│    │     ├─ 验签(HS256)失败 → 抛异常 → 401
│    │     └─ 过期 → 抛 ExpiredJwtException → 401
│    ├─ empId = claims.get("empId")                   ← 取工号
│    ├─ Redis GET login:token:{token}                 ← ★ 白名单校验
│    │     返回 null(已登出 / 被踢)→ response.setStatus(401); return false
│    ├─ employee = employeeMapper.getById(empId)      ← ★ 查库(每次请求都查!)
│    │     ├─ employee == null           → 401
│    │     ├─ role == "EMPLOYEE"         → 401        ← ★ 端隔离:普通员工禁入管理端
│    │     └─ status == DISABLE(0)       → 401        ← ★ 账号停用即时失效
│    ├─ BaseContext.setCurrentId(empId)               ← 写入 ThreadLocal
│    └─ BaseContext.setCurrentRole("PURCHASER")       ← ★ 写入角色,供切面使用
│    return true  → 放行
│
├─【第 4 层】HandlerAdapter:参数解析
│    ├─ @RequestBody GoodsDTO → JacksonObjectMapper 反序列化(自定义日期格式)
│    └─ ⚠️ 注意:JSON body 在这一层已经被反序列化了
│
├─【第 5 层】★★ Spring AOP:RoleAspect.checkRole()  ← 授权(管"能不能做这件事")
│    ├─ 切点匹配:GoodsController.update 上有 @RequireRole(ADMIN) 吗?
│    │     @annotation 命中 → 进入通知
│    ├─ method.getAnnotation(RequireRole.class) → @RequireRole(ADMIN)
│    ├─ allowed = ["ADMIN"]
│    ├─ currentRole = BaseContext.getCurrentRole() → "PURCHASER"
│    ├─ anyMatch("ADMIN".equals("PURCHASER")) → false
│    ├─ log.warn("角色校验失败:当前角色=PURCHASER,允许角色=[ADMIN],方法=update")
│    └─ throw new BaseException(MessageConstant.NO_PERMISSION)     ← "无操作权限"
│
├─【第 6 层】异常传播 + 全局异常处理
│    BaseException extends RuntimeException      ← ★ 运行时异常
│      ↓ 未被捕获,冒泡到 DispatcherServlet
│    GlobalExceptionHandler(@RestControllerAdvice)
│      @ExceptionHandler(BaseException ex)
│        → log.error(...); return Result.error(ex.getMessage())
│      ↓
│    响应:HTTP 200,body = {"code":0, "msg":"无操作权限", "data":null}
│    ⚠️ 注意是 HTTP 200 而不是 403 —— 业务错误码放在 body 里
│
└─【第 7 层】★ HandlerInterceptor.afterCompletion()  ← 清理 ThreadLocal
      BaseContext.clear()      ← 无论成功、失败、异常都会执行

这一条链路里有 7 个可以直接展开的面试点:

#面试点要点
1为什么认证和授权分两层认证在拦截器(能拿到 HandlerMethod,粒度是”请求”);授权在切面(能反射读方法/类注解,粒度是”方法”,还能支持方法级覆盖类级)。职责分离
2拦截器里为什么每次都查库为了拿到 role(JWT 里只放了 empId)并实时校验 status。代价是每次请求一次主键查询,好处是禁用账号能立即生效。优化方向:把 role/status 也放进 JWT,或缓存到 Redis——但那样就失去了实时性
3为什么 role == EMPLOYEE 要在拦截器里拦这是端隔离,不是功能授权。EmployeeServiceImpl.login 已经拦了一道(普通员工不允许登录管理端),拦截器这道是防御”管理端 token 被降级角色的员工复用”——双保险
4授权在参数解析之后JSON body 已经反序列化了。影响:越权请求的 body 仍会被解析(浪费一点 CPU),但控制器方法绝对不会执行。要在参数解析前拦,得放进拦截器的 preHandle —— 这是 M4-6 要讨论的取舍
5越权返回 HTTP 200 还是 403当前是 200 + code:0。这是项目的统一约定(所有业务错误都是 200 + code:0)。要说清这是约定,不是疏漏;但严格说”认证失败 401、授权失败 200”是不一致的
6BaseException 为什么能被全局处理器捕获它 extends RuntimeException,所以不受”受检异常”的限制,能穿过所有方法签名冒泡到 @RestControllerAdvice。对比:userCancelById 等方法声明了 throws Exception,那些受检异常不会被 GlobalExceptionHandler 捕获(它只声明了两个 handler)→ 会走 Spring 默认的 500
7afterCompletion 为什么必须调用 clear()Tomcat 复用线程。如果不清,下一个请求如果没走拦截器(比如静态资源、doc.html),getCurrentId() 会读到上一个请求的身份。这正是原苍穹外卖项目的已知缺陷——它写了 BaseContext.removeCurrentId() 但从未调用

链路 2:普通员工尝试登录管理端(端隔离,被拒绝在认证之前)

POST /admin/employee/login
Body: {"username":"zhangsan", "password":"123456"}      ← zhangsan 是 EMPLOYEE

   ↓ 【拦截器不生效】
     WebMvcConfiguration 里:admin 拦截器 excludePathPatterns("/admin/employee/login")
     ★ 这是唯一被豁免的管理端路径

   ↓ EmployeeController.login(EmployeeLoginDTO)
   ↓ EmployeeServiceImpl.login(dto)                        ← 第 49 行
        ├─ checkLogin(dto)                                ← 账号密码 + 状态校验
        │    ├─ Redis GET login:fail:zhangsan             ← 失败次数 ≥ 5 → 抛 AccountLockedException
        │    ├─ employeeMapper.getByUsername("zhangsan")
        │    ├─ password = DigestUtils.md5DigestAsHex(...) ← 无盐 MD5
        │    ├─ 不匹配 → incrLoginFail → 抛 PasswordErrorException
        │    ├─ status == DISABLE → 抛 AccountLockedException
        │    └─ 成功 → Redis DEL login:fail:zhangsan
        └─ if (RoleConstant.EMPLOYEE.equals(employee.getRole()))
               throw new AccountLockedException(MessageConstant.NO_ADMIN_PERMISSION)
                  ← ★ "当前账号无权登录管理端"
   ↓ GlobalExceptionHandler → Result.error("当前账号无权登录管理端")

对照:同一套账号打 POST /user/user/login → staffLogin() → 不做角色检查 → 登录成功

这里有一个设计上的细节值得讲:拒绝普通员工登录管理端时,抛的是 AccountLockedException(“账号被锁定”这个异常类),但消息是 NO_ADMIN_PERMISSION(“当前账号无权登录管理端”)。

  • 异常类的语义和消息不匹配——用了”账号锁定”的异常类型来表达”无权限”;
  • 影响:如果以后有人按异常类型做分支处理(比如”账号锁定就提示联系管理员”),会误判;
  • 面试被问到时的诚实回答:「这里复用了 AccountLockedException,语义上应该是 NoPermissionException 之类。因为项目里没有为’无权限登录’定义专门的异常类,我当时复用了已有的。属于可以清理的技术债。」

回归证据:regress_api_test.sh 第 2 节「普通员工不允许登录管理端(角色隔离)」专门验证了这一条。

链路 3:@RequireRole 的角色组合全景(决定”谁能做什么”)

【仅 ADMIN】
  POST   /admin/goods             新增物资
  DELETE /admin/goods             批量删除物资
  PUT    /admin/goods             修改物资
  POST   /admin/goods/status/{s}  物资启停
  POST   /admin/combo             新增组合包
  DELETE /admin/combo             批量删除组合包
  PUT    /admin/combo             修改组合包
  POST   /admin/combo/status/{s}  组合包启停
  POST   /admin/category          新增分类
  DELETE /admin/category          删除分类
  PUT    /admin/category          修改分类
  POST   /admin/category/status/{s} 分类启停
  POST   /admin/department        新增部门
  PUT    /admin/department        修改部门
  DELETE /admin/department        删除部门
  POST   /admin/budget            设置预算        ← 资金相关
  PUT    /admin/budget            修改预算        ← 资金相关
  POST   /admin/employee          新增员工
  POST   /admin/employee/status/{s} 启用禁用员工
  PUT    /admin/employee          编辑员工
  POST   /admin/common/upload     文件上传
  PUT    /admin/shop/{status}     设置采购开关
  ─────────────────────────── 共 22 处,全部写操作 + 资金操作

【ADMIN + PURCHASER】
  PUT    /admin/order/confirm       审批通过
  PUT    /admin/order/rejection     审批驳回
  PUT    /admin/order/cancel        取消申领单
  PUT    /admin/order/delivery/{id} 发货
  PUT    /admin/order/complete/{id} 核销完成
  ─────────────────────────── 共 5 处,采购专员的业务职责

【ADMIN + PURCHASER】(读接口,为了配合审批时查看上下文)
  GET    /admin/department/list        部门列表(审批时要按部门筛选)
  GET    /admin/department/listEnabled 启用部门(下拉框)
  GET    /admin/department/{id}        部门详情
  GET    /admin/budget/list            预算列表(审批时参考余额)
  ─────────────────────────── 共 4 处

【无注解 = 默认放行(任何管理端角色都能访问)】★ 15 个读接口
  GET /admin/goods/page              物资分页
  GET /admin/goods/{id}              物资详情
  GET /admin/goods/list              物资列表
  GET /admin/combo/page              组合包分页
  GET /admin/combo/{id}              组合包详情
  GET /admin/category/page           分类分页
  GET /admin/category/list           分类列表
  GET /admin/order/conditionSearch   申领单搜索    ← 含全公司单据、申请人电话/地址
  GET /admin/order/statistics        各状态数量
  GET /admin/order/details/{id}      申领单详情    ← 含全公司单据
  GET /admin/employee/page           员工分页      ← ★★ 含身份证号!
  GET /admin/employee/{id}           员工详情      ← ★★ 含身份证号!
  GET /admin/shop/status             采购开关
  POST /admin/employee/logout        登出(无角色要求,合理)
  (login 由拦截器豁免)

【无角色要求 = 任何已登录员工都能访问】员工端全部接口
  /user/goods/list、/user/combo/list、/user/category/list、
  /user/shoppingCart/**、/user/order/**、/user/addressBook/**、/user/budget/my

四、为什么这么设计

4.1 为什么用”自定义注解 + AOP”而不是 Spring Security

【业务场景】 需要一个轻量的、只区分三种角色的功能级鉴权。

【原始方案对比】 这里”原始方案”指的是备选方案,因为项目起步时就是空白的。

方案实现优点缺点本项目是否适用
A. 自定义注解 + AOP 切面(当前)@RequireRole + @Before轻:一个注解 + 一个 30 行切面;直观:权限要求写在方法签名上,一眼可见;与项目已有模式一致(@AutoFill + AutoFillAspect);零依赖默认放行(漏标即静默失效);不支持动态权限(角色硬编码在注解里,改权限要改代码重新部署);没有细粒度(数据级)权限;没有会话管理、CSRF 防护、密码加密策略等一整套能力✅ 选它
B. Spring Security引入 spring-boot-starter-security,配置 SecurityFilterChain + @PreAuthorize功能完备:认证、授权、会话、CSRF、密码加密(BCrypt)、方法级安全、Remember-Me;社区标准,简历加分学习曲线陡(FilterChain、AuthenticationManager、UserDetailsService 一整套);配置量大(本项目要做两个独立的认证链:管理端和员工端);与现有的 JWT+拦截器方案冲突,等于重写认证层;@PreAuthorize 的 SpEL 表达式调试困难⚠️ 未使用。对这个规模是过度设计
C. Apache ShiroSubject + @RequiresRoles比 Spring Security 简单;会话管理友好社区活跃度下降;本项目无会话(纯 JWT)用不上它的强项⚠️ 未使用
D. 拦截器里判断(不用 AOP)在 preHandle 里反射读 HandlerMethod 的方法注解能在参数解析之前拦截;不受 Spring AOP 自调用失效、final 方法失效的影响;一处集中,天然覆盖所有路径需要自己写反射逻辑(但和现在切面里的代码量差不多);要处理”注解在类上还是方法上”;会让拦截器承担两个职责(认证 + 授权),违反单一职责⚠️ 其实是很合理的选择,见 M4-6 的讨论
E. 数据库 RBAC 表驱动role / permission / role_permission / user_role 四张表 + 缓存权限动态可配,改权限不用改代码;支持自定义角色复杂度大幅上升:要建 4 张表、要做权限缓存、要写权限查询与判断逻辑、管理端要有角色权限配置界面⚠️ 未实现。本项目的角色是固定的三种、由代码定义,没有”管理员自定义角色”的需求

【为什么选 A】

「选自定义注解是因为这个项目的权限模型是固定的三种角色,由代码定义,不存在”运维在界面上配角色”的需求。Spring Security 功能完整但配置量大,而且它的强项(会话管理、CSRF、UserDetailsService)我基本用不上——我用的是无状态的 JWT。

更重要的是与项目已有模式的一致性:我已经有了 @AutoFill + AutoFillAspect 这个”自定义注解 + AOP”的先例,权限继续用同一个模式,代码风格统一、学习成本为零。

但我也清楚它的代价:一是默认放行,漏标就静默失效(这是 M4-1 的问题);二是权限硬编码在注解里,想改”哪些角色能审批”必须改代码重新部署。如果以后要做动态权限,就得上 RBAC 表驱动那套。」

4.2 为什么”默认放行”而不是”默认拒绝”(这是 M4 最核心的设计取舍)

【当前策略】 RoleAspect 第 40-42 行:

RequireRole requireRole = method.getAnnotation(RequireRole.class);
if (requireRole == null) {
    requireRole = method.getDeclaringClass().getAnnotation(RequireRole.class);
}
if (requireRole == null) {
    return;          // ★ 没标注 → 直接放行
}

注解放开后,项目的 javadoc 也明确承认了这一点(RequireRole.java 第 13-15 行):

设计取舍:未标注该注解的方法默认放行(RoleAspect 只在命中注解时才校验)。
因此管理端所有「写接口」必须显式标注 @RequireRole,避免采购专员等非管理员角色越权维护基础数据;读接口可省略注解(默认放行)。

【为什么选默认放行 —— 真实的理由】

  1. 读接口占多数:管理端 15 个读接口 vs 27 个写接口,而且读接口的权限需求是”所有管理端角色都能看”。默认放行让这 15 个接口不用写注解,减少了 15 处样板代码;
  2. 员工端完全不需要这个注解:员工端天然只有一种角色(能登录员工端的都是员工),全部放行是对的。如果默认拒绝,员工端的每一个接口都要标注——这是纯粹的负担;
  3. 演进路径:项目是从原苍穹外卖改造来的,那边完全没有权限注解(只有登录拦截器)。引入 @RequireRole 时选择”默认放行 + 按需标注”,是增量、不破坏现有行为的改法。

【默认放行的问题 —— 必须承认】

问题说明
新增接口忘记标注 → 静默越权这是最危险的:没有编译错误、没有运行时警告、测试也不会失败。一个采购专员可以直接调这个新接口
依赖人记住,没有机制保障覆盖率 100% 是靠人工核对得来的,不是靠构建/测试强制
”看起来有权限控制”的错觉因为 27 个写接口都标了,review 的人可能会认为权限是完整的,从而忽略读接口完全没有限制这一点

【替代方案对比】

策略做法优点缺点
A. 默认放行(当前)没注解 = 放行读接口和员工端零负担;增量改造成本低漏标即静默越权;无机制保障
B. 默认拒绝没注解 = 拒绝(或要求显式 @PermitAll)漏标会变成”访问被拒”而不是”静默放行”——错误立刻暴露,反而安全15 个读接口 + 员工端全部接口都要显式标注;员工端和 /admin/employee/login 这类豁免路径需要额外的白名单机制
C. 只对 /admin/** 的写接口默认拒绝按 HTTP 方法区分:@PostMapping/@PutMapping/@DeleteMapping 无注解即拒绝精准命中风险面(写操作才有越权危害);读接口保持零负担实现要在切面里读 @RequestMapping 的方法类型,稍复杂;logout 这类”写但不危险”的接口要额外处理
D. 编译期/测试期强制写一个测试遍历所有 /admin/** 写接口,断言都带 @RequireRole;或者用 ArchUnit 做架构测试不改运行期行为,只在 CI 里失败项目没有 CI、没有这类测试;而且”带注解”不等于”注解正确”

【面试标准答法 —— 这段要能一口气说完】

「当前是默认放行——RoleAspect 里 if (requireRole == null) return;。

选它的理由有两条:一是管理端 15 个读接口的权限需求都是”所有管理端角色都能看”,员工端天然只有一种角色,如果默认拒绝,这 20 多个接口全都要写注解,是纯粹的样板负担;二是项目是从一个没有权限注解的代码库改造来的,默认放行使改造是增量的、不破坏已有行为。

但我必须承认它的风险:新增管理端写接口时忘记标注,就是静默越权——没有编译错误、没有警告、测试也不会失败。我的 27 个写接口 100% 覆盖是靠人工逐条核对得到的,不是靠任何机制保证的。

如果要改进,我会选”按 HTTP 方法区分”的折中:只对 /admin/** 的写接口(@PostMapping/@PutMapping/@DeleteMapping)默认拒绝,读接口保持默认放行。因为只有写操作才有越权的实际危害,读接口的风险是信息泄露(这是另一个问题),这样既不增加大量样板、又把最危险的面收紧了。并且让”漏标”变成”访问被拒”而不是”静默放行”——让错误暴露出来,比事后审计有效。」

4.3 为什么认证在拦截器、授权在切面(职责分层)

【切分点】 两层各自回答一个问题:

层组件回答的问题手段
认证JwtTokenAdminInterceptor / JwtTokenUserInterceptor「你是谁?你登录了吗?」JWT 验签 + Redis 白名单 + 查库校验角色/状态
授权RoleAspect「你登录了,但你能做这件事吗?」反射读 @RequireRole + 比对 BaseContext 里的角色

【为什么授权不放拦截器里 —— 项目自己的理由】(RoleAspect 第 24-27 行的 javadoc 原文):

之所以用切面而不是在拦截器里判断,是因为拦截器只能拿到 HandlerMethod 的类型信息,而注解的读取与方法级覆盖(类上标注、方法上覆盖)用反射处理更自然,也和项目里 AutoFillAspect 的”自定义注解 + 切面”模式保持一致。

【这个理由站得住吗?说实话:部分站得住,部分不站得住。

项目给的理由客观评价
「拦截器只能拿到 HandlerMethod 的类型信息」⚠️ 不准确。拦截器里 (HandlerMethod) handler 能直接拿到 getMethod() 和 getBeanType(),读类/方法注解完全没有障碍。这个理由技术上说不通
「注解的读取与方法级覆盖用反射更自然」✅ 站得住。而且切面里的 method.getAnnotation() → 没有再查 getDeclaringClass() 这个”方法优先、类兜底”的逻辑,写在切面里确实更自然
「和 AutoFillAspect 的模式保持一致」✅ 站得住,而且是最重要的一条——项目里两个横切关注点(自动填充、权限)用同一个模式,风格统一、可维护性更好

【授权放切面的真实得失】

维度放切面(当前)放拦截器 preHandle
执行时机参数解析之后(@RequestBody 已反序列化)参数解析之前(body 还没解析)
性能越权请求仍会反序列化 body(浪费 CPU)越权请求直接短路,零浪费
失效风险⚠️ Spring AOP 的经典陷阱:同类内部自调用不走代理;final/private 方法不会被代理(不过 Controller 方法都是 public,且不存在自调用)✅ 不受 AOP 代理机制影响,只要是 Spring MVC 接管的请求就一定经过
职责单一(拦截器只管认证)⚠️ 拦截器要管两件事,变厚
风格一致性✅ 与 @AutoFill 一致❌ 与项目既有模式不一致

【面试标准答法 —— 注意这里要敢纠正项目注释的不准确之处】

「我把认证放在拦截器、授权放在切面。理由是职责分离:拦截器回答”你是谁、登录了吗”,切面回答”你登录了但能不能做这件事”。这两件事的失败语义完全不同——认证失败应该是 401,授权失败是”无权限”。

关于”为什么授权不放拦截器”:项目代码注释给的理由是”拦截器只能拿到 HandlerMethod 的类型信息,读注解不方便”。这个理由其实不太站得住——拦截器里 ((HandlerMethod) handler).getMethod() 就能直接拿到方法对象、读注解毫无障碍。

我认为真正站得住的理由有两个:一是风格一致性——项目里已经有 @AutoFill + AutoFillAspect 这个”注解 + 切面”的先例,权限用同一个模式,代码风格统一、学习成本为零;二是切面里”方法注解优先、类注解兜底”这段逻辑写起来更自然。

但切面方案有一个真实的代价我要说清:它的执行时机在参数解析之后,所以越权请求的 JSON body 已经被反序列化了——控制器方法不会执行,但解析的开销已经花了。如果放在拦截器的 preHandle 里,越权请求会被在参数解析之前短路掉。所以我这个方案的授权是”安全但不够早”。

另外还有 AOP 本身的失效风险:同类内部自调用不走代理、final 方法不被代理。我的 Controller 方法都是 public 且没有自调用,所以当前不受影响;但如果以后有人在 Controller 里调自己的另一个带注解的方法,权限校验会静默失效。拦截器方案没有这个问题。」

4.4 为什么 @RequireRole 的值是数组

【业务场景】 有些接口需要多个角色都能访问。

例子:GET /admin/department/list(部门列表)——采购专员在审批申领单时需要按部门筛选,所以他也得能看:

@RequireRole({RoleConstant.ADMIN, RoleConstant.PURCHASER})

【原始方案】 String value()(单个角色)。

【原始方案的问题】 遇到多角色可访问时只能:

  • 要么拆成多个注解(@RequireRole(ADMIN) @RequireRole(PURCHASER) —— 但 Java 不允许同一注解重复标注,除非加 @Repeatable);
  • 要么干脆不标注解(默认放行)——这就是最坏的解法:不仅失去了限制,还让”为什么这个接口没注解”变成无法回答的问题。

【当前方案】 String[] value() + anyMatch:

boolean pass = Arrays.stream(allowed).anyMatch(role -> role.equals(currentRole));

【语义选择:anyMatch(OR)而不是 allMatch(AND)】

  • OR 是唯一合理的选择:@RequireRole({ADMIN, PURCHASER}) 的语义显然是”管理员或采购专员都能访问”。
  • AND 没有业务意义:一个人不可能同时是两个角色(employee.role 是单个字符串字段,不是集合)。如果哪天真需要 AND,那说明权限模型要改成”多角色”了。

⚠️ 但数组带来一个隐蔽陷阱 —— 空数组等于放行:

String[] allowed = requireRole.value();
if (allowed.length == 0) {
    return;         // ★ @RequireRole({}) 等于没标注,放行
}

因为 value() 没有默认值(String[] value(); 不带 default),所以必须显式传参——@RequireRole({}) 是完全合法的 Java 写法,但它的效果是”看着加了注解,实际没有任何限制”。

这是一个真实的、隐蔽的坑:写的人以为加了权限控制,review 的人看到有注解也认为安全,实际上完全放行。

【修法建议】

public @interface RequireRole {
    String[] value();
    // 或者在切面里改为:空数组 = 拒绝(fail-safe)
}
// RoleAspect 里改成
if (allowed.length == 0) {
    log.warn("角色校验失败:@RequireRole 未指定任何角色,方法={},拒绝访问", method.getName());
    throw new BaseException(MessageConstant.NO_PERMISSION);   // 空数组 = 拒绝
}

面试标准答法:

「注解的值是数组,因为有些接口需要多个角色访问,比如部门列表我允许 {ADMIN, PURCHASER} 都能看——采购专员审批申领单时要按部门筛选。判断用 anyMatch 也就是 OR 语义,因为一个人只有一个角色字段,AND 没有业务意义。

但这里有个隐蔽的坑我要主动说:切面里对空数组是直接放行的——@RequireRole({}) 的效果等于没加注解。因为 value() 没有默认值,写 @RequireRole({}) 是合法语法,但它看着像加了权限、实际完全没限制。这个坑比”漏标注解”更危险,因为漏标至少是可见的疏忽,而空数组是伪装的保护。修法是在切面里把空数组改成拒绝,或者在注解里用 @Repeatable 让多角色有更清晰的表达方式。」

4.5 ThreadLocal 的生命周期与泄漏

【设计】 拦截器 preHandle 写入、afterCompletion 清理(JwtTokenAdminInterceptor 第 98-100 行、JwtTokenUserInterceptor 第 95-97 行):

public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
                            Object handler, Exception ex) {
    BaseContext.clear();          // threadLocal.remove() + roleThreadLocal.remove()
}

【为什么必须清理 —— 真实的事故场景】

Tomcat 的线程是池化复用的(默认 200 个线程)。如果不清理:

请求 A(管理员 admin,empId=1,role=ADMIN)→ 线程 T-7 处理
   preHandle: threadLocal.set(1), roleThreadLocal.set("ADMIN")
   ★ 如果 afterCompletion 不执行 clear():
   
请求 B(某个静态资源请求,不走拦截器)→ Tomcat 又分配给线程 T-7
   → 拦截器没执行,BaseContext 没被覆盖
   → 如果 B 的链路里有代码调 BaseContext.getCurrentId()
      → ★ 读到的是 A 的身份(empId=1)!

后果取决于哪里读了它:

读取点后果
assertOwner(orders.getUserId() 比对)✅ 会拒绝(因为 id 不匹配),是”误拒”不是”误放”——失败方向是安全的
@AutoFill 切面(填 createUser/updateUser)❌ 审计信息写错人——操作日志记录成上一个请求的人
RoleAspect(读 currentRole)❌❌ 最严重:如果刚才那个是 ADMIN,下一个请求的角色被当成 ADMIN → 越权

【好消息:项目实际上不受影响】 因为两个拦截器都正确实现了 afterCompletion 并调用 clear()。而且所有需要 BaseContext 的路径都在 /admin/** 或 /user/** 下,都会被拦截器覆盖或清理。

【注意这是一处”已修复的原项目缺陷”】 拦截器第 93-97 行的注释原文:

请求结束后清理 ThreadLocal。
Tomcat 复用线程,若不清理,下一个请求可能读到上一个请求的身份信息。
这是原项目遗漏的一环(原代码只写了 remove 方法却没有调用)。

这是一个非常好的面试素材:它说明你不是照着模板抄的,而是发现了原实现的问题并修了,而且能解释为什么。

【面试标准答法】

「我用 BaseContext 存当前登录人 id 和角色,两个 ThreadLocal。关键是必须在请求结束时清理——两个拦截器的 afterCompletion 里都调了 BaseContext.clear()。

为什么必须清:Tomcat 线程是池化复用的。如果不清理,一个没走拦截器的请求(比如静态资源)被分配到同一个线程,BaseContext 里就残留着上一个请求的身份。后果分三种:assertOwner 会误拒(这个方向是安全的);@AutoFill 会把 createUser 写成错的人;最严重的是 RoleAspect 会读到上一个请求的 ADMIN 角色,造成越权。

这个清理在原项目里是缺失的——它定义了两个 remove 方法,但从来没有调用。我在改造时补上了。这也提醒我 ThreadLocal 的一个本质问题:它让方法的依赖变得隐式——RoleAspect 读 BaseContext,但它的签名上看不出任何依赖,所以排查时很难找到数据从哪来。这也是为什么异步场景下它会失效:换了个线程,ThreadLocal 就是空的。」

4.6 拦截器为什么每次都查库(性能与实时性的取舍)

【当前实现】 两个拦截器的 preHandle 里都有:

Employee employee = employeeMapper.getById(empId);      // ← 每次请求一次主键查询
if (employee == null || ...) { response.setStatus(401); return false; }
BaseContext.setCurrentRole(employee.getRole());

【原始方案】 把 role 也放进 JWT 的 claims 里,拦截器只验签、不查库。

【原始方案的问题】

JWT 是自包含且不可撤销的:签发之后内容固定、到期之前一直有效。如果把 role 放进去:

员工 A 当前是 PURCHASER → 签发 JWT{empId:5, role:"PURCHASER"}
   ↓(管理员把 A 改成 EMPLOYEE,或者 A 调岗)
签发的旧 JWT 在 2 小时内【仍然是 role:"PURCHASER"】
   → ★ 降权不生效,A 在 2 小时内仍能审批申领单

同理,status(启用/停用)放进去也不行——禁用账号后旧 token 还能用 2 小时。

【当前方案】 每次请求查库拿最新的 role 和 status:

if (employee == null 
    || RoleConstant.EMPLOYEE.equals(employee.getRole())     // 角色实时判断
    || StatusConstant.DISABLE.equals(employee.getStatus())) { // 状态实时判断
    response.setStatus(401);
    return false;
}

【得失分析】

维度每次查库(当前)role 放 JWT
降权/禁用实时性✅ 立即生效(改库后下一个请求就 401)❌ 最长等到 JWT 过期(本项目 admin-ttl / user-ttl 都是 7200000 ms = 2 小时)
每次请求开销❌ 一次主键查询(走主键索引,约 0.1-0.5 ms)✅ 零查询
权限语义正确性✅ 权限判断基于”当前数据库状态”(单一事实来源)❌ 基于”签发时刻的状态”,是快照,可能与现实不一致
可优化性可以加 Redis 缓存或本地缓存(但引入缓存一致性问题)只能靠缩短 TTL

【为什么选每次查库 —— 面试要这么讲】

「我选每次请求查库,而不是把 role 放进 JWT。核心理由是权限判断必须基于当前状态,而不是签发时的快照。

如果 role 放在 JWT 里,因为 JWT 不可撤销,会出现”降权不生效”——把一个采购专员降成普通员工之后,他手里那张旧 token 在 2 小时内仍然是 PURCHASER,还能继续审批申领单。同理,禁用账号在 2 小时内也拦不住。而我的场景里”能不能审批、能不能维护基础数据”是权限问题,实时性比省一次查询重要得多。

代价是每次请求多一次主键查询。 但这是主键索引等值查询,成本大概是零点几毫秒,而且在同一个连接池里、能被 Buffer Pool 缓存命中。我觉得这个开销换”权限实时生效”是值得的。

如果要优化,我会把 empId → {role, status} 缓存在 Redis 里,改角色/改状态时主动删缓存。但那样就引入了”缓存与数据库不一致”的问题——而权限判断恰恰是不能不一致的地方,所以我倾向不缓存,宁可多查一次。」

⚠️ 但这里必须主动指出一个不一致:拦截器查库拿实时角色,但角色变更后,用户已经持有的 JWT 仍然有效(只是下次请求时角色会被重新读取)。这个组合是对的:

  • JWT 里只有 empId(不携带任何权限信息);
  • 权限信息每次从数据库读。

所以本项目的 JWT 是”纯身份凭证”,不承载授权信息——这是一个干净的设计,值得讲。对比”把 role 放 JWT”的做法,本项目的权限判断没有快照问题。


五、M4 复习优先级文件清单(第五部分 · M4 切片)

M4 的代码量极小(注解 26 行 + 切面 67 行 + 两个拦截器共 199 行),但面试价值高。核心是能讲清分层和默认策略。

A 类:必须能背、能默写

优先级文件看什么为什么
P0RoleAspect.java 第 34-65 行(checkRole 方法,32 行)切点表达式(@annotation || @within)→ 方法注解优先于类注解 → null 放行 → 空数组放行 → BaseContext 取角色 → anyMatchM4 的全部实现就在这 32 行。要能逐句解释,特别是两个放行分支
P0RequireRole.java(26 行,含 javadoc)@Target({METHOD, TYPE})、@Retention(RUNTIME)、String[] value() 无默认值、javadoc 里承认的”默认放行”设计取舍26 行要全看。javadoc 第 13-16 行就是面试答案
P0JwtTokenAdminInterceptor.java 第 49-101 行认证 5 步 + role == EMPLOYEE 拒绝 + status == DISABLE 拒绝 + afterCompletion 清 ThreadLocal(含”原项目遗漏”的注释)认证层全部逻辑。afterCompletion 那段注释是面试金句
P0BaseContext.java(55 行)两个 ThreadLocal(id + role)+ clear()要能说清”为什么必须清、不清会怎样、后果最严重的是什么”
P0覆盖率数字管理端 27 个写接口 / 27 个 @RequireRole(100%)、15 个读接口全部无注解、类级标注 0 处必须说准,被要求现场核对时不能出错

B 类:知道配置与调用关系即可

文件 / 位置看什么
WebMvcConfiguration.java 第 40-50 行拦截器注册:/admin/** 排除 login;/user/** 排除 login + shop/status
EmployeeServiceImpl.java 第 49-58 行login 里 if (RoleConstant.EMPLOYEE.equals(...)) throw AccountLockedException(NO_ADMIN_PERMISSION)
controller/admin/*.java31 处 @RequireRole 的位置(扫一眼即可,不用背)
GlobalExceptionHandler.java只有两个 handler:BaseException 和 SQLIntegrityConstraintViolationException。RoleAspect 抛的 BaseException 走第一个
sql/regress_api_test.sh第 2 节(普通员工不能登录管理端)、第 5 节(未登录被拦 401)、第 14 节(员工 token 调管理端 401)、第 22 节(采购专员不能新增部门、不能改预算、可以查部门列表)

C 类:不用看

  • RoleConstant(3 个字符串常量)
  • Employee 实体、EmployeeLoginDTO / EmployeeLoginVO
  • AccountLockedException 等异常类(知道都 extends RuntimeException 即可)

30 分钟复习顺序

1. RoleAspect 第 33-65 行                    ← 10 分钟,能逐句讲,重点是两个放行分支
2. RequireRole javadoc 第 12-16 行            ← 2 分钟,这就是"默认放行"的官方说法
3. JwtTokenAdminInterceptor 第 49-101 行      ← 8 分钟,认证 5 步 + afterCompletion
4. 覆盖率数字(27/27 写、15 读无注解)          ← 2 分钟,背下来
5. 自己讲一遍"为什么用 AOP 不用拦截器/Spring Security" + "默认放行的风险" ← 8 分钟

六、M4 面试追问链(8 层)

追问链 1:默认放行(M4 最核心的一条)


【面试官问题 1】
你这个权限注解是怎么生效的?

【推荐回答】
用自定义注解 + AOP 切面。定义了一个 @RequireRole 注解,值是一个角色数组;RoleAspect 用一个 @Before 通知,切点表达式是 @annotation(...) || @within(...)——也就是方法上有这个注解、或者类上有这个注解都会触发。通知里反射读出注解、拿到允许的角色数组,再和 BaseContext 里的当前角色做 anyMatch 匹配。

【回答关键词】 自定义注解 + @Before 切面 · @annotation || @within(方法级 + 类级)· 反射读注解 · anyMatch
【可能继续追问】 如果有个接口忘了加注解呢?


【面试官问题 2】
如果一个管理端接口忘了加这个注解,会怎样?

【推荐回答】
会直接放行,这是当前设计的一个已知风险。 切面里第一段逻辑就是:

RequireRole requireRole = method.getAnnotation(RequireRole.class);
if (requireRole == null) {
    requireRole = method.getDeclaringClass().getAnnotation(RequireRole.class);
}
if (requireRole == null) {
    return;          // ★ 没注解 → 放行
}

所以某天有人新增一个管理端写接口、忘记标注,任何能登录管理端的人——包括采购专员——都能直接调它。 而且这个错误是完全静默的:没有编译错误、没有运行时警告、也不会有测试失败。

【回答关键词】 主动承认 · 默认放行 · 忘标即静默越权 · 无任何提示
【可能继续追问】 那你为什么选默认放行?改成默认拒绝不行吗?


【面试官问题 3】
那为什么不改成默认拒绝?

【推荐回答】
可以改,而且我认同这个方向,但要说清当时的取舍。

选默认放行的理由有两条:

一是样板代码的量。我管理端有 15 个读接口,它们共同的权限需求是”所有管理端角色都能看”,如果默认拒绝,这 15 个都得写注解。更重要的是员工端——员工端天然只有一种角色(能登录员工端的都是员工),它那十几个接口全部需要权限吗?答案是”全部不需要”。如果默认拒绝,员工端每一个接口都要加一个”允许所有角色”的注解,这是纯粹的负担。

二是改造的增量性。这个项目是从一个完全没有权限注解的代码库改造来的——原来只有一个登录拦截器。引入 @RequireRole 时如果默认拒绝,会把所有现存接口一次性锁死,必须同时给每个接口都加注解才能不改行为,风险和后端工作量都大得多。默认放行让改造是增量的、不破坏现有行为。

但我承认默认放行的风险是实质性的。如果要改,我不会简单地全改成默认拒绝,而是选一个折中:只对 /admin/** 的写接口(@PostMapping/@PutMapping/@DeleteMapping)默认拒绝,读接口和员工端保持默认放行。

理由是:只有写操作才有越权的实际危害——写操作会改数据、会造成资金变动(比如改预算);而读接口的问题是信息泄露,那是另一类风险。这样既能收紧最危险的面,又不会增加大量样板代码。

【回答关键词】 样板代码量(15 读 + 员工端)· 改造增量性 · 折中方案:按 HTTP 方法区分 · 写操作才有实际危害 · 让漏标变成”被拒”而不是”放行”
【可能继续追问】 那你现在怎么保证没有漏标的?


【面试官问题 4】
那你怎么保证现在没有漏标的接口?

【回答回答】
说实话,靠人工核对,没有任何自动化保障。

我逐条把管理端的写接口和 @RequireRole 对了一遍:27 个写接口、27 个注解,覆盖率 100%。其中 22 个是 @RequireRole(ADMIN)(基础数据、预算、员工、上传、采购开关这些写操作),5 个是 @RequireRole({ADMIN, PURCHASER})(审批、驳回、取消、发货、核销)。

但我必须承认两件事:

第一,这个数字是人工数出来的,没有 CI、没有架构测试、没有构建期检查。如果有人加了一个新接口忘了标,我的这套核对方法完全发现不了。

第二,更值得说的是一件事——我只保证了”写接口有注解”,但整个管理端有 15 个读接口是完全没注解的。这不是漏标,是按默认放行的设计有意不标的,但它的后果我之前没有充分意识到:采购专员能读到他本不该看到的数据(具体见下一个问题)。

从工程上讲,正确的做法是让机制来保障,而不是靠人:

  • 让”漏标”变成”访问被拒”——改成默认拒绝,漏标的后果从”静默放行”变成”接口用不了”,问题会立刻暴露,而不是等到安全审计;
  • 或者写一个遍历式测试:用最低权限角色(PURCHASER)的 token 去打所有 /admin/** 的写接口,断言全部返回拒绝。这样新增接口如果忘了标,测试会失败。

【回答关键词】 人工核对 · 承认无自动化保障 · 27/27 写接口 · 主动提出”15 个读接口完全无限制” · 让机制而非人保障
【可能继续追问】 你刚才说采购专员能读到不该读的数据?


【面试官问题 5】
采购专员能读到什么不该读的?

【推荐回答】
最具体的是员工身份证号。

管理端有两个接口没有 @RequireRole,所以任何管理端角色都能访问:

GET  /admin/employee/page      员工分页
GET  /admin/employee/{id}      员工详情

它们的返回类型是 Employee 实体,而 Employee 有这些字段:

private String username;    // 登录账号
private String password;    // ← 已被置为 "****",这一处做对了
private String phone;
private String sex;
private String idNumber;    // ★★ 身份证号
private String role;
private Long   deptId;

EmployeeServiceImpl.pageQuery 和 getById 里都做了密码脱敏(setPassword("****"))——这一点是正确的,说明作者意识到要脱敏。但只脱了密码,idNumber 和 phone 是原样返回的。

所以采购专员只要调一个 GET 接口,就能拉出全公司员工的身份证号。

另外还有一处:申领单的两个读接口(conditionSearch、details/{id})也没有角色限制,返回的 OrderVO 里有全公司单据的申请人姓名、电话、收货地址。对采购专员来说查看申领单是他的职责、可以接受;但员工列表里的身份证号跟审批工作完全无关,纯粹是越权访问个人信息。

【回答关键词】 具体到接口和字段 · idNumber 身份证号 · 密码脱敏做了(说明有意识)但只脱了密码 · 申领单读接口可接受、员工身份证号无关职责 · 读接口的权限缺失
【可能继续追问】 那这个怎么修?


【面试官问题 6】
那这个怎么修?

【回答回答】
分三层,从治标到治本。

第一层,给这两个接口加注解。 员工信息属于”账号管理”,只有 ADMIN 应该能看:

@GetMapping("/page")
@RequireRole(RoleConstant.ADMIN)
public Result<PageResult> page(EmployeePageQueryDTO dto) { ... }

这是几行改动、立刻见效的修法。

第二层,返回 VO 而不是实体。 现在直接返回 Employee 实体,等于把数据库表结构暴露给了接口——表里加个字段,接口就会自动多返回一个字段,这是很危险的默认行为。应该定义 EmployeeVO,只包含前端真正需要的字段(id、姓名、账号、手机号、角色、部门、状态),身份证号根本不进 VO。

密码脱敏这件事恰好说明问题:作者在 Service 里手工 setPassword("****"),说明他意识到了风险,但这是黑名单式的思路——只处理已知的敏感字段,新增敏感字段就会漏。而 VO 是白名单式:不显式加进 VO 的字段就不会返回,默认安全。

第三层,如果身份证号确实需要展示(比如管理员要核对身份),那也应该做掩码(110101********0011),并且这一行为应该记审计日志。

我的项目只做了第一层的一半——密码脱敏做了,但没有 VO、没有给这两个接口加角色限制。

【回答关键词】 加 @RequireRole(ADMIN) · 返回 VO 而不是实体(白名单 vs 黑名单)· 密码脱敏是黑名单思路、会漏 · 掩码 + 审计日志 · 承认只做了一半
【可能继续追问】 好,那说说认证和授权你是怎么分工的?


追问链 2:认证与授权的分层(6 层)


【面试官问题 1】
认证和授权你是怎么分工的?

【推荐回答】
两层,回答两个不同的问题。

认证在拦截器(JwtTokenAdminInterceptor / JwtTokenUserInterceptor):回答”你是谁、你登录了吗”。做四件事——验 JWT 签名和过期、校验 Redis 白名单(登出即失效)、查库确认账号没被停用且角色有资格进这个端、把 empId 和 role 写进 BaseContext。

授权在切面(RoleAspect):回答”你登录了,但你能不能做这件事”。反射读 @RequireRole 注解,和 BaseContext 里的角色比对。

这样分的好处是失败语义清晰:认证失败是 401;授权失败是业务错误码”无操作权限”。

【回答关键词】 认证答”是谁/登录了吗” · 授权答”能不能做” · 拦截器 vs 切面 · 失败语义不同(401 vs 无权限)
【可能继续追问】 那授权为什么不也写在拦截器里?


【面试官问题 2】
授权为什么不也写在拦截器里?不是更方便吗?

【推荐回答】
其实技术上完全可以,我要先纠正一下项目注释里的一个说法。

RoleAspect 的 javadoc 写的理由是”拦截器只能拿到 HandlerMethod 的类型信息,而注解的读取与方法级覆盖用反射处理更自然”。这个理由不太站得住——拦截器里 ((HandlerMethod) handler).getMethod() 和 getBeanType() 就能直接拿到方法对象和类对象,读注解毫无障碍。

我认为真正站得住的理由有两个:

一是风格一致性,这也是最重要的一条。项目里已经有一个 @AutoFill + AutoFillAspect 的”自定义注解 + AOP”先例用来做公共字段自动填充,权限继续用同一个模式,整个项目的横切关注点只有一种写法,学习成本和维护成本都更低。

二是切面里那段”方法注解优先、类注解兜底”的逻辑写起来更自然,而且和 @AutoFill 的处理方式如出一辙。

【回答关键词】 主动纠正项目注释 · 拦截器其实能读注解 · 真正理由是风格一致性(与 @AutoFill 一致)· 类/方法注解覆盖逻辑更自然
【可能继续追问】 那放切面有什么代价?


【面试官问题 3】
放切面有什么代价?

【推荐回答】
两个真实的代价,我都要说。

第一,执行时机更晚。 切面的 @Before 是在参数解析之后执行的,所以越权请求的 JSON body 已经被 Jackson 反序列化过了。控制器方法绝对不会执行(安全性没问题),但解析开销已经花了。如果放在拦截器的 preHandle 里,越权请求会在参数解析之前就被短路掉。

所以我的授权是”安全但不够早”——如果需要挡的是”超大 body 消耗内存”这类攻击,切面方案就挡不住。

第二,Spring AOP 本身的失效风险。 两个经典陷阱:① 同类内部自调用不走代理——如果 Controller 里 A 方法调了自己的 B 方法,B 上的 @RequireRole 不会生效;② final / private 方法不会被代理。

我的 Controller 方法都是 public、也没有自调用,所以当前不受影响。但这意味着这个安全性依赖”以后没人这么写”——如果哪天有人在 Controller 里抽了一个私有方法并加上注解,权限会静默失效。而拦截器方案完全没有这个隐患,只要是 Spring MVC 接管的请求就一定经过。

【回答关键词】 执行时机在参数解析之后(安全但不够早)· AOP 自调用失效 · final/private 不代理 · 当前不受影响但依赖约定 · 拦截器无此隐患
【可能继续追问】 那拦截器里每次都查库,不慢吗?


【面试官问题 4】
拦截器里每次请求都查一次数据库?为什么?不慢吗?

【推荐回答】
是有意的,为了权限的实时性。 我不把 role 放进 JWT,而是在每次请求时 employeeMapper.getById(empId) 拿最新的 role 和 status。

理由是 JWT 不可撤销、内容固定。 如果把 role 放进 JWT,会出现”降权不生效”:

员工 A 当前是 PURCHASER → 签发 JWT{empId:5, role:"PURCHASER"}
   ↓ 管理员把 A 改成 EMPLOYEE
A 手里那张旧 JWT 在 2 小时内【仍然自称 PURCHASER】
   → 降权不生效,他还能继续审批申领单

status(启用/停用)同理——禁用账号后旧 token 还能用 2 小时。而我的 TTL 配的是 7200000 毫秒,也就是 2 小时,这个窗口不算短。

所以本项目的 JWT 是”纯身份凭证”——它只携带 empId,不携带任何权限信息,权限每次从数据库读。 我觉得这是一个干净的设计:权限判断基于当前数据库状态(单一事实来源),而不是签发时刻的快照。

至于性能:这是一次主键索引等值查询,走 Buffer Pool 命中,成本大约零点几毫秒,而且在连接池里。我觉得用这个换”降权/禁用立即生效”是值得的。

如果要优化,我会把这个员工信息缓存在 Redis,改角色/改状态时主动删缓存。但我不倾向这么做——权限判断恰恰是最不能出现缓存不一致的地方,宁可多查一次库。

【回答关键词】 JWT 不可撤销 → role 放进去会降权不生效 · status 同理(TTL 2 小时窗口不短)· JWT 只携带 empId(纯身份凭证)· 主键查询成本可忽略 · 不缓存权限数据是有意的
【可能继续追问】 那你这个 ThreadLocal 怎么管理的?


【面试官问题 5】
BaseContext 用的是 ThreadLocal,有什么风险?

【推荐回答】
最大的风险是线程复用导致的”身份串号”,所以必须清理。

Tomcat 的线程是池化的。如果不清理,一个不走拦截器的请求(比如静态资源)被分配到同一个线程时,BaseContext 里就残留着上一个请求的身份。后果分三种:

  1. assertOwner 会误拒(id 不匹配)——这个方向是安全的;
  2. @AutoFill 切面会把 createUser / updateUser 写成错的人——审计信息污染;
  3. 最严重的是 RoleAspect 会读到上一个请求的 ADMIN 角色,造成越权。

我的实现在两个拦截器的 afterCompletion 里都调了 BaseContext.clear(),而且 clear() 同时清两个 ThreadLocal(id 和 role),所以是干净的。

这里我要特别说一句:这个清理在原项目里是缺失的。 原来的代码定义了 removeCurrentId() 方法,但从来没有调用过。拦截器的注释里我留了记录。这说明一个本质问题:ThreadLocal 让方法依赖变得隐式——RoleAspect 读 BaseContext,但从它的方法签名上看不出任何依赖,排查时很难找到数据从哪来。

而且这也解释了为什么 ThreadLocal 在异步场景会失效:换了线程就是空的。所以我这个项目没有任何异步任务,某种程度上也回避了这个问题——如果以后加了 @Async,BaseContext 里的身份在异步线程里一定是 null,必须显式把 empId 当参数传进去。

【回答关键词】 线程复用串号 · 三种后果(误拒/审计污染/越权)· afterCompletion 清理 · 原项目遗漏了调用 · ThreadLocal 依赖隐式 · 异步场景一定失效
【可能继续追问】 那 employee.role 字段上为什么有索引?


追问链 3:权限模型与扩展(5 层)


【面试官问题 1】
你的角色是怎么定义的?为什么用字符串而不是枚举或数字?

【推荐回答】
三个角色,定义在 RoleConstant 里,是 String 常量:EMPLOYEE、PURCHASER、ADMIN。

用字符串的好处是”自解释”——直接查数据库 select role from employee 能看懂,日志里 角色校验失败:当前角色=PURCHASER 也能看懂。如果用数字 1/2/3,每次排查都要回来查”2 是什么角色”。

代价是没有类型安全:role 字段在实体里是 String,数据库是 VARCHAR(20),拼错一个字母(比如 "PURCHASER " 带空格)不会有任何编译错误,只会表现为”这个人的权限莫名其妙不对”。用枚举能避免这个问题,但要在 MyBatis 里配 TypeHandler 做映射,而且注解的值用枚举会更啰嗦。

数据库侧我用了一个 idx_employee_role 索引,因为管理端员工分页支持按 role 筛选。

【回答关键词】 String 自解释(DB、日志可读)· 代价是无类型安全(拼错静默失效)· 枚举需要 TypeHandler · idx_employee_role 索引支持按角色筛选
【可能继续追问】 如果以后要加一个”部门主管”角色呢?


【面试官问题 2】
如果以后要加一个”部门主管”角色,你的设计能支持吗?

【回答回答】
能加,但要改代码重新部署,这暴露了当前设计的局限。

加一个角色需要动四处:

  1. RoleConstant 加常量 DEPT_MANAGER;
  2. 数据库 employee.role 是 VARCHAR(20),不需要改表结构(这点设计得还行);
  3. 给需要放行的接口补注解或改注解的数组(比如审批接口可能要变成 {ADMIN, PURCHASER, DEPT_MANAGER});
  4. 前端菜单要按新角色显示。

核心局限是:权限是硬编码在注解里的,是”代码即配置”。 改任何一条权限规则都要改代码 + 重新编译 + 重新部署。而且没有”数据级权限”——比如”部门主管只能审批本部门的申领单”,用 @RequireRole 根本表达不出来。

如果要做数据级权限,得在 Service 里加过滤条件,比如审批时校验 ordersDB.getRequesterDept() 是不是等于当前用户所在部门。这个我项目里没做——现在的审批接口是 {ADMIN, PURCHASER},任何采购专员都能审批任何部门的申领单,这也是一个真实的越权风险面。

【回答关键词】 能加但要改代码部署 · 权限硬编码 = 代码即配置 · 无法表达数据级权限 · 缺”只能审批本部门”的过滤(真实缺口)
【可能继续追问】 那要做数据级权限怎么办?


【面试官问题 3】
那数据级权限怎么做?

【推荐回答】
两种思路,成本差很多。

轻量做法:在 Service 里加过滤条件。 比如审批时校验单据的申请部门和当前用户的部门是否一致:

if (!employee.getDeptId().equals(ordersDB.getRequesterDept())) {
    throw new OrderBusinessException(MessageConstant.NO_PERMISSION);
}

或者更松的”只能看到本部门单据”——在 conditionSearch 的查询条件里强制注入 requesterDept(类似员工端 pageQuery4User 强制 setUserId(BaseContext.getCurrentId()) 的做法,这个模式项目里已经有了)。

优点:改动小、直接。缺点:权限规则散落在各个 Service 方法里,和”哪里能审批”这个业务逻辑混在一起,容易被后续改动漏掉。

重量做法:完整 RBAC + 数据权限表。 建 role / permission / role_permission / user_role,再加数据范围表(全部 / 本部门 / 本部门及下级 / 仅本人)。权限判断从注解改成查表(加缓存)。

优点:动态可配、不改代码就能调整权限、能表达数据级范围。缺点:从 1 个注解变成 4-5 张表 + 权限缓存 + 管理界面,复杂度上升一个数量级。

对当前项目我倾向轻量做法——因为现在只有三种固定角色、单级部门(parent_id 统一为 0),业务上还没有”数据范围”的诉求。但这个缺口是真实存在的:现在的采购专员能审批全公司所有部门的申领单。

【回答关键词】 轻量:Service 层加过滤 / 强制注入部门条件(复用 pageQuery4User 的模式)· 重量:完整 RBAC + 数据范围表 · 规则散落的缺点 · 当前缺口是”能审批全公司”
【可能继续追问】 那为什么不用 Spring Security?


【面试官问题 4】
为什么不用 Spring Security?用了会更规范吧?

【推荐回答】
更规范,但功能和成本都不匹配。

Spring Security 的强项我基本用不上:会话管理(我用无状态 JWT)、CSRF 防护(前后端分离 + 自定义请求头,本来就不靠 Cookie)、UserDetailsService(我只有一张 employee 表)、记住我、表单登录——这些都是它成熟的部分,但我一个都不需要。

而它的成本是实的:SecurityFilterChain、AuthenticationManager、UserDetailsService、PasswordEncoder 这一整套概念要理解;而且我这个项目有两个独立的认证链(管理端和员工端,不同密钥、不同请求头、不同的角色准入规则),在 Spring Security 里要么配两个 SecurityFilterChain 加 @Order,要么用 RequestMatcher 区分——配置复杂度明显上升,而且很容易配错(配错的表现是”安全放行”或者”全部 403”,都不好排查)。

如果真要用,我会考虑它真正的收益点:它的 BCryptPasswordEncoder ——因为我的项目现在用无盐 MD5 存密码,这是我明确知道的一个缺陷。但这一点我可以单独引入 Spring Security 的 crypto 模块(或者用 BCrypt 库)来解决,不需要引入整个框架。

所以我的判断是: 在这个规模下,自定义注解 + AOP 恰好匹配需求;引入 Spring Security 是用一整套框架解决一个 30 行代码的问题。但如果项目要上生产、要处理密码策略、账号锁定、密码找回、审计日志这一整套账号体系,我会改用 Spring Security。

【回答关键词】 它的强项(会话/CSRF/表单登录)我一个不需要 · 两个认证链的配置复杂度 · 真正想要的只是 BCrypt(可单独引入)· 规模匹配 · 什么条件下会换
【可能继续追问】 那你密码为什么用 MD5?


【面试官问题 5】
那你的密码为什么用 MD5?

【推荐回答】
这是项目一个明确的缺陷,我用的是无盐 MD5,应该说清风险和修法。

代码是 DigestUtils.md5DigestAsHex(password.getBytes())——无盐、无迭代。问题有两个:

一是 MD5 本身已经被破解得很彻底,碰撞和快速计算都有成熟工具,GPU 每秒能算几十亿次;
二是无盐意味着相同密码得到相同哈希,可以用彩虹表直接反查。我的演示数据里三个账号密码都是 123456,哈希都是 e10adc3949ba59abbe56e057f20f883e——只要库泄露,弱密码几乎瞬间被还原。

修法是换 BCrypt:自带随机盐(同一个密码每次哈希结果都不同)、可配置计算成本(strength 参数让每次哈希耗时几十到几百毫秒,抗暴力破解的关键就是”慢”)。

但换算法有一个实际障碍:存量数据兼容。 库里已经存了 MD5 的值,直接换会导致所有人登录不上。可行的迁移方案是双算法并存:登录时判断密码字段的格式——BCrypt 的哈希有固定前缀($2a$/$2b$),以它开头的走 BCrypt 校验,否则走 MD5 校验;如果是 MD5 校验通过,就顺便把密码升级成 BCrypt 存回去。这样用户无感知、逐步迁移完成。

我没有做这个迁移,因为改动涉及登录逻辑和存量数据,而且这个项目的密码安全还叠加了另一个问题:没有登录失败限流——这一条我后来修了(5 次锁定 15 分钟),但密码算法这块还是 MD5。

【回答关键词】 无盐 MD5 的两个问题 · 演示数据哈希相同可反查 · 换 BCrypt(随机盐 + 慢)· 存量迁移:按前缀双算法并存 + 登录时升级 · 承认没做
【可能继续追问】 (通常收尾或转向别的模块)


七、M4 容易被质疑的地方(严格视角,不强行合理化)

#问题面试官为什么可能质疑当前代码实际情况我应该怎么解释是否建议修改
M4-1默认放行:漏标注解即静默越权”新增一个管理端写接口忘记加注解会怎样?”✅ 成立。RoleAspect 里 if (requireRole == null) return;。管理端 27 个写接口 100% 覆盖靠人工核对,无 CI/测试保障主动承认。选默认放行有理由(15 个读接口 + 员工端全部接口零负担;从无注解代码库增量改造),但风险是实质性的。推荐改成按 HTTP 方法区分:只对 /admin/** 的写接口默认拒绝,读接口保持放行。让”漏标”变成”接口被拒”而不是”静默放行”建议修(M4 最高优先级)。这是”看起来有权限控制、实际有盲区”的典型
M4-2★ 采购专员能拉取全部员工的身份证号”采购专员能看到员工身份证号吗?”✅ 成立,具体可验证。GET /admin/employee/page 和 /{id} 无 @RequireRole;返回类型是 Employee 实体,含 idNumber(身份证号)、phone、username、role。EmployeeServiceImpl 里只脱敏了 password(setPassword("****")),idNumber 原样返回主动承认,并给出三层修法:① 加 @RequireRole(ADMIN)(几行);② 返回 VO 而不是实体——现在是黑名单式脱敏(只处理已知敏感字段,新增字段就漏),VO 是白名单式(不进 VO 就不返回,默认安全);③ 若确需展示则做掩码 + 审计日志。注意要肯定”密码脱敏做了”这一点,说明有意识但方法不够建议修(优先级高,是真实的数据泄露)
M4-3@RequireRole({}) 空数组等于放行”注解的值如果传空数组呢?”✅ 成立。RequireRole.value() 无默认值(必须传),但 @RequireRole({}) 是合法语法;RoleAspect 里 if (allowed.length == 0) return; → 放行「这是一个伪装的保护——写的人以为加了权限,review 的人看到有注解也认为安全,实际完全没限制。它比”漏标注解”更危险,因为漏标是可见的疏忽,空数组是看起来正确的错误。修法是把空数组改成拒绝(throw NO_PERMISSION),fail-safe。」建议修(一行改动,消除一个隐蔽陷阱)
M4-4管理端 15 个读接口全部无角色限制”读接口不需要权限吗?“⚠️ 按设计如此(默认放行),但后果未被充分评估:采购专员能读全部物资/分类/组合包/申领单/员工/预算列表。其中申领单读接口对采购专员是合理的(审批需要),员工读接口不合理(关联 M4-2)「读接口统一放行是为了让采购专员能完成审批工作——他要看申领单、看部门、看预算余额。但这个’统一’太粗了:员工列表/详情和采购专员的工作无关,属于信息泄露。正确的做法是逐个读接口确认权限需求,而不是按”读接口都不标”一刀切。」建议逐个评估(至少修员工读接口)
M4-5采购专员能审批全公司任何部门的申领单”采购专员能审批别的部门的单吗?”✅ 成立。PUT /admin/order/confirm 是 @RequireRole({ADMIN, PURCHASER}),confirm() 里只校验单据状态,不校验申请部门。而 Department.parent_id 统一为 0(单级部门)「@RequireRole 只能表达角色级权限,表达不了数据级权限(“只能审批本部门的”)。要做得在 Service 里加过滤:校验 employee.getDeptId() 和 ordersDB.getRequesterDept() 是否一致,或者查列表时强制注入部门条件(这个模式项目里已经有了——员工端 pageQuery4User 就是强制 setUserId(BaseContext.getCurrentId()))。当前没做,所以是”任何采购专员能审批任何部门」建议修(若业务上要求部门隔离)
M4-6授权在参数解析之后执行”越权请求的 body 还会被解析吗?“⚠️ 成立。切面的 @Before 在 HandlerAdapter 完成参数解析(@RequestBody 反序列化)之后触发「控制器方法绝对不会执行,安全性没问题;但 JSON body 已经被反序列化了,越权请求仍消耗了 CPU 和内存。这是”安全但不够早”。放在拦截器 preHandle 能更早短路。代价是要把”方法注解优先、类注解兜底”的反射逻辑搬进拦截器,以及破坏与 @AutoFill 的风格一致性。」可选(要能讲清”不够早”这个代价)
M4-7BaseContext 在异步场景会失效”如果以后加异步任务,BaseContext 还能用吗?“⚠️ 潜在。项目当前无任何异步(无 @Async、无自定义线程池),所以不触发。但 BaseContext 被 assertOwner、@AutoFill 切面、submitOrder 依赖「ThreadLocal 绑线程,异步任务换了线程就是空的。所以一旦引入 @Async 或线程池,BaseContext.getCurrentId() 会返回 null——assertOwner 里 getCurrentId().equals(...) 会直接 NPE。正确做法是显式把 empId 当参数传进异步任务,不能依赖 ThreadLocal。项目现在没有异步,所以没踩到;但这是一个”加异步就会炸”的隐患。」不需要改(但要心里有数,且加异步时必须处理)
M4-8login 拒绝普通员工时复用了 AccountLockedException”为什么抛的是’账号被锁定’?“⚠️ 成立。EmployeeServiceImpl.login:53-55 抛 AccountLockedException,但消息是 NO_ADMIN_PERMISSION(“当前账号无权登录管理端”)。异常类的语义和消息不匹配「这里复用了 AccountLockedException 来表达”无权限登录管理端”,语义上应该是一个 NoPermissionException 之类。当时因为项目里没有为”无权限登录”定义专门的异常类,我复用了已有的。如果有按异常类型做分支处理的代码,会误判。属于可以清理的技术债。」建议清理(定义专门异常,或改用 BaseException)
M4-9越权返回 HTTP 200 + code:0,而认证失败返回 401”为什么认证失败是 401、授权失败却是 200?“⚠️ 成立,是不一致的。认证失败 → response.setStatus(401);授权失败 → 抛 BaseException → GlobalExceptionHandler → HTTP 200 + {"code":0,"msg":"无操作权限"}「这是项目的统一约定——所有业务错误都是 HTTP 200 + code:0,前端统一看 code 和 msg。但严格说”认证 401 / 授权 200”是不一致的,更规范的做法是授权也用 403。当时的考虑是让前端只维护一套错误处理逻辑。如果对接第三方或做监控告警,401/403/500 的区分是有意义的。」可选(约定问题,非缺陷;但要知道不一致)
M4-10employee.role 是字符串,无类型安全”角色拼错了会怎样?“⚠️ 成立。Employee.role 是 String,DB 是 VARCHAR(20)。拼错(如 "PURCHASER " 带空格)无编译错误,只表现为权限异常「用字符串的好处是自解释——查库、看日志都能直接读懂,不用回头查”2 是什么角色”。代价是没有类型安全,拼错静默失效。用枚举能避免,但要在 MyBatis 配 TypeHandler,而且注解值用枚举更啰嗦。这是一个可读性 vs 类型安全的取舍,我倾向可读性;如果要严谨,可以在 save/update 时校验 role 在合法集合内。」可选(建议加角色合法性校验)
M4-11User 实体是死代码”这个 User 类是干什么的?”✅ 成立。caiyuntai-pojo 里有 User 实体(原微信用户),全项目零引用(无 Mapper、无 Service)。migration_office_supplies.sql 把 user 表注释标记为「【已废弃】」「原项目的微信用户模型,身份已经合并到 employee 表了。实体和表都没删,只是标记废弃——表保留是为了留历史数据(迁移脚本里明确写了”不物理删除”),但实体类已经没有引用,应该删掉,否则会让人以为还有一套用户体系。」建议删除实体(表可保留留档)
M4-12Swagger/knife4j 文档端点不受任何拦截器保护”接口文档接口需要登录吗?“⚠️ 成立。两个拦截器只拦 /admin/** 和 /user/**;/doc.html、/v2/api-docs、/swagger-resources/**、/webjars/** 都不匹配,所以无需登录即可访问「接口文档是未授权可访问的。风险不是直接的越权(拿到文档也要有 token 才能调接口),而是信息暴露——攻击者能拿到完整的接口清单、参数结构、字段名,等于拿到了一份攻击面清单。项目甚至已经把接口文档导出成了 JSON 文件。如果部署在公网,文档端点必须加保护或关闭(比如生产环境用 @Profile("!prod") 关掉 Swagger,或者给文档路径也加拦截器)。」建议修(生产环境必须关闭或加认证)

明确不是问题、但可能被问的点

点回答
为什么 logout 没有 @RequireRole合理的。logout 只删除自己请求头里那个 token(stringRedisTemplate.delete(LOGIN_TOKEN_PREFIX + token)),没有越权危害。而且它在 /admin/** 下,已经被认证拦截器保护(必须是有效登录态才能调)
员工端所有接口都没有 @RequireRole正确的设计。能登录员工端的只有员工(任何角色都能登录员工端),而员工端的功能对所有员工都是开放的。员工端的数据隔离靠的不是角色,而是”强制使用当前登录人 id”——比如 pageQuery4User 强制 setUserId(BaseContext.getCurrentId())、assertOwner 校验单据归属
两个拦截器都写了一遍、代码几乎重复可以抽象,但重复的只有”验签 + 查白名单 + 查库”这三步,而两端的校验规则不同:管理端额外要求 role != EMPLOYEE,员工端不要求;两端的密钥和请求头名不同。抽象成一个模板方法基类可行,但收益不大、反而增加了”改一个影响两个”的风险
@RequireRole 用了 anyMatch 而不是 allMatch正确的选择。{ADMIN, PURCHASER} 的语义是”或”,而且 employee.role 是单个字符串字段(不是角色集合),一个人不可能同时是两个角色,AND 没有业务意义。如果要支持 AND,得先把权限模型改成”多角色关联表”
切点表达式为什么要 || 组合两个因为 @annotation 只管方法级、@within 只管类级。要同时支持”标在方法上”和”标在类上”,必须用 ||。只写 @annotation 的话,类级标注会静默失效

八、M4 关键数字与事实速查(面试前扫一眼)

事实值 / 位置
角色数量3 个:EMPLOYEE / PURCHASER / ADMIN(RoleConstant)
角色存储employee.role,VARCHAR(20) NOT NULL DEFAULT 'EMPLOYEE' + idx_employee_role 索引
关键设计一张 employee 表三角色(原 user 表已废弃、保留数据不删)
注解定义@Target({METHOD, TYPE}) + @Retention(RUNTIME) + String[] value()(无默认值)
切面RoleAspect,@Before("@annotation(...) || @within(...)"),67 行,checkRole 方法 32 行
判权逻辑方法注解优先 → 类注解兜底 → 都没有则放行 → anyMatch(OR 语义)
覆盖率(实测)管理端写接口 27/27 = 100%;@RequireRole 标记共 31 处(22 个纯 ADMIN + 9 个含 PURCHASER);类级标注 0 处;读接口 15 个全部无注解
认证层两个拦截器 + JWT(双密钥:admin=itcast / user=itheima;双请求头:token/authentication`)+ Redis 白名单 + 每次查库拿实时 role/status
拦截路径/admin/**(排除 /admin/employee/login);/user/**(排除 /user/user/login、/user/shop/status)
ThreadLocalBaseContext 两个(threadLocal = id、roleThreadLocal = role),afterCompletion 调 clear()(原项目漏了这次调用)
越权校验模式员工端靠 ThreadLocal 而不是角色:pageQuery4User 强制 setUserId;assertOwner 校验单据归属
异常RoleAspect 抛 BaseException(extends RuntimeException)→ GlobalExceptionHandler → HTTP 200 + code:0;认证失败则是 401
已知缺口① 默认放行(漏标即越权,无机制保障)② 采购专员可读全部员工身份证号 ③ @RequireRole({}) 空数组 = 放行 ④ 15 个读接口无限制 ⑤ 采购专员可审批全公司单据(无数据级权限)⑥ 授权在参数解析后 ⑦ BaseContext 异步即失效 ⑧ 密码无盐 MD5 ⑨ Swagger 文档未授权可访问 ⑩ User 实体死代码
回归验证regress_api_test.sh 第 2 节(员工不能登管理端)、第 5 节(未登录 401)、第 14 节(员工 token 调管理端 401)、第 22 节(采购专员不能新增部门 / 不能改预算 / 可以查部门列表)
未实现Spring Security、Shiro、RBAC 表驱动、数据级权限、多角色关联、审计日志、角色合法性校验

九、如果只给我 3 分钟讲 M4

设计:三种角色(EMPLOYEE / PURCHASER / ADMIN)共用一张 employee 表,靠 role 字段区分。原项目的 user 表(微信用户)已废弃,身份合并到 employee。

分层:认证在拦截器、授权在切面。拦截器回答”你是谁、登录了吗”——验 JWT、查 Redis 白名单、每次查库拿最新的 role 和 status(为了降权/禁用立即生效,所以 JWT 只携带 empId、不携带任何权限信息,是纯身份凭证);切面回答”你登录了能不能做这件事”——自定义 @RequireRole 注解 + @Before 切面,切点用 @annotation || @within 同时支持方法级和类级。

覆盖率:管理端 27 个写接口 100% 覆盖(22 个纯 ADMIN、5 个 {ADMIN,PURCHASER})。

我知道的问题,必须主动说:

  1. 默认放行——RoleAspect 里没注解就直接 return。所以新增写接口忘标注就是静默越权,没有编译错误也没有警告。覆盖率 100% 是人工核对出来的,没有任何机制保障。如果要改,我会只对 /admin/** 的写接口默认拒绝——因为只有写操作才有实际危害,而且能让漏标从”放行”变成”被拒”、错误立刻暴露;
  2. 最具体的一个越权是:采购专员能拉全部员工的身份证号。GET /admin/employee/page 和 /{id} 没有注解限制,返回类型是 Employee 实体,里面有 idNumber。密码脱敏做了(setPassword("****")),但只脱了密码——这是黑名单式思路,新增敏感字段就会漏。正确做法是返回 VO(白名单式,不进 VO 就不返回);
  3. @RequireRole({}) 空数组等于放行——因为 value() 无默认值但空数组是合法语法。这是个伪装的保护,比漏标更危险,应该改成 fail-safe 拒绝;
  4. @RequireRole 只有角色级权限,没有数据级权限——所以任何采购专员能审批全公司任何部门的申领单。要做得在 Service 里加部门过滤;
  5. ThreadLocal 的清理是原项目缺失的一环,我补上了——不清的话线程复用会导致 RoleAspect 读到上一个请求的 ADMIN 角色,造成越权。而且这也说明 ThreadLocal 依赖是隐式的,一旦加异步任务就一定失效;
  6. 另外两个相关短板:密码是无盐 MD5(应换 BCrypt + 双算法迁移),Swagger 文档端点未授权可访问(生产必须关闭)。