1. 程式人生 > >Spring Securtiy 認證流程(原始碼分析)

Spring Securtiy 認證流程(原始碼分析)

當用 Spring Security 框架進行認證時,你可能會遇到這樣的問題:

你輸入的使用者名稱或密碼不管是空還是錯誤,它的錯誤資訊都是 Bad credentials。

那麼如果你想根據不同的情況給出相應的錯誤提示該怎麼辦呢?

這個時候我們只有瞭解 Spring Securiy 認證的流程才能知道如何修改程式碼。

好啦,來看下面的例子,大部分人的 WebSecurityConfig 的 configure 程式碼都類似於下:

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // TODO Auto-generated method stub
        http
        .authorizeRequests()
        .anyRequest().permitAll()
        .and()
        .formLogin().loginPage("/signin")
        .usernameParameter("username")
        .passwordParameter("password")
        .loginProcessingUrl("/signin")
        .and()
        .csrf().disable();
    }

相信以上程式碼大家都知道什麼意思:任何請求資訊都允許,也就是不需要身份認證。

登入頁面請求為 /signin,使用者名稱和密碼引數的name屬性分別是 username,password。登入頁面 form 的 action 請求為 /signin。

當然這個 action 不必和登入頁面請求一樣。最後的那個是禁止跨站請求偽造。

這段程式碼和登入認證聯絡較大的應該是從 loginPage() 到 loginProcessingUrl() 裡的方法。

咱先從 loginPage 看起,滑鼠左鍵拖動覆蓋 loginPage,然後右鍵 Open Declaration 就進入到了 FormLoginConfigurer 類。

這個類裡值得注意的方法有兩個:構造方法和 loginPage 方法。

    public FormLoginConfigurer() {
        super(new UsernamePasswordAuthenticationFilter(), null);
        usernameParameter("username");
        passwordParameter("password");
    }

    public FormLoginConfigurer<H> loginPage(String loginPage) {
        return super.loginPage(loginPage);
    }

構造方法中使用了一個使用者名稱密碼認證過濾器類,這一看就和認證有關係。

loginPage 方法大家可以自行按照這個步驟檢視,現在直接看 UsernamePasswordAuthenticationFilter 類。

    public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";
    public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";
    private String usernameParameter = SPRING_SECURITY_FORM_USERNAME_KEY;
    private String passwordParameter = SPRING_SECURITY_FORM_PASSWORD_KEY;
    private boolean postOnly = true;

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

   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);
     setDetails(request, authRequest); return this.getAuthenticationManager().authenticate(authRequest); }

 這只是其中一部分程式碼,其他的可以自己看。該類中定義的兩個字串和構造方法定義了預設的登入方式。

登入 action 請求為以 POST 方式的 /login,使用者名稱及密碼分別以 username,password 屬性值獲取。

該類的父類的父類 GenericFilterBean 實現了 InitializingBean 介面,也就是會初始化為一個 Bean。

當看到 attemptAuthentication 時,就知道他是認證的方法啦。

這裡咱直接看到 new UsernamePasswordAuthenticationToken(username, password);

    public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
        super(null);
        this.principal = principal;
        this.credentials = credentials;
        setAuthenticated(false);
    }

從這裡可以知道,它把使用者名稱和密碼分別存在了 principal,credentials 裡。

現在我們只需要記住登入資訊存在了 authRequest 裡。現在來看下setDetails,雖然我不感興趣。

    protected void setDetails(HttpServletRequest request,
            UsernamePasswordAuthenticationToken authRequest) {
        authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
    }

它呼叫了一個 buildDetails 方法,實際上是呼叫的:(追根溯源可以看到)

    
/**
  * Records the remote address and will also set the session Id if a session already
  * exists (it won't create one).
  *
  * @param request that the authentication request was received from
  */
public WebAuthenticationDetails(HttpServletRequest request) { this.remoteAddress = request.getRemoteAddr(); HttpSession session = request.getSession(false); this.sessionId = (session != null) ? session.getId() : null; }

從原始碼註釋可以看到,它是記錄遠端地址並且會設定一個會話 ID,這裡我們不管它了。

直接看這一句:return this.getAuthenticationManager().authenticate(authRequest);

它呼叫的是一個實現了 AuthenticationManager 介面的類的 authenticate 方法。

從原始碼中我們找不到它用的是哪個實現類,網上說是 ProviderManager 類,我們來看一下該類。

public class ProviderManager implements AuthenticationManager, MessageSourceAware,
        InitializingBean {
  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 e) {
                prepareException(e, authentication);
                // SEC-546: Avoid polling additional providers if auth failure is due to
                // invalid account status
                throw e;
            }
            catch (InternalAuthenticationServiceException e) {
                prepareException(e, authentication);
                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 方法,從類的宣告可以看出來它也會初始化為一個 Bean,咱找不到很正常對吧。

authenticate 方法會遍歷所有的 AuthenticationProvider ,然後呼叫 provider 的 authenticate 方法。

如果認證結果不為空的話將會儲存到 result 中,並且擦除認證資訊再返回 result。

為空的話一般是沒有提供 AuthenticationProvider,會報 ProviderNotFoundException 錯誤。

現在我們來看下 provider 的 authenticate 方法。

    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider provider = new CustomAuthenticationProvider();
        provider.setMessageSource(messageSource);
        provider.setUserDetailsService(userService);
        provider.setPasswordEncoder(new BCryptPasswordEncoder());
        return provider;
    }

這個是我寫的一個 AuthenticationProvider,只不過我重寫了一個類繼承了 DaoAuthenticationProvider。

這裡我們來看 DaoAuthenticationProvider 類:(這個類裡面並沒有發現  authenticate 方法,那先從它的父類找)

父類是 AbstractUserDetailsAuthenticationProvider,它也實現了 InitializingBean 介面,也是初始化為一個 Bean。

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

 在這段程式碼中可以知道:如果 authentication.getPrincipal() 為空的話,username 將會為 NONE_PROVIDED。

不為空的話將會得到 authentication.getPrincipal(),也就是使用者名稱,只是這種型別不是 String 型別,但可以強制轉換。

程式碼中是 authentication.getName(),這種和上面基本一樣,只不過該型別是 String 型別的。

然後定義一個 user,先嚐試從快取中獲取 user,沒獲取到的話就通過 retrieveUser 獲取。

該類中 retrieveUser 是一個抽象方法,我們現在來看 DaoAuthenticationProvider 類裡的方法。

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

從程式碼中可以看到是通過我們之前寫的 UserDetailsService 方法獲取使用者。

接下來我們看後面的程式碼,這部分異常程式碼我們等會再看。

try {
            preAuthenticationChecks.check(user);
            additionalAuthenticationChecks(user,
                    (UsernamePasswordAuthenticationToken) authentication);
        }

這兩句程式碼是對使用者進行檢查的,第一行程式碼呼叫的其實是這部分的:

private class DefaultPreAuthenticationChecks implements UserDetailsChecker {
        public void check(UserDetails user) {
            if (!user.isAccountNonLocked()) {
                logger.debug("User account is locked");

                throw new LockedException(messages.getMessage(
                        "AbstractUserDetailsAuthenticationProvider.locked",
                        "User account is locked"));
            }

            if (!user.isEnabled()) {
                logger.debug("User account is disabled");

                throw new DisabledException(messages.getMessage(
                        "AbstractUserDetailsAuthenticationProvider.disabled",
                        "User is disabled"));
            }

            if (!user.isAccountNonExpired()) {
                logger.debug("User account is expired");

                throw new AccountExpiredException(messages.getMessage(
                        "AbstractUserDetailsAuthenticationProvider.expired",
                        "User account has expired"));
            }
        }
    }

可以看到並不是檢查密碼的,只是對使用者狀態進行檢查。那麼我們不管它了,看下一行程式碼:

@SuppressWarnings("deprecation")
    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"));
        }
    }

這裡有個獲取密碼的操作:authentication.getCredentials()。

然後如果密碼不為空的話就通過 passwordEncoder.matches(presentedPassword, userDetails.getPassword() 檢查是否匹配。

如果匹配成功的話,嗯,這部分結束了,我們回到 AbstractUserDetailsAuthenticationProvider 類裡的 authenticate 方法。

return createSuccessAuthentication(principalToReturn, authentication, user);

它會返回一個建立成功認證方法的返回值。這裡我們就不管了。

現在我們先回到AbstractUserDetailsAuthenticationProvider 類的錯誤處理上:

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

這個是使用者找不到引起的錯誤,我們看下 messages.getMessage():

    public String getMessage(String code, String defaultMessage) {
        String msg = this.messageSource.getMessage(code, null, defaultMessage, getDefaultLocale());
        return (msg != null ? msg : "");
    }

再來看下這個裡面的 getMessage():

它是一個介面類裡的方法:根據 code 返回 messageSource 裡的字串,如果不存在這個 code,就返回 defaultMessage。

既然是個介面類,那我們看下它的實現類,回到 messageSource,檢視一下它:

public class SpringSecurityMessageSource extends ResourceBundleMessageSource {
    // ~ Constructors
    // ===================================================================================================

    public SpringSecurityMessageSource() {
        setBasename("org.springframework.security.messages");
    }

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

    public static MessageSourceAccessor getAccessor() {
        return new MessageSourceAccessor(new SpringSecurityMessageSource());
    }
}

原來是從這個路徑裡找資料來源。

其他的錯誤處理也是一樣,這裡就省略了。那我們如何獲取錯誤資訊呢?

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // TODO Auto-generated method stub
        http
        .authorizeRequests()
        .anyRequest().permitAll()
        .and()
        .formLogin().loginPage("/signin")
        .usernameParameter("username")
        .passwordParameter("password")
        .loginProcessingUrl("/signin")
        .failureHandler(authenticationFailureHandler)
        .and()
        .csrf().disable();

看到那個 failureHandler 沒,這個是登入失敗處理器,這裡加上只是看一下里面原始碼:AbstractAuthenticationFilterConfigurer

    /**
     * Specifies the {@link AuthenticationFailureHandler} to use when authentication
     * fails. The default is redirecting to "/login?error" using
     * {@link SimpleUrlAuthenticationFailureHandler}
     *
     * @param authenticationFailureHandler the {@link AuthenticationFailureHandler} to use
     * when authentication fails.
     * @return the {@link FormLoginConfigurer} for additional customization
     */
    public final T failureHandler(
            AuthenticationFailureHandler authenticationFailureHandler) {
        this.failureUrl = null;
        this.failureHandler = authenticationFailureHandler;
        return getSelf();
    }

從註釋中可以看出預設的失敗處理器是 SimpleUrlAuthenticationFailureHandler:

    public void onAuthenticationFailure(HttpServletRequest request,
            HttpServletResponse response, AuthenticationException exception)
            throws IOException, ServletException {

        if (defaultFailureUrl == null) {
            logger.debug("No failure URL set, sending 401 Unauthorized error");

            response.sendError(HttpStatus.UNAUTHORIZED.value(),
                HttpStatus.UNAUTHORIZED.getReasonPhrase());
        }
        else {
            saveException(request, exception);

            if (forwardToDestination) {
                logger.debug("Forwarding to " + defaultFailureUrl);

                request.getRequestDispatcher(defaultFailureUrl)
                        .forward(request, response);
            }
            else {
                logger.debug("Redirecting to " + defaultFailureUrl);
                redirectStrategy.sendRedirect(request, response, defaultFailureUrl);
            }
        }
    }

因為預設的 defaultFailureUrl 為 /login?error,從 AbstractAuthenticationFilterConfigurer 類裡可以看出來。

登入失敗後,會呼叫 saveException(request, exception); 儲存錯誤資訊。

protected final void saveException(HttpServletRequest request,
            AuthenticationException exception) {
        if (forwardToDestination) {
            request.setAttribute(WebAttributes.AUTHENTICATION_EXCEPTION, exception);
        }
        else {
            HttpSession session = request.getSession(false);

            if (session != null || allowSessionCreation) {
                request.getSession().setAttribute(WebAttributes.AUTHENTICATION_EXCEPTION,
                        exception);
            }
        }
    }

由於該類中 forwardToDestination 為 false,它將執行 else 裡的語句。

將錯誤資訊儲存到會話的 WebAttributes.AUTHENTICATION_EXCEPTION 屬性中:

public static final String AUTHENTICATION_EXCEPTION = "SPRING_SECURITY_LAST_EXCEPTION";

所有我們可以通過會話的這個屬性來獲取錯誤資訊。(thymeleaf)

(注意:signin.html 不能放在 static 目錄下,不然獲取不到錯誤資訊。)

<p th:if="${param.error}" th:text="${session?.SPRING_SECURITY_LAST_EXCEPTION?.message}" ></p>

好啦,都介紹完了,可以看下我的 CustomAuthenticationProvider:

package security.config;

import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.util.Assert;

public class CustomAuthenticationProvider extends DaoAuthenticationProvider {
    
    @Override
    protected void additionalAuthenticationChecks(UserDetails userDetails,
            UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
        // TODO Auto-generated method stub

        String presentedPassword = authentication.getCredentials().toString();
        if (!getPasswordEncoder().matches(presentedPassword, userDetails.getPassword())) {
            logger.debug("Authentication failed: password does not match stored value");

            throw new BadCredentialsException(messages.getMessage(
                    "UNameOrPwdIsError","Username or Password is not correct"));
        }
    }

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        // TODO Auto-generated method stub
        Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
                () -> messages.getMessage(
                        "AbstractUserDetailsAuthenticationProvider.onlySupports",
                        "Only UsernamePasswordAuthenticationToken is supported"));

        if("".equals(authentication.getPrincipal())) {
            throw new BadCredentialsException(messages.getMessage(
                    "UsernameIsNull","Username cannot be empty"));
        }
        if("".equals(authentication.getCredentials())) {
            throw new BadCredentialsException(messages.getMessage(
                    "PasswordIsNull","Password cannot be empty"));
        }
        
        String username = (String) authentication.getPrincipal();
        boolean cacheWasUsed = true;
        UserDetails user = this.getUserCache().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(
                            "UNameOrPwdIsError","Username or Password is not correct"));
                }
                else {
                    throw notFound;
                }
            }
            Assert.notNull(user,
                    "retrieveUser returned null - a violation of the interface contract");
        }
        try {
            getPreAuthenticationChecks().check(user);
            additionalAuthenticationChecks(user,
                    (UsernamePasswordAuthenticationToken) authentication);
        }
        catch (AuthenticationException exception) {
            if (cacheWasUsed) {
                cacheWasUsed = false;
                user = retrieveUser(username,
                        (UsernamePasswordAuthenticationToken) authentication);
                getPreAuthenticationChecks().check(user);
                additionalAuthenticationChecks(user,
                        (UsernamePasswordAuthenticationToken) authentication);
            }
            else {
                throw exception;
            }
        }

        getPostAuthenticationChecks().check(user);

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

        Object principalToReturn = user;

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

        return createSuccessAuthentication(principalToReturn, authentication, user);
    }
    

}

這裡值得注意的是 "".equals(authentication.getPrincipal()),"".equals(authentication.getCredentials())

因為如果按照那個 AbstractUserDetailsAuthenticationProvider 類來寫的話,發現這一步永不為 null。

我通過加入程式碼 System.out.println(username);  才知道的,應該是個坑吧。

專案程式碼可供大家參考:

連結:https://pan.baidu.com/s/1pNWQMyIgZOzX5_rF3Tvd2A
提取碼: