Login mit E-Mail-Code (Appwrite-2FA)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
21325c4037
commit
b8e4de97c7
5 files changed
+252
-6
No files matched your search
@@ -8,9 +8,12 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
hasAppwriteSession,
|
hasAppwriteSession,
|
||||||
projectId as PROJECT_ID,
|
projectId as PROJECT_ID,
|
||||||
|
client,
|
||||||
} from '../lib/appwrite'
|
} from '../lib/appwrite'
|
||||||
|
import { createMfaApi, isMoreFactorsError, maskEmail, mfaErrorMessage } from '../lib/mfa'
|
||||||
|
|
||||||
const AuthContext = createContext()
|
const AuthContext = createContext()
|
||||||
|
const mfa = createMfaApi(client)
|
||||||
|
|
||||||
function clearStaleAppwriteSessions() {
|
function clearStaleAppwriteSessions() {
|
||||||
if (typeof window === 'undefined' || !window.localStorage) return
|
if (typeof window === 'undefined' || !window.localStorage) return
|
||||||
@@ -89,6 +92,9 @@ export function AuthProvider({ children }) {
|
|||||||
const [user, setUser] = useState(null)
|
const [user, setUser] = useState(null)
|
||||||
const [employee, setEmployee] = useState(null)
|
const [employee, setEmployee] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
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(() => {
|
useEffect(() => {
|
||||||
checkUser()
|
checkUser()
|
||||||
@@ -106,6 +112,12 @@ export function AuthProvider({ children }) {
|
|||||||
setUser(session)
|
setUser(session)
|
||||||
setEmployee(await ensureEmployeeExists(session))
|
setEmployee(await ensureEmployeeExists(session))
|
||||||
} catch (error) {
|
} 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) {
|
if (error.code !== 401 && error.code !== 404) {
|
||||||
console.error('Unexpected error checking user:', error)
|
console.error('Unexpected error checking user:', error)
|
||||||
}
|
}
|
||||||
@@ -126,7 +138,15 @@ export function AuthProvider({ children }) {
|
|||||||
|
|
||||||
await createSessionWithCleanup(normalizedEmail, normalizedPassword)
|
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)
|
setUser(session)
|
||||||
setEmployee(await ensureEmployeeExists(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() {
|
async function logout() {
|
||||||
try {
|
try {
|
||||||
await account.deleteSessions()
|
await account.deleteSessions()
|
||||||
@@ -165,6 +217,7 @@ export function AuthProvider({ children }) {
|
|||||||
clearAllLocalAppwriteSessions()
|
clearAllLocalAppwriteSessions()
|
||||||
setUser(null)
|
setUser(null)
|
||||||
setEmployee(null)
|
setEmployee(null)
|
||||||
|
setMfaPending(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +229,11 @@ export function AuthProvider({ children }) {
|
|||||||
loading,
|
loading,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
isAdmin
|
isAdmin,
|
||||||
|
mfaPending,
|
||||||
|
sendCode,
|
||||||
|
verifyCode,
|
||||||
|
cancelMfa,
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@ if (!projectId) {
|
|||||||
console.error('❌ FEHLER: VITE_APPWRITE_PROJECT_ID ist nicht gesetzt!')
|
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 account = new Account(client)
|
||||||
export const databases = new Databases(client)
|
export const databases = new Databases(client)
|
||||||
|
|||||||
@@ -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}`
|
||||||
|
}
|
||||||
+102
-3
@@ -8,8 +8,10 @@ export default function LoginPage() {
|
|||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [code, setCode] = useState('')
|
||||||
|
const [notice, setNotice] = useState('')
|
||||||
|
|
||||||
const { login } = useAuth()
|
const { login, mfaPending, sendCode, verifyCode, cancelMfa } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
@@ -22,6 +24,8 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
navigate('/tickets')
|
navigate('/tickets')
|
||||||
|
} else if (result.mfaRequired) {
|
||||||
|
if (result.error) setError(result.error)
|
||||||
} else {
|
} else {
|
||||||
// Bessere Fehlermeldungen
|
// Bessere Fehlermeldungen
|
||||||
let errorMessage = result.error || 'Login fehlgeschlagen'
|
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 && (
|
||||||
|
<form onSubmit={handleCode}>
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red text-white p-1 mb-2" style={{ borderRadius: '4px' }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p style={{ marginTop: 0 }}>
|
||||||
|
{!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.`}
|
||||||
|
</p>
|
||||||
|
{notice && <p style={{ marginTop: 0 }}>{notice}</p>}
|
||||||
|
{mfaPending.challengeId ? (
|
||||||
|
<>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label" htmlFor="mfa-code">Code aus der E-Mail</label>
|
||||||
|
<input
|
||||||
|
id="mfa-code"
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
maxLength={6}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
style={{ letterSpacing: '0.3em' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="btn btn-green" style={{ width: '100%' }} disabled={loading}>
|
||||||
|
{loading ? 'Prüfe…' : 'Anmelden'}
|
||||||
|
</button>
|
||||||
|
<p style={{ textAlign: 'center', marginBottom: 0 }}>
|
||||||
|
<button type="button" style={linkButton} onClick={handleSend} disabled={loading}>Code erneut senden</button>
|
||||||
|
{' · '}
|
||||||
|
<button type="button" style={linkButton} onClick={handleCancel}>Abbrechen</button>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button type="button" className="btn btn-green" style={{ width: '100%' }} onClick={handleSend} disabled={loading}>
|
||||||
|
{loading ? 'Sende…' : 'Code senden'}
|
||||||
|
</button>
|
||||||
|
<p style={{ textAlign: 'center', marginBottom: 0 }}>
|
||||||
|
<button type="button" style={linkButton} onClick={handleCancel}>Abbrechen</button>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
@@ -95,7 +194,7 @@ export default function LoginPage() {
|
|||||||
<h2>Webklar WOMS 2.0</h2>
|
<h2>Webklar WOMS 2.0</h2>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '16px', color: '#e2e8f0' }}>
|
<div style={{ padding: '16px', color: '#e2e8f0' }}>
|
||||||
<form onSubmit={handleSubmit}>
|
{codeStep || <form onSubmit={handleSubmit}>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red text-white p-1 mb-2" style={{ borderRadius: '4px' }}>
|
<div className="bg-red text-white p-1 mb-2" style={{ borderRadius: '4px' }}>
|
||||||
{error}
|
{error}
|
||||||
@@ -135,7 +234,7 @@ export default function LoginPage() {
|
|||||||
>
|
>
|
||||||
{loading ? 'Login läuft...' : 'Login'}
|
{loading ? 'Login läuft...' : 'Login'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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(''), '')
|
||||||
|
})
|
||||||
Reference in new issue
Block a user