@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.
- package/AGENTS.md +298 -0
- package/LICENSE +190 -0
- package/README.md +336 -0
- package/bin/hits.js +56 -0
- package/config/schema.json +94 -0
- package/config/settings.yaml +102 -0
- package/data/dev_handover.yaml +143 -0
- package/hits_core/__init__.py +9 -0
- package/hits_core/ai/__init__.py +11 -0
- package/hits_core/ai/compressor.py +86 -0
- package/hits_core/ai/llm_client.py +65 -0
- package/hits_core/ai/slm_filter.py +126 -0
- package/hits_core/api/__init__.py +3 -0
- package/hits_core/api/routes/__init__.py +8 -0
- package/hits_core/api/routes/auth.py +211 -0
- package/hits_core/api/routes/handover.py +117 -0
- package/hits_core/api/routes/health.py +8 -0
- package/hits_core/api/routes/knowledge.py +177 -0
- package/hits_core/api/routes/node.py +121 -0
- package/hits_core/api/routes/work_log.py +174 -0
- package/hits_core/api/server.py +181 -0
- package/hits_core/auth/__init__.py +21 -0
- package/hits_core/auth/dependencies.py +61 -0
- package/hits_core/auth/manager.py +368 -0
- package/hits_core/auth/middleware.py +69 -0
- package/hits_core/collector/__init__.py +18 -0
- package/hits_core/collector/ai_session_collector.py +118 -0
- package/hits_core/collector/base.py +73 -0
- package/hits_core/collector/daemon.py +94 -0
- package/hits_core/collector/git_collector.py +177 -0
- package/hits_core/collector/hits_action_collector.py +110 -0
- package/hits_core/collector/shell_collector.py +178 -0
- package/hits_core/main.py +36 -0
- package/hits_core/mcp/__init__.py +20 -0
- package/hits_core/mcp/server.py +429 -0
- package/hits_core/models/__init__.py +18 -0
- package/hits_core/models/node.py +56 -0
- package/hits_core/models/tree.py +68 -0
- package/hits_core/models/work_log.py +64 -0
- package/hits_core/models/workflow.py +92 -0
- package/hits_core/platform/__init__.py +5 -0
- package/hits_core/platform/actions.py +225 -0
- package/hits_core/service/__init__.py +6 -0
- package/hits_core/service/handover_service.py +382 -0
- package/hits_core/service/knowledge_service.py +172 -0
- package/hits_core/service/tree_service.py +105 -0
- package/hits_core/storage/__init__.py +11 -0
- package/hits_core/storage/base.py +84 -0
- package/hits_core/storage/file_store.py +314 -0
- package/hits_core/storage/redis_store.py +123 -0
- package/hits_web/dist/assets/index-Bgx7F6m6.css +1 -0
- package/hits_web/dist/assets/index-D1B5E67G.js +3 -0
- package/hits_web/dist/index.html +16 -0
- package/package.json +60 -0
- package/requirements-core.txt +7 -0
- package/requirements.txt +1 -0
- package/run.sh +271 -0
- 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
|