feat(auth): enhance SSO integration and token management
- add buildAuthorization function for token handling - implement consumeAuthTokensFromUrl to extract tokens from URL - update axios request interceptor to handle authorization - improve error handling for unauthorized access - refactor app.py to validate JWT tokens and manage user sessions - add auth_guard for claim-based authorization checks - create auth_user model for user profile management - update README with service details and setup instructions
This commit is contained in:
@@ -1,2 +1,122 @@
|
|||||||
# Micro Service apps
|
# Micro Service Apps
|
||||||
Backend micro service application for fst data production line.
|
|
||||||
|
Backend micro services for the FST data production line.
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
- `root_db_api`: FST/root database APIs (Flask + SQLAlchemy + PostgreSQL)
|
||||||
|
- `mta_manage_system`: MTA management service (Flask + Flask-SQLAlchemy)
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Python `>=3.12`
|
||||||
|
- `uv` package manager
|
||||||
|
- PostgreSQL (for `root_db_api`)
|
||||||
|
|
||||||
|
## Dependency Installation
|
||||||
|
|
||||||
|
### Option A: Install from repo root (recommended)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd "C:\Users\A200315753\Work\FST\fst-editor\fst_data_pipeline-feature-editor-api"
|
||||||
|
uv venv
|
||||||
|
.\.venv\Scripts\Activate.ps1
|
||||||
|
uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option B: Install per app
|
||||||
|
|
||||||
|
#### root_db_api
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd "C:\Users\A200315753\Work\FST\fst-editor\fst_data_pipeline-feature-editor-api\fst_data_pipeline\apps\root_db_api"
|
||||||
|
uv venv
|
||||||
|
.\.venv\Scripts\Activate.ps1
|
||||||
|
uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
#### mta_manage_system
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd "C:\Users\A200315753\Work\FST\fst-editor\fst_data_pipeline-feature-editor-api\fst_data_pipeline\apps\mta_manage_system"
|
||||||
|
uv venv
|
||||||
|
.\.venv\Scripts\Activate.ps1
|
||||||
|
uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Configuration
|
||||||
|
|
||||||
|
### 1) root_db_api
|
||||||
|
|
||||||
|
`root_db_api` reads DB settings from environment variables in:
|
||||||
|
|
||||||
|
- `fst_data_pipeline/apps/root_db_api/src/db/connection.py`
|
||||||
|
|
||||||
|
Required variables:
|
||||||
|
|
||||||
|
- `DB_USER`
|
||||||
|
- `DB_PASSWORD`
|
||||||
|
- `DB_BASE_URL`
|
||||||
|
|
||||||
|
The runtime DB URL is assembled as:
|
||||||
|
|
||||||
|
- `postgresql://{DB_USER}:{DB_PASSWORD}@{DB_BASE_URL}`
|
||||||
|
|
||||||
|
Example (`PowerShell`):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:DB_USER = "admin"
|
||||||
|
$env:DB_PASSWORD = "your_password"
|
||||||
|
$env:DB_BASE_URL = "127.0.0.1:5432/fsq_dev"
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also see a container run example in:
|
||||||
|
|
||||||
|
- `start.sh`
|
||||||
|
|
||||||
|
### 2) mta_manage_system
|
||||||
|
|
||||||
|
`mta_manage_system` reads DB from:
|
||||||
|
|
||||||
|
- `fst_data_pipeline/apps/mta_manage_system/config.py`
|
||||||
|
|
||||||
|
Key variable:
|
||||||
|
|
||||||
|
- `DATABASE_URL`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:DATABASE_URL = "postgresql://username:password@127.0.0.1:5432/dbname"
|
||||||
|
```
|
||||||
|
|
||||||
|
`config.py` will load env files automatically in this order:
|
||||||
|
|
||||||
|
- `.env`
|
||||||
|
- `.env.<FLASK_ENV>`
|
||||||
|
- `.env.local`
|
||||||
|
|
||||||
|
## Run (quick)
|
||||||
|
|
||||||
|
### root_db_api
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd "C:\Users\A200315753\Work\FST\fst-editor\fst_data_pipeline-feature-editor-api\fst_data_pipeline\apps\root_db_api"
|
||||||
|
python src\app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Default API endpoint:
|
||||||
|
|
||||||
|
- `http://localhost:5232/api`
|
||||||
|
|
||||||
|
### mta_manage_system
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd "C:\Users\A200315753\Work\FST\fst-editor\fst_data_pipeline-feature-editor-api\fst_data_pipeline\apps\mta_manage_system"
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `root_db_api` has a detailed service-level guide in `fst_data_pipeline/apps/root_db_api/README.md`.
|
||||||
|
- Keep secrets (DB password, OAuth secrets) in environment variables or local `.env` files; do not commit them.
|
||||||
|
|||||||
@@ -0,0 +1,395 @@
|
|||||||
|
# Benz SSO Java Implementation Guide
|
||||||
|
|
||||||
|
## 1. Background and Goal
|
||||||
|
|
||||||
|
This document describes how to reproduce the current Flask SSO behavior in Java (Spring Boot), based on:
|
||||||
|
|
||||||
|
- `fst_data_pipeline/apps/root_db_api/src/app.py`
|
||||||
|
|
||||||
|
Target: Keep behavior consistent with the existing Python implementation so frontend and API callers do not need changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Existing Flask SSO Behavior (Reference Baseline)
|
||||||
|
|
||||||
|
### 2.1 Endpoints
|
||||||
|
|
||||||
|
- `GET /api/login`
|
||||||
|
- Reads optional query `next`
|
||||||
|
- Stores normalized `next` into session key `auth_next`
|
||||||
|
- Redirects browser to SSO authorization endpoint
|
||||||
|
|
||||||
|
- `GET /api/daimler/authorized`
|
||||||
|
- Receives auth `code`
|
||||||
|
- Exchanges `code` for token at token endpoint
|
||||||
|
- Calls userinfo endpoint using `Bearer access_token`
|
||||||
|
- Stores `auth_user`, `auth_access_token`, optional `auth_id_token` in session
|
||||||
|
- Redirects to normalized `auth_next`
|
||||||
|
|
||||||
|
- `GET /api/auth/me`
|
||||||
|
- Returns current login user from session
|
||||||
|
|
||||||
|
### 2.2 Protected API Rule
|
||||||
|
|
||||||
|
`before_request` protects all `/api/**` routes except:
|
||||||
|
|
||||||
|
- `/api/login`
|
||||||
|
- `/api/daimler/authorized`
|
||||||
|
|
||||||
|
If unauthenticated:
|
||||||
|
|
||||||
|
- Returns `401`
|
||||||
|
- JSON body includes a `login_url`
|
||||||
|
|
||||||
|
### 2.3 Open Redirect Protection
|
||||||
|
|
||||||
|
The Python code validates `next`/redirect target by:
|
||||||
|
|
||||||
|
- Allowing absolute URL only if origin is in `AUTH_ALLOWED_NEXT_ORIGINS`
|
||||||
|
- Blocking redirect loops into login/callback endpoints
|
||||||
|
- Allowing only safe relative path starting with `/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Java Implementation Recommendation
|
||||||
|
|
||||||
|
Use Spring Boot 3.x + Spring Security 6.x.
|
||||||
|
|
||||||
|
Two implementation styles are possible:
|
||||||
|
|
||||||
|
1. **Preferred**: `spring-boot-starter-oauth2-client` for standard OAuth2/OIDC flow.
|
||||||
|
2. **Compatibility-first**: manual token/userinfo exchange with `RestClient` or `WebClient` to mimic Python line-by-line behavior.
|
||||||
|
|
||||||
|
For fastest parity with your current Flask logic, start with style 2, then migrate to style 1 later if needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Endpoint Mapping (Flask -> Java)
|
||||||
|
|
||||||
|
| Flask | Java (Suggested) | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `/api/login` | `/api/login` | Build authorization redirect + store `auth_next` |
|
||||||
|
| `/api/daimler/authorized` | `/api/daimler/authorized` | Handle callback, exchange token, fetch userinfo |
|
||||||
|
| `/api/auth/me` | `/api/auth/me` | Return session user |
|
||||||
|
| `before_request` | Security filter + auth entry point | Protect `/api/**`, return 401 with `login_url` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Spring Boot Project Dependencies
|
||||||
|
|
||||||
|
Maven example:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want clustered session sharing:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.session</groupId>
|
||||||
|
<artifactId>spring-session-data-redis</artifactId>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Configuration Mapping
|
||||||
|
|
||||||
|
Map Python env vars to Java properties:
|
||||||
|
|
||||||
|
| Python Env | Java Property |
|
||||||
|
|---|---|
|
||||||
|
| `AUTH_AUTHORIZATION_URL` | `auth.authorization-url` |
|
||||||
|
| `AUTH_TOKEN_URL` | `auth.token-url` |
|
||||||
|
| `AUTH_USERINFO_URL` | `auth.userinfo-url` |
|
||||||
|
| `AUTH_CLIENT_ID` | `auth.client-id` |
|
||||||
|
| `AUTH_CLIENT_SECRET` | `auth.client-secret` |
|
||||||
|
| `AUTH_SCOPE` | `auth.scope` |
|
||||||
|
| `AUTH_REDIRECT_URI` | `auth.redirect-uri` |
|
||||||
|
| `AUTH_ALLOWED_NEXT_ORIGINS` | `auth.allowed-next-origins` |
|
||||||
|
|
||||||
|
`application.yml` example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
auth:
|
||||||
|
authorization-url: https://ssoalpha.dvb.corpinter.net/v1/auth
|
||||||
|
token-url: https://ssoalpha.dvb.corpinter.net/v1/token
|
||||||
|
userinfo-url: https://ssoalpha.dvb.corpinter.net/v1/userinfo
|
||||||
|
client-id: ${AUTH_CLIENT_ID}
|
||||||
|
client-secret: ${AUTH_CLIENT_SECRET}
|
||||||
|
scope: "groups openid email profile"
|
||||||
|
redirect-uri: "http://localhost:8081/api/daimler/authorized"
|
||||||
|
allowed-next-origins:
|
||||||
|
- "http://localhost:8081"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Java Core Design
|
||||||
|
|
||||||
|
### 7.1 Session Keys (Keep Same Semantics)
|
||||||
|
|
||||||
|
- `auth_user` (Map or custom DTO)
|
||||||
|
- `auth_access_token` (String)
|
||||||
|
- `auth_id_token` (String, optional)
|
||||||
|
- `auth_next` (String)
|
||||||
|
|
||||||
|
### 7.2 Next Target Sanitization
|
||||||
|
|
||||||
|
Implement a utility equivalent to `_normalize_next_target`:
|
||||||
|
|
||||||
|
- Empty -> `/`
|
||||||
|
- Absolute URL allowed only for configured trusted origins and `http/https`
|
||||||
|
- Reject `//...`
|
||||||
|
- Reject `/api/login*` and `/api/daimler/authorized*`
|
||||||
|
- Reject non-leading-slash relative paths
|
||||||
|
|
||||||
|
### 7.3 Error Compatibility
|
||||||
|
|
||||||
|
For protected APIs when not logged in:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Unauthorized",
|
||||||
|
"login_url": "http://host/api/login?next=..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep this contract because frontend may already depend on it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Example Java Code Skeleton
|
||||||
|
|
||||||
|
### 8.1 AuthProperties
|
||||||
|
|
||||||
|
```java
|
||||||
|
@ConfigurationProperties(prefix = "auth")
|
||||||
|
public record AuthProperties(
|
||||||
|
String authorizationUrl,
|
||||||
|
String tokenUrl,
|
||||||
|
String userinfoUrl,
|
||||||
|
String clientId,
|
||||||
|
String clientSecret,
|
||||||
|
String scope,
|
||||||
|
String redirectUri,
|
||||||
|
List<String> allowedNextOrigins
|
||||||
|
) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 AuthController
|
||||||
|
|
||||||
|
```java
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AuthController {
|
||||||
|
|
||||||
|
private final AuthService authService;
|
||||||
|
private final RedirectTargetSanitizer sanitizer;
|
||||||
|
|
||||||
|
@GetMapping("/login")
|
||||||
|
public ResponseEntity<Void> login(@RequestParam(value = "next", required = false) String next,
|
||||||
|
HttpSession session) {
|
||||||
|
String safeNext = sanitizer.normalize(next);
|
||||||
|
session.setAttribute("auth_next", safeNext);
|
||||||
|
URI redirect = URI.create(authService.buildAuthorizationUrl());
|
||||||
|
return ResponseEntity.status(HttpStatus.FOUND).location(redirect).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/daimler/authorized")
|
||||||
|
public ResponseEntity<Void> authorized(@RequestParam(value = "code", required = false) String code,
|
||||||
|
HttpSession session) {
|
||||||
|
if (code == null || code.isBlank()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Missing code from SSO callback");
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthResult result = authService.exchangeAndLoadUser(code);
|
||||||
|
session.setAttribute("auth_user", result.userInfo());
|
||||||
|
session.setAttribute("auth_access_token", result.accessToken());
|
||||||
|
if (result.idToken() != null) {
|
||||||
|
session.setAttribute("auth_id_token", result.idToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
String next = (String) session.getAttribute("auth_next");
|
||||||
|
session.removeAttribute("auth_next");
|
||||||
|
String target = sanitizer.normalize(next == null ? "/" : next);
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.FOUND)
|
||||||
|
.location(URI.create(target))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/auth/me")
|
||||||
|
public Map<String, Object> me(HttpSession session) {
|
||||||
|
Object user = session.getAttribute("auth_user");
|
||||||
|
if (user == null) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Unauthorized");
|
||||||
|
}
|
||||||
|
return Map.of("user", user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 AuthService (Manual token + userinfo)
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AuthService {
|
||||||
|
|
||||||
|
private final AuthProperties props;
|
||||||
|
private final RestClient restClient = RestClient.create();
|
||||||
|
|
||||||
|
public String buildAuthorizationUrl() {
|
||||||
|
String scopeEncoded = URLEncoder.encode(props.scope(), StandardCharsets.UTF_8);
|
||||||
|
String redirectEncoded = URLEncoder.encode(props.redirectUri(), StandardCharsets.UTF_8);
|
||||||
|
return props.authorizationUrl()
|
||||||
|
+ "?response_type=code"
|
||||||
|
+ "&client_id=" + URLEncoder.encode(props.clientId(), StandardCharsets.UTF_8)
|
||||||
|
+ "&scope=" + scopeEncoded
|
||||||
|
+ "&redirect_uri=" + redirectEncoded
|
||||||
|
+ "&prompt=login";
|
||||||
|
}
|
||||||
|
|
||||||
|
public AuthResult exchangeAndLoadUser(String code) {
|
||||||
|
Map<String, Object> token = exchangeCodeForToken(code);
|
||||||
|
String accessToken = (String) token.get("access_token");
|
||||||
|
String idToken = (String) token.get("id_token");
|
||||||
|
if (accessToken == null || accessToken.isBlank()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "No access_token in token response");
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> userInfo = fetchUserInfo(accessToken);
|
||||||
|
return new AuthResult(accessToken, idToken, userInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private Map<String, Object> exchangeCodeForToken(String code) {
|
||||||
|
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
|
||||||
|
form.add("grant_type", "authorization_code");
|
||||||
|
form.add("code", code);
|
||||||
|
form.add("client_id", props.clientId());
|
||||||
|
form.add("client_secret", props.clientSecret());
|
||||||
|
form.add("redirect_uri", props.redirectUri());
|
||||||
|
|
||||||
|
try {
|
||||||
|
return restClient.post()
|
||||||
|
.uri(props.tokenUrl())
|
||||||
|
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
|
||||||
|
.body(form)
|
||||||
|
.retrieve()
|
||||||
|
.body(Map.class);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Auth request failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private Map<String, Object> fetchUserInfo(String accessToken) {
|
||||||
|
try {
|
||||||
|
return restClient.get()
|
||||||
|
.uri(props.userinfoUrl())
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
|
||||||
|
.retrieve()
|
||||||
|
.body(Map.class);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Auth request failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record AuthResult(String accessToken, String idToken, Map<String, Object> userInfo) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.4 Security Configuration
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSecurity
|
||||||
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
SecurityFilterChain filterChain(HttpSecurity http, AuthEntryPoint entryPoint) throws Exception {
|
||||||
|
http
|
||||||
|
.csrf(csrf -> csrf.disable())
|
||||||
|
.authorizeHttpRequests(auth -> auth
|
||||||
|
.requestMatchers("/api/login", "/api/daimler/authorized").permitAll()
|
||||||
|
.requestMatchers("/api/**").authenticated()
|
||||||
|
.anyRequest().permitAll()
|
||||||
|
)
|
||||||
|
.exceptionHandling(ex -> ex.authenticationEntryPoint(entryPoint));
|
||||||
|
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Custom entry point returns Flask-compatible 401 JSON with `login_url`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Testing Checklist (Must Pass)
|
||||||
|
|
||||||
|
1. Unauthenticated call to protected API returns `401` + `login_url`.
|
||||||
|
2. `GET /api/login?next=/data-browser` stores session `auth_next` and redirects to SSO.
|
||||||
|
3. Callback without `code` returns `400`.
|
||||||
|
4. Callback with valid `code` stores `auth_user` + token and redirects to `auth_next`.
|
||||||
|
5. Open-redirect attempts (e.g., `https://evil.com`) are rejected to `/`.
|
||||||
|
6. `/api/auth/me` returns current user after login.
|
||||||
|
7. Token endpoint failure maps to `502` with clear error body.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Migration Risks and Notes
|
||||||
|
|
||||||
|
- Current Python default includes hard-coded `AUTH_CLIENT_SECRET`; Java version should require env injection and avoid defaults in production.
|
||||||
|
- If Java service is behind reverse proxy, compute external `login_url` carefully (consider `X-Forwarded-*`).
|
||||||
|
- If multiple app instances are used, local session may break login continuity; use Redis session.
|
||||||
|
- Keep endpoint path names unchanged to avoid frontend regression.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Suggested Commit Log (for Java team)
|
||||||
|
|
||||||
|
Use this as commit message template:
|
||||||
|
|
||||||
|
```text
|
||||||
|
feat(auth): implement Benz SSO flow compatible with Flask root_db_api
|
||||||
|
|
||||||
|
- add /api/login endpoint to start SSO authorization flow
|
||||||
|
- add /api/daimler/authorized callback endpoint for code exchange
|
||||||
|
- add /api/auth/me endpoint to expose authenticated user info
|
||||||
|
- protect /api/** routes and return 401 with login_url when unauthenticated
|
||||||
|
- implement next-target sanitization to prevent open redirect
|
||||||
|
- persist auth_user/auth_access_token/auth_id_token in HttpSession
|
||||||
|
- add auth configuration mapping (authorization/token/userinfo URLs, client credentials)
|
||||||
|
- add integration tests for callback, unauthorized, and redirect safety
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Implementation Order (Recommended)
|
||||||
|
|
||||||
|
1. Build properties + sanitizer utility.
|
||||||
|
2. Implement auth controller endpoints.
|
||||||
|
3. Implement token/userinfo service.
|
||||||
|
4. Implement security entry point with Flask-compatible error format.
|
||||||
|
5. Add integration tests and run end-to-end with real SSO test environment.
|
||||||
|
|
||||||
|
This keeps risk low and makes behavior parity easy to verify against existing Flask service.
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ dependencies = [
|
|||||||
"cos-python-sdk-v5==1.9.37",
|
"cos-python-sdk-v5==1.9.37",
|
||||||
"python-dotenv==1.1.1",
|
"python-dotenv==1.1.1",
|
||||||
"requests==2.32.4",
|
"requests==2.32.4",
|
||||||
|
"authlib>=1.6.0",
|
||||||
"pyyaml==6.0.2",
|
"pyyaml==6.0.2",
|
||||||
"pydantic==2.11.7",
|
"pydantic==2.11.7",
|
||||||
"sqlalchemy==2.0.41",
|
"sqlalchemy==2.0.41",
|
||||||
|
|||||||
@@ -1,35 +1,55 @@
|
|||||||
# run.py
|
# run.py
|
||||||
import os
|
import os
|
||||||
from urllib.parse import urlencode, urlparse
|
from urllib.parse import parse_qsl, urlencode, urlparse
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
from authlib.oauth2 import OAuth2Error
|
||||||
|
from authlib.oauth2.rfc6749 import MissingAuthorizationError
|
||||||
from flasgger import Swagger
|
from flasgger import Swagger
|
||||||
from flask import Flask, jsonify, redirect, render_template, request, session, url_for
|
from flask import Flask, g, jsonify, redirect, render_template, request, url_for
|
||||||
|
from itsdangerous import BadSignature, URLSafeSerializer
|
||||||
|
|
||||||
from fst_data_pipeline.apps.root_db_api.src.api import api_bp
|
from fst_data_pipeline.apps.root_db_api.src.api import api_bp
|
||||||
|
from fst_data_pipeline.apps.root_db_api.src.core.auth_user import (
|
||||||
|
get_current_user,
|
||||||
|
set_current_user_from_claims,
|
||||||
|
)
|
||||||
|
from fst_data_pipeline.apps.root_db_api.src.core.oauth_protector import (
|
||||||
|
build_require_oauth,
|
||||||
|
validate_jwt_access_token,
|
||||||
|
)
|
||||||
|
|
||||||
app = Flask(__name__, template_folder="../templates")
|
app = Flask(__name__, template_folder="../templates")
|
||||||
app.config["SECRET_KEY"] = os.getenv("FLASK_SECRET_KEY", "SECRET")
|
app.config["SECRET_KEY"] = os.getenv("FLASK_SECRET_KEY", "SECRET")
|
||||||
|
state_signer = URLSafeSerializer(app.config["SECRET_KEY"], salt="auth-next-state")
|
||||||
|
|
||||||
AUTH_AUTHORIZATION_URL = os.getenv(
|
AUTHORIZATION_URL = os.getenv(
|
||||||
"AUTH_AUTHORIZATION_URL", "https://ssoalpha.dvb.corpinter.net/v1/auth"
|
"AUTHORIZATION_URL", "https://ssoalpha.dvb.corpinter.net/v1/auth"
|
||||||
)
|
)
|
||||||
AUTH_TOKEN_URL = os.getenv("AUTH_TOKEN_URL", "https://ssoalpha.dvb.corpinter.net/v1/token")
|
TOKEN_URL = os.getenv("TOKEN_URL", "https://ssoalpha.dvb.corpinter.net/v1/token")
|
||||||
AUTH_USERINFO_URL = os.getenv(
|
USERINFO_URL = os.getenv(
|
||||||
"AUTH_USERINFO_URL", "https://ssoalpha.dvb.corpinter.net/v1/userinfo"
|
"USERINFO_URL", "https://ssoalpha.dvb.corpinter.net/v1/userinfo"
|
||||||
)
|
)
|
||||||
AUTH_CLIENT_ID = os.getenv("AUTH_CLIENT_ID", "F0ED0AB5-16B9-49A2-96F5-A5702D83B614")
|
CLIENT_ID = os.getenv("CLIENT_ID", "F0ED0AB5-16B9-49A2-96F5-A5702D83B614")
|
||||||
AUTH_CLIENT_SECRET = os.getenv("AUTH_CLIENT_SECRET", "j92fUMG35PSCul8-7Hw0ca_.1vA~6mI4")
|
CLIENT_SECRET = os.getenv("CLIENT_SECRET", "j92fUMG35PSCul8-7Hw0ca_.1vA~6mI4")
|
||||||
AUTH_SCOPE = os.getenv("AUTH_SCOPE", "groups openid email profile")
|
SCOPE = os.getenv("SCOPE", "groups openid email profile")
|
||||||
AUTH_REDIRECT_URI = os.getenv("AUTH_REDIRECT_URI", "http://localhost:8081/api/daimler/authorized")
|
REDIRECT_URI = os.getenv("REDIRECT_URI", "http://localhost:8081/api/daimler/authorized")
|
||||||
AUTH_ALLOWED_NEXT_ORIGINS = {
|
JWK_SET_URI = os.getenv("JWK_SET_URI", "https://ssoalpha.dvb.corpinter.net/v1/keys")
|
||||||
|
JWT_ISSUER = os.getenv("JWT_ISSUER", "https://ssoalpha.dvb.corpinter.net/v1")
|
||||||
|
JWT_AUDIENCE = os.getenv("JWT_AUDIENCE", CLIENT_ID)
|
||||||
|
ALLOWED_NEXT_ORIGINS = {
|
||||||
origin.strip()
|
origin.strip()
|
||||||
for origin in os.getenv(
|
for origin in os.getenv(
|
||||||
"AUTH_ALLOWED_NEXT_ORIGINS",
|
"ALLOWED_NEXT_ORIGINS",
|
||||||
"http://localhost:8081",
|
"http://localhost:8081",
|
||||||
).split(",")
|
).split(",")
|
||||||
if origin.strip()
|
if origin.strip()
|
||||||
}
|
}
|
||||||
|
require_oauth, oauth_token_validator = build_require_oauth(
|
||||||
|
issuer=JWT_ISSUER,
|
||||||
|
resource_server=JWT_AUDIENCE,
|
||||||
|
jwk_set_uri=JWK_SET_URI,
|
||||||
|
)
|
||||||
|
|
||||||
# 注册蓝图
|
# 注册蓝图
|
||||||
app.register_blueprint(api_bp, url_prefix="/api")
|
app.register_blueprint(api_bp, url_prefix="/api")
|
||||||
@@ -56,7 +76,7 @@ def _normalize_next_target(next_target: str) -> str:
|
|||||||
safe_path = parsed.path or "/"
|
safe_path = parsed.path or "/"
|
||||||
if (
|
if (
|
||||||
parsed.scheme in {"http", "https"}
|
parsed.scheme in {"http", "https"}
|
||||||
and origin in AUTH_ALLOWED_NEXT_ORIGINS
|
and origin in ALLOWED_NEXT_ORIGINS
|
||||||
and not safe_path.startswith("/api/login")
|
and not safe_path.startswith("/api/login")
|
||||||
and not safe_path.startswith("/api/daimler/authorized")
|
and not safe_path.startswith("/api/daimler/authorized")
|
||||||
):
|
):
|
||||||
@@ -77,35 +97,52 @@ def _normalize_next_target(next_target: str) -> str:
|
|||||||
return next_target
|
return next_target
|
||||||
|
|
||||||
|
|
||||||
def _build_authorization_query() -> dict:
|
def _build_authorization_query(next_target: str) -> dict:
|
||||||
return {
|
return {
|
||||||
"response_type": "code",
|
"response_type": "code",
|
||||||
"client_id": AUTH_CLIENT_ID,
|
"client_id": CLIENT_ID,
|
||||||
"scope": AUTH_SCOPE,
|
"scope": SCOPE,
|
||||||
"redirect_uri": AUTH_REDIRECT_URI,
|
"redirect_uri": REDIRECT_URI,
|
||||||
|
"state": _build_state(next_target),
|
||||||
"prompt": "login",
|
"prompt": "login",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_state(next_target: str) -> str:
|
||||||
|
normalized_target = _normalize_next_target(next_target)
|
||||||
|
return state_signer.dumps({"next": normalized_target})
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_next_target_from_state(state: str) -> str:
|
||||||
|
if not state:
|
||||||
|
return "/"
|
||||||
|
try:
|
||||||
|
payload = state_signer.loads(state)
|
||||||
|
except BadSignature:
|
||||||
|
return "/"
|
||||||
|
|
||||||
|
return _normalize_next_target(payload.get("next", "/"))
|
||||||
|
|
||||||
|
|
||||||
def _exchange_code_for_token(code: str) -> dict:
|
def _exchange_code_for_token(code: str) -> dict:
|
||||||
data = {
|
data = {
|
||||||
"grant_type": "authorization_code",
|
"grant_type": "authorization_code",
|
||||||
"code": code,
|
"code": code,
|
||||||
"client_id": AUTH_CLIENT_ID,
|
"client_id": CLIENT_ID,
|
||||||
"client_secret": AUTH_CLIENT_SECRET,
|
"client_secret": CLIENT_SECRET,
|
||||||
"redirect_uri": AUTH_REDIRECT_URI,
|
"redirect_uri": REDIRECT_URI,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Try form-encoded first, then JSON fallback
|
# Try form-encoded first, then JSON fallback
|
||||||
try:
|
try:
|
||||||
form_resp = requests.post(AUTH_TOKEN_URL, data=data, timeout=10)
|
form_resp = requests.post(TOKEN_URL, data=data, timeout=10)
|
||||||
if form_resp.ok:
|
if form_resp.ok:
|
||||||
return form_resp.json()
|
return form_resp.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
json_resp = requests.post(AUTH_TOKEN_URL, json=data, timeout=10)
|
json_resp = requests.post(TOKEN_URL, json=data, timeout=10)
|
||||||
if json_resp.ok:
|
if json_resp.ok:
|
||||||
return json_resp.json()
|
return json_resp.json()
|
||||||
json_resp.raise_for_status()
|
json_resp.raise_for_status()
|
||||||
@@ -113,11 +150,15 @@ def _exchange_code_for_token(code: str) -> dict:
|
|||||||
raise e
|
raise e
|
||||||
|
|
||||||
|
|
||||||
def _fetch_userinfo(access_token: str) -> dict:
|
def _append_auth_fragment(target: str, access_token: str, id_token: str = "") -> str:
|
||||||
headers = {"Authorization": f"Bearer {access_token}"}
|
parsed_target = urlparse(target)
|
||||||
response = requests.get(AUTH_USERINFO_URL, headers=headers, timeout=10)
|
fragment_pairs = dict(parse_qsl(parsed_target.fragment, keep_blank_values=True))
|
||||||
response.raise_for_status()
|
fragment_pairs["auth_access_token"] = access_token
|
||||||
return response.json()
|
if id_token:
|
||||||
|
fragment_pairs["auth_id_token"] = id_token
|
||||||
|
|
||||||
|
updated_fragment = urlencode(fragment_pairs)
|
||||||
|
return parsed_target._replace(fragment=updated_fragment).geturl()
|
||||||
|
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
@@ -130,26 +171,35 @@ def _protect_api_routes():
|
|||||||
if request.path in auth_allowlist:
|
if request.path in auth_allowlist:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if request.path.startswith("/api") and "auth_user" not in session:
|
if request.path.startswith("/api"):
|
||||||
|
try:
|
||||||
|
token = require_oauth.acquire_token()
|
||||||
|
claims = dict(token)
|
||||||
|
g.auth_user = set_current_user_from_claims(claims).to_dict()
|
||||||
|
g.auth_access_token = request.headers.get("Authorization", "")
|
||||||
|
except MissingAuthorizationError:
|
||||||
referer = request.headers.get("Referer", "")
|
referer = request.headers.get("Referer", "")
|
||||||
next_target = _normalize_next_target(referer)
|
next_target = _normalize_next_target(referer)
|
||||||
login_url = url_for("auth_login_api", next=next_target, _external=True)
|
login_url = url_for("auth_login_api", next=next_target, _external=True)
|
||||||
return jsonify({"error": "Unauthorized", "login_url": login_url}), 401
|
return jsonify({"error": "Unauthorized", "login_url": login_url}), 401
|
||||||
|
except OAuth2Error as exc:
|
||||||
|
referer = request.headers.get("Referer", "")
|
||||||
|
next_target = _normalize_next_target(referer)
|
||||||
|
login_url = url_for("auth_login_api", next=next_target, _external=True)
|
||||||
|
return (
|
||||||
|
jsonify({"error": "Unauthorized", "detail": str(exc), "login_url": login_url}),
|
||||||
|
401,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def hello_world():
|
def hello_world():
|
||||||
user = session.get("auth_user")
|
return jsonify({"message": "Hello World!"})
|
||||||
if not user:
|
|
||||||
return redirect(url_for("auth_login_api"))
|
|
||||||
return jsonify({"message": "Hello World!", "user": user})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/data-browser")
|
@app.route("/data-browser")
|
||||||
def data_browser():
|
def data_browser():
|
||||||
"""数据浏览器前端界面"""
|
"""数据浏览器前端界面"""
|
||||||
if "auth_user" not in session:
|
|
||||||
return redirect(url_for("auth_login_api"))
|
|
||||||
return render_template("data_browser.html")
|
return render_template("data_browser.html")
|
||||||
|
|
||||||
|
|
||||||
@@ -157,15 +207,14 @@ def data_browser():
|
|||||||
def auth_login_api():
|
def auth_login_api():
|
||||||
next_target = request.args.get("next", "")
|
next_target = request.args.get("next", "")
|
||||||
|
|
||||||
session["auth_next"] = _normalize_next_target(next_target)
|
query = _build_authorization_query(next_target)
|
||||||
|
return redirect(f"{AUTHORIZATION_URL}?{urlencode(query)}")
|
||||||
query = _build_authorization_query()
|
|
||||||
return redirect(f"{AUTH_AUTHORIZATION_URL}?{urlencode(query)}")
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/daimler/authorized")
|
@app.route("/api/daimler/authorized")
|
||||||
def auth_callback_api():
|
def auth_callback_api():
|
||||||
code = request.args.get("code", "")
|
code = request.args.get("code", "")
|
||||||
|
state = request.args.get("state", "")
|
||||||
|
|
||||||
if not code:
|
if not code:
|
||||||
return jsonify({"error": "Missing code from SSO callback"}), 400
|
return jsonify({"error": "Missing code from SSO callback"}), 400
|
||||||
@@ -173,26 +222,24 @@ def auth_callback_api():
|
|||||||
try:
|
try:
|
||||||
token_response = _exchange_code_for_token(code)
|
token_response = _exchange_code_for_token(code)
|
||||||
access_token = token_response.get("access_token")
|
access_token = token_response.get("access_token")
|
||||||
id_token = token_response.get("id_token") # Save id_token for logout
|
id_token = token_response.get("id_token")
|
||||||
if not access_token:
|
if not access_token:
|
||||||
return jsonify({"error": "No access_token in token response"}), 400
|
return jsonify({"error": "No access_token in token response"}), 400
|
||||||
|
|
||||||
userinfo = _fetch_userinfo(access_token)
|
# Validate token before redirecting it back to frontend storage.
|
||||||
|
validate_jwt_access_token(oauth_token_validator, access_token)
|
||||||
session["auth_user"] = userinfo
|
except (requests.RequestException, OAuth2Error) as exc:
|
||||||
session["auth_access_token"] = access_token
|
|
||||||
if id_token:
|
|
||||||
session["auth_id_token"] = id_token
|
|
||||||
except requests.RequestException as exc:
|
|
||||||
return jsonify({"error": "Auth request failed", "detail": str(exc)}), 502
|
return jsonify({"error": "Auth request failed", "detail": str(exc)}), 502
|
||||||
|
|
||||||
target = _normalize_next_target(session.pop("auth_next", "/"))
|
target = _resolve_next_target_from_state(state)
|
||||||
return redirect(target)
|
return redirect(_append_auth_fragment(target, access_token, id_token or ""))
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/auth/me")
|
@app.route("/api/auth/me")
|
||||||
|
@require_oauth(scope="profile")
|
||||||
def auth_me():
|
def auth_me():
|
||||||
user = session.get("auth_user")
|
current_user = get_current_user()
|
||||||
|
user = current_user.to_dict() if current_user else None
|
||||||
if not user:
|
if not user:
|
||||||
return jsonify({"error": "Unauthorized"}), 401
|
return jsonify({"error": "Unauthorized"}), 401
|
||||||
return jsonify({"user": user})
|
return jsonify({"user": user})
|
||||||
|
|||||||
109
fst_data_pipeline/apps/root_db_api/src/core/auth_guard.py
Normal file
109
fst_data_pipeline/apps/root_db_api/src/core/auth_guard.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import wraps
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from flask import g, jsonify
|
||||||
|
|
||||||
|
from fst_data_pipeline.apps.root_db_api.src.core.auth_user import CurrentUser, get_current_user
|
||||||
|
|
||||||
|
|
||||||
|
def require_oauth(
|
||||||
|
scope: str | list[str] | tuple[str, ...] | None = None,
|
||||||
|
groups: str | list[str] | tuple[str, ...] | None = None,
|
||||||
|
roles: str | list[str] | tuple[str, ...] | None = None,
|
||||||
|
entitlements: str | list[str] | tuple[str, ...] | None = None,
|
||||||
|
):
|
||||||
|
"""Decorator for claim-based authorization checks on API endpoints."""
|
||||||
|
|
||||||
|
required_scope = _normalize_required(scope)
|
||||||
|
required_groups = _normalize_required(groups)
|
||||||
|
required_roles = _normalize_required(roles)
|
||||||
|
required_entitlements = _normalize_required(entitlements)
|
||||||
|
|
||||||
|
def _decorator(fn):
|
||||||
|
@wraps(fn)
|
||||||
|
def _wrapped(*args, **kwargs):
|
||||||
|
current_user = get_current_user()
|
||||||
|
if not current_user:
|
||||||
|
return jsonify({"error": "Unauthorized"}), 401
|
||||||
|
|
||||||
|
missing = _collect_missing_claims(
|
||||||
|
current_user=current_user,
|
||||||
|
required_scope=required_scope,
|
||||||
|
required_groups=required_groups,
|
||||||
|
required_roles=required_roles,
|
||||||
|
required_entitlements=required_entitlements,
|
||||||
|
)
|
||||||
|
if missing:
|
||||||
|
return jsonify({"error": "Forbidden", "missing": missing}), 403
|
||||||
|
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
return _wrapped
|
||||||
|
|
||||||
|
return _decorator
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_missing_claims(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
required_scope: list[str],
|
||||||
|
required_groups: list[str],
|
||||||
|
required_roles: list[str],
|
||||||
|
required_entitlements: list[str],
|
||||||
|
) -> dict[str, list[str]]:
|
||||||
|
missing: dict[str, list[str]] = {}
|
||||||
|
|
||||||
|
if required_scope:
|
||||||
|
token_scope = set(current_user.scope)
|
||||||
|
lack = [item for item in required_scope if item not in token_scope]
|
||||||
|
if lack:
|
||||||
|
missing["scope"] = lack
|
||||||
|
|
||||||
|
if required_groups:
|
||||||
|
token_groups = set(current_user.groups)
|
||||||
|
lack = [item for item in required_groups if item not in token_groups]
|
||||||
|
if lack:
|
||||||
|
missing["groups"] = lack
|
||||||
|
|
||||||
|
if required_roles:
|
||||||
|
token_roles = set(_claim_to_list(getattr(g, "auth_claims", {}).get("roles")))
|
||||||
|
if not token_roles:
|
||||||
|
token_roles = set(current_user.roles)
|
||||||
|
lack = [item for item in required_roles if item not in token_roles]
|
||||||
|
if lack:
|
||||||
|
missing["roles"] = lack
|
||||||
|
|
||||||
|
if required_entitlements:
|
||||||
|
token_entitlements = set(
|
||||||
|
_claim_to_list(getattr(g, "auth_claims", {}).get("entitlements"))
|
||||||
|
)
|
||||||
|
if not token_entitlements:
|
||||||
|
token_entitlements = set(current_user.entitlements)
|
||||||
|
lack = [item for item in required_entitlements if item not in token_entitlements]
|
||||||
|
if lack:
|
||||||
|
missing["entitlements"] = lack
|
||||||
|
|
||||||
|
return missing
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_required(value: str | list[str] | tuple[str, ...] | None) -> list[str]:
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, str):
|
||||||
|
# Support both "a b" and "a,b" styles in route decorators.
|
||||||
|
compact = value.replace(",", " ")
|
||||||
|
return [item for item in compact.split() if item]
|
||||||
|
return [str(item) for item in value if item is not None]
|
||||||
|
|
||||||
|
|
||||||
|
def _claim_to_list(value: Any) -> list[str]:
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, str):
|
||||||
|
compact = value.replace(",", " ")
|
||||||
|
return [item for item in compact.split() if item]
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [str(item) for item in value if item is not None]
|
||||||
|
return [str(value)]
|
||||||
|
|
||||||
107
fst_data_pipeline/apps/root_db_api/src/core/auth_user.py
Normal file
107
fst_data_pipeline/apps/root_db_api/src/core/auth_user.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from flask import g
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CurrentUser:
|
||||||
|
"""Normalized user profile decoded from JWT claims."""
|
||||||
|
|
||||||
|
sub: str = ""
|
||||||
|
iss: str = ""
|
||||||
|
aud: list[str] = field(default_factory=list)
|
||||||
|
exp: datetime | None = None
|
||||||
|
iat: datetime | None = None
|
||||||
|
email: str = ""
|
||||||
|
scope: list[str] = field(default_factory=list)
|
||||||
|
groups: list[str] = field(default_factory=list)
|
||||||
|
roles: list[str] = field(default_factory=list)
|
||||||
|
entitlements: list[str] = field(default_factory=list)
|
||||||
|
name: str = ""
|
||||||
|
preferred_username: str = ""
|
||||||
|
given_name: str = ""
|
||||||
|
family_name: str = ""
|
||||||
|
user_type: str = ""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_claims(cls, claims: dict[str, Any]) -> "CurrentUser":
|
||||||
|
return cls(
|
||||||
|
sub=str(claims.get("sub") or ""),
|
||||||
|
iss=str(claims.get("iss") or ""),
|
||||||
|
aud=_to_list(claims.get("aud")),
|
||||||
|
exp=_to_datetime(claims.get("exp")),
|
||||||
|
iat=_to_datetime(claims.get("iat")),
|
||||||
|
email=str(claims.get("email") or ""),
|
||||||
|
scope=_to_scope_list(claims.get("scope")),
|
||||||
|
groups=_to_list(claims.get("groups")),
|
||||||
|
roles=_to_list(claims.get("roles")),
|
||||||
|
entitlements=_to_list(claims.get("entitlements")),
|
||||||
|
name=str(claims.get("name") or ""),
|
||||||
|
preferred_username=str(claims.get("preferred_username") or ""),
|
||||||
|
given_name=str(claims.get("given_name") or ""),
|
||||||
|
family_name=str(claims.get("family_name") or ""),
|
||||||
|
user_type=str(claims.get("user_type") or ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
payload = asdict(self)
|
||||||
|
# Keep API response JSON-serializable.
|
||||||
|
payload["exp"] = self.exp.isoformat() if self.exp else None
|
||||||
|
payload["iat"] = self.iat.isoformat() if self.iat else None
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def set_current_user_from_claims(claims: dict[str, Any]) -> CurrentUser:
|
||||||
|
user = CurrentUser.from_claims(claims)
|
||||||
|
g.auth_claims = claims
|
||||||
|
g.current_user = user
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user() -> CurrentUser | None:
|
||||||
|
current = getattr(g, "current_user", None)
|
||||||
|
if isinstance(current, CurrentUser):
|
||||||
|
return current
|
||||||
|
|
||||||
|
claims = getattr(g, "auth_claims", None)
|
||||||
|
if isinstance(claims, dict):
|
||||||
|
user = CurrentUser.from_claims(claims)
|
||||||
|
g.current_user = user
|
||||||
|
return user
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _to_list(value: Any) -> list[str]:
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, str):
|
||||||
|
return [value] if value else []
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [str(item) for item in value if item is not None]
|
||||||
|
return [str(value)]
|
||||||
|
|
||||||
|
|
||||||
|
def _to_scope_list(value: Any) -> list[str]:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return [item for item in value.split() if item]
|
||||||
|
return _to_list(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_datetime(value: Any) -> datetime | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
try:
|
||||||
|
timestamp = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return datetime.fromtimestamp(timestamp, tz=timezone.utc)
|
||||||
|
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from authlib.integrations.flask_oauth2 import ResourceProtector
|
||||||
|
from authlib.oauth2 import OAuth2Error
|
||||||
|
from authlib.oauth2.rfc9068 import JWTBearerTokenValidator
|
||||||
|
|
||||||
|
|
||||||
|
class SSOJWTBearerTokenValidator(JWTBearerTokenValidator):
|
||||||
|
"""JWT validator backed by a remote JWK Set URI with in-memory cache."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
issuer: str,
|
||||||
|
resource_server: str,
|
||||||
|
jwk_set_uri: str,
|
||||||
|
cache_ttl_seconds: int = 300,
|
||||||
|
):
|
||||||
|
super().__init__(issuer=issuer, resource_server=resource_server)
|
||||||
|
self.jwk_set_uri = jwk_set_uri
|
||||||
|
self.cache_ttl_seconds = cache_ttl_seconds
|
||||||
|
self._cached_jwks: dict[str, Any] | None = None
|
||||||
|
self._cached_at = 0.0
|
||||||
|
|
||||||
|
def get_jwks(self):
|
||||||
|
now = time.monotonic()
|
||||||
|
if self._cached_jwks and (now - self._cached_at) < self.cache_ttl_seconds:
|
||||||
|
return self._cached_jwks
|
||||||
|
|
||||||
|
response = requests.get(self.jwk_set_uri, timeout=10)
|
||||||
|
response.raise_for_status()
|
||||||
|
self._cached_jwks = response.json()
|
||||||
|
self._cached_at = now
|
||||||
|
return self._cached_jwks
|
||||||
|
|
||||||
|
|
||||||
|
def build_require_oauth(
|
||||||
|
issuer: str,
|
||||||
|
resource_server: str,
|
||||||
|
jwk_set_uri: str,
|
||||||
|
) -> tuple[ResourceProtector, SSOJWTBearerTokenValidator]:
|
||||||
|
require_oauth = ResourceProtector()
|
||||||
|
validator = SSOJWTBearerTokenValidator(
|
||||||
|
issuer=issuer,
|
||||||
|
resource_server=resource_server,
|
||||||
|
jwk_set_uri=jwk_set_uri,
|
||||||
|
)
|
||||||
|
require_oauth.register_token_validator(validator)
|
||||||
|
return require_oauth, validator
|
||||||
|
|
||||||
|
|
||||||
|
def validate_jwt_access_token(
|
||||||
|
validator: SSOJWTBearerTokenValidator,
|
||||||
|
token_string: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
token = validator.authenticate_token(token_string)
|
||||||
|
if not token:
|
||||||
|
raise OAuth2Error(error="invalid_token")
|
||||||
|
validator.validate_token(token, scopes=[], request=None)
|
||||||
|
return dict(token)
|
||||||
|
|
||||||
Reference in New Issue
Block a user