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:
@@ -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