Shiro整合JWT+Token过期刷新,全都帮你整好了
By: Date: 2019年1月31日 Categories: 程序 标签:, ,

最近使用SpringBoot集成Shiro,JWT快速搭建了一个后台系统,Shiro前面已经使用过,JWT(JSON Web Tokens)是一种用于安全的传递信息而采用的一种标准。Web系统中,我们使用加密的Json来生成Token在服务端与客户端无状态传输,代替了之前常用的Session。
系统采用Redis作为缓存,解决Token过期更新的问题,同时集成SSO登录,完整过程这里来总结一下。

0. JWT登录主要流程:

  1. 登录时,密码验证通过,取当前时间戳生成签名Token,放在Response Header的Authorization属性中,同时在缓存中记录值为当前时间戳的RefreshToken,并设置有效期。
  2. 客户端请求每次携带Token进行请求。
  3. 服务端每次校验请求的Token有效后,同时比对Token中的时间戳与缓存中的RefreshToken时间戳是否一致,一致则判定Token有效。
  4. 当请求的Token被验证时抛出TokenExpiredException异常时说明Token过期,校验时间戳一致后重新生成Token并调用登录方法。
  5. 每次生成新的Token后,同时要根据新的时间戳更新缓存中的RefreshToken,以保证两者时间戳一致。

1. Shiro配置

首先是Shiro的配置,定义两个类ShiroChonfig以及ShiroRealm用来配置Shiro,以及验证部分。
这里重要的是关闭Session,因为我们使用JWT来传输安全信息。自定义缓存管理器,同时我们要添加一个JwttFilter,将所有的请求交由它处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
@Configuration
public class ShiroConfig {
    @Bean
    public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
        return new LifecycleBeanPostProcessor();
    }
 
    @Bean
    @DependsOn("lifecycleBeanPostProcessor")
    public static DefaultAdvisorAutoProxyCreator getLifecycleBeanPostProcessor() {
        DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
        // 强制使用cglib
        defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
        return defaultAdvisorAutoProxyCreator;
    }
 
    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(DefaultWebSecurityManager securityManager) {
        AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
        advisor.setSecurityManager(securityManager);
        return advisor;
    }
 
    @Bean
    public DefaultWebSecurityManager  securityManager(ShiroRealm shiroRealm,ShiroCacheManager shiroCacheManager){
        DefaultWebSecurityManager securityManager =  new DefaultWebSecurityManager();
 
        securityManager.setRealm(shiroRealm);
 
        //关闭shiro自带的session
        DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
        DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
        defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
        subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
        securityManager.setSubjectDAO(subjectDAO);
 
        //自定义缓存管理
        securityManager.setCacheManager(shiroCacheManager);
        return securityManager;
    }
 
    @Bean
    public ShiroFilterFactoryBean shiroFilter(DefaultWebSecurityManager securityManager) {
        ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();
        shiroFilter.setSecurityManager(securityManager);
 
        // 添加jwt过滤器
        Map<string, filter=""> filterMap = new HashMap<>();
        filterMap.put("jwt", jwtFilter());
        filterMap.put("logout", new SystemLogoutFilter());
        shiroFilter.setFilters(filterMap);</string,>
 
        //拦截器
        Map<string,string> filterRuleMap = new LinkedHashMap<>();
        filterRuleMap.put("/logout", "logout");
        filterRuleMap.put("/**", "jwt");
        shiroFilter.setFilterChainDefinitionMap(filterRuleMap);</string,string>
 
        return shiroFilter;
    }
 
    @Bean
    public JwtFilter jwtFilter(){
        return new JwtFilter();此处为AccessToken
    }
}

用户验证以及权限验证的地方,用户验证多加了一个校验,就是我们当前请求的token中包含的时间戳与缓存中的RefreshToken对比,一致才验证通过。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
@Service
public class ShiroRealm extends AuthorizingRealm {
    @Autowired
    private IBpUserService userService;
    @Autowired
    private IBpRoleService roleService;
    @Autowired
    private IBpAuthorityService bpAuthorityService;
    @Autowired
    private CacheClient cacheClient;
 
    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof JwtToken;
    }
 
    /**
     * 用户名信息验证
     * @param auth
     * @return
     * @throws AuthenticationException
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth)
            throws AuthenticationException {
        String token = (String)auth.getPrincipal();
        String account  = JwtUtil.getClaim(token,SecurityConsts.ACCOUNT);
 
        if (account == null) {
            throw new AuthenticationException("token invalid");
        }
 
        BpUser bpUserInfo = userService.findUserByAccount(account);
        if (bpUserInfo == null) {
            throw new AuthenticationException("BpUser didn't existed!");
        }
 
        String refreshTokenCacheKey = SecurityConsts.PREFIX_SHIRO_REFRESH_TOKEN + account;
        if (JwtUtil.verify(token) && cacheClient.exists(refreshTokenCacheKey)) {
            String currentTimeMillisRedis = cacheClient.get(refreshTokenCacheKey);
            // 获取AccessToken时间戳,与RefreshToken的时间戳对比
            if (JwtUtil.getClaim(token, SecurityConsts.CURRENT_TIME_MILLIS).equals(currentTimeMillisRedis)) {
                return new SimpleAuthenticationInfo(token, token, "shiroRealm");
            }
        }
        throw new AuthenticationException("Token expired or incorrect.");
    }
 
    /**
     * 检查用户权限
     * @param principals
     * @return
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
 
        String account = JwtUtil.getClaim(principals.toString(), SecurityConsts.ACCOUNT);
        BpUser bpUserInfo = userService.findUserByAccount(account);
 
        //获取用户角色
        List<bprole> bpRoleList = roleService.findRoleByUserId(bpUserInfo.getId());
        //获取权限
        List<object> bpAuthorityList = bpAuthorityService.findByUserId(bpUserInfo.getId());
        for(BpRole bpRole : bpRoleList){
            authorizationInfo.addRole(bpRole.getName());
            for(Object auth: bpAuthorityList){
                authorizationInfo.addStringPermission(auth.toString());
            }
        }
        return authorizationInfo;
    }
}

这里我们定义了一些常量,其中有请求头包含的Token的属性,以及放入缓存中的Key

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class SecurityConsts {
    public static final String LOGIN_SALT = "storyweb-bp";
    //request请求头属性
    public static final String REQUEST_AUTH_HEADER="Authorization";
 
    //JWT-account
    public static final String ACCOUNT = "account";
 
    //Shiro redis 前缀
    public static final String PREFIX_SHIRO_CACHE = "storyweb-bp:cache:";
 
    //redis-key-前缀-shiro:refresh_token
    public final static String PREFIX_SHIRO_REFRESH_TOKEN = "storyweb-bp:refresh_token:";
 
    //JWT-currentTimeMillis
    public final static String CURRENT_TIME_MILLIS = "currentTimeMillis";
}


2. JWT 配置

这里我们有几个参数放在配置文件中:

1
2
3
4
5
6
7
8
9
token:
  #  token过期时间,单位分钟
  tokenExpireTime: 120
  #  RefreshToken过期时间,单位:分钟, 24*60=1440
  refreshTokenExpireTime: 1440
  #  shiro缓存有效期,单位分钟,2*60=120
  shiroCacheExpireTime: 120
  #  token加密密钥
  secretKey: storywebkey
1
2
3
4
5
6
7
8
9
10
11
12
@ConfigurationProperties(prefix = "token")
@Data
public class JwtProperties {
    //token过期时间,单位分钟
    Integer tokenExpireTime;
    //刷新Token过期时间,单位分钟
    Integer refreshTokenExpireTime;
    //Shiro缓存有效期,单位分钟
    Integer shiroCacheExpireTime;
    //token加密密钥
    String secretKey;
}
当然了,你需要在SpringBoot的Application启动类中,加入注解:
@EnableConfigurationProperties({JwtProperties.class})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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;
    }
}

接下来是Jwt的Fiter,集成自Shiro的BasicHttpAuthenticationFilter,这里的注释比较详细。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
public class JwtFilter extends BasicHttpAuthenticationFilter {
 
    private Logger LOGGER = LoggerFactory.getLogger(this.getClass());
 
    @Autowired
    CacheClient cacheClient;
    @Autowired
    JwtProperties jwtProperties;
 
    /**
     * 检测Header里Authorization字段
     * 判断是否登录
     */
    @Override
    protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
        HttpServletRequest req = (HttpServletRequest) request;
        String authorization = req.getHeader(SecurityConsts.REQUEST_AUTH_HEADER);
        return authorization != null;
    }
 
    /**
     * 登录验证
     * @param request
     * @param response
     * @return
     * @throws Exception
     */
    @Override
    protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        String authorization = httpServletRequest.getHeader(SecurityConsts.REQUEST_AUTH_HEADER);
 
        JwtToken token = new JwtToken(authorization);
        // 提交给realm进行登入,如果错误他会抛出异常并被捕获
        getSubject(request, response).login(token);
 
        // 绑定上下文
        String account = JwtUtil.getClaim(authorization, SecurityConsts.ACCOUNT);
        UserContext userContext= new UserContext(new LoginUser(account));
 
        // 如果没有抛出异常则代表登入成功,返回true
        return true;
    }
 
    /**
     * 刷新AccessToken,进行判断RefreshToken是否过期,未过期就返回新的AccessToken且继续正常访问
     */
    private boolean refreshToken(ServletRequest request, ServletResponse response) {
        // 获取AccessToken(Shiro中getAuthzHeader方法已经实现)
        String token = this.getAuthzHeader(request);
        // 获取当前Token的帐号信息
        String account = JwtUtil.getClaim(token, SecurityConsts.ACCOUNT);
        String refreshTokenCacheKey = SecurityConsts.PREFIX_SHIRO_REFRESH_TOKEN + account;
        // 判断Redis中RefreshToken是否存在
        if (cacheClient.exists(refreshTokenCacheKey)) {
            // 获取RefreshToken时间戳,及AccessToken中的时间戳
            // 相比如果一致,进行AccessToken刷新
            String currentTimeMillisRedis = cacheClient.get(refreshTokenCacheKey);
            String tokenMillis=JwtUtil.getClaim(token, SecurityConsts.CURRENT_TIME_MILLIS);
 
            if (tokenMillis.equals(currentTimeMillisRedis)) {
 
                // 设置RefreshToken中的时间戳为当前最新时间戳
                String currentTimeMillis = String.valueOf(System.currentTimeMillis());
                Integer refreshTokenExpireTime = jwtProperties.refreshTokenExpireTime;
                cacheClient.set(refreshTokenCacheKey, currentTimeMillis,refreshTokenExpireTime*60l);
 
                // 刷新AccessToken,为当前最新时间戳
                token = JwtUtil.sign(account, currentTimeMillis);
 
                // 使用AccessToken 再次提交给ShiroRealm进行认证,如果没有抛出异常则登入成功,返回true
                JwtToken jwtToken = new JwtToken(token);
                this.getSubject(request, response).login(jwtToken);
 
                // 设置响应的Header头新Token
                HttpServletResponse httpServletResponse = (HttpServletResponse) response;
                httpServletResponse.setHeader(SecurityConsts.REQUEST_AUTH_HEADER, token);
                httpServletResponse.setHeader("Access-Control-Expose-Headers", SecurityConsts.REQUEST_AUTH_HEADER);
                return true;
            }
        }
        return false;
    }
 
    /**
     * 是否允许访问
     * @param request
     * @param response
     * @param mappedValue
     * @return
     */
    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
        if (isLoginAttempt(request, response)) {
            try {
                this.executeLogin(request, response);
            } catch (Exception e) {
                String msg = e.getMessage();
                Throwable throwable = e.getCause();
                if (throwable != null && throwable instanceof SignatureVerificationException) {
                    msg = "Token或者密钥不正确(" + throwable.getMessage() + ")";
                } else if (throwable != null && throwable instanceof TokenExpiredException) {
                    // AccessToken已过期
                    if (this.refreshToken(request, response)) {
                        return true;
                    } else {
                        msg = "Token已过期(" + throwable.getMessage() + ")";
                    }
                } else {
                    if (throwable != null) {
                        msg = throwable.getMessage();
                    }
                }
                this.response401(request, response, msg);
                return false;
            }
        }
        return true;
    }
 
    /**
     * 401非法请求
     * @param req
     * @param resp
     */
    private void response401(ServletRequest req, ServletResponse resp,String msg) {
        HttpServletResponse httpServletResponse = (HttpServletResponse) resp;
        httpServletResponse.setStatus(HttpStatus.UNAUTHORIZED.value());
        httpServletResponse.setCharacterEncoding("UTF-8");
        httpServletResponse.setContentType("application/json; charset=utf-8");
        PrintWriter out = null;
        try {
            out = httpServletResponse.getWriter();
 
            Result result = new Result();
            result.setResult(false);
            result.setCode(Constants.PASSWORD_CHECK_INVALID);
            result.setMessage(msg);
            out.append(JSON.toJSONString(result));
        } catch (IOException e) {
            LOGGER.error("返回Response信息出现IOException异常:" + e.getMessage());
        } finally {
            if (out != null) {
                out.close();
            }
        }
    }
}

这里再重复一下:当请求验证Token时抛出TokenExpiredException异常后,校验缓存中的RefreshToken的时间戳是否与当前请求Token时间戳一致,倘若一致,则重新生成Token,以当前时间戳更新缓存中的RefreshToken时间戳;倘若不一致,则以Json格式直接响应401未登录错误。
采用前后端分离的方式,我们的401就需要直接返回JSON格式的响应。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
@Component
public class JwtUtil {
 
    @Autowired
    JwtProperties jwtProperties;
 
    @Autowired
    private static JwtUtil jwtUtil;
 
    @PostConstruct
    public void init() {
        jwtUtil = this;
        jwtUtil.jwtProperties = this.jwtProperties;
    }
 
    /**
     * 校验token是否正确
     * @param token
     * @return
     */
    public static boolean verify(String token) {
        String secret = getClaim(token, SecurityConsts.ACCOUNT) + jwtUtil.jwtProperties.secretKey;
        Algorithm algorithm = Algorithm.HMAC256(secret);
        JWTVerifier verifier = JWT.require(algorithm)
                .build();
        verifier.verify(token);
        return true;
    }
 
    /**
     * 获得Token中的信息无需secret解密也能获得
     * @param token
     * @param claim
     * @return
     */
    public static String getClaim(String token, String claim) {
        try {
            DecodedJWT jwt = JWT.decode(token);
            return jwt.getClaim(claim).asString();
        } catch (JWTDecodeException e) {
            return null;
        }
    }
 
    /**
     * 生成签名,5min后过期
     * @param account
     * @param currentTimeMillis
     * @return
     */
    public static String sign(String account, String currentTimeMillis) {
        // 帐号加JWT私钥加密
        String secret = account + jwtUtil.jwtProperties.getSecretKey();
        // 此处过期时间,单位:毫秒
        Date date = new Date(System.currentTimeMillis() + jwtUtil.jwtProperties.getTokenExpireTime()*60*1000l);
        Algorithm algorithm = Algorithm.HMAC256(secret);
 
        return JWT.create()
                .withClaim(SecurityConsts.ACCOUNT, account)
                .withClaim(SecurityConsts.CURRENT_TIME_MILLIS, currentTimeMillis)
                .withExpiresAt(date)
                .sign(algorithm);
    }
}

3. 绑定当前上下文用户

用户登录后,在业务里想要获取当前登录用户信息,一是可以在登录时缓存用户信息,二是少量信息从token里拿,这里当每次验证请求成功后,我们都将当前用户信息绑定到当前的上下文中,这里我只提取了账号。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@Data
public class LoginUser implements Serializable {
    private static final long serialVersionUID = 1L;
 
    public Long userId;          // 主键ID
    public String account;      // 账号
    public String name;         // 姓名
 
    public LoginUser() {
    }
 
    public LoginUser(String account) {
        this.account=account;
    }
 
    public LoginUser(Long userId, String account, String name) {
        this.userId = userId;
        this.account = account;
        this.name = name;
    }
}
 
public class UserContext implements AutoCloseable {
    static final ThreadLocal<loginuser> current = new ThreadLocal<>();</loginuser>
 
    public UserContext(LoginUser user) {
        current.set(user);
    }
 
    public static LoginUser getCurrentUser() {
        return current.get();
    }
 
    public void close() {
        current.remove();
    }
}

4. 缓存

缓存这里的实现,可以自己完善,这里只实现了部分的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
@Service
public class ShiroCacheManager implements CacheManager {
    @Autowired
    CacheClient cacheClient;
  
    @Override
    public <K, V> Cache<K, V> getCache(String s) throws CacheException {
        return new ShiroCache<K,V>(cacheClient);
    }
}
  
/**
 * 重写Shiro的Cache保存读取
 * @param <K>
 * @param <V>
 */
public class ShiroCache<K,V> implements Cache<K,V> {
  
    private CacheClient cacheClient;
  
    public ShiroCache(CacheClient cacheClient) {
        this.cacheClient = cacheClient;
    }
  
    /**
     * 获取缓存
     * @param key
     * @return
     * @throws CacheException
     */
    @Override
    public Object get(Object key) throws CacheException {
        String tempKey= this.getKey(key);
        if(cacheClient.exists(tempKey)){
            return cacheClient.getObject(tempKey);
        }
        return null;
    }
  
    /**
     * 保存缓存
     * @param key
     * @param value
     * @return
     * @throws CacheException
     */
    @Override
    public Object put(Object key, Object value) throws CacheException {
        return cacheClient.setObject(this.getKey(key), value);
    }
  
    /**
     * 移除缓存
     * @param key
     * @return
     * @throws CacheException
     */
    @Override
    public Object remove(Object key) throws CacheException {
        String tempKey= this.getKey(key);
        if(cacheClient.exists(tempKey)){
            cacheClient.del(tempKey);
        }
        return null;
    }
  
    @Override
    public void clear() throws CacheException {}
  
    @Override
    public int size() {
        //@TODO
        return 20;
    }
  
    @Override
    public Set<K> keys() {
        return null;
    }
  
    @Override
    public Collection<V> values() {
        Set keys = this.keys();
        List<V> values = new ArrayList<>();
        for (Object key : keys) {
            values.add((V)cacheClient.getObject(this.getKey(key)));
        }
        return values;
    }
  
    /**
     * 根据名称获取
     * @param key
     * @return
     */
    private String getKey(Object key) {
        return SecurityConsts.PREFIX_SHIRO_CACHE + JwtUtil.getClaim(key.toString(), SecurityConsts.ACCOUNT);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//shiro工具类
public class ShiroKit {
    public final static String hashAlgorithmName = "MD5";
  
    //循环次数
    public final static int hashIterations = 1024;
  
    /**
     * shiro密码加密工具类
     *
     * @param credentials 密码
     * @param saltSource 密码盐
     * @return
     */
    public static String md5(String credentials, String saltSource) {
        ByteSource salt = new Md5Hash(saltSource);
        return new SimpleHash(hashAlgorithmName, credentials, salt, hashIterations).toString();
    }
}

5. 登录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
@Controller
@RequestMapping(value="/user")
public class LoginController {
  
    @Autowired
    IBpUserService bpUserService;
  
    /**
     * 登录
     * @param user
     * @return
     */
    @SuppressWarnings("unchecked")
    @RequestMapping(value="/login")
    @ResponseBody
    public Result login(HttpServletResponse response,@RequestBody User user) {
        return bpUserService.login(user,response);
    }
}
  
//Service类
@Service
public class BpUserServiceImpl extends ServiceImpl<BpUserMapper, BpUser> implements IBpUserService {
    @Autowired
    CacheClient CacheClient;
  
     /**
     * 用户登录
     * @param user
     * @return
     */
    @Override
    public Result login(User user, HttpServletResponse response) {
        Assert.notNull(user.getUsername(), "用户名不能为空");
        Assert.notNull(user.getPassword(), "密码不能为空");
  
        BpUser userBean = this.findUserByAccount(user.getUsername());
  
        if(userBean==null){
            return new Result(false, "用户不存在", null, Constants.PASSWORD_CHECK_INVALID);
        }
  
        //域账号直接提示账号不存在
        if ("1".equals(userBean.getDomainFlag())) {
            return new Result(false, "账号不存在", null, Constants.PASSWORD_CHECK_INVALID);
        }
  
        String encodePassword = ShiroKit.md5(user.getPassword(), SecurityConsts.LOGIN_SALT);
        if (!encodePassword.equals(userBean.getPassword())) {
            return new Result(false, "用户名或密码错误", null, Constants.PASSWORD_CHECK_INVALID);
        }
  
        //账号是否锁定
        if ("0".equals(userBean.getStatus())) {
            return new Result(false, "该账号已被锁定", null, Constants.PASSWORD_CHECK_INVALID);
        }
  
        //验证成功后处理
        this.loginSuccess(userBean.getAccount(),response);
  
        //登录成功
        return new Result(true, "登录成功", null ,Constants.TOKEN_CHECK_SUCCESS);
    }
  
    /**
     * 登录后更新缓存,生成token,设置响应头部信息
     * @param account
     * @param response
     */
    private void loginSuccess(String account, HttpServletResponse response){
  
        String currentTimeMillis = String.valueOf(System.currentTimeMillis());
  
        // 清除可能存在的Shiro权限信息缓存
        String tokenKey=SecurityConsts.PREFIX_SHIRO_CACHE + account;
        if (cacheClient.exists(tokenKey)) {
            cacheClient.del(tokenKey);
        }
  
        //更新RefreshToken缓存的时间戳
        String refreshTokenKey= SecurityConsts.PREFIX_SHIRO_REFRESH_TOKEN + account;
        if (cacheClient.exists(refreshTokenKey)) {
            cacheClient.set(refreshTokenKey, currentTimeMillis, jwtProperties.getRefreshTokenExpireTime()*60*60l);
        }else{
            cacheClient.set(refreshTokenKey, currentTimeMillis, jwtProperties.getRefreshTokenExpireTime()*60*60l);
        }
  
        //生成token
        JSONObject json = new JSONObject();
        String token = JwtUtil.sign(account, currentTimeMillis);
        json.put("token",token );
  
        //写入header
        response.setHeader(SecurityConsts.REQUEST_AUTH_HEADER, token);
        response.setHeader("Access-Control-Expose-Headers", SecurityConsts.REQUEST_AUTH_HEADER);
    }
}
登录成功后,我们在生成Token的同时,将当前时间戳以RefreshToken为Key存入Redis,用于Token过期时的校验及刷新。

当我们在业务中需要访问上下文用户时,可以这样获取:
UserContext.getCurrentUser().getAccount()


6. 注销登录状态

采用前后端分离的方式,当用户注销后,后端依然是以Json方式返回,因此,我们通过过滤器处理请求,注销完成返回Json结果。
再前面,我们已经添加了自定义的过滤器SystemLogoutFilter到Shiro的ShiroFilterFactoryBean中,这里只要实现就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class SystemLogoutFilter extends LogoutFilter {
    private static final Logger logger = LoggerFactory.getLogger(SystemLogoutFilter.class);
  
    @Override
    protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
        Subject subject = getSubject(request, response);
        try {
            subject.logout();
        } catch (Exception ex) {
            logger.error("退出登录错误",ex);
        }
  
        this.writeResult(response);
        //不执行后续的过滤器
        return false;
    }
  
    private void writeResult(ServletResponse response){
        //响应Json结果
        PrintWriter out = null;
        try {
            out = response.getWriter();
            Result result = new Result(true,null,null,Constants.TOKEN_CHECK_SUCCESS);
            out.append(JSON.toJSONString(result));
        } catch (IOException e) {
            logger.error("返回Response信息出现IOException异常:" + e.getMessage());
        } finally {
            if (out != null) {
                out.close();
            }
        }
    }
}

7. 添加依赖

把依赖放到最后,因为这个不需要说。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<dependency>
    <groupid>org.apache.shiro</groupid>
    <artifactid>shiro-spring</artifactid>
    <version>1.4.0</version>
</dependency>
<dependency>
    <groupid>org.apache.shiro</groupid>
    <artifactid>shiro-ehcache</artifactid>
    <version>1.4.0</version>
</dependency>
<!--JWT-->
<dependency>
    <groupid>com.auth0</groupid>
    <artifactid>java-jwt</artifactid>
    <version>3.4.1</version>
</dependency>
<!--Redis-->
<dependency>
    <groupid>redis.clients</groupid>
    <artifactid>jedis</artifactid>
    <version>2.9.0</version>
</dependency>

后续补充

  1. 关于本篇中Token刷新方案做了一些修改,详见 采用JWT有效期内刷新Token方案,解决并发请求问题
  2. 问的朋友比较多,于是就将项目后端代码上传至GitHub,地址:https://github.com/sunnj/story-admin
  3. 本项目的前端仓库地址:https://github.com/sunnj/story-admin-console

参考资料

  1. JSON Web Token 入门教程
  2. Shiro+JWT+Spring Boot Restful简易教程
  3. SpringBoot + Shiro + JWT集成Redis缓存(Jedis)

90 thoughts on “Shiro整合JWT+Token过期刷新,全都帮你整好了

    1. tokenMillis为null说明没有从你的token中获取到currentTimeMillis信息,检查下你生成token的地方是否有传入

        1. refreshTokenCacheKey = SecurityConsts.PREFIX_SHIRO_REFRESH_TOKEN + account
          refreshTokenCacheKey不会为null,cacheClient是你的Redis工具类,如果你的exists方法是static的应该不会空指针,
          不是static,那你的cacheClient就应该是需要注入的,用@Autowired标记下。

          1. 设置了jwtFilter 后 所有请求都会走这个, 上面设置的anon 没生效,请问是什么原因啊

  1. 感觉注销登录状态要手动去清理 redis里面的refreshtoken,这一步是不是没写啊

  2. 我是真是服了,为什么我的jwt过滤器不能注入redis工具类?@autowired根本不能注入,即便在类上加@Component注解依然是null。非spring管理的类调用spring容器的bean像你这样会报空指针异常

    1. 不知道你为什么可以这样运行,我对spring内部原理也不太懂,人都要被搞死了,唉

    2. 因为这个JWTfilter 是以Bean的方式注入的 这个时候SpringBoot会自动注入这个Filter 并且在shiro默认的过滤器之前;
      1.导致shiro的白名单全部失效,解决方式是可以在过滤器中手动判断请求是否在白名单中,这样太愚蠢了,所以我没有用.
      2.但是如果不以Bean的方式注入 这个时候再JwtFilter这个类里面使用注解是无法注入bean的,不过使用SpringContext工具类可以获取到,我目前用的是这种方式

  3. 设置了jwtFilter 后 所有请求都会走这个, 上面设置的anon 没生效,请问是什么原因啊

    1. 可以删掉ShiroConfig类中的public JwtFilter jwtFilter(){return new JwtFilter();}方法上的注解@Bean试下。

      1. 删掉后,anon确实能生效,但是不知道为何jwtFilter中的所有通过@Autowired注解的实体都注入不进来? 麻烦博主解答下。

  4. 有些方法需要验证,有些方法不需要验证,这个怎么区分并验证

  5. 你好运行到String account = JwtUtil.getClaim(token, SecurityConsts.ACCOUNT);就会报错请问为什么呢?

    1. 这里是提取token中的信息,可以贴出错误信息具体分析看是什么问题。

  6. token超时之后程序没走到refreshToken里,发现是Throwable throwable = e.getCause(); throwable 的类型是AuthenticationException,else if (throwable != null && throwable instanceof TokenExpiredException)是false,博主这里是怎么搞得?

    1. 应该token过期后,在verifier.verify(token);验证时抛出过期异常导致。
      文末提供了另一种token有效期内刷新方案,以及项目的git地址供君参考。

  7. 登录验证没有调用subject.login方法的话,那realm中的doGetAuthenticationInfo方法存在的意义是什么?

    1. realm中的doGetAuthenticationInfo这里主要用于验证token的有效性。登录时的token是新生成的,因此没有调用subject.login方法。当然你也可以调用:

      Subject subject = SecurityUtils.getSubject();
              AuthenticationToken token= new JwtToken(strToken);
              subject.login(token);
      
    1. 我也遇到同样的问题,当doGetAuthorizationInfo报错,就会运行两次doGetAuthorizationInfo方法,不知道是为什么

      1. doGetAuthorizationInfo 不知道你说的报错是为什么报错?父类中的onAccessDenied方法中倒是会再次调用executeLogin,而我们在JwtFilter中只重写了isAccessAllowed,并没有重写onAccessDenied,是会导致executeLogin被重新调用。我重写了onAccessDenied,代码已经提交,你可以重新pull测试下。

  8. 大佬,我在方法上加@RequiresAuthentication这个注解可以正常使用,但是如果使用@RequiresPermissions("system:role:list")这种的就会报异常,求指导啊

    1. 得看报什么异常?
      需要权限访问的Aciton是会进入doGetAuthorizationInfo方法的,当然如果配置了缓存,也会直接从缓存读取,而不调用这个方法。

    1. admin/111111
      项目前端地址:https://github.com/sunnj/story-admin-console
      postman只需要在构造请求参数时在请求头header中带上名为Authorization的Token内容即可。

      1. 您好,前台下载运行报了这个错:
        PS D:\java2019\react-devtools-master> npm run dev
        npm ERR! missing script: dev

        npm ERR! A complete log of this run can be found in:
        npm ERR! C:\Users\admin\AppData\Roaming\npm-cache_logs\2019-09-09T14_07_47_230Z-debug.log

          1. 不好意思,我执行了的,不过报的这些信息(我重又执行了一次)
            D:\java2019\react-devtools-master>npm install
            npm WARN ajv-errors@1.0.1 requires a peer of ajv@>=5.0.0 but none is installed. You must install peer dependencies yourself.
            npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\fsevents):
            npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})

            audited 32464 packages in 8.857s
            found 76 vulnerabilities (66 low, 7 moderate, 2 high, 1 critical)
            run npm audit fix to fix them, or npm audit for details

            再执行 npm audit fix 一直没有反应。

          2. 执行完install就行,不需要执行提示里的fix。
            我重新下载了代码,跑起来没有问题。

    2. 您好,后台访问http://localhost:9430/
      出现 欢迎来到 STORY-ADMIN 页面。我又重新下载了前端。运行后,前端url:http://localhost:9428/login的登录界面也出来了,用户和密码是系统默认的。但是点击登录出现提示信息:Request failed with status code 400。
      请看看是为什么?

        1. 装上redis,前端可以登录了,数据也出来了,不过还报:Request failed with status code 404。
          不知什么原因?

  9. 你好,我们重写了缓存管理器,是不是每次进来先去缓存里面查找对应的权限角色信息,没有的话再去数据库查?每次我从redis中查询时都会报错,提示Illegal character,能帮我解答一下吗

    1. 是每次先从缓存取,若没有才从数据库中取。提示非法字符那要看是哪一行,具体代码怎么写的才能确定是什么原因。

  10. 老哥,token会自动刷新么?是不是要有一个刷新token的接口,每次登录成功返回token和刷新token,token超时之后然后根据刷新token获取token

    1. 每次请求时会验证携带的token,检查是否需要刷新,倘若需要会自动重新生成新的token替换之前的。
      文章中已经写了,不需要额外的接口。

  11. 配合Swagger怎么使用啊,我设定了filterChainDefinitionMap.put("/swagger-ui.html", "anon");
    filterChainDefinitionMap.put("/swagger-resources", "anon");
    filterChainDefinitionMap.put("/swagger-resources/", "anon");
    filterChainDefinitionMap.put("/v2/api-docs", "anon");
    filterChainDefinitionMap.put("/webjars/springfox-swagger-ui/
    ", "anon");都没有用啊,提示我401权限错误

    1. 更新下代码。
      SwaggerCofing类继承WebMvcConfigurationSupport类添加addResourceHandlers方法

      @Override
      protected void addResourceHandlers(ResourceHandlerRegistry registry) {
          registry.addResourceHandler("/**").addResourceLocations("classpath:/static/");
          registry.addResourceHandler("/swagger-ui.html").addResourceLocations("classpath:/META-INF/resources/");
          registry.addResourceHandler("/webjars/**").addResourceLocations("classpath:/META-INF/resources/webjars/");
      }
      

      application.yml 增加配置

      - key: /swagger-ui.html
        value: anon
      - key: /swagger-resources/**
        value: anon
      - key: /v2/api-docs/**
        value: anon
      - key: /webjars/**
        value: anon
      
    1. 记录特定接口(带有@SysLogAnnotation注解的接口)的请求参数。
      这个请求参数日志只适用特定场景,一般情况下可以不要。

      1. 谢谢大佬,我在调试使用的过程中又产生了个问题,携带token访问增删改查的api时,总是报错显示java.lang.ClassCastException: java.util.LinkedHashMap cannot be cast to org.apache.shiro.authz.AuthorizationInfo 是咋会事呢 不知道问题所在 ?求解答 ,谢谢啦

        1. 应该是你的缓存有问题,检查下缓存中的内容是否正确。
          可以贴出完整的错误看下

  12. Filter类里面的isAccessAllowed方法,为什么isLoginAttempt(request, response)验证为flse也返回true呢,header里面没有Token

    1. 这段代码适用的场景是已登录和未登录都能看到页面但显示内容不同时,这里都返回true。
      如果未登录需要拒绝访问,可以直接返回false。
      代码已经做了变更,与本文稍有不同,请参考github代码,文末有仓库地址。

  13. 大佬,第二步,jwt配置文件那里,上面的配置文件是yml格式的吗?我的主配置文件 是properties,能否再创建一个yml格式的配置文件来放置jwt配置?

  14. 楼主你好,我想问一下,如果这样实现我是不是每次访问的时候都会进入自定义的过滤器,进入过滤器之后都需要在执行executeLogin进行登录?
    如果每次访问都要登录,哪效率是不是有点太低了啊

  15. 大佬,我想问下UserContextFilter这个过滤器的作用是什么,我的走完了这个过滤器就报了40001,token是通过成功登陆获取到的

  16. 大佬,我想问下UserContextFilter这个过滤器的作用是什么,我的走完了这个过滤器就报了40001,token是通过成功登陆获取到的。

    1. 这个过滤器目的是将请求头header中的信息解析出来放入当前请求的上下文中。

  17. 大佬你好!请问一下我整合好之后,没有起到拦截效果,所有接口都可以访问,是什么原因了?

    1. 这个得根据代码具体分析,shiro的配置是否生效?过滤器是否正确配置,或者其他的问题,或者可以跟踪下JwtFilter。
      总之原因比较多,得具体分析,可以对比看下github仓库的代码。

      1. 找到原因了是yml中设置了 /** 为 anon
        还请教一个问题,我是使用github仓库中最新的代码。这个tokenExpireTime和refreshCheckTime之间的联系有点模糊。
        tokenExpireTime是token有效时间,refreshCheckTime是更新令牌时间,看你yml文件中的配置我的理解是:
        token有效时间为1440分钟 ,refreshCheckTime更新令牌时间120分钟
        是表示在这个1440分钟中每过120分钟更新一个token吗?
        如果是那是用什么去更新的?更新后原有token还有效吗?原有token有效时间有1440分钟

  18. 大佬,我登录成功携带了token,但是用postman接口测试提示无权限(所有接口都无权限),但是我表中数据也是关联了的,可能是什么错误?

回复 迟到 取消回复

您的邮箱地址不会被公开。 必填项已用 * 标注