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):
|
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||||
to_encode = data.copy()
|
to_encode = data.copy()
|
||||||
if expires_delta:
|
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=15))
|
||||||
expire = datetime.utcnow() + expires_delta
|
to_encode.update({"exp": expire, "type": "access"})
|
||||||
else:
|
return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM)
|
||||||
expire = datetime.utcnow() + timedelta(minutes=15)
|
|
||||||
to_encode.update({"exp": expire})
|
def create_refresh_token(data: dict):
|
||||||
encoded_jwt = jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM)
|
to_encode = data.copy()
|
||||||
return encoded_jwt
|
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)):
|
async def get_current_user(token: str = Depends(oauth2_scheme)):
|
||||||
credentials_exception = HTTPException(
|
credentials_exception = HTTPException(
|
||||||
@@ -35,13 +37,11 @@ async def get_current_user(token: str = Depends(oauth2_scheme)):
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
||||||
username: str = payload.get("sub")
|
if payload.get("type") != "access":
|
||||||
if username is None:
|
raise credentials_exception
|
||||||
|
username: str = payload.get("sub")
|
||||||
|
if username is None or username != config.ADMIN_USER:
|
||||||
raise credentials_exception
|
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:
|
except jwt.PyJWTError:
|
||||||
raise credentials_exception
|
raise credentials_exception
|
||||||
return username
|
return username
|
||||||
@@ -53,6 +53,8 @@ async def get_current_user_ws(token: str):
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
||||||
|
if payload.get("type") != "access":
|
||||||
|
raise credentials_exception
|
||||||
username: str = payload.get("sub")
|
username: str = payload.get("sub")
|
||||||
if username is None or username != config.ADMIN_USER:
|
if username is None or username != config.ADMIN_USER:
|
||||||
raise credentials_exception
|
raise credentials_exception
|
||||||
@@ -63,6 +65,26 @@ async def get_current_user_ws(token: str):
|
|||||||
# TOTP Persistence
|
# TOTP Persistence
|
||||||
AUTH_FILE = config.VOLUME_ROOT + "/docker/nas-dashboard/auth.json"
|
AUTH_FILE = config.VOLUME_ROOT + "/docker/nas-dashboard/auth.json"
|
||||||
import json, os
|
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():
|
def _load_auth_data():
|
||||||
try:
|
try:
|
||||||
@@ -79,11 +101,14 @@ def _save_auth_data(data):
|
|||||||
json.dump(data, f)
|
json.dump(data, f)
|
||||||
|
|
||||||
def load_totp_secret():
|
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):
|
def save_totp_secret(secret: str):
|
||||||
data = _load_auth_data()
|
data = _load_auth_data()
|
||||||
data["totp_secret"] = secret
|
data["totp_secret"] = _encrypt(secret) if secret else ""
|
||||||
_save_auth_data(data)
|
_save_auth_data(data)
|
||||||
|
|
||||||
def load_password_hash():
|
def load_password_hash():
|
||||||
@@ -101,3 +126,19 @@ def verify_totp(token: str, secret: str = None):
|
|||||||
return True # 2FA not enabled
|
return True # 2FA not enabled
|
||||||
totp = pyotp.TOTP(secret)
|
totp = pyotp.TOTP(secret)
|
||||||
return totp.verify(token)
|
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
|
# Auth
|
||||||
SECRET_KEY = os.environ.get("SECRET_KEY", "")
|
SECRET_KEY = os.environ.get("SECRET_KEY", "")
|
||||||
ALGORITHM = "HS256"
|
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_USER = os.environ.get("ADMIN_USER", "admin")
|
||||||
ADMIN_PASSWORD_HASH = os.environ.get("ADMIN_PASSWORD_HASH", "")
|
ADMIN_PASSWORD_HASH = os.environ.get("ADMIN_PASSWORD_HASH", "")
|
||||||
TOTP_SECRET = os.environ.get("TOTP_SECRET", "")
|
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_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "")
|
||||||
TELEGRAM_PROXY = os.environ.get("TELEGRAM_PROXY", "")
|
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
|
||||||
CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "https://nas.jimmygan.com").split(",")
|
CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "https://nas.jimmygan.com").split(",")
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import auth as auth_module
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import httpx
|
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)
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
app = FastAPI(title="NAS Dashboard")
|
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-Frame-Options"] = "DENY"
|
||||||
response.headers["X-XSS-Protection"] = "1; mode=block"
|
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
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
|
return response
|
||||||
|
|
||||||
async def monitor_containers():
|
async def monitor_containers():
|
||||||
|
|||||||
@@ -9,3 +9,5 @@ passlib==1.7.4
|
|||||||
pyotp==2.9.0
|
pyotp==2.9.0
|
||||||
asyncssh==2.17.0
|
asyncssh==2.17.0
|
||||||
slowapi==0.1.9
|
slowapi==0.1.9
|
||||||
|
webauthn==2.7.1
|
||||||
|
cryptography>=44.0.2
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import time
|
||||||
|
import json
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from slowapi import Limiter
|
from slowapi import Limiter
|
||||||
from slowapi.util import get_remote_address
|
from slowapi.util import get_remote_address
|
||||||
|
import jwt
|
||||||
import config
|
import config
|
||||||
import auth
|
import auth
|
||||||
import pyotp
|
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()
|
router = APIRouter()
|
||||||
limiter = Limiter(key_func=get_remote_address)
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
@@ -19,10 +26,14 @@ class LoginRequest(BaseModel):
|
|||||||
|
|
||||||
class Token(BaseModel):
|
class Token(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
token_type: str
|
token_type: str
|
||||||
user: str
|
user: str
|
||||||
has_2fa: bool
|
has_2fa: bool
|
||||||
|
|
||||||
|
class RefreshRequest(BaseModel):
|
||||||
|
refresh_token: str
|
||||||
|
|
||||||
class Setup2FAResponse(BaseModel):
|
class Setup2FAResponse(BaseModel):
|
||||||
secret: str
|
secret: str
|
||||||
uri: str
|
uri: str
|
||||||
@@ -87,8 +98,10 @@ async def login(creds: LoginRequest, request: Request):
|
|||||||
data={"sub": creds.username},
|
data={"sub": creds.username},
|
||||||
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||||
)
|
)
|
||||||
|
refresh_token = auth.create_refresh_token(data={"sub": creds.username})
|
||||||
return {
|
return {
|
||||||
"access_token": access_token,
|
"access_token": access_token,
|
||||||
|
"refresh_token": refresh_token,
|
||||||
"token_type": "bearer",
|
"token_type": "bearer",
|
||||||
"user": creds.username,
|
"user": creds.username,
|
||||||
"has_2fa": bool(totp_secret)
|
"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()
|
totp_secret = auth.load_totp_secret()
|
||||||
return {"username": current_user, "has_2fa": bool(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)
|
@router.post("/setup-2fa/generate", response_model=Setup2FAResponse)
|
||||||
async def generate_2fa(current_user: str = Depends(auth.get_current_user)):
|
async def generate_2fa(current_user: str = Depends(auth.get_current_user)):
|
||||||
secret = pyotp.random_base32()
|
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")
|
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
||||||
auth.save_password_hash(auth.get_password_hash(req.new_password))
|
auth.save_password_hash(auth.get_password_hash(req.new_password))
|
||||||
return {"message": "Password changed successfully"}
|
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()
|
router = APIRouter()
|
||||||
BASE = Path(VOLUME_ROOT)
|
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:
|
def _safe_path(path: str) -> Path:
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
import Settings from "./routes/Settings.svelte";
|
import Settings from "./routes/Settings.svelte";
|
||||||
import Login from "./routes/Login.svelte";
|
import Login from "./routes/Login.svelte";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { getToken, checkAuth, setToken } from "./lib/api.js";
|
import { getToken, checkAuth, setToken, setRefreshToken } from "./lib/api.js";
|
||||||
|
|
||||||
let page = $state("dashboard");
|
let page = $state("dashboard");
|
||||||
let dark = $state(false);
|
let dark = $state(false);
|
||||||
@@ -60,6 +60,7 @@
|
|||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
setToken("");
|
setToken("");
|
||||||
|
setRefreshToken("");
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -11,6 +11,42 @@ export function getToken() {
|
|||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setRefreshToken(t) {
|
||||||
|
if (t) localStorage.setItem("refresh_token", t);
|
||||||
|
else localStorage.removeItem("refresh_token");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRefreshToken() {
|
||||||
|
return localStorage.getItem("refresh_token") || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
let refreshPromise = null;
|
||||||
|
|
||||||
|
async function tryRefresh() {
|
||||||
|
if (refreshPromise) return refreshPromise;
|
||||||
|
const rt = getRefreshToken();
|
||||||
|
if (!rt) return false;
|
||||||
|
refreshPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch(BASE + "/auth/refresh", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ refresh_token: rt }),
|
||||||
|
});
|
||||||
|
if (!r.ok) return false;
|
||||||
|
const data = await r.json();
|
||||||
|
setToken(data.access_token);
|
||||||
|
setRefreshToken(data.refresh_token);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
refreshPromise = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
async function request(path, opts = {}) {
|
async function request(path, opts = {}) {
|
||||||
const headers = opts.headers || {};
|
const headers = opts.headers || {};
|
||||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
@@ -22,15 +58,19 @@ async function request(path, opts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const r = await fetch(BASE + path, { ...opts, headers });
|
let r = await fetch(BASE + path, { ...opts, headers });
|
||||||
|
|
||||||
|
if (r.status === 401 && !path.includes("/auth/refresh")) {
|
||||||
|
const refreshed = await tryRefresh();
|
||||||
|
if (refreshed) {
|
||||||
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
r = await fetch(BASE + path, { ...opts, headers });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (r.status === 401) {
|
if (r.status === 401) {
|
||||||
// Session expired or invalid
|
|
||||||
setToken("");
|
setToken("");
|
||||||
// Only reload if not already on login page (simple check)
|
setRefreshToken("");
|
||||||
if (!location.pathname.includes("login") && !token) {
|
|
||||||
// Let the app handle redirect, but clearer is to throw
|
|
||||||
}
|
|
||||||
throw new Error("Unauthorized");
|
throw new Error("Unauthorized");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import { login, setToken } from "../lib/api.js";
|
import { login, setToken, setRefreshToken, get, post } from "../lib/api.js";
|
||||||
import { fade, fly } from "svelte/transition";
|
import { fade, fly } from "svelte/transition";
|
||||||
|
|
||||||
let username = $state("");
|
let username = $state("");
|
||||||
@@ -8,6 +8,64 @@
|
|||||||
let error = $state("");
|
let error = $state("");
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let require2FA = $state(false);
|
let require2FA = $state(false);
|
||||||
|
let showPasswordForm = $state(false);
|
||||||
|
|
||||||
|
function base64urlToBuffer(b64) {
|
||||||
|
const s = b64.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const raw = atob(s);
|
||||||
|
return Uint8Array.from(raw, c => c.charCodeAt(0)).buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bufferToBase64url(buf) {
|
||||||
|
const bytes = new Uint8Array(buf);
|
||||||
|
let s = '';
|
||||||
|
bytes.forEach(b => s += String.fromCharCode(b));
|
||||||
|
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginWithPasskey() {
|
||||||
|
loading = true;
|
||||||
|
error = "";
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/auth/passkey/login/options", { method: "POST" });
|
||||||
|
if (!r.ok) throw new Error("No passkeys registered");
|
||||||
|
const opts = await r.json();
|
||||||
|
opts.challenge = base64urlToBuffer(opts.challenge);
|
||||||
|
if (opts.allowCredentials) {
|
||||||
|
opts.allowCredentials = opts.allowCredentials.map(c => ({
|
||||||
|
...c, id: base64urlToBuffer(c.id)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
const cred = await navigator.credentials.get({ publicKey: opts });
|
||||||
|
const body = {
|
||||||
|
id: cred.id,
|
||||||
|
rawId: bufferToBase64url(cred.rawId),
|
||||||
|
type: cred.type,
|
||||||
|
response: {
|
||||||
|
authenticatorData: bufferToBase64url(cred.response.authenticatorData),
|
||||||
|
clientDataJSON: bufferToBase64url(cred.response.clientDataJSON),
|
||||||
|
signature: bufferToBase64url(cred.response.signature),
|
||||||
|
userHandle: cred.response.userHandle ? bufferToBase64url(cred.response.userHandle) : null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const res = await fetch("/api/auth/passkey/login/verify", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) { const e = await res.json(); throw new Error(e.detail || "Passkey verification failed"); }
|
||||||
|
const data = await res.json();
|
||||||
|
setToken(data.access_token);
|
||||||
|
setRefreshToken(data.refresh_token);
|
||||||
|
window.location.reload();
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name === "NotAllowedError") { error = "Passkey authentication cancelled"; }
|
||||||
|
else { error = e.message || "Passkey login failed"; }
|
||||||
|
showPasswordForm = true;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
async function handleSubmit(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -21,6 +79,7 @@
|
|||||||
const res = await login(payload); // api.js helper sends JSON
|
const res = await login(payload); // api.js helper sends JSON
|
||||||
|
|
||||||
setToken(res.access_token);
|
setToken(res.access_token);
|
||||||
|
setRefreshToken(res.refresh_token);
|
||||||
// Determine if we need to reload or just update state.
|
// Determine if we need to reload or just update state.
|
||||||
// Reload is safest to clear any stale state.
|
// Reload is safest to clear any stale state.
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
@@ -57,7 +116,35 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="mt-8 space-y-6" onsubmit={handleSubmit}>
|
<div class="mt-8 space-y-6">
|
||||||
|
{#if !showPasswordForm && !require2FA}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={loading}
|
||||||
|
onclick={loginWithPasskey}
|
||||||
|
class="flex w-full justify-center rounded-lg bg-surface-800 dark:bg-surface-700 px-3 py-2.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-surface-700 dark:hover:bg-surface-600 disabled:opacity-70 transition-all active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
{#if loading}
|
||||||
|
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
Sign in with Passkey
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-0 flex items-center"><div class="w-full border-t border-surface-200 dark:border-surface-700"></div></div>
|
||||||
|
<div class="relative flex justify-center text-xs"><span class="bg-white dark:bg-surface-900 px-2 text-surface-400">or</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" onclick={() => showPasswordForm = true} class="flex w-full justify-center text-sm text-primary-500 hover:text-primary-400">
|
||||||
|
Use password instead
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showPasswordForm || require2FA}
|
||||||
|
<form class="space-y-6" onsubmit={handleSubmit}>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
{#if !require2FA}
|
{#if !require2FA}
|
||||||
<div>
|
<div>
|
||||||
@@ -110,6 +197,22 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
class="flex w-full justify-center rounded-lg bg-primary-600 px-3 py-2.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-primary-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 disabled:opacity-70 transition-all active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
{#if loading}
|
||||||
|
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
{require2FA ? "Verify Code" : "Sign in"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<div class="rounded-md bg-red-50 dark:bg-red-900/30 p-3" in:fade>
|
<div class="rounded-md bg-red-50 dark:bg-red-900/30 p-3" in:fade>
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
@@ -124,22 +227,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
class="flex w-full justify-center rounded-lg bg-primary-600 px-3 py-2.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-primary-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 disabled:opacity-70 transition-all active:scale-[0.98]"
|
|
||||||
>
|
|
||||||
{#if loading}
|
|
||||||
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
{require2FA ? "Verify Code" : "Sign in"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { post } from "../lib/api.js";
|
import { post, get } from "../lib/api.js";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
let currentPassword = $state("");
|
let currentPassword = $state("");
|
||||||
let newPassword = $state("");
|
let newPassword = $state("");
|
||||||
@@ -8,6 +9,76 @@
|
|||||||
let error = $state("");
|
let error = $state("");
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
|
|
||||||
|
let passkeyCount = $state(0);
|
||||||
|
let passkeyMsg = $state("");
|
||||||
|
let passkeyErr = $state("");
|
||||||
|
let passkeyLoading = $state(false);
|
||||||
|
|
||||||
|
onMount(loadPasskeys);
|
||||||
|
|
||||||
|
async function loadPasskeys() {
|
||||||
|
try {
|
||||||
|
const res = await get("/auth/passkey/list");
|
||||||
|
passkeyCount = res.count;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64urlToBuffer(b64) {
|
||||||
|
const s = b64.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const raw = atob(s);
|
||||||
|
return Uint8Array.from(raw, c => c.charCodeAt(0)).buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bufferToBase64url(buf) {
|
||||||
|
const bytes = new Uint8Array(buf);
|
||||||
|
let s = '';
|
||||||
|
bytes.forEach(b => s += String.fromCharCode(b));
|
||||||
|
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerPasskey() {
|
||||||
|
passkeyLoading = true;
|
||||||
|
passkeyMsg = ""; passkeyErr = "";
|
||||||
|
try {
|
||||||
|
const opts = await post("/auth/passkey/register/options");
|
||||||
|
opts.challenge = base64urlToBuffer(opts.challenge);
|
||||||
|
opts.user.id = base64urlToBuffer(opts.user.id);
|
||||||
|
if (opts.excludeCredentials) {
|
||||||
|
opts.excludeCredentials = opts.excludeCredentials.map(c => ({ ...c, id: base64urlToBuffer(c.id) }));
|
||||||
|
}
|
||||||
|
const cred = await navigator.credentials.create({ publicKey: opts });
|
||||||
|
const body = {
|
||||||
|
id: cred.id,
|
||||||
|
rawId: bufferToBase64url(cred.rawId),
|
||||||
|
type: cred.type,
|
||||||
|
response: {
|
||||||
|
attestationObject: bufferToBase64url(cred.response.attestationObject),
|
||||||
|
clientDataJSON: bufferToBase64url(cred.response.clientDataJSON),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await post("/auth/passkey/register/verify", body);
|
||||||
|
passkeyMsg = "Passkey registered successfully";
|
||||||
|
await loadPasskeys();
|
||||||
|
} catch (e) {
|
||||||
|
passkeyErr = e.body?.detail || e.message || "Failed to register passkey";
|
||||||
|
}
|
||||||
|
passkeyLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearPasskeys() {
|
||||||
|
if (!confirm("Remove all passkeys?")) return;
|
||||||
|
passkeyLoading = true;
|
||||||
|
passkeyMsg = ""; passkeyErr = "";
|
||||||
|
try {
|
||||||
|
await post("/auth/passkey/clear");
|
||||||
|
passkeyMsg = "All passkeys removed";
|
||||||
|
passkeyCount = 0;
|
||||||
|
} catch (e) {
|
||||||
|
passkeyErr = e.body?.detail || "Failed to remove passkeys";
|
||||||
|
}
|
||||||
|
passkeyLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
async function changePassword() {
|
async function changePassword() {
|
||||||
error = "";
|
error = "";
|
||||||
message = "";
|
message = "";
|
||||||
@@ -59,4 +130,28 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-xl border border-surface-200 p-6 shadow-sm max-w-md">
|
||||||
|
<h3 class="text-sm font-semibold text-surface-700 mb-4">Passkeys</h3>
|
||||||
|
|
||||||
|
{#if passkeyMsg}
|
||||||
|
<div class="text-sm text-emerald-600 bg-emerald-50 px-3 py-2 rounded-lg mb-4">{passkeyMsg}</div>
|
||||||
|
{/if}
|
||||||
|
{#if passkeyErr}
|
||||||
|
<div class="text-sm text-rose-600 bg-rose-50 px-3 py-2 rounded-lg mb-4">{passkeyErr}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<p class="text-sm text-surface-500 mb-4">{passkeyCount} passkey{passkeyCount !== 1 ? 's' : ''} registered</p>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick={registerPasskey} disabled={passkeyLoading} class="px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-lg transition-colors disabled:opacity-50">
|
||||||
|
Register new passkey
|
||||||
|
</button>
|
||||||
|
{#if passkeyCount > 0}
|
||||||
|
<button onclick={clearPasskeys} disabled={passkeyLoading} class="px-4 py-2 text-sm font-medium text-rose-600 bg-rose-50 hover:bg-rose-100 rounded-lg transition-colors disabled:opacity-50">
|
||||||
|
Remove all
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user