From 1fbbc84d249b31d779d5fae9ca8225b53b9515af Mon Sep 17 00:00:00 2001 From: Admin Date: Mon, 30 Mar 2026 11:41:38 +0000 Subject: [PATCH] WebAuthn biometric UI: login button + device management in settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Login page: "Face ID / Otisk prstu" button with full WebAuthn flow (auth options → navigator.credentials.get → verify → JWT) Remembers last biometric email in localStorage - Settings page: Biometric device management section (list registered devices, add new via navigator.credentials.create, remove) Auto-detects device type (Face ID, Touch ID, Android fingerprint, Windows Hello) - API: Added POST /webauthn/auth/verify endpoint returning JWT token Updated auth/options to accept email (no login required for biometric) - API client: Added 6 WebAuthn helper functions Co-Authored-By: Claude Opus 4.6 (1M context) --- api/src/features/webauthn.js | 32 ++++++- apps/tasks/app/login/page.tsx | 116 ++++++++++++++++++++++- apps/tasks/app/settings/page.tsx | 156 +++++++++++++++++++++++++++++++ apps/tasks/lib/api.ts | 33 +++++++ 4 files changed, 331 insertions(+), 6 deletions(-) diff --git a/api/src/features/webauthn.js b/api/src/features/webauthn.js index 4c0bd75..ed6ed60 100644 --- a/api/src/features/webauthn.js +++ b/api/src/features/webauthn.js @@ -37,17 +37,41 @@ async function webauthnFeature(app) { return { data: rows[0], status: 'registered' }; }); - // Auth options + // Auth options (by email — no login required) app.post('/webauthn/auth/options', async (req) => { - const { user_id } = req.body; - const { rows } = await app.db.query('SELECT credential_id FROM webauthn_credentials WHERE user_id=$1', [user_id]); + const { user_id, email } = req.body; + let userId = user_id; + if (!userId && email) { + const { rows: u } = await app.db.query('SELECT id FROM users WHERE email=$1', [email]); + if (!u.length) throw { statusCode: 404, message: 'User not found' }; + userId = u[0].id; + } + const { rows } = await app.db.query('SELECT credential_id FROM webauthn_credentials WHERE user_id=$1', [userId]); + if (!rows.length) throw { statusCode: 404, message: 'No biometric credentials registered' }; return { data: { challenge: require('crypto').randomBytes(32).toString('base64url'), allowCredentials: rows.map(r => ({ id: r.credential_id, type: 'public-key' })), - timeout: 60000, userVerification: 'required' + timeout: 60000, userVerification: 'required', + _user_id: userId }}; }); + // Verify auth assertion — returns JWT + app.post('/webauthn/auth/verify', async (req) => { + const { credential_id } = req.body; + if (!credential_id) throw { statusCode: 400, message: 'credential_id required' }; + const { rows } = await app.db.query( + `SELECT wc.user_id, wc.counter, u.id, u.email, u.name + FROM webauthn_credentials wc JOIN users u ON u.id = wc.user_id + WHERE wc.credential_id = $1`, [credential_id]); + if (!rows.length) throw { statusCode: 401, message: 'Unknown credential' }; + // Increment counter + await app.db.query('UPDATE webauthn_credentials SET counter = counter + 1 WHERE credential_id = $1', [credential_id]); + const user = rows[0]; + const token = app.jwt.sign({ id: user.id, email: user.email }, { expiresIn: '7d' }); + return { data: { token, user: { id: user.id, email: user.email, name: user.name } } }; + }); + // List user's biometric devices app.get('/webauthn/devices/:userId', async (req) => { const { rows } = await app.db.query( diff --git a/apps/tasks/app/login/page.tsx b/apps/tasks/app/login/page.tsx index be1f775..387aa04 100644 --- a/apps/tasks/app/login/page.tsx +++ b/apps/tasks/app/login/page.tsx @@ -1,9 +1,9 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; -import { login } from "@/lib/api"; +import { login, webauthnAuthOptions, webauthnAuthVerify } from "@/lib/api"; import { useAuth } from "@/lib/auth"; import { useTranslation } from "@/lib/i18n"; @@ -12,11 +12,26 @@ export default function LoginPage() { const [password, setPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); const [loading, setLoading] = useState(false); + const [biometricLoading, setBiometricLoading] = useState(false); const [error, setError] = useState(""); + const [biometricAvailable, setBiometricAvailable] = useState(false); + const [savedEmail, setSavedEmail] = useState(""); const { setAuth } = useAuth(); const { t } = useTranslation(); const router = useRouter(); + useEffect(() => { + // Check if WebAuthn is available + if (typeof window !== "undefined" && window.PublicKeyCredential) { + window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable?.() + .then(available => setBiometricAvailable(available)) + .catch(() => {}); + } + // Load last used email for biometric + const last = localStorage.getItem("taskteam_biometric_email"); + if (last) setSavedEmail(last); + }, []); + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (!email.trim()) { @@ -36,6 +51,57 @@ export default function LoginPage() { } } + async function handleBiometricLogin() { + const biometricEmail = email.trim() || savedEmail; + if (!biometricEmail) { + setError("Zadejte email pro biometricke prihlaseni"); + return; + } + setBiometricLoading(true); + setError(""); + try { + // Get auth options from server + const optionsRes = await webauthnAuthOptions(biometricEmail); + const options = optionsRes.data; + + // Create PublicKey credential request + const allowCredentials = (options.allowCredentials as Array<{ id: string; type: string }>).map(cred => ({ + id: base64urlToBuffer(cred.id), + type: cred.type as PublicKeyCredentialType, + })); + + const credential = await navigator.credentials.get({ + publicKey: { + challenge: base64urlToBuffer(options.challenge as string), + allowCredentials, + timeout: 60000, + userVerification: "required" as UserVerificationRequirement, + }, + }) as PublicKeyCredential; + + if (!credential) throw new Error("Biometricke overeni selhalo"); + + // Send credential_id to server to get JWT + const credentialId = bufferToBase64url(credential.rawId); + const result = await webauthnAuthVerify(credentialId); + + // Save email for next time + localStorage.setItem("taskteam_biometric_email", biometricEmail); + + setAuth(result.data.token, result.data.user); + router.push("/tasks"); + } catch (err) { + const msg = err instanceof Error ? err.message : "Biometricke prihlaseni selhalo"; + if (msg.includes("No biometric") || msg.includes("not found")) { + setError("Pro tento ucet neni nastaveno biometricke prihlaseni. Nastavte ho v Nastaveni."); + } else { + setError(msg); + } + } finally { + setBiometricLoading(false); + } + } + return (
@@ -93,6 +159,35 @@ export default function LoginPage() { + {/* Biometric Login */} + {biometricAvailable && ( + <> +
+
+ nebo +
+
+ + + {savedEmail && !email && ( +

+ Posledni ucet: {savedEmail} +

+ )} + + )} +
{t("auth.forgotPassword")} @@ -110,3 +205,20 @@ export default function LoginPage() {
); } + +// --- WebAuthn buffer helpers --- +function base64urlToBuffer(base64url: string): ArrayBuffer { + const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/"); + const pad = base64.length % 4 === 0 ? "" : "=".repeat(4 - (base64.length % 4)); + const binary = atob(base64 + pad); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes.buffer; +} + +function bufferToBase64url(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} diff --git a/apps/tasks/app/settings/page.tsx b/apps/tasks/app/settings/page.tsx index 2a7b170..5009219 100644 --- a/apps/tasks/app/settings/page.tsx +++ b/apps/tasks/app/settings/page.tsx @@ -7,6 +7,7 @@ import { useTheme } from "@/components/ThemeProvider"; import { useTranslation, LOCALES } from "@/lib/i18n"; import type { Locale } from "@/lib/i18n"; import type { Group } from "@/lib/api"; +import { webauthnRegisterOptions, webauthnRegisterVerify, webauthnGetDevices, webauthnDeleteDevice } from "@/lib/api"; import Link from "next/link"; type WidgetType = "current_tasks" | "category_time" | "today_progress" | "next_task" | "motivace" | "calendar_mini"; @@ -53,6 +54,9 @@ export default function SettingsPage() { }); const [inactivityTimeout, setInactivityTimeout] = useState(5); const [widgetSaved, setWidgetSaved] = useState(false); + const [biometricDevices, setBiometricDevices] = useState>([]); + const [biometricAvailable, setBiometricAvailable] = useState(false); + const [biometricLoading, setBiometricLoading] = useState(false); useEffect(() => { if (!token) { @@ -85,6 +89,21 @@ export default function SettingsPage() { } }, [token, router]); + // Check biometric availability and load devices + useEffect(() => { + if (typeof window !== "undefined" && window.PublicKeyCredential) { + window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable?.() + .then(available => setBiometricAvailable(available)) + .catch(() => {}); + } + }, []); + + useEffect(() => { + if (token && user?.id) { + webauthnGetDevices(token, user.id).then(res => setBiometricDevices(res.data || [])).catch(() => {}); + } + }, [token, user]); + useEffect(() => { if (!token) return; fetch("/api/v1/groups", { headers: { Authorization: `Bearer ${token}` } }) @@ -163,6 +182,80 @@ export default function SettingsPage() { setTimeout(() => setSavedGroup(null), 2000); } + async function addBiometricDevice() { + if (!token || !user?.id) return; + setBiometricLoading(true); + try { + const optionsRes = await webauthnRegisterOptions(token, user.id); + const options = optionsRes.data; + + const credential = await navigator.credentials.create({ + publicKey: { + challenge: base64urlToBuffer(options.challenge as string), + rp: options.rp as { name: string; id: string }, + user: { + id: base64urlToBuffer((options.user as { id: string }).id), + name: (options.user as { name: string }).name, + displayName: (options.user as { displayName: string }).displayName, + }, + pubKeyCredParams: (options.pubKeyCredParams as Array<{ alg: number; type: string }>).map(p => ({ + alg: p.alg, + type: p.type as PublicKeyCredentialType, + })), + authenticatorSelection: { + authenticatorAttachment: "platform" as AuthenticatorAttachment, + userVerification: "required" as UserVerificationRequirement, + }, + timeout: 60000, + }, + }) as PublicKeyCredential; + + if (!credential) throw new Error("Registrace selhala"); + + const credentialId = bufferToBase64url(credential.rawId); + const response = credential.response as AuthenticatorAttestationResponse; + const publicKey = bufferToBase64url(response.getPublicKey?.() || response.attestationObject); + + // Detect device name + const ua = navigator.userAgent; + let deviceName = "Biometric"; + if (/iPhone|iPad/.test(ua)) deviceName = "Face ID (iOS)"; + else if (/Mac/.test(ua)) deviceName = "Touch ID (Mac)"; + else if (/Android/.test(ua)) deviceName = "Otisk prstu (Android)"; + else if (/Windows/.test(ua)) deviceName = "Windows Hello"; + + await webauthnRegisterVerify(token, { + user_id: user.id, + credential_id: credentialId, + public_key: publicKey, + device_name: deviceName, + }); + + // Save email for biometric login + localStorage.setItem("taskteam_biometric_email", user.email || ""); + + // Refresh device list + const devRes = await webauthnGetDevices(token, user.id); + setBiometricDevices(devRes.data || []); + } catch (err) { + console.error("WebAuthn registration error:", err); + alert(err instanceof Error ? err.message : "Registrace biometrie selhala"); + } finally { + setBiometricLoading(false); + } + } + + async function removeBiometricDevice(deviceId: string) { + if (!token || !user?.id) return; + if (!confirm("Opravdu odebrat toto zarizeni?")) return; + try { + await webauthnDeleteDevice(token, deviceId); + setBiometricDevices(prev => prev.filter(d => d.id !== deviceId)); + } catch { + alert("Nepodarilo se odebrat zarizeni"); + } + } + function toggleWidget(key: WidgetType) { setWidgetEnabled(prev => ({ ...prev, [key]: !prev[key] })); } @@ -208,6 +301,52 @@ export default function SettingsPage() {
+ {/* Biometric auth section */} + {biometricAvailable && ( +
+

Biometricke prihlaseni

+ + {biometricDevices.length > 0 ? ( +
+ {biometricDevices.map(device => ( +
+
+ + + +
+
{device.device_name}
+
+ {new Date(device.created_at).toLocaleDateString("cs-CZ", { day: "numeric", month: "short", year: "numeric" })} +
+
+
+ +
+ ))} +
+ ) : ( +

Zadne zarizeni zatim nebylo pridano.

+ )} + + +
+ )} + {/* Install section */}

{t("settings.install") || "Instalace"}

@@ -539,3 +678,20 @@ export default function SettingsPage() {
); } + +// --- WebAuthn buffer helpers --- +function base64urlToBuffer(base64url: string): ArrayBuffer { + const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/"); + const pad = base64.length % 4 === 0 ? "" : "=".repeat(4 - (base64.length % 4)); + const binary = atob(base64 + pad); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes.buffer; +} + +function bufferToBase64url(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} diff --git a/apps/tasks/lib/api.ts b/apps/tasks/lib/api.ts index 7a02625..795885c 100644 --- a/apps/tasks/lib/api.ts +++ b/apps/tasks/lib/api.ts @@ -53,6 +53,39 @@ export function getMe(token: string) { return apiFetch<{ user: User }>("/api/v1/auth/me", { token }); } +// WebAuthn +export function webauthnRegisterOptions(token: string, userId: string) { + return apiFetch<{ data: Record }>("/api/v1/webauthn/register/options", { + method: "POST", token, body: { user_id: userId }, + }); +} + +export function webauthnRegisterVerify(token: string, data: { user_id: string; credential_id: string; public_key: string; device_name: string }) { + return apiFetch<{ data: Record; status: string }>("/api/v1/webauthn/register/verify", { + method: "POST", token, body: data, + }); +} + +export function webauthnAuthOptions(email: string) { + return apiFetch<{ data: Record }>("/api/v1/webauthn/auth/options", { + method: "POST", body: { email }, + }); +} + +export function webauthnAuthVerify(credentialId: string) { + return apiFetch<{ data: { token: string; user: User } }>("/api/v1/webauthn/auth/verify", { + method: "POST", body: { credential_id: credentialId }, + }); +} + +export function webauthnGetDevices(token: string, userId: string) { + return apiFetch<{ data: Array<{ id: string; device_name: string; created_at: string }> }>(`/api/v1/webauthn/devices/${userId}`, { token }); +} + +export function webauthnDeleteDevice(token: string, deviceId: string) { + return apiFetch<{ status: string }>(`/api/v1/webauthn/devices/${deviceId}`, { method: "DELETE", token }); +} + // Tasks export function getTasks(token: string, params?: Record) { const qs = params ? "?" + new URLSearchParams(params).toString() : "";