Add security improvements: refresh tokens, passkeys, CSP, audit logging, TOTP encryption
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:
Gan, Jimmy
2026-02-22 10:54:23 +08:00
parent 8392b49bb7
commit 9683cd3165
10 changed files with 484 additions and 46 deletions
+134 -1
View File
@@ -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}
+1 -1
View File
@@ -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: