JWT實(shí)現(xiàn)登陸認(rèn)證及Token續(xù)期

過去這段時(shí)間主要負(fù)責(zé)了項(xiàng)目中的用戶管理模塊娩鹉,用戶管理模塊會(huì)涉及到加密及認(rèn)證流程,加密已經(jīng)在前面的文章中介紹了让虐,可以閱讀用戶管理模塊:如何保證用戶數(shù)據(jù)安全盅弛。今天就來講講認(rèn)證功能的技術(shù)選型及實(shí)現(xiàn)。技術(shù)上沒啥難度當(dāng)然也沒啥挑戰(zhàn)璧帝,但是對(duì)一個(gè)原先沒寫過認(rèn)證功能的菜雞甜來說也是一種鍛煉吧

技術(shù)選型

要實(shí)現(xiàn)認(rèn)證功能,很容易就會(huì)想到JWT或者session富寿,但是兩者有啥區(qū)別睬隶?各自的優(yōu)缺點(diǎn)?應(yīng)該P(yáng)ick誰页徐?奪命三連

區(qū)別

基于session和基于JWT的方式的主要區(qū)別就是用戶的狀態(tài)保存的位置苏潜,session是保存在服務(wù)端的,而JWT是保存在客戶端

認(rèn)證流程

基于session的認(rèn)證流程
  • 用戶在瀏覽器中輸入用戶名和密碼变勇,服務(wù)器通過密碼校驗(yàn)后生成一個(gè)session并保存到數(shù)據(jù)庫
  • 服務(wù)器為用戶生成一個(gè)sessionId恤左,并將具有sesssionId的cookie放置在用戶瀏覽器中贴唇,在后續(xù)的請(qǐng)求中都將帶有這個(gè)cookie信息進(jìn)行訪問
  • 服務(wù)器獲取cookie,通過獲取cookie中的sessionId查找數(shù)據(jù)庫判斷當(dāng)前請(qǐng)求是否有效
基于JWT的認(rèn)證流程
  • 用戶在瀏覽器中輸入用戶名和密碼飞袋,服務(wù)器通過密碼校驗(yàn)后生成一個(gè)token并保存到數(shù)據(jù)庫
  • 前端獲取到token戳气,存儲(chǔ)到cookie或者local storage中,在后續(xù)的請(qǐng)求中都將帶有這個(gè)token信息進(jìn)行訪問
  • 服務(wù)器獲取token值巧鸭,通過查找數(shù)據(jù)庫判斷當(dāng)前token是否有效

優(yōu)缺點(diǎn)

  • JWT保存在客戶端瓶您,在分布式環(huán)境下不需要做額外工作。而session因?yàn)楸4嬖诜?wù)端纲仍,分布式環(huán)境下需要實(shí)現(xiàn)多機(jī)數(shù)據(jù)共享
  • session一般需要結(jié)合Cookie實(shí)現(xiàn)認(rèn)證呀袱,所以需要瀏覽器支持cookie,因此移動(dòng)端無法使用session認(rèn)證方案
安全性
  • JWT的payload使用的是base64編碼的郑叠,因此在JWT中不能存儲(chǔ)敏感數(shù)據(jù)夜赵。而session的信息是存在服務(wù)端的,相對(duì)來說更安全
image.png

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

性能
  • 經(jīng)過編碼之后JWT將非常長油吭,cookie的限制大小一般是4k,cookie很可能放不下署拟,所以JWT一般放在local storage里面婉宰。并且用戶在系統(tǒng)中的每一次http請(qǐng)求都會(huì)把JWT攜帶在Header里面,HTTP請(qǐng)求的Header可能比Body還要大推穷。而sessionId只是很短的一個(gè)字符串心包,因此使用JWT的HTTP請(qǐng)求比使用session的開銷大得多
一次性

無狀態(tài)是JWT的特點(diǎn),但也導(dǎo)致了這個(gè)問題馒铃,JWT是一次性的蟹腾。想修改里面的內(nèi)容,就必須簽發(fā)一個(gè)新的JWT

  • 無法廢棄
    一旦簽發(fā)一個(gè)JWT区宇,在到期之前就會(huì)始終有效娃殖,無法中途廢棄。若想廢棄议谷,一種常用的處理手段是結(jié)合redis
  • 續(xù)簽
    如果使用JWT做會(huì)話管理炉爆,傳統(tǒng)的cookie續(xù)簽方案一般都是框架自帶的,session有效期30分鐘卧晓,30分鐘內(nèi)如果有訪問芬首,有效期被刷新至30分鐘。一樣的道理逼裆,要改變JWT的有效時(shí)間郁稍,就要簽發(fā)新的JWT。最簡單的一種方式是每次請(qǐng)求刷新JWT胜宇,即每個(gè)HTTP請(qǐng)求都返回一個(gè)新的JWT耀怜。這個(gè)方法不僅暴力不優(yōu)雅恢着,而且每次請(qǐng)求都要做JWT的加密解密,會(huì)帶來性能問題财破。另一種方法是在redis中單獨(dú)為每個(gè)JWT設(shè)置過期時(shí)間然评,每次訪問時(shí)刷新JWT的過期時(shí)間

選擇JWT或session

我投JWT一票,JWT有很多缺點(diǎn)狈究,但是在分布式環(huán)境下不需要像session一樣額外實(shí)現(xiàn)多機(jī)數(shù)據(jù)共享碗淌,雖然seesion的多機(jī)數(shù)據(jù)共享可以通過粘性sessionsession共享抖锥、session復(fù)制亿眠、持久化sessionterracoa實(shí)現(xiàn)seesion復(fù)制等多種成熟的方案來解決這個(gè)問題磅废。但是JWT不需要額外的工作纳像,使用JWT不香嗎?且JWT一次性的缺點(diǎn)可以結(jié)合redis進(jìn)行彌補(bǔ)拯勉。揚(yáng)長補(bǔ)短竟趾,因此在實(shí)際項(xiàng)目中選擇的是使用JWT來進(jìn)行認(rèn)證

功能實(shí)現(xiàn)

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,自定義過期時(shí)間 毫秒
     *
     * @param userTokenDTO
     * @return
     */
    public static String generateToken(UserTokenDTO userTokenDTO) {
        try {
            // 私鑰和加密算法
            Algorithm algorithm = Algorithm.HMAC256(TOKEN_SECRET);
            // 設(shè)置頭部信息
            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;
        }
    }

    /**
     * 檢驗(yàn)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中不帶有過期時(shí)間宫峦,token的過期時(shí)間由redis進(jìn)行管理
  • UserTokenDTO中不帶有敏感信息岔帽,如password字段不會(huì)出現(xiàn)在token中

Redis工具類

public final class RedisServiceImpl implements RedisService {
    /**
     * 過期時(shí)長
     */
    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簡單封裝

業(yè)務(wù)實(shí)現(xiàn)

登陸功能
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;
}

將對(duì)應(yīng)的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;
}

說明:
更新用戶密碼時(shí)需要重新生成新的token,并將新的token返回給前端导绷,由前端更新保存在local storage中的token犀勒,同時(shí)更新存儲(chǔ)在redis中的token,這樣實(shí)現(xiàn)可以避免用戶重新登陸妥曲,用戶體驗(yàn)感不至于太差

其他說明
  • 在實(shí)際項(xiàng)目中贾费,用戶分為普通用戶和管理員用戶,只有管理員用戶擁有刪除用戶的權(quán)限檐盟,這一塊功能也是涉及token操作的褂萧,但是我太懶了,demo工程就不寫了
  • 在實(shí)際項(xiàng)目中葵萎,密碼傳輸是加密過的

攔截器類

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.判斷請(qǐng)求是否有效
    if (redisService.get(userTokenDTO.getId()) == null 
            || !redisService.get(userTokenDTO.getId()).equals(token)) {
        return false;
    }

    //2.判斷是否需要續(xù)期
    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;
}

說明:
攔截器中主要做兩件事导犹,一是對(duì)token進(jìn)行校驗(yàn),二是判斷token是否需要進(jìn)行續(xù)期
token校驗(yàn):

  • 判斷id對(duì)應(yīng)的token是否不存在陌宿,不存在則token過期
  • 若token存在則比較token是否一致锡足,保證同一時(shí)間只有一個(gè)用戶操作

token自動(dòng)續(xù)期: 為了不頻繁操作redis,只有當(dāng)離過期時(shí)間只有30分鐘時(shí)才更新過期時(shí)間

攔截器配置類

@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();
    }
}
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末壳坪,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子掰烟,更是在濱河造成了極大的恐慌爽蝴,老刑警劉巖沐批,帶你破解...
    沈念sama閱讀 221,888評(píng)論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異蝎亚,居然都是意外死亡九孩,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,677評(píng)論 3 399
  • 文/潘曉璐 我一進(jìn)店門发框,熙熙樓的掌柜王于貴愁眉苦臉地迎上來躺彬,“玉大人,你說我怎么就攤上這事梅惯∠苡担” “怎么了?”我有些...
    開封第一講書人閱讀 168,386評(píng)論 0 360
  • 文/不壞的土叔 我叫張陵铣减,是天一觀的道長她君。 經(jīng)常有香客問我,道長葫哗,這世上最難降的妖魔是什么缔刹? 我笑而不...
    開封第一講書人閱讀 59,726評(píng)論 1 297
  • 正文 為了忘掉前任,我火速辦了婚禮劣针,結(jié)果婚禮上校镐,老公的妹妹穿的比我還像新娘。我一直安慰自己捺典,他們只是感情好灭翔,可當(dāng)我...
    茶點(diǎn)故事閱讀 68,729評(píng)論 6 397
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著辣苏,像睡著了一般肝箱。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上稀蟋,一...
    開封第一講書人閱讀 52,337評(píng)論 1 310
  • 那天煌张,我揣著相機(jī)與錄音,去河邊找鬼退客。 笑死骏融,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的萌狂。 我是一名探鬼主播档玻,決...
    沈念sama閱讀 40,902評(píng)論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢(mèng)啊……” “哼茫藏!你這毒婦竟也來了误趴?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,807評(píng)論 0 276
  • 序言:老撾萬榮一對(duì)情侶失蹤务傲,失蹤者是張志新(化名)和其女友劉穎凉当,沒想到半個(gè)月后枣申,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 46,349評(píng)論 1 318
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡看杭,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,439評(píng)論 3 340
  • 正文 我和宋清朗相戀三年忠藤,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片楼雹。...
    茶點(diǎn)故事閱讀 40,567評(píng)論 1 352
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡模孩,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出贮缅,到底是詐尸還是另有隱情榨咐,我是刑警寧澤,帶...
    沈念sama閱讀 36,242評(píng)論 5 350
  • 正文 年R本政府宣布携悯,位于F島的核電站祭芦,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏憔鬼。R本人自食惡果不足惜龟劲,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,933評(píng)論 3 334
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望轴或。 院中可真熱鬧昌跌,春花似錦、人聲如沸照雁。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,420評(píng)論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽饺蚊。三九已至萍诱,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間污呼,已是汗流浹背裕坊。 一陣腳步聲響...
    開封第一講書人閱讀 33,531評(píng)論 1 272
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留燕酷,地道東北人籍凝。 一個(gè)月前我還...
    沈念sama閱讀 48,995評(píng)論 3 377
  • 正文 我出身青樓,卻偏偏與公主長得像苗缩,于是被迫代替她去往敵國和親饵蒂。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,585評(píng)論 2 359

推薦閱讀更多精彩內(nèi)容