SpringSecurity系列 之 认证过程和原理

1、前言

  在《如何在SpringBoot项目中引入SpringSecurity进行权限控制》中,我们基于SpringBoot + SpringSecurity + Mybatis实现了登录认证相关功能。SpringSecurity 是如何实现登录认证的呢?我们这一节就通过跟踪代码执行的过程,来了解学习认证流程。

2、SpringSecurity过滤器链

  SpringSecurity是通过一系列的过滤器实现认证和授权的。其中,SpringSecurity 的过滤器并不是直接内嵌到Servlet Filter中的,而是通过FilterChainProxy来统一管理的,如下所示:
在这里插入图片描述

3、认证过程

3.1、访问需要验证的地址(http://localhost:8888/qriver-admin/)

  访问需要验证的地址http://localhost:8888/qriver-admin/时,因为该链接需要认证才能访问,所以直接重定向到了自定义的登录页面。其中原理可以参考《未认证的请求是如何重定向到登录地址的》

3.2、登录认证

  在第一次访问需要验证的地址时,浏览器会直接重定向到登录界面,这个时候输入用户名密码,点击“登录”按钮,就会进行登录认证。

  如果认证通过,则会跳转到http://localhost:8888/qriver-admin/地址对应的页面;否则,则会重定向到登录界面,地址上默认会缀上“error”,例如,“http://localhost:8888/qriver-admin/goLogin?error”。

4、认证过程和原理

  在基于SpringSecurity 实现登录认证的时候,是经过了一系列的过滤器,然后在UsernamePasswordAuthenticationFilter过滤器中进行用户登录认证的。

注意:在基于SpringSecurity 实现的登录认证中没有CsrfFilter过滤器,是因为我们在配置中把CSRF禁用了,所有没有该过滤器。

基于SpringSecurity 实现的登录认证过程,会经历如下过滤器:
在这里插入图片描述
  后续,我们将通过分析UsernamePasswordAuthenticationFilter过滤器,来梳理登录认证的过程。

4.1、UsernamePasswordAuthenticationFilter的doFilter()方法

  基于SpringSecurity 实现的登录认证,是在UsernamePasswordAuthenticationFilter过滤器中实现登录认证的,该过滤器是继承自AbstractAuthenticationProcessingFilter,其中doFilter()方法就是在抽象类AbstractAuthenticationProcessingFilter中定义的。实现如下:

//AbstractAuthenticationProcessingFilter.java
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
	throws IOException, ServletException {

	//省略 …… 
	
	Authentication authResult;

	try {
		//在UsernamePasswordAuthenticationFilter中实现
		authResult = attemptAuthentication(request, response);
		if (authResult == null) {
			return;
		}
		sessionStrategy.onAuthentication(authResult, request, response);
	}catch (InternalAuthenticationServiceException failed) {
		unsuccessfulAuthentication(request, response, failed);
		return;
	}catch (AuthenticationException failed) {
		// Authentication failed
		unsuccessfulAuthentication(request, response, failed);
		return;
	}
	// Authentication success
	if (continueChainBeforeSuccessfulAuthentication) {
		chain.doFilter(request, response);
	}
	successfulAuthentication(request, response, chain, authResult);
}

  在上述doFilter()方法中,主要做了四件事,其中:

  第一步,通过调用子类的实现方法attemptAuthentication(),实现用户名密码校验,并返回校验结果Authentication实例。
  第二步,根据session策略,处理session,即通过调用sessionStrategy.onAuthentication()方法实现。
  第三步,当前两步出现InternalAuthenticationServiceException 或AuthenticationException 异常时,即验证失败时,调用unsuccessfulAuthentication()方法进行后续的处理。
  第四步,如果执行成功,没有出现上述两个异常,这个时候会调用successfulAuthentication()方法执行登录成功的后续操作。

  当进入UsernamePasswordAuthenticationFilter过滤器时,首先就调用了上述的doFilter()方法(父类中定义),然后该方法又调用了attemptAuthentication()方法,实现用户名密码校验,我们下面详细分析。

4.2、attemptAuthentication()方法

  该方法是定义在UsernamePasswordAuthenticationFilter类中,在attemptAuthentication()方法,主要通过obtainUsername()和obtainPassword()方法获取了请求中的用户名和密码,然后封装成UsernamePasswordAuthenticationToken实例,最后再调用AuthenticationManager的authenticate()进行登录验证。实现如下:

//UsernamePasswordAuthenticationFilter.java
public Authentication attemptAuthentication(HttpServletRequest request,
	HttpServletResponse response) throws AuthenticationException {
	if (postOnly && !request.getMethod().equals("POST")) {
		throw new AuthenticationServiceException(
				"Authentication method not supported: " + request.getMethod());
	}

	String username = obtainUsername(request);
	String password = obtainPassword(request);

	if (username == null) {
		username = "";
	}

	if (password == null) {
		password = "";
	}
	username = username.trim();
	UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
			username, password);
	// Allow subclasses to set the "details" property
	setDetails(request, authRequest);
	return this.getAuthenticationManager().authenticate(authRequest);
}

  其中,this.getAuthenticationManager()方法默认获取的是ProviderManager对象实例,我们下面继续分析ProviderManager对象的authenticate()方法。

4.3、ProviderManager的authenticate()方法

  authenticate()方法就是用来验证请求中的用户名密码和实际的用户名密码是否匹配,其中请求中的用户名密码封装成了Authentication对象,即authenticate()方法传递过来的参数,而实际用户名密码一般可以存储在配置文件、内存、数据库等地方。

  其实,ProviderManager的authenticate()方法是只是提供了一个代理,真正实现校验是调用AuthenticationProvider对象的authenticate()方法实现。不过一个ProviderManager可能对应多个AuthenticationProvider对象。

  AuthenticationManager、ProviderManager和AuthenticationProvider的关系如下所示:
在这里插入图片描述
  ProviderManager的authenticate()方法实现如下:

//ProviderManager.java
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;
			}
		}
		//省略 catch代码块  ……
	}

	if (result == null && parent != null) {
		// Allow the parent to try.
		try {
			result = parentResult = parent.authenticate(authentication);
		}
		//省略 catch代码块  ……
	}

	if (result != null) {
		if (eraseCredentialsAfterAuthentication
				&& (result instanceof CredentialsContainer)) {

			((CredentialsContainer) result).eraseCredentials();
		}
		if (parentResult == null) {
			eventPublisher.publishAuthenticationSuccess(result);
		}
		return result;
	}

	if (lastException == null) {
		lastException = new ProviderNotFoundException(messages.getMessage(
				"ProviderManager.providerNotFound",
				new Object[] { toTest.getName() },
				"No AuthenticationProvider found for {0}"));
	}
	if (parentException == null) {
		prepareException(lastException, authentication);
	}
	throw lastException;
}

  在上述方法中,主要分了三部分:

  第一部分,for循环代码块。该代码块的逻辑:通过迭代(应该可能存在多个AuthenticationProvider对象),选出可用AuthenticationProvider对象(通过supports()方法判断),然后调用AuthenticationProvider对象的authenticate()方法,进一步进行校验,第一次校验成功就会跳出后续循环,不会再执行后续AuthenticationProvider对象的authenticate()方法。

  第二部分,如果上一步没有完成验证,即没有合适的AuthenticationProvider对象可用,这个时候就会调用父级对象的authenticate()方法进行验证,关系如前面的图所示。这部分我们将来会专门介绍AuthenticationManager、ProviderManager和AuthenticationProvider三者之间的关系。

  第三部分,如果验证通过,就会返回Authentication对象,否则就会抛出AuthenticationException异常,该异常会在doFilter()方法中被捕获,并进行后续的认证失败处理。

4.4、DaoAuthenticationProvider的authenticate()方法

  在ProviderManager的authenticate()方法中,又调用了AuthenticationProvider对象的authenticate()方法,而这里AuthenticationProvider对象一般是使用的DaoAuthenticationProvider实现(实现了AbstractUserDetailsAuthenticationProvider抽象类)。其实,authenticate()方法方法也是在抽象类AbstractUserDetailsAuthenticationProvider中定义的,实现如下:

public Authentication authenticate(Authentication authentication)
	throws AuthenticationException {
	Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
			() -> messages.getMessage(
					"AbstractUserDetailsAuthenticationProvider.onlySupports",
					"Only UsernamePasswordAuthenticationToken is supported"));

	// Determine username
	String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED"
			: authentication.getName();

	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");
	}
	try {
		preAuthenticationChecks.check(user);
		additionalAuthenticationChecks(user,
				(UsernamePasswordAuthenticationToken) authentication);
	}
	catch (AuthenticationException exception) {
		if (cacheWasUsed) {
			cacheWasUsed = false;
			user = retrieveUser(username,
					(UsernamePasswordAuthenticationToken) authentication);
			preAuthenticationChecks.check(user);
			additionalAuthenticationChecks(user,
					(UsernamePasswordAuthenticationToken) authentication);
		}
		else {
			throw exception;
		}
	}

	postAuthenticationChecks.check(user);

	if (!cacheWasUsed) {
		this.userCache.putUserInCache(user);
	}

	Object principalToReturn = user;

	if (forcePrincipalAsString) {
		principalToReturn = user.getUsername();
	}

	return createSuccessAuthentication(principalToReturn, authentication, user);
}

  在上述方法中,首先获取请求对象(Authentication对象)的用户名,然后根据用户名去缓存中查询用户对象UserDetails,如果没有查询到,即UserDetails对象user为null,则调用retrieveUser()方法获取用户信息,该方法在实现类DaoAuthenticationProvider中实现,最终是通过我们配置的UserDetailsService实现类查询用户信息的。

  然后,这个时候,如果还是没有对应的用户信息,则会抛出UsernameNotFoundException异常,否则,则开始验证用户信息的正确性。

  通过执行preAuthenticationChecks.check()方法,验证用户状态,比如是否禁用、是否过期等。

  最后,真正实现用户名密码匹配对比的是在additionalAuthenticationChecks()方法中,该方法是由子类实现,这里是在DaoAuthenticationProvider类中实现。

4.5、retrieveUser()方法

  retrieveUser()方法用来获取存储在配置文件、内存或数据库等地方的用户信息。实现逻辑如下:

protected final UserDetails retrieveUser(String username,
	UsernamePasswordAuthenticationToken authentication)
		throws AuthenticationException {
	prepareTimingAttackProtection();
	try {
		UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
		if (loadedUser == null) {
			throw new InternalAuthenticationServiceException(
					"UserDetailsService returned null, which is an interface contract violation");
		}
		return loadedUser;
	}
	catch (UsernameNotFoundException ex) {
		mitigateAgainstTimingAttack(authentication);
		throw ex;
	}
	catch (InternalAuthenticationServiceException ex) {
		throw ex;
	}
	catch (Exception ex) {
		throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
	}
}

  在上述方法中,首先通过this.getUserDetailsService()方法获取到加载用户数据所需要的的方式和策略,这里其实使用了我们自定义的QriverUserDetailsService类(UserDetailsService实现类),然后调用了QriverUserDetailsService类的loadUserByUsername()方法从数据库查询对应的用户信息。

4.6、additionalAuthenticationChecks()方法

  该方法主要实现用户名密码的校验,其中涉及到了密码的加密策略,由PasswordEncoder实现类的matches()方法完成密码的比对,如果请求传过来的密码和用户存储的密码匹配,则验证通过,否则就会抛出BadCredentialsException异常(继承自AuthenticationException异常类),即校验失败。

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"));
	}
}
4.7、认证失败处理

  在attemptAuthentication()方法中,如果出现InternalAuthenticationServiceException或AuthenticationException异常,则说明登录认证失败,就会执行unsuccessfulAuthentication()方法,完成认证失败的后续逻辑,详细内容可以通过《认证失败处理流程》了解学习。

4.8、认证成功处理

  在attemptAuthentication()方法中,如果认证成功,最后就会执行successfulAuthentication()方法,该方法会完成认证成功的后续逻辑,详细内容可以通过《认证成功处理流程》了解学习。

  至此,我们已经把登录认证过程中主要过程进行了分析和梳理,后续我们再针对一些其他方面进行学习,努力向前!!!

  • 4
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

姠惢荇者

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值