简介
Apache Shiro 是 Java 的一个安全框架,相对于SpringSecurity更简单、轻量。需要整合SpringSecurity的可移步《springBoot整合springsecurity、jwt-token实现权限验证》。本文主要介绍shiro的使用。
JSON Web Token(缩写 JWT)是目前最流行的跨域认证解决方案。
分析
首先我们需要了解shiro的三大主体。
1、Subject:主体,代表了当前 “用户”,这个用户不一定是一个具体的人,与当前应用交互的任何东西都是Subject;所有 Subject 都绑定到SecurityManager。
2、SecurityManager:安全管理器;即所有与安全有关的操作都会与 SecurityManager 交互;且它管理着所有Subject;是 Shiro 的核心,它负责与后边介绍的其他组件进行交互;
3、Realm:域,Shiro 从从 Realm 获取安全数据(如用户、角色、权限),就是说 SecurityManager要验证用户身份,那么它需要从 Realm 获取相应的用户进行比较以确定用户身份是否合法;也需要从 Realm 得到用户相应的角色 / 权限进行验证用户是否能进行操作;
其次需要了解Jwt相关知识:http://www.ruanyifeng.com/blog/2018/07/json_web_token-tutorial.html
了解这些后,我们就直接开始整合。
初始准备
项目中增加如下shiro和JWT的jar依赖。
<!-- shiro spring. -->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>${shiro.version}</version>
</dependency>
<!-- JWT -->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>${jwt.version}</version>
</dependency>
增加鉴权配置
# 鉴权
auth:
# JWT
jwt:
# jwt自定义请求头key
header: jwt-token
# 秘钥
encryptJWTKey: U0JBUElKV1RkV2FuZzkyNjQ1NA==
# JWT过期时间(单位s)
accessTokenExpireTime: 300
# JWT续期时间(单位s),即token过期后,此时间内操作会自动续期。
refreshTokenExpireTime: 1800
# 不需要鉴权的路径
ignores:
- "/"
- "/imgs/**"
- "*.css"
- "*.js"
- "*.gif"
- "*.jpg"
- "*.png"
- "*.ico"
- "/favicon.ico"
- "/actuator/**"
- "/swagger-ui.html"
- "/doc.html"
- "/swagger-resources/**"
- "/service-worker.js"
- "/v2/**"
- "/webjars/**"
配置属性类,接收配置属性。
@Data
@Component
@ConfigurationProperties(prefix = "auth")
public class AuthProperties {
/**
* 忽略校验的路径
*/
private String[] ignores;
/**
* jwt相关配置
*/
private JwtConfig jwt;
@Data
public static class JwtConfig {
/**
* jwt自定义请求头key
*/
private String header;
/**
* 密钥
*/
private String encryptJWTKey;
/**
* JWT过期时间(单位s)
*/
private Long accessTokenExpireTime ;
/**
* JWT续期时间(单位s),即token过期后,此时间内操作会自动续期。
*/
private Long refreshTokenExpireTime;
}
}
核心配置
JWT核心类,可根据实际情况调整。
@Data
public class JwtClaim {
/**
* 此处为用户Id
*/
private String subject;
/**
* 承租人id
*/
private Long tenantId;
/**
* 用户名
*/
private String userName;
/**
* 账号
*/
private String account;
/**
* 角色
*/
private String[] roles;
/**
* 权限
*/
private String[] permissions;
}
@Data
@NoArgsConstructor
@ApiModel(value = "用户Token", description = "用户Token")
public class JwtToken implements AuthenticationToken{
@ApiModelProperty("密钥")
private String accessToken;
@ApiModelProperty("承租人id")
private Long tenantId;
@ApiModelProperty("用户id")
private Long userId;
@ApiModelProperty("用户名")
private String userName;
@Override
@JsonIgnore
public Object getPrincipal() {
return accessToken;
}
@Override
@JsonIgnore
public Object getCredentials() {
return accessToken;
}
public JwtToken(String accessToken,Long userId,String userName){
this.accessToken = accessToken;
this.userId = userId;
this.userName = userName;
}
public JwtToken(String accessToken,Long tenantId,Long userId,String userName){
this.accessToken = accessToken;
this.tenantId=tenantId;
this.userId = userId;
this.userName = userName;
}
public JwtToken(String accessToken){
this.accessToken = accessToken;
}
}
核心工具类
@Slf4j
public class JwtUtil {
private AuthProperties authProperties;
public JwtUtil(AuthProperties authProperties){
this.authProperties = authProperties;
}
/**
* 过期时间改为从配置文件获取
*/
private static Long accessTokenExpireTime;
/**
* RefreshToken过期时间-30分钟-30*60(秒为单位)
*/
private static Long refreshTokenExpireTime;
/**
* JWT认证加密私钥(Base64加密)
*/
private static String encryptJWTKey;
@PostConstruct
public void init(){
encryptJWTKey = authProperties.getJwt().getEncryptJWTKey();
accessTokenExpireTime = authProperties.getJwt().getAccessTokenExpireTime();
refreshTokenExpireTime = authProperties.getJwt().getRefreshTokenExpireTime();
}
/**
* 获取当前用户信息
*
* @return
*/
public static JwtToken getCurrentUser(){
Object principal = SecurityUtils.getSubject().getPrincipal();
if(principal == null ){
return null;
}
String accessToken = principal.toString();
Map<String, Claim> claims = getClaims(accessToken);
Long userId = Long.valueOf(getSubject(accessToken));
String username = claims.get(JwtConstant.JWT_USER_NAME).asString();
Long tenantId = claims.get(JwtConstant.JWT_TENANT_ID).asLong();
return new JwtToken(accessToken,tenantId,userId,username);
}
/**
* 校验token是否正确
* @param token Token
* @return boolean 是否正确
* @author Wang926454
* @date 2018/8/31 9:05
*/
public static boolean verify(String token) {
try {
// 帐号加JWT私钥解密
String secret = getSubject(token) + Base64ConvertUtil.decode(encryptJWTKey);
Algorithm algorithm = Algorithm.HMAC256(secret);
JWTVerifier verifier = JWT.require(algorithm).build();
verifier.verify(token);
return true;
} catch (UnsupportedEncodingException e) {
log.error("JWTToken认证解密出现UnsupportedEncodingException异常:{}", e.getMessage());
throw ResponseEnum.INTERNAL_SERVER_ERROR.newException("JWTToken认证解密异常");
}catch (TokenExpiredException e){
throw new TokenExpiredException("JWTToken过期");
}
}
/**
* 获得Token中的信息无需secret解密也能获得
* @param token
* @param claim
* @return java.lang.String
*/
public static Object getClaimString(String token, String claim) {
try {
DecodedJWT jwt = JWT.decode(token);
// 只能输出String类型,如果是其他类型返回null
return jwt.getClaim(claim).asString();
} catch (JWTDecodeException e) {
log.error("解密Token中的公共信息出现JWTDecodeException异常:{}", e.getMessage());
throw ResponseEnum.INTERNAL_SERVER_ERROR.newException("解密Token中的公共信息异常");
}
}
/**
* 获得Token中的信息无需secret解密也能获得
* @param token
* @param claim
* @return java.lang.String
*/
public static Object getClaimLong(String token, String claim) {
try {
DecodedJWT jwt = JWT.decode(token);
// 只能输出String类型,如果是其他类型返回null
return jwt.getClaim(claim).asLong();
} catch (JWTDecodeException e) {
log.error("解密Token中的公共信息出现JWTDecodeException异常:{}", e.getMessage());
throw ResponseEnum.INTERNAL_SERVER_ERROR.newException("解密Token中的公共信息异常");
}
}
/**
* 获得Token中的信息
* @param token
* @return java.lang.String
*/
public static Map<String, Claim> getClaims(String token) {
try {
DecodedJWT jwt = JWT.decode(token);
// 只能输出String类型,如果是其他类型返回null
return jwt.getClaims();
} catch (JWTDecodeException e) {
log.error("解密Token中的公共信息出现JWTDecodeException异常:{}", e.getMessage());
throw ResponseEnum.INTERNAL_SERVER_ERROR.newException("解密Token中的公共信息异常");
}
}
/**
* 获得Token中的subject,即userId
* @param token
* @return java.lang.String
*/
public static String getSubject(String token) {
try {
DecodedJWT jwt = JWT.decode(token);
// 只能输出String类型,如果是其他类型返回null
return jwt.getSubject();
} catch (JWTDecodeException e) {
log.error("解密Token中的公共信息出现JWTDecodeException异常:{}", e.getMessage());
throw ResponseEnum.INTERNAL_SERVER_ERROR.newException("解密Token中的公共信息异常");
}
}
/**
* 生成Token
*
* @param jwtClaim
* @return 返回加密的Token
*/
public static String generateToken(JwtClaim jwtClaim) {
try {
// 帐号加JWT私钥加密
String secret = jwtClaim.getSubject() + Base64ConvertUtil.decode(encryptJWTKey);
long currentTimeMillis = System.currentTimeMillis();
// 此处过期时间是以毫秒为单位,所以乘以1000
Date date = new Date( currentTimeMillis + accessTokenExpireTime * 1000);
Algorithm algorithm = Algorithm.HMAC256(secret);
String token = JWT.create()
.withClaim(JwtConstant.JWT_USER_NAME, jwtClaim.getUserName())
.withClaim(JwtConstant.JWT_USER_ACCOUNT, jwtClaim.getAccount())
.withArrayClaim(JwtConstant.JWT_ROLES_KEY, jwtClaim.getRoles())
.withArrayClaim(JwtConstant.JWT_PERMISSIONS_KEY, jwtClaim.getPermissions())
.withClaim(JwtConstant.JWT_CURRENT_TIME_MILLIS, currentTimeMillis)
.withClaim(JwtConstant.JWT_TENANT_ID, jwtClaim.getTenantId())
.withSubject(jwtClaim.getSubject())
.withExpiresAt(date)
.sign(algorithm);
// 设置到redis缓存,key和value均为jwt-token,过期时间设置为 过期时间 + 续期时间
RedisUtil.setStrExpire(token, token,accessTokenExpireTime + refreshTokenExpireTime);
return token;
} catch (UnsupportedEncodingException e) {
log.error("JWTToken加密出现UnsupportedEncodingException异常:{}", e.getMessage());
throw ResponseEnum.INTERNAL_SERVER_ERROR.newException("JWTToken加密异常");
}
}
/**
* 刷新token
* @param token
* @return java.lang.String 返回加密的Token
*/
public static String refreshToken(String token) {
String subject = getSubject(token);
// 获取token的属性
Map<String, Claim> claims = getClaims(token);
JwtClaim jwtClaim = new JwtClaim();
jwtClaim.setSubject(subject);
jwtClaim.setUserName(claims.get(JwtConstant.JWT_USER_NAME).asString());
jwtClaim.setAccount(claims.get(JwtConstant.JWT_USER_ACCOUNT).asString());
jwtClaim.setRoles(claims.get(JwtConstant.JWT_ROLES_KEY).asArray(String.class));
jwtClaim.setPermissions(claims.get(JwtConstant.JWT_PERMISSIONS_KEY).asArray(String.class));
jwtClaim.setTenantId(claims.get(JwtConstant.JWT_TENANT_ID).asLong());
// 重新生成token
return generateToken(jwtClaim);
}
/**
* 续期token
* @param oldToken
* @return java.lang.String 返回加密的Token
*/
public static Boolean reNewToken(String oldToken) {
// 重新生成token
String refreshToken = refreshToken(oldToken);
// 续期原token的过期时间,并更新value为新token
RedisUtil.setStrExpire(oldToken,refreshToken,accessTokenExpireTime + refreshTokenExpireTime);
return Boolean.TRUE;
}
}
Shiro核心配置类
@Slf4j
@Configuration
@Import(AuthProperties.class)
public class ShiroConfig {
public static final String JWT = "jwt";
public static final String ANON = "anon";
public static final String ALL_PATH_KEY = "/**";
@Bean
public ShiroFilterFactoryBean shiroFilter(AuthProperties authProperties, @Qualifier("securityManager") DefaultSecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
// 添加自己的过滤器并且取名为jwt
Map<String, Filter> filterMap = new HashMap<>(4);
filterMap.put(JWT, new JwtAuthFilter());
shiroFilterFactoryBean.setFilters(filterMap);
shiroFilterFactoryBean.setSecurityManager(securityManager);
Map<String, String> filterRuleMap = new LinkedHashMap<>();
// 获取到不需要鉴权的路径
if(authProperties.getIgnores() != null ){
List<String> ignores = Arrays.asList(authProperties.getIgnores());
if(CollectionUtils.isNotEmpty(ignores)){
ignores.forEach(e->filterRuleMap.put(e, ANON));
}
}
// 过滤链定义,从上向下顺序执行,jwt过滤器放在最下边
filterRuleMap.put(ALL_PATH_KEY, JWT);
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterRuleMap);
return shiroFilterFactoryBean;
}
@Bean(name = "securityManager")
public DefaultWebSecurityManager getDefaultSecurityManager(ShiroRealm shiroRealm) {
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);
return securityManager;
}
@Bean
@DependsOn("lifecycleBeanPostProcessor")
public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
// 强制使用cglib,防止重复代理和可能引起代理出错的问题
defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
return defaultAdvisorAutoProxyCreator;
}
@Bean
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor();
}
/**
* 开启Shiro注解模式,可以在Controller中的方法上添加注解
* @param securityManager
* @return
*/
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(DefaultWebSecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
advisor.setSecurityManager(securityManager);
return advisor;
}
@Bean
public ShiroRealm getShiroRealm() {
return new ShiroRealm();
}
/**
* jwt工具类
* @param authProperties
* @return
*/
@Bean
public JwtUtil getJwtUtil(AuthProperties authProperties) {
return new JwtUtil(authProperties);
}
}
核心校验类
public class ShiroRealm extends AuthorizingRealm {
@Override
public boolean supports(AuthenticationToken token) {
// 表示此Realm只支持JWTToken类型
return token instanceof JwtToken;
}
/**
* 默认使用此方法进行用户正确与否验证,错误抛出异常即可
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws UnauthorizedException {
String token = auth.getCredentials().toString();
// 开始认证,要AccessToken认证通过
if (StringUtils.isNotBlank(RedisUtil.getStr(token)) && JwtUtil.verify(RedisUtil.getStr(token))) {
return new SimpleAuthenticationInfo(token, token, this.getClass().getName());
}
throw new AuthenticationException("Token验证失败(Token expired or incorrect.)");
}
/**
* 此方法调用 hasRole,hasPermission的时候才会进行回调.
*
* 权限信息.(授权):
* 1、如果用户正常退出,缓存自动清空;
* 2、如果用户非正常退出,缓存自动清空;
* 3、如果我们修改了用户的权限,而用户不退出系统,修改的权限无法立即生效。
* (需要手动编程进行实现;放在service进行调用)
* 在权限修改后调用realm中的方法,realm已经由spring管理,所以从spring中获取realm实例,
* 调用clearCached方法;
* :Authorization 是授权访问控制,用于对用户进行的操作授权,证明该用户是否允许进行当前操作,如访问某个链接,某个资源文件等。
* @param principals
* @return
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
String accessToken = principals.toString();
SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
Map<String, Claim> claims = JwtUtil.getClaims(accessToken);
if(MapUtils.isEmpty(claims)){
throw new UnauthorizedException("Token验证失败(Token expired or incorrect.)");
}
// 解析角色和权限
List<String> roles = claims.get(JwtConstant.JWT_ROLES_KEY).asList(String.class);
List<String> permissions = claims.get(JwtConstant.JWT_PERMISSIONS_KEY).asList(String.class);
simpleAuthorizationInfo.addRoles(roles);
simpleAuthorizationInfo.addStringPermissions(permissions);
return simpleAuthorizationInfo;
}
}
核心过滤器
代码的执行流程:preHandle->isAccessAllowed->isLoginAttempt->executeLogin
@Component
@Slf4j
public class JwtAuthFilter extends BasicHttpAuthenticationFilter {
@Autowired
private AuthProperties authProperties;
/**
* 判断用户是否想要登入
* 检测header里面是否包含token即可
*/
@Override
protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
if(authProperties == null ){
authProperties = SpringContextHolder.getBean("authProperties",AuthProperties.class);
}
HttpServletRequest req = (HttpServletRequest) request;
String jwtToken = req.getHeader(authProperties.getJwt().getHeader());
return StringUtils.isNotEmpty(jwtToken);
}
@Override
protected boolean executeLogin(ServletRequest request, ServletResponse response){
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String accessToken = httpServletRequest.getHeader(authProperties.getJwt().getHeader());
JwtToken jwtToken = new JwtToken(accessToken);
// 提交给realm进行登入,如果错误他会抛出异常并被捕获
getSubject(request, response).login(jwtToken);
// 如果没有抛出异常则代表登入成功,返回true
return true;
}
/**
* 这里我们详细说明下为什么最终返回的都是true,即允许访问 例如我们提供一个地址 GET /article 登入用户和游客看到的内容是不同的
* 如果在这里返回了false,请求会被直接拦截,用户看不到任何东西 所以我们在这里返回true,Controller中可以通过
* subject.isAuthenticated() 来判断用户是否登入
* 如果有些资源只有登入用户才能访问,我们只需要在方法上面加上 @RequiresAuthentication 注解即可
* 但是这样做有一个缺点,就是不能够对GET,POST等请求进行分别过滤鉴权(因为我们重写了官方的方法),但实际上对应用影响不大
*/
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
// 查看当前Header中是否携带Authorization属性(Token),有的话就进行登录认证授权
if (this.isLoginAttempt(request, response)) {
try {
// 进行Shiro的登录Realm
return this.executeLogin(request, response);
} catch (Exception e) {
// 认证出现异常,传递错误信息msg
String msg = e.getMessage();
// 获取应用异常(该Cause是导致抛出此throwable(异常)的throwable(异常))
Throwable throwable = e.getCause();
if (throwable instanceof SignatureVerificationException) {
// 该异常为JWT的AccessToken认证失败(Token或者密钥不正确)
msg = "Token或者密钥不正确(" + throwable.getMessage() + ")";
} else if (throwable instanceof TokenExpiredException) {
// 该异常为JWT的AccessToken已过期,判断RefreshToken未过期就进行AccessToken刷新
HttpServletRequest req = (HttpServletRequest) request;
String jwtToken = req.getHeader(authProperties.getJwt().getHeader());
if (RedisUtil.existStrAny(jwtToken)) {
log.info("Token自动续期");
JwtUtil.reNewToken(jwtToken);
// 进行Shiro的登录Realm
return this.executeLogin(request, response);
}else {
msg = "Token已过期(" + throwable.getMessage() + ")";
}
} else {
// 应用异常不为空
if (throwable != null) {
// 获取应用异常msg
msg = throwable.getMessage();
}
}
// Token认证失败直接返回Response信息
this.response401(response, msg);
return false;
}
} else {
// 没有携带Token
HttpServletRequest httpServletRequest = WebUtils.toHttp(request);
// 如果是feign调用不行鉴权
String tokenIgnoreFlag = httpServletRequest.getHeader(HeaderConstant.HEADER_TOKEN_IGNORE);
if(HeaderConstant.TOKEN_IGNORE_FLAG.equals(tokenIgnoreFlag)){
return true;
}
// 获取当前请求类型
String httpMethod = httpServletRequest.getMethod();
// 获取当前请求URI
String requestURI = httpServletRequest.getRequestURI();
log.info("当前请求 {} Authorization属性(Token)为空 请求类型 {}", requestURI, httpMethod);
this.response401(response, "请先登录");
return false;
}
}
@Override
protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin"));
httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers"));
// 跨域时会首先发送一个option请求,这里我们给option请求直接返回正常状态
if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
httpServletResponse.setStatus(HttpStatus.OK.value());
return false;
}
return super.preHandle(request, response);
}
/**
* 无需转发,直接返回Response信息
*/
private void response401(ServletResponse response, String msg) {
HttpServletResponse httpServletResponse = WebUtils.toHttp(response);
httpServletResponse.setStatus(HttpStatus.UNAUTHORIZED.value());
httpServletResponse.setCharacterEncoding("UTF-8");
httpServletResponse.setContentType("application/json; charset=utf-8");
try (PrintWriter out = httpServletResponse.getWriter()) {
String data = JsonConvertUtil.objectToJson( BaseResponse.fail(ResponseEnum.UNAUTHORIZED, "无权访问(Unauthorized):" + msg));
out.append(data);
} catch (IOException e) {
log.error("直接返回Response信息出现IOException异常:{}", e.getMessage());
throw ResponseEnum.INTERNAL_SERVER_ERROR.newException("直接返回Response信息出现IOException异常");
}
}
}
Token续期
Token续期逻辑,代码中均已实现,此处梳理下逻辑:
1、配置token过期时间、续期时间
2、登录生成token时,设置过期时间为配置的token过期时间,并以生成的 token为key和value设置到redis,同时设置过期时间为配置的过期时间+续期时间。
3、请求操作校验token时,捕获过期异常TokenExpiredException,校验redis中是否还存在当前token,若存在则续期,生成新的token,并以原token为key,新token为value,过期时间为配置的过期时间+续期时间,重新设置到redis。
登录Demo
生成JWT时需要获取roles、permissons等信息,如果不设置到JWT,需要每次校验权限的时候去查询数据库,此处直接将数据设置到JWT,使用时直接解析即可。
以下是简单逻辑,具体值需要自己根据业务获取。
public JwtToken login(String account, String password) {
// 业务逻辑....
// 生成token
JwtClaim jwtClaim = new JwtClaim();
jwtClaim.setSubject(user.getId().toString());
jwtClaim.setUserName(user.getName());
jwtClaim.setAccount(user.getAccount());
jwtClaim.setRoles(roleCodeArray);
jwtClaim.setPermissions(permissionArray);
jwtClaim.setTenantId(user.getTenantId());
String accessToken = JwtUtil.generateToken(jwtClaim);
JwtToken jwtUser = new JwtToken(accessToken,user.getId(),user.getName());
return jwtUser;
}
测试返回结果如下:
结语
至此,整合结束。如有错误之处,欢迎指正。
代码摘自本人的开源项目cloud-plus:https://blog.csdn.net/HXNLYW/article/details/104635673