2FA-Login: Prüfung schlägt nicht offen fehl, Fehlversuche vor dem Aufruf zählen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
acc86c267b
commit
d7c2027d3f
6 files changed
+48
-5
No files matched your search
@@ -10,7 +10,7 @@ import {
|
|||||||
updateDocument,
|
updateDocument,
|
||||||
Query,
|
Query,
|
||||||
} from '../services/appwriteAdmin.js'
|
} from '../services/appwriteAdmin.js'
|
||||||
import { loginWithAppwrite, getLoginCooldownRemainingSec } from '../services/appwriteClient.js'
|
import { loginWithAppwrite, getLoginCooldownRemainingSec, sessionNeedsSecondFactor } from '../services/appwriteClient.js'
|
||||||
import {
|
import {
|
||||||
SESSION_REVOKED_MESSAGE,
|
SESSION_REVOKED_MESSAGE,
|
||||||
clearPortalSession,
|
clearPortalSession,
|
||||||
@@ -169,7 +169,8 @@ router.post('/login', async (req, res) => {
|
|||||||
const staff = await resolveStaffAccount(user.$id, email.trim(), account)
|
const staff = await resolveStaffAccount(user.$id, email.trim(), account)
|
||||||
if (staff) {
|
if (staff) {
|
||||||
// Mitarbeiter mit Appwrite-2FA: erst der Code aus der Mail macht daraus eine Portal-Sitzung
|
// Mitarbeiter mit Appwrite-2FA: erst der Code aus der Mail macht daraus eine Portal-Sitzung
|
||||||
if (account?.mfa === true) {
|
// mfa-Flag ODER Appwrite verlangt einen zweiten Faktor: scheitert der Konto-Abruf, entscheidet Appwrite
|
||||||
|
if (account?.mfa === true || (await sessionNeedsSecondFactor(user.sessionCookie))) {
|
||||||
const { key, maskedEmail } = await startStaffMfa({ userId: user.$id, email: staff.email, sessionCookie: user.sessionCookie }).catch((err) => {
|
const { key, maskedEmail } = await startStaffMfa({ userId: user.$id, email: staff.email, sessionCookie: user.sessionCookie }).catch((err) => {
|
||||||
if (err.type === 'user_email_not_verified') {
|
if (err.type === 'user_email_not_verified') {
|
||||||
err.status = 403
|
err.status = 403
|
||||||
|
|||||||
@@ -64,6 +64,21 @@ export function appwriteSessionFetch(path, { method = 'GET', body, sessionCookie
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Braucht die Sitzung noch einen zweiten Faktor (Appwrite-2FA)? Fragt Appwrite selbst statt des
|
||||||
|
* mfa-Flags aus dem Admin-Abruf, damit ein gestoerter Abruf nicht ohne Code durchlaesst. Andere
|
||||||
|
* Fehler werden geworfen (fail closed).
|
||||||
|
*/
|
||||||
|
export async function sessionNeedsSecondFactor(sessionCookie) {
|
||||||
|
try {
|
||||||
|
await appwriteSessionFetch('/account', { sessionCookie })
|
||||||
|
return false
|
||||||
|
} catch (err) {
|
||||||
|
if (err.type === 'user_more_factors_required') return true
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Login via Appwrite Auth REST. userId kommt aus der Session – kein users.read nötig.
|
* Login via Appwrite Auth REST. userId kommt aus der Session – kein users.read nötig.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -51,7 +51,9 @@ export async function startStaffMfa({ userId, email, sessionCookie }) {
|
|||||||
/** @returns {Promise<{ok: true, userId: string, email: string} | {ok: false, reason: 'invalid' | 'gone'}>} */
|
/** @returns {Promise<{ok: true, userId: string, email: string} | {ok: false, reason: 'invalid' | 'gone'}>} */
|
||||||
export async function verifyStaffMfa(key, otp) {
|
export async function verifyStaffMfa(key, otp) {
|
||||||
const entry = liveEntry(key)
|
const entry = liveEntry(key)
|
||||||
if (!entry) return { ok: false, reason: 'gone' }
|
if (!entry || entry.attempts >= MAX_ATTEMPTS) return { ok: false, reason: 'gone' }
|
||||||
|
// Versuch vor dem Appwrite-Aufruf reservieren, sonst zaehlen parallele Anfragen alle als erster Versuch
|
||||||
|
entry.attempts += 1
|
||||||
try {
|
try {
|
||||||
await appwriteSessionFetch('/account/mfa/challenges', {
|
await appwriteSessionFetch('/account/mfa/challenges', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -59,8 +61,10 @@ export async function verifyStaffMfa(key, otp) {
|
|||||||
sessionCookie: entry.sessionCookie,
|
sessionCookie: entry.sessionCookie,
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.type !== 'user_invalid_token') throw err
|
if (err.type !== 'user_invalid_token') {
|
||||||
entry.attempts += 1
|
entry.attempts -= 1
|
||||||
|
throw err
|
||||||
|
}
|
||||||
if (entry.attempts >= MAX_ATTEMPTS) pending.delete(key)
|
if (entry.attempts >= MAX_ATTEMPTS) pending.delete(key)
|
||||||
return { ok: false, reason: 'invalid' }
|
return { ok: false, reason: 'invalid' }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,3 +101,14 @@ test('2FA an, E-Mail nicht bestaetigt: Hinweis statt Sitzung', async () => {
|
|||||||
assert.equal(res.body.error, '2FA ist für dein Konto noch nicht eingerichtet – bitte Kenso Bescheid geben.')
|
assert.equal(res.body.error, '2FA ist für dein Konto noch nicht eingerichtet – bitte Kenso Bescheid geben.')
|
||||||
assert.equal(cookieNamed(res, SESSION), null)
|
assert.equal(cookieNamed(res, SESSION), null)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('2FA-Pruefung schlaegt nicht offen fehl, wenn der Konto-Abruf scheitert', async () => {
|
||||||
|
aw.failUserLookups(true)
|
||||||
|
try {
|
||||||
|
const res = await login('kenso@webklar.com', 'mit-mit-mit')
|
||||||
|
assert.equal(cookieNamed(res, SESSION), null)
|
||||||
|
assert.equal(res.body.mfaRequired, true)
|
||||||
|
} finally {
|
||||||
|
aw.failUserLookups(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -79,3 +79,11 @@ test('Code erneut senden: neue Mail, nur der neue Code gilt', async () => {
|
|||||||
assert.equal((await verifyStaffMfa(key, newOtp)).ok, true)
|
assert.equal((await verifyStaffMfa(key, newOtp)).ok, true)
|
||||||
assert.equal(await resendStaffMfa(key), false)
|
assert.equal(await resendStaffMfa(key), false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Parallele Fehlversuche: hoechstens 5 Versuche zaehlen', async () => {
|
||||||
|
const { key } = await start()
|
||||||
|
const results = await Promise.all(Array.from({ length: 10 }, () => verifyStaffMfa(key, '000000')))
|
||||||
|
assert.equal(results.filter((r) => r.reason === 'invalid').length, 5)
|
||||||
|
assert.equal(results.filter((r) => r.reason === 'gone').length, 5)
|
||||||
|
assert.deepEqual(await verifyStaffMfa(key, aw.lastOtp('u_staff')), { ok: false, reason: 'gone' })
|
||||||
|
})
|
||||||
@@ -20,6 +20,7 @@ export async function startAppwriteMock({ schema = {} } = {}) {
|
|||||||
const mails = new Map()
|
const mails = new Map()
|
||||||
const userCalls = []
|
const userCalls = []
|
||||||
let lastLocale = null
|
let lastLocale = null
|
||||||
|
let failUserLookups = false
|
||||||
|
|
||||||
// Wie Appwrite (Validator UID): 'unique()' ist erlaubt, sonst [A-Za-z0-9][A-Za-z0-9._-]{0,35}
|
// Wie Appwrite (Validator UID): 'unique()' ist erlaubt, sonst [A-Za-z0-9][A-Za-z0-9._-]{0,35}
|
||||||
const validDocumentId = (id) => id === 'unique()' || (typeof id === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/.test(id))
|
const validDocumentId = (id) => id === 'unique()' || (typeof id === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/.test(id))
|
||||||
@@ -160,6 +161,7 @@ export async function startAppwriteMock({ schema = {} } = {}) {
|
|||||||
const docPath = url.pathname.match(/^\/v1\/databases\/[^/]+\/collections\/([^/]+)\/documents(?:\/([^/]+))?$/)
|
const docPath = url.pathname.match(/^\/v1\/databases\/[^/]+\/collections\/([^/]+)\/documents(?:\/([^/]+))?$/)
|
||||||
const userPath = url.pathname.match(/^\/v1\/users\/([^/]+)$/)
|
const userPath = url.pathname.match(/^\/v1\/users\/([^/]+)$/)
|
||||||
if (userPath && req.method === 'GET') {
|
if (userPath && req.method === 'GET') {
|
||||||
|
if (failUserLookups) return [503, { message: 'Nachbau: Konto-Abruf gestoert', code: 503, type: 'general_server_error' }]
|
||||||
return [200, users.has(userPath[1]) ? publicUser(users.get(userPath[1])) : { $id: userPath[1], passwordUpdate: '' }]
|
return [200, users.has(userPath[1]) ? publicUser(users.get(userPath[1])) : { $id: userPath[1], passwordUpdate: '' }]
|
||||||
}
|
}
|
||||||
// GET /v1/account (requireAppwriteStaff, Ticketsystem-JWT): jwt-admin = Mitarbeiter mit Label admin,
|
// GET /v1/account (requireAppwriteStaff, Ticketsystem-JWT): jwt-admin = Mitarbeiter mit Label admin,
|
||||||
@@ -252,6 +254,8 @@ export async function startAppwriteMock({ schema = {} } = {}) {
|
|||||||
expireChallenges: () => challenges.forEach((c) => (c.expired = true)),
|
expireChallenges: () => challenges.forEach((c) => (c.expired = true)),
|
||||||
mailsSent: (userId) => mails.get(userId) || 0,
|
mailsSent: (userId) => mails.get(userId) || 0,
|
||||||
lastLocale: () => lastLocale,
|
lastLocale: () => lastLocale,
|
||||||
|
/** GET /v1/users/:id scheitert (503), bis wieder false gesetzt wird */
|
||||||
|
failUserLookups: (on) => (failUserLookups = Boolean(on)),
|
||||||
userCalls: () => [...userCalls],
|
userCalls: () => [...userCalls],
|
||||||
close: () => new Promise((resolve) => server.close(resolve)),
|
close: () => new Promise((resolve) => server.close(resolve)),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user