上次总结了有关Token鉴权的理论知识,本次内容学习了将jwt、shiro、redis整合,实现token的自动刷新和可控性。
在微服务中,我们一般是无状态登录,而传统的session方式,在前后端分离的微服务架构下,如果继续使用,就需要解决跨域sessionId、集群session共享问题等。而通过整合shiro实现的话,则会出现以下问题:
(1)shiro默认的拦截跳转都是跳转url页面,在前后端分离中,后端并没有权力干涉页面跳转。
(2)shiro默认使用的登录拦截校验机制恰恰使用的就是session。
这并不是我们期望的,所以想使用shiro,则需要对其进行改造。我们在整合shiro的基础上自定义登录校验,并整合JWT或者oauth2.0等,实现服务端也可无状态登录,即token登录。
但是上述shiro+token整合改造后,还不能实现token的可控性和自动刷新。这样就会导致token在设定时限后过期,用户再次登录需要重新发起请求,这样就拉低了用户的体验感受;其次在token的有效期内,即使用户退出了登录,token依然有效,依然可以使用,这就会有安全风险,因此还需要整合redis来实现可控性操作。
1、首先前端通过登录接口,输入用户名与密码进行登录,如果成功在请求头Header返回一个加密的Authorization,失败则直接返回401未登录等状态码,以后访问均带上这个Authorization即可。
2、鉴权流程主要是重写shiro的入口过滤器BasicHttpAuthenticationFilter,在此基础上进行拦截、token验证授权等操作。
1、AccessToken:用于接口传输过程中的用户授权标识,客户端(前端)每次请求都需要携带,出于安全考虑,通常设定的有效时长较短。
2、RefreshToken:与AccessToken为共生关系,一般用于刷新AccessToken,保存于服务端(后端),客户端不可见,有效时长较长。
1、登录认证通过后返回AccessToken信息(在AccessToken中保存账号和当前的时间戳),同时在Redis中设置一条以账号为key,Value为当前时间戳(登录时间)的RefreshToken,现在认证时必须满足AccessToken没失效以及Redis存在所对应的RefreshToken,且RefreshToken时间戳和AccessToken信息中的时间戳一致才算认证通过,这样就实现了JWT的可控性。如果重新登录获取了新的AccessToken,旧的AccessToken就认证不了了,因为Redis中存放的RefreshToken时间戳信息只会和最新的AccessToken信息中携带的时间戳一致,这样每个用户就只能使用最新的AccessToken认证。(这样也避免了同一账号多人登录,因为会被挤下去!)
2、Redis的RefreshToken也可以用来判断用户是否在线,如果删除Redis的某个RefreshToken,那这个RefreshToken所对应的AccessToken之后也无法通过认证了,就相当于控制了用户的登录,可以剔除用户。
1、AccessToken的默认过期时间是5分钟(配置文件可以配置),RefreshToken过期时间为30分钟(配置文件也可配置),当登录时间过了5分钟,当前AccessToken就会失效,再次带上AccessToken访问JWT会抛出TokenExpiredException异常说明Token过期,开始判断是否要进行AccessToken刷新。首先redis查询RefreshToken是否存在,以及时间戳和过期AccessToken所携带的时间戳是否一致,如果存在且一致,就进行AccessToken刷新。
2、刷新后新的AccessToken过期时间依旧为5分钟,时间戳为当前最新时间戳,同时也设置RefreshToken中的时间戳为当前最新时间戳,刷新过期时间重新为30分钟过期,最终将刷新的AccessToken存放在Response的Header中的Authorization字段返回。
3、同时前端进行获取替换,下次使用新的AccessToken进行访问即可。
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for user
-- ----------------------------
DROP TABLE IF EXISTS `sys_user`;
CREATE TABLE `sys_user` (
`id` varchar(11) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`username` varchar(20) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`password` varchar(20) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`roles` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`permission` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;
-- ----------------------------
-- Records of user
-- ----------------------------
INSERT INTO `sys_user` VALUES ('1', 'admin', '111', 'admin', 'add');
INSERT INTO `sys_user` VALUES ('2', 'zhao', '222', 'user', 'update');
INSERT INTO `sys_user` VALUES ('3', 'wang', '333', 'vip', 'delete');
INSERT INTO `sys_user` VALUES ('4', 'li', '444', 'user', 'add');
SET FOREIGN_KEY_CHECKS = 1;
org.springframework.boot
spring-boot-starter-data-redis
org.springframework.boot
spring-boot-starter-web
org.mybatis.spring.boot
mybatis-spring-boot-starter
2.1.3
org.springframework.boot
spring-boot-devtools
runtime
true
mysql
mysql-connector-java
runtime
org.springframework.boot
spring-boot-configuration-processor
true
org.projectlombok
lombok
true
org.apache.shiro
shiro-spring
1.3.2
com.auth0
java-jwt
3.2.0
org.springframework.boot
spring-boot-starter-test
test
org.junit.vintage
junit-vintage-engine
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=utf-8&serverTimezone=GMT%2B8
spring.datasource.username=root
spring.datasource.password=root
spring.redis.host=127.0.0.1
spring.redis.port=6379
mybatis.type-aliases-package=csu.org.jwtshiroredis.domain
mybatis.mapper-location=classpath:mappers/*.xml
mybatis.lazy-initialization=true
redis的默认序列化是jdk序列化,这样的序列化是以转义字符的形式存储的,不方便查看,因此需要修改,以便查看。
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.net.UnknownHostException;
@Configuration
public class RedisConfig {
//编写我们自己的redisTemplate
@Bean
public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) throws UnknownHostException {
//我们为了自己开发使用方便,一般使用类型
RedisTemplate template = new RedisTemplate();
template.setConnectionFactory(redisConnectionFactory);
//序列化配置
//json序列化
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer=new Jackson2JsonRedisSerializer(Object.class);
ObjectMapper om=new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
jackson2JsonRedisSerializer.setObjectMapper(om);
//String序列化
StringRedisSerializer stringRedisSerializer=new StringRedisSerializer();
//key使用String的序列化方式
template.setKeySerializer(stringRedisSerializer);
//hash的key也使用String序列化
template.setHashKeySerializer(stringRedisSerializer);
//value使用json序列化
template.setValueSerializer(jackson2JsonRedisSerializer);
//hash的value使用json序列化
template.setHashValueSerializer(jackson2JsonRedisSerializer);
template.afterPropertiesSet();
return template;
}
}
编写redisUtil便于我们使用redisTrmplate的api,简化使用过程
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
@Component
public final class RedisUtil {
@Autowired
private RedisTemplate redisTemplate;
// =============================common============================
/**
* 指定缓存失效时间
* @param key 键
* @param time 时间(秒)
*/
public boolean expire(String key, long time) {
try {
if (time > 0) {
redisTemplate.expire(key, time, TimeUnit.SECONDS);
}
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
/**
* 根据key 获取过期时间
* @param key 键 不能为null
* @return 时间(秒) 返回0代表为永久有效
*/
public long getExpire(String key) {
return redisTemplate.getExpire(key, TimeUnit.SECONDS);
}
/**
* 判断key是否存在
* @param key 键
* @return true 存在 false不存在
*/
public boolean hasKey(String key) {
try {
return redisTemplate.hasKey(key);
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
/**
* 删除缓存
* @param key 可以传一个值 或多个
*/
@SuppressWarnings("unchecked")
public void del(String... key) {
if (key != null && key.length > 0) {
if (key.length == 1) {
redisTemplate.delete(key[0]);
} else {
redisTemplate.delete(CollectionUtils.arrayToList(key));
}
}
}
// ============================String=============================
/**
* 普通缓存获取
* @param key 键
* @return 值
*/
public Object get(String key) {
return key == null ? null : redisTemplate.opsForValue().get(key);
}
/**
* 普通缓存放入
* @param key 键
* @param value 值
* @return true成功 false失败
*/
public boolean set(String key, Object value) {
try {
redisTemplate.opsForValue().set(key, value);
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
/**
* 普通缓存放入并设置时间
* @param key 键
* @param value 值
* @param time 时间(秒) time要大于0 如果time小于等于0 将设置无限期
* @return true成功 false 失败
*/
public boolean set(String key, Object value, long time) {
try {
if (time > 0) {
redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);
} else {
set(key, value);
}
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
/**
* 递增
* @param key 键
* @param delta 要增加几(大于0)
*/
public long incr(String key, long delta) {
if (delta < 0) {
throw new RuntimeException("递增因子必须大于0");
}
return redisTemplate.opsForValue().increment(key, delta);
}
/**
* 递减
* @param key 键
* @param delta 要减少几(小于0)
*/
public long decr(String key, long delta) {
if (delta < 0) {
throw new RuntimeException("递减因子必须大于0");
}
return redisTemplate.opsForValue().decrement(key,delta);
// return redisTemplate.opsForValue().increment(key, -delta);
}
public long strLen(String key){
return redisTemplate.opsForValue().get(key).toString().length();
}
/*
* 追加字符
* @param key 键
* @param str 要追加的字符
* */
public boolean append(String key,String str){
try {
redisTemplate.opsForValue().append(key,str);
return true;
}catch (Exception e){
return false;
}
}
// ================================Map=================================
/**
* HashGet
* @param key 键 不能为null
* @param item 项 不能为null
*/
public Object hget(String key, String item) {
return redisTemplate.opsForHash().get(key, item);
}
/**
* 获取hashKey对应的所有键值
* @param key 键
* @return 对应的多个键值
*/
public Map
封装token来替换Shiro原生Token,要实现AuthenticationToken接口
shiro默认supports的是UsernamePasswordToken,而我们现在采用了jwt的方式,因此需要定义一个JwtToken,来完成shiro的supports方法。
import org.apache.shiro.authc.AuthenticationToken;
public class JWTToken implements AuthenticationToken {
private String token;
public JWTToken(String token){
this.token=token;
}
@Override
public Object getPrincipal() {
return token;
}
@Override
public Object getCredentials() {
return token;
}
}
JwtUtil是用来生成token和校验解码token的。
步骤:
1、设置密钥和token的有效时长
2、生成token
3、校验token
4、获取token的信息
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.interfaces.DecodedJWT;
import csu.org.jwtshiroredis.domain.User;
import java.io.UnsupportedEncodingException;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
public class JWTUtil {
//token有效时长 1分钟,L为long型
private static final long EXPIRE=1*60*1000L;
//token的密钥
private static final String SECRET="jwt+shiro";
public static String createToken(String username,Long current) {
//token过期时间
Date date=new Date(current+EXPIRE);
//jwt的header部分
Mapmap=new HashMap<>();
map.put("alg","HS256");
map.put("typ","JWT");
//使用jwt的api生成token
String token= null;//签名
try {
token = JWT.create()
.withHeader(map)
.withClaim("username", username)//私有声明
.withClaim("current",current)//当前时间截点
.withExpiresAt(date)//过期时间
.withIssuedAt(new Date())//签发时间
.sign(Algorithm.HMAC256(SECRET));
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
return token;
}
//校验token的有效性,1、token的header和payload是否没改过;2、没有过期
public static boolean verify(String token){
try {
//解密
JWTVerifier verifier=JWT.require(Algorithm.HMAC256(SECRET)).build();
verifier.verify(token);
return true;
}catch (Exception e){
return false;
}
}
//无需解密也可以获取token的信息
public static String getUsername(String token){
try {
DecodedJWT jwt = JWT.decode(token);
return jwt.getClaim("username").asString();
} catch (JWTDecodeException e) {
return null;
}
}
//获取过期时间
public static Long getExpire(String token){
try {
DecodedJWT jwt = JWT.decode(token);
return jwt.getClaim("current").asLong();
}catch (Exception e){
return null;
}
}
//根据request中的token获取用户账号
public static String getUserNameByToken(HttpServletRequest request){
//获取请求头中的token字段内容
String accessToken = request.getHeader("Authorization");
String username = getUsername(accessToken);
if (username.isEmpty()){
system.out.println("未获取到用户");
}
return username;
}
}
这个过滤器是我们的重点,这里我们继承的是shiro内置的BasicHttpAuthenticationFilter,一个可以内置了可以自动登录方法的过滤器。也可以继承AuthenticatingFilter。
这个过滤器是要注册到shiro配置里面去的,用来辅助shiro进行过滤处理。所有的请求都会到过滤器进行处理。
这个过滤器类的执行过程是:preHandle==>isAccessAllowed==>isLoginAttempt==>executeLogin
授权成功就会进入onLoginSuccess,否则进入onAccessDenied
在这里要重写几个方法:
1、isAccessAllowed:是否允许访问。如果带有token,就对token进行检查,否则直接通过。如果请求头不存在token,则返回false并提示“认证失败”信息。(注:根据需求可以修改,若有游客状态,则无需检查token,直接返回true)。
补充:在检查token时若有问题,那么Realm就会抛出异常,同时异常内容不为空的话则进行刷新token操作。
2、isLoginAttempt:判断用户是否想要登入。检测header中是否包含Token字段。
3、executeLogin:本来是先调用createToken来获取token的,在这里进行重写,实现不再自动调用createToken来获取token。然后调用getSubject方法来获取当前用户再调用login方法实现登录。这也解释了自定义jwtToken是因为不再使用shiro默认的UsernamePasswordToken。
4、preHandle:拦截器的前置拦截。在前后端分析项目中,除了跨域全局配置之外,在拦截器中也需要提供跨域支持。这样,拦截器就不会在进入Controller之前就被限制了。
在这里我们需要对token的刷新进行校验,如果符合刷新的条件就会进行刷新。
import com.auth0.jwt.exceptions.TokenExpiredException;
import csu.org.jwtshiroredis.shiro.JWTToken;
import csu.org.jwtshiroredis.utils.JWTUtil;
import csu.org.jwtshiroredis.utils.RedisUtil;
import org.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.context.support.WebApplicationContextUtils;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
public class JWTFilter extends BasicHttpAuthenticationFilter {
//是否允许访问,如果带有 token,则对 token 进行检查,否则直接通过
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
//判断请求的请求头是否带上 "Token"
System.out.println("isAccessAllowed");
if (isLoginAttempt(request, response)){
//如果存在,则进入 executeLogin 方法执行登入,检查 token 是否正确
try {
executeLogin(request, response);
return true;
}catch (Exception e){
/*
*注意这里捕获的异常其实是在Realm抛出的,但是由于executeLogin()方法抛出的异常是从login()来的,
* login抛出的异常类型是AuthenticationException,所以要去获取它的子类异常才能获取到我们在Realm抛出的异常类型。
* */
System.out.println("刷新token");
String msg=e.getMessage();
Throwable cause = e.getCause();
if (cause!=null&&cause instanceof TokenExpiredException){
//AccessToken过期,尝试去刷新token
String result=refreshToken(request, response);
if (result.equals("success")){
System.out.println("request.equals(\"success\")");
return true;
}
msg=result;
}
responseError(response,msg);
}
}
//如果请求头不存在 Token,则可能是执行登陆操作或者是游客状态访问,无需检查 token,直接返回 true
//如果请求头不存在Token,则返回false
if (!isLoginAttempt(request,response)){
responseError(response,"用户认证失败,请重新登录!");
return false;
}
return true;
}
@Override
protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
HttpServletRequest req= (HttpServletRequest) request;
String token=req.getHeader("Authorization");
return token !=null;
}
/*
* executeLogin实际上就是先调用createToken来获取token,这里我们重写了这个方法,就不会自动去调用createToken来获取token
* 然后调用getSubject方法来获取当前用户再调用login方法来实现登录
* 这也解释了我们为什么要自定义jwtToken,因为我们不再使用Shiro默认的UsernamePasswordToken了。
* */
@Override
protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
System.out.println("executeLogin");
HttpServletRequest req= (HttpServletRequest) request;
String token=req.getHeader("Authorization");
JWTToken jwt=new JWTToken(token);
//交给自定义的realm对象去登录,如果错误他会抛出异常并被捕获
getSubject(request, response).login(jwt);
return true;
}
@Override
protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
System.out.println("preHandle");
HttpServletRequest req= (HttpServletRequest) request;
HttpServletResponse res= (HttpServletResponse) response;
res.setHeader("Access-control-Allow-Origin",req.getHeader("Origin"));
res.setHeader("Access-control-Allow-Methods","GET,POST,OPTIONS,PUT,DELETE");
res.setHeader("Access-control-Allow-Headers",req.getHeader("Access-Control-Request-Headers"));
// 跨域时会首先发送一个option请求,这里我们给option请求直接返回正常状态
if (req.getMethod().equals(RequestMethod.OPTIONS.name())) {
res.setStatus(HttpStatus.OK.value());
return false;
}
return super.preHandle(request, response);
}
/**
* 将非法请求跳转到 /unauthorized/**
*/
private void responseError(ServletResponse response, String message) {
System.out.println("responseError");
try {
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
//设置编码,否则中文字符在重定向时会变为空字符串
message = URLEncoder.encode(message, "UTF-8");
httpServletResponse.sendRedirect("/unauthorized/" + message);
} catch (IOException e) {
System.out.println(e.getMessage());
}
}
/*
* 这里的getBean是因为使用@Autowired无法把RedisUtil注入进来
* 这样自动去注入当使用的时候是未NULL,是注入不进去了。通俗的来讲是因为拦截器在spring扫描bean之前加载所以注入不进去。
*
* 解决的方法:
* 可以通过已经初始化之后applicationContext容器中去获取需要的bean.
* */
public T getBean(Class clazz,HttpServletRequest request){
WebApplicationContext applicationContext = WebApplicationContextUtils.getRequiredWebApplicationContext(request.getServletContext());
return applicationContext.getBean(clazz);
}
//刷新token
private String refreshToken(ServletRequest request,ServletResponse response) {
System.out.println("refreshToken");
HttpServletRequest req= (HttpServletRequest) request;
RedisUtil redisUtil=getBean(RedisUtil.class,req);
//获取传递过来的accessToken
String accessToken=req.getHeader("Authorization");
//获取token里面的用户名
String username= JWTUtil.getUsername(accessToken);
System.out.println("username"+username);
//判断refreshToken是否过期了,过期了那么所含的username的键不存在
System.out.println("redisUtil.hasKey(username)"+redisUtil.hasKey(username));
if (redisUtil.hasKey(username)){
//判断refresh的时间节点和传递过来的accessToken的时间节点是否一致,不一致校验失败
long current= (long) redisUtil.get(username);
if (current==JWTUtil.getExpire(accessToken)){
//获取当前时间节点
long currentTimeMillis = System.currentTimeMillis();
//生成刷新的token
String token=JWTUtil.createToken(username,currentTimeMillis);
//刷新redis里面的refreshToken,过期时间是30min
redisUtil.set(username,currentTimeMillis,30*60);
//再次交给shiro进行认证
JWTToken jwtToken=new JWTToken(token);
try {
getSubject(request, response).login(jwtToken);
// 最后将刷新的AccessToken存放在Response的Header中的Authorization字段返回
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
httpServletResponse.setHeader("Authorization", token);
httpServletResponse.setHeader("Access-Control-Expose-Headers", "Authorization");
return "success";
}catch (Exception e){
return e.getMessage();
}
}
}
return "token认证失效,token过期,重新登陆";
}
}
AccountRealm是shiro进行登录或者权限校验的逻辑所在,在这里需要重写3个方法。
需要注意的点:认证抛出的异常会被jwtFilter的login( )方法获取到的
认证的流程需要梳理一下:
先获取token中信息包含的用户名,判断用户名是否存在,该用户名是否有对应的账号;再验证redis有没有该用户名对应的key,如果有,判断accessToken有没有过期,如果没有则获取对应的value,这就是refreshToken;接着判断该value和accessToken里面的时间戳是否一样,如果一样那么就认证通过。
import com.auth0.jwt.exceptions.TokenExpiredException;
import csu.org.jwtshiroredis.domain.User;
import csu.org.jwtshiroredis.service.UserService;
import csu.org.jwtshiroredis.utils.JWTUtil;
import csu.org.jwtshiroredis.utils.RedisUtil;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class MyRealm extends AuthorizingRealm {
@Autowired
private UserService userService;
@Autowired
private RedisUtil redisUtil;
//根据token判断此Authenticator是否使用该realm
//必须重写不然shiro会报错
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JWTToken;
}
/**
* 只有当需要检测用户权限的时候才会调用此方法,例如@RequiresRoles,@RequiresPermissions之类的
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
System.out.println("授权~~~~~");
String token=principals.toString();
String username= JWTUtil.getUsername(token);
User user=userService.getUser(username);
SimpleAuthorizationInfo info=new SimpleAuthorizationInfo();
//查询数据库来获取用户的角色
info.addRole(user.getRoles());
//查询数据库来获取用户的权限
info.addStringPermission(user.getPermission());
return info;
}
/**
* 默认使用此方法进行用户名正确与否验证,错误抛出异常即可,在需要用户认证和鉴权的时候才会调用
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
System.out.println("认证~~~~~~~");
String jwt= (String) token.getCredentials();
String username= null;
try {
username= JWTUtil.getUsername(jwt);
}catch (Exception e){
throw new AuthenticationException("token非法,不是规范的token,可能被篡改了,或者过期了");
}
if (username==null){
throw new AuthenticationException("token中无用户名");
}
User user=userService.getUser(username);
if (user==null){
throw new AuthenticationException("该用户不存在");
}
//开始认证,只要AccessToken没有过期,或者refreshToken的时间节点和AccessToken一致即可
if (redisUtil.hasKey(username)){
//判断AccessToken有无过期
if (!JWTUtil.verify(jwt)){
throw new TokenExpiredException("token认证失效,token过期,重新登陆");
}else {
//判断AccessToken和refreshToken的时间节点是否一致
long current= (long) redisUtil.get(username);
if (current==JWTUtil.getExpire(jwt)){
return new SimpleAuthenticationInfo(jwt,jwt,"MyRealm");
}else{
throw new AuthenticationException("token已经失效,请重新登录!");
}
}
}else{
throw new AuthenticationException("token过期或者Token错误!!");
}
}
}
配置文件的任务主要有:
1、创建defaultWebSecurityManager对象
2、创建ShiroFilterFactoryBean进行过滤拦截,权限和登录
3、关闭session
4、添加注解权限开发
注意:springboot整合jwt与单纯的shiro实现认证有三个不一样的地方。
1、因为不适用Session,所以为了防止会调用getSession( )方法而产生错误,需要关闭session。
2、一些修改,如关闭ShiroDao等。
3、注册JwtFilter到ShiroFilterFactoryBean中。
import csu.org.jwtshiroredis.filter.JWTFilter;
import csu.org.jwtshiroredis.shiro.MyRealm;
import org.apache.shiro.mgt.DefaultSessionStorageEvaluator;
import org.apache.shiro.mgt.DefaultSubjectDAO;
import org.apache.shiro.spring.LifecycleBeanPostProcessor;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.servlet.Filter;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
@Configuration
public class ShiroConfig {
@Bean(name = "securityManager")
public DefaultWebSecurityManager securityManager(MyRealm myRealm){
DefaultWebSecurityManager securityManager=new DefaultWebSecurityManager();
// 设置自定义 realm.
securityManager.setRealm(myRealm);
//关闭session
DefaultSubjectDAO subjectDAO=new DefaultSubjectDAO();
DefaultSessionStorageEvaluator sessionStorageEvaluator=new DefaultSessionStorageEvaluator();
sessionStorageEvaluator.setSessionStorageEnabled(false);
subjectDAO.setSessionStorageEvaluator(sessionStorageEvaluator);
securityManager.setSubjectDAO(subjectDAO);
return securityManager;
}
/**
* 先走 filter ,然后 filter 如果检测到请求头存在 token,则用 token 去 login,走 Realm 去验证
*/
@Bean
public ShiroFilterFactoryBean factory(@Qualifier("securityManager")DefaultWebSecurityManager securityManager){
ShiroFilterFactoryBean factoryBean=new ShiroFilterFactoryBean();
factoryBean.setSecurityManager(securityManager);
// 添加自己的过滤器并且取名为jwt
Map filterMap=new LinkedHashMap<>();
//设置我们自定义的JWT过滤器
filterMap.put("jwt",new JWTFilter());
factoryBean.setFilters(filterMap);
// 设置无权限时跳转的 url;
factoryBean.setUnauthorizedUrl("/unauthorized/无权限");
MapfilterRuleMap=new HashMap<>();
// 所有请求通过我们自己的JWT Filter
filterRuleMap.put("/**","jwt");
// 访问 /unauthorized/** 不通过JWTFilter
filterRuleMap.put("/unauthorized/**","anon");
factoryBean.setFilterChainDefinitionMap(filterRuleMap);
return factoryBean;
}
/**
* 添加注解支持,如果不加的话很有可能注解失效
*/
@Bean
public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator(){
DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator=new DefaultAdvisorAutoProxyCreator();
defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
return defaultAdvisorAutoProxyCreator;
}
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(@Qualifier("securityManager") DefaultWebSecurityManager securityManager){
AuthorizationAttributeSourceAdvisor advisor=new AuthorizationAttributeSourceAdvisor();
advisor.setSecurityManager(securityManager);
return advisor;
}
@Bean
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor();
}
}
import lombok.Data;
import java.io.Serializable;
@Data
public class R implements Serializable {
private static final long serialVersionUID = 1l;
//成功标志
private Boolean success;
//返回代码
private Integer code;
//返回处理消息
private String message;
//返回数据对象 data
private T data;
//时间戳
private long timestamp = System.currentTimeMillis();
public R(){}
public static R ok(){
R r = new R<>();
r.setSuccess(ResultEnum.SUCCESS.getSuccess());
r.setCode(ResultEnum.SUCCESS.getCode());
r.setMessage(ResultEnum.SUCCESS.getMessage());
return r;
}
public static R ok(String msg){
R r = new R<>();
r.setSuccess(ResultEnum.SUCCESS.getSuccess());
r.setCode(ResultEnum.SUCCESS.getCode());
r.setMessage(msg);
return r;
}
public static R ok(Object data){
R r = new R<>();
r.setSuccess(ResultEnum.SUCCESS.getSuccess());
r.setCode(ResultEnum.SUCCESS.getCode());
r.setMessage(ResultEnum.SUCCESS.getMessage());
r.setData(data);
return r;
}
public static R OK(){
R r = new R<>();
r.setSuccess(ResultEnum.SUCCESS.getSuccess());
r.setCode(ResultEnum.SUCCESS.getCode());
r.setMessage(ResultEnum.SUCCESS.getMessage());
return r;
}
public static R OK(T data){
R r = new R<>();
r.setSuccess(ResultEnum.SUCCESS.getSuccess());
r.setCode(ResultEnum.SUCCESS.getCode());
r.setMessage(ResultEnum.SUCCESS.getMessage());
r.setData(data);
return r;
}
public static R OK(String msg,T data){
R r = new R<>();
r.setSuccess(ResultEnum.SUCCESS.getSuccess());
r.setCode(ResultEnum.SUCCESS.getCode());
r.setMessage(msg);
r.setData(data);
return r;
}
/**
* 请求失败
*/
public static R error(){
R r = new R<>();
r.setSuccess(ResultEnum.FAILED.getSuccess());
r.setCode(ResultEnum.FAILED.getCode());
r.setMessage(ResultEnum.FAILED.getMessage());
return r;
}
public static R error(String msg){
R r = new R<>();
r.setSuccess(ResultEnum.FAILED.getSuccess());
r.setCode(ResultEnum.FAILED.getCode());
r.setMessage(msg);
return r;
}
/**
* 请求无权限
*/
public static R unanthorized(){
R r = new R<>();
r.setSuccess(ResultEnum.UNAUTHORIZED.getSuccess());
r.setCode(ResultEnum.UNAUTHORIZED.getCode());
r.setMessage(ResultEnum.UNAUTHORIZED.getMessage());
return r;
}
public R setResult(ResultEnum resultEnum){
R r = new R<>();
r.setSuccess(resultEnum.getSuccess());
r.setCode(resultEnum.getCode());
r.setMessage(resultEnum.getMessage());
return r;
}
public R success(Boolean status){
this.setSuccess(status);
return this;
}
public R code(Integer code){
this.setCode(code);
return this;
}
public R message(String message){
this.setMessage(message);
return this;
}
}
import lombok.Getter;
import lombok.ToString;
@Getter
@ToString
public enum ResultEnum {
SUCCESS(true,200,"操作成功"),
FAILED(false,300,"操作失败"),
NO_OPERATOR_AUTH(false,301,"无操作权限"),
DATA_ERROR(false,302,"数据错误"),
DATA_NO_EXIST(false,303,"数据不存在"),
UNAUTHORIZED(false,401,"用户认证失败");
private final Boolean success;
private final Integer code;
private final String message;
ResultEnum(Boolean success,Integer code,String message){
this.success=success;
this.code=code;
this.message=message;
}
}
前后端分离项目一定要注意异常的处理,便于前端人员看懂错误提示。
import com.auth0.jwt.exceptions.TokenExpiredException;
import csu.org.jwtshiroredis.utils.Result;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.ShiroException;
import org.apache.shiro.authz.UnauthenticatedException;
import org.apache.shiro.authz.UnauthorizedException;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindingResult;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.io.IOException;
//捕获全局异常的处理
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandle {
// 捕捉shiro的异常
@ResponseStatus(HttpStatus.UNAUTHORIZED)
@ExceptionHandler(ShiroException.class)
public Result handle401(ShiroException e) {
return Result.fail(401, e.getMessage(), null);
}
// 捕捉未登录的异常
@ResponseStatus(HttpStatus.UNAUTHORIZED)
@ExceptionHandler(UnauthenticatedException.class)
public Result handle401(UnauthenticatedException e) {
System.out.println(e.getMessage());
return Result.fail(401, "你还没有登录", null);
}
// 捕捉没有相应的权限或者角色的异常
@ResponseStatus(HttpStatus.UNAUTHORIZED)
@ExceptionHandler(UnauthorizedException.class)
public Result handle401(UnauthorizedException e) {
System.out.println(e.getMessage());
return Result.fail(401, "你没有权限访问"+e.getMessage(), null);
}
/**
* @Validated 校验错误异常处理
*/
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(value = MethodArgumentNotValidException.class)
public Result handler(MethodArgumentNotValidException e) throws IOException {
// log.error("运行时异常:-------------->",e);
BindingResult bindingResult = e.getBindingResult();
//这一步是把异常的信息最简化
ObjectError objectError = bindingResult.getAllErrors().stream().findFirst().get();
return Result.fail(HttpStatus.BAD_REQUEST.value(),objectError.getDefaultMessage(),null);
}
/**
* 处理Assert的异常
*/
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(value = IllegalArgumentException.class)
public Result handler(IllegalArgumentException e) throws IOException {
// log.error("Assert异常:-------------->{}",e.getMessage());
return Result.fail(400,e.getMessage(),null);
}
//运行时错误处理
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(value = RuntimeException.class)
public Result handle(RuntimeException e){
return Result.fail(HttpStatus.BAD_REQUEST.value(),e.getMessage(),null);
}
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(value = TokenExpiredException.class)
public Result handler(TokenExpiredException e) throws IOException {
return Result.fail(HttpStatus.BAD_REQUEST.value(),"token已经过期,请重新登录",null);
}
}
登录需要传递用户名、密码过来,然后到数据库去查找对应的用户,如果找不到就提醒找不到用户,如果用户名密码正确就生成对应的AccessToken,同时把refreshToken保存到redis里面。
import csu.org.jwtshiroredis.domain.User;
import csu.org.jwtshiroredis.service.UserService;
import csu.org.jwtshiroredis.utils.JWTUtil;
import csu.org.jwtshiroredis.utils.RedisUtil;
import csu.org.jwtshiroredis.utils.Result;
import org.apache.shiro.authz.annotation.RequiresAuthentication;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.Assert;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import java.io.UnsupportedEncodingException;
@RestController
public class LoginController {
@Autowired
private UserService userService;
@Autowired
private RedisUtil redisUtil;
@PostMapping("/login")
public Result login(@RequestParam String username, @RequestParam String password){
User user=userService.getUserByPass(username, password);
Assert.notNull(user,"用户名或密码错误");
long currentTimeMillis = System.currentTimeMillis();
String token= JWTUtil.createToken(user.getUsername(),currentTimeMillis);
redisUtil.set(username,currentTimeMillis,60*30);
return Result.succ(200,"登陆成功",token);
}
@RequestMapping(path = "/unauthorized/{message}")
public Result unauthorized(@PathVariable String message) throws UnsupportedEncodingException {
return Result.fail(message);
}
@DeleteMapping("/logout")
@RequiresAuthentication
public Result logout(HttpServletRequest request){
String token=request.getHeader("Authorization");
String username=JWTUtil.getUsername(token);
redisUtil.del(username);
return Result.succ(null);
}
}
import com.example.demo.domain.User;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public interface UserService {
List getUserByPwd(String username, String password);
/*SysUser getUserByPwd(String username,String password);*/
}
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.example.demo.domain.SysUser;
import com.example.demo.mapper.UserMapper;
import com.example.demo.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Service
public class UserServiceImpl implements UserService {
@Autowired
UserMapper userMapper;
@Override
public List getUserByPwd(String username, String password) {
List list = new ArrayList<>();
QueryWrapper qw = new QueryWrapper<>();
if (username!=null&&(!"".equals(username))){
qw.eq("username",username);
}
if (password!=null&&(!"".equals(password))){
qw.eq("password",password);
}
list = userMapper.selectList(qw);
return list;
}
/*@Override
public SysUser getUserByPwd(String username, String password) {
return userMapper.selectUserBypwd(username,password);
}*/
}
import com.baomidou.mybatisplus.extension.activerecord.Model;
import lombok.Data;
import java.io.Serializable;
@Data
public class SysUser extends Model implements Serializable {
private static final long serialVersionUID = 305168670107337418L;
private String id;
private String username;
private String password;
private String roles;
private String permission;
}
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.demo.domain.SysUser;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface UserMapper extends BaseMapper {
@Select("select * from sys_user where username=#{username} and password=#{password}")
SysUser selectUserBypwd(String username,String password);
}
只需要把redis里面的对应的用户名的refreshToken删除就可以了。
import csu.org.jwtshiroredis.domain.User;
import csu.org.jwtshiroredis.service.UserService;
import csu.org.jwtshiroredis.utils.JWTUtil;
import csu.org.jwtshiroredis.utils.RedisUtil;
import csu.org.jwtshiroredis.utils.Result;
import org.apache.shiro.authz.annotation.RequiresAuthentication;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.Assert;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import java.io.UnsupportedEncodingException;
@RestController
public class LoginController {
@Autowired
private UserService userService;
@Autowired
private RedisUtil redisUtil;
@PostMapping("/login")
public Result login(@RequestParam String username, @RequestParam String password){
User user=userService.getUserByPass(username, password);
Assert.notNull(user,"用户名或密码错误");
long currentTimeMillis = System.currentTimeMillis();
String token= JWTUtil.createToken(user.getUsername(),currentTimeMillis);
redisUtil.set(username,currentTimeMillis,60*30);
return Result.succ(200,"登陆成功",token);
}
@RequestMapping(path = "/unauthorized/{message}")
public Result unauthorized(@PathVariable String message) throws UnsupportedEncodingException {
return Result.fail(message);
}
@DeleteMapping("/logout")
@RequiresAuthentication
public Result logout(HttpServletRequest request){
String token=request.getHeader("Authorization");
String username=JWTUtil.getUsername(token);
redisUtil.del(username);
return Result.succ(null);
}
}
import csu.org.jwtshiroredis.service.UserService;
import csu.org.jwtshiroredis.utils.Result;
import org.apache.shiro.authz.annotation.RequiresAuthentication;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.apache.shiro.authz.annotation.RequiresRoles;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/user")
public class UserController {
@Autowired
private UserService userService;
@RequiresAuthentication
@GetMapping("/test")
public Result test(){
return Result.succ("test");
}
@RequiresRoles("admin")
@GetMapping("/admin")
public Result admin(){
return Result.succ("admin");
}
@RequiresRoles("vip")
@PostMapping("/vip")
public Result vip(){
return Result.succ("vip");
}
@RequiresPermissions("update")
@PutMapping("/update")
public Result update(){
return Result.succ("update");
}
@RequiresPermissions("delete")
@DeleteMapping("/delete")
public Result delete(){
return Result.succ("delete");
}
@GetMapping("/guest")
public Result guest(){
return Result.succ("guest");
}
}
1、上述知识点以及代码实现,大部分都是通过学习其他博主的内容,写的很清楚,要特别感谢,在这也放上链接:jwt+shiro+redis实现token的自动刷新和token的可控性_shiro+redis token_csu_zhuzi的博客-CSDN博客
2、以上内容主要是为了自身学习,提升点知识,所以加了部分自己的学习见解。有不足的还请大家指出改正!