➜ Old React website
Chung Cheuk Hang MichaelJava Web Developer
Newer PostJPA/Hibernate 使用方式(二)

Spring Security OAuth2

Table of contents

1 OAuth2 簡介

OAuth2 係一個 authorization(授權)既標準,主要用黎畀用戶去授權第三方網站或者應用程式去取得用戶既資料。
另外,OAuth2 都支援 machine-to-machine(亦可以話系統與系統)之間既 authorization。
概念描述
Authorization(授權)你可以做啲乜野
Authentication(驗證)你係邊個

1.1 OpenID Connect

OpenID Connect(OIDC)係基於 OAuth2 既一個 extension。
佢有以下既 endpoints:
  • GET /userinfo
  • GET /.well-known/openid-configuration

2 OAuth2 概念

2.1 角色

角色描述
用戶互聊網既使用者,擁有個人資料,而啲資料存放喺 resource server。
Resource server會 expose 一啲 APIs 黎提供用戶既資料(resources),呢啲資料可以係啲基本個人資料,包括 email、電話號碼。呢啲資料屬於私人既數據,需要用戶授權先可以存取。
Client app可以係 web app 或者手機 app,係需要取得用戶資料既一方。
Authorization server一個可以管理用戶、client apps 既 permissions 既平台,負責幫用戶授權畀 client apps。

2.2 授權類型、流程

OAuth2 裡面最重要既 grant types/flows:
授權類型、流程描述
Client credentials系統與系統之間既 authorization。
Authorization code用戶去授權 client app 去取得用戶既資料。呢個 flow 需要用到網頁瀏覽器。
Refresh token當 authorization code 授權流程完成之後,後續 client app 需要更新 access token 既流程。
Revoke token當授權完成之後,後續有機會需要取消授權既流程。

2.2.1 Client credentials 流程

  1. Client app 訪問 authorization server 取得存取 resource server 既 access token。
  2. Authorization server 會根據要求而 issue 一個帶有時限既 access token 畀 client app。
  3. Client app 拎住個 access token 訪問 resource server 上既 resources。
  4. Resource server 做以下其中一樣:
    • Resource server 睇下 access token 適唔適用於呢個 client app、有冇過期等等,並且訪問 auth server 取得 JWK public key,之後 resource server 會驗證 JWT access token 既 signature。
    • Resource server 訪問 auth server,由 auth server 根據 database 既數據去 introspect 個 access token 去判斷 access token 係咪有效,然後將結果交畀 resource server。

2.2.2 Authorization code 流程

  1. Client app 要求用戶登入,用戶選擇透過 auth server 登入。
  2. Client app 開啟網頁瀏覽器,並且 redirect 去 auth server 既登入頁面。
  3. 用戶輸入帳號密碼,成功登入 auth server。
  4. Auth server redirect 去 consent 頁面,問用戶想唔想授權一啲存取權限畀 client app。
  5. 用戶完成授權選項。
  6. Auth server redirect 去 client app 既 redirect URI,條 URL 會伴隨住一個隨機既 authorization code,以及一個隨機既 state value。
  7. Client app 問 authorization server 取得存取 resource server 既 access token。
  8. Authorization server 會根據要求而 issue 一個新既 access token 畀 client app。
  9. Client app 拎住個 access token 存取 resource server 上既 resources。

2.2.3 Refresh token 流程

  1. 完成 authorization code 流程既時候,除左 access token 之外,我地會得到一個 refresh token。
  2. Client app 根據現有 access token 既有效期而決定要唔要用 refresh token 問 authorization server 取得存取 resource server 既 access token。
  3. Authorization server 會根據要求而 issue 一個新既 access token 畀 client app。
  4. Client app 拎住個 access token 存取 resource server 上既 resources。

2.2.4 Revoke token 流程

2.3 Token 類別

Token 類別常見格式描述
Access tokenJWT實際用黎授權 client app 既 token。通過 client credentials 流程而 issue 既 access token 既 expiry 會長過 authorization code 流程既。
Refresh tokenJWT當 access token 即將或者已經過期,佢可以用黎換取新既 access token。
ID tokenJWTOIDC 加入既功能,用黎驗證用戶既 token。

2.4 Client authentication 類別

Client authentication 類別描述
client_secret_basicClient app 會用 HTTP 既 basic authentication(即係 curl -u <client ID>:<client secret>)將 client secret 以 plain text 傳送到 auth server。如果用呢個方法,咁 auth server 就可以將 client secret 以 BCrypt hash 既形式儲存。
client_secret_postclient_secret_basic 大致一樣,不過 client app 會用 HTTP POST 既 application/x-www-form-urlencoded 格式既 form parameter 將 client secret 傳送。
client_secret_jwtClient app 會用 client secret 作為 symmetric key 透過 HMAC algorithm 去 hash 一個 JWT 既 header 以及 payload 部分,再 attach 個 HMAC hash 作為個 JWT 既 signature 部分,然後用呢個 JWT 做 client authentication。如果用呢個方法,咁 auth server 就唔可以將 client secret 以 BCrypt hash 既形式儲存,當 auth server 驗證個 JWT 既時候,個 client secret 一定要係 plain text。
private_key_jwtclient_secret_jwt 相似,都係要用到一個 JWT 做 client authentication,但係用既係 asymmetric key(例如 RSA)既 private key 去 sign 個 JWT。Auth server 需要 call client app 既 GET /.well-known/jwks.json HTTP endpoint 去取得 public key 黎 verify 個 JWT signature。

3 JWT 格式

OAuth2 用既係 JWT 格式,例如 access token、refresh token、ID token、client authentication JWT 等等。
<header>.<payload>.<signature>
部分描述
HeaderBase64-encoded 既 JSON,提供 signature 部分既規格資訊。如果呢個 JWT 係一個 token,就會有 kid(key ID)以及 alg(algorithm),咁當 resource server 需要驗證 access token 既時候就可以用返相同規格去驗證 signature 部分。
PayloadBase64-encoded 既 JSON,佢既 fields 包括 sub(subject)、aud(audience,可以係 array)、nbf(not valid before)、scope(scope,可以係 array)、iss(issuer)、exp(expiration time)、iat(issued at)、jti(JWT ID),以及一啲自定義既 custom claims(如有)。
Signature一般會用 HMAC + client secret 去 hash 或者 RSA/ECDSA private key 去 sign <header>.<payload> 黎取得 signature 部分。

4 動手寫

Spring framework 提供左完整既 OAuth2 既 implementation,佢既 modules 包括:
  • Spring Security OAuth2 Authorization Server
  • Spring Security OAuth2 Resource Server
  • Spring Security OAuth2 Client

4.1 Authorization server

4.1.1 Authorization server Maven dependencies

1<parent> 2 <groupId>org.springframework.boot</groupId> 3 <artifactId>spring-boot-starter-parent</artifactId> 4 <version>3.4.0</version> 5</parent> 6 7<dependencies> 8 <dependency> 9 <groupId>org.springframework.boot</groupId> 10 <artifactId>spring-boot-starter-oauth2-authorization-server</artifactId> 11 </dependency> 12 13 <dependency> 14 <groupId>org.springframework.boot</groupId> 15 <artifactId>spring-boot-starter-data-jpa</artifactId> 16 </dependency> 17 <dependency> 18 <groupId>org.springframework.session</groupId> 19 <artifactId>spring-session-jdbc</artifactId> 20 </dependency> 21 22 <!-- 或者其他 SQL database 既 JDBC driver --> 23 <dependency> 24 <groupId>org.postgresql</groupId> 25 <artifactId>postgresql</artifactId> 26 </dependency> 27</dependencies>

4.1.2 Authorization server Java code

SecurityConfig.java
1@Configuration 2@EnableJdbcHttpSession(tableName = "tbl_session") 3public class SecurityConfig { 4 5 @Bean 6 public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { 7 return http 8 .authorizeHttpRequests(e -> e 9 .requestMatchers("/actuator/**", "/clients/**").permitAll() 10 .anyRequest().authenticated()) 11 .with(OAuth2AuthorizationServerConfigurer.authorizationServer().oidc(Customizer.withDefaults()), Customizer.withDefaults()) 12 .formLogin(Customizer.withDefaults()) 13 .csrf(e -> e.ignoringRequestMatchers("/clients/**")) 14 .build(); 15 } 16}

4.1.3 Authorization server 配置

1server: 2 port: 9000 3 4logging.level: 5 org.springframework.web: DEBUG 6 org.apache.coyote.http11.Http11InputBuffer: TRACE 7 org.springframework.security: DEBUG 8# org.springframework.jdbc.core.JdbcTemplate: DEBUG 9 10spring: 11 datasource: 12 url: jdbc:postgresql://localhost:5432/postgres 13 username: postgres 14 password: postgres 15 jpa: 16 show-sql: true 17 hibernate: 18 ddl-auto: update 19 20 security: 21 oauth2: 22 authorizationserver: 23 issuer-uri: http://localhost:9000

4.2 Resource server

4.2.1 Resource server Maven dependencies

1<parent> 2 <groupId>org.springframework.boot</groupId> 3 <artifactId>spring-boot-starter-parent</artifactId> 4 <version>3.4.0</version> 5</parent> 6 7<dependencies> 8 <dependency> 9 <groupId>org.springframework.boot</groupId> 10 <artifactId>spring-boot-starter-web</artifactId> 11 </dependency> 12 <dependency> 13 <groupId>org.springframework.boot</groupId> 14 <artifactId>spring-boot-starter-actuator</artifactId> 15 </dependency> 16 17 <dependency> 18 <groupId>org.springframework.boot</groupId> 19 <artifactId>spring-boot-starter-oauth2-resource-server</artifactId> 20 </dependency> 21</dependencies>

4.2.2 Resource server Java code

1@Configuration 2public class SecurityConfig { 3 4 @Value("${custom.security.oauth2.opaque-token.introspection-uri}") String introspectorUri; 5 @Value("${custom.security.oauth2.opaque-token.client-id}") String clientId; 6 @Value("${custom.security.oauth2.opaque-token.client-secret}") String clientSecret; 7 8 @Bean 9 public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { 10 return http 11 .authorizeHttpRequests(e -> 12 e.anyRequest().authenticated()) 13 .oauth2ResourceServer(e -> e.jwt(Customizer.withDefaults())) 14// .oauth2ResourceServer(e -> e.opaqueToken(Customizer.withDefaults())) 15 .build(); 16 } 17 18 // 如果用 http.oauth2ResourceServer(e -> e.opaqueToken(...)) 就需要 19 @Bean 20 public OpaqueTokenIntrospector introspector() { 21 return new SpringOpaqueTokenIntrospector( 22 introspectorUri, 23 clientId, 24 clientSecret); 25 } 26}
1@RestController 2@RequestMapping(path = "/resources") 3public class ResourcesController { 4 5 @PreAuthorize("hasAuthority('SCOPE_read')") 6 @GetMapping 7 public List<MyResource> getResources() { 8 return List.of( 9 new MyResource("Resource 1"), 10 new MyResource("Resource 2") 11 ); 12 } 13}

4.2.3 Resource server 配置

1server: 2 port: 8081 3 4logging.level: 5 org.springframework.web: DEBUG 6 org.apache.coyote.http11.Http11InputBuffer: TRACE 7 org.springframework.security: DEBUG 8 9spring: 10 security: 11 oauth2: 12 resourceserver: 13 jwt: 14 issuer-uri: http://localhost:9000 15 16custom: 17 security: 18 oauth2: 19 opaque-token: 20 introspection-uri: http://localhost:9000/oauth2/introspect 21 client-id: my-oauth2-resource-server 22 client-secret: 50728f65-de35-42be-a2cb-83ced41e337f

4.3 Client app

4.3.1 Client app Maven dependencies

1<parent> 2 <groupId>org.springframework.boot</groupId> 3 <artifactId>spring-boot-starter-parent</artifactId> 4 <version>3.4.0</version> 5</parent> 6 7<dependencies> 8 <dependency> 9 <groupId>org.springframework.boot</groupId> 10 <artifactId>spring-boot-starter-web</artifactId> 11 </dependency> 12 <dependency> 13 <groupId>org.springframework.boot</groupId> 14 <artifactId>spring-boot-starter-actuator</artifactId> 15 </dependency> 16 17 <dependency> 18 <groupId>org.springframework.boot</groupId> 19 <artifactId>spring-boot-starter-oauth2-client</artifactId> 20 </dependency> 21</dependencies>

4.3.2 Client app Java code

RestClientConfig.java
1@Configuration 2public class RestClientConfig { 3 4 @Value("${resource-service.uri}") 5 String resourceServiceUri; 6 7 @Bean 8 public RestClient restClient(RestClient.Builder builder, OAuth2AuthorizedClientManager authorizedClientManager) { 9 return builder 10 .baseUrl(resourceServiceUri) 11 .requestInterceptor(new OAuth2ClientHttpRequestInterceptor(authorizedClientManager)) 12 .build(); 13 } 14}

4.3.3 Client app 配置

1server: 2 port: 8080 3 4logging.level: 5 org.springframework.web: DEBUG 6 org.apache.coyote.http11.Http11InputBuffer: TRACE 7 org.springframework.security: DEBUG 8 9oauth2-issuer.uri: http://localhost:9000 10resource-service.uri: http://localhost:8081 11 12spring: 13 security: 14 oauth2: 15 client: 16 registration: 17 my-client: 18 provider: my-provider 19 client-id: my-oauth2-client 20 client-secret: xxxxxx 21 authorization-grant-type: client_credentials 22 client-authentication-method: client_secret_basic 23 scope: read 24 provider: 25 my-provider: 26 token-uri: ${oauth2-issuer.uri}/oauth2/token
MyService.java
1import static org.springframework.security.oauth2.client.web.client.RequestAttributeClientRegistrationIdResolver.clientRegistrationId; 2 3@RequiredArgsConstructor // Lombok 4public class MyService { 5 6 final RestClient restClient; 7 8 @GetMapping 9 public List<MyResource> getResources() { 10 final List<MyResource> resources = restClient 11 .get() 12 .uri("/api/resources") 13 .attributes(clientRegistrationId("my-client")) 14 .retrieve() 15 .body(new ParameterizedTypeReference<List<MyResource>>() {}); 16 17 return resources; 18 } 19}

5 測試

5.1 重新啟動會否令現有 access token 失效

  1. 啟動 auth server、resource server、client app。
  2. 令 client app 透過 OAuth2 成功訪問 resource server 既 protected resource endpoint。
  3. 暫停 auth server、resource server。
  4. 啟動 auth server、resource server。
  5. 令 client app 透過 OAuth2 訪問 resource server 既 protected resource endpoint。
預期結果:
  • 如果 auth server 重新啟動既時候用返之前既 JWK key pair,咁 client app 就應該會成功訪問到 resource server。
解釋:
  • Spring OAuth2 Authorization Server 默認會喺啟動既時候生成 in-memory 既隨機 JWK key pair。
  • 如果用 jwt() 模式,咁 resource server 會訪問 auth server 既 GET /oauth2/jwks endpoint 拎 JWK public key,並且會 cache 低。
  • Client app 會 cache 佢從 auth server 既 POST /oauth2/token endpoint 拎到既 access token。
  • 所以如果 auth server 因為重新啟動左而 JWK key pair 唔同左,就有可能影響到重新啟動前 issue 既 access tokens。

5.2 Revoke token 會否令現有 access token 失效

  1. 啟動 auth server、resource server、client app。
  2. 透過 client credentials flow 或者 authorization code flow 取得 access token。
  3. 用呢個 access token 成功訪問 resource server 既 protected resource endpoint。
  4. 訪問 auth server 既 POST /oauth2/revoke endpoint 去 revoke 呢個 access token。
  5. 用呢個 access token 訪問 resource server 既 protected resource endpoint。
預期結果:
  • 如果 resource server 用 opaqueToken() 模式,咁 resource server 會訪問 auth server 既 POST /oauth2/introspect endpoint 去判斷 access token 係咪有效,而 auth server 會根據 database 數據而判斷。
  • 如果 resource server 用 jwt() 模式,咁 resource server 就只會解讀 access token,睇下過期未、用 auth server 既 JWK public key 驗證 access token 既 JWT signature 等等,咁就做唔到 revoke token 既功能。
  • Revoke refresh token 既話,咁 refresh token 以及 access token 都會一齊被 revoke。
解釋:
  • 所有已經 issue 左既 tokens 只能帶有時限。
  • Revoke token 既時候,係冇可能改變到已經 issue 左出去既 tokens。Auth server 只可以喺 database 紀錄低呢個 access token 已經失效。
  • 如果要支援 revoke token 功能,resource server 就唔可以只係解讀個 access token,而係一定要訪問 auth server 了解 access token 係咪有效。

5.3 Authorization code 可否用多過一次

根據 OAuth2 官方規格文檔:
The client MUST NOT use the authorization code more than once. If an authorization code is used more than once, the authorization server MUST deny the request and SHOULD revoke (when possible) all tokens previously issued based on that authorization code.
  1. 啟動 auth server、resource server、client app。
  2. 透過 authorization code flow 取得 authorization code。
  3. 用呢個 authorization code 訪問 auth server 既 POST /oauth2/token endpoint 成功換成 refresh token、access token 以及 ID token。
  4. 用同一個 authorization code 訪問 auth server 既 POST /oauth2/token endpoint 換成 refresh token、access token 以及 ID token。
  5. 測試之前成功取得既 refresh token、access token 以及 ID token。
    1. 用 refresh token 訪問 auth server 既 POST /oauth2/token endpoint 拎新既 access token。
    2. 用 access token 訪問 auth server 既 POST /oauth2/introspect endpoint。
    3. 用 ID token 訪問 auth server 既 POST /oauth2/introspect endpoint。
預期結果:
  • 因為 authorization code 已經用過一次,所以:
    • 之後再換 tokens 既時候會失敗。
    • 之前成功拎到既 refresh token 會失效,唔可以再換成新既 access token。
    • 之前成功拎到既 access token 會失效。
    • 之前成功拎到既 ID token 會失效。

5.4 註冊多個 JWK 簽署 token

建立 JWK objects 既時候,我地可以提供唔同既 not-before time 以及 expiration time。

5.5 Well-known OIDC configuration 既 URLs

Call GET /.well-known/oidc-configuration endpoint 既時候,auth server 會根據以下既野去砌 response body 裡面既 OIDC URLs:
元素例子
個 HTTP request 既 Host request headerexample.com
個 HTTP request 既 X-Forwarded-Proto request headerhttps
Auth server 既 server.servlet.context-path 配置/api

5.6 Public client

  • 冇 client secret。
  • 只用 none 作為 client authentication method。
  • 只用 authorization_code 作為 grant type。
  • 需要 proof key(PKCE)。
  • Call POST /oauth2/token endpoint 既時候,提供 client_id 作為 POST form data,唔需要 Authorization request header。

6 參考資料

Java classes:
  • OAuth2AuthorizedClientProvider