過去這段時(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ì)來說更安全
如果在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ù)共享可以通過粘性session、session共享抖锥、session復(fù)制亿眠、持久化session、terracoa實(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();
}
}