1. 前言
随着增长的网络威胁和用户隐私重要性的不断增长,鉴权的重要性日渐增长,如何优雅地完成鉴权是一个值得探索的问题。 鉴权无非就是通过用户名和密码确定用户的身份的一个过程,在不断演变中出现了token简化了鉴权,然而在代码中我们通常难以做到真正的简化,因为鉴权往往伴随着大量重复的操作和高度的耦合,以下是我个人在不断实践中迭代的鉴权方式,希望对读者有一些启发。
(本文面向后端入门同学或是对鉴权欠缺研究的道友)
2. 准备
首先我们会以JWT作为鉴权的解决方案来说明,毕竟在分布式的风潮下,有着"无状态认证"和"跨域认证"的JWT几乎是鉴权的"最佳人选"。那么本文只讲解如何鉴权,并不解释如何使用JWT,详细内容可以阅读我的文章「Re:从零开始的JWTUtils」。那么这里我就引用上述文章中完成的JWTUtils
作为例子讲解(读者按自身需求替换为同类工具类即可,如未学习上文并不会影响本文的阅读体验)。
首先我们导入JWTUtils
的依赖(版本可能略有更新):
pom
<dependency>
<groupId>io.github.steadon</groupId>
<artifactId>utils</artifactId>
<version>2.1.3</version>
</dependency>
然后我们完成最基本的签名返回给前端:
java
// 配置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. 拦截器
鉴于传统封装的方式对代码的扩展性和维护性都颇有影响,我尝试引入了拦截器:
java
@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;
}
}
如此我们只需要为拦截器配置拦截范围即可完成鉴权:
java
@Resource
private JwtInterceptor jwtInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 注册拦截器,并配置拦截路径
registry.addInterceptor(jwtInterceptor)
.addPathPatterns("/**") // 拦截所有请求
.excludePathPatterns("/api/login") // 排除指定请求
}
同样我们来分析一下:
- 松耦合:此时我们将鉴权和业务层分离实现了松耦合,并且在请求进入控制层之前就统一拦截住了,极大减少了代码量并且在一定程度上提高了请求处理速度。
- 二级鉴权复杂 :如果业务需要二级鉴权(比如职级、权级)就只能再次在业务层中处理token传递的
permission
字段,否则就要实现多个拦截器,这将导致管理十分复杂。
5. 拦截器 + AOP
为了应对二级鉴权我又引入了aop进行统一处理,此时我的理解是鉴权是业务之外的,而通过鉴权后的二级权限划分应该属于业务内的,所以我在业务层中织入了前置通知(此处需要有一定的aop基础知识)进行权限鉴定并统一拦截非法请求:
java
@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,因此我们在此处抛出了一个自定义的异常,目的是在全局异常处理中捕获异常终止请求:
java
@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);
}
}
同样我们也分析一下:
- 解耦合:现在我们可以说我们基本上做到了鉴权与业务层的解耦,并且让请求在合适的时候终止或者抛出异常统一处理,此时的代码量机会不重复并且性能也在一个合适的区间。
- 获取载荷困难 :尽管我们处理好了耦合问题,但是当我们需要token的载荷字段时我们依然需要在业务层用
request
去获取,类似如下方式:
java
Integer uid = (Integer) request.getAttribute("uid");
虽然这种情况并不多,但是对于完美主义者来说是不够的,为此我又打算再抽象出一个全局类去获取这些载荷字段,当我想使用这些字段时可以直接调用静态方法获取而不必携带request
参数(我想LocalThread
应该可以做到),碍于某些原因我还没时间去实践,有经验的读者可以评论区留言与我交流分享。