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): 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,12 +37,10 @@ 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 raise credentials_exception
# In a real app, we'd check DB here. username: str = payload.get("sub")
# For this single-user system, we just check if it matches config. if username is None or username != config.ADMIN_USER:
if username != config.ADMIN_USER:
raise credentials_exception raise credentials_exception
except jwt.PyJWTError: except jwt.PyJWTError:
raise credentials_exception raise credentials_exception
@@ -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)
+5 -1
View File
@@ -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(",")
+36 -1
View File
@@ -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():
+2
View File
@@ -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
+133
View File
@@ -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}
+1 -1
View File
@@ -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:
+2 -1
View File
@@ -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>
+46 -6
View File
@@ -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");
} }
+105 -18
View File
@@ -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>
<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> </div>
</form>
</div> </div>
</div> </div>
+96 -1
View File
@@ -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>