Add security improvements: refresh tokens, passkeys, CSP, audit logging, TOTP encryption
- 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):
|
||||
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,13 +37,11 @@ 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
|
||||
username: str = payload.get("sub")
|
||||
if username is None or username != config.ADMIN_USER:
|
||||
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:
|
||||
raise credentials_exception
|
||||
return username
|
||||
@@ -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)
|
||||
|
||||
@@ -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(",")
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
from datetime import timedelta
|
||||
import asyncio
|
||||
import time
|
||||
import json
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
import jwt
|
||||
import config
|
||||
import auth
|
||||
import pyotp
|
||||
from webauthn import generate_registration_options, verify_registration_response, generate_authentication_options, verify_authentication_response
|
||||
from webauthn.helpers.structs import PublicKeyCredentialDescriptor, UserVerificationRequirement
|
||||
from webauthn.helpers import bytes_to_base64url, base64url_to_bytes, options_to_json
|
||||
|
||||
router = APIRouter()
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
@@ -19,10 +26,14 @@ class LoginRequest(BaseModel):
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str
|
||||
user: str
|
||||
has_2fa: bool
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
class Setup2FAResponse(BaseModel):
|
||||
secret: str
|
||||
uri: str
|
||||
@@ -87,8 +98,10 @@ async def login(creds: LoginRequest, request: Request):
|
||||
data={"sub": creds.username},
|
||||
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||
)
|
||||
refresh_token = auth.create_refresh_token(data={"sub": creds.username})
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": "bearer",
|
||||
"user": creds.username,
|
||||
"has_2fa": bool(totp_secret)
|
||||
@@ -99,6 +112,24 @@ async def read_users_me(current_user: str = Depends(auth.get_current_user)):
|
||||
totp_secret = auth.load_totp_secret()
|
||||
return {"username": current_user, "has_2fa": bool(totp_secret)}
|
||||
|
||||
@router.post("/refresh")
|
||||
async def refresh_token(req: RefreshRequest):
|
||||
try:
|
||||
payload = jwt.decode(req.refresh_token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
||||
if payload.get("type") != "refresh":
|
||||
raise HTTPException(status_code=401, detail="Invalid token type")
|
||||
username = payload.get("sub")
|
||||
if username != config.ADMIN_USER:
|
||||
raise HTTPException(status_code=401, detail="Invalid user")
|
||||
except jwt.PyJWTError:
|
||||
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
||||
access_token = auth.create_access_token(
|
||||
data={"sub": username},
|
||||
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||
)
|
||||
refresh_token = auth.create_refresh_token(data={"sub": username})
|
||||
return {"access_token": access_token, "refresh_token": refresh_token}
|
||||
|
||||
@router.post("/setup-2fa/generate", response_model=Setup2FAResponse)
|
||||
async def generate_2fa(current_user: str = Depends(auth.get_current_user)):
|
||||
secret = pyotp.random_base32()
|
||||
@@ -133,3 +164,105 @@ async def change_password(req: ChangePasswordRequest, request: Request, current_
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
||||
auth.save_password_hash(auth.get_password_hash(req.new_password))
|
||||
return {"message": "Password changed successfully"}
|
||||
|
||||
# WebAuthn — in-memory challenge store with TTL
|
||||
_challenges = {}
|
||||
|
||||
def _store_challenge(challenge: bytes):
|
||||
_challenges["current"] = (challenge, time.time())
|
||||
|
||||
def _get_challenge() -> bytes:
|
||||
entry = _challenges.pop("current", None)
|
||||
if not entry or time.time() - entry[1] > 300:
|
||||
raise HTTPException(status_code=400, detail="Challenge expired")
|
||||
return entry[0]
|
||||
|
||||
@router.post("/passkey/register/options")
|
||||
async def passkey_register_options(current_user: str = Depends(auth.get_current_user)):
|
||||
existing = auth.load_passkey_credentials()
|
||||
exclude = [PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["credential_id"])) for c in existing]
|
||||
options = generate_registration_options(
|
||||
rp_id=config.WEBAUTHN_RP_ID,
|
||||
rp_name="NAS Dashboard",
|
||||
user_id=config.ADMIN_USER.encode(),
|
||||
user_name=config.ADMIN_USER,
|
||||
user_display_name=config.ADMIN_USER,
|
||||
exclude_credentials=exclude,
|
||||
)
|
||||
_store_challenge(options.challenge)
|
||||
return JSONResponse(content=json.loads(options_to_json(options)))
|
||||
|
||||
@router.post("/passkey/register/verify")
|
||||
async def passkey_register_verify(request: Request, current_user: str = Depends(auth.get_current_user)):
|
||||
body = await request.json()
|
||||
challenge = _get_challenge()
|
||||
try:
|
||||
verification = verify_registration_response(
|
||||
credential=body,
|
||||
expected_challenge=challenge,
|
||||
expected_rp_id=config.WEBAUTHN_RP_ID,
|
||||
expected_origin=config.WEBAUTHN_ORIGIN,
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
auth.save_passkey_credential({
|
||||
"credential_id": bytes_to_base64url(verification.credential_id),
|
||||
"public_key": bytes_to_base64url(verification.credential_public_key),
|
||||
"sign_count": verification.sign_count,
|
||||
})
|
||||
return {"ok": True}
|
||||
|
||||
@router.post("/passkey/login/options")
|
||||
async def passkey_login_options():
|
||||
creds = auth.load_passkey_credentials()
|
||||
if not creds:
|
||||
raise HTTPException(status_code=404, detail="No passkeys registered")
|
||||
allow = [PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["credential_id"])) for c in creds]
|
||||
options = generate_authentication_options(
|
||||
rp_id=config.WEBAUTHN_RP_ID,
|
||||
allow_credentials=allow,
|
||||
user_verification=UserVerificationRequirement.PREFERRED,
|
||||
)
|
||||
_store_challenge(options.challenge)
|
||||
return JSONResponse(content=json.loads(options_to_json(options)))
|
||||
|
||||
@router.post("/passkey/login/verify")
|
||||
async def passkey_login_verify(request: Request):
|
||||
body = await request.json()
|
||||
challenge = _get_challenge()
|
||||
creds = auth.load_passkey_credentials()
|
||||
cred_id_b64 = body.get("id", "")
|
||||
matched = next((c for c in creds if c["credential_id"] == cred_id_b64), None)
|
||||
if not matched:
|
||||
raise HTTPException(status_code=400, detail="Unknown credential")
|
||||
try:
|
||||
verification = verify_authentication_response(
|
||||
credential=body,
|
||||
expected_challenge=challenge,
|
||||
expected_rp_id=config.WEBAUTHN_RP_ID,
|
||||
expected_origin=config.WEBAUTHN_ORIGIN,
|
||||
credential_public_key=base64url_to_bytes(matched["public_key"]),
|
||||
credential_current_sign_count=matched["sign_count"],
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
matched["sign_count"] = verification.new_sign_count
|
||||
data = auth._load_auth_data()
|
||||
data["passkey_credentials"] = creds
|
||||
auth._save_auth_data(data)
|
||||
username = config.ADMIN_USER
|
||||
access_token = auth.create_access_token(
|
||||
data={"sub": username},
|
||||
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||
)
|
||||
refresh_token = auth.create_refresh_token(data={"sub": username})
|
||||
return {"access_token": access_token, "refresh_token": refresh_token, "token_type": "bearer", "user": username}
|
||||
|
||||
@router.get("/passkey/list")
|
||||
async def passkey_list(current_user: str = Depends(auth.get_current_user)):
|
||||
return {"count": len(auth.load_passkey_credentials())}
|
||||
|
||||
@router.post("/passkey/clear")
|
||||
async def passkey_clear(current_user: str = Depends(auth.get_current_user)):
|
||||
auth.clear_passkey_credentials()
|
||||
return {"ok": True}
|
||||
|
||||
@@ -8,7 +8,7 @@ from config import VOLUME_ROOT
|
||||
router = APIRouter()
|
||||
BASE = Path(VOLUME_ROOT)
|
||||
|
||||
BLOCKED_DIRS = {"@appstore", "@docker", "@eaDir", "@S2S", "@sharesnap", "@tmp"}
|
||||
BLOCKED_DIRS = {"@appstore", "@docker", "@eaDir", "@S2S", "@sharesnap", "@tmp", "docker"}
|
||||
|
||||
|
||||
def _safe_path(path: str) -> Path:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user