使用spring security的一些心得(一)

前言

最近在做公司的一个新项目,采用了spring security作为认证和授权的框架。用了三天时间,完成了:

  • 账户密码登录
  • jwt
  • url级别的权限访问

其实之前的一个项目也用过,但是基本上都是ctrl + c,ctrl + v。没有太了解这个框架的整体流程,所以都是云里雾里,但是居然跑得挺正常的。

我之前也看过一些网上的教程,但是没有看得太懂,因为他们虽然把一下最重要的东西都讲到了,而一些不太重要,但是又能够承上启下的点没有讲到,所以看得还是比较懵的,典型的我看懂了,但是要我自己写,我不会!所以在似懂非懂的状态下,走上了这条不归路。

废话不多说,进入正题!!!

前置知识

spring security是通过一连串的过滤器来实现对应的功能的,每一个过滤器都对应着一种功能。所以此时过滤器的顺序就很重要,因为每一个过滤器都有可能会修饰原始数据,或者是只有经过上一个过滤器过滤的请求才能前往下一个过滤器。这就是spring security最核心的原理的。咋看起来好像还挺简单的。

过滤器链

这是我在项目中使用的一整条过滤器链,其中6,7是自定义的。

6:是用于账户密码登录的过滤器,7:是用于jwt验证的过滤器,12:是用于权限校验的过滤器

账户密码登录

账户密码登录是几乎所有项目中都会有的一个功能。

过滤器的代码

public class CustomUsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    CustomUsernamePasswordAuthenticationFilter() {
        super(new AntPathRequestMatcher("/login", "POST"));
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) throws AuthenticationException, IOException, ServletException {
        String body = StreamUtils.copyToString(httpServletRequest.getInputStream(), Charset.forName("UTF-8"));
        String username = null;
        String password = null;
        if (StringUtils.hasText(body)){
            JSONObject jsonObject = JSON.parseObject(body);
            username = jsonObject.getString("username");
            password = jsonObject.getString("password");
        }

        if (username == null){
            username = "";
        }
        if (password == null){
            password = "";
        }
        username = username.trim();
        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
        return this.getAuthenticationManager().authenticate(authRequest);
    }
}

复制代码

我们来分析一下这些代码:

扫描二维码关注公众号,回复: 8168232 查看本文章

首先我们继承了一个抽象过滤器类,该过滤器类实际上就是一个普通的过滤器,只不过他在里面做了一些事情,而我们不需要知道这些事情具体是用来干嘛的,我们只要知道它里面包含一个doFilter方法

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

	HttpServletRequest request = (HttpServletRequest) req;
	HttpServletResponse response = (HttpServletResponse) res;

	if (!requiresAuthentication(request, response)) {
		chain.doFilter(request, response);

		return;
	}

	if (logger.isDebugEnabled()) {
		logger.debug("Request is to process authentication");
	}

	Authentication authResult;

	try {
		authResult = attemptAuthentication(request, response);
		if (authResult == null) {
			// return immediately as subclass has indicated that it hasn't completed
			// authentication
			return;
		}
		sessionStrategy.onAuthentication(authResult, request, response);
	}
	catch (InternalAuthenticationServiceException failed) {
		logger.error(
				"An internal error occurred while trying to authenticate the user.",
				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);
}
复制代码

我们可以看到其中执行了attemptAuthentication这个方法,这个方法的名称是不是跟我们自定义的那个过滤器重写的方法名一样呢,没错,是一模一样的,这里实际上调用的也是我们自定义过滤器里面重写的attemptAuthentication方法

我解释一下,这里实际上用到的是一种模板模式,模板模式实际上就是大部分通用的功能都已经由父类实现了,而一些特殊的功能可以延迟到子类中去实现。但是调用的流程依然是父类来决定的,子类只是具体实现了流程中的某一个功能而已。在java中,有很多地方都有使用到这种设计模式,比如著名的用于控制并发的抽象同步队列AbstractQueuedSynchronized,就是用到了模板模式,有兴趣的大家可以看一下源码,写的特别精妙。

我理一下整个的流程: 首先过滤器链将会执行到CustomUsernamePasswordAuthenticationFilter的父过滤器的doFilter方法,然后doFilter方法将会执行重写的attemptAuthentication方法。

然后我们进入到attemptAuthentication方法中,看一下这个方法都做了什么:

首先获取到body中的username和password,然后构造一个UsernamePasswordAuthenticationToken对象传递给下一个方法。

重点来了!!!

它将构造的对象传递下去,那么它是传递给了谁呢?

首先我们先来看getAuthenticationManager()这个方法是做什么的

protected AuthenticationManager getAuthenticationManager() {
	return authenticationManager;
}
复制代码

实际上就是返回了一个认证的管理器,但是这个认证的管理器又是什么?

那我们就在进入到AuthenticationManager里面,发现这就是一个接口而已。既然它是一个接口,那么肯定是无法实例化的,但是这里却实例化了一个AuthenticationManager对象,那么真相就只有一个了,spring security提供了一个默认的实现了该接口的类。没错,事实也确实是这样,就是ProviderManager这个类。

我们先来看一下他的源码

public class ProviderManager implements AuthenticationManager, MessageSourceAware,
		InitializingBean {
	// ~ Static fields/initializers
	// =====================================================================================

	private static final Log logger = LogFactory.getLog(ProviderManager.class);

	// ~ Instance fields
	// ================================================================================================

	private AuthenticationEventPublisher eventPublisher = new NullEventPublisher();
	private List<AuthenticationProvider> providers = Collections.emptyList();
	protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();
	private AuthenticationManager parent;
	private boolean eraseCredentialsAfterAuthentication = true;

	public ProviderManager(List<AuthenticationProvider> providers) {
		this(providers, null);
	}

	public ProviderManager(List<AuthenticationProvider> providers,
			AuthenticationManager parent) {
		Assert.notNull(providers, "providers list cannot be null");
		this.providers = providers;
		this.parent = parent;
		checkState();
	}

	public Authentication authenticate(Authentication authentication)
			throws AuthenticationException {
		Class<? extends Authentication> toTest = authentication.getClass();
		AuthenticationException lastException = null;
		AuthenticationException parentException = null;
		Authentication result = null;
		Authentication parentResult = null;
		boolean debug = logger.isDebugEnabled();

		for (AuthenticationProvider provider : getProviders()) {
			if (!provider.supports(toTest)) {
				continue;
			}

			if (debug) {
				logger.debug("Authentication attempt using "
						+ provider.getClass().getName());
			}

			try {
				result = provider.authenticate(authentication);

				if (result != null) {
					copyDetails(authentication, result);
					break;
				}
			}
			catch (AccountStatusException | InternalAuthenticationServiceException e) {
				prepareException(e, authentication);
				// SEC-546: Avoid polling additional providers if auth failure is due to
				// invalid account status
				throw e;
			} catch (AuthenticationException e) {
				lastException = e;
			}
		}

		if (result == null && parent != null) {
			// Allow the parent to try.
			try {
				result = parentResult = parent.authenticate(authentication);
			}
			catch (ProviderNotFoundException e) {
				// ignore as we will throw below if no other exception occurred prior to
				// calling parent and the parent
				// may throw ProviderNotFound even though a provider in the child already
				// handled the request
			}
			catch (AuthenticationException e) {
				lastException = parentException = e;
			}
		}

		if (result != null) {
			if (eraseCredentialsAfterAuthentication
					&& (result instanceof CredentialsContainer)) {
				// Authentication is complete. Remove credentials and other secret data
				// from authentication
				((CredentialsContainer) result).eraseCredentials();
			}

			// If the parent AuthenticationManager was attempted and successful than it will publish an AuthenticationSuccessEvent
			// This check prevents a duplicate AuthenticationSuccessEvent if the parent AuthenticationManager already published it
			if (parentResult == null) {
				eventPublisher.publishAuthenticationSuccess(result);
			}
			return result;
		}

		// Parent was null, or didn't authenticate (or throw an exception).

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

		// If the parent AuthenticationManager was attempted and failed than it will publish an AbstractAuthenticationFailureEvent
		// This check prevents a duplicate AbstractAuthenticationFailureEvent if the parent AuthenticationManager already published it
		if (parentException == null) {
			prepareException(lastException, authentication);
		}

		throw lastException;
	}
}
复制代码

我把一些无关的注释和代码都删除了,只留下了有用的部分,不然太长了,不太好看。

首先我们发现这里有一个authenticate方法,这个方法的名字是不是跟我们CustomUsernamePasswordAuthenticationFilter类里面最后一行的那个代码一模一样,没错,就是调用了这里的这个方法。

这个方法主要是用来管理Provider的,实际上就是在这里调用了各个Provider的用于校验的方法,所有的Provider都是实现了AuthenticationProvider这个接口的类。

我们主要看的就是provider.authenticate(authentication)这条语句,他就是真正用来校验的方法。而真正校验的过程是要由我们自己实现,或者使用默认的。

而我们实现账户密码登录要使用的Provider是由spring security已经提供给我们的DaoAuthenticationProvider,然后这里又是一个模板模式(这个模式还真的挺常用啊)!

我们一起分析一下这个类到底做了什么?

public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {

	/**
	 * The plaintext password used to perform
	 * PasswordEncoder#matches(CharSequence, String)}  on when the user is
	 * not found to avoid SEC-2056.
	 */
	private static final String USER_NOT_FOUND_PASSWORD = "userNotFoundPassword";

	// ~ Instance fields
	// ================================================================================================

	private PasswordEncoder passwordEncoder;

	/**
	 * The password used to perform
	 * {@link PasswordEncoder#matches(CharSequence, String)} on when the user is
	 * not found to avoid SEC-2056. This is necessary, because some
	 * {@link PasswordEncoder} implementations will short circuit if the password is not
	 * in a valid format.
	 */
	private volatile String userNotFoundEncodedPassword;

	private UserDetailsService userDetailsService;

	private UserDetailsPasswordService userDetailsPasswordService;

	public DaoAuthenticationProvider() {
		setPasswordEncoder(PasswordEncoderFactories.createDelegatingPasswordEncoder());
	}

	// ~ Methods
	// ========================================================================================================

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

	public void setPasswordEncoder(PasswordEncoder passwordEncoder) {
		Assert.notNull(passwordEncoder, "passwordEncoder cannot be null");
		this.passwordEncoder = passwordEncoder;
		this.userNotFoundEncodedPassword = null;
	}

	protected PasswordEncoder getPasswordEncoder() {
		return passwordEncoder;
	}

	public void setUserDetailsService(UserDetailsService userDetailsService) {
		this.userDetailsService = userDetailsService;
	}

	protected UserDetailsService getUserDetailsService() {
		return userDetailsService;
	}

	public void setUserDetailsPasswordService(
			UserDetailsPasswordService userDetailsPasswordService) {
		this.userDetailsPasswordService = userDetailsPasswordService;
	}
}

复制代码

AbstractUserDetailsAuthenticationProvider这个类就是真正实现了AuthenticationProvider这个接口的抽象类。

该抽象类是模板模式中的父类,子类就是DaoAuthenticationProvider

抽象类只是定义了一些具体的流程,真正执行校验工作是由子类来具体实现的。

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) {
				// There was a problem, so try again after checking
				// we're using latest data (i.e. not from the cache)
				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);
	}
复制代码

其中比较重要的是retrieveUser方法,该方法是获取系统中的用户信息,在这里就是账户和密码,然后把他们构造成一个UserDetails对象。

而进行校验的方法是additionalAuthenticationChecks,该方法接受两个参数,一个是UsernamePasswordAuthenticationToken,另一个是个UserDetails,通过之前讲解的知识我们知道,UsernamePasswordAuthenticationToken是由用户登录请求中的账户,密码构造的对象。UserDetails是系统中的账户,密码构造的对象。我们只要比较这两个对象中的信息是否一致,就可以知道用户是否可以登录。

验证成功之后就会调用一个createSuccessAuthentication方法,该方法会调用一个成功的回调,然后我们就可以在该回调中给用户返回token。

如果验证失败,也会调用一个失败的方法,原理跟成功是一样的,只不过返回给用户的信息是错误信息。

该成功和失败的回调我们也是需要自定义的。成功是通过实现AuthenticationSuccessHandler接口,失败是通过实现AuthenticationFailureHandler接口。

我们知道retrieveUser方法会获取系统中的用户信息,那么它是怎么获取的呢?

他会执行这条语句this.getUserDetailsService().loadUserByUsername(username);

getUserDetailsService()这个方法跟我们业务上的Service是一样的,但是spring security要求我们实现他们提供的UserDetailsService接口,该接口就只有一个方法loadUserByUsername,该方法接受一个username参数,我们就是在该方法中获取用户的相关信息。因为是DAO,所以我是通过username查询数据库来获取用户信息。然后构造成一个UserDetails对象返回。

当然这个UserDetails也是一个接口,我们要实现该接口,自定义一个UserDetails的实现类用来使用。

最后我们只要通过一个Configurer对象将所有自定义的组件结合起来就可以了。

package com.liangxin.airport.security;

import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.HttpSecurityBuilder;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.logout.LogoutFilter;
import org.springframework.security.web.authentication.session.NullAuthenticatedSessionStrategy;

/**
 * 登录校验的配置,就是将各个有关的类进行组合,让spring security可以识别的出来
 *
 * @author LHD
 * @date 2019/12/9 17:17
 */
public class JsonLoginConfigurer<T extends JsonLoginConfigurer<T, B>, B extends HttpSecurityBuilder<B>> extends AbstractHttpConfigurer<T, B> {

    private CustomUsernamePasswordAuthenticationFilter authFilter;

    public JsonLoginConfigurer(){
        this.authFilter = new CustomUsernamePasswordAuthenticationFilter();
    }

    @Override
    public void configure(B http){
        // 设置filter使用AuthenticationManager
        authFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
        // 设置失败的handler
        authFilter.setAuthenticationFailureHandler(new JsonLoginFailureHandler());
        // 不将认证后的context放入session
        authFilter.setSessionAuthenticationStrategy(new NullAuthenticatedSessionStrategy());

        CustomUsernamePasswordAuthenticationFilter filter = postProcess(authFilter);

        http.addFilterAfter(filter, LogoutFilter.class);
    }

    public JsonLoginConfigurer<T, B> loginSuccessHandler(AuthenticationSuccessHandler authenticationSuccessHandler){
        authFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler);
        return this;
    }
}

复制代码

总结

因为源码中使用了很多的接口,继承,设计模式,所以看起来会比较绕。

我这里总结一下整个的流程:

Filter -> ProviderManager -> AbstractUserDetailsAuthenticationProvider -> DaoAuthenticationProvider -> AuthenticationSuccessHandler

首先进入Filter构造一个有用户提供的信息的token(用于后面进行校验),然后通过ProviderManager进入到对应的Provider,然后在Provider中通过username获取到系统中的用户信息,然后将之前的token与系统的用户信息进行比较,成功的话就调用AuthenticationSuccessHandler,失败的话就调用AuthenticationFailureHandler

尾声

到这里我们就把整个登录的流程说完了,因为是公司的项目,有保密协议,所有不能把源码直接贴出来,但是如果大家还有哪里不懂的话,可以随时留言,我会尽量解答。

猜你喜欢

转载自juejin.im/post/5df0c06ff265da3397729c82