1. 概述
首先需要知道为什么使用 Shiro+Jwt+Redis
进行登录认证和权限控制。
1. 为什么用Shiro?
主要用的是 shiro
的登录认证和权限控制功能。
Shiro 参见本栏目文章 🍃《Shiro实战》
2. 为什么用Jwt?
Shiro
默认的 Session
机制来帮助实现权限管理,用于维护用户的状态信息。而 JWT
是 token认证
的一种具体实现方式,相对于传统的 session认证
方式,有如下优点:
- 跨域支持:cookie 是无法跨域的,而 token 由于没有用到 cookie(前提是将 token 放到请求头中),所以跨域后不会存在信息丢失问题。
- 无状态:token 机制在服务端不需要存储 session 信息,因为 token 自身包含了所有登录用户的信息,所以可以减轻服务端压力,节约服务器资源,并且可以很容易地分布式横向扩展应用。
- 更适用CDN:可以通过内容分发网络请求服务端的所有资料。
- 更适用于移动端:当客户端是非浏览器平台时,cookie 是不被支持的,此时采用 token 认证方式会简单很多。
- 无需考虑CSRF:由于不再依赖 cookie,所以采用 token 认证方式不会发生 CSRF,所以也就无需考虑 CSRF 防御。
JWT 参见本栏目文章 🍃《JWT详解》
3. 为什么用Redis?
- JWT 本身不能续期,结合 Redis,可以实现
续期
、主动登出
。 - Redis可以缓存用户信息,减少数据库压力。
- 可以实现分布式环境下的会话共享。
2. Springboot整合Shiro+Jwt+Redis
2.1 JWT配置
2.1.1 依赖
<dependency><groupId>com.auth0</groupId><artifactId>java-jwt</artifactId><version>3.19.0</version>
</dependency>
2.1.2 配置JWT
shiro:jwt:# 加密秘钥secret: kGgySFGcfQML4ZOvvlE7856YvSCsbjBf# token有效时长,1天,单位毫秒expire: 86400000header:# 加密算法alg: HS256# token类型typ: JWT
2.1.2 JWT工具类
@Component
public class JwtUtil {@Value("${shiro.jwt.secret}")private String secret;@Value("${shiro.jwt.expire}")private Long expire;@Value("${shiro.jwt.header.alg}")private String headerAlg;@Value("${shiro.jwt.header.typ}")private String headerTyp;/*** 生成token*/public String getToken(String account, long currentTimeMillis) {// 设置秘钥StringBuilder stringBuilder = new StringBuilder();stringBuilder.append(account).append(secret);// 设置jwt头headerMap<String, Object> headerClaims = new HashMap<>();headerClaims.put("alg", headerAlg); // 签名算法headerClaims.put("typ", headerTyp); // token 类型// 设置jwt的header,负载paload以及加密算法String token = JWT.create().withHeader(headerClaims).withClaim("account" ,account).withClaim("expire", currentTimeMillis + expire).sign(Algorithm.HMAC256(stringBuilder.toString()));return token;}/*** 无需秘钥就能获取其中的信息* 解析token.* {* "account": "account",* "timeStamp": "134143214"* }*/public Map<String, String> parseToken(String token) {HashMap<String, String> map = new HashMap<String, String>();// 解码 JWTDecodedJWT decodedJwt = JWT.decode(token);Claim account = decodedJwt.getClaim("account");Claim expire = decodedJwt.getClaim("expire");map.put("account", account.asString());map.put("expire", expire.asLong().toString());return map;}/*** 解析token获取账号.*/public String getAccount(String token) {HashMap<String, String> map = new HashMap<String, String>();DecodedJWT decodedJwt = JWT.decode(token);Claim account = decodedJwt.getClaim("account");return account.asString();}/*** 校验token是否正确* @param token Token* @return boolean 是否正确*/public boolean verify(String token) {DecodedJWT decodedJwt = JWT.decode(token);Claim account = decodedJwt.getClaim("account");Claim expire = decodedJwt.getClaim("expire");StringBuilder stringBuilder = new StringBuilder();stringBuilder.append(account.asString()).append(secret);// 验证JWT的签名和有效性Algorithm algorithm = Algorithm.HMAC256(stringBuilder.toString());JWTVerifier verifier = JWT.require(algorithm).build();try {verifier.verify(token);return true; // 验证通过} catch (JWTVerificationException e) {return false; // 验证失败}}/*** 校验token是否过期* @param token Token* @return boolean 是否正确*/public boolean isExpired(String token) {DecodedJWT decodedJwt = JWT.decode(token);Claim expire = decodedJwt.getClaim("expire");// 验证过期时间Long expireTime = expire.asLong();if (System.currentTimeMillis() > expireTime) {return true;}return false;}/*** 获取token过期时间* @param token Token* @return boolean 是否正确*/public long getExpiredTime(String token) {DecodedJWT decodedJwt = JWT.decode(token);Claim expire = decodedJwt.getClaim("expire");return expire.asLong();}}
2.2 Redis配置
2.2.1 依赖
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
2.2.2 配置 Redis 地址
spring:redis:host: localhostport: 6379password: 123456database: 0timeout: 5000lettuce:pool:max-idle: 16max-active: 32min-idle: 8
2.2.3 Redis序列化
/*** redis序列化*/
@Configuration
public class RedisConfig {@Bean(name = "redisTemplate")public RedisTemplate<String, Object> getRedisTemplate(LettuceConnectionFactory lettuceConnectionFactory) {// 设置序列化Jackson2JsonRedisSerializer<Object> 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);// 配置redisTemplateRedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();redisTemplate.setConnectionFactory(lettuceConnectionFactory);RedisSerializer<?> stringSerializer = new StringRedisSerializer();// key序列化redisTemplate.setKeySerializer(stringSerializer);// value序列化redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);// Hash key序列化redisTemplate.setHashKeySerializer(stringSerializer);// Hash value序列化redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);redisTemplate.afterPropertiesSet();return redisTemplate;}
}
2.2.4 Redis工具类
封装 RedisTemplate
/*** RedisUtil 工具类*/
@Component
public class RedisUtil {// 使用jwt的过期时间毫秒private final long defaultTimeout = 1*24*60*60*1000;@Autowiredprivate RedisTemplate<String, Object> redisTemplate;/*** 是否存在指定的key** @param key* @return*/public boolean hasKey(String key) {return Boolean.TRUE.equals(redisTemplate.hasKey(key));}/*** 删除指定的key** @param key* @return*/public boolean delete(String key) {return Boolean.TRUE.equals(redisTemplate.delete(key));}//- - - - - - - - - - - - - - - - - - - - - String类型 - - - - - - - - - - - - - - - - - - - -/*** 根据key获取值** @param key 键* @return 值*/public Object get(String key) {return key == null ? null : redisTemplate.opsForValue().get(key);}/*** 将值放入缓存** @param key 键* @param value 值* @return true成功 false 失败*/public void set(String key, String value) {set(key, value, defaultTimeout);}/*** 将值放入缓存并设置时间** @param key 键* @param value 值* @param time 时间(秒) -1为无期限* @return true成功 false 失败*/public void set(String key, String value, long time) {if (time > 0) {redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);} else {redisTemplate.opsForValue().set(key, value, defaultTimeout, TimeUnit.SECONDS);}}//- - - - - - - - - - - - - - - - - - - - - object类型 - - - - - - - - - - - - - - - - - - - -/*** 根据key读取数据*/public Object getObject(final String key) {if (StringUtils.isBlank(key)) {return null;}try {return redisTemplate.opsForValue().get(key);} catch (Exception e) {e.printStackTrace();}return null;}/*** 写入数据*/public boolean setObject(final String key, Object value) {if (StringUtils.isBlank(key)) {return false;}try {setObject(key, value , defaultTimeout);return true;} catch (Exception e) {e.printStackTrace();}return false;}public boolean setObject(final String key, Object value, long time) {if (StringUtils.isBlank(key)) {return false;}if (time > 0) {redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);} else {redisTemplate.opsForValue().set(key, value, defaultTimeout, TimeUnit.SECONDS);}return true;}
}
2.2.5 Redis常量
public class RedisConstant {private RedisConstant() {}public static final String PREFIX_ACCESS_TOKEN = "access_token";public static final String PREFIX_SHIRO_JWT = "shiro:jwt:";
}
2.3 Shiro配置
🍃《Shiro实战》已经介绍 Shiro
基本用法,如果需要整合 JWT
进行 token 认证
,主要涉及三块改动:
- 关闭
Shiro
原有的Session
功能,依赖于Token
来进行认证。涉及 ShiroConfig 类改造。 - 配置
Shiro
的过滤器链
来指定哪些URL
需要进行JWT
认证。涉及 ShiroConfig 类改造。 - 自定义
Realm
类中认证方法doGetAuthenticationInfo
进行逻辑改造。涉及 CustomRealm 类改造。
2.3.1 ShiroConfig
@Configuration
public class ShiroConfig {@Beanpublic HashedCredentialsMatcher hashedCredentialsMatcher() {// 散列算法:这里使用 SHA-256 算法;HashedCredentialsMatcher credentialsMatcher = new HashedCredentialsMatcher("SHA-256");// 散列的次数,比如散列两次,相当于 SHA-256(SHA-256(""));credentialsMatcher.setHashIterations(1024);// storedCredentialsHexEncoded 默认是 true,此时用的是密码加密用的是 Hex 编码;false 时用 Base64 编码credentialsMatcher.setStoredCredentialsHexEncoded(true);return credentialsMatcher;}@Beanpublic CustomRealm customRealm() {CustomRealm customRealm = new CustomRealm();// 将 HashService 注入到自定义的 Realm 中,告诉 realm,使用 hashedCredentialsMatcher 加密算法类来验证密文customRealm.setCredentialsMatcher(hashedCredentialsMatcher());return customRealm;}@Beanpublic SecurityManager securityManager() {DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();// 设置自定义 RealmsecurityManager.setRealm(customRealm());// 禁用 Shiro 的 Session 存储,这样可以确保 shiro 不会创建或使用 Session,而是依赖于无状态的 Token 来进行认证。DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();defaultSessionStorageEvaluator.setSessionStorageEnabled(false);subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);securityManager.setSubjectDAO(subjectDAO);return securityManager;}@Bean(name = "shiroFilter")public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();// 注入 securityManagershiroFilterFactoryBean.setSecurityManager(securityManager);// 添加自定义 Filter,并且取名为 jwtMap<String, Filter> filterMap = new HashMap<>();filterMap.put("jwt", new JwtFilter());shiroFilterFactoryBean.setFilters(filterMap);/*//登录shiroFilterFactoryBean.setLoginUrl("/login");//首页shiroFilterFactoryBean.setSuccessUrl("/index");//错误页面,认证不通过跳转shiroFilterFactoryBean.setUnauthorizedUrl("/notRole");shiroFilterFactoryBean.setUnauthorizedUrl("/error");*/// 设置 Shiro 的过滤器链来指定哪些 URL 需要进行 JWT 认证。Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();// authc:所有url都必须认证通过才可以访问; anon:所有url都都可以匿名访问filterChainDefinitionMap.put("/login", "anon");filterChainDefinitionMap.put("/api/**", "anon");// /** 必须放在所有权限设置的最后,表示对所有资源起作用,本例使用自定义 jwtfilterChainDefinitionMap.put("/**", "jwt");shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);return shiroFilterFactoryBean;}/*** ** 开启 Shiro 的注解(如@RequiresRoles、@RequiresPermissions),需借助 SpringAOP 扫描使用 Shiro 注解的类,并在必要时进行安全逻辑验证* ** 配置以下两个 bean (DefaultAdvisorAutoProxyCreator(可选)和 AuthorizationAttributeSourceAdvisor)即可实现此功能* * @return*/// 配置 DefaultAdvisorAutoProxyCreator,执行权限注解 @RequiresPermissions 会调用两次 doGetAuthorizationInfo() 方法。故不注入该配置。/*@Beanpublic LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {return new LifecycleBeanPostProcessor();}@Bean@DependsOn({"lifecycleBeanPostProcessor"})public DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator() {DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();advisorAutoProxyCreator.setProxyTargetClass(true);return advisorAutoProxyCreator;}*/@Beanpublic AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor() {AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();authorizationAttributeSourceAdvisor.setSecurityManager(securityManager());return authorizationAttributeSourceAdvisor;}
}
其中,
该配置将 禁用 Shiro 的 Session 存储,这样可以确保 shiro 不会创建或使用 Session,而是依赖于 无状态的 Token 来进行认证。
filterMap.put("jwt", new JwtFilter())
添加自定义Filter
,并且取名为jwt
。filterChainDefinitionMap.put("/**", "jwt")
配置 Shiro 的过滤器链来指定哪些 URL 需要进行 JWT 认证。
2.3.2 自定义过滤器JwtFilter
自定义的 JWT 过滤器类。
/*** jwt过滤器,作为shiro的过滤器,对请求进行拦截并处理*/
@Slf4j
@Component
public class JwtFilter extends BasicHttpAuthenticationFilter {private String errorMsg;/*** 过滤器拦截请求的入口方法*/@Overrideprotected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {// 判断请求头是否带上“Token”HttpServletRequest httpServletRequest = (HttpServletRequest) request;String token = httpServletRequest.getHeader("Authorization");// 游客访问电商平台首页可以不用携带 tokenif (StringUtils.isEmpty(token)) {return true;}try {// 交给自定义 Realm 处理// getSubject(request, response).login(new JwtToken(token)); //getSubject(request, response) 等同于 SecurityUtils.getSubject()SecurityUtils.getSubject().login(new JwtToken(token));return true;} catch (Exception e) {errorMsg = e.getMessage();e.printStackTrace();return false;}}/*** isAccessAllowed()方法返回false,即认证不通过时进入onAccessDenied方法*/@Overrideprotected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {HttpServletResponse httpServletResponse = (HttpServletResponse) response;httpServletResponse.setStatus(400);httpServletResponse.setContentType("application/json;charset=utf-8");PrintWriter out = httpServletResponse.getWriter();out.println(JSONUtil.toJsonStr(Result.error(errorMsg)));out.flush();out.close();return false;}/*** 对跨域访问提供支持** @param request* @param response* @return* @throws Exception*/@Overrideprotected 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请求if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {httpServletResponse.setStatus(HttpStatus.OK.value());return false;}return super.preHandle(request, response);}}
isAccessAllowed
:过滤器拦截请求的入口方法。后续会交由自定义Realm
处理。onAccessDenied
:认证不通过时,需要做什么处理。preHandle
:对跨域访问提供支持
2.3.3 自定义AuthenticationToken
JWTFilter
传递给 Realm
的 token
必须是 AuthenticationToken
的实现类,通过这个类将 string
的 token
转型成 AuthenticationToken
,主要目的是在 Realm
的认证和授权的时候,能够获取到 token
。
/** 将 String 的 token 转型成 AuthenticationToken,供 Realm 认证和授权使用*/
public class JwtToken implements AuthenticationToken {private String token;public JwtToken(String token) {this.token = token;}@Overridepublic Object getPrincipal() {return token;}@Overridepublic Object getCredentials() {return token;}
}
✨注:需要重写 getPrincipal
和 getCredentials
方法。
2.3.4 自定义Realm
自定义 Realm 类中认证方法 doGetAuthenticationInfo
进行逻辑改造。
Shiro+Jwt+Redis
shiro整合redis jwt