在上一篇文章中,我们介绍了如何搭建一套基于SpringSecuity的项目框架,并且进行了演示,本文将继续扩展项目功能,实现自定义用户登录功能。
项目源码仓库:Gitee
代码分支:lesson2
SpringSecurity 提供了web服务项目相关的安全配置,通常我们使用 Spring MVC进行开发(基于Servlet 容器技术实现,现在 Spring 提供了 WebFlux 技术可以提高系统吞吐量,两者都是基于 HTTP协议开发的web服务,MVC提供的是阻塞I/O,WebFlux 提供非阻塞 I/O),Servlet 容器中提供了两种核心组件:
Filter 组件可以实现过滤器功能,Http 请求达到时,Filter 优先接收到请求信息,并且可以依据业务逻辑对请求提前进行处理,例如,CORS(浏览器的同源请求策略, 详细信息参见:阮一峰网络日志), 对于非同源的请求,项目方可以按照要求选择拒绝或者接受请求,接口定义如下:
public interface Filter {
/// 初始化
public default void init(FilterConfig filterConfig) throws ServletException {}
/// 过滤
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException;
/// 销毁
public default void destroy() {}
}
在 public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException; 方法中可以获取到http 请求信息,并且可以阻断 Http 请求,防止调用实际的业务逻辑代码,例如 实现基于IP黑名单过滤器,发现请求IP 在系统的 IP黑名单中,可以直接返回错误信息阻止请求继续执行。
Servlet 组件用于接收Http请求信息,并依据请求信息进行处理,项目的业务逻辑在 Servlet 中进行处理,接口定义如下:
public interface Servlet {
/// 初始化方法
public void init(ServletConfig config) throws ServletException;
/// 获取配置
public ServletConfig getServletConfig();
业务处理
public void service(ServletRequest req, ServletResponse res)
throws ServletException, IOException;
/// 获取基础信息
public String getServletInfo();
/// 销毁
public void destroy();
}
其中最主要的是 public void service(ServletRequest req, ServletResponse res) throws ServletException, IOException; 业务逻辑代码在此处进行调用处理(Spring MVC 中的重要组件 DispatcherServlet 是Servlet 子类,通过 service 方法接收并处理 Http 请求)
通过上述Servlet 技术简单讲解,我们知道Filter主要用于实现过滤功能,这些功能与业务逻辑关系不大,可以在请求进入业务逻辑之前进行拦截处理,保障系统稳定运行,SpringSecurity正是通过一系列“Filter组件”来实现安全过滤功能(在执行业务逻辑之前对Http请求进行身份校验和权限控制),SpringSecurity 中的两个主要功能分别是:
通过上述两个功能点可以实现系统的访问控制安全,对于不符合要求的请求,直接返回错误信息,阻止不安全的资源访问。本文重点讲解身份校验,权限控制将在后续进行分析。
在之前文章中使用“user”用户进行了登录,这是SpringSecurity提供的默认用户密码登录实现:"org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter",这是一个Filter 子类,可以实现Filter过滤功能,核心代码如下:
public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
/// 默认匹配 POST /login 请求
private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/login",
"POST");
public UsernamePasswordAuthenticationFilter() {
super(DEFAULT_ANT_PATH_REQUEST_MATCHER);
}
public UsernamePasswordAuthenticationFilter(AuthenticationManager authenticationManager) {
super(DEFAULT_ANT_PATH_REQUEST_MATCHER, authenticationManager);
}
在 doFilter 方法中调用该方法实现过滤
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
/// 判断请求方法是否支持
if (this.postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
String username = obtainUsername(request);
username = (username != null) ? username.trim() : "";
String password = obtainPassword(request);
password = (password != null) ? password : "";
/// 包装成 用户密码Token
UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username,
password);
// Allow subclasses to set the "details" property
/// 设置请求信息,这是一些额外的信息,例如用户IP地址等信息,与核心校验逻辑关系不大
setDetails(request, authRequest);
调用 AuthenticationManager 进行身份验证,成功返回 Authentication 对象,失败抛出异常
return this.getAuthenticationManager().authenticate(authRequest);
}
}
我们来分析一下 attemptAuthentication 方法,执行的逻辑如下:
可以理解为Filter中并没有承担核心的身份信息校验责任,主要完成校验请求是否为用户名密码请求,如果是提取出相关参数,委托给AuthenticationManager组件校验身份,如果成功返回Authentication对象。这里有几个关键的类:
UsernamePasswordAuthenticationToken和Authentication都是数据模型类,不存在处理逻辑,AuthenticationManager是主要的验证逻辑处理类,在SpringSecurity 中提供了ProviderManager实现类,核心代码如下:
public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean{
/// 身份校验处理器
private List providers = Collections.emptyList();
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Class toTest = authentication.getClass();
AuthenticationException lastException = null;
AuthenticationException parentException = null;
Authentication result = null;
Authentication parentResult = null;
/// 循环使用Provider 来验证身份信息,只要有一个验证通过就算成功
for (AuthenticationProvider provider : getProviders()) {
/// 判断Provider是否支持验证Authentication子类类型,例如前面的UsernamepasswordAuthenticationToken
if (!provider.supports(toTest)) {
continue;
}
try {
/// 使用具体的验证器进行验证,验证通过返回具体验证信息
result = provider.authenticate(authentication);
if (result != null) {
break;
}
}
catch (AccountStatusException | InternalAuthenticationServiceException ex) {
throw ex;
}
catch (AuthenticationException ex) {
lastException = ex;
}
}
/// 如果验证器无法验证,并且存在父级验证器那么使用父级验证器进行验证
if (result == null && this.parent != null) {
// Allow the parent to try.
try {
parentResult = this.parent.authenticate(authentication);
result = parentResult;
}
catch (ProviderNotFoundException ex) {
}
catch (AuthenticationException ex) {
parentException = ex;
lastException = ex;
}
}
/// 判断是否存在已验证结果,存在返回验证信息,不存在抛出一样信息
if (result != null) {
if (this.eraseCredentialsAfterAuthentication && (result instanceof CredentialsContainer)) {
((CredentialsContainer) result).eraseCredentials();
}
if (parentResult == null) {
this.eventPublisher.publishAuthenticationSuccess(result);
}
return result;
}
throw lastException;
}
}
在上述代码中最主要的是private List
public interface AuthenticationProvider {
///对待验证信息进行验证
Authentication authenticate(Authentication authentication) throws AuthenticationException;
判断当前验证器是否支持对该类型验证信息进行校验处理
boolean supports(Class authentication);
}
SpringSecurity中提供了对UsernamepasswordAuthenticationToken参数验证的AuthenticationProvider子类DaoAuthenticationProvider,相关接口实现如下:
/// 判断待验证参数authentication是否为UsernamePasswordAuthenticationToken类型或者是其子类
public boolean supports(Class authentication) {
return (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication));
}
@Override
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) {
/// 这里为了隐藏用户不存在错误,会对该错误进行包装,抛出新错误
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);
}
整体流程图如下所示:
通过上述分析我们可以知道,自定义一个身份验证逻辑需要实现以下三个组件:
下面我们将实现常用的手机验证码验证登录功能。
通过分析UsernamePasswordAuthenticationToken组件,我们知道该Token主要包装验证参数信息,方便后续使用,实现逻辑如下:
public class PhoneCodeAuthenticationToken extends AbstractAuthenticationToken {
private final Object principal;
private Object credentials;
///未验证参数构造器
private PhoneCodeAuthenticationToken(String phone, String code) {
super(null);
this.principal = phone;
this.credentials = code;
/// 设置是否验证:false-未验证,true-已验证
super.setAuthenticated(false);
}
///已验证参数构造器
/// authorities代表取得打权限信息
private PhoneCodeAuthenticationToken(Object principal,
Collection authorities) {
super(authorities);
this.principal = principal;
/// 设置是否验证:false-未验证,true-已验证
super.setAuthenticated(true);
}
/// 未验证Token
public PhoneCodeAuthenticationToken unAuthToken(String phone, String code) {
return new PhoneCodeAuthenticationToken(phone, code);
}
已验证Token
public PhoneCodeAuthenticationToken authToken(Object principal,
Collection authorities) {
return new PhoneCodeAuthenticationToken(principal, authorities);
}
@Override
public Object getCredentials() {
return credentials;
}
@Override
public Object getPrincipal() {
return principal;
}
}
通过分析UsernamePasswordAuthenticationFilter组件,我们知道拦截器主要完成三个功能:
手机验证码登录拦截POST/phone/login请求,解析参数,包装成PhoneCodeAuthenticationToken对象,最后委托给AuthenticationManager组件验证,具体代码参见Gitee仓库:地址
程序启动后发起POST请求,参数信息:
请求成功后返回用户信息(SpringSecurity默认配置会将登录成功请求跳转到 / 路径):
{
"code": 200,
"data": {
"username": "15000000000",
"phone": "15000000000",
"roles": [
"ROLE_USER"
]
},
"message": null
}
至此完成手机验证码登录功能
我们使用POST请求,SpringSecurity默认提供csrf保护,会拦截 POST请求,因此需要禁用
更多文章内容参见:博客
有过SpringSecurity开发经验的同学会发现仓库中的代码使用HttpSecurity进行配置的方式与之前的方式不同,这是SpringSecurity官方在新版中推荐使用的方式,老版本的配置方式将会被遗弃,目前两种方式都可以使用