二、SpringSecurity 自定义手机验证登录方式

简介

在上一篇文章中,我们介绍了如何搭建一套基于SpringSecuity的项目框架,并且进行了演示,本文将继续扩展项目功能,实现自定义用户登录功能。

项目源码仓库:Gitee

代码分支:lesson2

原理介绍

SpringSecurity 提供了web服务项目相关的安全配置,通常我们使用 Spring MVC进行开发(基于Servlet 容器技术实现,现在 Spring 提供了 WebFlux 技术可以提高系统吞吐量,两者都是基于 HTTP协议开发的web服务,MVC提供的是阻塞I/O,WebFlux 提供非阻塞 I/O),Servlet 容器中提供了两种核心组件:

  1. Filter
  2. Servlet

Filter 简介

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 简介

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 请求)

SpringSecurity原理

通过上述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 方法,执行的逻辑如下:

  1. 判断Http请求是否为用户密码登录请求(主要看请求路径是否为 /login 并且为 POST方法)
  2. 获取请求中的用户名密码信息(登录参数信息)
  3. 委托给AuthenticationManager组件进行身份验证
  4. 返回成功或者是错误信息

可以理解为Filter中并没有承担核心的身份信息校验责任,主要完成校验请求是否为用户名密码请求,如果是提取出相关参数,委托给AuthenticationManager组件校验身份,如果成功返回Authentication对象。这里有几个关键的类:

  • UsernamePasswordAuthenticationToken:保存用户名密码信息(是Authentication的子类)
  • Authentication:代表待验证信息或者是已验证完成后的身份信息(可以是未验证的信息也可以是已验证的身份信息,通过方法boolean isAuthenticated() 返回值判断是为已验证信息)
  • AuthenticationManager:验证管理器负责对待验证信息内容进行验证,验证成功返回身份信息,失败返回错误信息

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 providers = Collections.emptyList();属性信息,ProviderManager委托该属性循环处理Authentication子类验证信息知道验证通过或者是全部不通过, AuthenticationProvider核心代码如下:

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);
	}

整体流程图如下所示:

二、SpringSecurity 自定义手机验证登录方式_第1张图片

自定义验证流程

通过上述分析我们可以知道,自定义一个身份验证逻辑需要实现以下三个组件:

  1. 自定义验证参数类型:Authentication
  2. 自定义拦截过滤器:Filter
  3. 自定义特定验证参数类型验证器:AuthenticationProvider

下面我们将实现常用的手机验证码验证登录功能。

自定义验证参数

通过分析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组件,我们知道拦截器主要完成三个功能:

  1. 拦截特定请求
  2. 解析参数
  3. 委托给验证器进行验证处理

手机验证码登录拦截POST/phone/login请求,解析参数,包装成PhoneCodeAuthenticationToken对象,最后委托给AuthenticationManager组件验证,具体代码参见Gitee仓库:地址

运行验证

程序启动后发起POST请求,参数信息:

  • phone:15000000000
  • code:888888

请求成功后返回用户信息(SpringSecurity默认配置会将登录成功请求跳转到 / 路径):

{
    "code": 200,
    "data": {
        "username": "15000000000",
        "phone": "15000000000",
        "roles": [
            "ROLE_USER"
        ]
    },
    "message": null
}

至此完成手机验证码登录功能

我们使用POST请求,SpringSecurity默认提供csrf保护,会拦截 POST请求,因此需要禁用

总结

  • SpringSecurity 使用Servlet容器组件Filter功能进行请求拦截,实现身份校验以及权限控制
  • SpringSecurity 使用AuthenticationManager来实现身份校验功能(实际上你可以在Filter中直接完成身份验证功能,但是这种硬编码方式会增加程序耦合性,后期维护/扩展不方便)
  • SpringSecurity 中的AuthenticationManager委托多个AuthenticationProvider对请求参数进行校验
  • 自定义手机验证码验证流程需要实现三个类:
    • 继承Filter的PhoneCodeAuthenticationFilter, 对手机验证码登录请求进行拦截,并解析处请求参数信息,最后委托给AuthenticationManager进行身份校验
    • 继承Authentication的PhoneCodeAuthenticationToken
      • 登录时存放请求参数信息:手机号和验证码
      • 登录成功后存放用户信息:用户名、手机号、权限等
    • 继承AuthenticationProvider的PhoneCodeAuthenticationProvider,对请求参数进行验证,验证通过返回用户信息

更多文章内容参见:博客

有过SpringSecurity开发经验的同学会发现仓库中的代码使用HttpSecurity进行配置的方式与之前的方式不同,这是SpringSecurity官方在新版中推荐使用的方式,老版本的配置方式将会被遗弃,目前两种方式都可以使用

参考文档

  • 跨域资源共享 CORS 详解
  • Java Servlet Technology
  • SpringSecurity 文档

 

你可能感兴趣的:(SpringSecurity,mvc,spring,java)