JWT實現登陸認證及Token續期

過去這段時間主要負責了項目中的用戶管理模塊,用戶管理模塊會涉及到加密及認證流程,加密已經在前面的文章中介紹了,可以閱讀用戶管理模塊:如何保證用戶數據安全。今天就來講講認證功能的技術選型及實現。技術上沒啥難度當然也沒啥挑戰,但是對一個原先沒寫過認證功能的菜雞甜來說也是一種鍛煉吧

技術選型

要實現認證功能,很容易就會想到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的信息是存在服務端的,相對來說更安全
image.png

如果在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的多機數據共享可以通過粘性sessionsession共享session復制持久化sessionterracoa實現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)) {
        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校驗:

  • 判斷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();
    }
}
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,030評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,310評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,951評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,796評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,566評論 6 407
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,055評論 1 322
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,142評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,303評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,799評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,683評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,899評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,409評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,135評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,520評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,757評論 1 282
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,528評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,844評論 2 372

推薦閱讀更多精彩內容