项目中有一批提供给另外的应用调用的开放接口,起初只是简单的对接口放行,任意的应用和接口调用工具都能进行调用。由于考虑安全的原因,需要优化成,动态的自定义的对接口进行IP白名单和黑名单限制,或者接口需要指定的请求头才能访问。
一、AccessDecisionManager是什么?
顾名思义,我们可以把它理解为访问决策管理器,这个接口非常简单,里面只定义了三个方法,我们只需要关注decide方法,如果decide抛出了AccessDeniedException
异常则拒绝接口访问
public interface AccessDecisionManager {
void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes)
throws AccessDeniedException, InsufficientAuthenticationException;
boolean supports(ConfigAttribute attribute);
boolean supports(Class<?> clazz);
}
而AccessDecisionManager
接口由AbstractAccessDecisionManager
抽象类来实现,但是decide、supports方法并不在该抽象类里实现,而是由继承了AbstractAccessDecisionManager抽象类的AffirmativeBased
、ConsensusBased
、UnanimousBased
来实现。这三个具体的实现类的区别就是具体拒绝策略不同,直接看源码就很直观,或者参考这篇文章三个授权决策的区别。其中AbstractAccessDecisionManager抽象类里维护着一个AccessDecisionVoter的实现类数组。部分代码如下
public abstract class AbstractAccessDecisionManager
implements AccessDecisionManager, InitializingBean, MessageSourceAware {
private List<AccessDecisionVoter<?>> decisionVoters;
protected AbstractAccessDecisionManager(List<AccessDecisionVoter<?>> decisionVoters) {
Assert.notEmpty(decisionVoters, "A list of AccessDecisionVoters is required");
this.decisionVoters = decisionVoters;
}
}
而我们需要真正去实现自定义路由鉴权的就是实现AccessDecisionVoter
接口。然而AccessDecisionVoter接口也非常简单
public interface AccessDecisionVoter<S> {
int ACCESS_GRANTED = 1;
int ACCESS_ABSTAIN = 0;
int ACCESS_DENIED = -1;
boolean supports(ConfigAttribute attribute);
int vote(Authentication authentication, S object, Collection<ConfigAttribute> attributes);
}
可见,接口中的三个静态变量分别表示已授权,弃权和拒绝。然后在自定义的AccessDecisionVoter实现类中,重写vote方法,根据自定义的权限返回对应的静态常量即可。
二、自定义动态路由鉴权代码实现
- 首先为了能在不重启项目就能做到动态自定义配置路由权限,需要将配置写在数据库,然后利用Redis缓存路由配置。这样每次请求就在Redis读取配置信息,然后根据配置信息校验请求的权限。
数据库表如下图所示
建表语句如下:
CREATE TABLE `your_table_name` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键',
`path` varchar(255) DEFAULT NULL COMMENT '访问路径',
`ip_whitelist` varchar(1024) DEFAULT NULL COMMENT 'ip白名单 多个ip则使用逗号分隔',
`ip_blacklist` varchar(1024) DEFAULT NULL COMMENT 'ip黑名单 多个ip则使用逗号分隔',
`request_method` varchar(255) DEFAULT NULL COMMENT '请求方式',
`header_key` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '指定请求头的key',
`header_value` varchar(255) DEFAULT NULL COMMENT '指定请求头的value',
`include_whitelist` tinyint(1) DEFAULT '0' COMMENT '收录白名单ip开关 0-关,1-开 用作收录初始白名单,不建议长时间开启 ',
`status` tinyint(1) DEFAULT '1' COMMENT '状态 0-关 1-开 关闭时该配置不生效',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
- 在项目中找到spring security配置或者新建配置,如下
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private RoutingSecurityHandler routingSecurityHandler;
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
// 自定义动态配置开放接口权限
httpSecurity.accessDecisionManager(accessDecisionManager())
// 这里必定还有其他配置 只是不在本章讨论中,不展示无关本章配置
// ...
}
@Bean
public AccessDecisionManager accessDecisionManager() {
List<AccessDecisionVoter<?>> decisionVoters
= Arrays.asList(
//自定义动态路由控制,
new AnonymousAccessVoter(routingSecurityHandler),
new WebExpressionVoter(),//这个是spring security自带的投票器
new AuthenticatedVoter()//这个是spring security自带的投票器
);
return new AffirmativeBased(decisionVoters);
}
}
- 重中之重的实现就是这里的实现。实现AnonymousAccessVoter
public class AnonymousAccessVoter implements AccessDecisionVoter<Object> {
private final RoutingSecurityHandler routingSecurityHandler;
public AnonymousAccessVoter(RoutingSecurityHandler routingSecurityHandler) {
this.routingSecurityHandler = routingSecurityHandler;
}
@Override
public boolean supports(ConfigAttribute attribute) {
return true;
}
/**
* 1.获取需要校验的路径(开放接口、自定义配置接口)
* 2.判断请求路径是否在需要自定义校验的路径中
* 3.根据情况投票
* 3.1需要自定义校验但不符合条件 则抛异常拒绝访问
* 3.2需要自定义校验且符合条件,则投通过票
* 3.3不需要自定义校验,则投弃权票,则走正常的Authentication认证和WebExpressionVoter认证
*/
@Override
public int vote(Authentication authentication, Object object, Collection<ConfigAttribute> attributes) {
if (authentication == null) {
return ACCESS_DENIED;
}
//自定义鉴权主要的实现
int vote = routingSecurityHandler.verifyRouting();
if (vote == 1) {
return ACCESS_GRANTED;
} else if (vote == -1) {
throw new AccessDeniedException("该请求无权限,拒绝访问");
}
return ACCESS_ABSTAIN;
}
@Override
public boolean supports(Class clazz) {
return true;
}
}
然而具体的实现在 int vote = routingSecurityHandler.verifyRouting();这一行代码
@Component
public class RoutingSecurityHandler {
private final SysSecurityConfigService sysSecurityConfigService;
private final RedisCache redisCache;
private final ThreadPoolTaskExecutor threadPoolTaskExecutor;
private final AntPathMatcher antPathMatcher;
public RoutingSecurityHandler(SysSecurityConfigService sysSecurityConfigService, RedisCache redisCache, ThreadPoolTaskExecutor threadPoolTaskExecutor) {
this.sysSecurityConfigService = sysSecurityConfigService;
this.redisCache = redisCache;
this.threadPoolTaskExecutor = threadPoolTaskExecutor;
antPathMatcher = new AntPathMatcher();
}
/**
* 开放接口动态自定义鉴权主要实现
* 1. 从缓存获取自定义路由列表
* 2. 判断请求路径是否需要自定义鉴权
* 3. 判断需要自定义鉴权的路由是否满足权限要求
* 4. 根据权限返回投票
* @return 1、赞成票 -1、反对票 0、弃权票
*/
public int verifyRouting() {
ServletRequestAttributes sra = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (sra == null) {
return 0;
}
HttpServletRequest request = sra.getRequest();
String ip = IpUtils.getIpAddr(request);
String method = request.getMethod();
String uri = request.getRequestURI();
// 1. 从缓存获取自定义路由列表
SysSecurityConfig securityConfig = null;
List<SysSecurityConfig> routingList = getRoutingList();
// 2. 判断请求路径是否需要自定义鉴权
for (SysSecurityConfig sysSecurityConfig : routingList) {
if (antPathMatcher.match(sysSecurityConfig.getPath(), uri)) {
securityConfig = sysSecurityConfig;
break;
}
}
//需要自定义鉴权则判断是否满足权限
if (securityConfig != null) {
// 如果自定义路径 开启初始化白名单 则默认所有请求都返回赞成票并且把IP加入到白名单
if (securityConfig.getIncludeWhitelist() == 1) {
Integer id = securityConfig.getId();
threadPoolTaskExecutor.execute(() -> sysSecurityConfigService.updateSecurityConfig(id, ip, method));
return 1;
}
// 请求IP若在黑名单中,则返回反对票
List<String> list;
if (!CollectionUtils.isEmpty((list = securityConfig.getIpBlacklist()))) {
for (String blacklistIp : list) {
if (ip.matches(blacklistIp)) {
return -1;
}
}
}
// 若请求方式不满足权限,则返回反对票
if (StringUtils.isNotEmpty(securityConfig.getRequestMethod())
&& !method.equalsIgnoreCase(securityConfig.getRequestMethod())) {
return -1;
}
// 判断请求头是否满足权限,返回相应投票
if (StringUtils.isNotEmpty(securityConfig.getHeaderKey())) {
String headerValue = request.getHeader(securityConfig.getHeaderKey());
if (StringUtils.isEmpty(headerValue) || !headerValue.equals(securityConfig.getHeaderValue())) {
return -1;
} else {
return 1;
}
}
// 若请求IP在白名单中,则返回赞成票
if (!CollectionUtils.isEmpty((list = securityConfig.getIpWhitelist()))) {
for (String whitelistIp : list) {
if (ip.matches(whitelistIp)) {
return 1;
}
}
}
}
// 不需要鉴权,则投弃权票
return 0;
}
/**
* 从redis获取路由列表,3分钟刷新一次缓存
* 从数据库加载路由表同时将路径、白名单、黑名单根据通配符替换成正则的pattern
*/
private List<SysSecurityConfig> getRoutingList() {
List<SysSecurityConfig> sysSecurityConfigList = redisCache.getCacheList(Constants.SYS_SECURITY_CONFIG);
if (CollectionUtils.isEmpty(sysSecurityConfigList)) {
// 从数据库加载自定义鉴权的路由表
LambdaQueryWrapper<SysSecurityConfig> lqw = Wrappers.lambdaQuery();
lqw.eq(SysSecurityConfig::getStatus, 1);
lqw.isNotNull(SysSecurityConfig::getPath);
sysSecurityConfigList = sysSecurityConfigService.list(lqw);
List<String> list;
for (SysSecurityConfig securityConfig : sysSecurityConfigList) {
// 白名单根据通配符替换成正则的pattern
if (!CollectionUtils.isEmpty((list = securityConfig.getIpWhitelist()))) {
for (int i = 0; i < list.size(); i++) {
list.set(i, list.get(i).replaceAll("\\*", "[0-9]+").replaceAll("\\.", "\\\\."));
}
}
// 将黑名单根据通配符替换成正则的pattern
if (!CollectionUtils.isEmpty((list = securityConfig.getIpBlacklist()))) {
for (int i = 0; i < list.size(); i++) {
list.set(i, list.get(i).replaceAll("\\*", "[0-9]+").replaceAll("\\.", "\\\\."));
}
}
}
redisCache.setCacheList(Constants.SYS_SECURITY_CONFIG, sysSecurityConfigList);
redisCache.expire(Constants.SYS_SECURITY_CONFIG, 180, TimeUnit.SECONDS);
}
return sysSecurityConfigList;
}
}
总结
- 使用AccessDecisionManager实现HttpSecurity的自定义动态路由鉴的主要思路是利用投票机制进行鉴权。即通过自定义实现的AnonymousAccessVoter来鉴权投票,从Redis中读取到的路由权限表配置,进行逻辑判断请求是否符合配置权限要求,从而投出相对应的投票,如果请求是开放的接口,且不符合权限要求直接抛出AccessDeniedException异常即可,若符合权限要求则投已认证票,如果请求不是开放的接口,则投出弃权票,由其他AccessDecisionVoter来投票。
- 如果AnonymousAccessVoter投出了弃权票,相当于该请求不在管辖范围不参与投票,则由其他投票器投票。
- 所有的AccessDecisionVoter投票器投出的票由AccessDecisionManager来管理,其中本章示例所使用的访问决策管理器为AffirmativeBased。即只要有一个投票器投了已认证票则本次请求被视为认证通过,正常访问接口。