SpringBoot整合 shiro + jwt,并会话共享
Shrio的主要功能:
Shiro有三个核心的概念:Subject、SecurityManager 和 Realms。
<dependency>
<groupId>org.crazycakegroupId>
<artifactId>shiro-redis-spring-boot-starterartifactId>
<version>3.3.1version>
dependency>
<dependency>
<groupId>cn.hutoolgroupId>
<artifactId>hutool-allartifactId>
<version>5.7.22version>
dependency>
<dependency>
<groupId>io.jsonwebtokengroupId>
<artifactId>jjwtartifactId>
<version>0.9.1version>
dependency>
ShiroConfig
/**
* shiro启用注解拦截控制器
*/
@Configuration
public class ShiroConfig {
@Autowired
private JwtFilter jwtFilter;
@Bean
public SessionManager sessionManager(RedisSessionDAO redisSessionDAO) {
DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
// 注入 redisSessionDAO
sessionManager.setSessionDAO(redisSessionDAO);
return sessionManager;
}
/**
* 创建安全管理器
* AccountRealm--是 shiro 进行【登录】或者【权限校验】的逻辑所在,算是核心了,需要重写3个方法,分别是:
* supports(): 为了让 realm 支持 jwt 的凭证校验
* doGetAuthorizationInfo(): 权限校验
* doGetAuthenticationInfo(): 登录认证校验
* @param sessionManager
* @param redisCacheManager
*
*/
@Bean
public DefaultWebSecurityManager securityManager(AccountRealm accountRealm,
SessionManager sessionManager,
RedisCacheManager redisCacheManager) {
// 创建安全管理器对象
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(accountRealm);
// 注入 sessionManager
securityManager.setSessionManager(sessionManager);
// 关闭 shiro 自带的 session,这样用户就不能再通过 session 方式登录 shiro,后面将采用 jwt 凭证登录。
DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
securityManager.setSubjectDAO(subjectDAO);
// 注入 redisCacheManager
securityManager.setCacheManager(redisCacheManager);
return securityManager;
}
/**
* 在 ShiroFilterChainDefinition 中,我们不在通过编码形式拦截 Controller 的访问路径,而是所有的
* 路由都需要经过 JwtFilter 这个过滤器,然后判断请求头中是否含有 jwt 的信息,有就登录,没有就跳过。
* 跳过之后,由 Controller 中的 shiro注解 进行再次拦截,比如 @RequiresAuthentication,从而控制权限访问。
*
*/
@Bean
public ShiroFilterChainDefinition shiroFilterChainDefinition() {
DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();
Map<String, String> filterMap = new LinkedHashMap<>();
filterMap.put("/**", "jwt");
chainDefinition.addPathDefinitions(filterMap);
return chainDefinition;
}
/**
* 创建 shiroFilter 负责拦截所有请求
* @param securityManager
* @param chainDefinition
* @return
*/
@Bean("shiroFilterFactoryBean")
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager,
ShiroFilterChainDefinition chainDefinition) {
ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();
// 给 shiroFilter 设置安全管理器
shiroFilter.setSecurityManager(securityManager);
//配置系统受限资源
//配置系统公共资源
Map<String, Filter> filters = new HashMap<>();
// 使用 jwtFilter 过滤器
filters.put("jwt", jwtFilter);
shiroFilter.setFilters(filters);
Map<String, String> filterMap = shiroFilterChainDefinition().getFilterChainMap();
shiroFilter.setFilterChainDefinitionMap(filterMap);
return shiroFilter;
}
/**
* 解决 aop 与 shiro 冲突问题
*/
@Bean
public static DefaultAdvisorAutoProxyCreator getDefaultAdvisorAutoProxyCreator() {
DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
defaultAdvisorAutoProxyCreator.setUsePrefix(true);
return defaultAdvisorAutoProxyCreator;
}
}
上面的 ShiroConfig,主要做了几件事情:
接下来就是 ShiroConfig 出现的 AccountRealm,还有 JwtFilter。
AccountRealm:
AccountRealm 是 shiro 进行登录或者权限校验的逻辑所在,需要重写3个方法:
com.xxx.shiro.JwtToken
/**
* 自定义 JwtToken类,来完成 shiro 的 supports 方法
*/
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;
}
}
com.xxx.shiro.AccountRealm
/**
* 登录认证和授权
* 自定义Realm
*/
@Slf4j
@Component
public class AccountRealm extends AuthorizingRealm {
@Autowired
private JwtUtils jwtUtils;
@Autowired
private UserService userService;
/**
* shiro 默认 supports 的是UsernamePasswordToken,而我们现在采用的是 jwt 方式,
* 所以这里 自定义一个新类 JwtToken,来完成 shiro 的 supports 方法。
*/
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JwtToken;
}
/**
* 认证
* @param token 包含用户名和密码的令牌
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
JwtToken jwt = (JwtToken) token;
log.info("jwt------------------>{ }", jwt);
// 解析JWTtoken,从令牌 token 中拿到 用户id 和 用户名
String userId = (String) jwtUtils.parseJWT((String) jwt.getPrincipal()).get("userId");
String username = (String) jwtUtils.parseJWT((String) jwt.getPrincipal()).get("username");
// 根据 用户id 查询用户
User user = userService.getById(userId);
if (user == null) {
throw new UnknownAccountException("账户不存在!");
}
if (user.getStatus() == -1) {
throw new LockedAccountException("账户已被锁定!");
}
if (!user.getUsername().equals(username)) {
throw new UnknownAccountException("用户名错误!");
}
// 比较密码
// 登录成功后返回的用户信息的实体
AccountProfile profile = new AccountProfile();
BeanUtil.copyProperties(user, profile);
log.info("profile----------------->{}", profile.toString());
// 返回认证信息 参数1:用户身份信息 参数2:加密后的密码 参数3:realm的名字
return new SimpleAuthenticationInfo(profile, jwt.getCredentials(), getName());
}
/**
* 授权
* @param principals 身份集合信息
* @return
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
log.info("执行doGetAuthorizationInfo方法进行授权");
// String username = JwtUtil.getUsername(principalCollection.toString());
log.info(principals.toString());
// log.info("登录的用户:" + username);
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
// 获取当前登录用户的【主身份信息】
AccountProfile accountProfile = (AccountProfile) principals.getPrimaryPrincipal();
// 拿到当前用户的所有角色(因为一个用户可以有多个角色)
String[] roles = accountProfile.getRole().split(",");
log.info("roles");
// 根据用户的角色,来对用户进行授权
for (String role : roles) {
info.addRole(role);
if (role.equals("role_root")) {
info.addStringPermission("user:create");
info.addStringPermission("user:update");
info.addStringPermission("user:read");
info.addStringPermission("user:delete");
} else if (role.equals("role_admin")) {
info.addStringPermission("user:read");
info.addStringPermission("user:create");
info.addStringPermission("user:update");
} else if (role.equals("role_user")) {
info.addStringPermission("user:read");
info.addStringPermission("user:create");
} else if (role.equals("role_guest")) {
info.addStringPermission("user::read");
}
}
// 返回权限信息
return info;
}
}
com.xxx.util.JwtUtils
/**
* jwt (Json Web Token)工具类
* 用于 创建jwt字符串 和 解析jwt。
*/
@Slf4j
@Data
@Component
@ConfigurationProperties(prefix = "xxx.jwt")
public class JwtUtils {
private String secret;
private long expire;
private String header;
/**
* 生成 Jwt Token 字符串
* @param userId 签发人id
* expireDate 过期时间 签发时间
* claims 额外添加到荷部分的信息。
* 例如可以添加用户名、用户ID、用户(加密前的)密码等信息
*/
public String createJWT(long userId, String username) {
Date nowDate = new Date();
// 过期时间
Date expireDate = new Date(nowDate.getTime() + expire * 1000);
//创建payload的私有声明(根据特定的业务需要添加,如果要拿这个做验证,一般是需要和jwt的接收方提前沟通好验证方式的)
Map<String, Object> claims = new HashMap<String, Object>();
claims.put("userId", userId+"");
claims.put("username",username);
return Jwts.builder() // 创建 JWT 对象
.setHeaderParam("typ", "JWT") // 设置头部信息
.setClaims(claims) // 设置私有声明
.setIssuedAt(nowDate) // 设置payload的签发时间
.setExpiration(expireDate) // 这是payload的过期时间
.signWith(SignatureAlgorithm.HS512, secret)// 设置安全密钥(生成签名所需的密钥和算法)
.compact(); // 生成JWT token (1.编码 Header 和 Payload 2.生成签名 3.拼接字符串)
}
/**
* 解析 token
* JWT Token 由 头部 荷载部 和 签名部 三部分组成。签名部分是由加密算法生成,无法反向解密。
* 而 头部 和 荷载部分是由 Base64 URL算法生成,是可以反向反编码回原样的。
* 这也是为什么不要在 JWT Token 中放敏感数据的原因。
*
* @param token 加密后的token
* @return claims 返回荷载部分的键值对
*/
public Claims parseJWT(String token) {
try {
return Jwts.parser() // 创建解析对象
.setSigningKey(secret) // 设置安全密钥(生成签名所需的密钥和算法)
.parseClaimsJws(token) // 解析 token
.getBody(); // 获取 payload 部分内容
} catch (Exception e) {
log.debug("validate is token error ", e);
return null;
}
}
/**
* token 是否过期
* @return return true: 过期
*/
public boolean isTokenExpired(Date expiration) {
return expiration.before(new Date());
}
}
com.xxx.shiro.AccountProfile
**
* 用于登陆成功后返回的一个【用户信息的载体/实体】
* avatar 用户头像
*/
@Data
public class AccountProfile implements Serializable {
/**
* 用户id
*/
private Long id;
/**
* 用户名
*/
private String username;
/**
* 用户头像
*/
private String avatar;
/**
* 用户角色
*/
private String role;
}
application.yml:
shiro-redis:
enabled: true
redis-manager:
host: 127.0.0.0
xxx:
jwt:
# 加密密钥, 部署上线务必修改此配置,以保证token的安全性
secret: xxxxxxx
expire: 172800
header: token
这个过滤器是重点,这里继承的是Shiro内置的AuthenticatingFilter,一个可以内置自动登录方法的过滤器,也可以继承BasicHttpAuthenticationFilter。
需要重写几个方法:
1) createToken:实现登录,需要生成我们自定义支持的 JwtToken。
2) onAccessDenied:拦截校验,当头部没有Authorization,直接通过,不需要自动登录;当头部带有的时候,首先要验证 jwt 的有效性,没问题就直接执行 executeLogin 方法自动登录。
3) onLoginFailure:登陆异常的时候进入的方法,我们直接把异常信息封装然后抛出
4) preHandle:拦截器的前置拦截,因为是前后端分离项目,项目中除了选用跨域全局配置之外,我们在拦截其中也要提供跨域支持。这样拦截器就不会在进入Controller之前就被限制了。
com.xxx.shiro.JwtFilter总体代码:
@Component
public class JwtFilter extends AuthenticatingFilter {
@Autowired
private JwtUtils jwtUtils;
@Override
protected AuthenticationToken createToken(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
// 获取 Token
HttpServletRequest request = (HttpServletRequest) servletRequest;
String jwtToken = request.getHeader("Authorization");
if (StringUtils.isEmpty(jwtToken)) {
return null;
}
return new JwtToken(jwtToken);
}
@Override
protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
HttpServletRequest request = (HttpServletRequest) servletRequest;
String token = request.getHeader("Authorization");
// 当头部没有 Authorization的时候,直接通过,不需要自动登录;
if (StringUtils.isEmpty(token)) {
return true;
} else {
// 当头部带有的时候,首先要验证 jwt 的有效性,没问题就直接执行 executeLogin 方法自动登录
// 判断是否已经过期
Claims claims = jwtUtils.parseJWT(token);
if (claims == null || jwtUtils.isTokenExpired(claims.getExpiration())) {
throw new ExpiredCredentialsException("token已失效,请重新登录!");
}
}
// 没有失效就执行自动登录
return executeLogin(servletRequest, servletResponse);
}
/**
* 登陆异常的时候进入的方法,我们`在这里插入代码片`直接把异常信息封装然后抛出
*/
@Override
protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) {
HttpServletResponse httpResponse = (HttpServletResponse) response;
// 处理登录失败的异常
try {
Throwable throwable = e.getCause() == null ? e : e.getCause();
Result re = Result.fail(throwable.getMessage());
String json = JSONUtil.toJsonStr(re);
httpResponse.getWriter().print(json);
} catch (IOException ex) {
}
return false;
}
/**
* 对跨域提供支持
*/
@Override
protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest httpServletRequest = WebUtils.toHttp(request);
HttpServletResponse httpServletResponse = WebUtils.toHttp(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"));
// 跨域时会首先发送一个OPTIONS请求,这里我们给OPTIONS请求直接返回正常状态
if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
httpServletResponse.setStatus(org.springframework.http.HttpStatus.OK.value());
return false;
}
return super.preHandle(request, response);
}
}
到这里 shiro 就已经完成了整合,并且使用了 jwt 进行身份验证。