Yuandupier

Yuandupier

Spring Security中自定义认证逻辑(防暴力破解)

21
0
0
2022-06-11

背景

项目开发时,需要对服务接口进行防暴力破解的防护,项目中使用的Spring Security没有对放暴力破解的支持,所以需要自己重写Spring Security中的认证逻辑来实现防暴力破解的能力。

软件版本

本次使用的软件版本如下:

Spring Boot 2.6.7 (配套的Spring Security版本是5.6.3)

具体实现

下面是具体的实现案例,先简单梳理下实现方案。

实现方案

  1. 对于认证失败的用户IP,添加锁定机制,认证失败超过5次进行锁定操作。
  2. 默认锁定时间为30分钟,超过30分钟解除锁定。

基于servlet的应用和基于webflux的应用实现有点不太一样,这边都整理一下,不过差别也不大。

基于Servlet应用

新增Spring Security配置类,继承WebSecurityConfigurerAdapter适配类,同时需要添加@Configuration,@EnableWebSecurity注解:

/**
 * SpringSecurity 配置类
 *
 * @author yuanzhihao
 * @since 2022/6/8
 */
@Configuration
@EnableWebSecurity
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {}

重写WebSecurityConfigurerAdapter中configure(HttpSecurity http)方法,这边添加一个failureHandler的处理器,这个处理器可以自定义鉴权失败时的响应,不设置的话,默认是跳转到鉴权失败的页面,这边重写设置响应为json格式:

// 设置鉴权失败时响应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对象进行验证 这边主要是重写的校验逻辑
	Authentication authenticate(Authentication authentication) throws AuthenticationException;
  // 判断当前的AuthenticationProvider是否支持对应的Authentication
	boolean supports(Class<?> authentication);
}

CustomAuthenticationProvider中实现了自定义认证的逻辑,这边采用了guava中提供的Cache类进行缓存的管理,具体代码如下:

/**
 * 自定义认证逻辑
 *
 * @author yuanzhihao
 * @since 2022/6/8
 */
@Slf4j
public class CustomAuthenticationProvider implements AuthenticationProvider {
    private final String username;
    private final String password;

    // 缓存 记录某个IP地址basic认证失败的次数
    private Cache<Object, Integer> basicAuthCache;

    // 最大重试次数 5次
    private final int maxFailedTimes = 5;

    // 锁定时间 30分钟
    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();
        // 校验ip是否被锁定
        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() {
        // 设置缓存 超时时间设置为30分钟 并且添加remove监听
        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这个注解就行:

/**
 * SpringSecurity 配置类
 *
 * @author yuanzhihao
 * @since 2022/6/8
 */
@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() {
                // 设置鉴权失败时响应json格式
                @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 {

	/**
	 * Attempts to authenticate the provided {@link Authentication}
	 * @param authentication the {@link Authentication} to test
	 * @return if authentication is successful an {@link Authentication} is returned. If
	 * authentication cannot be determined, an empty Mono is returned. If authentication
	 * fails, a Mono error is returned.
	 */
	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