1. 程式人生 > >spring security小結

spring security小結

Spring Security 主要實現了Authentication(認證,解決who are you? ) 和 Access Control(訪問控制,也就是what are you allowed to do?,也稱為Authorization)。Spring Security在架構上將認證與授權分離,並提供了擴充套件點。

核心物件:

SecurityContextHolder 是 SecurityContext的存放容器,預設使用ThreadLocal 儲存,意味SecurityContext在相同執行緒中的方法都可用。

SecurityContext主要是儲存應用的principal資訊,在Spring Security中用Authentication 來表示。

獲取principal:

Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();

if (principal instanceof UserDetails) {
String username = ((UserDetails)principal).getUsername();
} else {
String username = principal.toString();
}

在Spring Security中,可以看一下Authentication定義:

public interface Authentication extends Principal, Serializable {

    Collection<? extends GrantedAuthority> getAuthorities();

    /**
     * 通常是密碼
     */
    Object getCredentials();

    /**
     * Stores additional details about the authentication request. These might be an IP
     * address, certificate serial number etc.
     */
    Object getDetails();

    /**
     * 用來標識是否已認證,如果使用使用者名稱和密碼登入,通常是使用者名稱 
     */
    Object getPrincipal();

    /**
     * 是否已認證
     */
    boolean isAuthenticated();

    void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}

在實際應用中,通常使用UsernamePasswordAuthenticationToken

public abstract class AbstractAuthenticationToken implements Authentication,
        CredentialsContainer {
        }
public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
}

一個常見的認證過程通常是這樣的,建立一個UsernamePasswordAuthenticationToken,然後交給authenticationManager認證,

認證通過則通過SecurityContextHolder存放Authentication資訊。

 UsernamePasswordAuthenticationToken authenticationToken =
            new UsernamePasswordAuthenticationToken(loginVM.getUsername(), loginVM.getPassword());

Authentication authentication = this.authenticationManager.authenticate(authenticationToken);
SecurityContextHolder.getContext().setAuthentication(authentication);

UserDetails 是Spring Security裡的一個關鍵介面,用來表示一個principal。

public interface UserDetails extends Serializable {
    /**
     * 使用者的授權資訊,可以理解為角色
     */
    Collection<? extends GrantedAuthority> getAuthorities();

    /**
     * 使用者密碼
     *
     * @return the password
     */
    String getPassword();

    /**
     * 使用者名稱 
     *   */
    String getUsername();

    boolean isAccountNonExpired();  // 使用者是否過期

    boolean isAccountNonLocked();  // 是否鎖定

    boolean isCredentialsNonExpired(); // 使用者密碼是否過期

    boolean isEnabled(); // 賬號是否可用
}

UserDetails提供了認證所需的必要資訊,在實際使用裡,可以自己實現UserDetails,並增加額外的資訊,比如email、mobile等資訊。

在Authentication中的principal通常是使用者名稱,我們可以通過UserDetailsService來通過principal獲取UserDetails:

public interface UserDetailsService {
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

小結

  • SecurityContextHolder, 用來訪問 SecurityContext.
  • SecurityContext, 用來儲存Authentication .
  • Authentication, 代表憑證.
  • GrantedAuthority, 代表權限.
  • UserDetails, 對使用者資訊進行封裝.
  • UserDetailsService,對使用者資訊進行管理.

整個過程:

Authentication認證

1、使用者進入登入頁面,輸入使用者名稱和密碼,security首先會進入UsernamePasswordAuthenticationFilter,呼叫

attemptAuthentication方法,將使用者名稱和密碼作為pricaipal和critial組合成UsernamePasswordAuthenticationToken例項

2、將令牌傳遞給AuthenticationManage例項進行驗證,根據使用者名稱查詢到使用者,在進行密碼比對

3、校驗成功後,會把查詢到的user物件填充到authenticaton物件中,並將標誌authenticated設為true

4、通過呼叫 SecurityContextHolder.getContext().setAuthentication(...) 建立安全上下文的例項,傳遞到返回的身份認證物件上

AbstractAuthenticationProcessingFilter 抽象類

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
			throws IOException, ServletException {

		HttpServletRequest request = (HttpServletRequest) req;
		HttpServletResponse response = (HttpServletResponse) res;

		if (!requiresAuthentication(request, response)) {
			chain.doFilter(request, response);

			return;
		}

		if (logger.isDebugEnabled()) {
			logger.debug("Request is to process authentication");
		}

		Authentication authResult;

		try {
			authResult = attemptAuthentication(request, response);
			if (authResult == null) {
				// return immediately as subclass has indicated that it hasn't completed
				// authentication
				return;
			}
			sessionStrategy.onAuthentication(authResult, request, response);
		}
		catch (InternalAuthenticationServiceException failed) {
			logger.error(
					"An internal error occurred while trying to authenticate the user.",
					failed);
			unsuccessfulAuthentication(request, response, failed);

			return;
		}
		catch (AuthenticationException failed) {
			// Authentication failed
			unsuccessfulAuthentication(request, response, failed);

			return;
		}

		// Authentication success
		if (continueChainBeforeSuccessfulAuthentication) {
			chain.doFilter(request, response);
		}

		successfulAuthentication(request, response, chain, authResult);
	}

呼叫requestsAuthentication()決定是否需要進行校驗,如果需要驗證,則會呼叫attemptAuthentication()進行校驗,有三種結果:

1、驗證成功,返回一個填充好的Authentication物件(通常帶上authenticated=true),接著執行successfulAuthentication()

2、驗證失敗,丟擲AuthenticationException,接著執行unsuccessfulAuthentication()

3、返回null,表示身份驗證不完整。假設子類做了一些必要的工作(如重定向)來繼續處理驗證,方法將立即返回。假設後一個請求將被這種方法接收,其中返回的Authentication物件不為空。

AuthenticationException是執行時異常,它通常由應用程式按通用方式處理,使用者程式碼通常不用特意被捕獲和處理這個異常。

UsernamePasswordAuthenticationFilter(AbstractAuthenticationProcessingFilter的子類)

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

		// Allow subclasses to set the "details" property
		setDetails(request, authRequest);

		return this.getAuthenticationManager().authenticate(authRequest);
	}

attemptAuthentication () 方法將 request 中的 username 和 password 生成 UsernamePasswordAuthenticationToken 物件,用於 AuthenticationManager 的驗證(即 this.getAuthenticationManager().authenticate(authRequest) )

預設情況下注入 Spring 容器的 AuthenticationManagerProviderManager

ProviderManager(AuthenticationManager的實現類)

public Authentication authenticate(Authentication authentication)
			throws AuthenticationException {
		Class<? extends Authentication> toTest = authentication.getClass();
		AuthenticationException lastException = null;
		Authentication result = 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 {
                // 如果沒有任何一個 Provider 驗證成功,則使用父型別AuthenticationManager進行驗證
				result = 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 = e;
			}
		}

		if (result != null) {
            // 擦除敏感資訊
			if (eraseCredentialsAfterAuthentication
					&& (result instanceof CredentialsContainer)) {
				// Authentication is complete. Remove credentials and other secret data
				// from authentication
				((CredentialsContainer) result).eraseCredentials();
			}

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

		prepareException(lastException, authentication);

		throw lastException;
	}

AuthenticationManager的預設實現是ProviderManager,它委託一組AuthenticationProvider例項來實現認證。AuthenticationProviderAuthenticationManager類似,都包含authenticate,但它有一個額外的方法supports,以允許查詢呼叫方是否支援給定Authentication型別:

public interface AuthenticationProvider {

    Authentication authenticate(Authentication authentication)
            throws AuthenticationException;
    boolean supports(Class<?> authentication);
}

ProviderManager包含一組AuthenticationProvider,執行authenticate時,遍歷Providers,然後呼叫supports,如果支援,則執行遍歷當前provider的authenticate方法,如果一個provider認證成功,則break。如果最後所有的 AuthenticationProviders 都沒有成功驗證 Authentication 物件,將丟擲 AuthenticationException。

由 provider 來驗證 authentication, 核心點方法是Authentication result = provider.authenticate(authentication);此處的 providerAbstractUserDetailsAuthenticationProvider,它是AuthenticationProvider的實現,看看它的 authenticate(authentication) 方法

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

		// 獲取使用者名稱
		String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED"
				: authentication.getName();

		boolean cacheWasUsed = true;
        // 從快取中獲取
		UserDetails user = this.userCache.getUserFromCache(username);

		if (user == null) {
			cacheWasUsed = false;

			try {
                // retrieveUser抽象方法,獲取使用者
				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 {
            // 預先檢查 DefaultPreAuthenticationChecks,檢查使用者是否被lock或者賬號是否可用
			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;
			}
		}
        // 後置檢查 DefaultPostAuthenticationChecks,檢查isCredentialsNonExpired
		postAuthenticationChecks.check(user);

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

		Object principalToReturn = user;

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

		return createSuccessAuthentication(principalToReturn, authentication, user);
	}
 三步驗證工作
    1. preAuthenticationChecks
    2. additionalAuthenticationChecks(抽象方法,子類實現)
    3. postAuthenticationChecks

AbstractUserDetailsAuthenticationProvider 內建了快取機制,從快取中獲取不到的 UserDetails 資訊的話,就呼叫retrieveUser()方法獲取使用者資訊,然後和使用者傳來的資訊進行對比來判斷是否驗證成功。retrieveUser()方法在 DaoAuthenticationProvider 中實現, DaoAuthenticationProviderAbstractUserDetailsAuthenticationProvider的子類。這個類的核心是讓開發者提供UserDetailsService來獲取UserDetails以及 PasswordEncoder來檢驗密碼是否有效:

private UserDetailsService userDetailsService;
private PasswordEncoder passwordEncoder;

具體實現如下

protected final UserDetails retrieveUser(String username,
			UsernamePasswordAuthenticationToken authentication)
			throws AuthenticationException {
		UserDetails loadedUser;

		try {
			loadedUser = this.getUserDetailsService().loadUserByUsername(username);
		}
		catch (UsernameNotFoundException notFound) {
			if (authentication.getCredentials() != null) {
				String presentedPassword = authentication.getCredentials().toString();
				passwordEncoder.isPasswordValid(userNotFoundEncodedPassword,
						presentedPassword, null);
			}
			throw notFound;
		}
		catch (Exception repositoryProblem) {
			throw new InternalAuthenticationServiceException(
					repositoryProblem.getMessage(), repositoryProblem);
		}

		if (loadedUser == null) {
			throw new InternalAuthenticationServiceException(
					"UserDetailsService returned null, which is an interface contract violation");
		}
		return loadedUser;
	}

可以看到此處的返回物件 userDetails 是由 UserDetailsServiceloadUserByUsername(username) 來獲取的。

再來看驗證:

protected void additionalAuthenticationChecks(UserDetails userDetails,
            UsernamePasswordAuthenticationToken authentication)
            throws AuthenticationException {
        Object salt = null;

        if (this.saltSource != null) {
            salt = this.saltSource.getSalt(userDetails);
        }

        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();
        // 比較passwordEncoder後的密碼是否和userdetails的密碼一致
        if (!passwordEncoder.isPasswordValid(userDetails.getPassword(),
                presentedPassword, salt)) {
            logger.debug("Authentication failed: password does not match stored value");

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

小結:要自定義認證,使用DaoAuthenticationProvider,只需要為其提供PasswordEncoder和UserDetailsService就可以了。

時序圖

補充:SecurityContextHolder的工作原理

這是一個工具類,只提供一些靜態方法,目的是用來儲存應用程式中當前使用人的安全上下文。

預設工作模式 MODE_THREADLOCAL

我們知道,一個應用同時可能有多個使用者,每個使用者對應不同的安全上下文,那麼SecurityContextHolder是怎麼儲存這些安全上下文的呢 ?預設情況下,SecurityContextHolder使用了ThreadLocal機制來儲存每個使用者的安全上下文。這意味著,只要針對某個使用者的邏輯執行都是在同一個執行緒中進行,即使不在各個方法之間以引數的形式傳遞其安全上下文,各個方法也能通過SecurityContextHolder工具獲取到該安全上下文。只要在處理完當前使用者的請求之後注意清除ThreadLocal中的安全上下文,這種使用ThreadLocal的方式是很安全的。當然在Spring Security中,這些工作已經被Spring Security自動處理,開發人員不用擔心這一點。

SecurityContextHolder原始碼

public class SecurityContextHolder {
	// ~ Static fields/initializers
	// =====================================================================================

	public static final String MODE_THREADLOCAL = "MODE_THREADLOCAL";
	public static final String MODE_INHERITABLETHREADLOCAL = "MODE_INHERITABLETHREADLOCAL";
	public static final String MODE_GLOBAL = "MODE_GLOBAL";
	public static final String SYSTEM_PROPERTY = "spring.security.strategy";
	private static String strategyName = System.getProperty(SYSTEM_PROPERTY);
	private static SecurityContextHolderStrategy strategy;
	private static int initializeCount = 0;

	static {
		initialize();
	}

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

	/**
	 * Explicitly clears the context value from the current thread.
	 */
	public static void clearContext() {
		strategy.clearContext();
	}

	/**
	 * Obtain the current <code>SecurityContext</code>.
	 *
	 * @return the security context (never <code>null</code>)
	 */
	public static SecurityContext getContext() {
		return strategy.getContext();
	}

	/**
	 * Primarily for troubleshooting purposes, this method shows how many times the class
	 * has re-initialized its <code>SecurityContextHolderStrategy</code>.
	 *
	 * @return the count (should be one unless you've called
	 * {@link #setStrategyName(String)} to switch to an alternate strategy.
	 */
	public static int getInitializeCount() {
		return initializeCount;
	}

	private static void initialize() {
		if (!StringUtils.hasText(strategyName)) {
			// Set default
			strategyName = MODE_THREADLOCAL;
		}

		if (strategyName.equals(MODE_THREADLOCAL)) {
			strategy = new ThreadLocalSecurityContextHolderStrategy();
		}
		else if (strategyName.equals(MODE_INHERITABLETHREADLOCAL)) {
			strategy = new InheritableThreadLocalSecurityContextHolderStrategy();
		}
		else if (strategyName.equals(MODE_GLOBAL)) {
			strategy = new GlobalSecurityContextHolderStrategy();
		}
		else {
			// Try to load a custom strategy
			try {
				Class<?> clazz = Class.forName(strategyName);
				Constructor<?> customStrategy = clazz.getConstructor();
				strategy = (SecurityContextHolderStrategy) customStrategy.newInstance();
			}
			catch (Exception ex) {
				ReflectionUtils.handleReflectionException(ex);
			}
		}

		initializeCount++;
	}

	/**
	 * Associates a new <code>SecurityContext</code> with the current thread of execution.
	 *
	 * @param context the new <code>SecurityContext</code> (may not be <code>null</code>)
	 */
	public static void setContext(SecurityContext context) {
		strategy.setContext(context);
	}

	/**
	 * Changes the preferred strategy. Do <em>NOT</em> call this method more than once for
	 * a given JVM, as it will re-initialize the strategy and adversely affect any
	 * existing threads using the old strategy.
	 *
	 * @param strategyName the fully qualified class name of the strategy that should be
	 * used.
	 */
	public static void setStrategyName(String strategyName) {
		SecurityContextHolder.strategyName = strategyName;
		initialize();
	}

	/**
	 * Allows retrieval of the context strategy. See SEC-1188.
	 *
	 * @return the configured strategy for storing the security context.
	 */
	public static SecurityContextHolderStrategy getContextHolderStrategy() {
		return strategy;
	}

	/**
	 * Delegates the creation of a new, empty context to the configured strategy.
	 */
	public static SecurityContext createEmptyContext() {
		return strategy.createEmptyContext();
	}

	public String toString() {
		return "SecurityContextHolder[strategy='" + strategyName + "'; initializeCount="
				+ initializeCount + "]";
	}
}

Spring security獲取當前使用者

前面介紹過用SecurityContextHolder.getContext().getAuthentication() .getPrincipal();獲取當前使用者,還有一種方法:

經過spring security認證後,spring security會把一個SecurityContextImpl物件儲存到session中,此物件中有當前使用者的各種資料

SecurityContextImpl securityContextImpl = 
(SecurityContextImpl) request.getSession.getAttribute("SPRING_SECURITY_CONTEXT");
//登入名
System.out.println("Username:" + securityContextImpl.getAuthentication().getName());
//登入密碼,未加密的
System.out.println("Credentials:" + securityContextImpl.getAuthentication().getCredentials());
SecurityContextImpl原始碼
/**
 * Base implementation of {@link SecurityContext}.
 * <p>
 * Used by default by {@link SecurityContextHolder} strategies.
 *
 * @author Ben Alex
 */
public class SecurityContextImpl implements SecurityContext {

	private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

	// ~ Instance fields
	// ================================================================================================

	private Authentication authentication;

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

	public boolean equals(Object obj) {
		if (obj instanceof SecurityContextImpl) {
			SecurityContextImpl test = (SecurityContextImpl) obj;

			if ((this.getAuthentication() == null) && (test.getAuthentication() == null)) {
				return true;
			}

			if ((this.getAuthentication() != null) && (test.getAuthentication() != null)
					&& this.getAuthentication().equals(test.getAuthentication())) {
				return true;
			}
		}

		return false;
	}

	public Authentication getAuthentication() {
		return authentication;
	}

	public int hashCode() {
		if (this.authentication == null) {
			return -1;
		}
		else {
			return this.authentication.hashCode();
		}
	}

	public void setAuthentication(Authentication authentication) {
		this.authentication = authentication;
	}

	public String toString() {
		StringBuilder sb = new StringBuilder();
		sb.append(super.toString());

		if (this.authentication == null) {
			sb.append(": Null authentication");
		}
		else {
			sb.append(": Authentication: ").append(this.authentication);
		}

		return sb.toString();
	}
}

可以看出,主要就兩個方法getAuthentication()和setAuthentication()

問題: 如果修改了登入使用者的資訊,怎麼更新到security context中呢

目前想到的解決辦法是重新認證:

//首先在WebSecurityConfig中注入AuthenticationManager
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
//在Controller中注入
    @Autowired
    private AuthenticationManager authenticationManager;
//接下來就是在會修改使用者資訊的地方設定重新認證
//也可以通過SecurityContextHolder獲取
SecurityContextImpl securityContext = (SecurityContextImpl) request.getSession().getAttribute("SPRING_SECURITY_CONTEXT");
UsernamePasswordAuthenticationToken token = 
new UsernamePasswordAuthenticationToken(登入名,登入密碼);
Authentication authentication = authenticationManager.authenticate(token);
//重新設定authentication
securityContext.setAuthentication(authentication);

因為我做的是前後端分離,返回的都是json格式的資料,所以在專案中還遇到的問題有:

參考文章: