@purpleraven/hits 0.2.0

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.
Files changed (58) hide show
  1. package/AGENTS.md +298 -0
  2. package/LICENSE +190 -0
  3. package/README.md +336 -0
  4. package/bin/hits.js +56 -0
  5. package/config/schema.json +94 -0
  6. package/config/settings.yaml +102 -0
  7. package/data/dev_handover.yaml +143 -0
  8. package/hits_core/__init__.py +9 -0
  9. package/hits_core/ai/__init__.py +11 -0
  10. package/hits_core/ai/compressor.py +86 -0
  11. package/hits_core/ai/llm_client.py +65 -0
  12. package/hits_core/ai/slm_filter.py +126 -0
  13. package/hits_core/api/__init__.py +3 -0
  14. package/hits_core/api/routes/__init__.py +8 -0
  15. package/hits_core/api/routes/auth.py +211 -0
  16. package/hits_core/api/routes/handover.py +117 -0
  17. package/hits_core/api/routes/health.py +8 -0
  18. package/hits_core/api/routes/knowledge.py +177 -0
  19. package/hits_core/api/routes/node.py +121 -0
  20. package/hits_core/api/routes/work_log.py +174 -0
  21. package/hits_core/api/server.py +181 -0
  22. package/hits_core/auth/__init__.py +21 -0
  23. package/hits_core/auth/dependencies.py +61 -0
  24. package/hits_core/auth/manager.py +368 -0
  25. package/hits_core/auth/middleware.py +69 -0
  26. package/hits_core/collector/__init__.py +18 -0
  27. package/hits_core/collector/ai_session_collector.py +118 -0
  28. package/hits_core/collector/base.py +73 -0
  29. package/hits_core/collector/daemon.py +94 -0
  30. package/hits_core/collector/git_collector.py +177 -0
  31. package/hits_core/collector/hits_action_collector.py +110 -0
  32. package/hits_core/collector/shell_collector.py +178 -0
  33. package/hits_core/main.py +36 -0
  34. package/hits_core/mcp/__init__.py +20 -0
  35. package/hits_core/mcp/server.py +429 -0
  36. package/hits_core/models/__init__.py +18 -0
  37. package/hits_core/models/node.py +56 -0
  38. package/hits_core/models/tree.py +68 -0
  39. package/hits_core/models/work_log.py +64 -0
  40. package/hits_core/models/workflow.py +92 -0
  41. package/hits_core/platform/__init__.py +5 -0
  42. package/hits_core/platform/actions.py +225 -0
  43. package/hits_core/service/__init__.py +6 -0
  44. package/hits_core/service/handover_service.py +382 -0
  45. package/hits_core/service/knowledge_service.py +172 -0
  46. package/hits_core/service/tree_service.py +105 -0
  47. package/hits_core/storage/__init__.py +11 -0
  48. package/hits_core/storage/base.py +84 -0
  49. package/hits_core/storage/file_store.py +314 -0
  50. package/hits_core/storage/redis_store.py +123 -0
  51. package/hits_web/dist/assets/index-Bgx7F6m6.css +1 -0
  52. package/hits_web/dist/assets/index-D1B5E67G.js +3 -0
  53. package/hits_web/dist/index.html +16 -0
  54. package/package.json +60 -0
  55. package/requirements-core.txt +7 -0
  56. package/requirements.txt +1 -0
  57. package/run.sh +271 -0
  58. package/server.js +234 -0
@@ -0,0 +1,181 @@
1
+ """FastAPI application factory with security middleware and web UI serving.
2
+
3
+ Security features:
4
+ - Argon2id password hashing (or HMAC-SHA256 fallback)
5
+ - JWT access/refresh tokens in HttpOnly cookies
6
+ - Content-Security-Policy (CSP) headers
7
+ - CORS with strict origin validation
8
+ - Rate limiting on authentication endpoints
9
+ - Secure response headers (HSTS, X-Frame-Options, etc.)
10
+ - Input validation via Pydantic v2
11
+ """
12
+
13
+ import asyncio
14
+ import os
15
+ import threading
16
+ from pathlib import Path
17
+ from typing import Optional
18
+
19
+ from fastapi import FastAPI, Request, status
20
+ from fastapi.responses import HTMLResponse, JSONResponse
21
+ from fastapi.staticfiles import StaticFiles
22
+ from starlette.middleware.cors import CORSMiddleware
23
+
24
+ from .routes import health, work_log, node, handover, auth, knowledge
25
+ from ..auth.middleware import SecurityMiddleware
26
+
27
+
28
+ # --- Rate Limiter (simple in-memory) ---
29
+
30
+ class RateLimiter:
31
+ """Simple in-memory rate limiter for authentication endpoints."""
32
+
33
+ def __init__(self, max_requests: int = 10, window_seconds: int = 60):
34
+ self.max_requests = max_requests
35
+ self.window_seconds = window_seconds
36
+ self._requests: dict[str, list[float]] = {}
37
+ self._lock = asyncio.Lock() if asyncio.get_event_loop().is_running() else None
38
+
39
+ def is_limited(self, client_id: str) -> bool:
40
+ """Check if client is rate limited. Returns True if limited."""
41
+ import time
42
+ now = time.time()
43
+
44
+ if client_id not in self._requests:
45
+ self._requests[client_id] = []
46
+
47
+ # Remove old entries
48
+ self._requests[client_id] = [
49
+ t for t in self._requests[client_id]
50
+ if now - t < self.window_seconds
51
+ ]
52
+
53
+ if len(self._requests[client_id]) >= self.max_requests:
54
+ return True
55
+
56
+ self._requests[client_id].append(now)
57
+ return False
58
+
59
+
60
+ _rate_limiter = RateLimiter(max_requests=10, window_seconds=60)
61
+
62
+
63
+ class APIServer:
64
+ def __init__(self, port: int = 8765, dev_mode: bool = False):
65
+ self.port = port
66
+ self.dev_mode = dev_mode
67
+ self.app: Optional[FastAPI] = None
68
+ self.server = None
69
+ self.thread: Optional[threading.Thread] = None
70
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
71
+
72
+ def create_app(self) -> FastAPI:
73
+ app = FastAPI(
74
+ title="HITS API",
75
+ description="Hybrid Intel Trace System - Secure Web UI",
76
+ version="0.2.0",
77
+ )
78
+
79
+ # --- Security Middleware ---
80
+ app.add_middleware(SecurityMiddleware, dev_mode=self.dev_mode)
81
+
82
+ # --- CORS ---
83
+ if self.dev_mode:
84
+ app.add_middleware(
85
+ CORSMiddleware,
86
+ allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
87
+ allow_credentials=True,
88
+ allow_methods=["*"],
89
+ allow_headers=["*"],
90
+ )
91
+
92
+ # --- Rate Limiting for Auth ---
93
+ @app.middleware("http")
94
+ async def rate_limit_auth(request: Request, call_next):
95
+ if request.url.path.startswith("/api/auth/login"):
96
+ client_id = request.client.host if request.client else "unknown"
97
+ if _rate_limiter.is_limited(client_id):
98
+ return JSONResponse(
99
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
100
+ content={"detail": "Too many login attempts. Try again later."},
101
+ )
102
+ return await call_next(request)
103
+
104
+ # --- API Routes ---
105
+ app.include_router(health.router, prefix="/api", tags=["health"])
106
+ app.include_router(auth.router, prefix="/api", tags=["auth"])
107
+ app.include_router(work_log.router, prefix="/api", tags=["work-log"])
108
+ app.include_router(node.router, prefix="/api", tags=["node"])
109
+ app.include_router(handover.router, prefix="/api", tags=["handover"])
110
+ app.include_router(knowledge.router, prefix="/api", tags=["knowledge"])
111
+
112
+ # --- Static Files (Web UI) ---
113
+ static_dir = Path(__file__).parent.parent.parent / "hits_web" / "dist"
114
+ if static_dir.exists():
115
+ # Mount static assets
116
+ assets_dir = static_dir / "assets"
117
+ if assets_dir.exists():
118
+ app.mount("/assets", StaticFiles(directory=str(assets_dir)), name="assets")
119
+
120
+ # SPA fallback: serve index.html for all non-API, non-static routes
121
+ index_html = static_dir / "index.html"
122
+
123
+ @app.get("/{path:path}", response_class=HTMLResponse, include_in_schema=False)
124
+ async def spa_fallback(path: str):
125
+ """Serve the SPA index.html for all non-API routes."""
126
+ # Try to serve a specific static file first
127
+ file_path = static_dir / path
128
+ if path and file_path.exists() and file_path.is_file():
129
+ from fastapi.responses import FileResponse
130
+ return FileResponse(file_path)
131
+ # Otherwise serve index.html (SPA routing)
132
+ if index_html.exists():
133
+ return HTMLResponse(content=index_html.read_text(encoding="utf-8"))
134
+ return HTMLResponse(content="<h1>HITS Web UI not built yet. Run: cd hits_web && npm run build</h1>")
135
+
136
+ return app
137
+
138
+ def _run_server(self):
139
+ self._loop = asyncio.new_event_loop()
140
+ asyncio.set_event_loop(self._loop)
141
+
142
+ import uvicorn
143
+ config = uvicorn.Config(
144
+ app=self.app,
145
+ host="127.0.0.1",
146
+ port=self.port,
147
+ loop="asyncio",
148
+ log_level="warning" if not self.dev_mode else "info",
149
+ )
150
+ self.server = uvicorn.Server(config)
151
+ self._loop.run_until_complete(self.server.serve())
152
+
153
+ def start(self):
154
+ if self.thread and self.thread.is_alive():
155
+ return
156
+
157
+ self.app = self.create_app()
158
+ self.thread = threading.Thread(target=self._run_server, daemon=True)
159
+ self.thread.start()
160
+
161
+ def stop(self):
162
+ if self.server and self._loop:
163
+ self._loop.call_soon_threadsafe(self.server.should_exit)
164
+
165
+
166
+ _api_server: Optional[APIServer] = None
167
+
168
+
169
+ def start_api_server(port: int = 8765, dev_mode: bool = False) -> APIServer:
170
+ global _api_server
171
+ if _api_server is None:
172
+ _api_server = APIServer(port=port, dev_mode=dev_mode)
173
+ _api_server.start()
174
+ return _api_server
175
+
176
+
177
+ def stop_api_server():
178
+ global _api_server
179
+ if _api_server:
180
+ _api_server.stop()
181
+ _api_server = None
@@ -0,0 +1,21 @@
1
+ """Authentication and security package for HITS web UI.
2
+
3
+ Security design:
4
+ - Argon2id password hashing (PHC winner, resistant to GPU/ASIC attacks)
5
+ - JWT with HttpOnly + Secure + SameSite=Lax cookies (no localStorage)
6
+ - Short-lived access tokens (15 min) + long-lived refresh tokens (7 days)
7
+ - Rate limiting on auth endpoints
8
+ - CSRF protection via SameSite cookies + origin validation
9
+ """
10
+
11
+ from .manager import AuthManager, get_auth_manager
12
+ from .middleware import SecurityMiddleware
13
+ from .dependencies import get_current_user, require_auth
14
+
15
+ __all__ = [
16
+ "AuthManager",
17
+ "get_auth_manager",
18
+ "SecurityMiddleware",
19
+ "get_current_user",
20
+ "require_auth",
21
+ ]
@@ -0,0 +1,61 @@
1
+ """FastAPI dependencies for authentication.
2
+
3
+ Usage in routes:
4
+ @router.get("/protected")
5
+ async def protected_route(user: dict = Depends(require_auth)):
6
+ username = user["username"]
7
+ ...
8
+
9
+ @router.get("/optional")
10
+ async def optional_route(user: Optional[dict] = Depends(get_current_user)):
11
+ if user:
12
+ # authenticated
13
+ else:
14
+ # anonymous
15
+ """
16
+
17
+ from typing import Optional
18
+
19
+ from fastapi import Depends, HTTPException, Request, status
20
+
21
+ from .manager import get_auth_manager
22
+
23
+
24
+ async def get_current_user(request: Request) -> Optional[dict]:
25
+ """Extract and verify user from access token cookie.
26
+
27
+ Returns user dict or None if not authenticated.
28
+ """
29
+ token = request.cookies.get("access_token")
30
+ if not token:
31
+ # Also check Authorization header for API clients
32
+ auth_header = request.headers.get("Authorization", "")
33
+ if auth_header.startswith("Bearer "):
34
+ token = auth_header[7:]
35
+
36
+ if not token:
37
+ return None
38
+
39
+ auth = get_auth_manager()
40
+ return auth.verify_access_token(token)
41
+
42
+
43
+ async def require_auth(user: Optional[dict] = Depends(get_current_user)) -> dict:
44
+ """Require authentication. Raises 401 if not authenticated."""
45
+ if user is None:
46
+ raise HTTPException(
47
+ status_code=status.HTTP_401_UNAUTHORIZED,
48
+ detail="Authentication required",
49
+ headers={"WWW-Authenticate": "Bearer"},
50
+ )
51
+ return user
52
+
53
+
54
+ async def require_admin(user: dict = Depends(require_auth)) -> dict:
55
+ """Require admin role."""
56
+ if user.get("role") != "admin":
57
+ raise HTTPException(
58
+ status_code=status.HTTP_403_FORBIDDEN,
59
+ detail="Admin access required",
60
+ )
61
+ return user
@@ -0,0 +1,368 @@
1
+ """Authentication manager - JWT + Argon2id password hashing.
2
+
3
+ Security choices:
4
+ - Argon2id: Winner of Password Hashing Competition (2015).
5
+ Resistant to GPU, ASIC, and side-channel attacks.
6
+ Parameters: memory=64MB, iterations=3, parallelism=1.
7
+ - JWT HS256: Symmetric signing for simplicity in single-server setup.
8
+ Access token: 15 minutes. Refresh token: 7 days.
9
+ - HttpOnly cookies: Not accessible to JavaScript (XSS protection).
10
+ Secure flag: Only sent over HTTPS (set False only for localhost dev).
11
+ SameSite=Lax: CSRF protection while allowing top-level navigations.
12
+ """
13
+
14
+ import json
15
+ import os
16
+ import secrets
17
+ import hashlib
18
+ import hmac
19
+ from datetime import datetime, timedelta, timezone
20
+ from pathlib import Path
21
+ from typing import Optional
22
+
23
+ # Argon2id - fallback to bcrypt if not available
24
+ try:
25
+ from argon2 import PasswordHasher
26
+ from argon2.exceptions import VerifyMismatchError
27
+
28
+ _HAS_ARGON2 = True
29
+ except ImportError:
30
+ import hashlib as _hl
31
+
32
+ _HAS_ARGON2 = False
33
+
34
+ # JWT - use python-jose if available, else pure Python HMAC-based tokens
35
+ try:
36
+ from jose import jwt, JWTError
37
+
38
+ _HAS_JOSE = True
39
+ except ImportError:
40
+ _HAS_JOSE = False
41
+
42
+
43
+ class PasswordHasher:
44
+ """Password hashing with Argon2id (preferred) or HMAC-SHA256 fallback."""
45
+
46
+ def __init__(self):
47
+ self._argon2: Optional["PasswordHasher"] = None
48
+ self._pepper: bytes = b""
49
+ self._load_pepper()
50
+
51
+ if _HAS_ARGON2:
52
+ self._argon2 = PasswordHasher(
53
+ time_cost=3, # iterations
54
+ memory_cost=65536, # 64 MB
55
+ parallelism=1,
56
+ hash_len=32,
57
+ salt_len=16,
58
+ )
59
+
60
+ def _load_pepper(self):
61
+ """Load or generate pepper for HMAC fallback."""
62
+ pepper_path = Path.home() / ".hits" / ".pepper"
63
+ pepper_path.parent.mkdir(parents=True, exist_ok=True)
64
+
65
+ if pepper_path.exists():
66
+ self._pepper = pepper_path.read_bytes()
67
+ else:
68
+ self._pepper = secrets.token_bytes(32)
69
+ # Restrict permissions to owner only
70
+ pepper_path.write_bytes(self._pepper)
71
+ os.chmod(pepper_path, 0o600)
72
+
73
+ def hash_password(self, password: str) -> str:
74
+ """Hash a password. Returns hash string."""
75
+ if self._argon2:
76
+ return self._argon2.hash(password)
77
+ else:
78
+ # Fallback: HMAC-SHA256 with pepper + random salt
79
+ salt = secrets.token_hex(16)
80
+ h = hmac.new(self._pepper, (salt + password).encode(), hashlib.sha256).hexdigest()
81
+ return f"hmac${salt}${h}"
82
+
83
+ def verify_password(self, password: str, hash_str: str) -> bool:
84
+ """Verify a password against a hash."""
85
+ if self._argon2 and not hash_str.startswith("hmac$"):
86
+ try:
87
+ self._argon2.verify(hash_str, password)
88
+ return True
89
+ except VerifyMismatchError:
90
+ return False
91
+ except Exception:
92
+ return False
93
+ elif hash_str.startswith("hmac$"):
94
+ parts = hash_str.split("$")
95
+ if len(parts) != 3:
96
+ return False
97
+ salt = parts[1]
98
+ expected = parts[2]
99
+ h = hmac.new(self._pepper, (salt + password).encode(), hashlib.sha256).hexdigest()
100
+ return hmac.compare_digest(h, expected)
101
+ return False
102
+
103
+
104
+ class TokenManager:
105
+ """JWT token management with HttpOnly cookie support."""
106
+
107
+ def __init__(self, secret_key: Optional[str] = None):
108
+ self._secret = secret_key or self._load_or_create_secret()
109
+
110
+ def _load_or_create_secret(self) -> str:
111
+ """Load or create a persistent JWT secret key."""
112
+ key_path = Path.home() / ".hits" / ".jwt_secret"
113
+ key_path.parent.mkdir(parents=True, exist_ok=True)
114
+
115
+ if key_path.exists():
116
+ return key_path.read_text().strip()
117
+ else:
118
+ secret = secrets.token_urlsafe(48)
119
+ key_path.write_text(secret)
120
+ os.chmod(key_path, 0o600)
121
+ return secret
122
+
123
+ def create_access_token(self, username: str, expires_minutes: int = 15) -> str:
124
+ """Create a short-lived access token."""
125
+ payload = {
126
+ "sub": username,
127
+ "type": "access",
128
+ "iat": datetime.now(timezone.utc),
129
+ "exp": datetime.now(timezone.utc) + timedelta(minutes=expires_minutes),
130
+ "jti": secrets.token_urlsafe(16),
131
+ }
132
+ if _HAS_JOSE:
133
+ return jwt.encode(payload, self._secret, algorithm="HS256")
134
+ else:
135
+ return self._encode_simple(payload)
136
+
137
+ def create_refresh_token(self, username: str, expires_days: int = 7) -> str:
138
+ """Create a long-lived refresh token."""
139
+ payload = {
140
+ "sub": username,
141
+ "type": "refresh",
142
+ "iat": datetime.now(timezone.utc),
143
+ "exp": datetime.now(timezone.utc) + timedelta(days=expires_days),
144
+ "jti": secrets.token_urlsafe(16),
145
+ }
146
+ if _HAS_JOSE:
147
+ return jwt.encode(payload, self._secret, algorithm="HS256")
148
+ else:
149
+ return self._encode_simple(payload)
150
+
151
+ def verify_token(self, token: str, expected_type: str = "access") -> Optional[dict]:
152
+ """Verify and decode a token. Returns payload or None."""
153
+ try:
154
+ if _HAS_JOSE:
155
+ payload = jwt.decode(token, self._secret, algorithms=["HS256"])
156
+ else:
157
+ payload = self._decode_simple(token)
158
+
159
+ if payload.get("type") != expected_type:
160
+ return None
161
+ return payload
162
+ except Exception:
163
+ return None
164
+
165
+ def _encode_simple(self, payload: dict) -> str:
166
+ """Simple HMAC-based token encoding (fallback without jose)."""
167
+ import base64
168
+
169
+ header = base64.urlsafe_b64encode(b'{"alg":"HS256","typ":"JWT"}').decode().rstrip("=")
170
+ body = base64.urlsafe_b64encode(
171
+ json.dumps(payload, default=str).encode()
172
+ ).decode().rstrip("=")
173
+
174
+ signing_input = f"{header}.{body}"
175
+ sig = hmac.new(
176
+ self._secret.encode(), signing_input.encode(), hashlib.sha256
177
+ ).hexdigest()
178
+
179
+ return f"{signing_input}.{sig}"
180
+
181
+ def _decode_simple(self, token: str) -> dict:
182
+ """Simple HMAC-based token decoding (fallback without jose)."""
183
+ import base64
184
+
185
+ parts = token.split(".")
186
+ if len(parts) != 3:
187
+ raise ValueError("Invalid token format")
188
+
189
+ header, body, sig = parts
190
+ signing_input = f"{header}.{body}"
191
+ expected_sig = hmac.new(
192
+ self._secret.encode(), signing_input.encode(), hashlib.sha256
193
+ ).hexdigest()
194
+
195
+ if not hmac.compare_digest(sig, expected_sig):
196
+ raise ValueError("Invalid signature")
197
+
198
+ # Decode payload
199
+ padding = 4 - len(body) % 4
200
+ if padding != 4:
201
+ body += "=" * padding
202
+ payload = json.loads(base64.urlsafe_b64decode(body))
203
+
204
+ # Check expiration
205
+ exp = payload.get("exp")
206
+ if exp:
207
+ exp_dt = datetime.fromisoformat(str(exp).replace("Z", "+00:00"))
208
+ if datetime.now(timezone.utc) > exp_dt:
209
+ raise ValueError("Token expired")
210
+
211
+ return payload
212
+
213
+
214
+ class AuthManager:
215
+ """Central authentication manager.
216
+
217
+ Manages users, password hashing, and token lifecycle.
218
+ Users are stored in ~/.hits/.auth/users.json with restricted permissions.
219
+ """
220
+
221
+ def __init__(self):
222
+ self._hasher = PasswordHasher()
223
+ self._tokens = TokenManager()
224
+ self._users_path = Path.home() / ".hits" / ".auth" / "users.json"
225
+ self._users_path.parent.mkdir(parents=True, exist_ok=True)
226
+ # Restrict auth directory permissions
227
+ os.chmod(self._users_path.parent, 0o700)
228
+
229
+ def _load_users(self) -> dict:
230
+ """Load users database."""
231
+ if self._users_path.exists():
232
+ try:
233
+ return json.loads(self._users_path.read_text(encoding="utf-8"))
234
+ except (json.JSONDecodeError, IOError):
235
+ return {}
236
+ return {}
237
+
238
+ def _save_users(self, users: dict) -> None:
239
+ """Save users database with restricted permissions."""
240
+ self._users_path.write_text(
241
+ json.dumps(users, ensure_ascii=False, indent=2),
242
+ encoding="utf-8",
243
+ )
244
+ os.chmod(self._users_path, 0o600)
245
+
246
+ def create_user(self, username: str, password: str) -> bool:
247
+ """Create a new user. Returns False if username exists."""
248
+ users = self._load_users()
249
+ if username in users:
250
+ return False
251
+
252
+ users[username] = {
253
+ "password_hash": self._hasher.hash_password(password),
254
+ "created_at": datetime.now(timezone.utc).isoformat(),
255
+ "role": "admin" if len(users) == 0 else "user",
256
+ }
257
+ self._save_users(users)
258
+ return True
259
+
260
+ def authenticate(self, username: str, password: str) -> Optional[str]:
261
+ """Authenticate a user. Returns access token or None."""
262
+ users = self._load_users()
263
+ user = users.get(username)
264
+
265
+ if not user:
266
+ # Constant-time: always hash to prevent timing attacks
267
+ self._hasher.hash_password(secrets.token_urlsafe(16))
268
+ return None
269
+
270
+ if not self._hasher.verify_password(password, user["password_hash"]):
271
+ return None
272
+
273
+ return self._tokens.create_access_token(username)
274
+
275
+ def create_tokens(self, username: str, password: str) -> Optional[dict]:
276
+ """Authenticate and return both access + refresh tokens."""
277
+ users = self._load_users()
278
+ user = users.get(username)
279
+
280
+ if not user:
281
+ self._hasher.hash_password(secrets.token_urlsafe(16))
282
+ return None
283
+
284
+ if not self._hasher.verify_password(password, user["password_hash"]):
285
+ return None
286
+
287
+ return {
288
+ "access_token": self._tokens.create_access_token(username),
289
+ "refresh_token": self._tokens.create_refresh_token(username),
290
+ "username": username,
291
+ "role": user.get("role", "user"),
292
+ }
293
+
294
+ def refresh_access_token(self, refresh_token: str) -> Optional[str]:
295
+ """Generate a new access token from a valid refresh token."""
296
+ payload = self._tokens.verify_token(refresh_token, expected_type="refresh")
297
+ if not payload:
298
+ return None
299
+
300
+ username = payload.get("sub")
301
+ if not username:
302
+ return None
303
+
304
+ # Verify user still exists
305
+ users = self._load_users()
306
+ if username not in users:
307
+ return None
308
+
309
+ return self._tokens.create_access_token(username)
310
+
311
+ def verify_access_token(self, token: str) -> Optional[dict]:
312
+ """Verify an access token. Returns payload with user info."""
313
+ payload = self._tokens.verify_token(token, expected_type="access")
314
+ if not payload:
315
+ return None
316
+
317
+ username = payload.get("sub")
318
+ users = self._load_users()
319
+ user = users.get(username)
320
+
321
+ if not user:
322
+ return None
323
+
324
+ return {
325
+ "username": username,
326
+ "role": user.get("role", "user"),
327
+ }
328
+
329
+ def change_password(self, username: str, old_password: str, new_password: str) -> bool:
330
+ """Change a user's password. Requires current password."""
331
+ users = self._load_users()
332
+ user = users.get(username)
333
+
334
+ if not user:
335
+ return False
336
+
337
+ if not self._hasher.verify_password(old_password, user["password_hash"]):
338
+ return False
339
+
340
+ users[username]["password_hash"] = self._hasher.hash_password(new_password)
341
+ self._save_users(users)
342
+ return True
343
+
344
+ def user_exists(self, username: str) -> bool:
345
+ """Check if a user exists."""
346
+ return username in self._load_users()
347
+
348
+ def has_any_user(self) -> bool:
349
+ """Check if any user has been created (for initial setup)."""
350
+ return len(self._load_users()) > 0
351
+
352
+ def get_user_role(self, username: str) -> Optional[str]:
353
+ """Get user role."""
354
+ users = self._load_users()
355
+ user = users.get(username)
356
+ return user.get("role") if user else None
357
+
358
+
359
+ # Singleton
360
+ _auth_manager: Optional[AuthManager] = None
361
+
362
+
363
+ def get_auth_manager() -> AuthManager:
364
+ """Get or create the global AuthManager singleton."""
365
+ global _auth_manager
366
+ if _auth_manager is None:
367
+ _auth_manager = AuthManager()
368
+ return _auth_manager
@@ -0,0 +1,69 @@
1
+ """Security middleware for FastAPI.
2
+
3
+ Applies defense-in-depth headers and protections:
4
+ - Content-Security-Policy (CSP): Prevents XSS by restricting resource sources
5
+ - X-Content-Type-Options: Prevents MIME type sniffing
6
+ - X-Frame-Options: Prevents clickjacking
7
+ - Strict-Transport-Security (HSTS): Forces HTTPS
8
+ - Referrer-Policy: Limits referrer information leakage
9
+ - Permissions-Policy: Restricts browser features
10
+ - X-XSS-Protection: Legacy XSS filter
11
+ """
12
+
13
+ from starlette.middleware.base import BaseHTTPMiddleware
14
+ from starlette.requests import Request
15
+ from starlette.responses import Response
16
+
17
+
18
+ class SecurityMiddleware(BaseHTTPMiddleware):
19
+ """Adds security headers to all responses."""
20
+
21
+ def __init__(self, app, dev_mode: bool = False):
22
+ super().__init__(app)
23
+ self.dev_mode = dev_mode
24
+
25
+ async def dispatch(self, request: Request, call_next):
26
+ response: Response = await call_next(request)
27
+
28
+ # Content-Security-Policy
29
+ if self.dev_mode:
30
+ # Dev: allow localhost connections for Vite HMR
31
+ csp = (
32
+ "default-src 'self'; "
33
+ "script-src 'self' 'unsafe-inline' http://localhost:5173; "
34
+ "style-src 'self' 'unsafe-inline'; "
35
+ "connect-src 'self' http://localhost:5173 http://localhost:8765; "
36
+ "img-src 'self' data:; "
37
+ "font-src 'self'; "
38
+ "frame-ancestors 'none'; "
39
+ "base-uri 'self'; "
40
+ "form-action 'self'"
41
+ )
42
+ else:
43
+ # Production: strict CSP
44
+ csp = (
45
+ "default-src 'self'; "
46
+ "script-src 'self'; "
47
+ "style-src 'self' 'unsafe-inline'; "
48
+ "connect-src 'self'; "
49
+ "img-src 'self' data:; "
50
+ "font-src 'self'; "
51
+ "frame-ancestors 'none'; "
52
+ "base-uri 'self'; "
53
+ "form-action 'self'"
54
+ )
55
+
56
+ response.headers["Content-Security-Policy"] = csp
57
+ response.headers["X-Content-Type-Options"] = "nosniff"
58
+ response.headers["X-Frame-Options"] = "DENY"
59
+ response.headers["X-XSS-Protection"] = "1; mode=block"
60
+ response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
61
+ response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
62
+ response.headers["Cache-Control"] = "no-store"
63
+
64
+ if not self.dev_mode:
65
+ response.headers["Strict-Transport-Security"] = (
66
+ "max-age=63072000; includeSubDomains; preload"
67
+ )
68
+
69
+ return response