Spring Security中自定义认证逻辑(防暴力破解)
背景
项目开发时,需要对服务接口进行防暴力破解的防护,项目中使用的Spring Security没有对放暴力破解的支持,所以需要自己重写Spring Security中的认证逻辑来实现防暴力破解的能力。
软件版本
本次使用的软件版本如下:
Spring Boot 2.6.7 (配套的Spring Security版本是5.6.3)
具体实现
下面是具体的实现案例,先简单梳理下实现方案。
实现方案
- 对于认证失败的用户IP,添加锁定机制,认证失败超过5次进行锁定操作。
- 默认锁定时间为30分钟,超过30分钟解除锁定。
基于servlet的应用和基于webflux的应用实现有点不太一样,这边都整理一下,不过差别也不大。
基于Servlet应用
新增Spring Security配置类,继承WebSecurityConfigurerAdapter适配类,同时需要添加@Configuration,@EnableWebSecurity注解:
@Configuration
@EnableWebSecurity
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {}
重写WebSecurityConfigurerAdapter中configure(HttpSecurity http)方法,这边添加一个failureHandler的处理器,这个处理器可以自定义鉴权失败时的响应,不设置的话,默认是跳转到鉴权失败的页面,这边重写设置响应为json格式:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest()
.authenticated()
.and()
.formLogin()
.failureHandler((request, response, exception) -> {
response.setContentType("application/json;charset=utf-8");
Map<String, Object> map = new HashMap<>();
map.put("message", exception.getMessage());
PrintWriter writer = response.getWriter();
writer.write(new ObjectMapper().writeValueAsString(map));
writer.flush();
writer.close();
})
.and()
.httpBasic();
}
重写WebSecurityConfigurerAdapter中configure(AuthenticationManagerBuilder auth)方法,设置authenticationProvider属性:
@Override
protected void configure(AuthenticationManagerBuilder auth) {
auth.authenticationProvider(new CustomAuthenticationProvider(username, password));
}
authenticationProvider方法中,需要提供一个AuthenticationProvider接口的实现类,该接口有如下几个需要重写的方法:
public interface AuthenticationProvider {
Authentication authenticate(Authentication authentication) throws AuthenticationException;
boolean supports(Class<?> authentication);
}
CustomAuthenticationProvider中实现了自定义认证的逻辑,这边采用了guava中提供的Cache类进行缓存的管理,具体代码如下:
@Slf4j
public class CustomAuthenticationProvider implements AuthenticationProvider {
private final String username;
private final String password;
private Cache<Object, Integer> basicAuthCache;
private final int maxFailedTimes = 5;
private final Duration lockDuration = Duration.ofMinutes(30);
public CustomAuthenticationProvider(String username, String password) {
this.username = username;
this.password = password;
this.setUp();
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String remoteIp = remoteIp();
checkRemoteIpBlocked(remoteIp);
String username = (String) authentication.getPrincipal();
String password = (String) authentication.getCredentials();
checkAccountCorrect(username, password, remoteIp);
authSuccess(remoteIp);
return new UsernamePasswordAuthenticationToken(username, password,
Collections.singletonList(new SimpleGrantedAuthority("ROLE_admin")));
}
@Override
public boolean supports(Class<?> authentication) {
return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
}
private void setUp() {
this.basicAuthCache = CacheBuilder.newBuilder().expireAfterWrite(lockDuration).removalListener(notification -> {
if (notification.getCause() == RemovalCause.EXPIRED && (int) notification.getValue() >= maxFailedTimes) {
log.warn("Remote IP [{}] removed form auth black list automatically", remoteIp());
}
}).build();
ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
scheduledExecutorService.scheduleWithFixedDelay(() -> basicAuthCache.cleanUp(), 1, 1, TimeUnit.DAYS);
}
private void checkRemoteIpBlocked(String remoteIp) {
int failedTimes = Optional.ofNullable(basicAuthCache.getIfPresent(remoteIp)).orElse(0);
if (failedTimes >= maxFailedTimes) {
log.error("Current IP [{}] are blocked, please try again later", remoteIp);
throw new LockedException("Current IP is blocked");
}
}
private void authSuccess(String remoteIp) {
int failedTimes = Optional.ofNullable(basicAuthCache.getIfPresent(remoteIp)).orElse(0);
if (failedTimes >= 1) {
log.info("IP [{}] Unlocked after auth success", remoteIp);
}
basicAuthCache.invalidate(remoteIp);
}
private String authFailed(String remoteIp) {
String res;
int failedTimes = Optional.ofNullable(basicAuthCache.getIfPresent(remoteIp)).orElse(0);
if (++failedTimes >= maxFailedTimes) {
res = "Auth failed and Current IP has been locked";
log.error(res);
} else {
int leftTimes = maxFailedTimes - failedTimes;
res = "Auth failed and has " + leftTimes + " chance left";
}
return res;
}
private void checkAccountCorrect(String username, String password, String remoteIp) {
if (StringUtils.isEmpty(username) || StringUtils.isEmpty(password)) {
log.error("username or password is null");
throw new BadCredentialsException("username or password is null");
}
if ( ! (StringUtils.equals(username, this.username) && StringUtils.equals(password, this.password))) {
throw new BadCredentialsException(authFailed(remoteIp));
}
}
private String remoteIp() {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
return "";
}
return attributes.getRequest().getRemoteAddr();
}
}
访问服务接口,输入错误的鉴权账号和密码,页面响应剩余重试次数: 当重试次数超过5次,提示当前IP被锁定:
基于Webflux应用
基于webflux的应用和servlet实现思路类似,主要就是一些api的区别。因为我们做了zuul到gateway的切换,所以迫不得已研究了spring security基于webflux应用的相关用法~~~
首先还是新增Spring Security配置类,不过不用继承WebSecurityConfigurerAdapter这个类了,webflux中貌似没有提供类似的适配类,添加@EnableWebFluxSecurity这个注解就行:
@EnableWebFluxSecurity
public class WebSecurityConfiguration {}
注入一个SecurityWebFilterChain类型的bean,这边会设置authenticationManager以及authenticationFailureHandler,分别是自定义的认证逻辑以及鉴权失败处理器:
@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
http
.authorizeExchange()
.anyExchange()
.authenticated()
.and()
.authenticationManager(new CustomReactiveAuthenticationManager(username, password))
.formLogin()
.authenticationFailureHandler(new ServerAuthenticationFailureHandler() {
@SneakyThrows
@Override
public Mono<Void> onAuthenticationFailure(WebFilterExchange webFilterExchange, AuthenticationException exception) {
ServerHttpResponse response = webFilterExchange.getExchange().getResponse();
response.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
Map<String, Object> map = new HashMap<>();
map.put("message", exception.getMessage());
String value = new ObjectMapper().writeValueAsString(map);
DataBuffer dataBuffer = response.bufferFactory().wrap(value.getBytes(StandardCharsets.UTF_8));
return response.writeWith(Mono.just(dataBuffer));
}
})
.and()
.httpBasic();
return http.build();
}
设置的authenticationManager方法中,需要提供一个ReactiveAuthenticationManager接口的实现类,这个接口只有一个方法authenticate(Authentication authentication),就是需要重写的校验的逻辑:
@FunctionalInterface
public interface ReactiveAuthenticationManager {
Mono<Authentication> authenticate(Authentication authentication);
}
自定义CustomReactiveAuthenticationManager实现和上面基本一致,大家可以参考下最后我贴的源码,这边就不再贴代码了。不过有一点要注意,就是在webflux项目中时没有RequestContextHolder这个类的,所以我们没有办法在全局获取到当前的request和response的信息,就无法获取IP。
参考了部分网络上面的文章,可以采用Reactor中提供的一个Context来实现类似ThreadLocal的功能,但是尝试了一下,貌似在@Controller里面可以生效,但是在别的地方获取时会报Context is empty错误,暂时还未清楚原因,感觉是设置的context和获取的context不是同一个,如果有知道的大佬希望可以指导下(抱拳)
结语
Spring Security比较复杂,我这边整理的可能不一定正确,如果有不对的地方,还希望大家能够指正!
参考:
https://www.javaboy.org/2020/0503/custom-authentication.html
https://github.com/spring-projects/spring-framework/issues/20239
代码地址:
https://github.com/yzh19961031/SpringCloudDemo/tree/main/security-servlet
https://github.com/yzh19961031/SpringCloudDemo/tree/main/security-webflux
|