Jwt 如何在 springboot 项目中进行接口访问鉴权

文章目录

    • 1 springboot 框架负责接口的拦截和放行
      • 1.1 原理
      • 1.2 思路
      • 1.3 坑: Springboot 访问了错误处理路径 `/error`
    • 2 jwt token 负责携带数据和签名的生成及校验
      • 2.1 初始化
      • 2.2 设置 Header
      • 2.3 携带数据 payload
      • 2.4 签名 sign 后, 生成 token
      • 2.5 校验
      • 2.6 获取信息
      • 2.7 字段说明
    • 3 拦截器代码
    • 其它

结合以下文章:
jwt.io 官网详细介绍

SpringBoot集成JWT实现Token登录验证

SpringBoot项目使用JWT+拦截器实现token验证

spring-boot + JWT实现TOKEN登录接口验证

SpringBoot集成JWT实现token验证

SpringBoot 开发 – JWT 认证教程

1 springboot 框架负责接口的拦截和放行

1.1 原理

使用 HandlerInterceptor (对于 springboot 框架不推荐使用 doFilter)

1.2 思路

白名单思路, 拦截所有接口目录/**, 放行需要的接口

@Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(authenticationInterceptor())
                .addPathPatterns("/**");
}

1.3 坑: Springboot 访问了错误处理路径 /error

接口程序中有未处理的异常, 报了 Null Pointer Exception, Springboot 调用默认的错误处理接口 /error 企图调用错误处理程序, 第二次触发了 HandlerInterceptor, 由于/error不带 token, 所以被拒绝,最终报 token 校验不通过的错误信息.

这里的解决方法:

1 处理程序中所有异常, 在最外层捕捉不可预见的异常, 返回统一错误信息,服务内部错误
2 自定义 springboot 的 error path 为符合自己程序的路径, 并用 controller 定义处理程序. 当springbot框架本身或者依赖包出现不可预知的错误时,转到这里, 可以返回统一错误信息

其它方法也可以尝试使用 @ControllerAdvice 自定义异常处理类, 处理程序自身不可预知的错误

2 jwt token 负责携带数据和签名的生成及校验

官方库

<dependency>
    <groupId>com.auth0groupId>
    <artifactId>java-jwtartifactId>
    <version>3.19.4version>
dependency>

2.1 初始化

JWTCreator.Builder builder = JWT.create();

2.2 设置 Header

Map<String,Object> headerMap = new HashMap<>();
builder.withHeader(headerMap);

2.3 携带数据 payload

自定义数据

for (Map.Entry<String,String> entry:data.entrySet()) {
    builder.withClaim(entry.getKey(), entry.getValue());
}

设置过期时间

builder.withExpiresAt(expireDate);

Token 放在请求Header的Authorization字段里。Token 携带数据userId

Token 的格式:

header

{
  "kid": "XXXXXXXXXXXXXXXXXX0MDVmLWIyMjEtMjQ1MWU3NWYxXXXXX5",
  "typ": "JWT",
  "alg": "RS256"
}

payload

{
  "exp": 1684829637,
  "userId": "xxxxxxxxxxxx==",
  "iat": 1684829607
}

2.4 签名 sign 后, 生成 token

token = builder.sign(Algorithm.HMAC256(secretKey))

如果使用RSA非对称算法,可以从jwt库的源码看出使用私钥签名

token = builder.sign(Algorithm.RSA256(rsaPrivateKey))

java-jwt-4.0.0-sources.jar!/com/auth0/jwt/algorithm/RSAAlgorithm.java

    @Override
    public byte[] sign(byte[] headerBytes, byte[] payloadBytes) throws SignatureGenerationException {
        try {
            RSAPrivateKey privateKey = keyProvider.getPrivateKey();
            if (privateKey == null) {
                throw new IllegalStateException("The given Private Key is null.");
            }
            return crypto.createSignatureFor(getDescription(), privateKey, headerBytes, payloadBytes);
        } catch (NoSuchAlgorithmException | SignatureException | InvalidKeyException | IllegalStateException e) {
            throw new SignatureGenerationException(this, e);
        }
    }

2.5 校验

配置算法

JWTVerifier verifier = JWT.require(Algorithm.HMAC256(secretKey)).build();

如果使用RSA非对称算法,可以从jwt库的源码看出使用公钥校验

JWTVerifier verifier = JWT.require(Algorithm.RSA256(rsaPublicKey)).build();

java-jwt-4.0.0-sources.jar!/com/auth0/jwt/algorithm/RSAAlgorithm.java

    @Override
    public void verify(DecodedJWT jwt) throws SignatureVerificationException {
        try {
            byte[] signatureBytes = Base64.getUrlDecoder().decode(jwt.getSignature());
            RSAPublicKey publicKey = keyProvider.getPublicKeyById(jwt.getKeyId());
            if (publicKey == null) {
                throw new IllegalStateException("The given Public Key is null.");
            }
            boolean valid = crypto.verifySignatureFor(
                    getDescription(), publicKey, jwt.getHeader(), jwt.getPayload(), signatureBytes);
            if (!valid) {
                throw new SignatureVerificationException(this);
            }
        } catch (NoSuchAlgorithmException | SignatureException | InvalidKeyException
                | IllegalArgumentException | IllegalStateException e) {
            throw new SignatureVerificationException(this, e);
        }
    }

校验

DecodedJWT decodedJWT  = verifier.verify(token);

校验的方法是再生成一遍进行比较

2.6 获取信息

两种方法

  • 第一种方法: 在 HandlerInterceptor 里的 PreHandle 校验通过后, 立即解析 token, 拿到数据. 把解析结果放入 threadlocal 变量, 这样在整个请求的主线程里, 可以使用该变量, 并且该变量对其它线程不可见, 在请求结束的 afterCompletion() 方法里把 threadlocal 变量注销释放.
  • 第二种方法: 在需要获取信息的时候, 先获取该severlet请求的上下文 RequestContextHolder, 进而拿到请求Request中的 header, 进而拿到 token, 重新解析 token, 获取数据. 由于接口进来时, 已经通过校验, 可以不通过校验的方式获取解析后的token, 直接调用解析方法进行解析即可.

2.7 字段说明

nbf 可用于多机部署时, 服务器之间时间的微小差异

3 拦截器代码

Springboot 建议使用HandlerInterceptor进行拦截

定义 annotation, 对这个annotation 修饰的接口进行拦截


@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface AccessWithoutToken {
    boolean required() default true;
}

@Slf4j
public class AuthenticationInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        log.info("Request from {} to URI: {}, URL: {}", HttpClientUtil.getRemoteIp(request), request.getRequestURI(), request.getRequestURL().toString());
        // 如果不是映射到方法直接通过
        if (!(handler instanceof HandlerMethod)) {
            return true;
        }

        HandlerMethod handlerMethod = (HandlerMethod) handler;
        Method method = handlerMethod.getMethod();
        log.info("Method {}, {}", method.getName(), method.getDeclaredAnnotations());
        if (method.isAnnotationPresent(AccessWithoutToken.class)) {
            AccessWithoutToken accessWithoutToken = method.getAnnotation(AccessWithoutToken.class);
            if (accessWithoutToken.required()) {
                return true;
            }
        }

        // Authorization: Bearer 
        String authorization = request.getHeader("Authorization");

        // TODO check token
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json; charset=utf-8");
        if (!org.apache.commons.lang3.StringUtils.isBlank(authorization)) {

            String[] authorizationStr= StringUtils.split(authorization, SPACE);
            if (2 == authorizationStr.length) {
                String authType = authorizationStr[0];
                String token = authorizationStr[1];

                if (authType.equals("Bearer") && !org.apache.commons.lang3.StringUtils.isBlank(token)) {

                    DecodedJWT decodedJWT = JwtUtil.verifyToken(token);
                    if (null != decodedJWT) {
                        // TODO 校验通过获取信息
                        log.info("token: {}......, 校验通过, 签发时间{}, userId{}", token.substring(0, 32), decodedJWT.getIssuedAt().getTime(), decodedJWT.getClaim("userId"));
                        return true;
                    }
                } else {
                    log.error("Token 校验失败, auth prefix={}, token={}", authType, token);
                }
            } else {
                log.error("Token 校验失败, http header 中解析 Authorization 字段错误, authorization={}", authorization);
            }

        } else {
            log.error("Token 校验失败, http header 中没有 Authorization 字段, authorization={}", authorization);
        }
        try (PrintWriter writer = response.getWriter()) {
            writer.print(RestResponse.fail(RestCode.USER_VALIDATE_FAIL_JWT_TOKEN));
        } catch (Exception e) {
            log.error("登录 JWT Token 校验失败 未知错误 error=", e);
        }

        return false;
    }
}

扩展阅读

OWASP Top Ten 2021 : Related Cheat Sheets

okta What-is-the-lifetime-of-the-JWT-tokens

其它

关于springboot 默认 error path

customize springboot default error path
ErrorMvcAutoConfiguration.java
get-started-with-custom-error-handling-in-spring-boot-java/
spring-boot-custom-error-page
how-to-fix-spring-boot-customize-http-error-response-in-java
howto.actuator.customize-whitelabel-error-page
boot-features-error-handling

你可能感兴趣的:(Java,spring,boot,java,后端,JWT)