diff --git a/server/services/appwriteClient.js b/server/services/appwriteClient.js index 4378d96..ac1da88 100644 --- a/server/services/appwriteClient.js +++ b/server/services/appwriteClient.js @@ -7,13 +7,14 @@ function appwriteHeaders() { } } -async function appwriteFetch(path, { method = 'GET', body } = {}) { +async function appwriteFetch(path, { method = 'GET', body, headers = {}, onResponse } = {}) { const url = `${config.appwrite.endpoint}${path}` const response = await fetch(url, { method, - headers: appwriteHeaders(), + headers: { ...appwriteHeaders(), ...headers }, body: body ? JSON.stringify(body) : undefined, }) + onResponse?.(response) let data = null const text = await response.text() @@ -41,6 +42,28 @@ async function appwriteFetch(path, { method = 'GET', body } = {}) { return data } +function readSessionCookie(response) { + const name = `a_session_${config.appwrite.projectId}=` + const cookies = response.headers.getSetCookie?.() || [] + const cookie = cookies.find((c) => c.startsWith(name)) + return cookie ? decodeURIComponent(cookie.slice(name.length).split(';')[0]) : '' +} + +/** + * Appwrite-Aufruf im Namen einer Sitzung aus loginWithAppwrite (z. B. MFA-Challenge). Deutsche Sprache, + * damit Appwrite die deutsche Mailvorlage nimmt. + */ +export function appwriteSessionFetch(path, { method = 'GET', body, sessionCookie } = {}) { + return appwriteFetch(path, { + method, + body, + headers: { + Cookie: `a_session_${config.appwrite.projectId}=${encodeURIComponent(sessionCookie || '')}`, + 'X-Appwrite-Locale': 'de', + }, + }) +} + /** * Login via Appwrite Auth REST. userId kommt aus der Session – kein users.read nötig. */ @@ -69,10 +92,14 @@ export async function loginWithAppwrite(email, password) { } let session + let sessionCookie = '' try { session = await appwriteFetch('/account/sessions/email', { method: 'POST', body: { email, password }, + onResponse: (response) => { + sessionCookie = readSessionCookie(response) + }, }) } catch (err) { if (err.status === 429) { @@ -89,7 +116,8 @@ export async function loginWithAppwrite(email, password) { throw error } - const user = { $id: session.userId, email, name: '' } + // sessionCookie: fuer den zweiten Faktor (Mitarbeiter-2FA) arbeitet der Server im Namen dieser Sitzung weiter + const user = { $id: session.userId, email, name: '', sessionCookie } clearLoginCooldown() return user diff --git a/test/staffMfa.test.js b/test/staffMfa.test.js new file mode 100644 index 0000000..5cee673 --- /dev/null +++ b/test/staffMfa.test.js @@ -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' + ) +}) diff --git a/test/support/appwriteMock.js b/test/support/appwriteMock.js index f723b43..94bb91a 100644 --- a/test/support/appwriteMock.js +++ b/test/support/appwriteMock.js @@ -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)), } }