mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-09-28 11:44:51 +02:00
Fix/auth fixes (#66)
* Fix session refresh * Hardened passkey implementation * Go to logout if session cannot be retrieved --------- Co-authored-by: Karl Ludwig Weise <ludwig@ludwigweise.de>
This commit is contained in:
co-authored by
Karl Ludwig Weise
parent
bf610cac2b
commit
1d4d8b888e
@@ -85,12 +85,12 @@
|
||||
"passkey": {
|
||||
"add": {
|
||||
"initial": "Warten auf E-Mail Adresse",
|
||||
"click": "Klicke/Tippe um einen Passkey hinzuzufügen",
|
||||
"click": "Tippe um Passkey hinzuzufügen",
|
||||
"loading": "Lade Passkey-Challenge",
|
||||
"user": "Warte auf Passkey-Erstellung",
|
||||
"success": "Passkey hinzugefügt",
|
||||
"error": "Passkey konnte nicht hinzugefügt werden",
|
||||
"retry": "Erneut versuchen"
|
||||
"error": "Passkey nicht hinzugefügt",
|
||||
"retry": "Wdh."
|
||||
}
|
||||
},
|
||||
"nav": {
|
||||
|
||||
@@ -30,7 +30,11 @@
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={onClick}
|
||||
class={cn("flex w-full justify-start", className)}
|
||||
class={cn(
|
||||
"flex w-full justify-start",
|
||||
state === "error" ? "dark:border-destructive border-destructive text-destructive" : "",
|
||||
className,
|
||||
)}
|
||||
disabled={disabledStates.includes(state)}
|
||||
>
|
||||
{#if state === "initial"}
|
||||
@@ -50,6 +54,9 @@
|
||||
{m["passkey.add.success"]()}
|
||||
{:else if state === "error"}
|
||||
<Error class="size-4" />
|
||||
{m["passkey.add.error"]()}
|
||||
<div class="flex w-full flex-wrap justify-between overflow-hidden">
|
||||
<div>{m["passkey.add.error"]()}</div>
|
||||
<div>{m["passkey.add.retry"]()}</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Button>
|
||||
|
||||
@@ -37,6 +37,14 @@ function createAuthStore() {
|
||||
user: undefined,
|
||||
});
|
||||
},
|
||||
isAuthenticated: () => {
|
||||
let authState: AuthState;
|
||||
const unsubscribe = store.subscribe((state) => {
|
||||
authState = state;
|
||||
});
|
||||
unsubscribe();
|
||||
return authState!.isAuthenticated;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+47
-32
@@ -1,3 +1,5 @@
|
||||
import logger from "$lib/logger";
|
||||
|
||||
export const arrayBufferToBase64 = (buffer: ArrayBuffer): string => {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
@@ -10,6 +12,16 @@ export const arrayBufferToBase64 = (buffer: ArrayBuffer): string => {
|
||||
return window.btoa(binary);
|
||||
};
|
||||
|
||||
export const base64UrlToArrayBuffer = (base64url: string) => {
|
||||
// Replace URL-safe characters and add padding
|
||||
const base64 = base64url
|
||||
.replace(/-/g, "+")
|
||||
.replace(/_/g, "/")
|
||||
.padEnd(base64url.length + ((4 - (base64url.length % 4)) % 4), "=");
|
||||
|
||||
return base64ToArrayBuffer(base64);
|
||||
};
|
||||
|
||||
export function base64ToArrayBuffer(base64: string) {
|
||||
const binaryString = atob(base64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
@@ -35,10 +47,41 @@ export const fetchChallenge = async (email: string) => {
|
||||
challenge: data.challenge,
|
||||
};
|
||||
} catch {
|
||||
logger.error("Failed to fetch challenge", { email, status: resp.status });
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const getCredentialOptions = ({
|
||||
id,
|
||||
challenge,
|
||||
email,
|
||||
}: {
|
||||
id: string;
|
||||
challenge: string;
|
||||
email: string;
|
||||
}): {
|
||||
publicKey: PublicKeyCredentialCreationOptions;
|
||||
} => {
|
||||
return {
|
||||
publicKey: {
|
||||
challenge: base64UrlToArrayBuffer(challenge),
|
||||
rp: {
|
||||
id,
|
||||
name: "Open Reception",
|
||||
},
|
||||
user: {
|
||||
id: new Uint8Array(16),
|
||||
name: email,
|
||||
displayName: email,
|
||||
},
|
||||
pubKeyCredParams: [
|
||||
{ alg: -7, type: "public-key" }, // ES256
|
||||
],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export type GeneratePasskeyResponse = {
|
||||
response: AuthenticatorAttestationResponse;
|
||||
id: string;
|
||||
@@ -53,22 +96,8 @@ export const generatePasskey = async ({
|
||||
challenge: string;
|
||||
email: string;
|
||||
}): Promise<GeneratePasskeyResponse> => {
|
||||
const publicKey: PublicKeyCredentialCreationOptions = {
|
||||
challenge: base64ToArrayBuffer(challenge),
|
||||
rp: {
|
||||
id,
|
||||
name: "Open Reception",
|
||||
},
|
||||
user: {
|
||||
id: new Uint8Array(16),
|
||||
name: email,
|
||||
displayName: email,
|
||||
},
|
||||
pubKeyCredParams: [
|
||||
{ alg: -7, type: "public-key" }, // ES256
|
||||
],
|
||||
};
|
||||
return (await navigator.credentials.create({ publicKey })) as GeneratePasskeyResponse;
|
||||
const options = getCredentialOptions({ id, challenge, email });
|
||||
return (await navigator.credentials.create(options)) as GeneratePasskeyResponse;
|
||||
};
|
||||
|
||||
export type GetCredentialResponse = PublicKeyCredential & {
|
||||
@@ -84,22 +113,8 @@ export const getCredential = async ({
|
||||
challenge: string;
|
||||
email: string;
|
||||
}) => {
|
||||
const publicKey: PublicKeyCredentialCreationOptions = {
|
||||
challenge: base64ToArrayBuffer(challenge),
|
||||
rp: {
|
||||
id,
|
||||
name: "Open Reception",
|
||||
},
|
||||
user: {
|
||||
id: new Uint8Array(16),
|
||||
name: email,
|
||||
displayName: email,
|
||||
},
|
||||
pubKeyCredParams: [
|
||||
{ alg: -7, type: "public-key" }, // ES256
|
||||
],
|
||||
};
|
||||
return (await navigator.credentials.get({ publicKey })) as GetCredentialResponse;
|
||||
const options = getCredentialOptions({ id, challenge, email });
|
||||
return (await navigator.credentials.get(options)) as GetCredentialResponse;
|
||||
};
|
||||
|
||||
export const getCounterFromAuthenticatorData = (authenticatorData: ArrayBuffer) => {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { ROUTES } from "$lib/const/routes";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
|
||||
export const refreshSession = async () => {
|
||||
if (!auth.isAuthenticated()) return;
|
||||
|
||||
auth.setRefreshing(true);
|
||||
|
||||
try {
|
||||
@@ -15,7 +17,6 @@ export const refreshSession = async () => {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Token is invalid, clear and redirect to login
|
||||
if (response.status === 401) {
|
||||
auth.setRefreshing(false);
|
||||
goto(ROUTES.LOGOUT);
|
||||
@@ -39,6 +40,13 @@ export const refreshUserData = async () => {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
goto(ROUTES.LOGOUT);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
auth.setUser(data.user);
|
||||
} catch (error) {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
const unsubscribe = () => {
|
||||
if (!intervalId) {
|
||||
refreshSession();
|
||||
intervalId = setInterval(refreshSession, 10 * 1000); // 10 minutes
|
||||
intervalId = setInterval(refreshSession, 10 * 60 * 1000); // 10 minutes
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import { arrayBufferToBase64, fetchChallenge, getCredential } from "$lib/utils/passkey";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import logger from "$lib/logger";
|
||||
|
||||
let {
|
||||
data,
|
||||
@@ -84,16 +85,19 @@
|
||||
|
||||
if (!challenge) {
|
||||
$passkeyLoading = "error";
|
||||
logger.error("Failed to fetch challenge", { email: $formData.email });
|
||||
} else {
|
||||
$passkeyLoading = "user";
|
||||
const credentialResp = await getCredential({ ...challenge, email: $formData.email }).catch(
|
||||
() => {
|
||||
(error) => {
|
||||
$passkeyLoading = "error";
|
||||
logger.error("Failed to get credential", { ...challenge, error });
|
||||
},
|
||||
);
|
||||
|
||||
if (!credentialResp) {
|
||||
$passkeyLoading = "error";
|
||||
logger.error("Credential response is falsy");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,15 @@
|
||||
import { PageWithClaim } from "$lib/components/ui/page";
|
||||
import { Skeleton } from "$lib/components/ui/skeleton";
|
||||
import { ROUTES } from "$lib/const/routes.js";
|
||||
import { auth } from "$lib/stores/auth.js";
|
||||
import Check from "@lucide/svelte/icons/check";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
export let data;
|
||||
|
||||
onMount(() => {
|
||||
auth.reset();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import type { PasskeyState } from "$lib/components/ui/passkey/state.svelte";
|
||||
import { arrayBufferToBase64, fetchChallenge, generatePasskey } from "$lib/utils/passkey";
|
||||
import logger from "$lib/logger";
|
||||
|
||||
let {
|
||||
data,
|
||||
@@ -90,17 +91,20 @@
|
||||
const challenge = await fetchChallenge($formData.email);
|
||||
|
||||
if (!challenge) {
|
||||
logger.error("Failed to fetch challenge", { email: $formData.email });
|
||||
$passkeyLoading = "error";
|
||||
} else {
|
||||
$passkeyLoading = "user";
|
||||
const passkeyResp = await generatePasskey({ ...challenge, email: $formData.email }).catch(
|
||||
() => {
|
||||
(error) => {
|
||||
$passkeyLoading = "error";
|
||||
logger.error("Failed to generate passkey", { ...challenge, error });
|
||||
},
|
||||
);
|
||||
|
||||
if (!passkeyResp) {
|
||||
$passkeyLoading = "error";
|
||||
logger.error("Passkey response is falsy");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -108,6 +112,7 @@
|
||||
const publicKey = passkeyResp.response.getPublicKey();
|
||||
if (!publicKey) {
|
||||
$passkeyLoading = "error";
|
||||
logger.error("Failed to get public key", { email: $formData.email });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user