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
|
||||
Backend micro service application for fst data production line.
|
||||
# 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.<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",
|
||||
"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",
|
||||
|
||||
@@ -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:
|
||||
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})
|
||||
|
||||
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