1.基本结构
首先新建了用户、权限和角色表
之后创建DAO层、service、controller层对应代码,此处省略就不一一列出了
2、增加maven依赖
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.4.0</version>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.3.2</version>
</dependency>
<!-- shiro-redis缓存插件 -->
<dependency>
<groupId>org.crazycake</groupId>
<artifactId>shiro-redis</artifactId>
<version>2.4.2.1-RELEASE</version>
</dependency>
3、配置JWT
创建jwtUtils,实现一个简单的JWT加密、检验
public class JWTUtils {
private static final long EXPIRE_TIME = 10 * 60 * 1000;//十分钟过期
private static final String TOKEN_SECRET = "mywords"; //密钥盐
/**
* 签名生成
*
* @param user
* @return
*/
public static String sign(User user) {
String token = null;
try {
Date expiresAt = new Date(System.currentTimeMillis() + EXPIRE_TIME);
token = JWT.create().withIssuer("words")
.withClaim("username", user.getUsername())
.withAudience(user.getId().toString())
.withExpiresAt(expiresAt)
//使用HMAC256加密
.sign(Algorithm.HMAC256(TOKEN_SECRET));
} catch (Exception e) {
e.printStackTrace();
}
return token;
}
/**
* 获取用户名
*
* @param token token中包含了用户名
* @return
*/
public static String getUsername(String token) {
try {
JWTVerifier verifier = JWT.require(Algorithm.HMAC256(TOKEN_SECRET)).withIssuer("words").build();
DecodedJWT jwt = verifier.verify(token);
return jwt.getClaim("username").asString();
} catch (JWTDecodeException e) {
e.printStackTrace();
return null;
}
}
/**
* 获取id
*
* @param token
* @return
*/
public static String getId(String token) {
try {
JWTVerifier verifier = JWT.require(Algorithm.HMAC256(TOKEN_SECRET)).withIssuer("words").build();
DecodedJWT jwt = verifier.verify(token);
return jwt.getAudience().get(0);
} catch (JWTDecodeException e) {
e.printStackTrace();
return null;
}
}
/**
* 签名验证
*
* @param token
* @return
*/
public static boolean verify(String token) {
try {
JWTVerifier verifier = JWT.require(Algorithm.HMAC256(TOKEN_SECRET)).withIssuer("words").build();
DecodedJWT jwt = verifier.verify(token);
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
}
自定义异常
为了能够抛出不同情况的异常,对权限异常进行统一化管理,所以我自己写了一个自定义权限异常
@Getter
public class AuthException extends RuntimeException {
private Integer code;
/**
* 使用已有的错误类型
* @param authEnum 枚举类中的错误类型
*/
public AuthException(AuthEnum authEnum){
super(authEnum.getMsg());
this.code = authEnum.getCode();
}
/**
* 自定义错误类型
* @param code 自定义的错误码
* @param msg 自定义的错误提示
*/
public AuthException(Integer code, String msg){
super(msg);
this.code = code;
}
}
枚举类:
@Getter
@AllArgsConstructor
public enum AuthEnum {
/**
* 错误类型
*/
TOKEN_FAIL_STATE(100, "token已失效!"),
ROLE_HAVE_NO_PERMISSION(101, "用户无权限"),
NO_ROLE_CONFIGER(102, "用户无角色"),
NO_User_NULL(103, "用户状态异常"),
NO_LOGIN(104, "用户未登录!"),
NO_NULL_TOKEN(105, "token不能为空!"),
ILLEGAL_LOGIN(202, "非法请求!");
/**
* 错误码
*/
private int code;
/**
* 提示信息
*/
private String msg;
}
4、配置 Shiro
4.1实现JWTToken
JWTToken 差不多就是 Shiro 用户名密码的载体。因为我们是前后端分离,服务器无需保存用户状态,所以不需要 RememberMe 这类功能,我们简单的实现下 AuthenticationToken 接口即可。因为 token 自己已经包含了用户名等信息,所以这里我就弄了一个字段。如果你喜欢钻研,可以看看官方的 UsernamePasswordToken 是如何实现的。
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;
}
}
4.2、自定义realm
自定义Realm,继承AuthorizingRealm,完成访问授权和登录认证
doGetAuthenticationInfo:登录认证,获取用户信息、校验
doGetAuthorizationInfo:权限信息认证(包括角色以及权限)是用户访问controller的时候才进行验证(redis存储的此处权限信息)
@Service
@Slf4j
public class MyRealm extends AuthorizingRealm {
@Autowired
private RedisUtils redisUtils;
/**
* 大坑!,必须重写此方法,不然Shiro会报错
*/
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JWTToken;
}
//授权
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
//获取到安全数据
LoginUser user = (LoginUser)principalCollection.getPrimaryPrincipal();
Set<String> authNames = (Set<String>)user.getAuths().get("authNames");
//构造权限数据
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
info.addStringPermissions(authNames);
return info;
}
//认证
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws AuthenticationException {
String token = (String) auth.getCredentials();
if (token == null) {
throw new AuthException(AuthEnum.NO_NULL_TOKEN);
}
if (!jwtTokenRefresh(token)) {
throw new AuthException(AuthEnum.TOKEN_FAIL_STATE);
}
LoginUser loginUser = checkUserToken(token);
return new SimpleAuthenticationInfo(loginUser, token, this.getName());
}
/**
* 检查token的有效性
*
* @param token
* @return
*/
public LoginUser checkUserToken(String token) {
//用户登录时已经判断过用户名和密码,所以此处直接从redis获取
String userJson = redisUtils.get("user:" + token);
if(StringUtil.isNullOrEmpty(userJson)){
throw new AuthException(AuthEnum.TOKEN_FAIL_STATE);
}
User user = JacksonUtils.deserialize(userJson, User.class);
//从redis中取出当前权限
String authJson = redisUtils.get("authName" + user.getId());
Set<String> authNames = JacksonUtils.deserialize(authJson, HashSet.class);
LoginUser loginUser = new LoginUser(user.getUsername(),authNames);
return loginUser;
}
/**
* JWTToken刷新生命周期 (实现: 用户在线操作不掉线功能)
* 1、登录成功后将用户的JWT生成的Token作为k、v存储到cache缓存里面(这时候k、v值一样),缓存有效期设置为Jwt有效时间的2倍
* 2、当该用户再次请求时,通过JWTFilter层层校验之后会进入到doGetAuthenticationInfo进行身份验证
* 3、当该用户这次请求jwt生成的token值已经超时,但该token对应cache中的k还是存在,则表示该用户一直在操作只是JWT的token失效了,
* 程序会给token对应的k映射的v值重新生成JWTToken并覆盖v值,该缓存生命周期重新计算
* 4、当该用户这次请求jwt在生成的token值已经超时,并在cache中不存在对应的k,则表示该用户账户空闲超时,返回用户信息已失效,请重新登录。
* 注意: 前端请求Header中设置Authorization保持不变,校验有效性以缓存中的token为准。
* 用户过期时间 = Jwt有效时间 * 2。
*
* @param token
* @return
*/
public Boolean jwtTokenRefresh(String token) {
String tokenRedis = redisUtils.get("user_token:" + token);
String userJson = redisUtils.get("user:" + token);
if (!StringUtil.isNullOrEmpty(tokenRedis) && !StringUtil.isNullOrEmpty(userJson)) {
//检验redis是否失效,如果超时证明一直在操作
if (!JWTUtils.verify(tokenRedis)) {
User user = JacksonUtils.deserialize(userJson, User.class);
String newToken = JWTUtils.sign(user);
//时间设置为二十分钟
redisUtils.setForTimeCustom("user_token:" + token, newToken, 20, TimeUnit.MINUTES);
redisUtils.setForTimeCustom("user:" + token, userJson, 20, TimeUnit.MINUTES);
log.info("------用户更新token------");
}
return true;
}
return false;
}
}
4.3、重写 Filter
所有的请求都会先经过 Filter,所以我们继承官方的 BasicHttpAuthenticationFilter ,并且重写鉴权的方法。
代码的执行流程 preHandle -> isAccessAllowed -> isLoginAttempt -> executeLogin 。
public class JWTFilter extends BasicHttpAuthenticationFilter {
/**
* 判断用户是否想要登录
* 检查请求头里是否带有Authorization字段
*
* @param request
* @param response
* @return
*/
@Override
protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
HttpServletRequest req = (HttpServletRequest)request;
String authorization = req.getHeader("Authorization");
return authorization != null;
}
/**
* 登陆拦截
*
* @param request
* @param response
* @return
* @throws Exception
*/
@Override
protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest req = (HttpServletRequest)request;
String authorization = req.getHeader("Authorization");
JWTToken token = new JWTToken(authorization);
//提交给realm进行判断,如果错误将会直接抛出异常
getSubject(request,response).login(token);
//如果没有抛出异常则代表登陆成功,返回true
return true;
}
/**
* 设置返回接口为true是为了判断用户和游客看到的内容不同
* 如果我们返回false,那么请求将会直接被拦截,用户看不到任何东西
* 所以我们返回true,controller层可以通过subject.isAuthenticated()判断用户是否登入
* 如果有些资源只有登上用户才能访问,我们只需要在方法上面加上 @RequiresAuthentication 注解即可
* 但是这样做有一个缺点,就是不能够对GET,POST等请求进行分别过滤鉴权(因为我们重写了官方的方法),但实际上对应用影响不大
*
* @param request
* @param response
* @param mappedValue
* @return
*/
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
if(isLoginAttempt(request,response)){//有token
try {
executeLogin(request,response);
return true;
}catch (Exception e){
//抛出token失效异常
throw new AuthException(AuthEnum.TOKEN_FAIL_STATE);
}
}else {
throw new AuthException(AuthEnum.NO_NULL_TOKEN);
}
}
/**
* 对跨域提供支持
*
* @param request
* @param response
* @return
* @throws Exception
*/
@Override
protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin"));
httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers"));
// 跨域时会首先发送一个option请求,这里我们给option请求直接返回正常状态
if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
httpServletResponse.setStatus(HttpStatus.OK.value());
return false;
}
return super.preHandle(request, response);
}
}
4.4、配置shiro
上面对于数据源之类的还有认证授权都完事了,然后就将这些东西注入到Shiro的配置里面,并且标记上@Configuration注入到spring容器就生效了。
@Configuration
@Slf4j
public class ShiroConfig {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private int port;
@Value("${spring.redis.password}")
private String password;
/**
* 创建安全管理器
*
* @param realm
* @return
*/
@Bean("securityManager")
public DefaultWebSecurityManager getManager(MyRealm realm) {
DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
//注入自己的realm
manager.setRealm(realm);
//自定义缓存实现,使用redis
manager.setCacheManager(cacheManager());
//自定义session管理,使用redis
manager.setSessionManager(sessionManager());
return manager;
}
/**
* 配置shiro的过滤器工厂
*/
@Bean("shiroFilter")
public ShiroFilterFactoryBean factory(DefaultWebSecurityManager securityManager) {
ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
//添加自己的过滤器并取名为jwt
Map<String, Filter> filterMap = new HashMap<>();
filterMap.put("jwt", new JWTFilter());
factoryBean.setFilters(filterMap);
factoryBean.setSecurityManager(securityManager);
//无权限跳转
factoryBean.setUnauthorizedUrl("/authority/unauth");
/*
* 自定义url规则
*/
Map<String, String> filterRuleMap = new HashMap<>();
// 所有请求通过我们自己的JWT Filter
filterRuleMap.put("/**", "jwt");
filterRuleMap.put("/user/login", "anon");
filterRuleMap.put("/user/register", "anon");
//注册
//authc -- 认证之后访问(登录)
//filterRuleMap.put("/**","authc");
factoryBean.setFilterChainDefinitionMap(filterRuleMap);
return factoryBean;
}
/**
* 下面的代码是添加注解支持
*/
@Bean
@DependsOn("lifecycleBeanPostProcessor")
public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
// 强制使用cglib,防止重复代理和可能引起代理出错的问题
// https://zhuanlan.zhihu.com/p/29161098
defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
return defaultAdvisorAutoProxyCreator;
}
/**
* 管理shiro的bean生命周期
*
* @return
*/
@Bean
public static LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor();
}
/**
* 注解访问授权动态拦截,不然不会执行doGetAuthenticationInfo
*
* @param securityManager
* @return
*/
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(DefaultWebSecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
advisor.setSecurityManager(securityManager);
return advisor;
}
/**
* 使用Redis作为缓存需要shiro重写cache、cacheManager、SessionDAO
* 1.redis的控制器,操作redis
*/
public RedisManager redisManager() {
RedisManager redisManager = new RedisManager();
redisManager.setHost(host);
redisManager.setPort(port);
redisManager.setPassword(password);
// 配置过期时间
redisManager.setExpire(1800);
return redisManager;
}
/**
* 2.sessionDao
*/
public RedisSessionDAO redisSessionDAO() {
RedisSessionDAO sessionDAO = new RedisSessionDAO();
sessionDAO.setRedisManager(redisManager());
return sessionDAO;
}
/**
* 3.会话管理器
*/
public DefaultWebSessionManager sessionManager() {
DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
sessionManager.setSessionDAO(redisSessionDAO());
return sessionManager;
}
/**
* 缓存管理器
*
* @return
*/
public RedisCacheManager cacheManager() {
RedisCacheManager redisCacheManager = new RedisCacheManager();
redisCacheManager.setRedisManager(redisManager());
return redisCacheManager;
}
}
5、遇到的坑
5.1、上面的配置好了后,在方法上添加@RequiresPermissions注解,但是却一直报类型转换错误,找了一圈才发现原来是一个很基础的问题,我把@RequiresPermissions注解放到了@GetMapping注解上方,所以才会一直报类型转换异常
5.2、shiro整合缓存时一直获取不到.yml配置文件中的地址和端口号
大致原因是因为LifecycleBeanPostProcessor这个类,LifecycleBeanPostProcessor用于在实现了Initializable接口的Shiro bean初始化时调用Initializable接口回调,在实现了Destroyable接口的Shiro bean销毁时调用 Destroyable接口回调。而我在创建的EhCacheManager 正是实现了Initializable接口。
最后解决问题很简单,只要在创建LifecycleBeanPostProcessor的方法变为静态static方法就可以了。