使用SpringSecurity搭建授權認證服務(1) -- 基本demo
登錄認證是做后台開發的最基本的能力,初學就知道一個interceptor或者filter攔截所有請求,然后判斷參數是否合理,如此即可。當涉及到某些接口權限的時候,則if-else判斷以下,也是沒問題的。
但如果判斷多了,業務邏輯也摻雜在一起,降低可讀性的同時也不利於擴展和維護。於是就出現了apache shiro, spring security這樣的框架,抽離出認證授權判斷。
由於我現在的項目都是給予springboot的,那選擇spring security就方便很多。接下來基於此構建我的認證授權服務: 基於Token的認證授權服務。
項目初始化
第一個版本,項目初始化https://github.com/Ryan-Miao/spring-security-token-login-server/releases/tag/v1.0
首先學習兩個單詞:
authentication 身份驗證
authorized 經授權的
我們登錄鑒權就是兩個步驟,先認證登錄,然后權限校驗。對應到Spring Security里就是 AuthenticationManager
和AccessDecisionManager
,前者負責對用戶憑證進行認證,后者對認證后的權限進行校驗。
首先,創建一個基本的springboot項目。
- 引入Springboot, Mybatis, Redis, Swagger, Spring Security
- 配置全局異常攔截
ExceptionInterceptor
- 配置Redis緩存,這里使用redisson,也可以直接使用starter
- 配置Spring Security Config
Spring Security參照官方文檔配置即可。接下來是自定義和可以修改的地方。
數據表權限模型
本項目簡單使用 user - role -permission的模型。
- 一個user可以有多個role
- 一個role可以指定給多個user
- 一個role可以擁有多個permission
- 一個permission也可以從屬於多個role
權限判定通過判斷user是否擁有permission來決定。通過role實現了user和permission之間的解耦,創建多個role模型,綁定對應的權限,當添加新用戶的時候,直接指定role就可以授權。
Spring Security自帶了org.springframework.security.provisioning.JdbcUserDetailsManager,它里面的模型為user-group-authority. 即用戶歸屬用戶組,用戶組有權限。差不多可以和當前模型一一對應。
認證流程
大體認證流程和涉及的核心類如下:
ApplicationFilterChain的filter順序:
FilterChainProxy(springSecurityFilterChain)執行認證的順序, 忽略的url將不命中任何filter, 而需要認證的url將通過VirtualFilterChain來認證。
使用Token認證
starter默認啟用的基於用戶名密碼的basic認證。
通過UsernamePasswordAuthenticationFilter組裝UsernamePasswordAuthenticationToken
去認證。
通過org.springframework.security.web.authentication.www.BasicAuthenticationFilter解析header Authorization
, 然后組裝成UsernamePasswordAuthenticationToken去給AuthenticationManager認證。
我們要做的就是模仿UsernamePasswordAuthenticationFilter
或者BasicAuthenticationFilter解析header
將我們的認證憑證傳遞給AuthenticationManager
.
兩種方式我實現了一遍,最終選擇了基於UsernamePasswordAuthenticationFilter來實現。
/**
* @author Ryan Miao
* @date 2019/5/30 10:11
* @see org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
*/
public class TokenAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
public TokenAuthenticationFilter(String defaultFilterProcessesUrl) {
super(defaultFilterProcessesUrl);
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response)
throws AuthenticationException, IOException, ServletException {
boolean debug = this.logger.isDebugEnabled();
String token = TokenUtils.readTokenFromRequest(request);
if (StringUtils.isBlank(token)) {
throw new UsernameNotFoundException("token not found");
}
if (debug) {
this.logger.debug("Token Authentication Authorization header found ");
}
//token包裝類, 使用principal來裝載token
UsernamePasswordAuthenticationToken tokenAuthenticationToken = new UsernamePasswordAuthenticationToken(
token, null);
//AuthenticationManager 負責解析
Authentication authResult = getAuthenticationManager()
.authenticate(tokenAuthenticationToken);
if (debug) {
this.logger.debug("Authentication success: " + authResult);
}
return authResult;
}
/**
* 重寫認證成功后的方法,不跳轉.
*/
@Override
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response, FilterChain chain, Authentication authResult)
throws IOException, ServletException {
if (this.logger.isDebugEnabled()) {
this.logger.debug(
"Authentication success. Updating SecurityContextHolder to contain: " + authResult);
}
SecurityContextHolder.getContext().setAuthentication(authResult);
getRememberMeServices().loginSuccess(request, response, authResult);
if (this.eventPublisher != null) {
this.eventPublisher.publishEvent(
new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
}
chain.doFilter(request, response);
}
}
- TokenUtils來從request里拿到我們的憑證,我這里是從cookie里取出token的值。
- 封裝給UsernamePasswordAuthenticationToken的username字段
- 交給getAuthenticationManager()去認證
認證Provider
上一步拿到用戶憑證,接下來就是對憑證進行認證。由AuthenticationManager提供。簡單理解下AuthenticationManager是什么。
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;
}
嘗試認證傳遞過來的Authentication對象(即我們的UsernamePasswordAuthenticationToken), 如果認證通過,返回全部信息以及authority權限,否則拋出AuthenticationException異常表示認證失敗。
AuthenticationManager的初始化比較復雜,繞了好多路。在我們的SecurityConfig里可以找到聲明的地方。
//com.example.serverapi.config.SecurityConfig#authenticationManagerBean
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
//com.example.serverapi.config.SecurityConfig#configure(org.springframework.security.config.annotation.web.builders.HttpSecurity)
TokenAuthenticationFilter filter = new TokenAuthenticationFilter("/**");
filter.setAuthenticationManager(authenticationManagerBean());
而AuthenticationManager是AuthenticationManagerDelegator來代替的,其代理的則是org.springframework.security.authentication.ProviderManager。
所以,我們定義provider來認證上一步的token是否合法。
//com.example.serverapi.config.SecurityConfig#configure(org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder)
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
//DaoAuthenticationConfigurer-DaoAuthenticationProvider用來提供登錄時用戶名和密碼認證
//auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
//自定義TokenAuthenticationProvider, 用來提供token認證
auth.authenticationProvider(new UserTokenAuthenticationProvider());
}
以下是provider全部信息
//com.example.serverapi.domain.security.config.UserTokenAuthenticationProvider
/**
* 這里只使用了username字段。
*
* @author Ryan Miao
* @date 2019/5/29 22:05
*/
public class UserTokenAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
@Override
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
}
@Override
protected UserDetails retrieveUser(String token,
UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
//驗證token
TokenManagement tokenManagement = ServerApiApplication.context
.getBean(TokenManagement.class);
com.example.serverapi.domain.security.entity.User userInfo = tokenManagement.get(token);
if (userInfo == null) {
throw new BadCredentialsException("token認證失敗");
}
authentication.setDetails(userInfo);
Set<SimpleGrantedAuthority> authorities = userInfo.getRoleList().stream()
.map(Role::getPermissionList)
.flatMap(Collection::stream)
.map(p -> new SimpleGrantedAuthority(p.getName())).collect(
Collectors.toSet());
return new User(userInfo.getUsername(), userInfo.getPassword(), authorities);
}
/**
* 對應我們的Token令牌類UsernamePasswordAuthenticationToken,可以采用本provide驗證.
*/
@Override
public boolean supports(Class<?> authentication) {
return (UsernamePasswordAuthenticationToken.class
.isAssignableFrom(authentication));
}
}
- supports方法來表示本provider提供的認證范圍,即傳遞UsernamePasswordAuthenticationToken的憑證將接受認證
- 自定義了我們自己的Token管理方法TokenManagement,來對token進行認證。根據token拿到userinfo則成功
- 從userInfo里提取authority,創建一個UserDetails,交給下一步的權限校驗
權限校驗
前面截圖里的filter chain,最前面是我們的自定義filter來認證的,最后面的FilterSecurityInterceptor則是權限校驗。
//spring-security-core-5.1.4.RELEASE-sources.jar!/org/springframework/security/access/intercept/AbstractSecurityInterceptor.java:229
Authentication authenticated = authenticateIfRequired();
// Attempt authorization
try {
this.accessDecisionManager.decide(authenticated, object, attributes);
}
catch (AccessDeniedException accessDeniedException) {
publishEvent(new AuthorizationFailureEvent(object, attributes, authenticated,
accessDeniedException));
throw accessDeniedException;
}
可以看到,調用accessDecisionManager來判斷是否繼續,權限不足則拋出AccessDeniedException,對應處理就是403了。
AccessDecisionManager目前看到有兩種,一個是全局配置,在我們配置Security Config里指定哪些url需要哪些權限。一個是method級別的配置,通過前者校驗后判斷method是否有權限。
AbstractAccessDecisionManager提供了3種方式。
- AffirmativeBased 任意一種權限校驗voter方式通過即通過
- UnanimousBased 必須所有voter通過才可以通過,即任意失敗則不通過
- ConsensusBased 通過的voter大於拒絕的voter則通過
- 其他,可以自己實現AbstractAccessDecisionManager
Voter是什么呢?AccessDecisionVoter是真正判斷權限的地方。通過對比當前登錄用戶的authority權限和要訪問的資源的權限比較,返回如下code。
- int ACCESS_GRANTED = 1;
- int ACCESS_ABSTAIN = 0;
- int ACCESS_DENIED = -1;
權限移除前綴ROLE_
Spring Security默認使用ROLE_作為authority的前綴,然后表達式里的hasRole, hasAuthority幾乎等價,這讓我一直很困惑。尤其是當我使用user-role-permission模型的時候,差點以為hasRole是角色判斷。所以,為了避免混淆,決定把ROLE_的前綴去掉。
方法就是聲明一個類, 具體理由可以追尋源碼hasRole來確定。
@Bean
GrantedAuthorityDefaults grantedAuthorityDefaults() {
return new GrantedAuthorityDefaults(""); // Remove the ROLE_ prefix
}
統一超級權限admin
到現在差不多已經可以實現用戶權限校驗了。我們給需要權限敏感的api添加注解,比如@PreAuthorize("hasRole('can_list_user')")
, 然后permission表里添加can_list_user
, 然后角色表role綁定permission,最后把role指派給user。
然而,當系統需要權限的地方特別多的時候,綁定role的代價也很高。比如,我們需要一個超級管理員admin角色,那么這個admin就必須把所有的permission綁定一遍。想想就恐怖。
既然理解了Spring Security的權限校驗方式,那么就可以自定義了。我們指定帶有admin的authority直接通過,無需校驗其他權限。
/**
* 允許設計admin權限的用戶直接通過所有認證
* @author Ryan Miao
* @date 2019/6/12 20:49
*/
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class GlobalMethodSecurityConfig extends GlobalMethodSecurityConfiguration {
@Override
protected AccessDecisionManager accessDecisionManager() {
List<AccessDecisionVoter<? extends Object>> decisionVoters = new ArrayList<AccessDecisionVoter<? extends Object>>();
ExpressionBasedPreInvocationAdvice expressionAdvice = new ExpressionBasedPreInvocationAdvice();
expressionAdvice.setExpressionHandler(getExpressionHandler());
decisionVoters
.add(new PreInvocationAuthorizationAdviceVoter(expressionAdvice));
decisionVoters.add(new RoleVoter());
decisionVoters.add(new AuthenticatedVoter());
decisionVoters.add(new AdminVoter());
return new AffirmativeBased(decisionVoters);
}
}
/**
* 擁有admin權限的角色,直接包含所有權限
*
* @author Ryan Miao
* @date 2019/6/12 20:00
*/
public class AdminVoter implements AccessDecisionVoter<Object> {
private static final String ADMIN = "admin";
@Override
public boolean supports(ConfigAttribute attribute) {
return true;
}
@Override
public int vote(Authentication authentication, Object object,
Collection<ConfigAttribute> attributes) {
if (authentication == null) {
return ACCESS_DENIED;
}
int result = ACCESS_ABSTAIN;
Collection<? extends GrantedAuthority> authorities = extractAuthorities(authentication);
for (ConfigAttribute attribute : attributes) {
if (this.supports(attribute)) {
result = ACCESS_DENIED;
// Attempt to find a matching granted authority
for (GrantedAuthority authority : authorities) {
if (ADMIN.equals(authority.getAuthority())) {
return ACCESS_GRANTED;
}
}
}
}
return result;
}
Collection<? extends GrantedAuthority> extractAuthorities(
Authentication authentication) {
return authentication.getAuthorities();
}
@Override
public boolean supports(Class clazz) {
return true;
}
}
總結
初步梳理了Spring Security的認證邏輯和流程,細節的地方還很多,比如SpEL的實現邏輯。但差不多可以理解認證授權是如何實現的了,基於此也足夠開展我們的業務開發了。如果說還有想要改造的地方,就是動態權限修改了,為了簡化邏輯模型,不做動態權限設定,所有權限初始化指定即可。簡單最重要!