常见的权限认证是通过提供“用户名密码”完成,业务中有一些 API,我们希望以 API Token 的形式验证。例如 URL 上加上 token /api?token=xxxx 就允许API 的访问。这种设计背后的逻辑是用户名密码拥有较高的权限,而 API token 可以只给出某个子系统的权限,类似于 Github 的 Personal API Tokens 。
本文会介绍如何用 Spring Security 来实现。Spring Security 虽然功能强大,但配置起来经常让人云里雾里,所以我们要试图了解一些 Spring Security 的工作原理,再具体实现 API Token 的权限认证。
Java Servlet 和 Spring Security 都使用了设计模式中的 责任链模式 。简单地说,它们都定义了许多过滤器(Filter),每一个请求都会经过层层过滤器的处理,最终返回。如下图:
其中,Spring Security 在 Servlet 的过滤链(filter chain)中注册了一个过滤器 FilterChainProxy ,它会把请求代理到 Spring Security 自己维护的多个过滤链,每个过滤链会匹配一些 URL,如图中的 /foo/** ,如果匹配则执行对应的过滤器。过滤链是有顺序的,一个请求只会执行第一条匹配的过滤链。Spring Security 的配置本质上就是新增、删除、修改过滤器。下图是配置了 http.formLogin() 的过滤链:
可以看到默认的过滤器里包含了许多内容,如 CsrfFilter 来生成和校验 CSRF Token , UsernamePasswordAuthenticationFilter 来处理用户名密码的认证, SessionManagementFilter 来管理 Session 等等。而我们关心的“权限认证”,它其实分为两个部分:
以用户名密码的方式为例,要认证一个用户是不是系统的用户,我们需要两个步骤:
Authentication
验证用户、密码的逻辑一般需要自定义且常常会比较复杂,Spring Security 中的 AuthenticationManager 定义了验证的接口:
public interface AuthenticationManager {
Authentication authenticate(Authentication authentication) throws AuthenticationException;
}
AuthenticationException
Spring Security 内部使用最多的实现是 ProviderManager ,而它内部又使用了一个认证的链条,包含了多个 AuthenticationProvier , ProviderManager 会逐一调用它们直到有一个 provider成功返回。
public interface AuthenticationProvider {
Authentication authenticate(Authentication authentication) throws AuthenticationException;
boolean supports(Class<?> authentication);
}
与 AuthenticationManager 不同的是它多了一个 supports 方法用来判断Provider 是否支持当前的认证信息。如一个 API Token 的认证器就不支持用户名密码的认证信息。
另外,ProviderManager 还定义了父子关系,如果当前 ProviderManager 中所有的 Provier 都无法认证某个信息,它就会让父 ProviderManager 来判断。如图:
理论上我们不需要理解这些内容,完全可以自己编写一个过滤器来处理所有需求。只是如果使用了这套接口,就能享受 Spring Security 的一些“基础设施”,例如抛 AuthenticationException 时, ExceptionTranslationFilter 会调用配置好的 authenticationEntryPoint.commence() 方法进行处理,返回 401 等等。
要判断“你有没有资格”,首先要知道关于“你”的信息,也就是前一小节中说的 Authentication 接口;其次需要知道要访问的资源及资源的配置,如要访问 URL,该 URL 能被什么角色访问。类似地,Spring Security 已经定义了相关的接口,授权会在 FilterSecurityInterceptor 中启动。
public interface AccessDecisionManager {
void decide(Authentication authentication,
Object object,
Collection<ConfigAttribute> configAttributes)
throws AccessDeniedException, InsufficientAuthenticationException;
boolean supports(ConfigAttribute attribute);
boolean supports(Class<?> clazz);
}
函数 decide 会决定授权是否成功,如果权限不足则抛 AccessDeniedException 异常。函数参数说明:
authentication 代表了“认证信息”,从中可以获得诸如当前用户的角色等信息 object 即要访问的资源,如某个 URL 或是某个函数 configAttributes 代表该资源的配置,如该 URL 只能被“管理员”角色( ROLE_ADMIN )访问。 Spring Security 中,具体的授权策略是“投票机制”,每一个 AccessDecisionVoter 都能投票,而最后如何统计结果,由 AccessDecisionManager 的具体实现决定。如 AffirmativeBased 只需要有人赞成即可; ConsensusBased 需要多数人赞成; UnanimousBased 需要所有人赞成。默认使用 AffirmativeBased 。
同 Authentication 一样,遵循这套逻辑,Spring Security 的默认配置就能减少我们的工作量。例如上面提到的投票机制,还有抛 AccessDeniedException 异常时返回 403 等处理。
Spring Security 的运行原理不难理解,但如何达到想要的配置一直是我学习时的痛点。这里也只是简要说明,具体的配置不是三言两语能说清的。下面举一个简单的示例,说明一些对应关系:
@Configuration
@Order(1)
public class TokenSecurityConfig extends WebSecurityConfigurerAdapter { // ①
// ②
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(new TokenAuthenticationProvider(tokenService));
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.antMatcher("/api/v1/square/**") // ③
.addFilterAfter(new TokenAuthenticationFilter(), BasicAuthenticationFilter.class) // ④
.authorizeRequests()
.anyRequest().hasRole("API"); // ⑤
}
}
WebSecurityConfigurerAdapter 开始。之前提到 Spring Security 可以包含多条过滤链,每个 WebSecurityConfigurerAdapter 对应一条过滤链。③ 中指定要匹配的 URL 模式,顺序由 @Order 指定。 configure(AuthenticationManagerBuilder auth) 方法来配置认证逻辑,一份 WebSecurityConfigurerAdapter 配置会生成一个 ProviderManager ,而这个 configure 方法可以提供多个 AuthenticationProvier 。 antMatcher 指定一个模式,使用 requestMatcher 或 requestMatchers 来进行高级配置,如指定多个模式。 addFilter 相关方法可以在当前过滤链中添加过滤器,但似乎没有删除的方法。 hasRole 等用来指定“授权”的逻辑,比如该行表示访问所有的 URL 都需要 API 角色。 要实现开头说的 API Token 的权限认证,我们需要下面几样东西:
Authentication AuthenticationProvier
由于 API token 只需要存放 token 本身即可,所以实现如下:
public class TokenAuthentication implements Authentication {
private String token;
private TokenAuthentication(String token) {
this.token = token;
}
@Override
public Object getCredentials() {
return token;
}
// ... 省略其它方法
}
因为 token 信息是在 URL 中指定的,所以这个过滤器会读取 URL 中的 parameter 并生成上节定义的 TokenAuthentication :
public class TokenAuthenticationFilter extends OncePerRequestFilter { // ①
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain fc)
throws ServletException, IOException {
SecurityContext context = SecurityContextHolder.getContext();
if (context.getAuthentication() != null && context.getAuthentication().isAuthenticated()) {
// do nothing
} else {
// ②
Map<String, String[]> params = req.getParameterMap();
if (!params.isEmpty() && params.containsKey("token")) {
String token = params.get("token")[0];
if (token != null) {
Authentication auth = new TokenAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
req.setAttribute("me.lotabout.springsecurityexample.security.TokenAuthenticationFilter.FILTERED", true); //③
}
fc.doFilter(req, res); //④
}
}
OncePerRequestFilter
上面会 URL 中获得 Token,我们需要与数据库中的 token 比较看是否一致,这里就用内存中的比较代替:
public class TokenAuthenticationProvider implements AuthenticationProvider {
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
if (authentication.isAuthenticated()) {
return authentication;
}
// 从 TokenAuthentication 中获取 token
String token = authentication.getCredentials().toString();
if (Strings.isNullOrEmpty(token)) {
return authentication;
}
if (!token.equals("abcdefg")) {
throw ResultException.of(MyError.TOKEN_NOT_FOUND).errorData(token);
}
User user = User.builder()
.username("api")
.password("")
.authorities(Role.API)
.build();
// 返回新的认证信息,带上 token 和反查出的用户信息
Authentication auth = new PreAuthenticatedAuthenticationToken(user, token, user.getAuthorities());
auth.setAuthenticated(true);
return auth;
}
@Override
public boolean supports(Class<?> aClass) {
return (TokenAuthenticationFilter.TokenAuthentication.class.isAssignableFrom(aClass));
}
}
我们希望在错误时,返回 200 状态码,同时 body 中包含 "success": false 及具体的错误信息。
public class ResultExceptionTranslationFilter extends GenericFilterBean {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain fc) throws IOException, ServletException {
try {
fc.doFilter(request, response);
} catch (ResultException ex) {
response.setContentType("application/json; charset=UTF-8");
response.setCharacterEncoding("UTF-8");
response.getWriter().println(JsonUtil.toJson(Response.of(ex)));
response.getWriter().flush();
}
}
}
具体的配置和上面提到的差不多,注意到我们还关闭了 CSRF 和 Session。
@Configuration
@Order(1)
public class PredictorSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(new TokenAuthenticationProvider(tokenService));
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.antMatcher(PATTERN_SQUARE)
.addFilterAfter(new TokenAuthenticationFilter(), BasicAuthenticationFilter.class)
.addFilterAfter(new ResultExceptionTranslationFilter(), ExceptionTranslationFilter.class)
.authorizeRequests()
.anyRequest().hasRole("API")
.and()
.csrf()
.disable()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
}
完整的代码可以在 Spring Security Example 找到。
每次用 Spring Security 都是现搜现用,如果示例不工作时往往不知道如何处理,所以这些更深入地学习了原理并做了笔记,希望各位看官用得上。
AuthenticationManager 包含多个 AuthenticationProvider 且可以有父节点 AccessDecisionManager ,它的几个实现类代表着不同的投票方法。 WebSecurityConfigurerAdapter 的类定义一条新的 Filter Chain 最后我们用了上面的知识实现了基于 API token 的认证,授权仍旧用的 Spring Security 默认的机制。