当你像我一样鉴权

782 阅读6分钟

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载荷中的参数。但这种方法存在以下弊端:

  1. 代码重复:尽管方法已被封装,但每次都需要调用它。对于仅需要鉴权而不需要获取参数的方法,这样的处理显然会导致代码重复和资源浪费。
  2. 耦合度高:我们不应该在业务层处理前端传入的token,更不应该将token参数传递到业务层,因为鉴权与业务之间并不具有直接关联。
  3. 技术停滞:尽管这种方法不会对业务造成严重问题,但技术的停滞不前本身就是一个大问题。

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") // 排除指定请求
}

使用拦截器进行鉴权具有以下特点:

  1. 松耦合:通过使用拦截器,我们实现了鉴权与业务逻辑的分离,达到了松耦合的效果。此外,由于请求在进入控制层之前就被拦截,这不仅大大减少了代码量,还在一定程度上提高了请求处理的速度。
  2. 复杂的二级鉴权:对于需要进行二级鉴权的业务(如基于职级或权级的鉴权),我们可能需要在业务层中再次处理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进行鉴权具有以下特点:

  1. 解耦合:通过这种方法,我们基本上实现了鉴权与业务逻辑的完全分离。这不仅确保了请求在合适的时机被终止或抛出异常进行统一处理,而且保证了代码量适中、不重复且性能良好。
  2. 获取载荷困难:虽然我们成功地解决了耦合问题,但当我们需要从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();

这样,我们成功地实现了完全的解耦,并构建了一个高效且可靠的鉴权模块。期待在评论区看到你的反馈和宝贵建议,尤其是关于技术方面的!