diff --git a/fst_data_pipeline/apps/README.md b/fst_data_pipeline/apps/README.md index b4ff4c7..a811b0a 100644 --- a/fst_data_pipeline/apps/README.md +++ b/fst_data_pipeline/apps/README.md @@ -1,2 +1,122 @@ -# Micro Service apps -Backend micro service application for fst data production line. \ No newline at end of file +# Micro Service Apps + +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.` +- `.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. diff --git a/fst_data_pipeline/apps/root_db_api/SSO_JAVA_IMPLEMENTATION_GUIDE.md b/fst_data_pipeline/apps/root_db_api/SSO_JAVA_IMPLEMENTATION_GUIDE.md new file mode 100644 index 0000000..de6dec5 --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/SSO_JAVA_IMPLEMENTATION_GUIDE.md @@ -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 + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-validation + + +``` + +If you want clustered session sharing: + +```xml + + org.springframework.session + spring-session-data-redis + +``` + +--- + +## 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 allowedNextOrigins +) {} +``` + +### 8.2 AuthController + +```java +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + private final RedirectTargetSanitizer sanitizer; + + @GetMapping("/login") + public ResponseEntity 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 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 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 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 userInfo = fetchUserInfo(accessToken); + return new AuthResult(accessToken, idToken, userInfo); + } + + @SuppressWarnings("unchecked") + private Map exchangeCodeForToken(String code) { + MultiValueMap 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 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 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. + diff --git a/fst_data_pipeline/apps/root_db_api/pyproject.toml b/fst_data_pipeline/apps/root_db_api/pyproject.toml index a9f5d09..546492e 100644 --- a/fst_data_pipeline/apps/root_db_api/pyproject.toml +++ b/fst_data_pipeline/apps/root_db_api/pyproject.toml @@ -28,6 +28,7 @@ dependencies = [ "cos-python-sdk-v5==1.9.37", "python-dotenv==1.1.1", "requests==2.32.4", + "authlib>=1.6.0", "pyyaml==6.0.2", "pydantic==2.11.7", "sqlalchemy==2.0.41", diff --git a/fst_data_pipeline/apps/root_db_api/src/app.py b/fst_data_pipeline/apps/root_db_api/src/app.py index 9da13a8..de9d812 100644 --- a/fst_data_pipeline/apps/root_db_api/src/app.py +++ b/fst_data_pipeline/apps/root_db_api/src/app.py @@ -1,35 +1,55 @@ # run.py import os -from urllib.parse import urlencode, urlparse +from urllib.parse import parse_qsl, urlencode, urlparse import requests +from authlib.oauth2 import OAuth2Error +from authlib.oauth2.rfc6749 import MissingAuthorizationError 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.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.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( - "AUTH_AUTHORIZATION_URL", "https://ssoalpha.dvb.corpinter.net/v1/auth" +AUTHORIZATION_URL = os.getenv( + "AUTHORIZATION_URL", "https://ssoalpha.dvb.corpinter.net/v1/auth" ) -AUTH_TOKEN_URL = os.getenv("AUTH_TOKEN_URL", "https://ssoalpha.dvb.corpinter.net/v1/token") -AUTH_USERINFO_URL = os.getenv( - "AUTH_USERINFO_URL", "https://ssoalpha.dvb.corpinter.net/v1/userinfo" +TOKEN_URL = os.getenv("TOKEN_URL", "https://ssoalpha.dvb.corpinter.net/v1/token") +USERINFO_URL = os.getenv( + "USERINFO_URL", "https://ssoalpha.dvb.corpinter.net/v1/userinfo" ) -AUTH_CLIENT_ID = os.getenv("AUTH_CLIENT_ID", "F0ED0AB5-16B9-49A2-96F5-A5702D83B614") -AUTH_CLIENT_SECRET = os.getenv("AUTH_CLIENT_SECRET", "j92fUMG35PSCul8-7Hw0ca_.1vA~6mI4") -AUTH_SCOPE = os.getenv("AUTH_SCOPE", "groups openid email profile") -AUTH_REDIRECT_URI = os.getenv("AUTH_REDIRECT_URI", "http://localhost:8081/api/daimler/authorized") -AUTH_ALLOWED_NEXT_ORIGINS = { +CLIENT_ID = os.getenv("CLIENT_ID", "F0ED0AB5-16B9-49A2-96F5-A5702D83B614") +CLIENT_SECRET = os.getenv("CLIENT_SECRET", "j92fUMG35PSCul8-7Hw0ca_.1vA~6mI4") +SCOPE = os.getenv("SCOPE", "groups openid email profile") +REDIRECT_URI = os.getenv("REDIRECT_URI", "http://localhost:8081/api/daimler/authorized") +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() for origin in os.getenv( - "AUTH_ALLOWED_NEXT_ORIGINS", + "ALLOWED_NEXT_ORIGINS", "http://localhost:8081", ).split(",") 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") @@ -56,7 +76,7 @@ def _normalize_next_target(next_target: str) -> str: safe_path = parsed.path or "/" if ( 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/daimler/authorized") ): @@ -77,35 +97,52 @@ def _normalize_next_target(next_target: str) -> str: return next_target -def _build_authorization_query() -> dict: +def _build_authorization_query(next_target: str) -> dict: return { "response_type": "code", - "client_id": AUTH_CLIENT_ID, - "scope": AUTH_SCOPE, - "redirect_uri": AUTH_REDIRECT_URI, + "client_id": CLIENT_ID, + "scope": SCOPE, + "redirect_uri": REDIRECT_URI, + "state": _build_state(next_target), "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: data = { "grant_type": "authorization_code", "code": code, - "client_id": AUTH_CLIENT_ID, - "client_secret": AUTH_CLIENT_SECRET, - "redirect_uri": AUTH_REDIRECT_URI, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "redirect_uri": REDIRECT_URI, } # Try form-encoded first, then JSON fallback 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: return form_resp.json() except Exception: pass 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: return json_resp.json() json_resp.raise_for_status() @@ -113,11 +150,15 @@ def _exchange_code_for_token(code: str) -> dict: raise e -def _fetch_userinfo(access_token: str) -> dict: - headers = {"Authorization": f"Bearer {access_token}"} - response = requests.get(AUTH_USERINFO_URL, headers=headers, timeout=10) - response.raise_for_status() - return response.json() +def _append_auth_fragment(target: str, access_token: str, id_token: str = "") -> str: + parsed_target = urlparse(target) + fragment_pairs = dict(parse_qsl(parsed_target.fragment, keep_blank_values=True)) + fragment_pairs["auth_access_token"] = access_token + 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 @@ -130,26 +171,35 @@ def _protect_api_routes(): if request.path in auth_allowlist: return None - if request.path.startswith("/api") and "auth_user" not in session: - 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", "login_url": login_url}), 401 + 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", "") + next_target = _normalize_next_target(referer) + login_url = url_for("auth_login_api", next=next_target, _external=True) + 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("/") def hello_world(): - user = session.get("auth_user") - if not user: - return redirect(url_for("auth_login_api")) - return jsonify({"message": "Hello World!", "user": user}) + return jsonify({"message": "Hello World!"}) @app.route("/data-browser") def data_browser(): """数据浏览器前端界面""" - if "auth_user" not in session: - return redirect(url_for("auth_login_api")) return render_template("data_browser.html") @@ -157,15 +207,14 @@ def data_browser(): def auth_login_api(): next_target = request.args.get("next", "") - session["auth_next"] = _normalize_next_target(next_target) - - query = _build_authorization_query() - return redirect(f"{AUTH_AUTHORIZATION_URL}?{urlencode(query)}") + query = _build_authorization_query(next_target) + return redirect(f"{AUTHORIZATION_URL}?{urlencode(query)}") @app.route("/api/daimler/authorized") def auth_callback_api(): code = request.args.get("code", "") + state = request.args.get("state", "") if not code: return jsonify({"error": "Missing code from SSO callback"}), 400 @@ -173,26 +222,24 @@ def auth_callback_api(): try: token_response = _exchange_code_for_token(code) 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: return jsonify({"error": "No access_token in token response"}), 400 - userinfo = _fetch_userinfo(access_token) - - session["auth_user"] = userinfo - session["auth_access_token"] = access_token - if id_token: - session["auth_id_token"] = id_token - except requests.RequestException as exc: + # Validate token before redirecting it back to frontend storage. + validate_jwt_access_token(oauth_token_validator, access_token) + except (requests.RequestException, OAuth2Error) as exc: return jsonify({"error": "Auth request failed", "detail": str(exc)}), 502 - target = _normalize_next_target(session.pop("auth_next", "/")) - return redirect(target) + target = _resolve_next_target_from_state(state) + return redirect(_append_auth_fragment(target, access_token, id_token or "")) @app.route("/api/auth/me") +@require_oauth(scope="profile") 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: return jsonify({"error": "Unauthorized"}), 401 return jsonify({"user": user}) diff --git a/fst_data_pipeline/apps/root_db_api/src/core/auth_guard.py b/fst_data_pipeline/apps/root_db_api/src/core/auth_guard.py new file mode 100644 index 0000000..2f5b0b2 --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/src/core/auth_guard.py @@ -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)] + diff --git a/fst_data_pipeline/apps/root_db_api/src/core/auth_user.py b/fst_data_pipeline/apps/root_db_api/src/core/auth_user.py new file mode 100644 index 0000000..5790074 --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/src/core/auth_user.py @@ -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) + diff --git a/fst_data_pipeline/apps/root_db_api/src/core/oauth_protector.py b/fst_data_pipeline/apps/root_db_api/src/core/oauth_protector.py new file mode 100644 index 0000000..ffdeec6 --- /dev/null +++ b/fst_data_pipeline/apps/root_db_api/src/core/oauth_protector.py @@ -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) +