作者:何甜甜在嗎
鏈接:https://juejin.cn/post/6932702419344162823
過去這段時間主要負責了專案中的用戶管理模塊,用戶管理模塊會涉及到加密及認證流程,加密已經在前面的文章中介紹了,可以閱讀用戶管理模塊:如何保證用戶資料安全,
今天就來講講認證功能的技術選型及實作,技術上沒啥難度當然也沒啥挑戰,但是對一個原先沒寫過認證功能的菜雞甜來說也是一種鍛煉吧
技術選型
要實作認證功能,很容易就會想到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 = https://www.cnblogs.com/javastack/p/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();
}
}
寫在最后
若有紕漏不足,歡迎指出
近期熱文推薦:
1.1,000+ 道 Java面試題及答案整理(2022最新版)
2.勁爆!Java 協程要來了,,,
3.Spring Boot 2.x 教程,太全了!
4.別再寫滿屏的爆爆爆炸類了,試試裝飾器模式,這才是優雅的方式!!
5.《Java開發手冊(嵩山版)》最新發布,速速下載!
覺得不錯,別忘了隨手點贊+轉發哦!
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/500495.html
標籤:Java
