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:
1 parent
d5c70a5bd6
commit
23d78815f9
3 files changed
+151
-6
No files matched your search
@@ -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
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user