auth-gate 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- auth_gate/__init__.py +52 -0
- auth_gate/config.py +99 -0
- auth_gate/fastapi_utils.py +275 -0
- auth_gate/middleware.py +205 -0
- auth_gate/s2s_auth.py +353 -0
- auth_gate/schemas.py +66 -0
- auth_gate/user_auth.py +363 -0
- auth_gate-0.1.0.dist-info/METADATA +213 -0
- auth_gate-0.1.0.dist-info/RECORD +21 -0
- auth_gate-0.1.0.dist-info/WHEEL +5 -0
- auth_gate-0.1.0.dist-info/licenses/LICENSE +19 -0
- auth_gate-0.1.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +1 -0
- tests/conftest.py +211 -0
- tests/test_config.py +78 -0
- tests/test_fastapi_utils.py +211 -0
- tests/test_intergration.py +200 -0
- tests/test_middleware.py +143 -0
- tests/test_s2s_auth.py +295 -0
- tests/test_schema.py +131 -0
- tests/test_user_auth.py +231 -0
auth_gate/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tradelink Authentication Client
|
|
3
|
+
|
|
4
|
+
Enterprise authentication client for microservices with Kong/Keycloak integration.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .config import AuthMode, AuthSettings
|
|
8
|
+
from .fastapi_utils import (
|
|
9
|
+
get_current_user,
|
|
10
|
+
get_optional_user,
|
|
11
|
+
require_admin,
|
|
12
|
+
require_customer,
|
|
13
|
+
require_moderator,
|
|
14
|
+
require_roles,
|
|
15
|
+
require_scopes,
|
|
16
|
+
require_supplier,
|
|
17
|
+
require_supplier_or_admin,
|
|
18
|
+
verify_hmac_signature,
|
|
19
|
+
)
|
|
20
|
+
from .middleware import AuthMiddleware
|
|
21
|
+
from .s2s_auth import CircuitBreaker, CircuitBreakerOpenError, ServiceAuthClient
|
|
22
|
+
from .schemas import UserContext
|
|
23
|
+
from .user_auth import UserValidator
|
|
24
|
+
|
|
25
|
+
__version__ = "1.0.0"
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
# Configuration
|
|
29
|
+
"AuthSettings",
|
|
30
|
+
"AuthMode",
|
|
31
|
+
# Schemas
|
|
32
|
+
"UserContext",
|
|
33
|
+
# User Authentication
|
|
34
|
+
"UserValidator",
|
|
35
|
+
# Service-to-Service
|
|
36
|
+
"ServiceAuthClient",
|
|
37
|
+
"CircuitBreaker",
|
|
38
|
+
"CircuitBreakerOpenError",
|
|
39
|
+
# Middleware
|
|
40
|
+
"AuthMiddleware",
|
|
41
|
+
# FastAPI Dependencies
|
|
42
|
+
"get_current_user",
|
|
43
|
+
"get_optional_user",
|
|
44
|
+
"require_roles",
|
|
45
|
+
"require_scopes",
|
|
46
|
+
"require_admin",
|
|
47
|
+
"require_supplier",
|
|
48
|
+
"require_customer",
|
|
49
|
+
"require_moderator",
|
|
50
|
+
"require_supplier_or_admin",
|
|
51
|
+
"verify_hmac_signature",
|
|
52
|
+
]
|
auth_gate/config.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration management for Tradelink Auth Client
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from pydantic_settings import BaseSettings
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AuthMode(Enum):
|
|
12
|
+
"""Authentication modes for different environments"""
|
|
13
|
+
|
|
14
|
+
KONG_HEADERS = "kong_headers" # Production: Kong validates tokens
|
|
15
|
+
DIRECT_KEYCLOAK = "direct_keycloak" # Development: Direct Keycloak validation
|
|
16
|
+
BYPASS = "bypass" # Testing only - NEVER use in production
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AuthSettings(BaseSettings):
|
|
20
|
+
"""
|
|
21
|
+
Centralized configuration for authentication client.
|
|
22
|
+
All settings can be overridden via environment variables.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
model_config = {
|
|
26
|
+
"env_file": (".env", ".env.test", ".env.prod"),
|
|
27
|
+
"extra": "ignore",
|
|
28
|
+
"env_file_encoding": "utf-8",
|
|
29
|
+
"case_sensitive": True,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# Authentication Mode
|
|
33
|
+
AUTH_MODE: str = "kong_headers"
|
|
34
|
+
|
|
35
|
+
# Keycloak Configuration
|
|
36
|
+
KEYCLOAK_REALM_URL: str = "http://localhost:8080/realms/master"
|
|
37
|
+
KEYCLOAK_CLIENT_ID: str = "backend-service"
|
|
38
|
+
KEYCLOAK_CLIENT_SECRET: str = ""
|
|
39
|
+
|
|
40
|
+
# Service Account Configuration (for S2S)
|
|
41
|
+
SERVICE_CLIENT_ID: str = "service-account"
|
|
42
|
+
SERVICE_CLIENT_SECRET: str = ""
|
|
43
|
+
|
|
44
|
+
# HMAC Verification (optional)
|
|
45
|
+
VERIFY_HMAC: bool = False
|
|
46
|
+
INTERNAL_HMAC_KEY: str = ""
|
|
47
|
+
|
|
48
|
+
# Circuit Breaker Settings
|
|
49
|
+
CIRCUIT_BREAKER_FAILURE_THRESHOLD: int = 5
|
|
50
|
+
CIRCUIT_BREAKER_RECOVERY_TIMEOUT: int = 60
|
|
51
|
+
|
|
52
|
+
# Token Settings
|
|
53
|
+
TOKEN_CACHE_TTL: int = 300 # seconds
|
|
54
|
+
TOKEN_REFRESH_BUFFER: int = 30 # seconds before expiry
|
|
55
|
+
|
|
56
|
+
# HTTP Client Settings
|
|
57
|
+
HTTP_TIMEOUT: float = 10.0
|
|
58
|
+
HTTP_MAX_KEEPALIVE_CONNECTIONS: int = 10
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def auth_mode_enum(self) -> AuthMode:
|
|
62
|
+
"""Get AUTH_MODE as enum"""
|
|
63
|
+
try:
|
|
64
|
+
return AuthMode(self.AUTH_MODE.lower())
|
|
65
|
+
except ValueError:
|
|
66
|
+
raise ValueError(f"Invalid AUTH_MODE: {self.AUTH_MODE}")
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def is_production(self) -> bool:
|
|
70
|
+
"""Check if running in production mode"""
|
|
71
|
+
return self.auth_mode_enum == AuthMode.KONG_HEADERS
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def is_development(self) -> bool:
|
|
75
|
+
"""Check if running in development mode"""
|
|
76
|
+
return self.auth_mode_enum == AuthMode.DIRECT_KEYCLOAK
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def is_testing(self) -> bool:
|
|
80
|
+
"""Check if running in testing/bypass mode"""
|
|
81
|
+
return self.auth_mode_enum == AuthMode.BYPASS
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# Global settings instance
|
|
85
|
+
_settings: Optional[AuthSettings] = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_settings() -> AuthSettings:
|
|
89
|
+
"""Get or create settings instance"""
|
|
90
|
+
global _settings
|
|
91
|
+
if _settings is None:
|
|
92
|
+
_settings = AuthSettings()
|
|
93
|
+
return _settings
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def reset_settings() -> None:
|
|
97
|
+
"""Reset settings (useful for testing)"""
|
|
98
|
+
global _settings
|
|
99
|
+
_settings = None
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI dependency injection utilities for authentication
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from fastapi import Depends, Header, HTTPException, Request, status
|
|
9
|
+
|
|
10
|
+
from .config import get_settings
|
|
11
|
+
from .schemas import UserContext
|
|
12
|
+
from .user_auth import HMACVerifier, get_user_validator
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Core authentication dependencies
|
|
18
|
+
async def get_current_user(
|
|
19
|
+
request: Request,
|
|
20
|
+
authorization: Optional[str] = Header(None),
|
|
21
|
+
x_token_verified: Optional[str] = Header(None, alias="X-Token-Verified"),
|
|
22
|
+
x_user_id: Optional[str] = Header(None, alias="X-User-ID"),
|
|
23
|
+
x_username: Optional[str] = Header(None, alias="X-Username"),
|
|
24
|
+
x_user_email: Optional[str] = Header(None, alias="X-User-Email"),
|
|
25
|
+
x_user_roles: Optional[str] = Header(None, alias="X-User-Roles"),
|
|
26
|
+
x_user_scopes: Optional[str] = Header(None, alias="X-User-Scopes"),
|
|
27
|
+
x_session_id: Optional[str] = Header(None, alias="X-Session-ID"),
|
|
28
|
+
x_client_id: Optional[str] = Header(None, alias="X-Client-ID"),
|
|
29
|
+
x_auth_source: Optional[str] = Header(None, alias="X-Auth-Source"),
|
|
30
|
+
) -> UserContext:
|
|
31
|
+
"""
|
|
32
|
+
Main dependency for getting authenticated user.
|
|
33
|
+
|
|
34
|
+
This is the primary dependency to use in your FastAPI endpoints.
|
|
35
|
+
|
|
36
|
+
Example:
|
|
37
|
+
@app.get("/api/profile")
|
|
38
|
+
async def get_profile(user: UserContext = Depends(get_current_user)):
|
|
39
|
+
return {"user_id": user.user_id}
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
Various authentication headers
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
UserContext with authenticated user information
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
HTTPException: If authentication fails
|
|
49
|
+
"""
|
|
50
|
+
validator = get_user_validator()
|
|
51
|
+
return await validator.get_current_user(
|
|
52
|
+
request,
|
|
53
|
+
authorization,
|
|
54
|
+
x_token_verified,
|
|
55
|
+
x_user_id,
|
|
56
|
+
x_username,
|
|
57
|
+
x_user_email,
|
|
58
|
+
x_user_roles,
|
|
59
|
+
x_user_scopes,
|
|
60
|
+
x_session_id,
|
|
61
|
+
x_client_id,
|
|
62
|
+
x_auth_source,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def get_optional_user(
|
|
67
|
+
request: Request,
|
|
68
|
+
authorization: Optional[str] = Header(None),
|
|
69
|
+
x_token_verified: Optional[str] = Header(None, alias="X-Token-Verified"),
|
|
70
|
+
x_user_id: Optional[str] = Header(None, alias="X-User-ID"),
|
|
71
|
+
x_username: Optional[str] = Header(None, alias="X-Username"),
|
|
72
|
+
x_user_email: Optional[str] = Header(None, alias="X-User-Email"),
|
|
73
|
+
x_user_roles: Optional[str] = Header(None, alias="X-User-Roles"),
|
|
74
|
+
x_user_scopes: Optional[str] = Header(None, alias="X-User-Scopes"),
|
|
75
|
+
x_session_id: Optional[str] = Header(None, alias="X-Session-ID"),
|
|
76
|
+
x_client_id: Optional[str] = Header(None, alias="X-Client-ID"),
|
|
77
|
+
x_auth_source: Optional[str] = Header(None, alias="X-Auth-Source"),
|
|
78
|
+
) -> Optional[UserContext]:
|
|
79
|
+
"""
|
|
80
|
+
Optional authentication - returns None if not authenticated.
|
|
81
|
+
|
|
82
|
+
Use this for endpoints where authentication is optional.
|
|
83
|
+
|
|
84
|
+
Example:
|
|
85
|
+
@app.get("/api/products")
|
|
86
|
+
async def list_products(user: Optional[UserContext] = Depends(get_optional_user)):
|
|
87
|
+
if user:
|
|
88
|
+
# Show personalized products
|
|
89
|
+
pass
|
|
90
|
+
else:
|
|
91
|
+
# Show public products
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
Various authentication headers
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
UserContext if authenticated, None otherwise
|
|
99
|
+
"""
|
|
100
|
+
try:
|
|
101
|
+
return await get_current_user(
|
|
102
|
+
request,
|
|
103
|
+
authorization,
|
|
104
|
+
x_token_verified,
|
|
105
|
+
x_user_id,
|
|
106
|
+
x_username,
|
|
107
|
+
x_user_email,
|
|
108
|
+
x_user_roles,
|
|
109
|
+
x_user_scopes,
|
|
110
|
+
x_session_id,
|
|
111
|
+
x_client_id,
|
|
112
|
+
x_auth_source,
|
|
113
|
+
)
|
|
114
|
+
except HTTPException:
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# Role-based access control factories
|
|
119
|
+
def require_roles(*required_roles: str):
|
|
120
|
+
"""
|
|
121
|
+
Factory for role-checking dependencies.
|
|
122
|
+
|
|
123
|
+
Creates a dependency that ensures the user has at least one of the specified roles.
|
|
124
|
+
|
|
125
|
+
Example:
|
|
126
|
+
require_editor = require_roles("editor", "admin")
|
|
127
|
+
|
|
128
|
+
@app.post("/api/articles")
|
|
129
|
+
async def create_article(user: UserContext = Depends(require_editor)):
|
|
130
|
+
return {"created_by": user.user_id}
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
*required_roles: Variable number of role names
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Dependency function that validates roles
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
async def role_checker(user: UserContext = Depends(get_current_user)) -> UserContext:
|
|
140
|
+
if not user.has_any_role(list(required_roles)):
|
|
141
|
+
logger.warning(f"User {user.user_id} lacks required roles: {required_roles}")
|
|
142
|
+
raise HTTPException(
|
|
143
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
144
|
+
detail=f"Requires one of roles: {', '.join(required_roles)}",
|
|
145
|
+
)
|
|
146
|
+
return user
|
|
147
|
+
|
|
148
|
+
return role_checker
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def require_scopes(*required_scopes: str):
|
|
152
|
+
"""
|
|
153
|
+
Factory for scope-checking dependencies.
|
|
154
|
+
|
|
155
|
+
Creates a dependency that ensures the user has all specified scopes.
|
|
156
|
+
|
|
157
|
+
Example:
|
|
158
|
+
require_write = require_scopes("write", "publish")
|
|
159
|
+
|
|
160
|
+
@app.post("/api/publish")
|
|
161
|
+
async def publish(user: UserContext = Depends(require_write)):
|
|
162
|
+
return {"published_by": user.user_id}
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
*required_scopes: Variable number of scope names
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Dependency function that validates scopes
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
async def scope_checker(user: UserContext = Depends(get_current_user)) -> UserContext:
|
|
172
|
+
missing_scopes = [s for s in required_scopes if not user.has_scope(s)]
|
|
173
|
+
if missing_scopes:
|
|
174
|
+
logger.warning(f"User {user.user_id} lacks required scopes: {missing_scopes}")
|
|
175
|
+
raise HTTPException(
|
|
176
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
177
|
+
detail=f"Requires scopes: {', '.join(missing_scopes)}",
|
|
178
|
+
)
|
|
179
|
+
return user
|
|
180
|
+
|
|
181
|
+
return scope_checker
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# Pre-configured role dependencies for common use cases
|
|
185
|
+
require_admin = require_roles("admin")
|
|
186
|
+
"""Dependency that requires admin role"""
|
|
187
|
+
|
|
188
|
+
require_supplier = require_roles("supplier")
|
|
189
|
+
"""Dependency that requires supplier role"""
|
|
190
|
+
|
|
191
|
+
require_customer = require_roles("customer")
|
|
192
|
+
"""Dependency that requires customer role"""
|
|
193
|
+
|
|
194
|
+
require_moderator = require_roles("moderator")
|
|
195
|
+
"""Dependency that requires moderator role"""
|
|
196
|
+
|
|
197
|
+
require_supplier_or_admin = require_roles("supplier", "admin")
|
|
198
|
+
"""Dependency that requires either supplier or admin role"""
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# HMAC verification for service-to-service communication
|
|
202
|
+
async def verify_hmac_signature(
|
|
203
|
+
request: Request,
|
|
204
|
+
x_authz_signature: Optional[str] = Header(None, alias="X-Authz-Signature"),
|
|
205
|
+
x_authz_ts: Optional[str] = Header(None, alias="X-Authz-Ts"),
|
|
206
|
+
user: UserContext = Depends(get_current_user),
|
|
207
|
+
) -> UserContext:
|
|
208
|
+
"""
|
|
209
|
+
Dependency to verify HMAC signatures from Kong.
|
|
210
|
+
|
|
211
|
+
Use this when Kong is configured with HMAC plugin for additional security.
|
|
212
|
+
|
|
213
|
+
Example:
|
|
214
|
+
@app.post("/api/sensitive")
|
|
215
|
+
async def sensitive_operation(user: UserContext = Depends(verify_hmac_signature)):
|
|
216
|
+
return {"verified_user": user.user_id}
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
request: FastAPI request
|
|
220
|
+
x_authz_signature: HMAC signature header
|
|
221
|
+
x_authz_ts: Timestamp header
|
|
222
|
+
user: Authenticated user context
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
UserContext if HMAC is valid
|
|
226
|
+
|
|
227
|
+
Raises:
|
|
228
|
+
HTTPException: If HMAC verification fails
|
|
229
|
+
"""
|
|
230
|
+
settings = get_settings()
|
|
231
|
+
|
|
232
|
+
if not settings.VERIFY_HMAC:
|
|
233
|
+
return user
|
|
234
|
+
|
|
235
|
+
if not x_authz_signature or not x_authz_ts:
|
|
236
|
+
raise HTTPException(
|
|
237
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
238
|
+
detail="Missing HMAC headers",
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
verifier = HMACVerifier(settings.INTERNAL_HMAC_KEY)
|
|
242
|
+
|
|
243
|
+
if not verifier.verify_signature(
|
|
244
|
+
x_authz_signature,
|
|
245
|
+
x_authz_ts,
|
|
246
|
+
user.user_id,
|
|
247
|
+
user.session_id or "",
|
|
248
|
+
request.method,
|
|
249
|
+
request.url.path,
|
|
250
|
+
):
|
|
251
|
+
raise HTTPException(
|
|
252
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
253
|
+
detail="Invalid HMAC signature",
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
return user
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# Utility functions for checking authentication mode
|
|
260
|
+
def is_using_kong() -> bool:
|
|
261
|
+
"""Check if using Kong authentication"""
|
|
262
|
+
settings = get_settings()
|
|
263
|
+
return settings.is_production
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def is_using_keycloak() -> bool:
|
|
267
|
+
"""Check if using direct Keycloak authentication"""
|
|
268
|
+
settings = get_settings()
|
|
269
|
+
return settings.is_development
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def is_bypass_mode() -> bool:
|
|
273
|
+
"""Check if in bypass/testing mode"""
|
|
274
|
+
settings = get_settings()
|
|
275
|
+
return settings.is_testing
|
auth_gate/middleware.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI middleware for authentication
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from typing import Optional, Set
|
|
8
|
+
|
|
9
|
+
from fastapi import HTTPException, Request, status
|
|
10
|
+
from fastapi.responses import JSONResponse
|
|
11
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
12
|
+
from starlette.types import ASGIApp
|
|
13
|
+
|
|
14
|
+
from .config import get_settings
|
|
15
|
+
from .user_auth import get_user_validator
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AuthMiddleware(BaseHTTPMiddleware):
|
|
21
|
+
"""
|
|
22
|
+
Middleware for handling authentication in Kong/Keycloak environment.
|
|
23
|
+
|
|
24
|
+
Features:
|
|
25
|
+
- Automatic authentication based on configured mode
|
|
26
|
+
- Configurable path exclusions
|
|
27
|
+
- Optional authentication paths
|
|
28
|
+
- Request enrichment with user context
|
|
29
|
+
- Security headers injection
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
app: ASGIApp,
|
|
35
|
+
excluded_paths: Optional[Set[str]] = None,
|
|
36
|
+
excluded_prefixes: Optional[Set[str]] = None,
|
|
37
|
+
optional_auth_paths: Optional[Set[str]] = None,
|
|
38
|
+
):
|
|
39
|
+
"""
|
|
40
|
+
Initialize authentication middleware.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
app: ASGI application
|
|
44
|
+
excluded_paths: Exact paths that don't require authentication
|
|
45
|
+
excluded_prefixes: Path prefixes that don't require authentication
|
|
46
|
+
optional_auth_paths: Paths where authentication is optional
|
|
47
|
+
"""
|
|
48
|
+
super().__init__(app)
|
|
49
|
+
|
|
50
|
+
# Paths that don't require authentication
|
|
51
|
+
self.excluded_paths = excluded_paths or {
|
|
52
|
+
"/health",
|
|
53
|
+
"/metrics",
|
|
54
|
+
"/openapi.json",
|
|
55
|
+
"/favicon.ico",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
# Path prefixes that don't require authentication
|
|
59
|
+
self.excluded_prefixes = excluded_prefixes or {
|
|
60
|
+
"/api/docs",
|
|
61
|
+
"/api/redoc",
|
|
62
|
+
"/static",
|
|
63
|
+
"/_health",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# Paths where authentication is optional
|
|
67
|
+
self.optional_auth_paths = optional_auth_paths or set()
|
|
68
|
+
|
|
69
|
+
self.validator = get_user_validator()
|
|
70
|
+
self.settings = get_settings()
|
|
71
|
+
|
|
72
|
+
def is_excluded(self, path: str) -> bool:
|
|
73
|
+
"""Check if path is excluded from authentication"""
|
|
74
|
+
# Exact match
|
|
75
|
+
if path in self.excluded_paths:
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
# Prefix match
|
|
79
|
+
for prefix in self.excluded_prefixes:
|
|
80
|
+
if path.startswith(prefix):
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
def is_optional_auth(self, path: str) -> bool:
|
|
86
|
+
"""Check if path has optional authentication"""
|
|
87
|
+
return path in self.optional_auth_paths
|
|
88
|
+
|
|
89
|
+
async def dispatch(self, request: Request, call_next):
|
|
90
|
+
"""Process request with authentication"""
|
|
91
|
+
start_time = time.time()
|
|
92
|
+
|
|
93
|
+
# Add request ID for tracing
|
|
94
|
+
request_id = request.headers.get("X-Request-ID", str(time.time()))
|
|
95
|
+
|
|
96
|
+
# Skip authentication for excluded paths
|
|
97
|
+
if self.is_excluded(request.url.path):
|
|
98
|
+
response = await call_next(request)
|
|
99
|
+
self._add_security_headers(response, request_id, start_time)
|
|
100
|
+
return response
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
# Extract authentication headers
|
|
104
|
+
auth_headers = self._extract_auth_headers(request)
|
|
105
|
+
|
|
106
|
+
# Check if we have any authentication
|
|
107
|
+
has_auth = self._has_authentication(auth_headers)
|
|
108
|
+
|
|
109
|
+
if not has_auth:
|
|
110
|
+
# No authentication present
|
|
111
|
+
if self.is_optional_auth(request.url.path):
|
|
112
|
+
request.state.user = None
|
|
113
|
+
else:
|
|
114
|
+
logger.warning(f"Missing authentication for {request.url.path}")
|
|
115
|
+
return JSONResponse(
|
|
116
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
117
|
+
content={
|
|
118
|
+
"error": "unauthorized",
|
|
119
|
+
"message": "Authentication required",
|
|
120
|
+
"request_id": request_id,
|
|
121
|
+
},
|
|
122
|
+
)
|
|
123
|
+
else:
|
|
124
|
+
# Validate authentication
|
|
125
|
+
user_context = await self.validator.get_current_user(request, **auth_headers)
|
|
126
|
+
request.state.user = user_context
|
|
127
|
+
|
|
128
|
+
# Log authentication details
|
|
129
|
+
logger.info(
|
|
130
|
+
f"Authenticated request: user={user_context.user_id}, "
|
|
131
|
+
f"roles={user_context.roles}, path={request.url.path}, "
|
|
132
|
+
f"method={request.method}, source={user_context.auth_source}"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Process request
|
|
136
|
+
response = await call_next(request)
|
|
137
|
+
|
|
138
|
+
# Add security headers
|
|
139
|
+
self._add_security_headers(response, request_id, start_time)
|
|
140
|
+
|
|
141
|
+
return response
|
|
142
|
+
|
|
143
|
+
except HTTPException as e:
|
|
144
|
+
# Handle authentication exceptions
|
|
145
|
+
logger.warning(f"Auth failed: {e.detail}, path={request.url.path}")
|
|
146
|
+
return JSONResponse(
|
|
147
|
+
status_code=e.status_code,
|
|
148
|
+
content={
|
|
149
|
+
"error": "authentication_failed",
|
|
150
|
+
"message": e.detail,
|
|
151
|
+
"request_id": request_id,
|
|
152
|
+
},
|
|
153
|
+
)
|
|
154
|
+
except Exception as e:
|
|
155
|
+
# Handle unexpected errors
|
|
156
|
+
logger.error(f"Middleware error: {e}", exc_info=True)
|
|
157
|
+
return JSONResponse(
|
|
158
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
159
|
+
content={
|
|
160
|
+
"error": "internal_error",
|
|
161
|
+
"message": "An internal error occurred",
|
|
162
|
+
"request_id": request_id,
|
|
163
|
+
},
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def _extract_auth_headers(self, request: Request) -> dict:
|
|
167
|
+
"""Extract all authentication-related headers"""
|
|
168
|
+
return {
|
|
169
|
+
"authorization": request.headers.get("Authorization"),
|
|
170
|
+
"x_token_verified": request.headers.get("X-Token-Verified"),
|
|
171
|
+
"x_user_id": request.headers.get("X-User-ID"),
|
|
172
|
+
"x_username": request.headers.get("X-Username"),
|
|
173
|
+
"x_user_email": request.headers.get("X-User-Email"),
|
|
174
|
+
"x_user_roles": request.headers.get("X-User-Roles"),
|
|
175
|
+
"x_user_scopes": request.headers.get("X-User-Scopes"),
|
|
176
|
+
"x_session_id": request.headers.get("X-Session-ID"),
|
|
177
|
+
"x_client_id": request.headers.get("X-Client-ID"),
|
|
178
|
+
"x_auth_source": request.headers.get("X-Auth-Source"),
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
def _has_authentication(self, auth_headers: dict) -> bool:
|
|
182
|
+
"""Check if request has any authentication"""
|
|
183
|
+
if self.settings.is_production:
|
|
184
|
+
# Kong mode - check for verified token header
|
|
185
|
+
return auth_headers.get("x_token_verified") == "true"
|
|
186
|
+
elif self.settings.is_development:
|
|
187
|
+
# Direct Keycloak mode - check for Bearer token
|
|
188
|
+
auth: str = auth_headers.get("authorization", "")
|
|
189
|
+
return auth.startswith("Bearer ")
|
|
190
|
+
elif self.settings.is_testing:
|
|
191
|
+
# Bypass mode - always authenticated
|
|
192
|
+
return True
|
|
193
|
+
return False
|
|
194
|
+
|
|
195
|
+
def _add_security_headers(self, response, request_id: str, start_time: float):
|
|
196
|
+
"""Add security and performance headers to response"""
|
|
197
|
+
response.headers["X-Request-ID"] = request_id
|
|
198
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
199
|
+
response.headers["X-Frame-Options"] = "DENY"
|
|
200
|
+
response.headers["X-XSS-Protection"] = "1; mode=block"
|
|
201
|
+
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
|
202
|
+
|
|
203
|
+
# Add performance metrics
|
|
204
|
+
process_time = time.time() - start_time
|
|
205
|
+
response.headers["X-Process-Time"] = str(process_time)
|