Portal: offene Mitarbeiter-Code-Anmeldungen (staffMfa)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
23d78815f9
commit
25f15c0ecb
2 files changed
+121
No files matched your search
@@ -0,0 +1,76 @@
|
|||||||
|
import { randomBytes } from 'node:crypto'
|
||||||
|
import { appwriteSessionFetch } from './appwriteClient.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offene Mitarbeiter-Anmeldungen mit E-Mail-Code (Appwrite-2FA). Das Passwort ist geprueft, die
|
||||||
|
* Appwrite-Sitzung hat aber erst einen Faktor; erst der richtige Code aus der Mail macht daraus eine
|
||||||
|
* Portal-Sitzung. Nur im Speicher: nach einem Neustart meldet man sich neu an.
|
||||||
|
*/
|
||||||
|
export const MFA_COOKIE = 'webklar_portal_mfa'
|
||||||
|
export const MFA_TTL_MS = 10 * 60 * 1000
|
||||||
|
const MAX_ATTEMPTS = 5
|
||||||
|
|
||||||
|
const pending = new Map()
|
||||||
|
|
||||||
|
function purgeExpired() {
|
||||||
|
const now = Date.now()
|
||||||
|
for (const [key, entry] of pending) {
|
||||||
|
if (entry.expiresAt <= now) pending.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function liveEntry(key) {
|
||||||
|
purgeExpired()
|
||||||
|
return (key && pending.get(key)) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createChallenge(sessionCookie) {
|
||||||
|
const challenge = await appwriteSessionFetch('/account/mfa/challenges', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { factor: 'email' },
|
||||||
|
sessionCookie,
|
||||||
|
})
|
||||||
|
return challenge.$id
|
||||||
|
}
|
||||||
|
|
||||||
|
/** kenso@webklar.com -> k…@webklar.com */
|
||||||
|
export function maskEmail(email) {
|
||||||
|
const [local, domain] = String(email || '').split('@')
|
||||||
|
if (!local || !domain) return ''
|
||||||
|
return `${local[0]}…@${domain}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startStaffMfa({ userId, email, sessionCookie }) {
|
||||||
|
const challengeId = await createChallenge(sessionCookie)
|
||||||
|
purgeExpired()
|
||||||
|
const key = randomBytes(32).toString('hex')
|
||||||
|
pending.set(key, { userId, email, sessionCookie, challengeId, expiresAt: Date.now() + MFA_TTL_MS, attempts: 0 })
|
||||||
|
return { key, maskedEmail: maskEmail(email) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {Promise<{ok: true, userId: string, email: string} | {ok: false, reason: 'invalid' | 'gone'}>} */
|
||||||
|
export async function verifyStaffMfa(key, otp) {
|
||||||
|
const entry = liveEntry(key)
|
||||||
|
if (!entry) return { ok: false, reason: 'gone' }
|
||||||
|
try {
|
||||||
|
await appwriteSessionFetch('/account/mfa/challenges', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { challengeId: entry.challengeId, otp: String(otp || '').trim() },
|
||||||
|
sessionCookie: entry.sessionCookie,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
if (err.type !== 'user_invalid_token') throw err
|
||||||
|
entry.attempts += 1
|
||||||
|
if (entry.attempts >= MAX_ATTEMPTS) pending.delete(key)
|
||||||
|
return { ok: false, reason: 'invalid' }
|
||||||
|
}
|
||||||
|
pending.delete(key)
|
||||||
|
return { ok: true, userId: entry.userId, email: entry.email }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resendStaffMfa(key) {
|
||||||
|
const entry = liveEntry(key)
|
||||||
|
if (!entry) return false
|
||||||
|
entry.challengeId = await createChallenge(entry.sessionCookie)
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -34,3 +34,48 @@ test('appwriteSessionFetch schickt Sitzung und Sprache', async () => {
|
|||||||
(err) => err.status === 401 && err.type === 'user_invalid_token'
|
(err) => err.status === 401 && err.type === 'user_invalid_token'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { maskEmail, startStaffMfa, verifyStaffMfa, resendStaffMfa } = await import('../server/services/staffMfa.js')
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
const { sessionCookie } = await loginWithAppwrite('kenso@webklar.com', 'geheim-geheim')
|
||||||
|
return startStaffMfa({ userId: 'u_staff', email: 'kenso@webklar.com', sessionCookie })
|
||||||
|
}
|
||||||
|
|
||||||
|
test('maskEmail: erstes Zeichen, Rest der Adresse ausgeblendet', () => {
|
||||||
|
assert.equal(maskEmail('kenso@webklar.com'), 'k…@webklar.com')
|
||||||
|
assert.equal(maskEmail('a@b.de'), 'a…@b.de')
|
||||||
|
assert.equal(maskEmail(''), '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Richtiger Code meldet an, danach ist die Anmeldung verbraucht', async () => {
|
||||||
|
const { key, maskedEmail } = await start()
|
||||||
|
assert.equal(maskedEmail, 'k…@webklar.com')
|
||||||
|
assert.deepEqual(await verifyStaffMfa(key, aw.lastOtp('u_staff')), { ok: true, userId: 'u_staff', email: 'kenso@webklar.com' })
|
||||||
|
assert.deepEqual(await verifyStaffMfa(key, aw.lastOtp('u_staff')), { ok: false, reason: 'gone' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Falscher Code: nach 5 Fehlversuchen ist die Anmeldung weg', async () => {
|
||||||
|
const { key } = await start()
|
||||||
|
for (let i = 0; i < 5; i++) assert.deepEqual(await verifyStaffMfa(key, '000000'), { ok: false, reason: 'invalid' })
|
||||||
|
assert.deepEqual(await verifyStaffMfa(key, aw.lastOtp('u_staff')), { ok: false, reason: 'gone' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Nach 10 Minuten ist die Anmeldung abgelaufen', async (t) => {
|
||||||
|
const { key } = await start()
|
||||||
|
const now = Date.now()
|
||||||
|
t.mock.method(Date, 'now', () => now + 11 * 60 * 1000)
|
||||||
|
assert.deepEqual(await verifyStaffMfa(key, aw.lastOtp('u_staff')), { ok: false, reason: 'gone' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Code erneut senden: neue Mail, nur der neue Code gilt', async () => {
|
||||||
|
const { key } = await start()
|
||||||
|
const oldOtp = aw.lastOtp('u_staff')
|
||||||
|
const before = aw.mailsSent('u_staff')
|
||||||
|
assert.equal(await resendStaffMfa(key), true)
|
||||||
|
assert.equal(aw.mailsSent('u_staff'), before + 1)
|
||||||
|
const newOtp = aw.lastOtp('u_staff')
|
||||||
|
if (oldOtp !== newOtp) assert.deepEqual(await verifyStaffMfa(key, oldOtp), { ok: false, reason: 'invalid' })
|
||||||
|
assert.equal((await verifyStaffMfa(key, newOtp)).ok, true)
|
||||||
|
assert.equal(await resendStaffMfa(key), false)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user