当你像我一样鉴权

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即可完成鉴权并拿到载荷中的参数,但是我们分析一下这么做的弊端:

  1. 代码重复:即使已经封装了方法,但是每次都需要调用该方法,而且有些方法只需要鉴权不需要拿到其中的参数,显然这么做代码重复且浪费资源。
  2. 耦合度高:无论前端通过什么方式传入token,我们都不应该在业务层去处理token,甚至说token这个参数就不应该传递到业务层,毕竟鉴权和业务并没有必要联系。
  3. 表达低级:虽然这并不会导致业务出现大问题,但是技术停滞不前本身就是大问题。

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

同样我们来分析一下:

  1. 松耦合:此时我们将鉴权和业务层分离实现了松耦合,并且在请求进入控制层之前就统一拦截住了,极大减少了代码量并且在一定程度上提高了请求处理速度。
  2. 二级鉴权复杂 :如果业务需要二级鉴权(比如职级、权级)就只能再次在业务层中处理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);
    }
}

同样我们也分析一下:

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

虽然这种情况并不多,但是对于完美主义者来说是不够的,为此我又打算再抽象出一个全局类去获取这些载荷字段,当我想使用这些字段时可以直接调用静态方法获取而不必携带request参数(我想LocalThread应该可以做到),碍于某些原因我还没时间去实践,有经验的读者可以评论区留言与我交流分享。

相关推荐
喵叔哟几秒前
重构代码之移动字段
java·数据库·重构
喵叔哟1 分钟前
重构代码之取消临时字段
java·前端·重构
fa_lsyk3 分钟前
maven环境搭建
java·maven
Daniel 大东22 分钟前
idea 解决缓存损坏问题
java·缓存·intellij-idea
wind瑞29 分钟前
IntelliJ IDEA插件开发-代码补全插件入门开发
java·ide·intellij-idea
HappyAcmen29 分钟前
IDEA部署AI代写插件
java·人工智能·intellij-idea
马剑威(威哥爱编程)34 分钟前
读写锁分离设计模式详解
java·设计模式·java-ee
鸽鸽程序猿35 分钟前
【算法】【优选算法】前缀和(上)
java·算法·前缀和
修道-032336 分钟前
【JAVA】二、设计模式之策略模式
java·设计模式·策略模式
九圣残炎41 分钟前
【从零开始的LeetCode-算法】2559. 统计范围内的元音字符串数
java·算法·leetcode