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
+55 -14
View File
@@ -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,12 +37,10 @@ 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
# 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:
username: str = payload.get("sub")
if username is None or username != config.ADMIN_USER:
raise credentials_exception
except jwt.PyJWTError:
raise credentials_exception
@@ -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)
+5 -1
View File
@@ -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(",")
+36 -1
View File
@@ -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():
+2
View File
@@ -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
+133
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,
"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:
+2 -1
View File
@@ -9,7 +9,7 @@
import Settings from "./routes/Settings.svelte";
import Login from "./routes/Login.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 dark = $state(false);
@@ -60,6 +60,7 @@
function logout() {
setToken("");
setRefreshToken("");
window.location.reload();
}
</script>
+46 -6
View File
@@ -11,6 +11,42 @@ export function getToken() {
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 = {}) {
const headers = opts.headers || {};
if (token) headers["Authorization"] = `Bearer ${token}`;
@@ -22,15 +58,19 @@ async function request(path, opts = {}) {
}
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) {
// Session expired or invalid
setToken("");
// Only reload if not already on login page (simple check)
if (!location.pathname.includes("login") && !token) {
// Let the app handle redirect, but clearer is to throw
}
setRefreshToken("");
throw new Error("Unauthorized");
}
+106 -19
View File
@@ -1,5 +1,5 @@
<script>
import { login, setToken } from "../lib/api.js";
import { login, setToken, setRefreshToken, get, post } from "../lib/api.js";
import { fade, fly } from "svelte/transition";
let username = $state("");
@@ -8,6 +8,64 @@
let error = $state("");
let loading = $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) {
e.preventDefault();
@@ -21,6 +79,7 @@
const res = await login(payload); // api.js helper sends JSON
setToken(res.access_token);
setRefreshToken(res.refresh_token);
// Determine if we need to reload or just update state.
// Reload is safest to clear any stale state.
window.location.reload();
@@ -57,7 +116,35 @@
</p>
</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">
{#if !require2FA}
<div>
@@ -110,6 +197,22 @@
{/if}
</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}
<div class="rounded-md bg-red-50 dark:bg-red-900/30 p-3" in:fade>
<div class="flex">
@@ -124,22 +227,6 @@
</div>
</div>
{/if}
<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>
+96 -1
View File
@@ -1,5 +1,6 @@
<script>
import { post } from "../lib/api.js";
import { post, get } from "../lib/api.js";
import { onMount } from "svelte";
let currentPassword = $state("");
let newPassword = $state("");
@@ -8,6 +9,76 @@
let error = $state("");
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() {
error = "";
message = "";
@@ -59,4 +130,28 @@
</button>
</form>
</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>