• 如何使用JWT做鉴权
  • 发布于 2个月前
  • 201 热度
    0 评论
  • 久就旧
  • 0 粉丝 25 篇博客
  •   
一. 前言
随着增长的网络威胁和用户隐私重要性的不断增长,鉴权的重要性日渐增长,如何优雅地完成鉴权是一个值得探索的问题。鉴权无非就是通过用户名和密码确定用户的身份的一个过程,在不断演变中出现了token简化了鉴权,然而在代码中我们通常难以做到真正的简化,因为鉴权往往伴随着大量重复的操作和高度的耦合,以下是我个人在不断实践中迭代的鉴权方式,希望对读者有一些启发。

(本文面向后端入门同学或是对鉴权欠缺研究的道友)


二. 准备
首先我们会以JWT作为鉴权的解决方案来说明,毕竟在分布式的风潮下,有着“无状态认证”和“跨域认证”的JWT几乎是鉴权的“最佳人选”。那么本文只讲解如何鉴权,并不解释如何使用JWT。那么这里我就引用上述文章中完成的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字段中)。


三. 封装
最简单的方式就是封装,将处理token的代码逻辑封装成一个方法在需要的时候直接调用,比如JWTUtils已经封装好了checkToken(String token)以及parseToken(String token)方法,那么我们再将这两个方法的联合处理封装一次,之后每次只需要调用方法传入token即可完成鉴权并拿到载荷中的参数,但是我们分析一下这么做的弊端:
代码重复:即使已经封装了方法,但是每次都需要调用该方法,而且有些方法只需要鉴权不需要拿到其中的参数,显然这么做代码重复且浪费资源。
耦合度高:无论前端通过什么方式传入token,我们都不应该在业务层去处理token,甚至说token这个参数就不应该传递到业务层,毕竟鉴权和业务并没有必要联系。
表达低级:虽然这并不会导致业务出现大问题,但是技术停滞不前本身就是大问题。

四. 拦截器
鉴于传统封装的方式对代码的扩展性和维护性都颇有影响,我尝试引入了拦截器:
@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字段,否则就要实现多个拦截器,这将导致管理十分复杂。

五. 拦截器 + 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);
    }
}
同样我们也分析一下:
解耦合:现在我们可以说我们基本上做到了鉴权与业务层的解耦,并且让请求在合适的时候终止或者抛出异常统一处理,此时的代码量机会不重复并且性能也在一个合适的区间。
获取载荷困难:尽管我们处理好了耦合问题,但是当我们需要token的载荷字段时我们依然需要在业务层用request去获取,类似如下方式:
Integer uid = (Integer) request.getAttribute("uid");

虽然这种情况并不多,但是对于完美主义者来说是不够的,为此我又打算再抽象出一个全局类去获取这些载荷字段,当我想使用这些字段时可以直接调用静态方法获取而不必携带request参数,我想ThreadLocal应该可以做到。


五. 拦截器 + 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();
显而易见,我们彻底实现了解耦,并且完成了一个高可用的鉴权模块!期待能在评论区看到你的留言!也期待每一条技术方面的建议!

用户评论