告别 session,还是这个认证方案优秀!

大家好,我是 杰哥

互联网系统,几乎都离不开认证

一个系统中的大部分功能只能有认证信息的用户访问,但是总不可能每次请求都要求客户端专门认证一次吧,要是那样的话,用户每点一次鼠标,就得输入一次用户名密码,那可真是太麻烦了

那么,如何解决这种情况呢?

一 如何实现认证?

(一) session 机制

一种解决方案是由服务器将 session 数据保存至数据库(mysqlreids 等)中。服务收到请求后,都向持久层请求数据进行认证操作。也就是我们最初学习 WEB 项目时比较简单直观的 Cookies-Session 机制,相对于其他方案,常被叫做 Session 机制

Session 机制的流程如下:

1、用户输入登录信息(比如用户名密码),由客户端传递至服务端

2、服务端验证无误之后,将 Session 存储至持久层

3、服务端,返回带有 sessionIDCookie(头部 Set-Cookie

4、客户端,保存 Cookie 信息。将 JSESSIONID 保存至 Cookie

5、客户端请求服务端时,携带着 Cookie

6、服务端检查是否存在对应的 sessionID

7、若存在,则通过认证,服务端返回响应内容;否则,则返回 403-禁止访问

这种机制,其实架构还是比较清晰的。但是会存在以下几个缺点

  • 开销大session 保存在服务器,随着注册用户的增加,必然会引起服务器更大的开销

  • 扩展能力受限。对于分布式环境,如果采用 Session  机制进行认证,那么,就需要每台服务器保存或者共享所有用户的 session 信息。有时候为了解决这种 session 共享的问题,会采用 redis 集群存储 session 的方式来解决。但是这样相对来说复杂度会随之增大

  • CSRF 危险Session 是基于 Cookie 来进行用户识别的, Cookie  如果被截获,用户就会很容易受到跨站请求伪造(**CSRF**)的攻击

    于是便出现了另一种解决方案

于是,便渐渐衍生出另一种解决方案:JWT 机制

(二)JWT 机制

1 认证流程

通俗来讲,JSON Web TokenJWT)是一个含签名并携带用户相关信息的加密串,页面请求校验登录接口时,请求头中携带 JWT 串到后端服务,后端通过签名加密串匹配校验,保证信息未被篡改。校验通过则认为是可靠的请求,将正常返回数据

相较于 Session 机制来说,JWT 机制,服务器干脆不保存 session 数据了,所有认证信息只存储在客户端

服务器端只保留一个秘钥,每次进行认证时,只需要通过这个秘钥,重新加密签名之后,与客户端所传递的 token 信息进行对比即可

因此,其流程与  Session 机制差不多,只是少了服务器存储 token 信息的步骤,并且校验 token 时,是服务端重新加密生成之后与所收到的 token 值进行对比的,而不是通过查询持久层获得

流程如下:

1)用户输入登录信息(比如用户名密码)来请求服务器

2)服务器验证用户的信息

3)通过验证之后,服务器会返回一个 token

4)客户端存储 token(可以选择存储在 Cookie 中,也可以选择存储在 localStorage 中)

5)客户端在请求时一般会通过请求头,携带这个 token

6)服务端会使用秘钥以及用户的相关信息重新计算生成一个 token,然后验证所传递的这个 token

7)若验证成功,则返回数据;否则,返回 403-禁止访问

2 token 的样子

了解了 JWT 的流程,我们来看下 JWT 生成的 token 长什么样

杰哥项目中刚才生成的 token 字符串如下:

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJwYXNzd29yZCI6IjUzYjQzMmU2MTMxNGUwMGJjYzk5Mjg3YWY5NTM3ZGM0IiwiaXNzIjoiamllZ2UiLCJleHAiOjE2NTk0NjM4ODUsImlhdCI6MTY1OTQyNzg4NSwidXNlcm5hbWUiOiJhZG1pbiJ9.z4NFlH92V0fzOxWmxfrsxBb6dIfkMUOdj9slINKVPu8

复制代码

乍一看,好像是一堆”乱码“,其实是由三部分组成:header(头部)、payload(载荷)、signature(签名),以.进行分割,

即:

header.payload.signature

复制代码

Header

header 用来声明类型typ)和算法alg

{
  "typ": "JWT",
  "alg": "HS256"
}

复制代码

alg 属性表示签名的算法(algorithm),默认是 HMAC SHA256(写成 HS256

typ属性表示这个令牌(token)的类型(type),JWT 令牌统一写为JWT

这个 JSON 对象使用 Base64URL 算法转成字符串,就变成上面 token 字符串的第一部分了

Payload

payload一般存放一些不敏感的信息,比如用户名、权限、角色等。

{
  "iss": "jiege",
  "exp": 1659463885,
  "iat": 1659427885,
  "username": "admin"
}

复制代码

除了传递用户信息以外,payload 也可以传递 JWT 所规定的 7 个官方字段:

  • iss (issuer):签发人

  • exp (expiration time):过期时间

  • sub (subject):主题

  • aud (audience):受众

  • nbf (Not Before):生效时间

  • iat (Issued At):签发时间

  • jti (JWT ID):编号

这个 JSON 对象使用 Base64URL 算法转成字符串,就变成上面 token 字符串的第二部分了

Signature

signature则是将 headerpayload 对应的 JSON 结构进行 base64url 编码之后得到的两个串用英文句点号拼接起来,然后根据header里面 alg 指定的签名算法生成出来的

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  
) secret base64 encoded

复制代码

注意:

前面提到,HeaderPayload 串型化的算法是 Base64URL。这个算法跟 Base64 算法基本类似,但有一些小的不同。

JWT 作为一个令牌(token),有些场合可能会放到 URL(比如 api.example.com/?token=xxx)。Base64 有三个字符+/=,在 URL 里面有特殊含义,所以要被替换掉:=被省略、+替换成-/替换成_

二 JWT 实战

好了,认识了 JWT 以后,我们趁热打铁,进入实战环节,通过一个简单的例子,来进一步认识 JWT

1 引入依赖

<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>3.7.0</version>
</dependency>

复制代码

引入 java-jwt 的依赖

2 配置文件 application.yml

server:
  port: 8086

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/jwt_demo?serverTimezone=UTC&useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&useSSL=false
    username: root
    password: 123456
  profiles:
    #默认配置为dev,会在开发调试时跳过token 的校验,提高调试效率
    active: prod

复制代码

分别配置数据库的连接信息和环境信息

这里的环境信息配置为 dev,只是为了在项目中的开发环境中,避免进行每次发起请求时 token 的校验,从而提高调试效率

3 访问资源定义

访问资源:UserController

@RestController
@RequestMapping("/user")
public class UserController {

    @GetMapping("/common")
    public String common() {
        return "hello~ common";
    }

    @GetMapping("/admin")
    public String admin() {
        return "hello~ admin";
    }

}

复制代码

分别定义 /user/common/user/admin 两个接口作为后续的测试访问资源

4 User 类

@Data
@Builder
public class User {

    private Long id;

    private String username;

    private String password;

    private String token;
    //刷新 token
    private String refreshToken;
}

复制代码

定义 User 类,分别包含 用户名、密码、token 以及 refreshToken

5 JWT 的工具类 - JwtService

@Slf4j
@Service
public class JwtService {
    //加密秘钥
    private static final String SECRET_KEY = "wangjienihao";
    // 签发人
    private static final String ISSUER = "jiege";
    // token 过期时间
    public static final Long TOKEN_EXSPIRE_TIME = 1000 * 60 * 60 * 10L;

    /**
     * 生成 token
     * @param userVo 用户信息
     * @return
     */
    public String token(UserVo userVo) {
        //1-确定加密算法
        Algorithm algorithm = Algorithm.HMAC256(SECRET_KEY);
        Date now = new Date();
        //2-开始创建和生成 token
        return JWT.create()
                .withIssuedAt(now)
                .withIssuer(ISSUER)
                .withExpiresAt(new Date(now.getTime() + TOKEN_EXSPIRE_TIME))//token 的过期时间
                .withClaim("username", userVo.getUsername())
                .withClaim("password", userVo.getPassword())
                .sign(algorithm);
    }

    /**
     * 校验用户名
     * @param token token
     * @param username 用户名
     * @return
     */
    public ResponseResult verifyUsername(String token,String username){
       log.info("verify jwt-username - {}",username);
        ResponseResult responseResult = new ResponseResult();
       try {
            //1-定义算法
           Algorithm algorithm = Algorithm.HMAC256(SECRET_KEY);
           //2-进行校验
           JWTVerifier jwtVerifier = JWT.require(algorithm)
                   .withIssuer(ISSUER)
                   .withClaim("username", username)
                   .build();
           jwtVerifier.verify(token);
       } catch (Exception ex){
           responseResult.setStatus(-1);
           responseResult.setMessage("失败!");
           log.error("auth verify fail:{}",ex.getMessage());
       }
        return responseResult;

    }
}

复制代码

分别包括生成 token 方法和校验用户名的方法

1)生成 token

/**
     * 生成 token
     * @param userVo 用户信息
     * @return
     */
    public String token(UserVo userVo) {
        //1-确定加密算法
        Algorithm algorithm = Algorithm.HMAC256(SECRET_KEY);
        Date now = new Date();
        //2-开始创建和生成 token
        return JWT.create()
                //payload 信息开始
                .withIssuedAt(now)
                .withIssuer(ISSUER)
                .withExpiresAt(new Date(now.getTime() + TOKEN_EXSPIRE_TIME))//token 的过期时间
                .withClaim("username", userVo.getUsername())
                .withClaim("password", userVo.getPassword())
                 //payload 信息结束
                 // 签名
                .sign(algorithm);
    }

复制代码

预先定义一个加密秘钥 SECRET_KEYwangjienihao,并确定加密算法为 HMAC256

采用 JWT.create() 方法,分别添加了签发时间、签发人、token 有效期、用户名、密码这几个信息作为 payload,然后采用加密算法进行加密,从而生成 token

这里的 head 定义为了:

{
  "typ": "JWT",
  "alg": "HS256"
}

复制代码

Payload 定义为了:

{
  "password": "53b432e61314e00bcc99287af9537dc4",
  "iss": "jiege",
  "exp": 1659463885,
  "iat": 1659427885,
  "username": "admin"
}

复制代码

而其签名 Signature 则为:

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),

) secret base64 encoded

复制代码

2)验证 token

一般服务端需要校验客户端请求时所携带的 token 是否正确或者是否过期,就需要以下的方法进行校验

/**

   * 校验用户名
      @param token token
        * @param username 用户名
           @return
               */
          public ResponseResult verifyUsername(String token,String username){
          log.info("verify jwt-username - {}",username);
           ResponseResult responseResult = new ResponseResult();
          try {
               //1-定义算法
              Algorithm algorithm = Algorithm.HMAC256(SECRET_KEY);
              //2-进行校验
           JWTVerifier jwtVerifier = JWT.require(algorithm)
          .withIssuer(ISSUER)
          .withClaim("username", username)
          .build();
              jwtVerifier.verify(token);
          } catch (Exception ex){
              responseResult.setStatus(-1);
              responseResult.setMessage("失败!");
              log.error("auth verify fail:{}",ex.getMessage());
          }
           return responseResult;
           }

复制代码

首先,依旧是根据已知的秘钥与加密算法,获得加密算法对象 algorithm

然后采用该算法对象、签发人、用户名等信息生成 JWTVerifier 对象;

接着,调用 JWTVerifierverify() 方法,进行用户名的校验

verify() 方法,会根据 token,解密出 token 中的三部分信息,如官网中解析出来的样子

而我们添加了一个当前的用户名:admin,它会将这个用户名与解密出的 payload 中的 用户名进行比较,如果一致,则表示验证成功,否则验证失败

可以顺便来看下  JWTVerifierverify() 方法:

1)首先根据 token,采用 parser 生成一个 JWTDecoder 对象

2)进入 JWTDecoder 构造方法

分别解析出头部 headerpayload 部分 的 json 字符串

3)再退出来,来到 verify(jwt) 方法

如上所示,分别校验如下 3 个部分

a.verifyAlgorithm(jwt, algorithm):校验获取到的 jwt 的加密方式和发送方的加密方式是否相同

b.algorithm.verify(jwt):通过加密方式的名称,秘钥和头部信息,实体信息等校验获取到的 jwt  的 Signature 和发送方的 Signature 是否一致

c. verifyClaims(jwt, this.claims):校验 payload 中的具体数据,如 username签发人

好了,JwtService 类就完成了,接下来,我们就需要分别实现在用户登录时生成 token,而在接到客户端的请求时,进行 token 的校验工作了~

6 登录 LoginController

一般来讲,正如 Session 会存在过期时间,token 也是会存在过期时间的,比如过了 1个小时,token 过期了,就需要重新生成 token` 了

@RestController
public class LoginController {

    @Resource
    private JwtService jwtService;

    @Resource
    private UserService userService;

    @Resource
    private RedisTemplate<String, UserVo> redisTemplate;


    @PostMapping("/jwt/login")
    public ResponseResult login(String username,String password) throws Exception {
        //1-校验用户名密码是否为空
        if(StrUtil.isBlank(username) || StrUtil.isBlank(password)){
            throw new Exception("用户名或密码不能为空!");
        }

        // 2-根据用户查询用户是否存在
        User user = userService.findByUsername(username);
        if (user == null){
            throw new Exception("用户名或密码有误!");
        }
        //3-验证用户名密码
        password = MD5Util.md5slat(password);
        if (!password.equalsIgnoreCase(user.getPassword())){
            throw new Exception("用户名或密码有误!");
        }
        //4-生成 token
        UserVo userVo =  UserVo.builder().build();
        userVo.setId(user.getId());
        userVo.setUsername(username);
        userVo.setPassword(password);
        String token = jwtService.token(userVo);
        userVo.setToken(token);
        userVo.setRefreshToken(UUID.randomUUID().toString());
        //同时存储用户到 redis 中
        redisTemplate.opsForValue().set(token,userVo,JwtService.TOKEN_EXSPIRE_TIME, TimeUnit.SECONDS);
        return new ResponseResult(userVo);
    }

    @PostMapping("refreshToken")
    public ResponseResult refreshToken(@RequestParam("token") String oldToken){
       //1-获取 token
        UserVo userVo = redisTemplate.opsForValue().get(oldToken);
        if (userVo == null){
            return new ResponseResult(500,"user not found!",null);
        }

        String token = jwtService.token(userVo);
        userVo.setToken(token);
        userVo.setRefreshToken(UUID.randomUUID().toString());
        //同时存储用户到 redis 中
        redisTemplate.delete(oldToken);
        redisTemplate.opsForValue().set(token,userVo,JwtService.TOKEN_EXSPIRE_TIME +10000, TimeUnit.SECONDS);
        return new ResponseResult(userVo);

    }
}

复制代码

这里分别定义了登录接口 /jwt/login刷新 token 接口 /refreshToken

1)登录接口

分别校验了用户名密码是否为空用户名是否存在以及用户名密码是否正确之后,便可以生成 token操作,并将其存入 redis 中,同时采用 UUID 的随机数,生成 refreshToken

调用 jwtService.token() 方法,传递的参数为 userVo 对象

其实,你可能也发现了,如果考虑 token 的刷新,将 token 存入 redis 中,实际上也类似于 Session 机制的做法了,因为它也将 token 存储在了服务器

所以,token 的优势,并不在于扩展性,其实主要还是在于在进行 token 认证时,直接计算生成 token,与客户端所携带的 token 进行对比,而不是从持久层中查询,从而对于大用户量的系统,比较明显地提升了性能

2)刷新 token

其实就是获取 token,然后重新生成 token,并存储在 redis

可以考虑由前端在访问某个功能调用的时候,若检测到 token 过期,然后调用 /refreshToken 接口进行 token 的刷新操作

7 请求时拦截

1)拦截器

@Slf4j
public class AuthorisationInterceptor implements HandlerInterceptor {

    @Autowired
    private JwtService jwtService;

    @Value("${spring.profiles.active}")
    private String profiles;

    private static final String AUTH = "Authorization";
    private static final String AUTH_USERNAME = "username";

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        log.info("执行了 AuthorisationInterceptor 的 preHandle 方法");

        //1-过滤开发环境,开发环境不需要验证token
         if (!StrUtil.isBlank(profiles) && "dev".equals(profiles) ){
             return true;
         }
        //2-ignoreToken,不需要验证 token
        if (ignoreToken((HandlerMethod) handler)) return true;

        //3- 获取 token
        String token = getParamValue(request, AUTH);

        //4- 获取并验证 username
        String username = getParamValue(request, AUTH_USERNAME);
        ResponseResult responseResult = jwtService.verifyUsername(token, username);
        if (responseResult.getStatus()!=1){
            log.error("用户名校验失败");
            throw new ValidationException("300","用户名校验失败!");
        }
        //这里需要注意:
        // 1)如果设置为false时,被请求时,拦截器执行到此处将不会继续操作
        // 2)如果设置为true时,请求将会继续执行后面的操作
        return true;


    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        log.info("执行了 AuthorisationInterceptor 的 postHandle 方法");


    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        log.info("执行了 AuthorisationInterceptor 的 afterCompletion 方法");
    }

   
    private String getParamValue(HttpServletRequest request, String filed) throws ValidationException {
        String value = getParam(request,filed);

        if (StrUtil.isEmpty(value)){
            throw new ValidationException("300",filed+"不允许为空,请重新登录!");
        }

        return value;
    }

    /**
     * 获取参数的值 -- 若参数中不存在,则从请求头中获取
     * @param request 请求
     * @param filedName 参数名称
     * @return
     */
    private static String getParam(HttpServletRequest request,String filedName){
       String param = request.getParameter(filedName);
       if (StrUtil.isEmpty(param)){
           param = request.getHeader(filedName);
       }
       return param;
    }

    /**
     * 忽略 token 的处理
     * @param handler
     * @return
     */
    private boolean ignoreToken(HandlerMethod handler) {
        Method method = handler.getMethod();
        if (method.isAnnotationPresent(IgnoreToken.class)){
            IgnoreToken ignoreToken = method.getAnnotation(IgnoreToken.class);
            return ignoreToken.required();
        }
        return false;
    }
}

复制代码

通过实现 HandlerInterceptor ,定义 Spring 拦截器类 AuthorisationInterceptor 来进行 token 的拦截验证,当然是在调用接口之前进行处理,所以,我们的逻辑主要由其 preHandle() 方法实现

 @Override
 public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        log.info("执行了 AuthorisationInterceptor 的 preHandle 方法");    
   //1-过滤开发环境,开发环境不需要验证token
     if (!StrUtil.isBlank(profiles) && "dev".equals(profiles) ){
         return true;
     }
    //2-ignoreToken,不需要验证 token
    if (ignoreToken((HandlerMethod) handler)) return true;

    //3- 获取 token
    String token = getParamValue(request, AUTH);

    //4- 获取并验证 username
    String username = getParamValue(request, AUTH_USERNAME);
    ResponseResult responseResult = jwtService.verifyUsername(token, username);
    if (responseResult.getStatus()!=1){
        log.error("用户名校验失败");
        throw new ValidationException("300","用户名校验失败!");
    }
    //这里需要注意:
    // 1)如果设置为false时,被请求时,拦截器执行到此处将不会继续操作
    // 2)如果设置为true时,请求将会继续执行后面的操作
    return true;
    }

复制代码

前两步骤,只是为大家提供了一种技巧思路而已

步骤1-过滤开发环境,开发环境不需要验证 token

就是在开发环境如果需要快速测试一下接口,可以考虑先跳过 token 的验证操作

步骤2-ignoreToken,不需要验证 token

则可以考虑对于个别方法可以通过注解的方式,实现忽略 token 的验证操作

接下来,我们分别获取头信息或者参数中 的 token 和用户名,然后调用 jwtService.verifyUsername(token, username) 进行校验,如果校验失败,表示用户名校验失败,也就是说 token 不正确,那么,抛出异常;

否则就会通过这一关,进入下一关的拦截了~

2)WebMvcConfig - 注册拦截器

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Bean
    public AuthorisationInterceptor authorazationIntercepter(){
        return new AuthorisationInterceptor();
    }
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 添加拦截器
        registry.addInterceptor(authorazationIntercepter())
        //指定需要拦截的路径
        .addPathPatterns("/user/**");

    }

}

复制代码

注册 AuthorisationInterceptor 拦截器,并指定需要拦截验证的资源路径:/user/*

三 测试

启动项目,并进行如下测试

1 访问登录接口

http://localhost:8086/jwt/login

参数为 usernamepassword

如预期,得到了 token 信息

2 资源接口访问测试

1)不带 token

若不配置 header 中的 Authorization 认证信息,则会返回 500 的错误(一般可以配置为 403-禁止访问)

2) 带上token 访问

/user/admin 接口,参数为 username、头信息 Authorization 配置为上面获取到的 token 的值

便实现了接口的成功访问

3 刷新 token

传递 token 参数,便可以重新得到一个 token ,然后下次请求重新带上这个 token 即可,直至它过期

总结

从实战中,我们可以发现,使用 JWT,对于大用户量的系统,可以明显降低服务器查询数据库的次数,从而对服务器的性能提升有一定的正向作用

但是对于用户量很少的一些管理平台,其实没有必要采用 JWT,这时,采用 Session 反而更简单,既不会有太大的数据库查询性能影响,也不需要引入 Redis 进行 token 的刷新,带来更大的复杂度

JWT 本身包含了认证信息,一旦泄露,任何人都可以获得该令牌的所有权限。所以为了减少盗用的情况,JWT 的有效期应该设置得比较短。对于一些比较重要的权限,使用时应该再次对用户进行认证

此外,在实际项目中,JWT 不应该使用 HTTP 协议明码传输,要使用 HTTPS 协议传输

文章演示代码地址:github.com/helemile/Sp…

参考链接:

www.ruanyifeng.com/blog/2018/0…

嗯,就这样。每天学习一点,时间会见证你的强大~

欢迎大家关注我们的公众号,一起持续性学习吧~

往期精彩回顾

总结复盘

架构设计读书笔记与感悟总结

带领新人团队的沉淀总结

复盘篇:问题解决经验总结复盘

网络篇

网络篇(四):《图解 TCP/IP》读书笔记

网络篇(一):《趣谈网络协议》读书笔记(一)

事务篇章

事务篇(四):Spring事务并发问题解决

事务篇(三):分享一个隐性事务失效场景

事务篇(一):毕业三年,你真的学会事务了吗?

Docker篇章

Docker篇(六):Docker Compose如何管理多个容器?

Docker篇(二):Docker实战,命令解析

Docker篇(一):为什么要用Docker?

..........

SpringCloud篇章

Spring Cloud(十三):Feign居然这么强大?

Spring Cloud(十):消息中心篇-Kafka经典面试题,你都会吗?

Spring Cloud(九):注册中心选型篇-四种注册中心特点超全总结

Spring Cloud(四):公司内部,关于Eureka和zookeeper的一场辩论赛

..........

Spring Boot篇章

Spring Boot(十二):陌生又熟悉的 OAuth2.0 协议,实际上每个人都在用

Spring Boot(七):你不能不知道的Mybatis缓存机制!

Spring Boot(六):那些好用的数据库连接池们

Spring Boot(四):让人又爱又恨的JPA

SpringBoot(一):特性概览

..........

翻译

[译]用 Mint 这门强大的语言来创建一个 Web 应用

【译】基于 50 万个浏览器指纹的新发现

使用 CSS 提升页面渲染速度

WebTransport 会在不久的将来取代 WebRTC 吗?

.........

职业、生活感悟

你有没有想过,旅行的意义是什么?

程序员的职业规划

灵魂拷问:人生最重要的是什么?

如何高效学习一个新技术?

如何让自己更坦然地度过一天?

..........

猜你喜欢

转载自juejin.im/post/7128212823892557861