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 流程
- Client app 訪問 authorization server 取得存取 resource server 既 access token。
- Authorization server 會根據要求而 issue 一個帶有時限既 access token 畀 client app。
- Client app 拎住個 access token 訪問 resource server 上既 resources。
- 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 流程
- Client app 要求用戶登入,用戶選擇透過 auth server 登入。
- Client app 開啟網頁瀏覽器,並且 redirect 去 auth server 既登入頁面。
- 用戶輸入帳號密碼,成功登入 auth server。
- Auth server redirect 去 consent 頁面,問用戶想唔想授權一啲存取權限畀 client app。
- 用戶完成授權選項。
- Auth server redirect 去 client app 既 redirect URI,條 URL 會伴隨住一個隨機既 authorization code,以及一個隨機既 state value。
- Client app 問 authorization server 取得存取 resource server 既 access token。
- Authorization server 會根據要求而 issue 一個新既 access token 畀 client app。
- Client app 拎住個 access token 存取 resource server 上既 resources。
2.2.3 Refresh token 流程
- 完成 authorization code 流程既時候,除左 access token 之外,我地會得到一個 refresh token。
- Client app 根據現有 access token 既有效期而決定要唔要用 refresh token 問 authorization server 取得存取 resource server 既 access token。
- Authorization server 會根據要求而 issue 一個新既 access token 畀 client app。
- Client app 拎住個 access token 存取 resource server 上既 resources。
2.2.4 Revoke token 流程
2.3 Token 類別
Token 類別 | 常見格式 | 描述 |
---|
Access token | JWT | 實際用黎授權 client app 既 token。通過 client credentials 流程而 issue 既 access token 既 expiry 會長過 authorization code 流程既。 |
Refresh token | JWT | 當 access token 即將或者已經過期,佢可以用黎換取新既 access token。 |
ID token | JWT | OIDC 加入既功能,用黎驗證用戶既 token。 |
2.4 Client authentication 類別
Client authentication 類別 | 描述 |
---|
client_secret_basic | Client app 會用 HTTP 既 basic authentication(即係 curl -u <client ID>:<client secret> )將 client secret 以 plain text 傳送到 auth server。如果用呢個方法,咁 auth server 就可以將 client secret 以 BCrypt hash 既形式儲存。 |
client_secret_post | 同 client_secret_basic 大致一樣,不過 client app 會用 HTTP POST 既 application/x-www-form-urlencoded 格式既 form parameter 將 client secret 傳送。 |
client_secret_jwt | Client 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_jwt | 同 client_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>
部分 | 描述 |
---|
Header | Base64-encoded 既 JSON,提供 signature 部分既規格資訊。如果呢個 JWT 係一個 token,就會有 kid (key ID)以及 alg (algorithm),咁當 resource server 需要驗證 access token 既時候就可以用返相同規格去驗證 signature 部分。 |
Payload | Base64-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 失效
- 啟動 auth server、resource server、client app。
- 令 client app 透過 OAuth2 成功訪問 resource server 既 protected resource endpoint。
- 暫停 auth server、resource server。
- 啟動 auth server、resource server。
- 令 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 失效
- 啟動 auth server、resource server、client app。
- 透過 client credentials flow 或者 authorization code flow 取得 access token。
- 用呢個 access token 成功訪問 resource server 既 protected resource endpoint。
- 訪問 auth server 既
POST /oauth2/revoke
endpoint 去 revoke 呢個 access token。
- 用呢個 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.
- 啟動 auth server、resource server、client app。
- 透過 authorization code flow 取得 authorization code。
- 用呢個 authorization code 訪問 auth server 既
POST /oauth2/token
endpoint 成功換成 refresh token、access token 以及 ID token。
- 用同一個 authorization code 訪問 auth server 既
POST /oauth2/token
endpoint 換成 refresh token、access token 以及 ID token。
- 測試之前成功取得既 refresh token、access token 以及 ID token。
- 用 refresh token 訪問 auth server 既
POST /oauth2/token
endpoint 拎新既 access token。
- 用 access token 訪問 auth server 既
POST /oauth2/introspect
endpoint。
- 用 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 header | example.com |
個 HTTP request 既 X-Forwarded-Proto request header | https |
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