Spring Securityのデフォルトログイン認証の実装原理
1. デフォルト設定のログイン認証プロセス
2. プロセス分析
デフォルトの SecurityFilterChain (つまり、フォームログイン) を例にとると、Spring Security がサーバーに /hello リソースを要求するプロセス分析は次のようになります。
- /hello インターフェースをリクエストするには、Spring Security を導入した後、まず一連のフィルターを通過します (1 つのリクエストは /test インターフェースです)。
- リクエストが に到着したとき
FilterSecurityInterceptor
、リクエストが認証されていないことが判明しました。リクエストはインターセプトされ、AccessDeniedException
例外がスローされます。 - スローされた例外は
AccessDeniedException
キャッチされExceptionTranslationFilter
、Filter は LoginUrlAuthenticationEntryPoint#commence メソッドを呼び出して client に戻り302(暂时重定向)
、クライアントが /login ページにリダイレクトするように要求します。 - クライアントは /login リクエストを送信します。
DefaultLoginPageGeneratingFilter
/login リクエストは、フィルタに再度遭遇するとログイン ページを返します。
ログインページの由来
以下にDefaultLoginPageGeneratingFilter
書き換えdoFilter
方法を示します。これは、デフォルト設定でログイン ページが返され、ログイン ページが次のフィルタによって実装される理由も説明できます。
// DefaultLoginPageGeneratingFilter
@Override
public void doFilter(ServletRequest request,
ServletResponse response,
FilterChain chain)
throws IOException, ServletException {
doFilter((HttpServletRequest) request, (HttpServletResponse) response, chain);
}
private void doFilter(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws IOException, ServletException {
boolean loginError = this.isErrorPage(request);
boolean logoutSuccess = this.isLogoutSuccess(request);
// 判断是否是登录请求、登录错误和注销确认
// 不是的话给用户返回登录界面
if (!this.isLoginUrlRequest(request) && !loginError && !logoutSuccess) {
chain.doFilter(request, response);
} else {
// generateLoginPageHtml方法中有对页面登录代码进行了字符串拼接
// 太长了,这里就不给出来了
String loginPageHtml = this.generateLoginPageHtml(request, loginError, logoutSuccess);
response.setContentType("text/html;charset=UTF-8");
response.setContentLength(loginPageHtml.getBytes(StandardCharsets.UTF_8).length);
response.getWriter().write(loginPageHtml);
}
}
フォームログイン認証処理(ソースコード解析)
ログイン ページにリダイレクトされた後、どのように検証され、ユーザー名とパスワードを認証するかという質問が表示されます。
まず、デフォルトロードではフォーム認証が有効になっていることが分かりますが、[Spring Security入門(2)] Spring Securityの実装原理の中で、デフォルトでロードされるフィルタの1つがあることを編集者が指摘していましたUsernamePasswordAuthenticationFilter
。はフォームリクエストを処理するために使用されます。実際には、のメソッドHttpSecurity
をformLogin
呼び出すことで設定されたフィルターです。
次に、UsernamePasswordAuthenticationFilter が何を行うかを分析します (これはネイティブ フィルターではなく、doFilter ではなく、attemptAuthentication によってフィルターされ、パラメーターはネイティブ フィルターよりも 1 チェーン少ないです)。
@Override
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response)
throws AuthenticationException {
// 首先是判断是否是POST请求
if (this.postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
// 获取用户名和密码
// 这是通过获取表单输入框名为username的数据
String username = obtainUsername(request);
username = (username != null) ? username.trim() : "";
// 这是获取表单输入框名为password的数据
String password = obtainPassword(request);
password = (password != null) ? password : "";
UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username,
password);
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
// 在一中小编也说了,这是Security中的认证
// 通过调用AuthenticationManager中的authenticate方法
// 需要传递的参数的Authentication对象,当时是这样解释的
return this.getAuthenticationManager()
.authenticate(authRequest);
}
デバッグ後、authenticate
メソッドを入力して認証方法を確認します。デバッグの認証プロセスは次のとおりです。
-
認証メソッドを入力すると、
ProviderManager
その下の認証メソッドが呼び出され、AuthenticationManager が書き換えられます。初めて、プロバイダーには、匿名認証に使用される AnoymousAuthenticationProvider オブジェクトのみが存在します。最終的に、これが認証されるかどうかを判断します。認証はサポートされていますが、プロバイダーはサポートされていません。
-
この時点では匿名認証が一致しないため次のステップが実行されますが、
parent
属性が空ではないため親のauthenticateを呼び出して認証を行うことになります。(その親も ProviderManager オブジェクトですが、DaoAuthenticationProvider
そのプロバイダー コレクションには認証オブジェクトがあります)。このことから、の AuthenticationManager オブジェクトは次の構築方法によって取得されることが
間接的に推測できます。UsernamePasswordAuthenticationFilter
-
provider.supports
メソッドが正常に一致したので、プロバイダーに検証させ、検証された結果セットを返します。
AuthenticationProvider の認証メソッドは DaoAuthenticationProvider でオーバーライドされず、AbstractUserDetailsAuthenticationProvider
その抽象親クラスによって実装されます。コア メソッドはオブジェクトretrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
を取得し、他のパラメーターを組み合わせて Authentication オブジェクトを作成し、それを返します。UserDetails
AbstractUserDetailsAuthenticationProvider下的authenticate方法
@Override
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
// 断言 authentication 是否是UsernamePasswordAuthenticationToken对象
Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
() -> this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports",
"Only UsernamePasswordAuthenticationToken is supported"));
// 获取一下用户名
String username = determineUsername(authentication);
boolean cacheWasUsed = true;
// 从缓存中拿UserDetails 对象,显然没有,咱刚调试呢,哪来的缓存
UserDetails user = this.userCache.getUserFromCache(username);
if (user == null) {
// 既然为空呢,就说明这不是从缓存中拿的,调为false
cacheWasUsed = false;
try {
// 核心代码,获取UserDetails对象去
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);
// 这里是验证密码的,通过子类DaoAuthenticationProvider的这个方法对密码去进行验证
// 传过去的参数是user(UserDetails对象)和authentication对象
additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
}
catch (AuthenticationException ex) {
if (!cacheWasUsed) {
throw ex;
}
// 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);
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);
}
- 以下は、コア メソッド
retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication)
の概要DaoAuthenticationProvider
です。このメソッドは、UserDetails オブジェクト、つまりユーザーの詳細情報を返すために使用され、認証情報にパッケージ化されていると便利です。認証が成功したかどうか。
// 一共两个参数,一个是用户名,一个是传过来的认证信息
@Override
protected final UserDetails retrieveUser(String username,
UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
prepareTimingAttackProtection();
try {
// 核心方法就是这个,通过UserDetatilsService中的loadUserByUsername方法去获取UserDetails对象
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);
}
}
デフォルト設定では InMemoryUserDetailsManager オブジェクトであり、UserDetails に関するメモリベースの操作オブジェクトであることがわかります。その中のloadUserByUsernameメソッドを見ると、記述も非常に簡単です。ユーザー名は大文字と小文字が区別されません。
- パスワード検証について話しましょう。パスワード検証は
3
ソース コードで指摘されています。UserDetails オブジェクト user を取得した後、additionalAuthenticationChecks
サブクラスのメソッドが呼び出され、パスワード検証が実行されます。主なことは、出力ボックスに入力されたパスワードと UserDetails オブジェクトのパスワードを比較することです。UserDetails パスワードはエンコードされたパスワード (暗号文) として理解でき、入力ボックスへの入力は平文として理解できますPasswordEncoder
。これと同じくらい簡単です。理解してください。次に、PasswordEncoder
一致するものがあるかどうかを確認します。デフォルトはDelegatingPasswordEncoder
パスワードエンコーダです。
三、ユーザー詳細サービス
Spring Security での UserDetailsService の実装
- ユーザー詳細マネージャーUserDetailsServiceをベースに、ユーザーの追加、ユーザーの更新、ユーザーの削除、パスワードの変更、ユーザーの存在判定の5つのメソッドを定義していきます。
- JdbcDaoImplUserDetailsService に基づいて、データベースからユーザーをクエリするメソッドは spring-jdbc を通じて実装されます。
- InMemoryUserDetailsManagerUserDetailsManager でユーザーを追加、削除、変更、クエリするメソッドを実装しましたが、これらはすべてメモリベースの操作であり、データは永続化されません。
- JdbcUserDetailsManagerJdbcDaoImpl から継承され、同時に UserDetailsManager インターフェイスを実装するため、JdbcUserDetailsManager を通じてユーザーの追加、削除、変更、クエリを行うことができ、これらの操作はデータベースに永続化されます。ただし、JdbcUserDetailsManagerには、データベース上のユーザーを操作するためのSQLがあらかじめ書かれているという制限があり、柔軟性に欠けるため、実際の開発ではあまり使われていません。
- キャッシュユーザー詳細サービスUserDetailsServiceがキャッシュされるのが特徴です。
- UserDetailsServiceDelegatorUserDetailsServiceの遅延読み込み機能を提供します。
- ReactiveUserDetailsServiceAdapterこれは、webflux-web-security モジュールによって定義された UserDetailsService の実装です。
デフォルトの UserDetailsService 構成 (ソース コード分析)
UserDetailsService に関するデフォルトの構成はUserDetailsServiceAutoConfiguration
自動構成クラスにあります。(コードが非常に長いので、ここでは核心部分のみ抜粋します)
@AutoConfiguration
@ConditionalOnClass(AuthenticationManager.class)
@ConditionalOnBean(ObjectPostProcessor.class)
@ConditionalOnMissingBean(
value = {
AuthenticationManager.class, AuthenticationProvider.class, UserDetailsService.class,
AuthenticationManagerResolver.class },
type = {
"org.springframework.security.oauth2.jwt.JwtDecoder",
"org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector",
"org.springframework.security.oauth2.client.registration.ClientRegistrationRepository",
"org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository" })
public class UserDetailsServiceAutoConfiguration {
@Bean
@Lazy
public InMemoryUserDetailsManager inMemoryUserDetailsManager(SecurityProperties properties,
ObjectProvider<PasswordEncoder> passwordEncoder) {
// 这里是从SecurityProperties中获取User对象(这里的User对象是SecurityProperties的静态内部类)
SecurityProperties.User user = properties.getUser();
List<String> roles = user.getRoles();
// 然后创建InMemoryUserDetailsManager对象返回
// 交给Spring容器管理
return new InMemoryUserDetailsManager(User.withUsername(user.getName())
.password(getOrDeducePassword(user, passwordEncoder.getIfAvailable()))
.roles(StringUtils.toStringArray(roles))
.build());
}
}
UserDetailsServiceAutoConfiguration の注釈を観察してください@ConditionalOnMissingBean
。どう思いますか? 自動構成 SecurityFilterChain が発生しました。
上記の構成の意味は、デフォルトの構成を使用する場合は、まずコンテナーに次のものが含まれていないことを確認する必要があります。AuthenticationManager、AuthenticationProvider、UserDetailsService、AuthenticationManagerResolverこの状態の例。
デフォルトのユーザー名とパスワード
上記の UserDetailsService の自動設定から、使用される User オブジェクトがSecurityProperties
そこ、それがどのような User オブジェクトであるかを見てみましょう。
まず、getUser を呼び出して取得します。このユーザーは常に新しい User オブジェクトであり、静的な内部クラス インスタンスです。
以下の静的内部クラスの User 属性を見ると、ユーザー名は「user」、パスワードは UUID 文字列、ロールは複数回指定できるリスト コレクションであることがわかります。
注: 以下の getter メソッドと setter メソッドはインターセプトされません。
独自のユーザー名とパスワードを設定できますか?
もちろん落とすことも可能です。
ご覧のとおり、これはアノテーションSecurityProperties
によって変更されます@ConfigurationProperties
(ここでは、SecurityProperties が Spring コンテナーによって管理されるオブジェクトであることを知っておく必要があります)。
@ConfigurationProperties アノテーションは、設定ファイルに設定された値を、セッター注入を通じてアノテーションによって変更されたオブジェクトにマップします。
したがって、構成ファイルで独自の構成を作成し、独自のユーザー名とパスワードを構成できます。
たとえば、次のように構成します。
# application.yml
spring:
security:
user:
name: xxx
password: 123
ユーザー名とパスワードが変更されます。
4. まとめ
- AuthenticationManager、ProviderManager、AuthenticationProvider 系。
- retrieveUser
DaoAuthenticationProvider
メソッドとAdditionalAuthenticationChecks メソッド (これら 2 つのメソッドは、それぞれ UserDetailsService オブジェクトと PasswordEncoder オブジェクトを適用します)。UsernamePasswordAuthenticationFilter
最終的には でProviderManager
認証を渡し、最後にAbstractUserDetailsAuthenticationProvider
DaoAuthenticationProvider の親クラスの認証に移行して認証することになりますが、このプロセスとこれらのクラスとメソッドについては明確にする必要があります。後のニーズに便利で、デバッグにも利用可能。 - インターフェース (カスタム UserDetailsService)を実装し
UserDetailsService
、実装クラスのインスタンスを Spring コンテナー管理に渡すことで、デフォルトの実装ではなくカスタム実装を使用することができます。 - UserDetails は、ユーザー名、パスワード、権限などの情報をカプセル化するユーザー詳細オブジェクトです。これは UserDetailsService の戻り値でもあり、カスタマイズできます。