上一章SpringBoot项目实战(008)Spring Security(二)JWT中,实现了Spring Security
的JWT认证。但还是存在几个问题:
所以本章打算:
redis方面可参考的资料:
增加依赖即可:
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
dependency>
<dependency>
<groupId>org.apache.commonsgroupId>
<artifactId>commons-pool2artifactId>
<version>2.5.0version>
dependency>
<dependency>
<groupId>com.fasterxml.jackson.coregroupId>
<artifactId>jackson-databindartifactId>
<version>2.9.6version>
dependency>
增加redis链接,除了host、port,其他沿用即可。
spring:
redis:
# 数据库索引,默认0
database: 0
# redis实例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
处理一些redis连接的问题,这里使用StringRedisSerializer,可以防止Redis中出现乱码。
@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,封装RedisTemplate的一些操作。
package com.it_laowu.springbootstudy.springbootstudydemo.core.utils;
......
@Component
public final class RedisUtil {
@Autowired
private RedisTemplate<String, Object> oRedisTemplate;
// ==========common=========
/**
* 指定缓存失效时间
* @param key 键
* @param time 时间(秒)
* @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;
}
}
/**
* 普通缓存放入并设置时间
* @param key 键
* @param value 值
* @param time 时间(秒) time要大于0 如果time小于等于0 将设置无限期
* @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;
}
}
......
简单修改一下,以便校验Redis的使用有没有问题:
@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);
}
}
在postman中,设置一个string类型的key:
在redis-cli中,client list
查看链接的客户端,其中一个即redis-cli
的db=1
,所以查不到对应的key,使用select 0
命令切换database,然后就可以查看到key了。
使用postman,读取redis中的key:
原本的数据库保存token可以取消,同时我们需要修改JwtProperties
和yml文件,增加一些参数。
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
关于token的种类及生成方式需要大改一下,分为三种token
:access、roles、refresh。
部分代码:
// 根据用户信息生成token
public Map<String, String> generateToken(UserDetails userDetails) {
Map<String, String> rst = new HashMap<String, String>();
// 访问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;
}
// 根据权限生成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的过期时间
*/
private Date generateExpirationDate(Integer seconds) {
return new Date(System.currentTimeMillis() + (int) (seconds * 1000));
}
//根据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;
}
//几个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,返回两个token给客户端(roles没必要返回)。
//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());
// 绑定到当前用户
user.setAccessToken(accesstoken);
user.setRefreshToken(refreshtoken);
return new UsernamePasswordAuthenticationToken(user, password, user.getAuthorities());
}
...
}
同时调整一下MyAuthenticationSuccessHandler
,将两个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);
//设置返回请求头
response.setContentType("application/json;charset=utf-8");
//写出流
PrintWriter out = response.getWriter();
ObjectMapper mapper = new ObjectMapper();
out.write(mapper.writeValueAsString(resultBody));
out.flush();
out.close();
}
只需要处理JwtokenAuthenticationFilter
文件,通过redis而不是数据库验证token的有效性,以及获得roles。
如果roles存在,则利用,如果roles不存在,则从数据库读取,并将他缓存。
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) {
// 缓存内有权限
authorities = jwtTokenUtil.getRolesFromToken(rolesToken);
userDetails = new MyUserDetails(username, "", "", "", false, authorities);
} else {
// 提取数据,并存入缓存
userDetails = (MyUserDetails) myUserDetailsService.loadUserByUsername(username);
authorities = (List<GrantedAuthority>) userDetails.getAuthorities();
//生成三个token,只用一个
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);
}
}
为了刷新token,我在AdminController
中暴露一个服务,根据RefreshToken
获得新的AccessToken
。
增加了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;
}
}
注意,这里对刷新频率做了控制,你也可以把频率参数放到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分钟之内刚刷新过,返回原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,增加一个服务:
@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
开放访问。
.antMatchers("/admin/token/**").permitAll()
由于客户端长期拥有的仅仅是refreshtoken,所以前端可以根据username,也可以使用refreshtoken登出系统(即清除redis中信息)。
比如我们在admincontroller中加个清除token服务即可:
@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;
}