文章目录
想必很多人接触了spring security 后都想知道,登录流程是怎么样的?
今天悬弧带大家扒了登录流程源码的外衣,透过光溜溜的源码分析spring security的登录流程
回顾
我们在《循序渐进学习spring security 第三篇,如何自定义登录页面?登录回调》探索了自定义登录页面时,发现提交的默认登录用户名和密码参数是被写死在代码类UsernamePasswordAuthenticationFilter中的,也可以通过代码配置修改。既然登录提交的用户名和密码在UsernamePasswordAuthenticationFilter类中定义的,那对应的登录验证,想必也是在这里面验证的了
探索登录流程
探索UsernamePasswordAuthenticationFilter 的attemptAuthentication 验证方法
UsernamePasswordAuthenticationFilter类是一个过滤器,代码不多,不难发现,attemptAuthentication(HttpServletRequest request, HttpServletResponse response) 就是验证登录的接口,在《循序渐进学习spring security 第三篇,如何自定义登录页面?登录回调》的项目基础上,打个断点,走一遍登录流程,发现果然和猜想想没错
不难发现
- 首先验证登录接口是否是POST请求,如果不是,直接抛异常,当然,这个是默认的,如果希望支持get请求,需要重写过滤器UsernamePasswordAuthenticationFilter,将postOnly属性设置为FALSE
- 通过 obtainUsername 和 obtainPassword 方法提取出请求里边的用户名/密码出来,提取方式就是 request.getParameter ,这也是为什么 Spring Security 中默认的表单登录要通过 key/value 的形式传递参数,而不能传递 JSON 参数,如果像传递 JSON 参数,修改这里的逻辑
- 使用登录提交的用户名和密码,构造UsernamePasswordAuthenticationToken 对象,构造的对象isAuthenticated() 将返回 false.
- 设置客户端信息,如客户端IP,sessionId等
- 通过this.getAuthenticationManager().authenticate(authRequest) 验证用户登录,验证用户名,密码,用户状态等,如果都OK则登录成功,否则登录失败,具体过程往下看
探索AuthenticationManager#authenticate(authRequest) 验证过程
在前面的 attemptAuthentication 方法中,该方法的最后一步开始做验证了,首先要获取到一个 AuthenticationManager,这里拿到的是 ProviderManager ,所以接下来我们就进入到 ProviderManager 的 authenticate 方法中,这个方法比较长,我把关键的贴出来分析
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Class<? extends Authentication> toTest = authentication.getClass();
for (AuthenticationProvider provider : getProviders()) {
if (!provider.supports(toTest)) {
continue;
}
try {
result = provider.authenticate(authentication);
if (result != null) {
copyDetails(authentication, result);
break;
}
}
}
if (result == null && this.parent != null) {
try {
parentResult = this.parent.authenticate(authentication);
result = parentResult;
}
catch (ProviderNotFoundException ex) {
}
catch (AuthenticationException ex) {
parentException = ex;
lastException = ex;
}
}
...
throw lastException;
}
几乎所有的认证逻辑都在这里了
分析下:
- 首先获取 authentication 的 Class,判断当前 provider 是否支持该 authentication。如果支持,则调用 provider 的 authenticate 方法开始做校验,校验完成后,会返回一个新的 Authentication。一会来和大家捋这个方法的具体逻辑。
- 这里的 provider 可能有多个,如果 provider 的 authenticate 方法没能正常返回一个 Authentication,则调用 provider 的 parent 的 authenticate 方法继续校验。
- copyDetails 方法则用来把旧的 Token 的 details 属性拷贝到新的 Token 中来。
- 接下来会调用 eraseCredentials 方法擦除凭证信息,也就是你的密码,这个擦除方法比较简单,就是将 Token 中的 credentials 属性置空。
- 最后通过 publishAuthenticationSuccess 方法将登录成功的事件广播出去。
大致的流程,就是上面这样,在 for 循环中,第一次拿到的 provider 是一个 AnonymousAuthenticationProvider,这个 provider 不支持 UsernamePasswordAuthenticationToken,也就是会直接在 provider.supports 方法中返回 false,结束 for 循环,然后会进入到下一个 if 中,直接调用 parent 的 authenticate 方法进行校验。而 parent 就是 ProviderManager,所以会再次回到这个 authenticate 方法中。再次回到 authenticate 方法中,provider 也变成了 DaoAuthenticationProvider,这个 provider 是支持 UsernamePasswordAuthenticationToken 的,所以会顺利进入到该类的 authenticate 方法去执行,而 DaoAuthenticationProvider 继承自 AbstractUserDetailsAuthenticationProvider 并且没有重写 authenticate 方法,所以 我们最终来到 AbstractUserDetailsAuthenticationProvider#authenticate 方法中:
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = determineUsername(authentication);
boolean cacheWasUsed = true;
UserDetails user = this.userCache.getUserFromCache(username);
if (user == null) {
cacheWasUsed = false;
try {
user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
}
catch (UsernameNotFoundException ex) {
this.logger.debug("Failed to find user '" + username + "'");
if (!this.hideUserNotFoundExceptions) {
throw ex;
}
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
}
try {
this.preAuthenticationChecks.check(user);
additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
}
catch (AuthenticationException ex) {
if (!cacheWasUsed) {
throw ex;
}
cacheWasUsed = false;
user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
this.preAuthenticationChecks.check(user);
additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
}
this.postAuthenticationChecks.check(user);
if (!cacheWasUsed) {
this.userCache.putUserInCache(user);
}
Object principalToReturn = user;
if (this.forcePrincipalAsString) {
principalToReturn = user.getUsername();
}
return createSuccessAuthentication(principalToReturn, authentication, user);
}
这里的逻辑就比较简单了:
-
首先从 Authentication 提取出登录用户名。
-
然后通过拿着 username 去调用 retrieveUser 方法去获取当前用户对象
-
接下来调用 preAuthenticationChecks.check 方法去检验 user 中的各个账户状态属性是否正常,例如账户是否被禁用、账户是否被锁定、账户是否过期等等。
-
additionalAuthenticationChecks 方法则是做密码比对的,好多小伙伴好奇 Spring Security 的密码加密之后,是如何进行比较的,看这里就懂了。
-
在 postAuthenticationChecks.check 方法中检查密码是否过期。
-
有一个 forcePrincipalAsString 属性,这个是是否强制将 Authentication 中的 principal 属性设置为字符串,默认为 false,一般不用改,就用 false,这样在后期获取当前用户信息的时候反而方便很多。
-
最后,通过 createSuccessAuthentication 方法构建一个新的 UsernamePasswordAuthenticationToken。
好了,那么登录的校验流程就算完成了
用户登录后的信息是如何保存的?
要去找登录的用户信息,得先来解决一个问题,就是上面我们说了这么多UsernamePasswordAuthenticationFilter #attemptAuthentication 登录验证过程,但attemptAuthentication 是在哪里触发的?
其实不用多想,登录验证肯定是通过拦截器或者说过滤器触发的,UsernamePasswordAuthenticationFilter 本来就是一个过滤器,但其上面还有父类 AbstractAuthenticationProcessingFilter ,很多时候当我们想要在 Spring Security 自定义一个登录验证码或者将登录参数改为 JSON 的时候,我们都需自定义过滤器继承自 AbstractAuthenticationProcessingFilter ,毫无疑问,UsernamePasswordAuthenticationFilter#attemptAuthentication 方法就是在 AbstractAuthenticationProcessingFilter 类的 doFilter 方法中被触发的:
从上面的代码中,当 attemptAuthentication 方法被调用时,实际上就是触发了 UsernamePasswordAuthenticationFilter#attemptAuthentication 方法,当登录抛出异常的时候,unsuccessfulAuthentication 方法会被调用,而当登录成功的时候,successfulAuthentication 方法则会被调用,那我们就来看一看 successfulAuthentication 方法:
可以看到很重要的一行代码,就是 SecurityContextHolder.getContext().setAuthentication(authResult); ,登录成功的用户信息被保存在这里,也就是说,在任何地方,如果我们想获取用户登录信息,都可以从 SecurityContextHolder.getContext() 中获取到,想修改,也可以在这里修改。
最后大家还看到有一个 successHandler.onAuthenticationSuccess,这就是我们在 SecurityConfig 中配置登录成功回调方法,就是在这里被触发的
分析了一遍源码,发现也就那样