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:
Gan, Jimmy
2026-02-22 10:54:23 +08:00
parent a10cb50ca0
commit be1f3b3ad6
10 changed files with 484 additions and 46 deletions
+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>