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:
ZhuJW
2026-06-24 18:20:00 +08:00
parent 988678b75f
commit e5d3c957de
7 changed files with 899 additions and 56 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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",

View File

@@ -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})

View 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)]

View 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)

View File

@@ -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)