JWT 實現登陸認證及 Token 自動續期
過去這段時間主要負責了項目中的用戶管理模塊,用戶管理模塊會涉及到加密及認證流程,加密已經在前面的文章中介紹了,可以閱讀用戶管理模塊:
https://juejin.cn/post/6916150628955717646
今天就來講講認證功能的技術選型及實現。技術上沒啥難度當然也沒啥挑戰,但是對一個原先沒寫過認證功能的菜雞甜來說也是一種鍛鍊吧
技術選型
要實現認證功能,很容易就會想到 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)) {
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();
}
}
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/6bHVH8KysoS6rZKPyvTfjg