小米安全Dayeh:《Spring Security入坑指南2》

小米安全中心發表於2019-03-15

Spring Security 02 — Basic Introduction Part II
Spring Security 5.1.4 RELEASE

 

書接上文,在上一篇中介紹了Spring Security的基本元件以及基本的認證流程,此篇介紹一下Spring Security的一些核心服務。

 

2 核心服務

 

Spring Security中還有許多十分重要的介面,特別是AuthenticationManager, UserDetailsService 和AccessDecisionManager,Spring Security提供了一些實現,使用者亦可自己實現定製的認證授權機制。下面我們來具體看一下幾個介面及Spring Security提供的實現,從而有助於瞭解在認證授權環節中它們的具體作用,以及如何使用。

 

2.1 AuthenticationManager, ProviderManager and AuthenticationProvider

 

AuthenticationManger是一個介面,用來完成認證的邏輯,開發者可按照自己的專案設計需求進行實現。如果,我們希望能夠組合使用多種認證服務,比如基於資料庫和LDAP伺服器的認證服務,Spring Security也是支援的。

 

ProviderManager是Spring Security提供的一個實現,但是它本身不處理認證請求,而是將任務委託給一個配置好的AuthenticationProvider的列表,其中每一個AuthenticationProvider按序確認能否完成認證,每個provider如果認證失敗,會丟擲一個異常,如果認證通過,則會返回一個Authentication物件。

 

AuthenticationManager

 

AuthenticationManger是一個介面,其中只有一個方法authenticate,用來嘗試對傳入的Authentication物件進行認證。

 

這裡保留了原始碼中的一大段註釋,其中包括了該介面的作用,以及在實現時應當注意的一些問題,這裡不多做敘述,我們目前只需要瞭解這個介面的作用即可,感興趣的同學可以自行閱讀原始碼中的註釋。

/**
 * Processes an {@link Authentication} request.
 *
 * @author Ben Alex
 */
public interface AuthenticationManager {
    // ~ Methods
    // ========================================================================================================

    /**
     * Attempts to authenticate the passed {@link Authentication} object, returning a
     * fully populated <code>Authentication</code> object (including granted authorities)
     * if successful.
     * <p>
     * An <code>AuthenticationManager</code> must honour the following contract concerning
     * exceptions:
     * <ul>
     * <li>A {@link DisabledException} must be thrown if an account is disabled and the
     * <code>AuthenticationManager</code> can test for this state.</li>
     * <li>A {@link LockedException} must be thrown if an account is locked and the
     * <code>AuthenticationManager</code> can test for account locking.</li>
     * <li>A {@link BadCredentialsException} must be thrown if incorrect credentials are
     * presented. Whilst the above exceptions are optional, an
     * <code>AuthenticationManager</code> must <B>always</B> test credentials.</li>
     * </ul>
     * Exceptions should be tested for and if applicable thrown in the order expressed
     * above (i.e. if an account is disabled or locked, the authentication request is
     * immediately rejected and the credentials testing process is not performed). This
     * prevents credentials being tested against disabled or locked accounts.
     *
     * @param authentication the authentication request object
     *
     * @return a fully authenticated object including credentials
     *
     * @throws AuthenticationException if authentication fails
     */
    Authentication authenticate(Authentication authentication)
            throws AuthenticationException;
}

ProviderManager

 

ProviderManager是Authentication的一個實現,並將具體的認證操作委託給一系列的AuthenticationProvider來完成,從而可以實現支援多種認證方式。為了幫助閱讀和理解原始碼具體做了什麼,這裡刪除了原來的一部分註釋,並對重要的部分進行了註釋說明。

public class ProviderManager implements AuthenticationManager, MessageSourceAware,
        InitializingBean {

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

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

    /**
     * 用List<AuthenticationProvider>初始化一個ProviderManager
     * 也就是上文提到的,ProviderManager將具體的認證委託給不同的provider,從而支援不同的認證方式
     */      
    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 void afterPropertiesSet() throws Exception {
        checkState();
    }

    private void checkState() {
        if (parent == null && providers.isEmpty()) {
            throw new IllegalArgumentException(
                    "A parent AuthenticationManager or a list "
                            + "of AuthenticationProviders is required");
        }
    }

    /**
     * ProviderManager的核心方法,authentication方法嘗試對傳入的Authentication物件進行認證,傳入的Authentication是
     * 以使用者的提交的認證資訊,比如使用者名稱和密碼,建立的一個Authentication物件。
     * 
     * 會依次詢問各個AuthenticationProvider,當provider支援對傳入的Authentication認證,
     * 便會嘗試使用該provider進行認證。如果有多個provider都支援認證傳入的Authentication物件,
     * 則只會使用第一個支援的provider進行認證。
     *
     * 一旦有一個provider認證成功了,便會忽略之前任何provider丟擲的異常,之後的provider也不會再
     * 繼續認證的嘗試。
     *
     * 如果所有provider都認證失敗,方法則會丟擲最後一個provider丟擲的異常。
     */
    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();

        // 依次使用各個provider嘗試進行認證
        for (AuthenticationProvider provider : getProviders()) {
            // 如果provider不支援對傳入的Authentication進行認證,則跳過。
            if (!provider.supports(toTest)) {
                continue;
            }

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

            try {
                // 呼叫provider的authenticate方法進行認證
                result = provider.authenticate(authentication);

                // 如果認證成功,則將authentication中使用者的細節資訊複製到result中
                // 然後跳出迴圈,不再嘗試後面其他的provider
                if (result != null) {
                    copyDetails(authentication, result);
                    break;
                }
            }
            catch (AccountStatusException e) {
                prepareException(e, authentication);
                // 如果待認證的賬號資訊無誤,但是賬號本身異常,比如賬號停用了,則丟擲AccountStatusException異常,
                // 並通過prepareException方法,釋出一個AbstractAuthenticationFailureEvent,避免繼續嘗試其他provider進行認證
                throw e;
            }
            catch (InternalAuthenticationServiceException e) {
                prepareException(e, authentication);
                throw e;
            }
            catch (AuthenticationException e) {
                // 如果該provider認證失敗,捕獲異常AuthenticationException後不丟擲,繼續嘗試下一個provider
                // lastException會記錄下最後一個認證失敗的provider丟擲的AuthenticationException異常。
                lastException = e;
            }
        }

        if (result == null && parent != null) {
            // 如果所有provider都沒能認證成功,則交給父類嘗試認證
            try {
                result = parentResult = parent.authenticate(authentication);
            }
            catch (ProviderNotFoundException e) {
                // 父類如果丟擲該異常不做處理,因為後面有對子類丟擲該異常的處理
            }
            catch (AuthenticationException e) {
                // 父類也沒能認證成功,則最後一個異常為來自父類認證失敗的異常
                lastException = parentException = e;
            }
        }

        if (result != null) {
            if (eraseCredentialsAfterAuthentication
                    && (result instanceof CredentialsContainer)) {
                // 認證成功,從Authentication中刪除密碼祕鑰等敏感資訊
                ((CredentialsContainer) result).eraseCredentials();
            }

            // 如果父類認證成功,則會釋出一個AuthenticationSuccessEvent,
            // 這一步檢查,防止子類重複釋出
            if (parentResult == null) {
                eventPublisher.publishAuthenticationSuccess(result);
            }

            //返回的result為一個Authentication,其中包含了已認證使用者的資訊
            return result;

        }

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

        // 如果父類認證失敗,會釋出一個AbstractAuthenticationFailureEvent
        // 這一步檢查,防止子類重複釋出
        if (parentException == null) {
            prepareException(lastException, authentication);
        }

        throw lastException;
    }

    @SuppressWarnings("deprecation")
    private void prepareException(AuthenticationException ex, Authentication auth) {
        eventPublisher.publishAuthenticationFailure(ex, auth);
    }

    /**
     * 從source中複製使用者的資訊到dest
     *
     * Copies the authentication details from a source Authentication object to a
     * destination one, provided the latter does not already have one set.
     *
     * @param source source authentication
     * @param dest the destination authentication object
     */
    private void copyDetails(Authentication source, Authentication dest) {
        if ((dest instanceof AbstractAuthenticationToken) && (dest.getDetails() == null)) {
            AbstractAuthenticationToken token = (AbstractAuthenticationToken) dest;

            token.setDetails(source.getDetails());
        }
    }

    public List<AuthenticationProvider> getProviders() {
        return providers;
    }

    public void setMessageSource(MessageSource messageSource) {
        this.messages = new MessageSourceAccessor(messageSource);
    }

    public void setAuthenticationEventPublisher(
            AuthenticationEventPublisher eventPublisher) {
        Assert.notNull(eventPublisher, "AuthenticationEventPublisher cannot be null");
        this.eventPublisher = eventPublisher;
    }

    /**
     * 設定是否要在認證完成後,讓Authentication呼叫自己的eraseCredentials方法來清除密碼資訊。
     *
     * If set to, a resulting {@code Authentication} which implements the
     * {@code CredentialsContainer} interface will have its
     * {@link CredentialsContainer#eraseCredentials() eraseCredentials} method called
     * before it is returned from the {@code authenticate()} method.
     *
     * @param eraseSecretData set to {@literal false} to retain the credentials data in
     * memory. Defaults to {@literal true}.
     */
    public void setEraseCredentialsAfterAuthentication(boolean eraseSecretData) {
        this.eraseCredentialsAfterAuthentication = eraseSecretData;
    }

    public boolean isEraseCredentialsAfterAuthentication() {
        return eraseCredentialsAfterAuthentication;
    }

    private static final class NullEventPublisher implements AuthenticationEventPublisher {
        public void publishAuthenticationFailure(AuthenticationException exception,
                Authentication authentication) {
        }

        public void publishAuthenticationSuccess(Authentication authentication) {
        }
    }
}

至此可以看到,ProviderManager的認證邏輯還是很簡單清晰的,我們也可以比較清楚理解AuthenticationManager,ProviderManager和AuthenticationProvider的關係了。

 

AuthenticationProvider

 

AuthenticationProvider也是一個介面,用來完成具體的認證邏輯。不同的認證方式有不同的實現,Spring Security中提供了多種實現,包括DaoAuthenticationProvider,AnonymousAuthenticationProvider和LdapAuthenticationProvider等。其中最簡單的DaoAuthenticationProvider會在後面介紹,首先來看一下AuthenticationProvider的原始碼。同樣的,保留了原始碼中的註釋,感興趣的同學可以細讀,這裡只做簡單的介紹。

 

可以看到,AuthenticationProvider中只有2個方法:

  • authenticate完成具體的認證邏輯,如果認證失敗,丟擲AuthenticationException異常
  • supports判斷是否支援傳入的Authentication認證資訊

    /**

    • Indicates a class can process a specific
    • {@link org.springframework.security.core.Authentication} implementation.
      *
    • @author Ben Alex
      */
      public interface AuthenticationProvider {
      // ~ Methods
      // ========================================================================================================

      /**

      • Performs authentication with the same contract as
      • {@link org.springframework.security.authentication.AuthenticationManager#authenticate(Authentication)}
      • .
        *
      • @param authentication the authentication request object.
        *
      • @return a fully authenticated object including credentials. May return
      • <code>null</code> if the <code>AuthenticationProvider</code> is unable to support
      • authentication of the passed <code>Authentication</code> object. In such a case,
      • the next <code>AuthenticationProvider</code> that supports the presented
      • <code>Authentication</code> class will be tried.
        *
      • @throws AuthenticationException if authentication fails.
        */
        Authentication authenticate(Authentication authentication)

         throws AuthenticationException;
        

        /**

      • Returns <code>true</code> if this <Code>AuthenticationProvider</code> supports the
      • indicated <Code>Authentication</code> object.
      • <p>

      • Returning <code>true</code> does not guarantee an
      • <code>AuthenticationProvider</code> will be able to authenticate the presented
      • instance of the <code>Authentication</code> class. It simply indicates it can
      • support closer evaluation of it. An <code>AuthenticationProvider</code> can still
      • return <code>null</code> from the {@link #authenticate(Authentication)} method to
      • indicate another <code>AuthenticationProvider</code> should be tried.
      • </p>
      • <p>

      • Selection of an <code>AuthenticationProvider</code> capable of performing
      • authentication is conducted at runtime the <code>ProviderManager</code>.
      • </p>
        *
      • @param authentication
        *
      • @return <code>true</code> if the implementation can more closely evaluate the
      • <code>Authentication</code> class presented
        */
        boolean supports(Class<?> authentication);
        }

DaoAuthenticationProvider

 

DaoAuthenticationProvider是Spring Security提供的最簡單的一個AuthenticationProvider的實現,也是框架中最早支援的。它使用UserDetailsService作為一個DAO來查詢使用者名稱、密碼以及使用者的許可權GrantedAuthority。它認證使用者的方式就是簡單的比較UsernamePasswordAuthenticationToken中由使用者提交的密碼和通過UserDetailsService查詢獲得的密碼是否一致。

 

下面我們來看一下DaoAuthenticationProvider的原始碼,對於原始碼的說明也寫在了註釋中。

 

DaoAuthenticationProvider繼承了AbstractUserDetailsAuthenticationProvider,而後者實現了AuthenticationProvider介面。

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

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

    @SuppressWarnings("deprecation")
    protected void additionalAuthenticationChecks(UserDetails userDetails,
            UsernamePasswordAuthenticationToken authentication)
            throws AuthenticationException {

        // 使用者未提交密碼,丟擲異常BadCredentialsException
        if (authentication.getCredentials() == null) {
            logger.debug("Authentication failed: no credentials provided");

            throw new BadCredentialsException(messages.getMessage(
                    "AbstractUserDetailsAuthenticationProvider.badCredentials",
                    "Bad credentials"));
        }

        // 從傳入了Authentication物件中獲取使用者提交的密碼
        String presentedPassword = authentication.getCredentials().toString();

        // 用passwordEncoder的matches方法,比較使用者提交的密碼和userDetails中查詢到的正確密碼。
        // 由於使用者密碼的存放一般都是hash後保密的,因此userDetails獲取到的密碼一般是一個hash值,而使用者提交
        // 的是一個明文密碼,因此需要對使用者提交的密碼進行同樣的hash計算後再進行比較。
        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"));
        }
    }

    protected void doAfterPropertiesSet() throws Exception {
        Assert.notNull(this.userDetailsService, "A UserDetailsService must be set");
    }

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

    @Override
    protected Authentication createSuccessAuthentication(Object principal,
            Authentication authentication, UserDetails user) {
        boolean upgradeEncoding = this.userDetailsPasswordService != null
                && this.passwordEncoder.upgradeEncoding(user.getPassword());
        if (upgradeEncoding) {
            String presentedPassword = authentication.getCredentials().toString();
            String newPassword = this.passwordEncoder.encode(presentedPassword);
            user = this.userDetailsPasswordService.updatePassword(user, newPassword);
        }
        return super.createSuccessAuthentication(principal, authentication, user);
    }

    private void prepareTimingAttackProtection() {
        if (this.userNotFoundEncodedPassword == null) {
            this.userNotFoundEncodedPassword = this.passwordEncoder.encode(USER_NOT_FOUND_PASSWORD);
        }
    }

    private void mitigateAgainstTimingAttack(UsernamePasswordAuthenticationToken authentication) {
        if (authentication.getCredentials() != null) {
            String presentedPassword = authentication.getCredentials().toString();
            this.passwordEncoder.matches(presentedPassword, this.userNotFoundEncodedPassword);
        }
    }

    /**
     * Sets the PasswordEncoder instance to be used to encode and validate passwords. If
     * not set, the password will be compared using {@link PasswordEncoderFactories#createDelegatingPasswordEncoder()}
     *
     * @param passwordEncoder must be an instance of one of the {@code PasswordEncoder}
     * types.
     */
    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

 

可以看到DaoAuthenticationProvider繼承自AbstractUserDetailsAuthenticationProvider, 而一個provider最核心的authenticate方法,便寫在了AbstractUserDetailsAuthenticationProvider中,下面我們只關注一下authenticate這個方法的原始碼。

public Authentication authenticate(Authentication authentication)
            throws AuthenticationException {
        Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
                () -> messages.getMessage(
                        "AbstractUserDetailsAuthenticationProvider.onlySupports",
                        "Only UsernamePasswordAuthenticationToken is supported"));

        // 從傳入的Authentication物件中獲取使用者名稱
        String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED"
                : authentication.getName();

        // 根據使用者名稱,從快取中獲取使用者的UserDetails
        boolean cacheWasUsed = true;
        UserDetails user = this.userCache.getUserFromCache(username);

        if (user == null) {
            cacheWasUsed = false;
            // 如果從快取中沒有獲取到使用者,則通過方法retrieveUser來獲取使用者資訊
            // retrieve方法為一個抽象方法,不同的子類中有不同的實現,而在子類中,一般又會通過UserDetailService來獲取使用者資訊,返回UserDetails
            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為具體的認證邏輯,是一個抽象方法,在子類中實現。
            // 比如前文中DaoAuthenticationProvider中,便是比較使用者提交的密碼和UserDetails中的密碼
            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);
    }

可以看到在DaoAuthenticationProvider中還用到UserDetailsService來查詢使用者的密碼許可權資訊,幷包裝為UserDetails返回,然後與使用者提交的使用者名稱密碼資訊進行比較來完成認證。UserDetailsService和UserDetails在不同的provider中都會被用到,關於這兩個介面的說明,在下一篇文章中介紹。

 

總結

 

最後我們總結一下這幾個介面和類

 

 

這些類和介面之間的關係,大致可以用下圖進行表示

 

相關文章