認證鑑權與API許可權控制在微服務架構中的設計與實現:授權碼模式

aoho發表於2019-03-02

引言: 之前系列文章《認證鑑權與API許可權控制在微服務架構中的設計與實現》,前面文章已經將認證鑑權與API許可權控制的流程和主要細節講解完。由於有些同學想了解下授權碼模式,本文特地補充講解。

授權碼型別介紹

授權碼型別(authorization code)通過重定向的方式讓資源所有者直接與授權伺服器進行互動來進行授權,避免了資源所有者資訊洩漏給客戶端,是功能最完整、流程最嚴密的授權型別,但是需要客戶端必須能與資源所有者的代理(通常是Web瀏覽器)進行互動,和可從授權伺服器中接受請求(重定向給予授權碼),授權流程如下:

 +----------+
 | Resource |
 |   Owner  |
 |          |
 +----------+
      ^
      |
     (B)
 +----|-----+          Client Identifier      +---------------+
 |         -+----(A)-- & Redirection URI ---->|               |
 |  User-   |                                 | Authorization |
 |  Agent  -+----(B)-- User authenticates --->|     Server    |
 |          |                                 |               |
 |         -+----(C)-- Authorization Code ---<|               |
 +-|----|---+                                 +---------------+
   |    |                                         ^      v
  (A)  (C)                                        |      |
   |    |                                         |      |
   ^    v                                         |      |
 +---------+                                      |      |
 |         |>---(D)-- Authorization Code ---------'      |
 |  Client |          & Redirection URI                  |
 |         |                                             |
 |         |<---(E)----- Access Token -------------------'
 +---------+       (w/ Optional Refresh Token)
複製程式碼
  1. 客戶端引導資源所有者的使用者代理到授權伺服器的endpoint,一般通過重定向的方式。客戶端提交的資訊應包含客戶端標識(client identifier)、請求範圍(requested scope)、本地狀態(local state)和用於返回授權碼的重定向地址(redirection URI)
  2. 授權伺服器認證資源所有者(通過使用者代理),並確認資源所有者允許還是拒絕客戶端的訪問請求
  3. 如果資源所有者授予客戶端訪問許可權,授權伺服器通過重定向使用者代理的方式回撥客戶端提供的重定向地址,並在重定向地址中新增授權碼和客戶端先前提供的任何本地狀態
  4. 客戶端攜帶上一步獲得的授權碼向授權伺服器請求訪問令牌。在這一步中授權碼和客戶端都要被授權伺服器進行認證。客戶端需要提交用於獲取授權碼的重定向地址
  5. 授權伺服器對客戶端進行身份驗證,和認證授權碼,確保接收到的重定向地址與第三步中用於的獲取授權碼的重定向地址相匹配。如果有效,返回訪問令牌,可能會有重新整理令牌(Refresh Token)

快速入門

Spring-Securiy 配置

由於授權碼模式需要登入使用者給請求access_token的客戶端授權,所以auth-server需要新增Spring-Security的相關配置用於引導使用者進行登入。

在原來的基礎上,進行Spring-Securiy相關配置,允許使用者進行表單登入:

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    CustomLogoutHandler customLogoutHandler;


    @Override
    protected void configure(HttpSecurity http) throws Exception {


        http.csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
                .and()
                .requestMatchers().antMatchers("/**")
                .and().authorizeRequests()
                .antMatchers("/**").permitAll()
                .anyRequest().authenticated()
                .and().formLogin()
                .permitAll()
                .and().logout()
                .logoutUrl("/logout")
                .clearAuthentication(true)
                .logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler())
                .addLogoutHandler(customLogoutHandler);

    }

}

複製程式碼

同時需要把ResourceServerConfig中的資源伺服器中的對於登出埠的處理遷移到WebSecurityConfig中,註釋掉ResourceServerConfigHttpSecurity配置:

public class ResourceServerConfig extends ResourceServerConfigurerAdapter {

//    @Override
//    public void configure(HttpSecurity http) throws Exception {
//        http.csrf().disable()
//                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
//                .and()
//                .requestMatchers().antMatchers("/**")
//                .and().authorizeRequests()
//                .antMatchers("/**").permitAll()
//                .anyRequest().authenticated()
//                .and().logout()
//                .logoutUrl("/logout")
//                .clearAuthentication(true)
//                .logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler())
//                .addLogoutHandler(customLogoutHandler());
//
//        //http.antMatcher("/api/**").addFilterAt(customSecurityFilter(), FilterSecurityInterceptor.class);
//
//    }

 /*   @Bean
    public CustomSecurityFilter customSecurityFilter() {
        return new CustomSecurityFilter();
    }
*/
.....
}

複製程式碼

AuthenticationProvider

由於使用者表單登入的認證過程可能有所不同,為此再新增一個CustomSecurityAuthenticationProvider,基本上與CustomAuthenticationProvider一致,只是忽略對client客戶端的認證和處理。

@Component
public class CustomSecurityAuthenticationProvider implements AuthenticationProvider {

    @Autowired
    private UserClient userClient;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String username = authentication.getName();
        String password;

        Map map;

        password = (String) authentication.getCredentials();
        //如果你是呼叫user服務,這邊不用注掉
        //map = userClient.checkUsernameAndPassword(getUserServicePostObject(username, password, type));
        map = checkUsernameAndPassword(getUserServicePostObject(username, password));


        String userId = (String) map.get("userId");
        if (StringUtils.isBlank(userId)) {
            String errorCode = (String) map.get("code");
            throw new BadCredentialsException(errorCode);
        }
        CustomUserDetails customUserDetails = buildCustomUserDetails(username, password, userId);
        return new CustomAuthenticationToken(customUserDetails);
    }

    private CustomUserDetails buildCustomUserDetails(String username, String password, String userId) {
        CustomUserDetails customUserDetails = new CustomUserDetails.CustomUserDetailsBuilder()
                .withUserId(userId)
                .withPassword(password)
                .withUsername(username)
                .withClientId("for Security")
                .build();
        return customUserDetails;
    }

    private Map<String, String> getUserServicePostObject(String username, String password) {
        Map<String, String> requestParam = new HashMap<String, String>();
        requestParam.put("userName", username);
        requestParam.put("password", password);
        return requestParam;
    }

    //模擬呼叫user服務的方法
    private Map checkUsernameAndPassword(Map map) {

        //checkUsernameAndPassword
        Map ret = new HashMap();
        ret.put("userId", UUID.randomUUID().toString());

        return ret;
    }

    @Override
    public boolean supports(Class<?> aClass) {
        return true;
    }

}
複製程式碼

AuthenticationManagerConfig新增CustomSecurityAuthenticationProvider配置:

@Configuration
public class AuthenticationManagerConfig extends GlobalAuthenticationConfigurerAdapter {

    @Autowired
    CustomAuthenticationProvider customAuthenticationProvider;
    @Autowired
    CustomSecurityAuthenticationProvider securityAuthenticationProvider;

    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(customAuthenticationProvider)
                .authenticationProvider(securityAuthenticationProvider);
    }

}
複製程式碼

保證資料庫中的請求客戶端存在授權碼的請求授權和具備回撥地址,回撥地址是用來接受授權碼的。

認證鑑權與API許可權控制在微服務架構中的設計與實現:授權碼模式

測試使用

啟動服務,瀏覽器訪問地址http://localhost:9091/oauth/authorize?response_type=code&client_id=frontend& scope=all&redirect_uri=http://localhost:8080

重定向到登入介面,引導使用者登入:

認證鑑權與API許可權控制在微服務架構中的設計與實現:授權碼模式

登入成功,授權客戶端獲取授權碼。

認證鑑權與API許可權控制在微服務架構中的設計與實現:授權碼模式

授權之後,從回撥地址中獲取到授權碼:

http://localhost:8080/?code=7OglOJ
複製程式碼

攜帶授權碼獲取對應的token:

認證鑑權與API許可權控制在微服務架構中的設計與實現:授權碼模式

認證鑑權與API許可權控制在微服務架構中的設計與實現:授權碼模式

原始碼詳解

AuthorizationServerTokenServices是授權伺服器中進行token操作的介面,提供了以下的三個介面:

public interface AuthorizationServerTokenServices {

	// 生成與OAuth2認證繫結的access_token
	OAuth2AccessToken createAccessToken(OAuth2Authentication authentication) throws AuthenticationException;

	// 根據refresh_token重新整理access_token
	OAuth2AccessToken refreshAccessToken(String refreshToken, TokenRequest tokenRequest)
			throws AuthenticationException;

	// 獲取OAuth2認證的access_token,如果access_token存在的話
	OAuth2AccessToken getAccessToken(OAuth2Authentication authentication);

}

複製程式碼

請注意,生成的token都是與授權的使用者進行繫結的。

AuthorizationServerTokenServices介面的預設實現是DefaultTokenServices,注意token通過TokenStore進行儲存管理。

生成token:

//DefaultTokenServices
@Transactional
public OAuth2AccessToken createAccessToken(OAuth2Authentication authentication) throws AuthenticationException {
	// 從TokenStore獲取access_token
	OAuth2AccessToken existingAccessToken = tokenStore.getAccessToken(authentication);
	OAuth2RefreshToken refreshToken = null;
	if (existingAccessToken != null) {
		if (existingAccessToken.isExpired()) {
			// 如果access_token已經存在但是過期了
			// 刪除對應的access_token和refresh_token
			if (existingAccessToken.getRefreshToken() != null) {
				refreshToken = existingAccessToken.getRefreshToken();
			  	tokenStore.removeRefreshToken(refreshToken);
			}
			tokenStore.removeAccessToken(existingAccessToken);
		}
		else {
			// 如果access_token已經存在並且沒有過期
			// 重新儲存一下防止authentication改變,並且返回該access_token
			tokenStore.storeAccessToken(existingAccessToken, authentication);
			return existingAccessToken;
		}
	}

	// 只有當refresh_token為null時,才重新建立一個新的refresh_token
	// 這樣可以使持有過期access_token的客戶端可以根據以前拿到refresh_token拿到重新建立的access_token
	// 因為建立的access_token需要繫結refresh_token
	if (refreshToken == null) {
		refreshToken = createRefreshToken(authentication);
	}else if (refreshToken instanceof ExpiringOAuth2RefreshToken) {
	 	// 如果refresh_token也有期限並且過期,重新建立
		ExpiringOAuth2RefreshToken expiring = (ExpiringOAuth2RefreshToken) refreshToken;
		if (System.currentTimeMillis() > expiring.getExpiration().getTime()) {
			refreshToken = createRefreshToken(authentication);
		}
	}
	// 繫結授權使用者和refresh_token建立新的access_token
	OAuth2AccessToken accessToken = createAccessToken(authentication, refreshToken);
	// 將access_token與授權使用者對應儲存
	tokenStore.storeAccessToken(accessToken, authentication);
	// In case it was modified
	refreshToken = accessToken.getRefreshToken();
	if (refreshToken != null) {
		// 將refresh_token與授權使用者對應儲存
		tokenStore.storeRefreshToken(refreshToken, authentication);
	}
	return accessToken;
}
複製程式碼

需要注意到,在建立token的過程中,會根據該授權使用者去查詢是否存在未過期的access_token,有就直接返回,沒有的話才會重新建立新的access_token,同時也應該注意到是先建立refresh_token,再去建立access_token,這是為了防止持有過期的access_token能夠通過refresh_token重新獲得access_token,因為前後建立access_token繫結了同一個refresh_token。

DefaultTokenServices中重新整理token的refreshAccessToken()以及獲取token的getAccessToken()方法就留給讀者們自己去檢視,在此不介紹。

小結

本文主要講了授權碼模式,在授權碼模式需要使用者登入之後進行授權才獲取獲取授權碼,再攜帶授權碼去向TokenEndpoint請求訪問令牌,當然也可以在請求中設定response_token=token通過隱式型別直接獲取到access_token。這裡需要注意一個問題,在到達AuthorizationEndpoint端點時,並沒有對客戶端進行驗證,但是必須要經過使用者認證的請求才能被接受。

訂閱最新文章,歡迎關注我的公眾號

微信公眾號

推薦閱讀

系列文章:認證鑑權與API許可權控制在微服務架構中的設計與實現

參考

spring-security

相關文章