create-nuxt-base 1.1.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/nuxt-base-template/app/composables/use-better-auth.ts +218 -0
- package/nuxt-base-template/app/lib/auth-client.ts +11 -2
- package/nuxt-base-template/app/pages/app/index.vue +101 -0
- package/nuxt-base-template/app/pages/app/settings/security.vue +30 -13
- package/nuxt-base-template/app/pages/auth/login.vue +6 -1
- package/nuxt-base-template/app/pages/auth/register.vue +49 -77
- package/nuxt-base-template/app/utils/crypto.ts +31 -0
- package/nuxt-base-template/server/api/iam/[...path].ts +65 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
|
4
4
|
|
|
5
|
+
### [1.1.1](https://github.com/lenneTech/nuxt-base-starter/compare/v1.1.0...v1.1.1) (2026-01-20)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **auth:** auto-login after registration and improved passkey handling ([625128b](https://github.com/lenneTech/nuxt-base-starter/commit/625128b18fe812c141859946c196f8efb0738dca))
|
|
11
|
+
* **auth:** improve 2FA UX and document dev-mode proxy requirements ([4c4b1f4](https://github.com/lenneTech/nuxt-base-starter/commit/4c4b1f4d8b77fa93469ccc1a31d4f3292cc7c724))
|
|
12
|
+
|
|
5
13
|
## [1.1.0](https://github.com/lenneTech/nuxt-base-starter/compare/v1.0.3...v1.1.0) (2026-01-20)
|
|
6
14
|
|
|
7
15
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { authClient } from '~/lib/auth-client';
|
|
2
|
+
import { arrayBufferToBase64Url, base64UrlToUint8Array } from '~/utils/crypto';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* User type for Better Auth session
|
|
@@ -19,6 +20,15 @@ interface StoredAuthState {
|
|
|
19
20
|
user: BetterAuthUser | null;
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Result of passkey authentication
|
|
25
|
+
*/
|
|
26
|
+
interface PasskeyAuthResult {
|
|
27
|
+
error?: string;
|
|
28
|
+
success: boolean;
|
|
29
|
+
user?: BetterAuthUser;
|
|
30
|
+
}
|
|
31
|
+
|
|
22
32
|
/**
|
|
23
33
|
* Better Auth composable with client-side state management
|
|
24
34
|
*
|
|
@@ -36,6 +46,13 @@ export function useBetterAuth() {
|
|
|
36
46
|
sameSite: 'lax',
|
|
37
47
|
});
|
|
38
48
|
|
|
49
|
+
// Auth token cookie (for 2FA sessions where no session cookie is set)
|
|
50
|
+
const authToken = useCookie<string | null>('auth-token', {
|
|
51
|
+
default: () => null,
|
|
52
|
+
maxAge: 60 * 60 * 24 * 7, // 7 days
|
|
53
|
+
sameSite: 'lax',
|
|
54
|
+
});
|
|
55
|
+
|
|
39
56
|
// Loading state
|
|
40
57
|
const isLoading = ref<boolean>(false);
|
|
41
58
|
|
|
@@ -173,7 +190,207 @@ export function useBetterAuth() {
|
|
|
173
190
|
}
|
|
174
191
|
};
|
|
175
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Authenticate with a passkey (WebAuthn)
|
|
195
|
+
*
|
|
196
|
+
* This function handles the complete WebAuthn authentication flow:
|
|
197
|
+
* 1. Fetches authentication options from the server
|
|
198
|
+
* 2. Prompts the user to select a passkey via the browser's WebAuthn API
|
|
199
|
+
* 3. Sends the signed credential to the server for verification
|
|
200
|
+
* 4. Stores user data on successful authentication
|
|
201
|
+
*
|
|
202
|
+
* @returns Result with success status, user data, or error message
|
|
203
|
+
*/
|
|
204
|
+
async function authenticateWithPasskey(): Promise<PasskeyAuthResult> {
|
|
205
|
+
isLoading.value = true;
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
// Direct API access (CORS is configured on the server for trusted origins)
|
|
209
|
+
const runtimeConfig = useRuntimeConfig();
|
|
210
|
+
const apiUrl = runtimeConfig.public.apiUrl || 'http://localhost:3000';
|
|
211
|
+
|
|
212
|
+
// Step 1: Get authentication options from server
|
|
213
|
+
const optionsResponse = await fetch(`${apiUrl}/iam/passkey/generate-authenticate-options`, {
|
|
214
|
+
method: 'GET',
|
|
215
|
+
credentials: 'include',
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
if (!optionsResponse.ok) {
|
|
219
|
+
return { success: false, error: 'Konnte Passkey-Optionen nicht laden' };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const options = await optionsResponse.json();
|
|
223
|
+
|
|
224
|
+
// Step 2: Convert challenge from base64url to ArrayBuffer
|
|
225
|
+
const challengeBuffer = base64UrlToUint8Array(options.challenge).buffer as ArrayBuffer;
|
|
226
|
+
|
|
227
|
+
// Step 3: Get credential from browser's WebAuthn API
|
|
228
|
+
const credential = (await navigator.credentials.get({
|
|
229
|
+
publicKey: {
|
|
230
|
+
challenge: challengeBuffer,
|
|
231
|
+
rpId: options.rpId,
|
|
232
|
+
allowCredentials: options.allowCredentials || [],
|
|
233
|
+
userVerification: options.userVerification,
|
|
234
|
+
timeout: options.timeout,
|
|
235
|
+
},
|
|
236
|
+
})) as PublicKeyCredential | null;
|
|
237
|
+
|
|
238
|
+
if (!credential) {
|
|
239
|
+
return { success: false, error: 'Kein Passkey ausgewählt' };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Step 4: Convert credential response to base64url format
|
|
243
|
+
const response = credential.response as AuthenticatorAssertionResponse;
|
|
244
|
+
const credentialBody = {
|
|
245
|
+
id: credential.id,
|
|
246
|
+
rawId: arrayBufferToBase64Url(credential.rawId),
|
|
247
|
+
type: credential.type,
|
|
248
|
+
response: {
|
|
249
|
+
authenticatorData: arrayBufferToBase64Url(response.authenticatorData),
|
|
250
|
+
clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
|
|
251
|
+
signature: arrayBufferToBase64Url(response.signature),
|
|
252
|
+
userHandle: response.userHandle ? arrayBufferToBase64Url(response.userHandle) : null,
|
|
253
|
+
},
|
|
254
|
+
clientExtensionResults: credential.getClientExtensionResults?.() || {},
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// Step 5: Verify with server
|
|
258
|
+
const authResponse = await fetch(`${apiUrl}/iam/passkey/verify-authentication`, {
|
|
259
|
+
method: 'POST',
|
|
260
|
+
credentials: 'include',
|
|
261
|
+
headers: { 'Content-Type': 'application/json' },
|
|
262
|
+
body: JSON.stringify(credentialBody),
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const result = await authResponse.json();
|
|
266
|
+
|
|
267
|
+
if (!authResponse.ok) {
|
|
268
|
+
return { success: false, error: result.message || 'Passkey-Anmeldung fehlgeschlagen' };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Store user data after successful passkey login
|
|
272
|
+
if (result.user) {
|
|
273
|
+
setUser(result.user as BetterAuthUser);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return { success: true, user: result.user as BetterAuthUser };
|
|
277
|
+
} catch (err: unknown) {
|
|
278
|
+
// Handle WebAuthn-specific errors
|
|
279
|
+
if (err instanceof Error && err.name === 'NotAllowedError') {
|
|
280
|
+
return { success: false, error: 'Passkey-Authentifizierung wurde abgebrochen' };
|
|
281
|
+
}
|
|
282
|
+
return { success: false, error: err instanceof Error ? err.message : 'Passkey-Anmeldung fehlgeschlagen' };
|
|
283
|
+
} finally {
|
|
284
|
+
isLoading.value = false;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Register a new passkey for the current user
|
|
290
|
+
*
|
|
291
|
+
* This function handles the complete WebAuthn registration flow:
|
|
292
|
+
* 1. Fetches registration options from the server
|
|
293
|
+
* 2. Prompts the user to create a passkey via the browser's WebAuthn API
|
|
294
|
+
* 3. Sends the credential to the server for storage
|
|
295
|
+
*
|
|
296
|
+
* @param name - Optional name for the passkey
|
|
297
|
+
* @returns Result with success status or error message
|
|
298
|
+
*/
|
|
299
|
+
async function registerPasskey(name?: string): Promise<{ success: boolean; error?: string; passkey?: any }> {
|
|
300
|
+
isLoading.value = true;
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
// Direct API access (CORS is configured on the server for trusted origins)
|
|
304
|
+
const runtimeConfig = useRuntimeConfig();
|
|
305
|
+
const apiUrl = runtimeConfig.public.apiUrl || 'http://localhost:3000';
|
|
306
|
+
|
|
307
|
+
// Step 1: Get registration options from server
|
|
308
|
+
const optionsResponse = await fetch(`${apiUrl}/iam/passkey/generate-register-options`, {
|
|
309
|
+
method: 'GET',
|
|
310
|
+
credentials: 'include',
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
if (!optionsResponse.ok) {
|
|
314
|
+
const error = await optionsResponse.json().catch(() => ({}));
|
|
315
|
+
return { success: false, error: error.message || 'Konnte Registrierungsoptionen nicht laden' };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const options = await optionsResponse.json();
|
|
319
|
+
|
|
320
|
+
// Step 2: Convert challenge from base64url to ArrayBuffer
|
|
321
|
+
const challengeBuffer = base64UrlToUint8Array(options.challenge).buffer as ArrayBuffer;
|
|
322
|
+
|
|
323
|
+
// Step 3: Convert user.id from base64url to ArrayBuffer
|
|
324
|
+
const userIdBuffer = base64UrlToUint8Array(options.user.id).buffer as ArrayBuffer;
|
|
325
|
+
|
|
326
|
+
// Step 4: Create credential via browser's WebAuthn API
|
|
327
|
+
const credential = (await navigator.credentials.create({
|
|
328
|
+
publicKey: {
|
|
329
|
+
challenge: challengeBuffer,
|
|
330
|
+
rp: options.rp,
|
|
331
|
+
user: {
|
|
332
|
+
...options.user,
|
|
333
|
+
id: userIdBuffer,
|
|
334
|
+
},
|
|
335
|
+
pubKeyCredParams: options.pubKeyCredParams,
|
|
336
|
+
timeout: options.timeout,
|
|
337
|
+
attestation: options.attestation,
|
|
338
|
+
authenticatorSelection: options.authenticatorSelection,
|
|
339
|
+
excludeCredentials: (options.excludeCredentials || []).map((cred: any) => ({
|
|
340
|
+
...cred,
|
|
341
|
+
id: base64UrlToUint8Array(cred.id).buffer as ArrayBuffer,
|
|
342
|
+
})),
|
|
343
|
+
},
|
|
344
|
+
})) as PublicKeyCredential | null;
|
|
345
|
+
|
|
346
|
+
if (!credential) {
|
|
347
|
+
return { success: false, error: 'Passkey-Erstellung abgebrochen' };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Step 5: Convert credential response to base64url format
|
|
351
|
+
const attestationResponse = credential.response as AuthenticatorAttestationResponse;
|
|
352
|
+
const credentialBody = {
|
|
353
|
+
name,
|
|
354
|
+
response: {
|
|
355
|
+
id: credential.id,
|
|
356
|
+
rawId: arrayBufferToBase64Url(credential.rawId),
|
|
357
|
+
type: credential.type,
|
|
358
|
+
response: {
|
|
359
|
+
attestationObject: arrayBufferToBase64Url(attestationResponse.attestationObject),
|
|
360
|
+
clientDataJSON: arrayBufferToBase64Url(attestationResponse.clientDataJSON),
|
|
361
|
+
transports: attestationResponse.getTransports?.() || [],
|
|
362
|
+
},
|
|
363
|
+
clientExtensionResults: credential.getClientExtensionResults?.() || {},
|
|
364
|
+
},
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
// Step 6: Send to server for verification and storage
|
|
368
|
+
const registerResponse = await fetch(`${apiUrl}/iam/passkey/verify-registration`, {
|
|
369
|
+
method: 'POST',
|
|
370
|
+
credentials: 'include',
|
|
371
|
+
headers: { 'Content-Type': 'application/json' },
|
|
372
|
+
body: JSON.stringify(credentialBody),
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
const result = await registerResponse.json();
|
|
376
|
+
|
|
377
|
+
if (!registerResponse.ok) {
|
|
378
|
+
return { success: false, error: result.message || 'Passkey-Registrierung fehlgeschlagen' };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return { success: true, passkey: result };
|
|
382
|
+
} catch (err: unknown) {
|
|
383
|
+
if (err instanceof Error && err.name === 'NotAllowedError') {
|
|
384
|
+
return { success: false, error: 'Passkey-Erstellung wurde abgebrochen' };
|
|
385
|
+
}
|
|
386
|
+
return { success: false, error: err instanceof Error ? err.message : 'Passkey-Registrierung fehlgeschlagen' };
|
|
387
|
+
} finally {
|
|
388
|
+
isLoading.value = false;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
176
392
|
return {
|
|
393
|
+
authenticateWithPasskey,
|
|
177
394
|
changePassword: authClient.changePassword,
|
|
178
395
|
clearUser,
|
|
179
396
|
is2FAEnabled,
|
|
@@ -181,6 +398,7 @@ export function useBetterAuth() {
|
|
|
181
398
|
isAuthenticated,
|
|
182
399
|
isLoading: computed(() => isLoading.value),
|
|
183
400
|
passkey: authClient.passkey,
|
|
401
|
+
registerPasskey,
|
|
184
402
|
setUser,
|
|
185
403
|
signIn,
|
|
186
404
|
signOut,
|
|
@@ -80,9 +80,18 @@ export interface AuthClientConfig {
|
|
|
80
80
|
* plain text password transmission over the network.
|
|
81
81
|
*/
|
|
82
82
|
export function createBetterAuthClient(config: AuthClientConfig = {}) {
|
|
83
|
+
// In development, use empty baseURL and /api/iam path to leverage Nuxt server proxy
|
|
84
|
+
// This is REQUIRED for WebAuthn/Passkey to work correctly because:
|
|
85
|
+
// - Frontend runs on localhost:3002, API on localhost:3000
|
|
86
|
+
// - WebAuthn validates the origin, which must be consistent
|
|
87
|
+
// - The Nuxt server proxy ensures requests come from the frontend origin
|
|
88
|
+
const isDev = import.meta.env?.DEV || process.env.NODE_ENV === 'development';
|
|
89
|
+
const defaultBaseURL = isDev ? '' : (import.meta.env?.VITE_API_URL || process.env.API_URL || 'http://localhost:3000');
|
|
90
|
+
const defaultBasePath = isDev ? '/api/iam' : '/iam';
|
|
91
|
+
|
|
83
92
|
const {
|
|
84
|
-
baseURL =
|
|
85
|
-
basePath =
|
|
93
|
+
baseURL = defaultBaseURL,
|
|
94
|
+
basePath = defaultBasePath,
|
|
86
95
|
twoFactorRedirectPath = '/auth/2fa',
|
|
87
96
|
enableAdmin = true,
|
|
88
97
|
enableTwoFactor = true,
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// ============================================================================
|
|
3
|
+
// Composables
|
|
4
|
+
// ============================================================================
|
|
5
|
+
const { user, signOut } = useBetterAuth();
|
|
6
|
+
|
|
7
|
+
// ============================================================================
|
|
8
|
+
// Variables
|
|
9
|
+
// ============================================================================
|
|
10
|
+
const pages = [
|
|
11
|
+
{
|
|
12
|
+
title: 'Sicherheit',
|
|
13
|
+
description: 'Verwalte 2FA, Passkeys und Kontosicherheit',
|
|
14
|
+
icon: 'i-lucide-shield-check',
|
|
15
|
+
to: '/app/settings/security',
|
|
16
|
+
},
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// Functions
|
|
21
|
+
// ============================================================================
|
|
22
|
+
async function handleSignOut(): Promise<void> {
|
|
23
|
+
await signOut();
|
|
24
|
+
await navigateTo('/auth/login');
|
|
25
|
+
}
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<template>
|
|
29
|
+
<div class="mx-auto max-w-4xl px-4 py-8">
|
|
30
|
+
<!-- Welcome Header -->
|
|
31
|
+
<div class="mb-8">
|
|
32
|
+
<h1 class="text-3xl font-bold">
|
|
33
|
+
Willkommen{{ user?.name ? `, ${user.name}` : '' }}!
|
|
34
|
+
</h1>
|
|
35
|
+
<p class="mt-2 text-muted">
|
|
36
|
+
{{ user?.email }}
|
|
37
|
+
</p>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<!-- Quick Actions -->
|
|
41
|
+
<div class="mb-8">
|
|
42
|
+
<h2 class="mb-4 text-xl font-semibold">Schnellzugriff</h2>
|
|
43
|
+
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
44
|
+
<UCard
|
|
45
|
+
v-for="page in pages"
|
|
46
|
+
:key="page.to"
|
|
47
|
+
class="cursor-pointer transition-shadow hover:shadow-lg"
|
|
48
|
+
@click="navigateTo(page.to)"
|
|
49
|
+
>
|
|
50
|
+
<div class="flex items-start gap-4">
|
|
51
|
+
<div class="rounded-lg bg-primary/10 p-3">
|
|
52
|
+
<UIcon :name="page.icon" class="size-6 text-primary" />
|
|
53
|
+
</div>
|
|
54
|
+
<div>
|
|
55
|
+
<h3 class="font-semibold">{{ page.title }}</h3>
|
|
56
|
+
<p class="mt-1 text-sm text-muted">{{ page.description }}</p>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
</UCard>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<!-- User Info Card -->
|
|
64
|
+
<UCard>
|
|
65
|
+
<template #header>
|
|
66
|
+
<div class="flex items-center gap-3">
|
|
67
|
+
<UIcon name="i-lucide-user" class="size-6 text-primary" />
|
|
68
|
+
<h2 class="font-semibold">Dein Konto</h2>
|
|
69
|
+
</div>
|
|
70
|
+
</template>
|
|
71
|
+
|
|
72
|
+
<div class="space-y-3">
|
|
73
|
+
<div class="flex justify-between">
|
|
74
|
+
<span class="text-muted">Name</span>
|
|
75
|
+
<span class="font-medium">{{ user?.name || '-' }}</span>
|
|
76
|
+
</div>
|
|
77
|
+
<div class="flex justify-between">
|
|
78
|
+
<span class="text-muted">E-Mail</span>
|
|
79
|
+
<span class="font-medium">{{ user?.email || '-' }}</span>
|
|
80
|
+
</div>
|
|
81
|
+
<div class="flex justify-between">
|
|
82
|
+
<span class="text-muted">E-Mail verifiziert</span>
|
|
83
|
+
<UBadge :color="user?.emailVerified ? 'success' : 'warning'">
|
|
84
|
+
{{ user?.emailVerified ? 'Ja' : 'Nein' }}
|
|
85
|
+
</UBadge>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<template #footer>
|
|
90
|
+
<UButton
|
|
91
|
+
color="error"
|
|
92
|
+
variant="outline"
|
|
93
|
+
icon="i-lucide-log-out"
|
|
94
|
+
@click="handleSignOut"
|
|
95
|
+
>
|
|
96
|
+
Abmelden
|
|
97
|
+
</UButton>
|
|
98
|
+
</template>
|
|
99
|
+
</UCard>
|
|
100
|
+
</div>
|
|
101
|
+
</template>
|
|
@@ -24,7 +24,7 @@ interface Passkey {
|
|
|
24
24
|
// ============================================================================
|
|
25
25
|
const toast = useToast();
|
|
26
26
|
const overlay = useOverlay();
|
|
27
|
-
const { is2FAEnabled, user } = useBetterAuth();
|
|
27
|
+
const { is2FAEnabled, registerPasskey, setUser, user } = useBetterAuth();
|
|
28
28
|
|
|
29
29
|
// ============================================================================
|
|
30
30
|
// Variables
|
|
@@ -39,6 +39,11 @@ const passkeyLoading = ref<boolean>(false);
|
|
|
39
39
|
const newPasskeyName = ref<string>('');
|
|
40
40
|
const showAddPasskey = ref<boolean>(false);
|
|
41
41
|
|
|
42
|
+
// Form states for UForm (required for proper data binding)
|
|
43
|
+
const enable2FAForm = reactive({ password: '' });
|
|
44
|
+
const disable2FAForm = reactive({ password: '' });
|
|
45
|
+
const totpForm = reactive({ code: '' });
|
|
46
|
+
|
|
42
47
|
const passwordSchema = v.object({
|
|
43
48
|
password: v.pipe(v.string('Passwort ist erforderlich'), v.minLength(1, 'Passwort ist erforderlich')),
|
|
44
49
|
});
|
|
@@ -70,14 +75,13 @@ async function addPasskey(): Promise<void> {
|
|
|
70
75
|
passkeyLoading.value = true;
|
|
71
76
|
|
|
72
77
|
try {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
});
|
|
78
|
+
// Use custom registerPasskey method with direct API calls
|
|
79
|
+
const result = await registerPasskey(newPasskeyName.value);
|
|
76
80
|
|
|
77
|
-
if (
|
|
81
|
+
if (!result.success) {
|
|
78
82
|
toast.add({
|
|
79
83
|
color: 'error',
|
|
80
|
-
description: error
|
|
84
|
+
description: result.error || 'Passkey konnte nicht hinzugefügt werden',
|
|
81
85
|
title: 'Fehler',
|
|
82
86
|
});
|
|
83
87
|
return;
|
|
@@ -143,6 +147,11 @@ async function disable2FA(payload: FormSubmitEvent<PasswordSchema>): Promise<voi
|
|
|
143
147
|
return;
|
|
144
148
|
}
|
|
145
149
|
|
|
150
|
+
// Update user state to reflect 2FA disabled
|
|
151
|
+
if (user.value) {
|
|
152
|
+
setUser({ ...user.value, twoFactorEnabled: false });
|
|
153
|
+
}
|
|
154
|
+
|
|
146
155
|
toast.add({
|
|
147
156
|
color: 'success',
|
|
148
157
|
description: '2FA wurde deaktiviert',
|
|
@@ -150,6 +159,7 @@ async function disable2FA(payload: FormSubmitEvent<PasswordSchema>): Promise<voi
|
|
|
150
159
|
});
|
|
151
160
|
|
|
152
161
|
show2FADisable.value = false;
|
|
162
|
+
disable2FAForm.password = '';
|
|
153
163
|
} finally {
|
|
154
164
|
loading.value = false;
|
|
155
165
|
}
|
|
@@ -175,6 +185,7 @@ async function enable2FA(payload: FormSubmitEvent<PasswordSchema>): Promise<void
|
|
|
175
185
|
totpUri.value = data?.totpURI ?? '';
|
|
176
186
|
backupCodes.value = data?.backupCodes ?? [];
|
|
177
187
|
showTotpSetup.value = true;
|
|
188
|
+
enable2FAForm.password = '';
|
|
178
189
|
} finally {
|
|
179
190
|
loading.value = false;
|
|
180
191
|
}
|
|
@@ -224,6 +235,11 @@ async function verifyTotp(payload: FormSubmitEvent<TotpSchema>): Promise<void> {
|
|
|
224
235
|
return;
|
|
225
236
|
}
|
|
226
237
|
|
|
238
|
+
// Update user state to reflect 2FA enabled
|
|
239
|
+
if (user.value) {
|
|
240
|
+
setUser({ ...user.value, twoFactorEnabled: true });
|
|
241
|
+
}
|
|
242
|
+
|
|
227
243
|
toast.add({
|
|
228
244
|
color: 'success',
|
|
229
245
|
description: '2FA wurde erfolgreich aktiviert',
|
|
@@ -231,6 +247,7 @@ async function verifyTotp(payload: FormSubmitEvent<TotpSchema>): Promise<void> {
|
|
|
231
247
|
});
|
|
232
248
|
|
|
233
249
|
showTotpSetup.value = false;
|
|
250
|
+
totpForm.code = '';
|
|
234
251
|
await openBackupCodesModal(backupCodes.value);
|
|
235
252
|
} finally {
|
|
236
253
|
loading.value = false;
|
|
@@ -271,9 +288,9 @@ async function verifyTotp(payload: FormSubmitEvent<TotpSchema>): Promise<void> {
|
|
|
271
288
|
</div>
|
|
272
289
|
|
|
273
290
|
<template v-if="!is2FAEnabled && !showTotpSetup">
|
|
274
|
-
<UForm :schema="passwordSchema" class="space-y-4" @submit="enable2FA">
|
|
291
|
+
<UForm :schema="passwordSchema" :state="enable2FAForm" class="space-y-4" @submit="enable2FA">
|
|
275
292
|
<UFormField label="Passwort bestätigen" name="password">
|
|
276
|
-
<UInput
|
|
293
|
+
<UInput v-model="enable2FAForm.password" type="password" placeholder="Dein Passwort" />
|
|
277
294
|
</UFormField>
|
|
278
295
|
<UButton type="submit" :loading="loading"> 2FA aktivieren </UButton>
|
|
279
296
|
</UForm>
|
|
@@ -281,15 +298,15 @@ async function verifyTotp(payload: FormSubmitEvent<TotpSchema>): Promise<void> {
|
|
|
281
298
|
|
|
282
299
|
<template v-if="showTotpSetup">
|
|
283
300
|
<div class="space-y-4">
|
|
284
|
-
<
|
|
301
|
+
<p class="text-sm text-muted">Scanne den QR-Code mit deiner Authenticator-App (z.B. Google Authenticator, Authy) und gib den Code ein.</p>
|
|
285
302
|
|
|
286
303
|
<div class="flex justify-center">
|
|
287
304
|
<img v-if="totpUri" :src="`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(totpUri)}`" alt="TOTP QR Code" class="rounded-lg" />
|
|
288
305
|
</div>
|
|
289
306
|
|
|
290
|
-
<UForm :schema="totpSchema" class="space-y-4" @submit="verifyTotp">
|
|
307
|
+
<UForm :schema="totpSchema" :state="totpForm" class="space-y-4" @submit="verifyTotp">
|
|
291
308
|
<UFormField label="Verifizierungscode" name="code">
|
|
292
|
-
<UInput
|
|
309
|
+
<UInput v-model="totpForm.code" placeholder="000000" class="text-center font-mono" />
|
|
293
310
|
</UFormField>
|
|
294
311
|
<div class="flex gap-2">
|
|
295
312
|
<UButton type="submit" :loading="loading"> Verifizieren </UButton>
|
|
@@ -307,10 +324,10 @@ async function verifyTotp(payload: FormSubmitEvent<TotpSchema>): Promise<void> {
|
|
|
307
324
|
</template>
|
|
308
325
|
|
|
309
326
|
<template v-if="show2FADisable">
|
|
310
|
-
<UForm :schema="passwordSchema" class="space-y-4" @submit="disable2FA">
|
|
327
|
+
<UForm :schema="passwordSchema" :state="disable2FAForm" class="space-y-4" @submit="disable2FA">
|
|
311
328
|
<UAlert color="warning" icon="i-lucide-alert-triangle"> 2FA zu deaktivieren verringert die Sicherheit deines Kontos. </UAlert>
|
|
312
329
|
<UFormField label="Passwort bestätigen" name="password">
|
|
313
|
-
<UInput
|
|
330
|
+
<UInput v-model="disable2FAForm.password" type="password" placeholder="Dein Passwort" />
|
|
314
331
|
</UFormField>
|
|
315
332
|
<div class="flex gap-2">
|
|
316
333
|
<UButton type="submit" color="error" :loading="loading"> 2FA deaktivieren </UButton>
|
|
@@ -127,8 +127,13 @@ async function onSubmit(payload: FormSubmitEvent<Schema>): Promise<void> {
|
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
// Check if 2FA is required
|
|
130
|
+
// Better-Auth native uses 'twoFactorRedirect', nest-server REST API uses 'requiresTwoFactor'
|
|
130
131
|
const resultData = 'data' in result ? result.data : result;
|
|
131
|
-
|
|
132
|
+
const requires2FA = resultData && (
|
|
133
|
+
('twoFactorRedirect' in resultData && resultData.twoFactorRedirect) ||
|
|
134
|
+
('requiresTwoFactor' in resultData && resultData.requiresTwoFactor)
|
|
135
|
+
);
|
|
136
|
+
if (requires2FA) {
|
|
132
137
|
// Redirect to 2FA page
|
|
133
138
|
await navigateTo('/auth/2fa');
|
|
134
139
|
return;
|
|
@@ -7,12 +7,11 @@ import type { InferOutput } from 'valibot';
|
|
|
7
7
|
|
|
8
8
|
import * as v from 'valibot';
|
|
9
9
|
|
|
10
|
-
import { authClient } from '~/lib/auth-client';
|
|
11
|
-
|
|
12
10
|
// ============================================================================
|
|
13
11
|
// Composables
|
|
14
12
|
// ============================================================================
|
|
15
13
|
const toast = useToast();
|
|
14
|
+
const { signUp, signIn } = useBetterAuth();
|
|
16
15
|
|
|
17
16
|
// ============================================================================
|
|
18
17
|
// Page Meta
|
|
@@ -25,8 +24,6 @@ definePageMeta({
|
|
|
25
24
|
// Variables
|
|
26
25
|
// ============================================================================
|
|
27
26
|
const loading = ref<boolean>(false);
|
|
28
|
-
const showPasskeyPrompt = ref<boolean>(false);
|
|
29
|
-
const passkeyLoading = ref<boolean>(false);
|
|
30
27
|
|
|
31
28
|
const fields: AuthFormField[] = [
|
|
32
29
|
{
|
|
@@ -74,111 +71,86 @@ const schema = v.pipe(
|
|
|
74
71
|
|
|
75
72
|
type Schema = InferOutput<typeof schema>;
|
|
76
73
|
|
|
77
|
-
|
|
78
|
-
|
|
74
|
+
// ============================================================================
|
|
75
|
+
// Functions
|
|
76
|
+
// ============================================================================
|
|
77
|
+
async function onSubmit(payload: FormSubmitEvent<Schema>): Promise<void> {
|
|
78
|
+
loading.value = true;
|
|
79
79
|
|
|
80
80
|
try {
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
// Step 1: Sign up
|
|
82
|
+
const signUpResult = await signUp.email({
|
|
83
|
+
email: payload.data.email,
|
|
84
|
+
name: payload.data.name,
|
|
85
|
+
password: payload.data.password,
|
|
83
86
|
});
|
|
84
87
|
|
|
85
|
-
|
|
88
|
+
const signUpError = 'error' in signUpResult ? signUpResult.error : null;
|
|
89
|
+
|
|
90
|
+
if (signUpError) {
|
|
86
91
|
toast.add({
|
|
87
92
|
color: 'error',
|
|
88
|
-
description:
|
|
93
|
+
description: signUpError.message || 'Registrierung fehlgeschlagen',
|
|
89
94
|
title: 'Fehler',
|
|
90
95
|
});
|
|
91
96
|
return;
|
|
92
97
|
}
|
|
93
98
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
description: 'Passkey wurde erfolgreich hinzugefügt',
|
|
97
|
-
title: 'Erfolg',
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
await navigateTo('/app');
|
|
101
|
-
} finally {
|
|
102
|
-
passkeyLoading.value = false;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// ============================================================================
|
|
107
|
-
// Functions
|
|
108
|
-
// ============================================================================
|
|
109
|
-
async function onSubmit(payload: FormSubmitEvent<Schema>): Promise<void> {
|
|
110
|
-
loading.value = true;
|
|
111
|
-
|
|
112
|
-
try {
|
|
113
|
-
const { error } = await authClient.signUp.email({
|
|
99
|
+
// Step 2: Sign in to create session (required for passkey registration)
|
|
100
|
+
const signInResult = await signIn.email({
|
|
114
101
|
email: payload.data.email,
|
|
115
|
-
name: payload.data.name,
|
|
116
102
|
password: payload.data.password,
|
|
117
103
|
});
|
|
118
104
|
|
|
119
|
-
|
|
105
|
+
const signInError = 'error' in signInResult ? signInResult.error : null;
|
|
106
|
+
|
|
107
|
+
if (signInError) {
|
|
108
|
+
// Sign-up was successful but sign-in failed - still show success and redirect
|
|
120
109
|
toast.add({
|
|
121
|
-
color: '
|
|
122
|
-
description:
|
|
123
|
-
title: '
|
|
110
|
+
color: 'success',
|
|
111
|
+
description: 'Dein Konto wurde erstellt. Bitte melde dich an.',
|
|
112
|
+
title: 'Willkommen!',
|
|
124
113
|
});
|
|
114
|
+
await navigateTo('/auth/login');
|
|
125
115
|
return;
|
|
126
116
|
}
|
|
127
117
|
|
|
128
118
|
toast.add({
|
|
129
119
|
color: 'success',
|
|
130
|
-
description: 'Dein Konto wurde erfolgreich erstellt',
|
|
120
|
+
description: 'Dein Konto wurde erfolgreich erstellt. Du kannst Passkeys später in den Sicherheitseinstellungen hinzufügen.',
|
|
131
121
|
title: 'Willkommen!',
|
|
132
122
|
});
|
|
133
123
|
|
|
134
|
-
|
|
124
|
+
// Navigate to app - passkeys can be added later in security settings
|
|
125
|
+
// Note: Immediate passkey registration after sign-up is currently not supported
|
|
126
|
+
// due to session handling differences between nest-server and Better Auth
|
|
127
|
+
await navigateTo('/app', { external: true });
|
|
135
128
|
} finally {
|
|
136
129
|
loading.value = false;
|
|
137
130
|
}
|
|
138
131
|
}
|
|
139
|
-
|
|
140
|
-
async function skipPasskey(): Promise<void> {
|
|
141
|
-
await navigateTo('/app');
|
|
142
|
-
}
|
|
143
132
|
</script>
|
|
144
133
|
|
|
145
134
|
<template>
|
|
146
135
|
<UPageCard class="w-md" variant="naked">
|
|
147
|
-
<
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
:
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
>
|
|
160
|
-
<
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
</UAuthForm>
|
|
167
|
-
</template>
|
|
168
|
-
|
|
169
|
-
<template v-else>
|
|
170
|
-
<div class="flex flex-col items-center gap-6">
|
|
171
|
-
<UIcon name="i-lucide-key" class="size-16 text-primary" />
|
|
172
|
-
<div class="text-center">
|
|
173
|
-
<h2 class="text-xl font-semibold">Passkey hinzufügen?</h2>
|
|
174
|
-
<p class="mt-2 text-sm text-muted">Mit einem Passkey kannst du dich schnell und sicher ohne Passwort anmelden.</p>
|
|
175
|
-
</div>
|
|
176
|
-
|
|
177
|
-
<div class="flex w-full flex-col gap-3">
|
|
178
|
-
<UButton block :loading="passkeyLoading" @click="addPasskey"> Passkey hinzufügen </UButton>
|
|
179
|
-
<UButton block variant="outline" color="neutral" @click="skipPasskey"> Später einrichten </UButton>
|
|
180
|
-
</div>
|
|
181
|
-
</div>
|
|
182
|
-
</template>
|
|
136
|
+
<UAuthForm
|
|
137
|
+
:schema="schema"
|
|
138
|
+
title="Registrieren"
|
|
139
|
+
icon="i-lucide-user-plus"
|
|
140
|
+
:fields="fields"
|
|
141
|
+
:loading="loading"
|
|
142
|
+
:submit="{
|
|
143
|
+
label: 'Konto erstellen',
|
|
144
|
+
block: true,
|
|
145
|
+
}"
|
|
146
|
+
@submit="onSubmit"
|
|
147
|
+
>
|
|
148
|
+
<template #footer>
|
|
149
|
+
<p class="text-center text-sm text-muted">
|
|
150
|
+
Bereits ein Konto?
|
|
151
|
+
<ULink to="/auth/login" class="text-primary font-medium">Anmelden</ULink>
|
|
152
|
+
</p>
|
|
153
|
+
</template>
|
|
154
|
+
</UAuthForm>
|
|
183
155
|
</UPageCard>
|
|
184
156
|
</template>
|
|
@@ -11,3 +11,34 @@ export async function sha256(message: string): Promise<string> {
|
|
|
11
11
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
12
12
|
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
13
13
|
}
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// WebAuthn/Passkey Utilities
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Converts an ArrayBuffer to a base64url-encoded string
|
|
21
|
+
* Used for WebAuthn credential responses
|
|
22
|
+
*
|
|
23
|
+
* @param buffer - The ArrayBuffer to convert
|
|
24
|
+
* @returns The base64url-encoded string (no padding)
|
|
25
|
+
*/
|
|
26
|
+
export function arrayBufferToBase64Url(buffer: ArrayBuffer): string {
|
|
27
|
+
const bytes = new Uint8Array(buffer);
|
|
28
|
+
let binary = '';
|
|
29
|
+
bytes.forEach((b) => (binary += String.fromCharCode(b)));
|
|
30
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Converts a base64url-encoded string to a Uint8Array
|
|
35
|
+
* Used for WebAuthn challenge decoding
|
|
36
|
+
*
|
|
37
|
+
* @param base64url - The base64url-encoded string
|
|
38
|
+
* @returns The decoded Uint8Array
|
|
39
|
+
*/
|
|
40
|
+
export function base64UrlToUint8Array(base64url: string): Uint8Array {
|
|
41
|
+
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
|
|
42
|
+
const paddedBase64 = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
|
|
43
|
+
return Uint8Array.from(atob(paddedBase64), (c) => c.charCodeAt(0));
|
|
44
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proxy handler for Better Auth API requests
|
|
3
|
+
*
|
|
4
|
+
* This proxy is REQUIRED for WebAuthn/Passkey functionality in development mode because:
|
|
5
|
+
* 1. Frontend runs on a different port (e.g., 3001/3002) than the API (3000)
|
|
6
|
+
* 2. WebAuthn validates the origin, which must be consistent
|
|
7
|
+
* 3. Cross-origin requests have cookie handling issues
|
|
8
|
+
*
|
|
9
|
+
* The proxy ensures:
|
|
10
|
+
* - Cookies are properly forwarded between frontend and API
|
|
11
|
+
* - The origin header is preserved for WebAuthn validation
|
|
12
|
+
* - Set-Cookie headers from the API are forwarded to the browser
|
|
13
|
+
*/
|
|
14
|
+
export default defineEventHandler(async (event) => {
|
|
15
|
+
const path = getRouterParam(event, 'path') || '';
|
|
16
|
+
const apiUrl = process.env.API_URL || 'http://localhost:3000';
|
|
17
|
+
const targetUrl = `${apiUrl}/iam/${path}`;
|
|
18
|
+
|
|
19
|
+
// Get query string
|
|
20
|
+
const query = getQuery(event);
|
|
21
|
+
const queryString = new URLSearchParams(query as Record<string, string>).toString();
|
|
22
|
+
const fullUrl = queryString ? `${targetUrl}?${queryString}` : targetUrl;
|
|
23
|
+
|
|
24
|
+
// Get request body for POST/PUT/PATCH requests
|
|
25
|
+
const method = event.method;
|
|
26
|
+
let body: any;
|
|
27
|
+
if (['POST', 'PUT', 'PATCH'].includes(method)) {
|
|
28
|
+
body = await readBody(event);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Forward cookies from the incoming request
|
|
32
|
+
const cookieHeader = getHeader(event, 'cookie');
|
|
33
|
+
// Use the actual origin from the request, fallback to localhost
|
|
34
|
+
const originHeader = getHeader(event, 'origin') || getHeader(event, 'referer')?.replace(/\/[^/]*$/, '') || 'http://localhost:3001';
|
|
35
|
+
|
|
36
|
+
// Make the request to the API
|
|
37
|
+
const response = await $fetch.raw(fullUrl, {
|
|
38
|
+
method: method as any,
|
|
39
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
40
|
+
headers: {
|
|
41
|
+
'Content-Type': 'application/json',
|
|
42
|
+
'Origin': originHeader,
|
|
43
|
+
...(cookieHeader ? { Cookie: cookieHeader } : {}),
|
|
44
|
+
},
|
|
45
|
+
credentials: 'include',
|
|
46
|
+
// Don't throw on error status codes
|
|
47
|
+
ignoreResponseError: true,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Forward Set-Cookie headers from the API response
|
|
51
|
+
const setCookieHeaders = response.headers.getSetCookie?.() || [];
|
|
52
|
+
for (const cookie of setCookieHeaders) {
|
|
53
|
+
// Rewrite cookie to work on localhost (remove domain/port specifics)
|
|
54
|
+
const rewrittenCookie = cookie
|
|
55
|
+
.replace(/domain=[^;]+;?\s*/gi, '')
|
|
56
|
+
.replace(/secure;?\s*/gi, '');
|
|
57
|
+
appendResponseHeader(event, 'Set-Cookie', rewrittenCookie);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Set response status
|
|
61
|
+
setResponseStatus(event, response.status);
|
|
62
|
+
|
|
63
|
+
// Return the response body
|
|
64
|
+
return response._data;
|
|
65
|
+
});
|
package/package.json
CHANGED