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オブジェクトを返却する