Spring Security 匿名接口与IP白名单

本文最后更新于:2024年5月5日 晚上

最近在公司开发公司的一个系统时,需要开放一些接口提供给其他系统进行调用,考虑到这些开放接口的安全性问题,所以需要做一些限制来让这些接口即使被开放也能更安全的被其他系统调用,所以才有了这篇文章。
目前的想法是,限制固定的 IP 列表对应固定的开放接口列表允许访问,但是其他非开放的接口,也就是要授权的,是不允许这些固定 IP 列表访问。

一、access()hasIpAddress() 实现

hasIpAddress :这个方法传入指定的 IP 地址或网段,和 antMatchers/mvcMatchers/anyRequest 一起使用,如果是网段,那么任意一个属于该网段内的 IP 都可以访问的到,如果是 IP ,那么则要完全匹配相同的 IP 地址。
查看源码是如下,生成了一个字符串,不过最终会调用 IpAddressMatcher.matches 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// ExpressionUrlAuthorizationConfigurer.hasIpAddress 
private static String hasIpAddress(String ipAddressExpression) {
return "hasIpAddress('" + ipAddressExpression + "')";
}

// IpAddressMatcher.matches
public boolean matches(String address) {
InetAddress remoteAddress = parseAddress(address);
if (!this.requiredAddress.getClass().equals(remoteAddress.getClass())) {
return false;
}
if (this.nMaskBits < 0) {
return remoteAddress.equals(this.requiredAddress);
}
byte[] remAddr = remoteAddress.getAddress();
byte[] reqAddr = this.requiredAddress.getAddress();
int nMaskFullBytes = this.nMaskBits / 8;
byte finalByte = (byte) (0xFF00 >> (this.nMaskBits & 0x07));
for (int i = 0; i < nMaskFullBytes; i++) {
if (remAddr[i] != reqAddr[i]) {
return false;
}
}
if (finalByte != 0) {
return (remAddr[nMaskFullBytes] & finalByte) == (reqAddr[nMaskFullBytes] & finalByte);
}
return true;
}

access 方法:和 antMatchers/mvcMatchers/anyRequest 一起使用,这个方法传入一个或多个表达式字符串,进行授权或是其他处理。下面是一些示例:

1
2
3
httpSecurity
.antMatchers()
.access('hasIpAddress('192.168.1.100'))

下面是具体的代码实现:
application.yml 中编写 ip 白名单配置属性。

1
2
3
4
5
web:
ip-whitelist: 10.10.10.10, 130.10.10.10
permit-api:
- /hello
- /test

创建响应的配置类,映射到配置文件中的配置属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Data
@Component
@ConfigurationProperties(prefix = "gather")
public class WebConfig {

private String permitIp;

private List<String> permitApi;

public void setIpWhiteList(String permitIp) {
if (StringUtils.isEmpty(permitIp)) {
return;
}

String[] ipAddresses = permitIp.split(",");
StringBuilder sb = new StringBuilder();
for (String ipAddress : ipAddresses) {
sb.append("hasIpAddress('").append(ipAddress.trim()).append("') or "); // 拼接每个 IP 地址的条件
}

String ipAddressConditions = sb.toString();
ipAddressConditions = ipAddressConditions.substring(0, ipAddressConditions.lastIndexOf(" or "));
this.permitIp = ipAddressConditions;
}
}

下面是 SecurityConfig 的配置,这里为什么不在 antMatchers() 方法后面接 hasIpAddress() 方法呢,是因为 hasIpAddress() 之限制了一个 IP 地址,就算是用网段也只限制了一个网段,那么这样肯定是不行的,所以使用 access() 方法可以构造出多个 hasIpAddress() 表达式更合适。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter
{
@Autowird
private WebConfig webconfig;


@Override
protected void configure(HttpSecurity httpSecurity) throws Exception
{
// 注解标记允许匿名访问的url
ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry = httpSecurity.authorizeRequests();
permitAllUrl.getUrls().forEach(url -> registry.antMatchers(url).permitAll());
httpSecurity
.csrf().disable()
.headers().cacheControl().disable().and()
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
.authorizeRequests()
//
.antMatchers(webConfig.getPermitApi().toArray(new String[0]))
.access(webConfig.getPermitIp())
.anyRequest().authenticated()
// 除上面外的所有请求全部需要鉴权认证
.and()
.headers().frameOptions().disable();
}

这样配置之后,在配置文件写上填写好 IP 地址列表和开放接口列表

二、自定义 Access 实现

通过研究 Access 的源码后,发现可以通过自定义 Access 来进行开放接口的安全性控制。
还是使用上面的 WebConfig 配置类来从配置文件中读取要开放的接口列表和 IP 白名单,首先要进行改造 WebConfig 配置类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Data
@Component
@ConfigurationProperties(prefix = "gather")
public class WebConfig {

private String permitIp;

private List<String> ipWhitelist;

private List<String> permitApi;

public void setIpWhiteList(String permitIp) {
if (StringUtils.isEmpty(permitIp)) {
return;
}

String[] ipAddresses = permitIp.split(",");

ipWhitelist = new ArrayList<>(Arrays.asList(ipAddresses));

StringBuilder sb = new StringBuilder();
for (String ipAddress : ipAddresses) {
sb.append("hasIpAddress('").append(ipAddress.trim()).append("') or "); // 拼接每个 IP 地址的条件
}

String ipAddressConditions = sb.toString();
ipAddressConditions = ipAddressConditions.substring(0, ipAddressConditions.lastIndexOf(" or "));
this.permitIp = ipAddressConditions;
}
}

下面是一个自定义的 Access 类,首先对已经登录(有 token )的请求时直接放行的,然后接下来是校验开发接口列表和 IP 白名单列表是否符合条件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Component
public class AnonymousAccessService {

@Resource
private GatherConfig gatherConfig;

public Boolean hasAccess(HttpServletRequest request, Authentication authentication) {
Object details = authentication.getDetails();
if (details instanceof UserDetails) {
return true;
}

String permitIp = gatherConfig.getPermitIp();
String[] ipAddresses = permitIp.split(",");
List<String> ipWhitlelist = new ArrayList<>(Arrays.asList(ipAddresses));
String path = request.getRequestURI();
String ip = request.getRemoteAddr();
if (ipWhitlelist.contains(ip) && gatherConfig.getPermitApi().contains(path)) {
return true;
} else {
return false;
}
}
}

然后在 Spring Security 配置类中按如下配置;这个配置要放到所有配置的最后面,因为其他的要先放行,这个配置要成为最后一道屏障。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter
{
@Autowird
private WebConfig webconfig;


@Override
protected void configure(HttpSecurity httpSecurity) throws Exception
{
// 注解标记允许匿名访问的url
ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry = httpSecurity.authorizeRequests();
permitAllUrl.getUrls().forEach(url -> registry.antMatchers(url).permitAll());
httpSecurity
.csrf().disable()
.headers().cacheControl().disable().and()
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
.authorizeRequests()
//... 其他配置
.anyRequest()
.access("@AnonymousAccessService.hasAccess(request, authentication)")
// .antMatchers(webConfig.getPermitApi().toArray(new String[0]))
// .access(webConfig.getIpWhitelist())
// .anyRequest().authenticated()
.and()
.headers().frameOptions().disable();
}

三、自定义 AccessDecisionManager 实现

AccessDecisionManagerSpring Security 中用于访问控制的管理器,其本身有三个实现类。
AffirmativeBased :默认机制,采取遇到就通过的策略,AccessDecisionVoter 来判断是否通过、判断依据为相关访问配置,只要有一个 AccessDecisionVoter 通过即可拥有访问权限;需要注意:AccessDecisionVoter 是有多个的。
ConsensusBased :采取大多数策略,AccessDecisionVoter 判断、并记录通过和拒绝的票数,哪边票数多就采取哪边,若两边票数一样,则需要判断额外判断条件。
UnanimousBased :采取绝对原则策略,遍历所有 AccessDecisionVoter,至少需要1票通过,若遇到1票拒绝即抛出异常 AccessDeniedException
下面是自定义 AccessDecisionManager,其细节不包含投票的操作,只实现对开放接口和 IP 白名单的校验。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@Component
public class AnonymousDecisionManager implements AccessDecisionManager {

@Resource
private GatherConfig gatherConfig;


@Override
public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException, InsufficientAuthenticationException {
FilterInvocation filterInvocation = (FilterInvocation) object;
AntPathMatcher antPathMatcher = new AntPathMatcher();
String finalPath = null;

// 从配置文件中匹配到要放行的路径
for (String path : gatherConfig.getPermitApi()) {
if (antPathMatcher.match(path, filterInvocation.getRequest().getRequestURI())) {
finalPath = path;
}
}

// 如果存在要放行的路径,那么做进一步操作
if (!StringUtils.isEmpty(finalPath)) {
if (!CollectionUtils.isEmpty(gatherConfig.getIpWhitelist()) && !gatherConfig.getIpWhitelist().contains(IpUtils.getIpAddr(filterInvocation.getRequest()))) {
if (authentication.getPrincipal().equals("anonymousUser")) {
throw new AccessDeniedException("认证失败");
}
}
}
}

@Override
public boolean supports(ConfigAttribute attribute) {
return true;
}

@Override
public boolean supports(Class<?> clazz) {
return true;
}
}

然后在 Spring Security 配置类中配置自定义 AccessDecisionManager

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter
{
@Autowird
private AnonymousDecisionManager anonymousDecisionManager;


@Override
protected void configure(HttpSecurity httpSecurity) throws Exception
{
// 注解标记允许匿名访问的url
ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry = httpSecurity.authorizeRequests();
permitAllUrl.getUrls().forEach(url -> registry.antMatchers(url).permitAll());
httpSecurity
.csrf().disable()
.headers().cacheControl().disable().and()
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
.authorizeRequests()
//... 其他配置
.accessDecisionManager(anonymousDecisionManager)
.anyRequest()
.authenticated()
.and()
.headers().frameOptions().disable();
}

四、总结

以上就是通过 Spring Security 一些内置的功能对匿名接口和 IP 白名单的实现,在研究这个功能的时候对于 Spring Secutity 也有了更深入的理解;当然上面的代码还是有些不足,如果有想动态的设置,那么只能修改配置,然后再重新系统服务,这样如果是一些比较重要的服务那可不会轻易的进行重启,所以后续我会继续完善这篇文章和这个功能,让这个功能更加完美。


Spring Security 匿名接口与IP白名单
http://aim467.github.io/2024/05/05/Spring-Security-匿名接口与IP白名单/
作者
Dedsec2z
发布于
2024年5月5日
更新于
2024年5月5日
许可协议