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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user