Add security improvements: refresh tokens, passkeys, CSP, audit logging, TOTP encryption
Deploy Dashboard / deploy (push) Successful in 1m26s
Deploy Dashboard / deploy (push) Successful in 1m26s
- Shorten access token to 30min with 7-day refresh token flow - Add WebAuthn passkey registration and login with TOTP fallback - Add Content-Security-Policy header - Add audit logging middleware to /volume1/docker/nas-dashboard/audit.log - Block /volume1/docker/ in files endpoint - Encrypt TOTP secret at rest with Fernet (derived from SECRET_KEY) - New deps: py_webauthn, cryptography
This commit is contained in:
+56
-15
@@ -19,13 +19,15 @@ def get_password_hash(password):
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=15)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM)
|
||||
return encoded_jwt
|
||||
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=15))
|
||||
to_encode.update({"exp": expire, "type": "access"})
|
||||
return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM)
|
||||
|
||||
def create_refresh_token(data: dict):
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + timedelta(minutes=config.REFRESH_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode.update({"exp": expire, "type": "refresh"})
|
||||
return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM)
|
||||
|
||||
async def get_current_user(token: str = Depends(oauth2_scheme)):
|
||||
credentials_exception = HTTPException(
|
||||
@@ -35,13 +37,11 @@ async def get_current_user(token: str = Depends(oauth2_scheme)):
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
||||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
if payload.get("type") != "access":
|
||||
raise credentials_exception
|
||||
username: str = payload.get("sub")
|
||||
if username is None or username != config.ADMIN_USER:
|
||||
raise credentials_exception
|
||||
# In a real app, we'd check DB here.
|
||||
# For this single-user system, we just check if it matches config.
|
||||
if username != config.ADMIN_USER:
|
||||
raise credentials_exception
|
||||
except jwt.PyJWTError:
|
||||
raise credentials_exception
|
||||
return username
|
||||
@@ -53,6 +53,8 @@ async def get_current_user_ws(token: str):
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
||||
if payload.get("type") != "access":
|
||||
raise credentials_exception
|
||||
username: str = payload.get("sub")
|
||||
if username is None or username != config.ADMIN_USER:
|
||||
raise credentials_exception
|
||||
@@ -63,6 +65,26 @@ async def get_current_user_ws(token: str):
|
||||
# TOTP Persistence
|
||||
AUTH_FILE = config.VOLUME_ROOT + "/docker/nas-dashboard/auth.json"
|
||||
import json, os
|
||||
import base64
|
||||
import hashlib
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
def _get_fernet():
|
||||
key = base64.urlsafe_b64encode(hashlib.sha256(config.SECRET_KEY.encode()).digest())
|
||||
return Fernet(key)
|
||||
|
||||
def _encrypt(plaintext: str) -> str:
|
||||
if not plaintext:
|
||||
return ""
|
||||
return _get_fernet().encrypt(plaintext.encode()).decode()
|
||||
|
||||
def _decrypt(ciphertext: str) -> str:
|
||||
if not ciphertext:
|
||||
return ""
|
||||
try:
|
||||
return _get_fernet().decrypt(ciphertext.encode()).decode()
|
||||
except Exception:
|
||||
return ciphertext # fallback for unencrypted legacy values
|
||||
|
||||
def _load_auth_data():
|
||||
try:
|
||||
@@ -79,11 +101,14 @@ def _save_auth_data(data):
|
||||
json.dump(data, f)
|
||||
|
||||
def load_totp_secret():
|
||||
return _load_auth_data().get("totp_secret", "") or config.TOTP_SECRET
|
||||
encrypted = _load_auth_data().get("totp_secret", "")
|
||||
if not encrypted:
|
||||
return config.TOTP_SECRET
|
||||
return _decrypt(encrypted)
|
||||
|
||||
def save_totp_secret(secret: str):
|
||||
data = _load_auth_data()
|
||||
data["totp_secret"] = secret
|
||||
data["totp_secret"] = _encrypt(secret) if secret else ""
|
||||
_save_auth_data(data)
|
||||
|
||||
def load_password_hash():
|
||||
@@ -101,3 +126,19 @@ def verify_totp(token: str, secret: str = None):
|
||||
return True # 2FA not enabled
|
||||
totp = pyotp.TOTP(secret)
|
||||
return totp.verify(token)
|
||||
|
||||
# Passkey credentials
|
||||
def load_passkey_credentials():
|
||||
return _load_auth_data().get("passkey_credentials", [])
|
||||
|
||||
def save_passkey_credential(cred):
|
||||
data = _load_auth_data()
|
||||
creds = data.get("passkey_credentials", [])
|
||||
creds.append(cred)
|
||||
data["passkey_credentials"] = creds
|
||||
_save_auth_data(data)
|
||||
|
||||
def clear_passkey_credentials():
|
||||
data = _load_auth_data()
|
||||
data["passkey_credentials"] = []
|
||||
_save_auth_data(data)
|
||||
|
||||
@@ -9,7 +9,8 @@ VOLUME_ROOT = os.environ.get("VOLUME_ROOT", "/volume1")
|
||||
# Auth
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY", "")
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
||||
REFRESH_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
|
||||
ADMIN_USER = os.environ.get("ADMIN_USER", "admin")
|
||||
ADMIN_PASSWORD_HASH = os.environ.get("ADMIN_PASSWORD_HASH", "")
|
||||
TOTP_SECRET = os.environ.get("TOTP_SECRET", "")
|
||||
@@ -28,5 +29,8 @@ TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "")
|
||||
TELEGRAM_PROXY = os.environ.get("TELEGRAM_PROXY", "")
|
||||
|
||||
WEBAUTHN_RP_ID = os.environ.get("WEBAUTHN_RP_ID", "nas.jimmygan.com")
|
||||
WEBAUTHN_ORIGIN = os.environ.get("WEBAUTHN_ORIGIN", "https://nas.jimmygan.com")
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "https://nas.jimmygan.com").split(",")
|
||||
|
||||
@@ -10,7 +10,10 @@ import auth as auth_module
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
from config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, CORS_ORIGINS
|
||||
import logging
|
||||
import time
|
||||
import jwt
|
||||
from config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, CORS_ORIGINS, SECRET_KEY, ALGORITHM, VOLUME_ROOT
|
||||
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
app = FastAPI(title="NAS Dashboard")
|
||||
@@ -35,6 +38,38 @@ async def security_headers(request: Request, call_next):
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
response.headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' wss://nas.jimmygan.com; frame-ancestors 'none'"
|
||||
return response
|
||||
|
||||
# Audit logger
|
||||
import os
|
||||
_audit_log_path = os.path.join(VOLUME_ROOT, "docker/nas-dashboard/audit.log")
|
||||
os.makedirs(os.path.dirname(_audit_log_path), exist_ok=True)
|
||||
_audit_logger = logging.getLogger("audit")
|
||||
_audit_logger.setLevel(logging.INFO)
|
||||
_audit_handler = logging.FileHandler(_audit_log_path)
|
||||
_audit_handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_audit_logger.addHandler(_audit_handler)
|
||||
_audit_logger.propagate = False
|
||||
|
||||
@app.middleware("http")
|
||||
async def audit_log(request: Request, call_next):
|
||||
start = time.time()
|
||||
response = await call_next(request)
|
||||
duration = int((time.time() - start) * 1000)
|
||||
user = "-"
|
||||
try:
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
payload = jwt.decode(auth_header[7:], SECRET_KEY, algorithms=[ALGORITHM])
|
||||
user = payload.get("sub", "-")
|
||||
except Exception:
|
||||
pass
|
||||
_audit_logger.info(
|
||||
"%s %s %s %s %s %d %dms",
|
||||
time.strftime("%Y-%m-%dT%H:%M:%S"), request.client.host, user,
|
||||
request.method, request.url.path, response.status_code, duration,
|
||||
)
|
||||
return response
|
||||
|
||||
async def monitor_containers():
|
||||
|
||||
@@ -9,3 +9,5 @@ passlib==1.7.4
|
||||
pyotp==2.9.0
|
||||
asyncssh==2.17.0
|
||||
slowapi==0.1.9
|
||||
webauthn==2.7.1
|
||||
cryptography>=44.0.2
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
from datetime import timedelta
|
||||
import asyncio
|
||||
import time
|
||||
import json
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
import jwt
|
||||
import config
|
||||
import auth
|
||||
import pyotp
|
||||
from webauthn import generate_registration_options, verify_registration_response, generate_authentication_options, verify_authentication_response
|
||||
from webauthn.helpers.structs import PublicKeyCredentialDescriptor, UserVerificationRequirement
|
||||
from webauthn.helpers import bytes_to_base64url, base64url_to_bytes, options_to_json
|
||||
|
||||
router = APIRouter()
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
@@ -19,10 +26,14 @@ class LoginRequest(BaseModel):
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str
|
||||
user: str
|
||||
has_2fa: bool
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
class Setup2FAResponse(BaseModel):
|
||||
secret: str
|
||||
uri: str
|
||||
@@ -87,8 +98,10 @@ async def login(creds: LoginRequest, request: Request):
|
||||
data={"sub": creds.username},
|
||||
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||
)
|
||||
refresh_token = auth.create_refresh_token(data={"sub": creds.username})
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": "bearer",
|
||||
"user": creds.username,
|
||||
"has_2fa": bool(totp_secret)
|
||||
@@ -99,6 +112,24 @@ async def read_users_me(current_user: str = Depends(auth.get_current_user)):
|
||||
totp_secret = auth.load_totp_secret()
|
||||
return {"username": current_user, "has_2fa": bool(totp_secret)}
|
||||
|
||||
@router.post("/refresh")
|
||||
async def refresh_token(req: RefreshRequest):
|
||||
try:
|
||||
payload = jwt.decode(req.refresh_token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
||||
if payload.get("type") != "refresh":
|
||||
raise HTTPException(status_code=401, detail="Invalid token type")
|
||||
username = payload.get("sub")
|
||||
if username != config.ADMIN_USER:
|
||||
raise HTTPException(status_code=401, detail="Invalid user")
|
||||
except jwt.PyJWTError:
|
||||
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
||||
access_token = auth.create_access_token(
|
||||
data={"sub": username},
|
||||
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||
)
|
||||
refresh_token = auth.create_refresh_token(data={"sub": username})
|
||||
return {"access_token": access_token, "refresh_token": refresh_token}
|
||||
|
||||
@router.post("/setup-2fa/generate", response_model=Setup2FAResponse)
|
||||
async def generate_2fa(current_user: str = Depends(auth.get_current_user)):
|
||||
secret = pyotp.random_base32()
|
||||
@@ -133,3 +164,105 @@ async def change_password(req: ChangePasswordRequest, request: Request, current_
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
||||
auth.save_password_hash(auth.get_password_hash(req.new_password))
|
||||
return {"message": "Password changed successfully"}
|
||||
|
||||
# WebAuthn — in-memory challenge store with TTL
|
||||
_challenges = {}
|
||||
|
||||
def _store_challenge(challenge: bytes):
|
||||
_challenges["current"] = (challenge, time.time())
|
||||
|
||||
def _get_challenge() -> bytes:
|
||||
entry = _challenges.pop("current", None)
|
||||
if not entry or time.time() - entry[1] > 300:
|
||||
raise HTTPException(status_code=400, detail="Challenge expired")
|
||||
return entry[0]
|
||||
|
||||
@router.post("/passkey/register/options")
|
||||
async def passkey_register_options(current_user: str = Depends(auth.get_current_user)):
|
||||
existing = auth.load_passkey_credentials()
|
||||
exclude = [PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["credential_id"])) for c in existing]
|
||||
options = generate_registration_options(
|
||||
rp_id=config.WEBAUTHN_RP_ID,
|
||||
rp_name="NAS Dashboard",
|
||||
user_id=config.ADMIN_USER.encode(),
|
||||
user_name=config.ADMIN_USER,
|
||||
user_display_name=config.ADMIN_USER,
|
||||
exclude_credentials=exclude,
|
||||
)
|
||||
_store_challenge(options.challenge)
|
||||
return JSONResponse(content=json.loads(options_to_json(options)))
|
||||
|
||||
@router.post("/passkey/register/verify")
|
||||
async def passkey_register_verify(request: Request, current_user: str = Depends(auth.get_current_user)):
|
||||
body = await request.json()
|
||||
challenge = _get_challenge()
|
||||
try:
|
||||
verification = verify_registration_response(
|
||||
credential=body,
|
||||
expected_challenge=challenge,
|
||||
expected_rp_id=config.WEBAUTHN_RP_ID,
|
||||
expected_origin=config.WEBAUTHN_ORIGIN,
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
auth.save_passkey_credential({
|
||||
"credential_id": bytes_to_base64url(verification.credential_id),
|
||||
"public_key": bytes_to_base64url(verification.credential_public_key),
|
||||
"sign_count": verification.sign_count,
|
||||
})
|
||||
return {"ok": True}
|
||||
|
||||
@router.post("/passkey/login/options")
|
||||
async def passkey_login_options():
|
||||
creds = auth.load_passkey_credentials()
|
||||
if not creds:
|
||||
raise HTTPException(status_code=404, detail="No passkeys registered")
|
||||
allow = [PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["credential_id"])) for c in creds]
|
||||
options = generate_authentication_options(
|
||||
rp_id=config.WEBAUTHN_RP_ID,
|
||||
allow_credentials=allow,
|
||||
user_verification=UserVerificationRequirement.PREFERRED,
|
||||
)
|
||||
_store_challenge(options.challenge)
|
||||
return JSONResponse(content=json.loads(options_to_json(options)))
|
||||
|
||||
@router.post("/passkey/login/verify")
|
||||
async def passkey_login_verify(request: Request):
|
||||
body = await request.json()
|
||||
challenge = _get_challenge()
|
||||
creds = auth.load_passkey_credentials()
|
||||
cred_id_b64 = body.get("id", "")
|
||||
matched = next((c for c in creds if c["credential_id"] == cred_id_b64), None)
|
||||
if not matched:
|
||||
raise HTTPException(status_code=400, detail="Unknown credential")
|
||||
try:
|
||||
verification = verify_authentication_response(
|
||||
credential=body,
|
||||
expected_challenge=challenge,
|
||||
expected_rp_id=config.WEBAUTHN_RP_ID,
|
||||
expected_origin=config.WEBAUTHN_ORIGIN,
|
||||
credential_public_key=base64url_to_bytes(matched["public_key"]),
|
||||
credential_current_sign_count=matched["sign_count"],
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
matched["sign_count"] = verification.new_sign_count
|
||||
data = auth._load_auth_data()
|
||||
data["passkey_credentials"] = creds
|
||||
auth._save_auth_data(data)
|
||||
username = config.ADMIN_USER
|
||||
access_token = auth.create_access_token(
|
||||
data={"sub": username},
|
||||
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||
)
|
||||
refresh_token = auth.create_refresh_token(data={"sub": username})
|
||||
return {"access_token": access_token, "refresh_token": refresh_token, "token_type": "bearer", "user": username}
|
||||
|
||||
@router.get("/passkey/list")
|
||||
async def passkey_list(current_user: str = Depends(auth.get_current_user)):
|
||||
return {"count": len(auth.load_passkey_credentials())}
|
||||
|
||||
@router.post("/passkey/clear")
|
||||
async def passkey_clear(current_user: str = Depends(auth.get_current_user)):
|
||||
auth.clear_passkey_credentials()
|
||||
return {"ok": True}
|
||||
|
||||
@@ -8,7 +8,7 @@ from config import VOLUME_ROOT
|
||||
router = APIRouter()
|
||||
BASE = Path(VOLUME_ROOT)
|
||||
|
||||
BLOCKED_DIRS = {"@appstore", "@docker", "@eaDir", "@S2S", "@sharesnap", "@tmp"}
|
||||
BLOCKED_DIRS = {"@appstore", "@docker", "@eaDir", "@S2S", "@sharesnap", "@tmp", "docker"}
|
||||
|
||||
|
||||
def _safe_path(path: str) -> Path:
|
||||
|
||||
Reference in New Issue
Block a user