當前位置: 妍妍網 > 碼農

JWT 實作登入認證 + Token 自動續期方案,這才是正確的使用姿勢!

2024-03-28碼農

點選下方「 Java編程鴨 」關註並標星

更多精彩 第一時間直達

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

技術選型

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

區別

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

認證流程

基於session的認證流程
  • 使用者在瀏覽器中輸入使用者名稱和密碼,伺服器透過密碼校驗後生成一個session並保存到資料庫

  • 伺服器為使用者生成一個sessionId,並將具有sesssionId的cookie放置在使用者瀏覽器中,在後續的請求中都將帶有這個cookie資訊進行存取

  • 伺服器獲取cookie,透過獲取cookie中的sessionId尋找資料庫判斷當前請求是否有效

  • 基於JWT的認證流程
  • 使用者在瀏覽器中輸入使用者名稱和密碼,伺服器透過密碼校驗後生成一個token並保存到資料庫

  • 前端獲取到token,儲存到cookie或者local storage中,在後續的請求中都將帶有這個token資訊進行存取

  • 伺服器獲取token值,透過尋找資料庫判斷當前token是否有效

  • 優缺點

    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);
    }
    }


    說明:

  • 生成的token中不帶有過期時間,token的過期時間由redis進行管理

  • UserTokenDTO中不帶有敏感資訊,如password欄位不會出現在token中

  • 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;
    }

    說明:

  • 判斷使用者名稱密碼是否正確

  • 使用者名稱密碼正確則生成token

  • 將生成的token保存至redis

  • 登出功能

    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)) {
    returnfalse;
    }
    //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);
    }
    returntrue;
    }

    說明:

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

    token校驗:

  • 判斷id對應的token是否不存在,不存在則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();
    }
    }

    來源:juejin.cn/post/6932702419344162823

    END


    看完本文有收獲?請轉發分享給更多人

    關註「Java編程鴨」,提升Java技能

    關註Java編程鴨微信公眾號,後台回復:碼農大禮包可以獲取最新整理的技術資料一份。涵蓋Java 框架學習、架構師學習等

    文章有幫助的話,在看,轉發吧。

    謝謝支持喲 (*^__^*)