最近使用 SpringBoot 集成 Shiro,JWT 快速搭建了一个后台系统,Shiro 前面已经使用过,JWT(JSON Web Tokens)是一种用于安全的传递信息而采用的一种标准。Web 系统中,我们使用加密的 Json 来生成 Token 在服务端与客户端无状态传输,代替了之前常用的 Session。 系统采用 Redis 作为缓存,解决 Token 过期更新的问题,同时集成 SSO 登录,完整过程这里来总结一下。
JWT 登录主要流程:
登录时,密码验证通过,取当前时间戳生成签名 Token,放在 Response Header 的 Authorization 属性中,同时在缓存中记录值为当前时间戳的 RefreshToken,并设置有效期。
客户端请求每次携带 Token 进行请求。
服务端每次校验请求的 Token 有效后,同时比对 Token 中的时间戳与缓存中的 RefreshToken 时间戳是否一致,一致则判定 Token 有效。
当请求的 Token 被验证时抛出TokenExpiredException异常时说明 Token 过期,校验时间戳一致后重新生成 Token 并调用登录方法。
每次生成新的 Token 后,同时要根据新的时间戳更新缓存中的 RefreshToken,以保证两者时间戳一致。
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);</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 对比,一致才验证通过。
@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";
}
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);
}
}
绑定当前上下文用户
用户登录后,在业务里想要获取当前登录用户信息,一是可以在登录时缓存用户信息,二是少量信息从 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<loginuser>();
public UserContext(LoginUser user) {
current.set(user);
}
public static LoginUser getCurrentUser() {
return current.get();
}
public void close() {
current.remove();
}
}
缓存
缓存这里的实现,可以自己完善,这里只实现了部分的方法。
@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();
}
}
登录
@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()
注销登录状态
采用前后端分离的方式,当用户注销后,后端依然是以 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();
}
}
}
}
添加依赖 把依赖放到最后,因为这个不需要说。
<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>
完整源码下载
项目后端代码
https://github.com/sunnj/story-admin
本项目的前端代码
https://github.com/sunnj/story-admin-console
作者:sunnj87
来源链接:
https://www.sundayfine.com/shiro-jwt/