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 url = `${config.appwrite.endpoint}${path}`
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
headers: appwriteHeaders(),
|
headers: { ...appwriteHeaders(), ...headers },
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
})
|
})
|
||||||
|
onResponse?.(response)
|
||||||
|
|
||||||
let data = null
|
let data = null
|
||||||
const text = await response.text()
|
const text = await response.text()
|
||||||
@@ -41,6 +42,28 @@ async function appwriteFetch(path, { method = 'GET', body } = {}) {
|
|||||||
return data
|
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.
|
* 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 session
|
||||||
|
let sessionCookie = ''
|
||||||
try {
|
try {
|
||||||
session = await appwriteFetch('/account/sessions/email', {
|
session = await appwriteFetch('/account/sessions/email', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { email, password },
|
body: { email, password },
|
||||||
|
onResponse: (response) => {
|
||||||
|
sessionCookie = readSessionCookie(response)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.status === 429) {
|
if (err.status === 429) {
|
||||||
@@ -89,7 +116,8 @@ export async function loginWithAppwrite(email, password) {
|
|||||||
throw error
|
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()
|
clearLoginCooldown()
|
||||||
return user
|
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()
|
const collections = new Map()
|
||||||
let sequence = 0
|
let sequence = 0
|
||||||
let pendingFailure = null
|
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}
|
// 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))
|
||||||
@@ -88,17 +95,84 @@ export async function startAppwriteMock({ schema = {} } = {}) {
|
|||||||
req.on('end', () => resolve(raw ? JSON.parse(raw) : {}))
|
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 handle = async (req) => {
|
||||||
const url = new URL(req.url, 'http://127.0.0.1')
|
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 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') 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,
|
// 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
|
// jwt-kunde = angemeldet ohne Label, alles andere 401 wie Appwrite bei einem ungueltigen JWT
|
||||||
if (url.pathname === '/v1/account' && req.method === 'GET') {
|
if (url.pathname === '/v1/account' && req.method === 'GET') {
|
||||||
const jwt = req.headers['x-appwrite-jwt']
|
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-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: [] }]
|
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' }]
|
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 }]
|
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) => {
|
const server = http.createServer(async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const [status, body] = await handle(req)
|
const [status, body, headers = {}] = await handle(req)
|
||||||
res.writeHead(status, { 'Content-Type': 'application/json' })
|
res.writeHead(status, { 'Content-Type': 'application/json', ...headers })
|
||||||
res.end(body === null ? '' : JSON.stringify(body))
|
res.end(body === null ? '' : JSON.stringify(body))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.writeHead(500, { 'Content-Type': 'application/json' })
|
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 }) => {
|
failNext: ({ collection = null, method = null, status = 500, message = '', after = 0 }) => {
|
||||||
pendingFailure = { collection, method, status, message, after }
|
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)),
|
close: () => new Promise((resolve) => server.close(resolve)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user