使用Shiro+Redis+jwt实现会话共享和身份校验
Shiro是一个轻量级的权限管理系统,可以比较轻松的实现权限管理和养护登录身份校验。Shiro的缓存和会话信息则可以通过Redis存储。可以参考开源项目shiro-redis-spring-boot-starter的jar包。
本示例采用jwt作为跨域身份验证解决方案。逻辑如下:
1.导入依赖文件
<dependency>
<!-- shiro-redis启动包-->
<groupId>org.crazycake</groupId>
<artifactId>shiro-redis-spring-boot-starter</artifactId>
<version>3.2.1</version>
</dependency>
<!-- hutool工具类-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.3.3</version>
</dependency>
<!-- jwt -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
2.编写ShiroConfig配置文件
该文件主要完成3件事:
- 引入RedisSessionDAO和RedisCacheManager,为了解决shiro的权限数据和会话信息能保存到redis中,实现会话共享。
- 重写SessionManager和DefaultWebSecurityManager,同时DefaultWebSecurityManager中为了关闭shiro自带的session方式,我们需要设置为false,这样用户就不再能通过session方式登录shiro。后面将采用jwt凭证登录。
- 在ShiroFilterChainDefinition中,我们不再通过编码形式拦截Controller访问路径,而是所有的路由都需要经过JwtFilter这个过滤器,然后判断请求头中是否含有jwt的信息,有就登录,没有就跳过。跳过之后,有Controller中的shiro注解进行再次拦截,比如@RequiresAuthentication,这样控制权限访问。
@Configuration
public class ShiroConfig {
@Autowired
JwtFilter jwtFilter;
@Bean
public SessionManager sessionManager(RedisSessionDAO redisSessionDAO) {
DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
// inject redisSessionDAO
sessionManager.setSessionDAO(redisSessionDAO);
// other stuff...
return sessionManager;
}
@Bean
public SessionsSecurityManager securityManager(AccountRealm accountRealm,
RedisCacheManager redisCacheManager,
SessionManager sessionManager) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(accountRealm);
//inject sessionManager
securityManager.setSessionManager(sessionManager);
// inject redisCacheManager
securityManager.setCacheManager(redisCacheManager);
// other stuff...
return securityManager;
}
@Bean
public ShiroFilterChainDefinition shiroFilterChainDefinition() {
DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();
Map<String, String> filterMap = new LinkedHashMap<>();
filterMap.put("/**", "jwt"); // 主要通过注解方式校验权限
chainDefinition.addPathDefinitions(filterMap);
return chainDefinition;
}
@Bean("shiroFilterFactoryBean")
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager,
ShiroFilterChainDefinition shiroFilterChainDefinition) {
ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();
shiroFilter.setSecurityManager(securityManager);
Map<String, Filter> filters = new HashMap<>();
filters.put("jwt", jwtFilter);
shiroFilter.setFilters(filters);
Map<String, String> filterMap = shiroFilterChainDefinition.getFilterChainMap();
shiroFilter.setFilterChainDefinitionMap(filterMap);
return shiroFilter;
}
}
接着来看AccountRealm,还有JwtFilter。
3.编写AccountRealm文件
AccountRealm是shiro进行登录或者权限校验的逻辑所在,需要重写3个方法,分别是
- supports:为了让realm支持jwt的凭证校验
- doGetAuthorizationInfo:权限校验
- doGetAuthenticationInfo:登录认证校验
@Component
public class AccountRealm extends AuthorizingRealm {
@Autowired
JwtUtils jwtUtils;
@Autowired
UserService userService;
/**
*让shiro支持jwt的凭证效验
*/
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JwtToken;
}
/**
*权限校验
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
return null;
}
/**
*登录认证效验
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
JwtToken jwtToken = (JwtToken) authenticationToken;
String userId = jwtUtils.getClaimByToken((String) jwtToken.getPrincipal()).getSubject();
User user = userService.getById(Long.valueOf(userId));
if (user == null)
{
throw new UnknownAccountException("账号不存在");
}
if (user.getStatus() == -1)
{
throw new LockedAccountException("账号已被锁定");
}
System.out.println("--------------");
AccountProfile profile = new AccountProfile();
BeanUtil.copyProperties(user,profile);
return new SimpleAuthenticationInfo(profile,jwtToken.getCredentials(),getName());
}
}
这里主要是doGetAuthenticationInfo这个方法,可以看到,通过jwt获取到用户信息,判断用户的状态,最后异常就抛出对应的异常信息,否者封装成SimpleAuthenticationInfo返回给shiro。
shiro默认supports的是UsernamePasswordToken,而我们现在采用了jwt的方式,所以这里我们自定义一个JwtToken,来完成shiro的supports方法。
4.JwtToken
public class JwtToken implements AuthenticationToken {
private String token;
public JwtToken(String jwt)
{
this.token = jwt;
}
@Override
public Object getPrincipal() {
return token;
}
@Override
public Object getCredentials() {
return token;
}
}
JwtUtils工具类
这个工具类用于生成和校验jwt
/**
* jwt工具类
*/
@Slf4j
@Data
@Component
@ConfigurationProperties(prefix = "crisp.jwt")
public class JwtUtils {
private String secret;
private long expire;
private String header;
/**
* 生成jwt token
*/
public String generateToken(long userId) {
Date nowDate = new Date();
//过期时间
Date expireDate = new Date(nowDate.getTime() + expire * 1000);
return Jwts.builder()
.setHeaderParam("typ", "JWT")
.setSubject(userId+"")
.setIssuedAt(nowDate)
.setExpiration(expireDate)
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
public Claims getClaimByToken(String token) {
try {
return Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
}catch (Exception e){
log.debug("validate is token error ", e);
return null;
}
}
/**
* token是否过期
* @return true:过期
*/
public boolean isTokenExpired(Date expiration) {
return expiration.before(new Date());
}
}
其中与jwt相关的密钥信息以及shiro-redis配置(application.yml)如下:
注意设置Redis服务的IP和端口
crisp:
jwt:
#加密秘钥
secret: f4e2e52034348f86b67cde581c0f9eb5
#有效时长,7天,单位秒
expire: 604800
header: Authorization
shiro-redis:
enabled: true
redis-manager:
host: 192.168.1.111:6379
接着我们使用一个AccountProfile返回用户登录成功后,可以拿到的信息载体,一般是不敏感信息,密码不在其中。
@Data
public class AccountProfile implements Serializable {
private Long id;
private String username;
private String avatar;
private String email;
private String identity;
}
4.JwtFilter
定义jwt的过滤器JwtFilter,我们需要重写几个方法:
- createToken:实现登录,我们需要生成我们自定义支持的JwtToken
- onAccessDenied:拦截校验,当头部没有Authorization时候,我们直接通过,不需要自动登录;当带有的时候,首先我们校验jwt的有效性,没问题我们就直接执行executeLogin方法实现自动登录
- onLoginFailure:登录异常时候进入的方法,我们直接把异常信息封装然后抛出
- preHandle:拦截器的前置拦截,因为我们是前后端分析项目,项目中除了需要跨域全局配置之外,我们再拦截器中也需要提供跨域支持。这样,拦截器才不会在进入Controller之前就被限制了。
代码如下
@Component
public class JwtFilter extends AuthenticatingFilter {
@Autowired
JwtUtils jwtUtils;
/**
* 实现登录,我们需要生成我们自定义支持的JwtToken
*/
@Override
protected AuthenticationToken createToken(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
HttpServletRequest request = (HttpServletRequest) servletRequest;
String jwt = request.getHeader("Authorization");
if (StringUtils.isEmpty(jwt)){
return null;
}
return new JwtToken(jwt);
}
/**
* 拦截效验
*/
@Override
protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
HttpServletRequest request = (HttpServletRequest) servletRequest;
String jwt = request.getHeader("Authorization");
if (StringUtils.isEmpty(jwt)){
return true; //无jwt,直接通过
}else {
//校验jwt
Claims claims = jwtUtils.getClaimByToken(jwt);
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 httpServletResponse = (HttpServletResponse) response;
Throwable throwable = e.getCause() == null ? e : e.getCause();
Result result = Result.fail(throwable.getMessage()); //抛出自定义异常信息
String json = JSONUtil.toJsonStr(result);
try {
httpServletResponse.getWriter().print(json);
} catch (IOException ioException) {
ioException.printStackTrace();
}
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进行身份校验。最后编写映射进行测试即可
登录注销部分如下:
/**
*用户登录
*/
@PostMapping("/login")
public Result login(@Validated @RequestBody LoginDto loginDto, HttpServletResponse response)
{
User user = userService.getOne(new QueryWrapper<User>().eq("username",loginDto.getUsername()));
Assert.notNull(user,"用户不存在");//断言user不为空,为空则抛出message
if (!user.getPassword().equals(SecureUtil.md5(loginDto.getPassword()))) {
return Result.fail("密码不正确");
}
String jwt = jwtUtils.generateToken(user.getId());
response.setHeader("Authorization",jwt);
response.setHeader("Access-control-Expose-Headers","Authorization");
return Result.success(MapUtil.builder()
.put("id",user.getId())
.put("username",user.getUsername())
.put("avatar",user.getAvatar())
.put("email",user.getEmail())
.map()
);
}
/**
*用户注销
*/
@RequiresAuthentication
@GetMapping("/logout")
public Result logout()
{
SecurityUtils.getSubject().logout();
return Result.success(null);
}