From b8e4de97c70c8661fc284db886dcc82f24b9ed10 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 10:35:16 +0000 Subject: [PATCH] Login mit E-Mail-Code (Appwrite-2FA) Co-Authored-By: Claude Opus 5.5 --- src/context/AuthContext.jsx | 61 +++++++++++++++++++- src/lib/appwrite.js | 2 +- src/lib/mfa.js | 37 +++++++++++++ src/pages/LoginPage.jsx | 107 ++++++++++++++++++++++++++++++++++-- tests/mfa.test.mjs | 53 ++++++++++++++++++ 5 files changed, 253 insertions(+), 7 deletions(-) create mode 100644 src/lib/mfa.js create mode 100644 tests/mfa.test.mjs diff --git a/src/context/AuthContext.jsx b/src/context/AuthContext.jsx index 6b9fa60..3a13f27 100644 --- a/src/context/AuthContext.jsx +++ b/src/context/AuthContext.jsx @@ -8,9 +8,12 @@ import { Query, hasAppwriteSession, projectId as PROJECT_ID, + client, } from '../lib/appwrite' +import { createMfaApi, isMoreFactorsError, maskEmail, mfaErrorMessage } from '../lib/mfa' const AuthContext = createContext() +const mfa = createMfaApi(client) function clearStaleAppwriteSessions() { if (typeof window === 'undefined' || !window.localStorage) return @@ -89,6 +92,9 @@ export function AuthProvider({ children }) { const [user, setUser] = useState(null) const [employee, setEmployee] = useState(null) const [loading, setLoading] = useState(true) + // 2FA: Passwort stimmt, Sitzung braucht noch den Code aus der E-Mail. + // challengeId null = noch keine Mail verschickt (z. B. nach dem Neuladen) + const [mfaPending, setMfaPending] = useState(null) useEffect(() => { checkUser() @@ -106,6 +112,12 @@ export function AuthProvider({ children }) { setUser(session) setEmployee(await ensureEmployeeExists(session)) } catch (error) { + if (isMoreFactorsError(error)) { + // Halbe Sitzung: Code-Schritt zeigen, aber keine Mail von selbst (jedes Neuladen wuerde sonst eine ausloesen) + setMfaPending({ challengeId: null, email: '' }) + setUser(null) + return + } if (error.code !== 401 && error.code !== 404) { console.error('Unexpected error checking user:', error) } @@ -126,7 +138,15 @@ export function AuthProvider({ children }) { await createSessionWithCleanup(normalizedEmail, normalizedPassword) - const session = await account.get() + let session + try { + session = await account.get() + } catch (error) { + if (!isMoreFactorsError(error)) throw error + setMfaPending({ challengeId: null, email: maskEmail(normalizedEmail) }) + const sent = await sendCode() + return { success: false, mfaRequired: true, error: sent.error } + } setUser(session) setEmployee(await ensureEmployeeExists(session)) @@ -156,6 +176,38 @@ export function AuthProvider({ children }) { } } + async function sendCode() { + try { + const challengeId = await mfa.createEmailChallenge() + setMfaPending((pending) => ({ email: '', ...pending, challengeId })) + return { success: true } + } catch (error) { + return { success: false, error: mfaErrorMessage(error) } + } + } + + async function verifyCode(otp) { + if (!mfaPending?.challengeId) return { success: false, error: 'Bitte zuerst einen Code anfordern.' } + try { + await mfa.completeChallenge(mfaPending.challengeId, String(otp || '').trim()) + const session = await account.get() + setUser(session) + setEmployee(await ensureEmployeeExists(session)) + setMfaPending(null) + return { success: true } + } catch (error) { + return { success: false, error: mfaErrorMessage(error) } + } + } + + async function cancelMfa() { + try { + await account.deleteSession('current') + } catch { /* Sitzung ggf. schon weg */ } + clearAllLocalAppwriteSessions() + setMfaPending(null) + } + async function logout() { try { await account.deleteSessions() @@ -165,6 +217,7 @@ export function AuthProvider({ children }) { clearAllLocalAppwriteSessions() setUser(null) setEmployee(null) + setMfaPending(null) } } @@ -176,7 +229,11 @@ export function AuthProvider({ children }) { loading, login, logout, - isAdmin + isAdmin, + mfaPending, + sendCode, + verifyCode, + cancelMfa, } return ( diff --git a/src/lib/appwrite.js b/src/lib/appwrite.js index bcd86ee..57c67f6 100644 --- a/src/lib/appwrite.js +++ b/src/lib/appwrite.js @@ -19,7 +19,7 @@ if (!projectId) { console.error('❌ FEHLER: VITE_APPWRITE_PROJECT_ID ist nicht gesetzt!') } -const client = new Client().setEndpoint(endpoint).setProject(projectId) +export const client = new Client().setEndpoint(endpoint).setProject(projectId) export const account = new Account(client) export const databases = new Databases(client) diff --git a/src/lib/mfa.js b/src/lib/mfa.js new file mode 100644 index 0000000..4cdd861 --- /dev/null +++ b/src/lib/mfa.js @@ -0,0 +1,37 @@ +/** + * Mitarbeiter-2FA per E-Mail-Code (Appwrite 1.8.1). Das SDK 13 kennt die MFA-Endpunkte nicht, deshalb + * gehen die Aufrufe ueber client.call - mit derselben Sitzung (Cookie-Fallback) wie das SDK. + * Bewusst ohne Import von appwrite.js, damit Node dieses Modul in den Tests laden kann. + */ +const HEADERS = { 'content-type': 'application/json', 'X-Appwrite-Locale': 'de' } + +export function createMfaApi(client) { + const url = () => new URL(`${client.config.endpoint}/account/mfa/challenges`) + return { + async createEmailChallenge() { + const challenge = await client.call('post', url(), { ...HEADERS }, { factor: 'email' }) + return challenge.$id + }, + async completeChallenge(challengeId, otp) { + await client.call('put', url(), { ...HEADERS }, { challengeId, otp }) + }, + } +} + +export function isMoreFactorsError(err) { + return err?.type === 'user_more_factors_required' +} + +export function mfaErrorMessage(err) { + if (err?.type === 'user_invalid_token') return 'Code falsch oder abgelaufen.' + if (err?.code === 429 || err?.type === 'general_rate_limit_exceeded') return 'Zu viele Versuche. Bitte warte einige Minuten.' + if (err?.type === 'user_email_not_verified') return '2FA ist für dein Konto noch nicht eingerichtet – bitte Kenso Bescheid geben.' + return err?.message || 'Anmeldung fehlgeschlagen' +} + +/** kenso@webklar.com -> k…@webklar.com */ +export function maskEmail(email) { + const [local, domain] = String(email || '').split('@') + if (!local || !domain) return '' + return `${local[0]}…@${domain}` +} diff --git a/src/pages/LoginPage.jsx b/src/pages/LoginPage.jsx index a47fd1e..a945a18 100644 --- a/src/pages/LoginPage.jsx +++ b/src/pages/LoginPage.jsx @@ -8,8 +8,10 @@ export default function LoginPage() { const [password, setPassword] = useState('') const [error, setError] = useState('') const [loading, setLoading] = useState(false) - - const { login } = useAuth() + const [code, setCode] = useState('') + const [notice, setNotice] = useState('') + + const { login, mfaPending, sendCode, verifyCode, cancelMfa } = useAuth() const navigate = useNavigate() const handleSubmit = async (e) => { @@ -22,6 +24,8 @@ export default function LoginPage() { if (result.success) { navigate('/tickets') + } else if (result.mfaRequired) { + if (result.error) setError(result.error) } else { // Bessere Fehlermeldungen let errorMessage = result.error || 'Login fehlgeschlagen' @@ -41,6 +45,101 @@ export default function LoginPage() { } } + const handleCode = async (e) => { + e.preventDefault() + setError('') + setLoading(true) + const result = await verifyCode(code) + setLoading(false) + if (result.success) { + navigate('/tickets') + } else { + setError(result.error) + setCode('') + } + } + + const handleSend = async () => { + setError('') + setNotice('') + setLoading(true) + const result = await sendCode() + setLoading(false) + if (result.success) setNotice('Neuer Code ist unterwegs.') + else setError(result.error) + } + + const handleCancel = async () => { + await cancelMfa() + setError('') + setNotice('') + setCode('') + setPassword('') + } + + const linkButton = { + background: 'none', + border: 0, + padding: 0, + color: '#e2e8f0', + textDecoration: 'underline', + cursor: 'pointer', + font: 'inherit', + } + + const codeStep = mfaPending && ( +
+ {error && ( +
+ {error} +
+ )} +

+ {!mfaPending.challengeId + ? 'Wir schicken dir einen Code an deine E-Mail-Adresse.' + : `Wir haben dir einen Code an ${mfaPending.email || 'deine E-Mail-Adresse'} geschickt.`} +

+ {notice &&

{notice}

} + {mfaPending.challengeId ? ( + <> +
+ + setCode(e.target.value.replace(/\D/g, ''))} + inputMode="numeric" + autoComplete="one-time-code" + maxLength={6} + required + autoFocus + style={{ letterSpacing: '0.3em' }} + /> +
+ +

+ + {' · '} + +

+ + ) : ( + <> + +

+ +

+ + )} +
+ ) + return (
Webklar WOMS 2.0
-
+ {codeStep || {error && (
{error} @@ -135,7 +234,7 @@ export default function LoginPage() { > {loading ? 'Login läuft...' : 'Login'} - + }
diff --git a/tests/mfa.test.mjs b/tests/mfa.test.mjs new file mode 100644 index 0000000..b29becf --- /dev/null +++ b/tests/mfa.test.mjs @@ -0,0 +1,53 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { createMfaApi, isMoreFactorsError, mfaErrorMessage, maskEmail } from '../src/lib/mfa.js' + +// Fake-Client wie Appwrite-SDK 13: call(method, url, headers, params) +function fakeClient(response = {}) { + const calls = [] + return { + calls, + config: { endpoint: 'https://ticket.webklar.com/v1' }, + call: async (method, url, headers, params) => { + calls.push({ method, url: String(url), headers, params }) + return response + }, + } +} + +test('createEmailChallenge: POST mit factor email und deutscher Sprache', async () => { + const client = fakeClient({ $id: 'ch1' }) + assert.equal(await createMfaApi(client).createEmailChallenge(), 'ch1') + const [call] = client.calls + assert.equal(call.method, 'post') + assert.equal(call.url, 'https://ticket.webklar.com/v1/account/mfa/challenges') + assert.deepEqual(call.params, { factor: 'email' }) + assert.equal(call.headers['X-Appwrite-Locale'], 'de') +}) + +test('completeChallenge: PUT mit challengeId und Code', async () => { + const client = fakeClient({}) + await createMfaApi(client).completeChallenge('c1', '123456') + assert.equal(client.calls[0].method, 'put') + assert.deepEqual(client.calls[0].params, { challengeId: 'c1', otp: '123456' }) +}) + +test('isMoreFactorsError', () => { + assert.equal(isMoreFactorsError({ type: 'user_more_factors_required' }), true) + assert.equal(isMoreFactorsError({ type: 'user_unauthorized', code: 401 }), false) + assert.equal(isMoreFactorsError(null), false) +}) + +test('mfaErrorMessage: feste Texte', () => { + assert.equal(mfaErrorMessage({ type: 'user_invalid_token' }), 'Code falsch oder abgelaufen.') + assert.equal(mfaErrorMessage({ code: 429 }), 'Zu viele Versuche. Bitte warte einige Minuten.') + assert.equal(mfaErrorMessage({ type: 'general_rate_limit_exceeded' }), 'Zu viele Versuche. Bitte warte einige Minuten.') + assert.equal(mfaErrorMessage({ type: 'user_email_not_verified' }), '2FA ist für dein Konto noch nicht eingerichtet – bitte Kenso Bescheid geben.') + assert.equal(mfaErrorMessage({ message: 'Netzwerk weg' }), 'Netzwerk weg') + assert.equal(mfaErrorMessage({}), 'Anmeldung fehlgeschlagen') +}) + +test('maskEmail', () => { + assert.equal(maskEmail('kenso@webklar.com'), 'k…@webklar.com') + assert.equal(maskEmail(''), '') +})