1. 前言
随着网络威胁的增长和用户隐私的日益重要性,鉴权成为了一个关键环节。鉴权基本上是通过用户名和密码来验证用户身份的过程。尽管token为鉴权带来了简化,但在实际代码中真正实现简化却并不容易。这是因为鉴权经常涉及大量的重复操作和高度的耦合。在本文中,我将分享我在实践中不断迭代的鉴权方法,希望能为后端新手或对鉴权不够了解的读者提供一些启示。
(本文主要面向后端入门的同学或对鉴权技术不够熟悉的朋友。)
2. 准备
在鉴权的解决方案中,我选择使用JWT。在当前的分布式发展趋势下,拥有“无状态认证”和“跨域认证”特性的JWT无疑成为了鉴权的首选。本文将重点讨论如何实现鉴权,而不深入到JWT的具体使用方法。如需了解更多关于JWT的内容,可以参考我之前的文章「Re:从零开始的JWTUtils」。在此,我将使用该文章中介绍的JWTUtils作为示例(如果你已经有类似的工具,可以根据需要进行替换。即使你未读过上述文章,也不会影响你理解本文的内容)。
首先我们导入JWTUtils的依赖(版本可能略有更新):
<dependency>
<groupId>io.github.steadon</groupId>
<artifactId>utils</artifactId>
<version>2.1.3</version>
</dependency>
然后我们完成最基本的签名返回给前端:
// 配置token载荷中的字段
@Data
@AllArgsConstructor
public class LoginBackVo {
@Token
private Integer uid;
@Token
private String phone;
}
....
// 注入工具依赖
@Autowired
private JWTUtils jwtUtils;
....
// 签发token并返回前端
LoginBackVo backVo = new LoginBackVo(user.getId(), phone);
return CommonResult.success(new TokenResultC(jwtUtils.createToken(backVo)));
现在,我们已经完成了鉴权的基本操作。接下来,我们将探索当前端携带token请求接口时,如何更高效且优雅地处理这些请求。本文假设token存放在Header.Authorization字段中。
你对封装方式的鉴权进行了清晰的描述,并指出了其弊端。为了使这部分更加简洁和有条理,我为你提供了以下修改建议:
3. 封装
封装是处理token的最简单方法。例如,JWTUtils已经为我们封装了checkToken(String token)和parseToken(String token)方法。我们可以进一步将这两个方法的联合处理逻辑进行再次封装。这样,在每次需要时,只需调用该方法并传入token即可完成鉴权并获取token载荷中的参数。但这种方法存在以下弊端:
- 代码重复:尽管方法已被封装,但每次都需要调用它。对于仅需要鉴权而不需要获取参数的方法,这样的处理显然会导致代码重复和资源浪费。
- 耦合度高:我们不应该在业务层处理前端传入的token,更不应该将token参数传递到业务层,因为鉴权与业务之间并不具有直接关联。
- 技术停滞:尽管这种方法不会对业务造成严重问题,但技术的停滞不前本身就是一个大问题。
4. 拦截器
考虑到传统封装方法可能对代码的扩展性和维护性产生不利影响,我决定尝试另一种方法:使用拦截器。
@Component
public class JwtInterceptor implements HandlerInterceptor {
@Autowired
private JWTUtils jwtUtils;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 从请求头中获取 JWT Token
String token = request.getHeader("Authorization");
// 浏览器option预检查放行
if (!(handler instanceof HandlerMethod)) {
return true;
}
// 验证token
if (!jwtUtils.checkToken(token)) {
// 设置 HTTP 状态码为 401
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return false;
}
// 验证通过,获取uid并向下传递
LoginBackVo loginBackVo = jwtUtils.parseToken(token, LoginBackVo.class);
request.setAttribute("uid", loginBackVo.getUid());
request.setAttribute("phone", loginBackVo.getPhone());
return true;
}
}
如此我们只需要为拦截器配置拦截范围即可完成鉴权:
@Resource
private JwtInterceptor jwtInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 注册拦截器,并配置拦截路径
registry.addInterceptor(jwtInterceptor)
.addPathPatterns("/**") // 拦截所有请求
.excludePathPatterns("/api/login") // 排除指定请求
}
使用拦截器进行鉴权具有以下特点:
- 松耦合:通过使用拦截器,我们实现了鉴权与业务逻辑的分离,达到了松耦合的效果。此外,由于请求在进入控制层之前就被拦截,这不仅大大减少了代码量,还在一定程度上提高了请求处理的速度。
- 复杂的二级鉴权:对于需要进行二级鉴权的业务(如基于职级或权级的鉴权),我们可能需要在业务层中再次处理token中的
permission字段。另一种选择是实现多个拦截器,但这可能导致管理变得复杂。
5. 拦截器 + AOP
为了更好地处理二级鉴权,我决定结合拦截器和AOP进行统一处理。我的思考是,鉴权应当独立于业务逻辑之外,而基于鉴权后的二级权限划分则是业务逻辑的一部分。因此,我在业务层引入了前置通知进行权限检查,并统一拦截非法请求。(读者需要具备一些AOP的基础知识来理解此部分内容。)
@Aspect
@Component
public class PermissionAspect {
@Autowired
private HttpServletResponse response;
/* 对相关业务模块织入前置通知 */
@Before("execution(* com.example.test.service.impl.OrderServiceImpl.*(..))")
public void beforeRequest() {
checkPermission();
}
/* 二次校验token中的 permission */
private void checkPermission() {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
Byte permission = (Byte) request.getAttribute("permission");
// 鉴别条件灵活处理
if (permission == 0) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
throw new UnauthorizedException("no permission!");
}
}
}
但是AOP提供了一种修饰方式,它本身不能直接结束请求并向前端响应401状态码。因此,我们选择在此处抛出一个自定义异常。这样做的目的是在全局异常处理中捕获此异常并终止请求。
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(UnauthorizedException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED) // This ensures that the HTTP status is set to 401
public ResponseEntity<String> handleUnauthorizedException(UnauthorizedException ex) {
return new ResponseEntity<>(ex.getMessage(), HttpStatus.UNAUTHORIZED);
}
}
结合拦截器和AOP进行鉴权具有以下特点:
- 解耦合:通过这种方法,我们基本上实现了鉴权与业务逻辑的完全分离。这不仅确保了请求在合适的时机被终止或抛出异常进行统一处理,而且保证了代码量适中、不重复且性能良好。
- 获取载荷困难:虽然我们成功地解决了耦合问题,但当我们需要从token中获取载荷字段时,仍然需要在业务层通过
request进行操作,具体如下:
Integer uid = (Integer) request.getAttribute("uid");
虽然这种情况并不多,但是对于完美主义者来说是不够的,为此我又打算再抽象出一个全局类去获取这些载荷字段,当我想使用这些字段时可以直接调用静态方法获取而不必携带request参数,我想ThreadLocal应该可以做到。
5. 拦截器 + ThreadLocal
当我将token解析后的参数存入ThreadLocal,一切都是那么的刚好:
public class TokenHandler {
private static final ThreadLocal<String> username = new ThreadLocal<>();
public static void set(String payload) {
username.set(payload);
}
public static String get() {
return username.get();
}
public static void remove() {
username.remove();
}
}
....
// 将载荷存入ThreadLocal
AuthorizationParam authorizationParam = jwtUtils.parseToken(token, AuthorizationParam.class);
TokenHandler.set(authorizationParam.getUsername());
....
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
// 清除 ThreadLocal 中的数据(不清除会导致数据错乱)
TokenHandler.remove();
}
至此,我相信我们已经达到了对"优雅"的期望。只需一行简单的代码,即可轻松获取所需的参数:
String username = TokenHandler.get();
这样,我们成功地实现了完全的解耦,并构建了一个高效且可靠的鉴权模块。期待在评论区看到你的反馈和宝贵建议,尤其是关于技术方面的!