SpringSecurity && JWT && Redis
上一章SpringBoot項(xiàng)目實(shí)戰(zhàn)(008)Spring Security(二)JWT中兵钮,實(shí)現(xiàn)了Spring Security
的JWT認(rèn)證啤呼。但還是存在幾個(gè)問(wèn)題:
- 每次token訪問(wèn)晶渠,都要去數(shù)據(jù)庫(kù)訪問(wèn),效率低下鸯檬。
- token有效期參與了token的生成,無(wú)法延長(zhǎng),除非重新生成区匠。
- 登出時(shí)沒(méi)有清理token
所以本章打算:
- 使用redis作為緩存。
- 在登錄生成Token后帅腌,將token和username的對(duì)照關(guān)系保存在redis中驰弄,同時(shí)在redis中設(shè)置失效時(shí)間。
- 將部分高訪問(wèn)頻率的數(shù)據(jù)庫(kù)內(nèi)的用戶權(quán)限信息保存在redis中速客,提高效率戚篙。
- 最后在登出時(shí),清理redis中的token溺职。
redis集成
redis方面可參考的資料:
配置文件
pom文件
增加依賴(lài)即可:
<!-- redis starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- lettuce 池化 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
<version>2.5.0</version>
</dependency>
<!-- jackson json 優(yōu)化緩存對(duì)象序列化 -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.9.6</version>
</dependency>
application-dev.yml
增加redis鏈接,除了host辅愿、port智亮,其他沿用即可。
spring:
redis:
# 數(shù)據(jù)庫(kù)索引点待,默認(rèn)0
database: 0
# redis實(shí)例IP 端口 密碼
host: 172.17.0.2
port: 6379
password: 123456
timeout: 3000
lettuce:
pool:
max-active: 8
max-wait: -1
max-idle: 8
min-idle: 0
shutdown-timeout: 3000
LettuceRedisConfig
處理一些redis連接的問(wèn)題阔蛉,這里使用StringRedisSerializer,可以防止Redis中出現(xiàn)亂碼癞埠。
@Configuration
public class LettuceRedisConfig {
@Bean
public RedisTemplate<String, Object> oRedisTemplate(LettuceConnectionFactory connectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(connectionFactory);
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
redisTemplate.setKeySerializer(stringRedisSerializer);
redisTemplate.setValueSerializer(stringRedisSerializer);
redisTemplate.setHashKeySerializer(stringRedisSerializer);
redisTemplate.setHashValueSerializer(stringRedisSerializer);
return redisTemplate;
}
}
RedisUtil
新增一個(gè)RedisUtil状原,封裝RedisTemplate的一些操作。
package com.it_laowu.springbootstudy.springbootstudydemo.core.utils;
......
@Component
public final class RedisUtil {
@Autowired
private RedisTemplate<String, Object> oRedisTemplate;
// ==========common=========
/**
* 指定緩存失效時(shí)間
* @param key 鍵
* @param time 時(shí)間(秒)
* @return
*/
public boolean expire(final String key, final long time) {
try {
if (time > 0) {
oRedisTemplate.expire(key, time, TimeUnit.SECONDS);
}
return true;
} catch (final Exception e) {
e.printStackTrace();
return false;
}
}
// =========String==========
/**
* 普通緩存獲取
* @param key 鍵
* @return 值
*/
public Object get(final String key) {
return key == null ? null : oRedisTemplate.opsForValue().get(key);
}
/**
* 普通緩存放入
* @param key 鍵
* @param value 值
* @return true成功 false失敗
*/
public boolean set(final String key, final Object value) {
try {
oRedisTemplate.opsForValue().set(key, value);
return true;
} catch (final Exception e) {
e.printStackTrace();
return false;
}
}
/**
* 普通緩存放入并設(shè)置時(shí)間
* @param key 鍵
* @param value 值
* @param time 時(shí)間(秒) time要大于0 如果time小于等于0 將設(shè)置無(wú)限期
* @return true成功 false 失敗
*/
public boolean set(final String key, final Object value, final long time) {
try {
if (time > 0) {
oRedisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);
} else {
set(key, value);
}
return true;
} catch (final Exception e) {
e.printStackTrace();
return false;
}
}
......
controller
簡(jiǎn)單修改一下苗踪,以便校驗(yàn)Redis的使用有沒(méi)有問(wèn)題:
@RestController
@RequestMapping(value = "/admin")
public class AdminController {
@RequestMapping(value="/keys/{key}",method=RequestMethod.GET)
public String redisGet(@PathVariable(value = "key") String key) {
Object val = redisUtil.get(key);
return (String) val;
}
@RequestMapping(value="/keys/{key}",method=RequestMethod.POST)
public Boolean redisSet(@PathVariable(value = "key") String key,String val) {
return redisUtil.set(key, val);
}
}
redis驗(yàn)證
在postman中颠区,設(shè)置一個(gè)string類(lèi)型的key:
在redis-cli中,client list
查看鏈接的客戶端通铲,其中一個(gè)即redis-cli
的db=1
毕莱,所以查不到對(duì)應(yīng)的key,使用select 0
命令切換database,然后就可以查看到key了朋截。
使用postman蛹稍,讀取redis中的key:
token保存到redis
JwtProperties
原本的數(shù)據(jù)庫(kù)保存token可以取消,同時(shí)我們需要修改JwtProperties
和yml文件部服,增加一些參數(shù)唆姐。
jwt:
secret: "this is a secret"
token-head: "Bearer "
header-name: "Authorization"
access-expiration: 3600
roles-expiration: 300
refresh-expiration: 604800
@Component
@ConfigurationProperties(prefix="jwt")
@Data
public class JwtProperties {
private String secret="this is a secret";
private String tokenHead = "Bearer ";
private String headerName ="Authorization";
private Integer accessExpiration = 60 * 60;
private Integer rolesExpiration =60*5;
private Integer refreshExpiration =60 * 60 * 24 * 7;
}
JwtTokenUtil
JwtTokenUtil
關(guān)于token的種類(lèi)及生成方式需要大改一下,分為三種token
:access廓八、roles奉芦、refresh。
- access:訪問(wèn)token
- roles:鑒權(quán)token
- refresh:更新token的token
部分代碼:
// 根據(jù)用戶信息生成token
public Map<String, String> generateToken(UserDetails userDetails) {
Map<String, String> rst = new HashMap<String, String>();
// 訪問(wèn)token
Map<String, Object> claims = new HashMap<>();
claims.put(CLAIM_KEY_USERNAME, userDetails.getUsername());
claims.put(CLAIM_KEY_CREATED, new Date());
rst.put(getAccessTokenKey(), generateToken(claims, jwtProperties.getAccessExpiration()));
rst.put(getRefreshTokenKey(), generateToken(claims, jwtProperties.getRefreshExpiration()));
claims.put(CLAIM_KEY_ROLES, userDetails.getAuthorities());
rst.put(getRoleTokenKey(), generateToken(claims, jwtProperties.getRolesExpiration()));
return rst;
}
// 根據(jù)權(quán)限生成JWT的token
private String generateToken(Map<String, Object> claims, Integer seconds) {
return Jwts.builder().setClaims(claims).setExpiration(generateExpirationDate(seconds))
.signWith(SignatureAlgorithm.HS512, jwtProperties.getSecret()).compact();
}
/**
* 生成token的過(guò)期時(shí)間
*/
private Date generateExpirationDate(Integer seconds) {
return new Date(System.currentTimeMillis() + (int) (seconds * 1000));
}
//根據(jù)token獲得roles
public List<GrantedAuthority> getRolesFromToken(String token) {
Claims claims = getClaimsFromToken(token);
List<HashMap> roles = (List<HashMap>) claims.get(CLAIM_KEY_ROLES);
List<GrantedAuthority> authority = roles.stream().map(i->new SimpleGrantedAuthority((String) i.get("authority"))).collect(Collectors.toList());
return authority;
}
//幾個(gè)key及生成方式
public String getAccessTokenKey(){
return "accesstoken";
}
public String getAccessTokenKey(String username){
return username+":accesstoken";
}
public String getRefreshTokenKey(){
return "refreshtoken";
}
public String getRefreshTokenKey(String username){
return username+":refreshtoken";
}
public String getRoleTokenKey(){
return "roletoken";
}
public String getRoleTokenKey(String username){
return username+":roletoken";
}
token存入redis
登錄時(shí)將三個(gè)token存入Redis剧蹂,返回兩個(gè)token給客戶端(roles沒(méi)必要返回)声功。
//MyAuthenticationProvider
package com.it_laowu.springbootstudy.springbootstudydemo.core.auth;
...
@Component
public class MyAuthenticationProvider implements AuthenticationProvider {
...
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
...
logger.info(String.format("用戶%s登錄成功", username));
// 生成新token
Map<String,String> tokens = jwtTokenUtil.generateToken(user);
String accesstoken = tokens.get(jwtTokenUtil.getAccessTokenKey());
String refreshtoken = tokens.get(jwtTokenUtil.getRefreshTokenKey());
String rolestoken = tokens.get(jwtTokenUtil.getRoleTokenKey());
// 保存到 redis
redisUtil.set(jwtTokenUtil.getAccessTokenKey(username),accesstoken);
redisUtil.expire(jwtTokenUtil.getAccessTokenKey(username), jwtProperties.getAccessExpiration());
redisUtil.set(jwtTokenUtil.getRefreshTokenKey(username),refreshtoken);
redisUtil.expire(jwtTokenUtil.getRefreshTokenKey(username),jwtProperties.getRefreshExpiration());
redisUtil.set(jwtTokenUtil.getRoleTokenKey(username), rolestoken);
redisUtil.expire(jwtTokenUtil.getRoleTokenKey(username), jwtProperties.getRolesExpiration());
// 綁定到當(dāng)前用戶
user.setAccessToken(accesstoken);
user.setRefreshToken(refreshtoken);
return new UsernamePasswordAuthenticationToken(user, password, user.getAuthorities());
}
...
}
同時(shí)調(diào)整一下MyAuthenticationSuccessHandler
,將兩個(gè)token都返回国夜。
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException {
//登錄成功返回
String accessToken = ((MyUserDetails) authentication.getPrincipal()).getAccessToken();
String refreshToken = ((MyUserDetails) authentication.getPrincipal()).getRefreshToken();
ResultBody resultBody = new ResultBody("200", "登錄成功:\n"+accessToken+"\nrefreshtoken:\n"+refreshToken);
//設(shè)置返回請(qǐng)求頭
response.setContentType("application/json;charset=utf-8");
//寫(xiě)出流
PrintWriter out = response.getWriter();
ObjectMapper mapper = new ObjectMapper();
out.write(mapper.writeValueAsString(resultBody));
out.flush();
out.close();
}
使用accesstoken
只需要處理JwtokenAuthenticationFilter
文件减噪,通過(guò)redis而不是數(shù)據(jù)庫(kù)驗(yàn)證token的有效性,以及獲得roles车吹。
如果roles存在筹裕,則利用,如果roles不存在窄驹,則從數(shù)據(jù)庫(kù)讀取朝卒,并將他緩存。
package com.it_laowu.springbootstudy.springbootstudydemo.core.auth;
...
@Component
public class JwtokenAuthenticationFilter extends OncePerRequestFilter {
...
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 取出auth
String authHeader = request.getHeader(jwtProperties.getHeaderName());
if (authHeader != null && authHeader.startsWith(jwtProperties.getTokenHead())) {
// tokenBody
String tokenBody = authHeader.substring(jwtProperties.getTokenHead().length());
if (tokenBody != null) {
String username = jwtTokenUtil.getUserNameFromToken(tokenBody);
if (username != null) {
String accessToken = (String) redisUtil.get(jwtTokenUtil.getAccessTokenKey(username));
if (accessToken.equals(tokenBody)) {
String rolesToken = (String) redisUtil.get(jwtTokenUtil.getRoleTokenKey(username));
List<GrantedAuthority> authorities = null;
UserDetails userDetails = null;
if (rolesToken != null) {
// 緩存內(nèi)有權(quán)限
authorities = jwtTokenUtil.getRolesFromToken(rolesToken);
userDetails = new MyUserDetails(username, "", "", "", false, authorities);
} else {
// 提取數(shù)據(jù)乐埠,并存入緩存
userDetails = (MyUserDetails) myUserDetailsService.loadUserByUsername(username);
authorities = (List<GrantedAuthority>) userDetails.getAuthorities();
//生成三個(gè)token抗斤,只用一個(gè)
String newRoleToken = jwtTokenUtil.generateToken(userDetails).get(jwtTokenUtil.getRoleTokenKey());
redisUtil.set(jwtTokenUtil.getRoleTokenKey(username),newRoleToken);
redisUtil.expire(jwtTokenUtil.getRoleTokenKey(username), jwtProperties.getRolesExpiration());
}
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, authorities);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
}
}
filterChain.doFilter(request, response);
}
}
使用refreshtoken
為了刷新token,我在AdminController
中暴露一個(gè)服務(wù)丈咐,根據(jù)RefreshToken
獲得新的AccessToken
瑞眼。
返回結(jié)構(gòu) ResultBody 調(diào)整
增加了Data屬性。
package com.it_laowu.springbootstudy.springbootstudydemo.core.base;
...
@Data
@Accessors(chain = true)
public class ResultBody<T> {
private String code;
private String message;
private String detailMessage;
private T data;
public ResultBody() {
}
public ResultBody(String code, String message) {
this.code = code;
this.message = message;
}
public ResultBody(String code, String message, String detailMessage) {
this.code = code;
this.message = message;
this.detailMessage = detailMessage;
}
}
JwtTokenUtil 增加 refreshHeadToken
注意棵逊,這里對(duì)刷新頻率做了控制伤疙,你也可以把頻率參數(shù)放到JwtProperties
中。
public String refreshHeadToken(String refreshtoken,String accesstoken) {
if (StrUtil.isEmpty(refreshtoken)) {
return null;
}
String username = getUserNameFromToken(token);
if (StrUtil.isEmpty(username)) {
return null;
}
// 如果token在30分鐘之內(nèi)剛刷新過(guò)辆影,返回原token
if (accesstoken != null && tokenRefreshJustBefore(accesstoken, 30 * 60)) {
return "";
} else {
Map<String, Object> accessClaims = new HashMap<>();
accessClaims.put(CLAIM_KEY_USERNAME,username);
accessClaims.put(CLAIM_KEY_CREATED, new Date());
return generateToken(accessClaims, jwtProperties.getAccessExpiration());
}
}
AdminController暴露服務(wù)
修改AdminController徒像,增加一個(gè)服務(wù):
@RequestMapping(value = "/token/refresh/{token}", method = RequestMethod.GET)
public ResultBody<String> refreshToken(@PathVariable(value = "token") String token) {
ResultBody<String> rst = new ResultBody<String>().setCode("200");
if (token == null) {
return rst.setMessage("令牌不能為空");
}
String refreshtoken = token.substring(jwtProperties.getTokenHead().length());
String username = jwtTokenUtil.getUserNameFromToken(refreshtoken);
if (username == null) {
return rst.setMessage("令牌格式有誤");
}
String accesstoken = (String) redisUtil.get(jwtTokenUtil.getAccessTokenKey(username));
String new_accesstoken = jwtTokenUtil.refreshHeadToken(refreshtoken, accesstoken);
if (new_accesstoken == null) {
return rst.setMessage("令牌格式有誤");
}
if (new_accesstoken == "") {
return rst.setMessage("令牌不要頻繁刷新");
}
redisUtil.set(jwtTokenUtil.getAccessTokenKey(username), new_accesstoken);
return rst.setData(new_accesstoken);
}
記得WebSecurityConfig
開(kāi)放訪問(wèn)。
.antMatchers("/admin/token/**").permitAll()
postman驗(yàn)證
- 使用用戶密碼登錄成功蛙讥。
- 查看redis中的key锯蛀。
- 使用token訪問(wèn)成功。
- 清除accesstoken次慢。
- 刷新token旁涤,用新token訪問(wèn)成功翔曲。
登出處理
由于客戶端長(zhǎng)期擁有的僅僅是refreshtoken,所以前端可以根據(jù)username劈愚,也可以使用refreshtoken登出系統(tǒng)(即清除redis中信息)部默。
比如我們?cè)赼dmincontroller中加個(gè)清除token服務(wù)即可:
@RequestMapping(value = "/token/{token}", method = RequestMethod.DELETE)
public ResultBody<String> deleteToken(@PathVariable(value = "token") String token) {
ResultBody<String> rst = new ResultBody<String>().setCode("200");
if (token == null) {
return rst.setMessage("令牌不能為空");
}
String refreshtoken = token.substring(jwtProperties.getTokenHead().length());
String username = jwtTokenUtil.getUserNameFromToken(refreshtoken);
if (username == null) {
return rst.setMessage("令牌格式有誤");
}
redisUtil.expire(jwtTokenUtil.getAccessTokenKey(username), 1);
redisUtil.expire(jwtTokenUtil.getRefreshTokenKey(username), 1);
redisUtil.expire(jwtTokenUtil.getRoleTokenKey(username), 1);
return rst;
}