一.背景
公司之前的鉴权都是自研的,并未引入shiro
或者spring security oauth2
等成熟的开源框架。主项目使用的鉴权方式是利用session
来鉴权,这样的好处是,在requestHeader
中,用户不能直接获取到token
或者相关信息,但是对于权限续期和采用分布式或微服务方案后的鉴权一直都处理不好,于是需要对权限模块进行重做。
二. 选型
目前市面上最流行的两个开源鉴权框架,shiro
和spring security oauth2
我都有使用过,不过两者都有各自不好的地方。
spring security oauth2
:
- 细粒度鉴权方面没有
shiro
好,其粒度到role
,不能到permission
,如果要做的更好需要扩展 - 验证码登录、微信小程序登录等实现起来较为麻烦,具体方案可参见另外一篇文章: spring security Oauth2验证码等多方式登录
- 需要单独部署一个
server
服务,相对而言更浪费服务器资源 - 配置很反锁,重量级
shiro
:
- 第三方鉴权需要自己去实现
- 对oauth2协议不支持,有些功能需要自己实现
- 不支持token的刷新机制
由于以上原因,决定基于两者实现一个简单易用的鉴权流程满足需求。
二.方案
1.请求拦截
通过Interceptor
拦截器来拦截所有请求,拦截器配置如下
/**
*mvc配置
* @program: Mr1ght
*/
@Slf4j
@Configuration
public class MvcConfiguration {
@Bean
public WebMvcConfigurer webMvcConfigurer(RedisOptUtils redisOptUtils, RedisUtils<Object> redisUtils) {
return new WebMvcConfigurer() {
@Override
public void addInterceptors(InterceptorRegistry registry) {
// jwt拦截器
registry
.addInterceptor(new JwtSignInterceptor())
.addPathPatterns("/**")
.excludePathPatterns("/swagger-resources/**", "/webjars/**", "/v2/**", "/doc.html", "/error"
, "/static/**" //静态资源
)
;
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/static/**").addResourceLocations("classpath:/static/");
}
};
}
}
2.鉴权
2.1 token续期
取消掉token本身的过期时间,而采用redis
的key
过期策略来替代。将token
存入redis
时,设置token
在redis
中的过期时间,用户每次访问接口,被拦截器拦截到后,都重置redis
的key
过期时长,这样就保证了用户在经常访问的时候,能够永不退出,一直保持登录状态。也能保证用户在很久不登录之后,让用户重新登录以保证用户的账号安全
2.2 无需登录接口
因为部分接口是不需要登录的,因此定义了注解FreePassage
,将此注解写到类上,则这个类下的所有接口都不会进行权限校验,如果将此注解写到接口上,则对应接口无需权限即可访问
import java.lang.annotation.*;
/**
* 免登录
*/
@Target(value = {ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface FreePassage {
}
2.3 用户密码修改toke失效
缓存用户信息的时候缓存用户密码,利用用户密码作为token的秘钥进行加密解密,这样保证用户修改密码后,能让其他token
失效。同时也能保证用户的token
的加解密秘钥有一定保密性,保障用户的账户安全。
JwtUtil部分代码
/**
* 校验token是否正确
*
* @param token 密钥
* @param userId 用户id
* @param secret 用户的密码
* @return token校验是否通过
*/
public static boolean verify(String token, String userId, String secret) {
try {
Algorithm algorithm = Algorithm.HMAC256(secret);
JWTVerifier verifier = JWT.require(algorithm)
.withClaim("userId", userId)
.build();
verifier.verify(token);
return true;
} catch (Exception e) {
log.warn(String.format("token校验失败,token:%s,userId:%s,secret:%S",token,userId,secret),e);
//throw new com.auth0.jwt.exceptions.TokenExpiredException("token过期");
return false;
}
}
/**
* 生成token
*
* @param userId 用户id
* @param secret 用户的密码
* @return 加密的token
*/
public static String sign(String userId, String secret) {
try {
Algorithm algorithm = Algorithm.HMAC256(secret);
// 附带username信息
return JWT.create()
.withClaim("userId", userId)
.sign(algorithm);
} catch (UnsupportedEncodingException e) {
log.warn(String.format("生成token异常,userId:%s,secret:%S",userId,secret),e);
return null;
}
}
以下是拦截器中鉴权的核心代码。
/**
* jwt签名拦截器
* @author: Mr1ght
*/
@Slf4j
public class JwtSignInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
//1.是否免登录
if (!(handler instanceof HandlerMethod) || this.checkFreePassage((HandlerMethod) handler)) {
return true;
}
//从redis中获取用户信息(此处redisOptUtils的代码就不贴出来了)
String token = request.getHeader(TokenConstant.TOKEN_HEADER);
SystemUserInfo userInfo = redisOptUtils.getUserFromRedisById(userId);
String password = userInfo.getPassword();
String userId = userInfo.getUserId();
// jwt校验
boolean verify = JwtUtil.verify(token,userId,password);
if(!verify){
log.warn("token校验不通过,token:{}", token);
throw new BusinessException(PermissionCodeEnum.INVALID_ID);
}
//确认token是否已经过期;检查token在redis中是否存在
String cacheToken = redisOptUtils.getToken(token);
if (StringUtils.isBlank(cacheToken)) {
log.warn("token在redis中不存在,检查是否过期,token:{}", token);
throw new BusinessException(PermissionCodeEnum.TOKEN_OVERDUE);
}
//token续期(此处redisUtils和sessionConf的代码就不贴出来了)
String redisKey = redisOptUtils.getTokenKey(token);
redisUtils.expire(redisKey, sessionConf.getRefreshExpiration(), TimeUnit.DAYS);
return true;
}
/**
*校验是否免登录
*/
private boolean checkFreePassage(HandlerMethod hm) {
Class<?> beanType = hm.getBeanType();
FreePassage freePassage = beanType.getAnnotation(FreePassage.class);
if (freePassage != null) {
return true;
}
Method method = hm.getMethod();
freePassage = method.getAnnotation(FreePassage.class);
return freePassage != null;
}
}
4.细粒度鉴权
细粒度鉴权的方案借鉴shiro的细粒度权限校验方式,采用shiro的权限表:user、role、user_role、menu、menu_role等表,表的详细字段和介绍请参考shiro
。
/**
* 自定义注解,实现权限拦截
*
* @author Mr1ght
*/
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface RequiresPermissions {
PermissionEnum[] value();
AuthConditionEnum condition() default AuthConditionEnum.ANY;
}
/**
* 细粒度权限校验
*
* @author Mr1ght
*/
@Aspect
@Component
public class AuthAspect {
/**
* 权限校验
*
* @param point 切点
* @return Object
* @throws Throwable 没有权限的异常
*/
@Around("@annotation(requiresPermissions)")
public Object preAuth(ProceedingJoinPoint point, RequiresPermissions requiresPermissions) {
if (!hasPermission(point, requiresPermissions)) {
throw new BusinessException(PermissionCodeEnum.NO_PERMISSION);
}
return point.proceed();
}
/**
* 校验用户是否有权限
*
* @param point
* @param requiresPermissions
* @return
*/
private Boolean hasPermission(ProceedingJoinPoint point, RequiresPermissions requiresPermissions) {
// 权限检查
if (requiresPermissions == null) {
return true;
}
// 接口要求权限
PermissionEnum[] requirePermissions = requiresPermissions.value();
AuthConditionEnum condition = requiresPermissions.condition();
if (requirePermissions.length == 0) {
return true;
}
HttpServletRequest request = WebUtil.getRequest();
assert request != null;
String token = request.getHeader(TokenConstant.TOKEN_HEADER);
String userId = JwtUtil.getId(token);
//查询用户权限
String redisKey = redisUtils.genKey(RedisConstant.USERINFO_REDIS_PREFIX, userId);
SystemUserInfo userInfo = (SystemUserInfo) redisUtils.get(redisKey);
Set<String> permissions = userInfo.getPermissions();
if (CollectionUtils.isEmpty(permissions)) {
throw new BusinessException(PermissionCodeEnum.NO_PERMISSION);
}
// 判断有无权限
switch (condition) {
case ANY:
return this.checkAnyAuth(permissions,requirePermissions);
case AND:
for (PermissionEnum permission : requirePermissions) {
if (!permissions.contains(permission.getCode())) {
throw new BusinessException(PermissionCodeEnum.NO_PERMISSION);
}
}
return true;
default:
return this.checkAnyAuth(permissions, requirePermissions);
}
}
private boolean checkAnyAuth(Set<String> permissions, PermissionEnum[] requirePermissions){
for (PermissionEnum permission : requirePermissions) {
if (permissions.contains(permission.getCode())) {
return true;
}
}
throw new BusinessException(PermissionCodeEnum.NO_PERMISSION);
}
}
5.第三方鉴权
第三方鉴权也是使用HandlerInterceptor
拦截器,对指定url进行拦截,url的参数加解密类似于调用阿里或微信的支付接口时的加密方式,给对应第三方颁发appId
、和appSecret
,第三方通过拼接参数后利用指定加密算法进行加密访问
/**
* 第三方鉴权
*/
@Slf4j
@Component
public class ThirdPartInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String appId = request.getParameter("appId");
if(StringUtils.isBlank(appId)){
throw new BusinessException(PermissionCodeEnum.NO_ACCESS_CONFIGURATION_FOUND);
}
SysAccessConfigPo sysAccessConfig = redisOptUtils.getSysAccessConfigByAppId(appId);
if (sysAccessConfig == null) {
throw new BusinessException(PermissionCodeEnum.NO_ACCESS_CONFIGURATION_FOUND);
}
String timestamp = request.getHeader("timestamp");
String sign = request.getHeader("sign");
if(StringUtils.isBlank(timestamp)){
throw new BusinessException(PermissionCodeEnum.WRONG_ACCESS_CONF);
}
if(StringUtils.isBlank(sign)){
throw new BusinessException(PermissionCodeEnum.EXCEPTION_ID);
}
//校验签名
String queryString = URLDecoder.decode(request.getQueryString(), "utf-8");
SignUtil.verifySign(queryString, Long.parseLong(timestamp), sysAccessConfig.getAppKey(), sign);
return true;
}
}
三.总结
这个鉴权方案是在使用spring security oauth2
和shiro
之后,总结了两者的优缺点,做的一个精简版,基本满足了公司目前的鉴权需求。