Springboot Shiro整合JWT实现无状态鉴权续签机制

前言 我所在的小公司里后端写的代码有些惨不忍睹,在遇到一些问题的时候还总是找不到问题出在哪里:face with head bandage:。趁着最近闲下来想要多了解了解相关使用的框架...

前言

我所在的小公司里后端写的代码有些惨不忍睹,在遇到一些问题的时候还总是找不到问题出在哪里:face_with_head_bandage:。趁着最近闲下来想要多了解了解相关使用的框架以便后面合作协助定位问题(怼同事)。

在前后端分离项目中使用 session 不是很方便,所以一般会采用 token 进行无状态登陆。SpringBoot 中首选的权限管理框架应该是 Spring Security,但现在后台使用的是 Shiro,但使用 Shiro 就会遇到几个问题:

1. Shiro 默认的登陆拦截校验是基于session的

  1. Shiro 默认的拦截跳转机制是跳转 url 页面, 如果使用模版引擎后端可以控制; 但是在前后端分离项目中, 后端并无权干涉页面跳转。

我们要使用 Shiro 做无状态鉴权则需要对其进行改造。

JWT

JWT 的全称是 Json Web Token,是一种基于 JSON 的、用于在网络上声明某种主张的令牌(token)规范。更加具体信息可查看JWT

https://oss.j3dream.top//img/202108100948367.png@img.blog.webp

Token 的优势:

1. Token 支持跨域访问,Cookie 不可以跨域访问

  1. Token 支持多平台,Cookie 只支持部分 web 端

尝试

需求

需求简单处理:

1. 用户通过Restful请求提交用户名、密码(hash)进行登陆

  1. 登陆后返回 AccessToken给客户端保存
  2. 客户端每次请求都将本地存放的 AccessToken 放置到 header 中, 用于权限校验
  3. 如果 AccessToken 即将过期或已经过期后段需要根据 RefreshToken 校验刷新 AccessToken,刷新后通过 ServletResponse 中的 header 响应给客户端,客户端更新本地的 AccessToken

pom.xml


<properties>
  <shiro.spring.version>1.7.1</shiro.spring.version>
  <jwt.auth0.version>3.18.1</jwt.auth0.version>
  <ehcache.version>2.10.9.2</ehcache.version>
</properties>

<dependencies>
 	<dependency>
     <groupId>org.apache.shiro</groupId>
     <artifactId>shiro-spring-boot-web-starter</artifactId>
     <version>${shiro.spring.version}</version>
  </dependency>
  <dependency>
     <groupId>com.auth0</groupId>
     <artifactId>java-jwt</artifactId>
     <version>${jwt.auth0.version}</version>
   </dependency>
   <dependency>
      <groupId>net.sf.ehcache</groupId>
      <artifactId>ehcache</artifactId>
      <version>${ehcache.version}</version>
   </dependency>
</dependencies>

Shiro 配置(SecurityConfiguration)

配置的初始化流程为:

1. 初始化相关的 Configuration 的Bean.

  1. 初始化自定义的 Realm
  2. 注册时自定义的拦截器 Filter, 设置 filterChain
  • SecurityConfiguration

    import org.apache.shiro.mgt.DefaultSessionStorageEvaluator;
    import org.apache.shiro.mgt.DefaultSubjectDAO;
    import org.apache.shiro.mgt.SecurityManager;
    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.apache.shiro.web.mgt.DefaultWebSubjectFactory;
    import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.context.annotation.DependsOn;
    
    import javax.servlet.Filter;
    import java.util.HashMap;
    import java.util.LinkedHashMap;
    import java.util.Map;
    
    /**
     * 权限框架配置 主要采用了Shiro进行控制
     *
     * @author JiaJunjian
     * @date 2021-07-29 10:30
     */
    @Configuration
    public class SecurityConfiguration {
    
        @Bean("shiroFilterFactoryBean")
        public ShiroFilterFactoryBean shirFilter(DefaultWebSecurityManager securityManager, JwtTokenHelper jwtTokenHelper, IUserRepository userRepository) {
            ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
            // 必须设置 SecurityManager
            shiroFilterFactoryBean.setSecurityManager(securityManager);
            // 设置拦截器, 添加Token拦截器
            String jwtFilterName = JwtFilter.class.getSimpleName();
            Map<String, Filter> filterMap = new HashMap<String, Filter>(1);
            // 注意请避免JwtFilter直接使用 Component 注解进行注入。 如果采用直接注入的方式会导致 setFilterChainDefinitionMap 失效
            filterMap.put(jwtFilterName, new JwtFilter(jwtTokenHelper, userRepository));
            shiroFilterFactoryBean.setFilters(filterMap);
            //未授权界面;
            shiroFilterFactoryBean.setUnauthorizedUrl("/403");
            Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
            // 静态资源
            filterChainDefinitionMap.put("/static/**", "anon");
            // 认证相关
            filterChainDefinitionMap.put("/login", "anon");
            // druid
            filterChainDefinitionMap.put("/druid/**", "anon");
            // swagger
            filterChainDefinitionMap.put("/swagger**/**", "anon");
            filterChainDefinitionMap.put("/**/swagger**/**", "anon");
            // 其余请求交由jwt控制
            filterChainDefinitionMap.put("/**", jwtFilterName);
            shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
            return shiroFilterFactoryBean;
        }
    
        /**
         * 注入安全管理器
         * @param shiroRealm shiroRealm
         * @return SecurityManager
         */
        @Bean
        public DefaultWebSecurityManager securityManager(MyShiroRealm shiroRealm, DefaultWebSubjectFactory subjectFactory, ShiroCacheManager shiroCacheManager) {
            DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
            /*
             * 关闭shiro自带的session,详情见文档
             * <http://shiro.apache.org/session-management.html#SessionManagement-StatelessApplications%28Sessionless%29>
             */
            DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
            DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
            // 关闭 session 存储
            defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
            subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
            securityManager.setSubjectDAO(subjectDAO);
            // 禁用 session DefaultWebSubjectFactory
            securityManager.setSubjectFactory(subjectFactory);
            // 认证域
            securityManager.setRealm(shiroRealm);
            // 为 shiro 添加 cache 管理器. 作用请查看 ShiroCacheManager 详情
            securityManager.setCacheManager(shiroCacheManager);
            return securityManager;
        }
    
        @Bean
        public DefaultWebSubjectFactory subjectFactory(){
            return new StatelessDefaultSubjectFactory();
        }
    
        /**
         * 下面的代码是添加注解支持
         */
        @Bean
        @DependsOn("lifecycleBeanPostProcessor")
        public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
            DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
            // 强制使用cglib,防止重复代理和可能引起代理出错的问题
            // <https://zhuanlan.zhihu.com/p/29161098>
            defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
            return defaultAdvisorAutoProxyCreator;
        }
    
        @Bean
        public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
            return new LifecycleBeanPostProcessor();
        }
    
        @Bean
        public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
            AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
            advisor.setSecurityManager(securityManager);
            return advisor;
        }
    }
    
    
  • StatelessDefaultSubjectFactory

    
    import org.apache.shiro.subject.Subject;
    import org.apache.shiro.subject.SubjectContext;
    import org.apache.shiro.web.mgt.DefaultWebSubjectFactory;
    
    /**
     * @author JiaJunjian
     * @date 2021/8/4 09:30
     *
     * 实现无状态的 DefaultWebSubjectFactory。 主要功能为 进行 session 实现
     *
     */
    public class StatelessDefaultSubjectFactory extends DefaultWebSubjectFactory {
    
        @Override
        public Subject createSubject(SubjectContext context) {
            // 该步骤会禁用 session 创建
            context.setSessionCreationEnabled(false);
            return super.createSubject(context);
        }
    }
    
    

登陆(UserLoginController)

在上面 SecurityConfiguration#shirFilter 中对 setFilterChainDefinitionMap 进行了配置, 可以控制放行一些 url 不需要进行登陆认证。其中我们就放行了 /login 接口

  • UserLoginController

    /**
    * 用户登陆
    * @param reqBody req
    * @return resp
    */
    @PostMapping("login")
    public RespBody<AuthLoginRespBody> login(@RequestBody @Valid AuthLoginReqBody reqBody){
         return ok(mAuthService.login(reqBody));
    }
    
    
  • UserLoginServiceImpl

    @Override
    public AuthLoginRespBody login(AuthLoginReqBody reqBody) {
        SysUserEntity byAccount = mUserRepository.findByAccount(reqBody.getAccount());
        if (ObjectUtil.isNull(byAccount)){
            throw new BusinessException("账户或密码错误");
        }
        HMac hMac = SecureUtil.hmacMd5(byAccount.getSalt());
        String digestPassword = hMac.digestHex(reqBody.getPassword());
        if ( ! StringUtils.equals(byAccount.getLoginPwd(), digestPassword)){
            throw new BusinessException("账户或密码错误");
        }
        String token = mJwtTokenHelper.sign(new TokenInfo(reqBody.getAccount()), digestPassword);
        return new AuthLoginRespBody(token);
    }
    
    

请求认证流程

请求认证就是对每次客户端请求中头部携带的 Token 进行一次校验并获取其中的参数. 因为前后端分离的请求都是无状态的,所以每次请求带来 Token 后都会进行一次登陆操作。

JwtToken

/**
 * @author JiaJunjian
 * @date 2021/7/28 08:38
 */
@AllArgsConstructor
public class JwtToken implements AuthenticationToken {

    private static final long serialVersionUID = 4046003751585743822L;

    /**
     * token
     */
    private final String token;

    @Override
    public Object getPrincipal() {
        return token;
    }

    @Override
    public Object getCredentials() {
        return token;
    }
}

JwtFilter

import cn.hutool.core.util.ObjectUtil;
import com.auth0.jwt.exceptions.TokenExpiredException;
import lombok.extern.log4j.Log4j2;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter;
import org.apache.shiro.web.util.WebUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.RequestMethod;

import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * @author JiaJunjian
 * @date 2021/7/28 09:35
 */
public class JwtFilter extends BasicHttpAuthenticationFilter {

    private final JwtTokenHelper mJwtTokenHelper;
    private final IUserRepository mUserRepository;

    public JwtFilter(JwtTokenHelper jwtTokenHelper, IUserRepository userRepository) {
        this.mJwtTokenHelper = jwtTokenHelper;
        this.mUserRepository = userRepository;
    }

    /**
     * 确定传入请求是否是尝试登录。
     * 主要是通过判断请求中是否包含了 token 来进行拦截的
     * @param request request
     * @param response response
     * @return {@code true 尝试登陆, false: 不登陆}
     */
    @Override
    protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
        HttpServletRequest httpServletRequest = WebUtils.toHttp(request);
        String authorization = httpServletRequest.getHeader(Constant.HEAD_AUTH_ACCESS_TOKEN);
        return StringUtils.isNotBlank(authorization);
    }

    /**
     * 执行登陆操作
     *
     * 步骤中的
     *     Subject subject = getSubject(request, response);
     *     subject.login(jwtToken);
     * 登陆操作会最终被
     * @see MyShiroRealm#doGetAuthenticationInfo(AuthenticationToken)
     * 所拦截并在该部分获取到用户的信息.
     * 随后会在 onLoginSuccess 中进行 token 的认证.
     * @param request request
     * @param response response
     * @return {@code true 会放行请求, false 拦截请求} 如果为 false 则用于会看不到任何内容。
     * @throws Exception ex
     */
    @Override
    protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletRequest httpServletRequest = WebUtils.toHttp(request);
        String authorization = httpServletRequest.getHeader(Constant.HEAD_AUTH_ACCESS_TOKEN);
        JwtToken jwtToken = new JwtToken(authorization);
        try {
            Subject subject = getSubject(request, response);
            subject.login(jwtToken);
            return this.onLoginSuccess(jwtToken, subject, request, response);
        }catch (AuthenticationException ex){
            return this.onLoginFailure(jwtToken, ex, request, response);
        }
    }

    /**
     * 基本身份验证过滤器可以使用它应该应用的 HTTP 方法列表进行配置。 这
     * 方法确保<em>仅</em>需要对那些指定的 HTTP 方法进行身份验证。 例如
     * 如果你有这样的配置:
     * <pre>
     *     [urls]
     *     /basic/** = authcBasic[POST,PUT,DELETE]
     * </pre>
     * 那么 GET 请求不需要身份验证,但 POST 需要。
     *
     * @param request request
     * @param response response
     * @param mappedValue value
     * @return {@code true 会放行请求, false 拦截请求} 如果为 false 则用于会看不到任何内容。
     */
    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
        // 如果没有token则直接返回true
        if ( ! isLoginAttempt(request, response)){
            throw new AuthenticationException("未认证的请求");
        }
        try {
            // 执行登陆操作. 如果登陆失败了则证明了没有权限访问则直接重定向到 401 页面最终会跳转到 登陆 页面重新登陆
            return executeLogin(request, response);
        }catch (Exception ex){
            sendRedirect401(response);
            return false;
        }
    }

    /**
     * 处理未经身份验证的请求。它处理两阶段 requestchallenge 身份验证协议。
     * @param request request
     * @param response response
     * @return {@code true 会放行请求, false 拦截请求} 如果为 false 则用于会看不到任何内容。
     * @throws Exception ex
     */
    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
        return false;
    }

    /**
     * 该部分对 filter 进行跨域支持
     * @param request request
     * @param response response
     * @return super
     * @throws Exception ex
     */
    @Override
    protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        HttpServletResponse httpServletResponse = (HttpServletResponse) response;
        String origin = httpServletRequest.getHeader("Origin");
        if (StringUtils.isNotBlank(origin)){
            httpServletResponse.setHeader("Access-control-Allow-Origin", origin);
        }
        httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
        String header = httpServletRequest.getHeader("Access-Control-Request-Headers");
        if (StringUtils.isNotBlank(header)){
            httpServletResponse.setHeader("Access-Control-Allow-Headers", header);
        }
        // 跨域时会首先发送一个option请求,这里我们给option请求直接返回正常状态
        if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
            httpServletResponse.setStatus(HttpStatus.OK.value());
            return false;
        }
        return super.preHandle(request, response);
    }

    /**
     * shiro 框架中的认证城中
     * @param token token
     * @param subject subject
     * @param request request
     * @param response response
     * @return
     * @throws Exception ex
     */
    @Override
    @SuppressWarnings("ConstantConditions")
    protected boolean onLoginSuccess(AuthenticationToken token, Subject subject, ServletRequest request, ServletResponse response) throws Exception {
        String credentials = String.valueOf(token.getCredentials());
        TokenInfo tokenInfo = mJwtTokenHelper.getTokenInfo(credentials);
        SysUserEntity byAccount = mUserRepository.findByAccount(tokenInfo.getAccount());
        try {
            // 进行 token 认证, 如果 token 过期则抛出 TokenExpiredException 进行刷新 token 操作. false 则直接抛出认证失败
            if ( ! mJwtTokenHelper.verify(credentials, tokenInfo.getAccount(), byAccount.getLoginPwd())){
                throw new AuthenticationException("认证失败!");
            }
            return true;
        }catch (TokenExpiredException ex){
            return refreshToken(tokenInfo, byAccount.getLoginPwd(), request, response);
        }
    }

    /**
     * 刷新token信息, 如果已经过期的token可以通过重新签发的方式生成新的 token 并在 response header 中将新的token
     * 写入. 客户端收到新的token后更新本地的token.
     * 如果重新签名token失败则直接响应 403 给客户端, 由客户端清除token信息跳转到登陆页面
     * @param request request
     * @param response response
     * @return 刷新 token 信息
     */
    private boolean refreshToken(TokenInfo tokenInfo, String secret, ServletRequest request, ServletResponse response) {
        TokenInfo refreshToken = mJwtTokenHelper.getCacheRefreshToken(tokenInfo);
        // 判断是否能够获取refresh token
        if (ObjectUtil.isNotNull(refreshToken)){
            // 更新时间戳等待重新签名
            tokenInfo.updateTimestamp();
            try {
                String newAccessToken = mJwtTokenHelper.sign(tokenInfo, secret);
                // 将刷新的token 通过 Access-Control-Expose-Headers 响应给前端用于刷新token
                HttpServletResponse httpServletResponse = WebUtils.toHttp(response);
                httpServletResponse.setHeader(Constant.HEAD_AUTH_ACCESS_TOKEN, newAccessToken);
                httpServletResponse.setHeader("Access-Control-Expose-Headers", Constant.HEAD_AUTH_ACCESS_TOKEN);
                return true;
            }catch (Exception ex){
                ex.printStackTrace();
            }
        }
        sendHttpCode403(response);
        return false;
    }

    /**
     * 发送 403 状态嘛
     * @param response response
     */
    private void sendHttpCode403(ServletResponse response){
        try {
            WebUtils.toHttp(response).sendError(HttpStatus.FORBIDDEN.value());
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    /**
     * 发送 403 状态嘛
     * @param response response
     */
    private void sendRedirect401(ServletResponse response){
        try {
            WebUtils.toHttp(response).sendRedirect("/401");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

MyShiroRealm

import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import lombok.extern.log4j.Log4j2;
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.apache.shiro.web.mgt.DefaultWebSubjectFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.util.Collections;
import java.util.Set;

/**
 *
 * 自定义的认证领域
 *
 * @author JiaJunjian
 * @date 2021/7/28 10:04
 *
 * 该类为Shiro控制的核心. 该会提供了包含并不限于 登陆认证 、 权限授权、权限控制等操作
 *
 */
@Log4j2
@Component
public class MyShiroRealm extends AuthorizingRealm {

    private final IUserRepository mUserRepository;
    private final JwtTokenHelper mJwtTokenHelper;

    @Autowired
    public MyShiroRealm(IUserRepository userRepository, JwtTokenHelper tokenHelper) {
        this.mUserRepository = userRepository;
        this.mJwtTokenHelper = tokenHelper;
    }

    /**
     * Shiro filter 中可能包含多种的 AuthenticationToken 实现. 则需要根据该方法对登陆请求进行过滤。只接受 jwtToken.
     * @param token token
     * @return {@code true: 支持, other: 不支持的 AuthenticationToken}
     */
    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof JwtToken;
    }

    /**
     * 获取账户授权信息. 需要搭配
     * @see org.apache.shiro.authz.annotation.RequiresPermissions
     * @see org.apache.shiro.authz.annotation.RequiresRoles
     * 注解使用. 请注意Shiro的授权操作并不是一开始就进行的他会在首次调用到标记了上面👆两个注解的时候调用 doGetAuthorizationInfo 方法
     * 获取 AuthorizationInfo 信息并将该数据缓存到
     * @see cn.system.configuration.SecurityConfiguration#securityManager(MyShiroRealm, DefaultWebSubjectFactory, ShiroCacheManager)
     * 的 ShiroCacheManager 中的Cache中以供再次获取是无需再次的执行耗时操作. 如果我们更新了权限信息我们可以通过直接操作 cache 的方式移除掉
     * 响应账户的cache让Shiro重新填充权限做到权限的动态更新.
     *
     * @param principalCollection 认证信息 证书
     * @return 授权信息
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        String principal = principalCollection.toString();
        TokenInfo tokenInfo = mJwtTokenHelper.getTokenInfo(principal);
        Set<String> userRoles = mUserRepository.findUserRoleNameByAccount(tokenInfo.getAccount());
        SimpleAuthorizationInfo simpleAuthenticationInfo = new SimpleAuthorizationInfo(userRoles);
        simpleAuthenticationInfo.setStringPermissions(Collections.emptySet());
        return simpleAuthenticationInfo;
    }

    /**
     * 获取账户认证信息, 该步骤中会通过JWT将 token 中的账户信息提取出来并响应给Shiro
     *
     * 其中 AuthenticationToken 其实就是自定义的
     * @see cn.system.security.JwtToken
     * 该操作是由上方
     * @see MyShiroRealm#supports(AuthenticationToken)
     * 方法控制的.
     *
     * @param authenticationToken 认证token信息
     * @return 认证信息
     * @throws AuthenticationException AuthenticationException
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        if (StrUtil.isBlankIfStr(authenticationToken.getCredentials())){
            throw new AuthenticationException("token credentials is empty!");
        }
        // 获取token. 为什么getCredentials获取的是token. 请看 JwtToken 类
        String credentials = ObjectUtil.toString(authenticationToken.getCredentials());
        TokenInfo tokenInfo;
        try{
            tokenInfo = mJwtTokenHelper.getTokenInfo(credentials);
        }catch (Exception ex){
            throw new AuthenticationException("token failure", ex);
        }

        if (ObjectUtil.isNull(tokenInfo)){
            throw new AuthenticationException("token is empty!");
        }
        SysUserEntity byAccount = mUserRepository.findByAccount(tokenInfo.getAccount());
        if (ObjectUtil.isNull(byAccount)){
            throw new AuthenticationException("账户或密码错误!");
        }
        return new SimpleAuthenticationInfo(credentials, credentials, getName());
    }

    /**
     * 设置 授权操作 中 缓存Cache的Key. 我们这里直接使用了用户的 account 作为用户授权缓存时的key
     * 这里的 account 其实 对应了 数据库中的 user 表的 loginCode. 该步骤获取失败会将
     * @see cn.system.constant.Constant#UNKNOWN
     * 返回作为默认的KEY
     * @param principals 证书凭证
     * @return AuthorizationInfo Cache Key
     */
    @Override
    protected Object getAuthorizationCacheKey(PrincipalCollection principals) {
        String principal = principals.toString();
        TokenInfo tokenInfo = mJwtTokenHelper.getTokenInfo(principal);
        return tokenInfo == null? Constant.UNKNOWN: tokenInfo.getAccount();
    }
}

Shiro 中的缓存

ShiroCache

使用 Springboot 中的 Cache 封装 ShiroCache. 后面可以通过 Springboot Cache 切换为 Redis 或 EhCache 等

import org.apache.shiro.cache.Cache;
import org.apache.shiro.cache.CacheException;

import java.util.Collection;
import java.util.Set;

/**
 *
 * ShiroCache 对 Shiro 中的Cache实现
 *
 * @author JiaJunjian
 * @date 2021/7/30 11:17
 */
public class ShiroCache<K, V> implements Cache<K, V> {

    private final org.springframework.cache.Cache mUseCache;

    public ShiroCache(org.springframework.cache.Cache cache) {
        this.mUseCache = cache;
    }

    @Override
    public V get(K k) throws CacheException {
        org.springframework.cache.Cache.ValueWrapper valueWrapper = getUseCache().get(k);
        if (valueWrapper == null){
            return null;
        }
        return (V) valueWrapper.get();
    }

    @Override
    public V put(K k, V v) throws CacheException {
        org.springframework.cache.Cache useCache = getUseCache();
        useCache.put(k, v);
        if (useCache.get(k) != null){
            return v;
        }
        return null;
    }

    @Override
    public V remove(K k) throws CacheException {
        org.springframework.cache.Cache useCache = getUseCache();
        org.springframework.cache.Cache.ValueWrapper valueWrapper = useCache.get(k);
        if (valueWrapper == null){
            return null;
        }
        useCache.evict(k);
        return (V) valueWrapper.get();
    }

    @Override
    public void clear() throws CacheException {
        org.springframework.cache.Cache useCache = getUseCache();
        useCache.clear();
    }

    @Override
    public int size() {
        return -1;
    }

    @Override
    public Set<K> keys() {
        return null;
    }

    @Override
    public Collection<V> values() {
        return null;
    }

    private org.springframework.cache.Cache getUseCache(){
        return mUseCache;
    }
}

ShiroCacheManager

import cn.system.manager.ehcache.CacheCenter;
import org.apache.shiro.cache.Cache;
import org.apache.shiro.cache.CacheException;
import org.apache.shiro.cache.CacheManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

/**
 *
 * Shiro Cache manager
 *
 * @author JiaJunjian
 * @date 2021/7/30 11:27
 */
@Component
public class ShiroCacheManager implements CacheManager {

    private final CacheCenter mCacheCenter;

    @Autowired
    public ShiroCacheManager(CacheCenter cacheCenter) {
        this.mCacheCenter = cacheCenter;
    }

    @Override
    public <K, V> Cache<K, V> getCache(String s) throws CacheException {
        return new ShiroCache<>(mCacheCenter.getSecurityCache());
    }
}

简述

Jwt Token 刷新

在 JwtFilter 执行登陆时在onLoginSucces方法中校验 AccessToken 时如果抛出了 TokenExpiredException 即为 Token 过期则执行重新签发操作。

protected boolean onLoginSuccess(AuthenticationToken token, Subject subject, ServletRequest request, ServletResponse response) throws Exception {
    .....
    try {
        // 进行 token 认证, 如果 token 过期则抛出 TokenExpiredException 进行刷新 token 操作. false 则直接抛出认证失败
        if ( ! mJwtTokenHelper.verify(credentials, tokenInfo.getAccount(), byAccount.getLoginPwd())){
            throw new AuthenticationException("认证失败!");
        }
        return true;
    }catch (TokenExpiredException ex){
        // 重新签发AccessToken
        return refreshToken(tokenInfo, byAccount.getLoginPwd(), request, response);
    }
}

refreshToken 中判断是否有重新签发的条件,很多博客中说到 AccessToken 要与 RefreshToken 中的 时间戳 一致才允许重新签发, 但是有些时候客户端会进行并发请求导致可能同一时间上有多个请求到达服务器端导致前一个请求更新了 token,其他请求还是携带着老的 token。我们可以通过将刚刚被刷新掉的 RefreshToken 进行一次很短的缓存比如 30s。在 onLoginSuccess 中判断如果存在则直接返回 true

访问控制

用于在访问 @RequiresRoles@RequiresPermissions标记的方法时,会通过 MyShiroRealm 中的 doGetAuthorizationInfo 方法获取角色和权限信息并组装成 AuthorizationInfo 供 Shiro 使用。第二次访问时则会先从设置的 ShiroCache 中获取,如果没有再走 doGetAuthorizationInfo

@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
    String principal = principalCollection.toString();
    TokenInfo tokenInfo = mJwtTokenHelper.getTokenInfo(principal);
    Set<String> userRoles = mUserRepository.findUserRoleNameByAccount(tokenInfo.getAccount());
    SimpleAuthorizationInfo simpleAuthenticationInfo = new SimpleAuthorizationInfo(userRoles);
  	// 这里的权限设置为 emptySet 了... 请注意替换成自己的
    simpleAuthenticationInfo.setStringPermissions(Collections.emptySet());
    return simpleAuthenticationInfo;
}

总结

初次接触可能有很多不对的地方,欢迎各位大佬指正交流。