0%

Spring security 登陆认证的重要概念、流程梳理及自定义设置

昨天看了下 Spring Security 的使用,打着断点弄清了认证的流程。

首先,可以了解下涉及到的关键的几个类,形成一个整体的了解。

主要的类

  1. Authentication

Authentication接口,子类有 AbstractAuthenticationToken,直接用的实现常是 UsernamePasswordAuthenticationToken 类,用于保存认证信息,最重要的字段有:

  • principal

对应用户名之类的用户ID。

  • credentials

根据用户信息加密生成的凭证,用于认证。

  1. UsernamePasswordAuthenticationFilter

UsernamePasswordAuthenticationFilter 实现了AbstractAuthenticationProcessingFilter。filter 负责从请求中获取用户名和密码,生成 UsernamePasswordAuthenticationToken 交由 AuthenticationManager 进行验证。

  1. UserDetails

spring security 为我们提供了一个用于认证的 User 类,作为一个“Models core user information retrieved by a UserDetailsService”,继承自 UserDetails 接口,其中包含的字段有:

  • String password

  • String username

  • Set authorities

  • boolean accountNonExpired

  • boolean accountNonLocked

  • boolean credentialsNonExpired

  • boolean enabled

这些字段中,除了 password 之外,全部是 final 的。关于这些字段的作用,我们在后文的流程梳理中进行说明。需要注意的是,参照 User 类的构造方法,特别指明了 authorities 参数不应为 null。authorities 用于控制用户的权限,我们熟悉的以下代码中的写法里为用户设定的 role,其实就是定制化的 authorities。

1
auth.inMemoryAuthentication().withUser("xxx").password("xxxxxx").roles("USER");

对应的,spring security 提供了一个读取 UserDetails 的接口 UserDetailsService,这一接口中只有 loadUserByUsername(String username) 方法,作用是根据 username 取出服务端保存的UserDetails,以用于和客户端发送的信息组建成的 UserDetails 进行对比。

UserDetailsManager 接口继承了 UserDetailsService,其实现有 InMemoryUserDetailsManager 和 JdbcUserDetailsManager,分别实现了通过内存和 JDBC 存取用户认证信息。

  1. AuthenticationProvider

提供认证服务,实现有 AbstractUserDetailsAuthenticationProvider 类,一方面,管理使用的 UserDetailsService 和 PasswordEncoder;另外,其中管理了多个 UserDetailsChecker 进行 UserDetails 的比对和检查判断其有效性,并为进行认证的方法 authenticate(Authentication authentication) 提供了默认实现。

如果不需要自定义 authenticate(Authentication authentication) 方法,不必对其进行修改,一般只需要设置使用的 UserDetailsService 和 PasswordEncoder。

  1. PasswordEncoder

负责对密码进行编码,包括 BCryptPasswordEncoder 及 MessageDigestPasswordEncoder 等多个实现。还有一个建议仅用于测试的 NoOpPasswordEncoder 的实现,顾名思义,这一实现在密码的存储中不进行加密编码。

  1. SavedRequest 和 RequestCache

可保存登陆请求。如果我们希望实现这一功能:未认证的用户访问某个需要认证的地址 A ——> 跳转到登陆页面,要求用户登陆 ——> 用户登陆成功后自动跳转回 A,那么我们就需要保存用户对 A 的 HttpServletRequest。spring security 在默认情况下使用 SavedRequestAwareAuthenticationSuccessHandler 来实现登陆成功后的跳转,其父类 SimpleUrlAuthenticationSuccessHandler 则实现了登陆成功后跳转到固定的设置的默认页面。

如果我们向在登陆成功后的处理中加入部分我们自定义的功能,那么从 SavedRequestAwareAuthenticationSuccessHandler 继承并不方便,万幸 spring security 使用 RequestCache 和 SavedRequest 对此功能进行了很充分的支持,我们可以直接从最原始的接口 AuthenticationSuccessHandler 来实现,加入登陆后跳转到原地址的功能,也可以继承 SimpleUrlAuthenticationSuccessHandler 来实现。只需要在我们的 handler 中初始化一个 RequestCache,就可以从其中读取 SavedRequest 进而获取我们需要的重定向地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// HttpSessionRequestCache 是 RequestCache 的实现,
// 在 HttpServletRequest 的 session 中使用 key 为 “SPRING_SECURITY_SAVED_REQUEST” 的字段保存请求信息。
private RequestCache mRequestCache = new HttpSessionRequestCache();

@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
// 可以直接使用 HttpSessionRequestCache 保存 HttpServletRequest 的 key 从 session 中读取,
// 但这显然不是一个优雅安全的方式...
//SavedRequest savedRequest = (SavedRequest) request.getSession().getAttribute("SPRING_SECURITY_SAVED_REQUEST");

// 使用 HttpSessionRequestCache 提供的方法读取
SavedRequest savedRequest = mRequestCache.getRequest(request, response);
String s = savedRequest.getRedirectUrl();
}

认证流程

  1. 调用 filterChain 中的各个 filter。调用 UsernamePasswordAuthenticationFilter,获取客户端发送的用户名和密码等信息,组建出 AuthenticationToken。

  2. 将 AuthenticationToken 交由 AuthenticationManager 管理其认证。AuthenticationManager 调用 AuthenticationProvider 提供的认证服务。认证通过时,构建一个 Authentication 返回给用户;当认证不通过时,AuthenticationProvider 抛出相应异常,同样的构建认证失败的 Authentication 返回给用户。此处我们也可以自定义 handler 自定义返回给用户的信息。

  3. 以下进入 AbstractUserDetailsAuthenticationProvider 的 authenticate(Authentication authentication) 方法看下具体的认证过程

首先,尝试从缓存中读取服务端保存的该用户的认证信息,未使用缓存或者缓存未命中时则调用 retrieveUser() 方法读取。AbstractUserDetailsAuthenticationProvider 提供的实现只有 DaoAuthenticationProvider,是通过 UserDetailsService 读取。无法读取时抛出异常 UsernameNotFoundException,认证不通过。

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

boolean cacheWasUsed = true;

UserDetails user = this.userCache.getUserFromCache(username);

if (user == null) {
cacheWasUsed = false;

try {
user = retrieveUser(username,
(UsernamePasswordAuthenticationToken) authentication);
}
catch (UsernameNotFoundException notFound) {
logger.debug("User '" + username + "' not found");

if (hideUserNotFoundExceptions) {
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
else {
throw notFound;
}
}

Assert.notNull(user,
"retrieveUser returned null - a violation of the interface contract");
}

如果成功读到了服务端保存的用户信息,则依次调用了以下函数继续认证过程。

1
2
3
 preAuthenticationChecks.check(user);
additionalAuthenticationChecks(user,
(UsernamePasswordAuthenticationToken) authentication);

AbstractUserDetailsAuthenticationProvider 使用 DefaultPreAuthenticationChecks 作为
preAuthenticationChecks,其 check 实现依次检查上述 UserDetails 中的 accountNonLocked、enabled 和 accountNonExpired 字段,检查了是否被锁定、不可用和过期。

接下来在 additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication) 方法中调用 passwordEncoder 进行 password 的验证。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
 protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
if (authentication.getCredentials() == null) {
logger.debug("Authentication failed: no credentials provided");

throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}

String presentedPassword = authentication.getCredentials().toString();

if (!passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
logger.debug("Authentication failed: password does not match stored value");

throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
}

过程很简单,credentials 不为空时,使用设置的 passwordEncoder 对客户端发送的密码编码,与服务端存储的已编码的密码比对。同样的,比对不一致时抛出异常。

最后,使用 DefaultPostAuthenticationChecks 的实例 postAuthenticationChecks 检查 credentials 是否已经过期。

通过全部验证后调用 createSuccessAuthentication(principalToReturn, authentication, user) 构建一个 Authentication 返回给用户。

在 createSuccessAuthentication() 部分需要注意的是,默认情况下传入的参数 principalToReturn 和 user 都是从我们指定的 UserDetailsService 中的 loadUserByUsername(String username) 方法获取到的 userDetails 对象。因为返回给客户端的数据中不应再存有 password 信息,所以默认情况下,会对 principalToReturn —— 也就是 loadUserByUsername() 返回的 userDetails 实例调用 eraseCredentials() 方法,将 password 字段设为 null。所以,我们在实现 loadUserByUsername() 方法时,应注意不要直接返回保存的 userDetails 的引用,而是返回其数据副本。在 User 类的文档中也强调了 User 并不是不可变对象,而在 spring security 提供的 InMemoryUserDetailsManager 和 JdbcUserDetailsManager 中,其 loadUserByUsername() 方法也是返回了保存的实例的副本。

自定义设置

通常,这一验证流程中我们需要自定义的部分有:

  • filter,默认的实现 UsernamePasswordAuthenticationFilter 中使用 request.getParameter() 读取参数,这一方法的文档中说明,只能读取 GET 方法的 url 参数和 POST 方法使用 form-data 及 x-www-form-urlencoded 的参数,如果在 RequestBody 中传输 json 等格式的数据,则不能读取。所以需要我们自行实现 filter 来替换默认的 filter。

  • UserDetailsService,自定义服务端读取已保存的用户认证信息。我们可以实现 UserDetailsManager 接口管理用户信息缓存以及数据库存取,或者使用 JdbcUserDetailsManager 类在数据库中存取。

  • PasswordEncoder,设置使用的密码编码方式。

以下进行示例。

妈的累死了,越写越大,考虑了下,光实现 UserDetailsManager 就可以再整合缓存等,全写完怕是累死了…我先缓缓,今天周六,放个假~