Portal-Login: Appwrite-Sitzung merken, MFA-Aufrufe im Namen der Sitzung

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-30 10:27:05 +00:00
1 parent d5c70a5bd6
commit 23d78815f9
3 files changed
+151 -6

No files matched your search

+36
View File
@@ -0,0 +1,36 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
// Mitarbeiter-2FA per E-Mail-Code: Appwrite-Sitzung aus dem Login und offene Code-Anmeldungen
const aw = await startAppwriteMock()
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { loginWithAppwrite, appwriteSessionFetch } = await import('../server/services/appwriteClient.js')
after(() => Promise.all([aw.close(), st.close()]))
aw.addUser({ $id: 'u_staff', email: 'kenso@webklar.com', password: 'geheim-geheim', mfa: true, emailVerification: true })
test('loginWithAppwrite liefert die Appwrite-Sitzung', async () => {
const user = await loginWithAppwrite('kenso@webklar.com', 'geheim-geheim')
assert.equal(user.$id, 'u_staff')
assert.equal(user.sessionCookie, 'sess-u_staff')
})
test('appwriteSessionFetch schickt Sitzung und Sprache', async () => {
const before = aw.mailsSent('u_staff')
const challenge = await appwriteSessionFetch('/account/mfa/challenges', {
method: 'POST',
body: { factor: 'email' },
sessionCookie: 'sess-u_staff',
})
assert.match(challenge.$id, /^ch/)
assert.equal(aw.mailsSent('u_staff'), before + 1)
assert.equal(aw.lastLocale(), 'de')
await assert.rejects(
appwriteSessionFetch('/account/mfa/challenges', { method: 'PUT', body: { challengeId: challenge.$id, otp: '000000' }, sessionCookie: 'sess-u_staff' }),
(err) => err.status === 401 && err.type === 'user_invalid_token'
)
})
+84 -3
View File
@@ -13,6 +13,13 @@ export async function startAppwriteMock({ schema = {} } = {}) {
const collections = new Map()
let sequence = 0
let pendingFailure = null
// Konten, Sitzungen und MFA wie Appwrite 1.8.1 (Mitarbeiter-2FA): addUser(), lastOtp(), mailsSent()
const users = new Map()
const sessions = new Map()
const challenges = new Map()
const mails = new Map()
const userCalls = []
let lastLocale = null
// 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))
@@ -88,17 +95,84 @@ export async function startAppwriteMock({ schema = {} } = {}) {
req.on('end', () => resolve(raw ? JSON.parse(raw) : {}))
})
const sessionOf = (req) => {
const match = String(req.headers.cookie || '').match(/a_session_[^=]+=([^;]+)/)
return match ? sessions.get(decodeURIComponent(match[1])) || null : null
}
const needsMoreFactors = (session) => {
const user = users.get(session.userId)
return Boolean(user?.mfa && user?.emailVerification && session.factors.length < 2)
}
const moreFactors = [401, { message: 'More factors are required to complete the sign in process.', code: 401, type: 'user_more_factors_required' }]
const publicUser = ({ password: _pw, ...user }) => ({ passwordUpdate: '', labels: [], mfa: false, emailVerification: false, ...user })
const handleAuth = async (req, url) => {
if (url.pathname === '/v1/account/sessions/email' && req.method === 'POST') {
const { email, password } = await readBody(req)
const user = [...users.values()].find((u) => u.email === email && u.password === password)
if (!user) return [401, { message: 'Invalid credentials.', code: 401, type: 'user_invalid_credentials' }]
const secret = `sess-${user.$id}`
sessions.set(secret, { userId: user.$id, factors: ['password'] })
const project = req.headers['x-appwrite-project'] || 'x'
return [201, { $id: `s-${user.$id}`, userId: user.$id }, { 'Set-Cookie': `a_session_${project}=${secret}; path=/; httponly` }]
}
if (url.pathname === '/v1/account/mfa/challenges') {
const session = sessionOf(req)
if (!session) return [401, { message: 'Unauthorized', code: 401, type: 'general_unauthorized_scope' }]
const user = users.get(session.userId)
if (req.method === 'POST') {
lastLocale = req.headers['x-appwrite-locale'] || null
if (!user.emailVerification) return [400, { message: 'Email not verified', code: 400, type: 'user_email_not_verified' }]
const id = `ch${(sequence += 1)}`
const otp = String(100000 + Math.floor(Math.random() * 900000))
challenges.set(id, { userId: user.$id, otp, expired: false })
mails.set(user.$id, (mails.get(user.$id) || 0) + 1)
return [201, { $id: id, userId: user.$id, expire: new Date(Date.now() + 3600e3).toISOString() }]
}
if (req.method === 'PUT') {
const { challengeId, otp } = await readBody(req)
const challenge = challenges.get(challengeId)
if (!challenge || challenge.expired || challenge.userId !== user.$id || challenge.otp !== otp) {
return [401, { message: 'Invalid token passed in the request.', code: 401, type: 'user_invalid_token' }]
}
challenges.delete(challengeId)
session.factors.push('email')
return [200, { $id: `s-${user.$id}`, userId: user.$id, factors: session.factors }]
}
}
const mfaPath = url.pathname.match(/^\/v1\/users\/([^/]+)\/(mfa|verification)$/)
if (mfaPath && req.method === 'PATCH') {
const user = users.get(mfaPath[1])
if (!user) return [404, { message: 'User not found', code: 404, type: 'user_not_found' }]
const body = await readBody(req)
if (mfaPath[2] === 'mfa') user.mfa = Boolean(body.mfa)
else user.emailVerification = Boolean(body.emailVerification)
userCalls.push(`${mfaPath[2]}:${mfaPath[1]}`)
return [200, publicUser(user)]
}
return null
}
const handle = async (req) => {
const url = new URL(req.url, 'http://127.0.0.1')
const auth = await handleAuth(req, url)
if (auth) return auth
const docPath = url.pathname.match(/^\/v1\/databases\/[^/]+\/collections\/([^/]+)\/documents(?:\/([^/]+))?$/)
const userPath = url.pathname.match(/^\/v1\/users\/([^/]+)$/)
if (userPath && req.method === 'GET') return [200, { $id: userPath[1], passwordUpdate: '' }]
if (userPath && req.method === 'GET') {
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,
// jwt-kunde = angemeldet ohne Label, alles andere 401 wie Appwrite bei einem ungueltigen JWT
if (url.pathname === '/v1/account' && req.method === 'GET') {
const jwt = req.headers['x-appwrite-jwt']
if (jwt === 'jwt-admin') return [200, { $id: 'u_admin', email: 'kenso@webklar.com', labels: ['admin'] }]
if (jwt === 'jwt-kunde') return [200, { $id: 'u_admin', email: 'kenso@webklar.com', labels: [] }]
// jwt-staff = Mitarbeiter ohne Label admin, jwt-halb = JWT aus einer Sitzung ohne zweiten Faktor
if (jwt === 'jwt-staff') return [200, { $id: 'u_staff2', email: 'andrej@webklar.com', name: 'Andrej', labels: [] }]
if (jwt === 'jwt-halb') return moreFactors
const session = sessionOf(req)
if (session) return needsMoreFactors(session) ? moreFactors : [200, publicUser(users.get(session.userId))]
return [401, { message: 'User (role: guests) missing scope (account)', code: 401, type: 'general_unauthorized_scope' }]
}
if (!docPath) return [404, { message: 'Route not found', code: 404 }]
@@ -154,8 +228,8 @@ export async function startAppwriteMock({ schema = {} } = {}) {
const server = http.createServer(async (req, res) => {
try {
const [status, body] = await handle(req)
res.writeHead(status, { 'Content-Type': 'application/json' })
const [status, body, headers = {}] = await handle(req)
res.writeHead(status, { 'Content-Type': 'application/json', ...headers })
res.end(body === null ? '' : JSON.stringify(body))
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' })
@@ -172,6 +246,13 @@ export async function startAppwriteMock({ schema = {} } = {}) {
failNext: ({ collection = null, method = null, status = 500, message = '', after = 0 }) => {
pendingFailure = { collection, method, status, message, after }
},
addUser: (user) => users.set(user.$id, { labels: [], mfa: false, emailVerification: false, ...user }),
user: (id) => (users.has(id) ? publicUser(users.get(id)) : null),
lastOtp: (userId) => [...challenges.values()].filter((c) => c.userId === userId).at(-1)?.otp || null,
expireChallenges: () => challenges.forEach((c) => (c.expired = true)),
mailsSent: (userId) => mails.get(userId) || 0,
lastLocale: () => lastLocale,
userCalls: () => [...userCalls],
close: () => new Promise((resolve) => server.close(resolve)),
}
}