From 8ffcf769052461e03a2c69220392cb37a623f3f4 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 27 Feb 2026 14:46:27 -0800 Subject: 2 step register --- src/lib/deriveAuthKey.ts | 5 +- src/lib/keyWrapping.ts | 61 ++++ src/lib/passkey.ts | 7 +- src/lib/utils.ts | 2 + src/lib/walletEntropy.ts | 10 +- src/routes/(app)/settings/+layout.svelte | 20 +- src/routes/(app)/settings/security/+page.svelte | 19 +- src/routes/(static)/login/+page.server.ts | 24 +- src/routes/(static)/login/+page.svelte | 91 +++-- src/routes/(static)/register/+page.server.ts | 107 +----- src/routes/(static)/register/+page.svelte | 374 ++------------------ src/routes/(static)/register/auth/+page.server.ts | 115 +++++++ src/routes/(static)/register/auth/+page.svelte | 393 ++++++++++++++++++++++ 13 files changed, 724 insertions(+), 504 deletions(-) create mode 100644 src/lib/keyWrapping.ts create mode 100644 src/routes/(static)/register/auth/+page.server.ts create mode 100644 src/routes/(static)/register/auth/+page.svelte (limited to 'src') diff --git a/src/lib/deriveAuthKey.ts b/src/lib/deriveAuthKey.ts index 9215b515..eb498e0a 100644 --- a/src/lib/deriveAuthKey.ts +++ b/src/lib/deriveAuthKey.ts @@ -1,7 +1,6 @@ import { scryptAsync } from "@noble/hashes/scrypt.js"; import { bytesToHex } from "@noble/hashes/utils.js"; import { getPublicKey } from "nostr-tools"; -import { rememberPrfKey, defaultRememberForMs } from "$lib/passwordCache"; export async function deriveAuthKeypair(username: string, password: string) { const salt = `coinos:auth:${username.toLowerCase().replace(/\s/g, "")}`; @@ -17,7 +16,5 @@ export async function deriveWalletEntropy(sk: Uint8Array): Promise const sigBytes = new Uint8Array(signed.sig.length / 2); for (let i = 0; i < signed.sig.length; i += 2) sigBytes[i / 2] = parseInt(signed.sig.slice(i, i + 2), 16); - const entropy = await crypto.subtle.digest("SHA-256", sigBytes); - rememberPrfKey(entropy, defaultRememberForMs); - return entropy; + return crypto.subtle.digest("SHA-256", sigBytes); } diff --git a/src/lib/keyWrapping.ts b/src/lib/keyWrapping.ts new file mode 100644 index 00000000..b176e741 --- /dev/null +++ b/src/lib/keyWrapping.ts @@ -0,0 +1,61 @@ +import { prfEncrypt, prfDecrypt } from "$lib/crypto"; +import { rememberPrfKey, defaultRememberForMs, getCachedPrfKey } from "$lib/passwordCache"; +import { post } from "$lib/utils"; + +export function generateCanonicalKey(): ArrayBuffer { + return crypto.getRandomValues(new Uint8Array(32)).buffer as ArrayBuffer; +} + +export async function wrapCanonicalKey( + wrappingKey: ArrayBuffer, + canonicalKey: ArrayBuffer, +): Promise { + return prfEncrypt(wrappingKey, new Uint8Array(canonicalKey)); +} + +export async function unwrapCanonicalKey( + wrappingKey: ArrayBuffer, + encrypted: string, +): Promise { + const bytes = await prfDecrypt(wrappingKey, encrypted); + return bytes.buffer as ArrayBuffer; +} + +export async function resolveCanonicalKey( + methodId: string, + wrappingKey: ArrayBuffer, + encryptedKeys: Record | null | undefined, + oldPrfKey: ArrayBuffer, +): Promise<{ canonicalKey: ArrayBuffer; encryptedKeys: Record }> { + if (encryptedKeys && encryptedKeys[methodId]) { + const canonicalKey = await unwrapCanonicalKey(wrappingKey, encryptedKeys[methodId]); + rememberPrfKey(canonicalKey, defaultRememberForMs); + return { canonicalKey, encryptedKeys }; + } + + // Migration: old PRF key becomes canonical key + const canonicalKey = oldPrfKey; + const wrapped = await wrapCanonicalKey(wrappingKey, canonicalKey); + const updated = { ...(encryptedKeys || {}), [methodId]: wrapped }; + await saveEncryptedKeys(updated); + rememberPrfKey(canonicalKey, defaultRememberForMs); + return { canonicalKey, encryptedKeys: updated }; +} + +export async function saveEncryptedKeys( + encryptedKeys: Record, +): Promise { + await post("/post/user", { encryptedKeys }); +} + +export function passwordMethodId(): string { + return "password"; +} + +export function passkeyMethodId(credentialId: string): string { + return `passkey:${credentialId}`; +} + +export function nostrMethodId(pubkey: string): string { + return `nostr:${pubkey}`; +} diff --git a/src/lib/passkey.ts b/src/lib/passkey.ts index 053eb06a..7c9e98cc 100644 --- a/src/lib/passkey.ts +++ b/src/lib/passkey.ts @@ -10,12 +10,15 @@ function authHeader(token: string) { return { authorization: `Bearer ${token}` }; } -export async function registerPasskey(token?: string): Promise { +export async function registerPasskey( + token?: string, +): Promise<{ prfKey: ArrayBuffer; credentialId: string }> { const headers = token ? authHeader(token) : undefined; const options = await post("/passkey/register/options", {}, headers); const registration = await startRegistration({ optionsJSON: options }); await post("/passkey/register/verify", registration, headers); - return deriveKey(registration.id); + const prfKey = await deriveKey(registration.id); + return { prfKey, credentialId: registration.id }; } export async function loginWithPasskey(): Promise<{ diff --git a/src/lib/utils.ts b/src/lib/utils.ts index eee6903e..e37556e3 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -214,6 +214,8 @@ export const login = async ( }); } catch {} } + + return u; }; export const auth = (cookies) => ({ diff --git a/src/lib/walletEntropy.ts b/src/lib/walletEntropy.ts index 25bd49b8..95b3e903 100644 --- a/src/lib/walletEntropy.ts +++ b/src/lib/walletEntropy.ts @@ -1,5 +1,5 @@ import { browser } from "$app/environment"; -import { getCachedPrfKey, rememberPrfKey, defaultRememberForMs } from "$lib/passwordCache"; +import { getCachedPrfKey } from "$lib/passwordCache"; const DERIVATION_EVENT = { kind: 1, @@ -27,9 +27,7 @@ export async function tryDeriveFromNsec(): Promise { const { nip19, finalizeEvent } = await import("nostr-tools"); const sk = nip19.decode(nsec).data as Uint8Array; const signed = finalizeEvent({ ...DERIVATION_EVENT } as any, sk); - const entropy = await hashSignature(signed.sig); - rememberPrfKey(entropy, defaultRememberForMs); - return entropy; + return await hashSignature(signed.sig); } catch (e) { console.log("nsec entropy derivation failed", e); return null; @@ -60,7 +58,5 @@ export async function deriveNostrEntropy(): Promise { const { sign } = await import("$lib/nostr"); const signed = await sign({ ...DERIVATION_EVENT }); - const entropy = await hashSignature(signed.sig); - rememberPrfKey(entropy, defaultRememberForMs); - return entropy; + return hashSignature(signed.sig); } diff --git a/src/routes/(app)/settings/+layout.svelte b/src/routes/(app)/settings/+layout.svelte index ce45f915..99bdc4ce 100644 --- a/src/routes/(app)/settings/+layout.svelte +++ b/src/routes/(app)/settings/+layout.svelte @@ -13,6 +13,8 @@ import { sign, send, getPrivateKey } from "$lib/nostr"; import { invalidateAll } from "$app/navigation"; import { bytesToHex } from "@noble/hashes/utils.js"; + import { getCachedPrfKey } from "$lib/passwordCache"; + import { wrapCanonicalKey, saveEncryptedKeys, passwordMethodId } from "$lib/keyWrapping"; import { SimplePool } from "nostr-tools/pool"; import { finalizeEvent } from "nostr-tools/pure"; @@ -201,14 +203,21 @@ } } - // If password is changing, derive new authPubkey + // If password is changing, derive new authPubkey and re-wrap canonical key const pw = body.get("password") as string; const un = (body.get("username") as string) || user.username; + let pendingEncryptedKeys: Record | null = null; if (pw && pw === body.get("confirm")) { try { const { deriveAuthKeypair } = await import("$lib/deriveAuthKey"); - const { pubkey: authPubkey } = await deriveAuthKeypair(un, pw); + const { sk, pubkey: authPubkey } = await deriveAuthKeypair(un, pw); body.set("authPubkey", authPubkey); + + const canonicalKey = getCachedPrfKey(); + if (canonicalKey) { + const wrapped = await wrapCanonicalKey(sk.buffer as ArrayBuffer, canonicalKey); + pendingEncryptedKeys = { ...(user.encryptedKeys || {}), [passwordMethodId()]: wrapped }; + } } catch (e) { console.log("Failed to derive authPubkey for password change", e); } @@ -222,6 +231,13 @@ const result = deserialize(await response.text()); if (result.type === "success") { + if (pendingEncryptedKeys) { + try { + await saveEncryptedKeys(pendingEncryptedKeys); + } catch (e) { + console.log("Failed to save re-wrapped encryptedKeys", e); + } + } await invalidateAll(); if (pw) $password = pw; } diff --git a/src/routes/(app)/settings/security/+page.svelte b/src/routes/(app)/settings/security/+page.svelte index 0991c679..9ab16ded 100644 --- a/src/routes/(app)/settings/security/+page.svelte +++ b/src/routes/(app)/settings/security/+page.svelte @@ -9,7 +9,12 @@ import { save, pin as current } from "$lib/store"; import { getNsec } from "$lib/nostr"; import { registerPasskey } from "$lib/passkey"; - import { rememberPrfKey, defaultRememberForMs } from "$lib/passwordCache"; + import { getCachedPrfKey, rememberPrfKey, defaultRememberForMs } from "$lib/passwordCache"; + import { + wrapCanonicalKey, + saveEncryptedKeys, + passkeyMethodId, + } from "$lib/keyWrapping"; import { invalidate } from "$app/navigation"; import { page } from "$app/stores"; @@ -155,8 +160,16 @@ let addPasskey = async () => { passkeyLoading = true; try { - const prfKey = await registerPasskey(); - rememberPrfKey(prfKey, defaultRememberForMs); + const { prfKey, credentialId } = await registerPasskey(); + const canonicalKey = getCachedPrfKey(); + if (canonicalKey) { + const wrapped = await wrapCanonicalKey(prfKey, canonicalKey); + const updated = { ...(user.encryptedKeys || {}), [passkeyMethodId(credentialId)]: wrapped }; + await saveEncryptedKeys(updated); + user.encryptedKeys = updated; + } else { + rememberPrfKey(prfKey, defaultRememberForMs); + } success("Passkey added"); } catch (e: any) { if (e.name !== "NotAllowedError") { diff --git a/src/routes/(static)/login/+page.server.ts b/src/routes/(static)/login/+page.server.ts index eb0bf079..68143d83 100644 --- a/src/routes/(static)/login/+page.server.ts +++ b/src/routes/(static)/login/+page.server.ts @@ -26,18 +26,21 @@ export const actions = { if (loginRedirect === "undefined") loginRedirect = undefined; try { - await login( + const u = await login( user, cookies, request.headers.get("cf-connecting-ip") ?? "", request.headers.get("host") ?? "", ); + + return { + encryptedKeys: u?.encryptedKeys || null, + redirectUrl: loginRedirect || `/${user.username}`, + }; } catch (e) { const { message } = e as Error; return fail(400, { error: "Login failed", message, ...form }); } - - redirect(307, loginRedirect || `/${user.username}`); }, passwordAuth: async ({ cookies, fetch, request }) => { @@ -88,7 +91,10 @@ export const actions = { cookies.set("username", uname, opts); cookies.set("token", token, opts); - redirect(307, loginRedirect || `/${uname}`); + return { + encryptedKeys: user.encryptedKeys || null, + redirectUrl: loginRedirect || `/${uname}`, + }; }, passkey: async ({ cookies, fetch, request }) => { @@ -134,7 +140,10 @@ export const actions = { cookies.set("username", username, opts); cookies.set("token", token, opts); - redirect(307, loginRedirect || `/${username}`); + return { + encryptedKeys: user.encryptedKeys || null, + redirectUrl: loginRedirect || `/${username}`, + }; }, nostr: async ({ cookies, fetch, request }) => { @@ -175,6 +184,9 @@ export const actions = { cookies.set("username", username, opts); cookies.set("token", token, opts); - redirect(307, loginRedirect || `/${username}`); + return { + encryptedKeys: user.encryptedKeys || null, + redirectUrl: loginRedirect || `/${username}`, + }; }, }; diff --git a/src/routes/(static)/login/+page.svelte b/src/routes/(static)/login/+page.svelte index 6d7a6e19..96a03ef5 100644 --- a/src/routes/(static)/login/+page.svelte +++ b/src/routes/(static)/login/+page.svelte @@ -11,15 +11,17 @@ import Spinner from "$comp/Spinner.svelte"; import { focus, fail } from "$lib/utils"; import { loginWithPasskey } from "$lib/passkey"; - import { rememberPrfKey, defaultRememberForMs } from "$lib/passwordCache"; import { password, signer, pin, loginRedirect } from "$lib/store"; + import { + resolveCanonicalKey, + passwordMethodId, + passkeyMethodId, + nostrMethodId, + } from "$lib/keyWrapping"; import { t } from "$lib/translations"; import { page } from "$app/stores"; import { sign } from "$lib/nostr"; - import { invalidateAll } from "$app/navigation"; - import { sha256 } from "@noble/hashes/sha2.js"; - import { bytesToHex } from "@noble/hashes/utils.js"; - + import { goto, invalidateAll } from "$app/navigation"; let { data, form }: any = $props(); onMount(() => { @@ -134,18 +136,25 @@ body: fd, }); - const result = deserialize(await response.text()); + const result: any = deserialize(await response.text()); - if (result.type === "success" || result.type === "redirect") { + if (result.type === "success") { try { - await deriveWalletEntropy(sk); + const oldPrfKey = await deriveWalletEntropy(sk); + await resolveCanonicalKey( + passwordMethodId(), + sk.buffer as ArrayBuffer, + result.data?.encryptedKeys, + oldPrfKey, + ); } catch (e) { - console.log("Wallet entropy derivation failed", e); + console.log("Canonical key resolution failed", e); } await invalidateAll(); + goto(result.data?.redirectUrl || `/${uname}`); + } else { + applyAction(result); } - - applyAction(result); return; } @@ -153,24 +162,33 @@ formData.set("authPubkey", pubkey); return async ({ result }: any) => { - if (result.type === "success" || result.type === "redirect") { + if (result.type === "success") { try { - await deriveWalletEntropy(sk); + const oldPrfKey = await deriveWalletEntropy(sk); + await resolveCanonicalKey( + passwordMethodId(), + sk.buffer as ArrayBuffer, + result.data?.encryptedKeys, + oldPrfKey, + ); } catch (e) { - console.log("Wallet entropy derivation failed", e); + console.log("Canonical key resolution failed", e); } await invalidateAll(); + goto(result.data?.redirectUrl || `/${uname}`); + } else { + applyAction(result); } - applyAction(result); }; } catch (e: any) { console.log("Auth key derivation failed, falling back to legacy login", e); - // Fallback: plain password login without migration return async ({ result }: any) => { if (result.type === "success") { await invalidateAll(); + goto(result.data?.redirectUrl || "/"); + } else { + applyAction(result); } - applyAction(result); }; } }; @@ -201,14 +219,24 @@ body: formData, }); - const result = deserialize(await response.text()); + const result: any = deserialize(await response.text()); - if (result.type === "success" || result.type === "redirect") { - rememberPrfKey(prfKey, defaultRememberForMs); + if (result.type === "success") { + try { + await resolveCanonicalKey( + passkeyMethodId(credential.id), + prfKey, + result.data?.encryptedKeys, + prfKey, + ); + } catch (e) { + console.log("Canonical key resolution failed", e); + } await invalidateAll(); + goto(result.data?.redirectUrl || "/"); + } else { + applyAction(result); } - - applyAction(result); } catch (e: any) { if (e.name === "NotAllowedError") { // user cancelled the browser prompt @@ -248,19 +276,26 @@ body: formData, }); - const result = deserialize(await response.text()); + const result: any = deserialize(await response.text()); - if (result.type === "success" || result.type === "redirect") { + if (result.type === "success") { try { const { deriveNostrEntropy } = await import("$lib/walletEntropy"); - await deriveNostrEntropy(); + const oldPrfKey = await deriveNostrEntropy(); + await resolveCanonicalKey( + nostrMethodId(signedEvent.pubkey), + oldPrfKey, + result.data?.encryptedKeys, + oldPrfKey, + ); } catch (e) { - console.log("Nostr entropy derivation failed", e); + console.log("Nostr canonical key resolution failed", e); } await invalidateAll(); + goto(result.data?.redirectUrl || "/"); + } else { + applyAction(result); } - - applyAction(result); } catch (e: any) { fail(e.message); } diff --git a/src/routes/(static)/register/+page.server.ts b/src/routes/(static)/register/+page.server.ts index 55331c5a..e56f4e14 100644 --- a/src/routes/(static)/register/+page.server.ts +++ b/src/routes/(static)/register/+page.server.ts @@ -1,113 +1,10 @@ -import { fd, get, login, post } from "$lib/utils"; -import { fail, redirect } from "@sveltejs/kit"; +import { redirect } from "@sveltejs/kit"; export const load = async ({ parent }) => { const { user } = await parent(); if (user) redirect(307, `/${user.username}`); const index = Math.floor(Math.random() * 64) + 1; - const { challenge } = await get("/challenge"); - return { index, challenge }; -}; - -export const actions = { - register: async ({ cookies, request }) => { - const ip = request.headers.get("cf-connecting-ip") ?? ""; - const host = request.headers.get("host") ?? ""; - const form = await fd(request); - const { picture, username, password, challenge, recaptcha, authPubkey } = form; - let { loginRedirect } = form; - if (loginRedirect === "undefined") loginRedirect = undefined; - - const user = { - picture, - username, - password, - challenge, - recaptcha, - authPubkey: authPubkey || undefined, - }; - - const headers: Record = {}; - if (ip) headers["cf-connecting-ip"] = ip; - - try { - const { sk, token } = await post("/register", { user }, headers); - - // Log the user in and set cookies - await login(user, cookies, ip, host); - - // Set sk cookie if server generated one - if (sk) { - const skExpires = new Date(); - skExpires.setSeconds(skExpires.getSeconds() + 21000000); - cookies.set("sk", sk, { - path: "/", - expires: skExpires, - httpOnly: false, - sameSite: "lax", - }); - } - } catch (e) { - const { message } = e as Error; - return fail(400, { error: message }); - } - - redirect(303, loginRedirect || `/${username}`); - }, - - passkeyCreate: async ({ request }) => { - const ip = request.headers.get("cf-connecting-ip") ?? ""; - const form = await fd(request); - const { username, password, challenge, recaptcha, picture } = form; - - const user = { picture, username, password, challenge, recaptcha }; - const headers: Record = {}; - if (ip) headers["cf-connecting-ip"] = ip; - - try { - const { sk, token } = await post("/register", { user }, headers); - return { token, username, sk }; - } catch (e) { - const { message } = e as Error; - return fail(400, { error: message }); - } - }, - - activate: async ({ cookies, request }) => { - const form = await fd(request); - const { token, username, sk, pubkey } = form; - let { loginRedirect } = form; - if (loginRedirect === "undefined") loginRedirect = undefined; - - const maxAge = 380 * 24 * 60 * 60; - const expires = new Date(); - expires.setSeconds(expires.getSeconds() + maxAge); - const opts = { path: "/", expires }; - - cookies.set("username", username, opts); - cookies.set("token", token, opts); - - if (sk) { - const skExpires = new Date(); - skExpires.setSeconds(skExpires.getSeconds() + 21000000); - cookies.set("sk", sk, { - path: "/", - expires: skExpires, - httpOnly: false, - sameSite: "lax", - }); - } - - if (pubkey) { - try { - await post("/user", { pubkey }, { authorization: `Bearer ${token}` }); - } catch (e) { - console.log("Failed to update pubkey:", e); - } - } - - redirect(303, loginRedirect || `/${username}`); - }, + return { index }; }; diff --git a/src/routes/(static)/register/+page.svelte b/src/routes/(static)/register/+page.svelte index e2d53fd0..0d32cf21 100644 --- a/src/routes/(static)/register/+page.svelte +++ b/src/routes/(static)/register/+page.svelte @@ -1,57 +1,18 @@ -{#if need2fa} - -{/if} -

{$t("login.createAccount")}

- handleFile(e)} /> + handleFile(e)} />
-
- +
+ + Upload
- {#if form?.error || form?.message} -
{form?.message}{form?.error}
- {/if} - -
- - - - -
diff --git a/src/routes/(static)/register/auth/+page.server.ts b/src/routes/(static)/register/auth/+page.server.ts new file mode 100644 index 00000000..06735396 --- /dev/null +++ b/src/routes/(static)/register/auth/+page.server.ts @@ -0,0 +1,115 @@ +import { fd, get, login, post } from "$lib/utils"; +import { fail, redirect } from "@sveltejs/kit"; + +export const load = async ({ parent, url }) => { + const { user } = await parent(); + if (user) redirect(307, `/${user.username}`); + + const username = url.searchParams.get("username"); + if (!username) redirect(307, "/register"); + + const { challenge } = await get("/challenge"); + + return { username, challenge }; +}; + +export const actions = { + register: async ({ cookies, request }) => { + const ip = request.headers.get("cf-connecting-ip") ?? ""; + const host = request.headers.get("host") ?? ""; + const form = await fd(request); + const { picture, username, password, challenge, recaptcha, authPubkey } = form; + let { loginRedirect } = form; + if (loginRedirect === "undefined") loginRedirect = undefined; + + const user = { + picture, + username, + password, + challenge, + recaptcha, + authPubkey: authPubkey || undefined, + }; + + const headers: Record = {}; + if (ip) headers["cf-connecting-ip"] = ip; + + try { + const { sk, token } = await post("/register", { user }, headers); + + // Log the user in and set cookies + await login(user, cookies, ip, host); + + // Set sk cookie if server generated one + if (sk) { + const skExpires = new Date(); + skExpires.setSeconds(skExpires.getSeconds() + 21000000); + cookies.set("sk", sk, { + path: "/", + expires: skExpires, + httpOnly: false, + sameSite: "lax", + }); + } + } catch (e) { + const { message } = e as Error; + return fail(400, { error: message }); + } + + redirect(303, loginRedirect || `/${username}`); + }, + + passkeyCreate: async ({ request }) => { + const ip = request.headers.get("cf-connecting-ip") ?? ""; + const form = await fd(request); + const { username, password, challenge, recaptcha, picture } = form; + + const user = { picture, username, password, challenge, recaptcha }; + const headers: Record = {}; + if (ip) headers["cf-connecting-ip"] = ip; + + try { + const { sk, token } = await post("/register", { user }, headers); + return { token, username, sk }; + } catch (e) { + const { message } = e as Error; + return fail(400, { error: message }); + } + }, + + activate: async ({ cookies, request }) => { + const form = await fd(request); + const { token, username, sk, pubkey } = form; + let { loginRedirect } = form; + if (loginRedirect === "undefined") loginRedirect = undefined; + + const maxAge = 380 * 24 * 60 * 60; + const expires = new Date(); + expires.setSeconds(expires.getSeconds() + maxAge); + const opts = { path: "/", expires }; + + cookies.set("username", username, opts); + cookies.set("token", token, opts); + + if (sk) { + const skExpires = new Date(); + skExpires.setSeconds(skExpires.getSeconds() + 21000000); + cookies.set("sk", sk, { + path: "/", + expires: skExpires, + httpOnly: false, + sameSite: "lax", + }); + } + + if (pubkey) { + try { + await post("/user", { pubkey }, { authorization: `Bearer ${token}` }); + } catch (e) { + console.log("Failed to update pubkey:", e); + } + } + + redirect(303, loginRedirect || `/${username}`); + }, +}; diff --git a/src/routes/(static)/register/auth/+page.svelte b/src/routes/(static)/register/auth/+page.svelte new file mode 100644 index 00000000..d285e0af --- /dev/null +++ b/src/routes/(static)/register/auth/+page.svelte @@ -0,0 +1,393 @@ + + +{#if need2fa} + +{/if} + +
+

{$t("login.createAccount")}

+ +
+
+ {username} +
+ {username} +
+ + {#if form?.error || form?.message} +
{form?.message}{form?.error}
+ {/if} + +
+ + + + + + + + + +
+
+ or +
+
+ + + + + +

+ {$t("login.haveAccount")} + + {$t("login.signIn")} + +

+ + + + Back + + +
-- cgit v1.2.3