SpringSecurityの認証処理を行う流れ

2022.06.03

この記事ではSpringSecurityが認証処理を行う流れをざっくりと確認するのが目的です。

ざっくり書くと認証処理は以下のように流れていきます

  • UsernamePasswordAuthenticationFilter#attemptAuthentication
  • AuthenticationManager(ProviderManager)#authenticate
  • DaoAuthenticationProvider(AbstractUserDetailsAuthenticationProvider)#authenticate
  • UserDetailsService(JdbcUserDetailsManager)#loadUserByUsername

また流れを確認する上で前回の記事で作成したJDBC認証を使うようにしています。

@Bean
public UserDetailsManager userDetailsService(){
    // 既存User : misaka/mikoto
    JdbcUserDetailsManager users = new JdbcUserDetailsManager(this.dataSource);
    return users;
}

UsernamePasswordAuthenticationFilter

フォーム画面でログインした場合にUsernamePasswordAuthenticationFilterが呼ばれます。

ログインボタンを押して実際の認証処理が走る時には、一般的にはこのフィルターが利用されるということですね。

実際の認証処理

どういったことをしているのか見てみます

@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 : "";
	UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username,
			password);
	// Allow subclasses to set the "details" property
	setDetails(request, authRequest);
	return this.getAuthenticationManager().authenticate(authRequest);
}
  • リクエストの内容からusernameとpasswordを取得しています
  • UsernamePasswordAuthenticationTokenというものを作成して、AuthenticationManager#authenticateを呼出しているのがわかります

この時利用されるAuthenticationManagerがProviderManagerというものであることがわかります

画像

そもそもAuthenticationManagerというのはAuthenticationを返すauthenticateメソッドのみを持ったインターフェースです

つまり実際の認証処理というのはAuthenticationManagerが担当しているようです。引用文にもあるようにauthenticateメソッドの引数はAuthenticationで、戻り値もAuthenticationです。

UsernamePasswordAuthenticationFilterで作成したUsernamePasswordAuthenticationTokenというのは、Authenticationの実装クラスです

画像

  • misaka/mikotoでログイン
  • また認可の情報などは取得されていない

ここまでを一旦整理すると

  • UsernamePasswordAuthenticationFilterがログインすると呼ばれる
  • AuthenticationManager#authenticateメソッドを呼び出す
  • AuthenticationManagerはProviderManagerという実装クラスが処理を行っている

AuthenticationManager

UsernamePasswordAuthenticationFilterから呼ばれたAuthenticationManager#authenticateは、ProviderManagerという実装クラスが処理を行います。このauthenticateメソッドは以下のように説明されています

authenticateメソッドの中では以下のようにprovider.authenticateを呼出して、戻り値であるAuthenticationを設定しています

@Override
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;
	int currentPosition = 0;
	int size = this.providers.size();
	for (AuthenticationProvider provider : getProviders()) {
		if (!provider.supports(toTest)) {
			continue;
		}
		if (logger.isTraceEnabled()) {
			logger.trace(LogMessage.format("Authenticating request with %s (%d/%d)",
					provider.getClass().getSimpleName(), ++currentPosition, size));
		}
		try {
			(1) result = provider.authenticate(authentication);
			if (result != null) {
				copyDetails(authentication, result);
				break;
			}
		}
..........省略
}
  • (1)がそのprovider.authenticate

このproviderはDaoAuthenticationProviderというクラスであることがわかりますDaoAuthenticationProviderにはauthenticateメソッドは定義されておらず、基底クラスのAbstractUserDetailsAuthenticationProviderに処理が移ります

画像

AbstractUserDetailsAuthenticationProvider#authenticateメソッドでは、UserDetailsを作成していることがわかります。

画像

UserDetailsというのはユーザー名やパスワード、そしてユーザーが有効かどうか(enabled)などの情報が格納されており、AuthenticationオブジェクトのgetPrincipalメソッドから取得可能です

ここで呼出すretrieveUserは抽象メソッドになっているためDaoAuthenticationProviderがその実装を行っています

@Override
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;
	}
...省略
}

UserDetailsServiceのloadUserByUsernameメソッドを呼び出しています。たまにDB認証する際にはUserDetailsServiceの実装クラスを作ってloadUserByUsernameメソッドをオーバーライドします。というのを見かけますが、やっているのはココですね。

UserDetailsServiceの実体が何者か確認してみると

画像

Bean登録したJdbcUserDetailsManagerです。

ここまでを一旦整理すると

  • AuthenticationManager(ProviderManager)#authenticateが、DaoAuthenticationProvider#authenticateを呼出す
  • 基底クラスのAbstractUserDetailsAuthenticationProvider#authenticateが呼ばれる
  • 基底クラスから抽象メソッドretrieveUserを呼出してUserDetailsを作成する
  • DaoAuthenticationProviderがretrieveUserメソッドでUserDetailsServiceのloadUserByUsernameメソッドを呼出してUserDetailsを作成する
  • UserDetailsServiceの実装はBean登録したJdbcUserDetailsManagerです

UserDetailsService

DaoAuthenticationProviderから、UserDetailsServiceのloadUserByUsernameが呼ばれてUserDetailsを取得しようとしていました。

そしてUserDetailsServiceの実体がBean登録したJdbcUserDetailsManagerでした。

JdbcUserDetailsManagerを見てみると、loadUserByUsernameメソッドは定義されておらず、基底クラスのJdbcDaoImplに定義があります

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
	(1) List<UserDetails> users = loadUsersByUsername(username);
	if (users.size() == 0) {
		this.logger.debug("Query returned no results for user '" + username + "'");
		throw new UsernameNotFoundException(this.messages.getMessage("JdbcDaoImpl.notFound",
				new Object[] { username }, "Username {0} not found"));
	}
	UserDetails user = users.get(0); // contains no GrantedAuthority[]
	Set<GrantedAuthority> dbAuthsSet = new HashSet<>();
	if (this.enableAuthorities) {
		(2) dbAuthsSet.addAll(loadUserAuthorities(user.getUsername()));
	}
	if (this.enableGroups) {
		dbAuthsSet.addAll(loadGroupAuthorities(user.getUsername()));
	}
	List<GrantedAuthority> dbAuths = new ArrayList<>(dbAuthsSet);
	addCustomAuthorities(user.getUsername(), dbAuths);
	if (dbAuths.size() == 0) {
		this.logger.debug("User '" + username + "' has no authorities and will be treated as 'not found'");
		throw new UsernameNotFoundException(this.messages.getMessage("JdbcDaoImpl.noAuthority",
				new Object[] { username }, "User {0} has no GrantedAuthority"));
	}
	(3) return createUserDetails(username, user, dbAuths);
}
  • (1)でデータベースからレコードを取得しています
  • (2)でデータベースから認可を取得しています

(1)loadUsersByUsernameメソッド

画像

(2)loadUserAuthoritiesメソッド

画像

(3)最終的にはUserDetailsを作成して返します

protected UserDetails createUserDetails(String username, UserDetails userFromUserQuery,
		List<GrantedAuthority> combinedAuthorities) {
	String returnUsername = userFromUserQuery.getUsername();
	if (!this.usernameBasedPrimaryKey) {
		returnUsername = username;
	}
	return new User(returnUsername, userFromUserQuery.getPassword(), userFromUserQuery.isEnabled(),
			userFromUserQuery.isAccountNonExpired(), userFromUserQuery.isCredentialsNonExpired(),
			userFromUserQuery.isAccountNonLocked(), combinedAuthorities);
}
  • UserというのはUserDetailsの実装クラスです

こうやってデータベースからレコードを取得して認証ユーザーを取得しているんですね。

ここまでを一旦整理すると

  • UserDetailsService(JdbcUserDetailsManager)のloadUserByUsernameメソッドは、基底クラスのJdbcDaoImplが実装しています
  • loadUserByUsernameメソッドで実際にSQLを発行してデータベースからレコードを取得している
  • また認可などの情報もここでSQLを発行して取得している
  • 最終的にUserDetailsを作成して返却する

これでデータベースからレコードを取得しているのがわかりました。

しかしユーザー取得のSQLを見ると理解できないことが起きています。

public static final String DEF_USERS_BY_USERNAME_QUERY = "select username,password,enabled "
			+ "from users "
			+ "where username = ?";

なんとパスワードのチェックをしていません

認証情報のチェック

AbstractUserDetailsAuthenticationProvider#authenticateメソッドは、辿っていくとUserDetailsSerivceからUserDetailsを取得していましたね

画像

このAbstractUserDetailsAuthenticationProvider#authenticateメソッドはAuthenticationManager(ProviderManager)のauthenticateメソッドから呼ばれていて、戻り値としてAuthenticationを返却しています。

ここまでDBから取得したのはUserDetailsであるため、この後の処理でAuthenticationを返却することになります。このメソッドを見ると以下のようになっています

@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
	Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
			() -> this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports",
					"Only UsernamePasswordAuthenticationToken is supported"));
	String username = determineUsername(authentication);
	boolean cacheWasUsed = true;
	UserDetails user = this.userCache.getUserFromCache(username);
	if (user == null) {
		cacheWasUsed = false;
		try {
			(1) 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"));
		}
		Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract");
	}
	try {
		this.preAuthenticationChecks.check(user);
		(2) additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
	}
.....省略
	return (3) createSuccessAuthentication(principalToReturn, authentication, user);
}
  • (1)ではUserDetailsSerivceからUserDetailsを取得してきます
  • (2)でadditionalAuthenticationChecksということをしています
  • (3)で成功認証情報の作成をして返却しています

(1)については上述してきた処理です。

(2)のadditionalAuthenticationChecksは抽象メソッドになっていますので、継承先のクラスにて実装されているはずです。その実装はDaoAuthenticationProviderでしたね

@Override
@SuppressWarnings("deprecation")
protected void additionalAuthenticationChecks(UserDetails userDetails,
		UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
	if (authentication.getCredentials() == null) {
		this.logger.debug("Failed to authenticate since no credentials provided");
		throw new BadCredentialsException(this.messages
				.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
	}
	String presentedPassword = authentication.getCredentials().toString();
	if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
		this.logger.debug("Failed to authenticate since password does not match stored value");
		throw new BadCredentialsException(this.messages
				.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
	}
}
  • ここでパスワードの整合性をチェックしていることがわかります
  • presentedPasswordは入力時のパスワード
  • userDetails.getPassword()はDBから取得したUserDetailsのパスワード
  • presentedPasswordとuserDetails.getPassword()がマッチしていない場合はエラーになる

引数のauthenticationというのは、UsernamePasswordAuthenticationFilterのattemptAuthenticationメソッドで作成されたUsernamePasswordAuthenticationTokenでしたね

UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username,password);

(3)は抽象メソッドではないのですが、DaoAuthenticationProviderにてオーバーライドされています。オーバーライド先では最終的にsuperを呼び出しています

最終的には以下のような形のAuthenticationオブジェクトが返却されていました

画像

ここまでを一旦整理すると

  • AuthenticationManagerから呼ばれるAuthenticationProviderの実装クラスAbstractUserDetailsAuthenticationProviderがパスワードのチェックを行う
  • パスワードの整合性はadditionalAuthenticationChecksメソッドで行う
  • 最終的にはAuthenticationオブジェクトを返却する