如何为已有Spring项目新增 APIKey 认证登录

技术向的SaaS平台多数都会提供API提供给开发者,或者来支撑客户端和SDK,我司也不例外。虽然浏览器保存Oauth2 token很方便,但token自身存在过期时间,并且由于token本身是第三方颁发的,对于客户端和sdk来说是一种潜在的隐患,经常因为第三方验证服务出问题,造成用户无法登录。因此,提供API Key方式访问成为了一种更好的选择。

新增APIKey访问方式有两个思路:

  • 复用原有Web API,在现有项目提供OAuth2的基础上,再并行引入API Key的认证方式来访问相同的服务
  • 完全另起一套服务,通过代码共享来提供一致的服务。

两个选项个有优劣,在产品前中期,web和api访问方式大致相同的情况下,第一种的效果会好一些,而当项目进入中后期,web和api的差异,无论是功能方面去考虑还是安全方面去考虑,完全隔离可能会是一个更好的选择。因为目前大部分和web功能是一致的决定在后端已有OAuth2的认证之上,再添加一个并行的API Key的认证方式,从而方便用户使用。

本文主要介绍如何为已有Spring项目新增APIKey认证登录的最佳实践。

实现

{{< admonition type=warning title=“注意!” open=true >}} 该方法之适用于Spring Security 小于等于5.2.2(Spring boot <2.2.5),最新的Spring Secuirty 5.7(Spring boot ^2.3.0)有bug,并且API也会有较大变动,不适用本方法。详情见文末。 {{< /admonition >}}

获取API Key并生成AuthenticationToken

@Component("apiKeyFilter")
public class APIKeyFilter extends OncePerRequestFilter {


    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String authorization = request.getHeader("application-api-key");
        if (authorization != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            SecurityContext emptyContext = SecurityContextHolder.createEmptyContext();
            emptyContext.setAuthentication(new APIKeyAuthenticationToken(authorization));
            SecurityContextHolder.setContext(emptyContext);
        }
        filterChain.doFilter(request, response);
    }
}

通过查看HttpRequest的header来获取APIKey,将之封装进自定义的Authentication Token,并将其放在SecurityContext之中。这里要注意,因为Servlet是多线程环境,需要先创建一个新的空context,再通过setContext设置回去,而不要直接SecurityContextHolder.getContext().setAuthentication(token),这样会有发生race condition。 Servlet Filter在编写的时候一定要小心,特别是exception的处理,处理的不好会造成用户无法访问。

创建APIKeyAuthenticationProvder

public class APIKeyAuthenticationProvider implements AuthenticationProvider {

    UserDetailsService userDetailsService;
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String apiKey = (String) authentication.getCredentials();
        UserDetails userdetails = null;
//        userdetails = userDetailsService.loadUserByAPIKey(authentication.getPrincipal().toString());
        APIKeyAuthenticationToken newAuth = new APIKeyAuthenticationToken(userdetails, userdetails.getAuthorities());
        newAuth.setAuthenticated(true);
        return newAuth;
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return APIKeyAuthenticationToken.class.isAssignableFrom(authentication);
    }

}

ProviderManager会根据SecurityContext中的Token去匹配所有的AuthenticationProvider,supports方法就是用于匹配。匹配到了之后就可以调用authenticate方法去认证。这里就可以通过查询数据库或者调用其他服务的方式来获取用户信息。这里同样要注意exception的处理,任何认证不通过的请求,都要以抛出AuthenticationException子类的方式异常返回,这样Spring Filter就可以捕获到异常,并且返回相应的错误信息。

注册APIKeyAuthenticationProvider

@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    final APIKeyFilter apiKeyFilter;
    public SecurityConfiguration(APIKeyFilter apiKeyFilter) {
        this.apiKeyFilter = apiKeyFilter;
    }
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .anyRequest().authenticated();
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
        http.addFilterBefore(apiKeyFilter, UsernamePasswordAuthenticationFilter.class);
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(new APIKeyAuthenticationProvider());
    }
}

未完成的工作

Spring Security 5.7之后将WebSecurityConfigurerAdapter已经弃用了,按照官方文档改造后的代码并不能正常工作,现象是Token在filter中设置之后,并没有触发ProviderManager去调用我们自定义的APIKeyAuthenticationProvider. 这应该是一个bug,在社区内发现有合并AuthenticationManagerAuthenticationProvider两个接口的issue,所以先暂缓解决5.7的问题。

写在后面

Java相对于其他语言来说,组件和框架比较丰富,但这同时也是一把双刃剑:组件多的开发速度快,但同时学习成本也高。Spring Security能快速完成90%的工作,但是剩下10%特殊要求,需要多出三五倍的时间来配置和调试。 我喜欢小而精,物尽其用的哲学主张:如果还没有上Java或spring,建议选择更简单的语言和框架,例如golang或者pythong;如果已经上了Spring security,最好是把他研究透彻,并好好利用。当然Spring Security作为一款开源框架,设计和实现都是值得去学习的。

Reference