最近使用SpringBoot集成Shiro,JWT快速搭建了一个后台系统,Shiro前面已经使用过,JWT(JSON Web Tokens)是一种用于安全的传递信息而采用的一种标准。Web系统中,我们使用加密的Json来生成Token在服务端与客户端无状态传输,代替了之前常用的Session。
系统采用Redis作为缓存,解决Token过期更新的问题,同时集成SSO登录,完整过程这里来总结一下。
0. JWT登录主要流程:
登录时,密码验证通过,取当前时间戳生成签名Token,放在Response Header的Authorization属性中,同时在缓存中记录值为当前时间戳的RefreshToken,并设置有效期。
客户端请求每次携带Token进行请求。
服务端每次校验请求的Token有效后,同时比对Token中的时间戳与缓存中的RefreshToken时间戳是否一致,一致则判定Token有效。
当请求的Token被验证时抛出TokenExpiredException异常时说明Token过期,校验时间戳一致后重新生成Token并调用登录方法。
每次生成新的Token后,同时要根据新的时间戳更新缓存中的RefreshToken,以保证两者时间戳一致。
1. Shiro配置
首先是Shiro的配置,定义两个类ShiroChonfig以及ShiroRealm用来配置Shiro,以及验证部分。
这里重要的是关闭Session,因为我们使用JWT来传输安全信息。自定义缓存管理器,同时我们要添加一个JwttFilter,将所有的请求交由它处理。
示例代码: |
@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);
//拦截器 Map<String,String> filterRuleMap = new LinkedHashMap<>(); filterRuleMap.put("/logout", "logout"); filterRuleMap.put("/**", "jwt"); shiroFilter.setFilterChainDefinitionMap(filterRuleMap);
return shiroFilter; }
@Bean public JwtFilter jwtFilter(){ return new JwtFilter();此处为AccessToken } } |
用户验证以及权限验证的地方,用户验证多加了一个校验,就是我们当前请求的token中包含的时间戳与缓存中的RefreshToken对比,一致才验证通过。
示例代码: |
@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
示例代码: |
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 配置
这里我们有几个参数放在配置文件中:
示例代码: |
token: # token过期时间,单位分钟 tokenExpireTime: 120 # RefreshToken过期时间,单位:分钟, 24*60=1440 refreshTokenExpireTime: 1440 # shiro缓存有效期,单位分钟,2*60=120 shiroCacheExpireTime: 120 # token加密密钥 secretKey: storywebkey |
示例代码: |
@ConfigurationProperties(prefix = "token") @Data public class JwtProperties { //token过期时间,单位分钟 Integer tokenExpireTime; //刷新Token过期时间,单位分钟 Integer refreshTokenExpireTime; //Shiro缓存有效期,单位分钟 Integer shiroCacheExpireTime; //token加密密钥 String secretKey; } |
当然了,你需要在SpringBoot的Application启动类中,加入注解:
@EnableConfigurationProperties({JwtProperties.class})
示例代码: |
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,这里的注释比较详细。
示例代码: |
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格式的响应。
示例代码: |
@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里拿,这里当每次验证请求成功后,我们都将当前用户信息绑定到当前的上下文中,这里我只提取了账号。
示例代码: |
@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<>();
public UserContext(LoginUser user) { current.set(user); }
public static LoginUser getCurrentUser() { return current.get(); }
public void close() { current.remove(); } } |
4. 缓存
缓存这里的实现,可以自己完善,这里只实现了部分的方法。
示例代码: |
@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); } } |
示例代码: |
//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. 登录
示例代码: |
@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中,这里只要实现就可以了。
示例代码: |
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. 添加依赖
把依赖放到最后,因为这个不需要说。
示例代码: |
<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> |
后续补充
关于本篇中Token刷新方案做了一些修改,详见 采用JWT有效期内刷新Token方案,解决并发请求问题
问的朋友比较多,于是就将项目后端代码上传至GitHub,地址:https://github.com/sunnj/story-admin
本项目的前端仓库地址:https://github.com/sunnj/story-admin-console
参考资料