JWT 實現登陸認證及 Token 自動續期


過去這段時間主要負責了項目中的用戶管理模塊,用戶管理模塊會涉及到加密及認證流程,加密已經在前面的文章中介紹了,可以閱讀用戶管理模塊:

https://juejin.cn/post/6916150628955717646

今天就來講講認證功能的技術選型及實現。技術上沒啥難度當然也沒啥挑戰,但是對一個原先沒寫過認證功能的菜雞甜來說也是一種鍛鍊吧

技術選型

要實現認證功能,很容易就會想到 JWT 或者 session,但是兩者有啥區別?各自的優缺點?應該 Pick 誰?奪命三連

區別

基於 session 和基於 JWT 的方式的主要區別就是用戶的狀態保存的位置,session 是保存在服務端的,而 JWT 是保存在客戶端的

認證流程

基於 session 的認證流程
基於 JWT 的認證流程

優缺點

JWT 保存在客戶端,在分佈式環境下不需要做額外工作。而 session 因爲保存在服務端,分佈式環境下需要實現多機數據共享 session 一般需要結合 Cookie 實現認證,所以需要瀏覽器支持 cookie,因此移動端無法使用 session 認證方案

安全性

JWT 的 payload 使用的是 base64 編碼的,因此在 JWT 中不能存儲敏感數據。而 session 的信息是存在服務端的,相對來說更安全

如果在 JWT 中存儲了敏感信息,可以解碼出來非常的不安全

性能

經過編碼之後 JWT 將非常長,cookie 的限制大小一般是 4k,cookie 很可能放不下,所以 JWT 一般放在 local storage 裏面。並且用戶在系統中的每一次 http 請求都會把 JWT 攜帶在 Header 裏面,HTTP 請求的 Header 可能比 Body 還要大。而 sessionId 只是很短的一個字符串,因此使用 JWT 的 HTTP 請求比使用 session 的開銷大得多

一次性

無狀態是 JWT 的特點,但也導致了這個問題,JWT 是一次性的。想修改裏面的內容,就必須簽發一個新的 JWT

無法廢棄

一旦簽發一個 JWT,在到期之前就會始終有效,無法中途廢棄。若想廢棄,一種常用的處理手段是結合 redis

續簽

如果使用 JWT 做會話管理,傳統的 cookie 續簽方案一般都是框架自帶的,session 有效期 30 分鐘,30 分鐘內如果有訪問,有效期被刷新至 30 分鐘。一樣的道理,要改變 JWT 的有效時間,就要簽發新的 JWT。

最簡單的一種方式是每次請求刷新 JWT,即每個 HTTP 請求都返回一個新的 JWT。這個方法不僅暴力不優雅,而且每次請求都要做 JWT 的加密解密,會帶來性能問題。另一種方法是在 redis 中單獨爲每個 JWT 設置過期時間,每次訪問時刷新 JWT 的過期時間

選擇 JWT 或 session

我投 JWT 一票,JWT 有很多缺點,但是在分佈式環境下不需要像 session 一樣額外實現多機數據共享,雖然 seesion 的多機數據共享可以通過粘性 session、session 共享、session 複製、持久化 session、terracoa 實現 seesion 複製等多種成熟的方案來解決這個問題。但是 JWT 不需要額外的工作,使用 JWT 不香嗎?且 JWT 一次性的缺點可以結合 redis 進行彌補。

揚長補短,因此在實際項目中選擇的是使用 JWT 來進行認證

功能實現

JWT 所需依賴

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

JWT 工具類

public class JWTUtil {
    private static final Logger logger = LoggerFactory.getLogger(JWTUtil.class);
    //私鑰
    private static final String TOKEN_SECRET = "123456";
    /**
     * 生成token,自定義過期時間 毫秒
     *
     * @param userTokenDTO
     * @return
     */
    public static String generateToken(UserTokenDTO userTokenDTO) {
        try {
            // 私鑰和加密算法
            Algorithm algorithm = Algorithm.HMAC256(TOKEN_SECRET);
            // 設置頭部信息
            Map<String, Object> header = new HashMap<>(2);
            header.put("Type", "Jwt");
            header.put("alg", "HS256");
            return JWT.create()
                    .withHeader(header)
                    .withClaim("token", JSONObject.toJSONString(userTokenDTO))
                    //.withExpiresAt(date)
                    .sign(algorithm);
        } catch (Exception e) {
            logger.error("generate token occur error, error is:{}", e);
            return null;
        }
    }
    /**
     * 檢驗token是否正確
     *
     * @param token
     * @return
     */
    public static UserTokenDTO parseToken(String token) {
        Algorithm algorithm = Algorithm.HMAC256(TOKEN_SECRET);
        JWTVerifier verifier = JWT.require(algorithm).build();
        DecodedJWT jwt = verifier.verify(token);
        String tokenInfo = jwt.getClaim("token").asString();
        return JSON.parseObject(tokenInfo, UserTokenDTO.class);
    }
}

說明:

Redis 工具類

public final class RedisServiceImpl implements RedisService {
    /**
     * 過期時長
     */
    private final Long DURATION = 1 * 24 * 60 * 60 * 1000L;
    @Resource
    private RedisTemplate redisTemplate;
    private ValueOperations<String, String> valueOperations;
    @PostConstruct
    public void init() {
        RedisSerializer redisSerializer = new StringRedisSerializer();
        redisTemplate.setKeySerializer(redisSerializer);
        redisTemplate.setValueSerializer(redisSerializer);
        redisTemplate.setHashKeySerializer(redisSerializer);
        redisTemplate.setHashValueSerializer(redisSerializer);
        valueOperations = redisTemplate.opsForValue();
    }
    @Override
    public void set(String key, String value) {
        valueOperations.set(key, value, DURATION, TimeUnit.MILLISECONDS);
        log.info("key={}, value is: {} into redis cache", key, value);
    }
    @Override
    public String get(String key) {
        String redisValue = valueOperations.get(key);
        log.info("get from redis, value is: {}", redisValue);
        return redisValue;
    }
    @Override
    public boolean delete(String key) {
        boolean result = redisTemplate.delete(key);
        log.info("delete from redis, key is: {}", key);
        return result;
    }
    @Override
    public Long getExpireTime(String key) {
        return valueOperations.getOperations().getExpire(key);
    }
}

RedisTemplate 簡單封裝

業務實現

登陸功能
public String login(LoginUserVO loginUserVO) {
    //1.判斷用戶名密碼是否正確
    UserPO userPO = userMapper.getByUsername(loginUserVO.getUsername());
    if (userPO == null) {
        throw new UserException(ErrorCodeEnum.TNP1001001);
    }
    if (!loginUserVO.getPassword().equals(userPO.getPassword())) {
        throw new UserException(ErrorCodeEnum.TNP1001002);
    }
    //2.用戶名密碼正確生成token
    UserTokenDTO userTokenDTO = new UserTokenDTO();
    PropertiesUtil.copyProperties(userTokenDTO, loginUserVO);
    userTokenDTO.setId(userPO.getId());
    userTokenDTO.setGmtCreate(System.currentTimeMillis());
    String token = JWTUtil.generateToken(userTokenDTO);
    //3.存入token至redis
    redisService.set(userPO.getId(), token);
    return token;
}

說明:

登出功能
public boolean loginOut(String id) {
     boolean result = redisService.delete(id);
     if (!redisService.delete(id)) {
        throw new UserException(ErrorCodeEnum.TNP1001003);
     }
     return result;
}

將對應的 key 刪除即可

更新密碼功能
public String updatePassword(UpdatePasswordUserVO updatePasswordUserVO) {
    //1.修改密碼
    UserPO userPO = UserPO.builder().password(updatePasswordUserVO.getPassword())
            .id(updatePasswordUserVO.getId())
            .build();
    UserPO user = userMapper.getById(updatePasswordUserVO.getId());
    if (user == null) {
        throw new UserException(ErrorCodeEnum.TNP1001001);
    }
    if (userMapper.updatePassword(userPO) != 1) {
        throw new UserException(ErrorCodeEnum.TNP1001005);
    }
    //2.生成新的token
    UserTokenDTO userTokenDTO = UserTokenDTO.builder()
            .id(updatePasswordUserVO.getId())
            .username(user.getUsername())
            .gmtCreate(System.currentTimeMillis()).build();
    String token = JWTUtil.generateToken(userTokenDTO);
    //3.更新token
    redisService.set(user.getId(), token);
    return token;
}

說明:

更新用戶密碼時需要重新生成新的 token,並將新的 token 返回給前端,由前端更新保存在 local storage 中的 token,同時更新存儲在 redis 中的 token,這樣實現可以避免用戶重新登陸,用戶體驗感不至於太差

其他說明

在實際項目中,用戶分爲普通用戶和管理員用戶,只有管理員用戶擁有刪除用戶的權限,這一塊功能也是涉及 token 操作的,但是我太懶了,demo 工程就不寫了

在實際項目中,密碼傳輸是加密過的

攔截器類
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
                             Object handler) throws Exception {
    String authToken = request.getHeader("Authorization");
    String token = authToken.substring("Bearer".length() + 1).trim();
    UserTokenDTO userTokenDTO = JWTUtil.parseToken(token);
    //1.判斷請求是否有效
    if (redisService.get(userTokenDTO.getId()) == null 
            || !redisService.get(userTokenDTO.getId()).equals(token)) {
        return false;
    }
    //2.判斷是否需要續期
    if (redisService.getExpireTime(userTokenDTO.getId()) < 1 * 60 * 30) {
        redisService.set(userTokenDTO.getId(), token);
        log.error("update token info, id is:{}, user info is:{}", userTokenDTO.getId(), token);
    }
    return true;
}
說明:

攔截器中主要做兩件事,一是對 token 進行校驗,二是判斷 token 是否需要進行續期

token 校驗:

token 自動續期:

爲了不頻繁操作 redis,只有當離過期時間只有 30 分鐘時才更新過期時間

攔截器配置類
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(authenticateInterceptor())
                .excludePathPatterns("/logout/**")
                .excludePathPatterns("/login/**")
                .addPathPatterns("/**");
    }
    @Bean
    public AuthenticateInterceptor authenticateInterceptor() {
        return new AuthenticateInterceptor();
    }
}
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/6bHVH8KysoS6rZKPyvTfjg