Compare commits

...
6 Commits
19 changed files with 945 additions and 44 deletions

No files matched your search

+1
View File
@@ -149,6 +149,7 @@
<a class="portal-logo" href="/admin.html">Webklar <span style="opacity:.6">Admin</span></a>
<div class="portal-header-actions">
<span class="portal-meta" id="admin-meta"></span>
<a class="portal-btn-outline" href="https://ticket.webklar.com/tickets">← Ticketsystem</a>
<button type="button" class="portal-btn-outline" id="logout-btn">Abmelden</button>
</div>
</header>
+57
View File
@@ -548,6 +548,10 @@ async function initLoginPage() {
password: document.getElementById('password').value,
}),
})
if (result.mfaRequired) {
showMfaStep(result.email)
return
}
if (result.role === 'admin') {
window.location.href = adminLoginTarget()
return
@@ -568,6 +572,59 @@ async function initLoginPage() {
})
}
/* Mitarbeiter-2FA: Code aus der E-Mail (POST /api/auth/login/mfa) */
function showMfaStep(maskedEmail) {
const loginForm = document.getElementById('login-form')
const form = document.getElementById('mfa-form')
const input = document.getElementById('mfa-code')
const errorEl = document.getElementById('mfa-error')
const btn = document.getElementById('mfa-btn')
const resend = document.getElementById('mfa-resend')
loginForm?.classList.add('hidden')
form?.classList.remove('hidden')
document.getElementById('mfa-hint').textContent = `Wir haben dir einen Code an ${maskedEmail} geschickt.`
input?.focus()
form?.addEventListener('submit', async (e) => {
e.preventDefault()
if (btn.disabled) return
hideError(errorEl)
btn.disabled = true
btn.classList.add('is-loading')
try {
await api('/api/auth/login/mfa', { method: 'POST', body: JSON.stringify({ otp: input.value.trim() }) })
window.location.href = adminLoginTarget()
} catch (err) {
if (err.status === 410) {
window.location.href = `/login.html?error=${encodeURIComponent(err.message)}`
return
}
showError(errorEl, err.message)
input.select()
} finally {
btn.disabled = false
btn.classList.remove('is-loading')
}
})
resend?.addEventListener('click', async () => {
hideError(errorEl)
resend.disabled = true
try {
await api('/api/auth/login/mfa/resend', { method: 'POST' })
document.getElementById('mfa-hint').textContent = `Neuer Code ist unterwegs an ${maskedEmail}.`
} catch (err) {
if (err.status === 410) {
window.location.href = `/login.html?error=${encodeURIComponent(err.message)}`
return
}
showError(errorEl, err.message)
} finally {
resend.disabled = false
}
})
}
/* --------------------------------------------------------------- View-as --- */
function renderViewAsBanner(me) {
const banner = document.getElementById('viewas-banner')
+22
View File
@@ -265,6 +265,13 @@
border-color 0.25s var(--ease-out);
}
a.portal-btn-outline {
display: inline-flex;
align-items: center;
line-height: normal;
text-decoration: none;
}
.portal-btn-outline:hover {
background: #fff;
color: #1c1c1c;
@@ -647,3 +654,18 @@
}
}
}
/* Mitarbeiter-2FA: Code-Schritt */
#mfa-form.hidden { display: none; }
.mfa-hint { margin: 0 0 4px; text-align: center; }
#mfa-code { letter-spacing: 0.3em; font-variant-numeric: tabular-nums; }
.mfa-actions { margin-top: 12px; }
.mfa-link {
background: none;
border: 0;
padding: 0;
font: inherit;
color: inherit;
text-decoration: underline;
cursor: pointer;
}
+25
View File
@@ -72,6 +72,31 @@
</svg>
</button>
</form>
<!-- Mitarbeiter mit 2FA: zweiter Schritt, Code aus der E-Mail -->
<form id="mfa-form" class="login-form hidden" novalidate>
<p id="mfa-hint" class="login-hint mfa-hint"></p>
<label class="auth-label" for="mfa-code">Code aus der E-Mail</label>
<input
class="auth-input"
type="text"
name="otp"
id="mfa-code"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="6"
pattern="[0-9]{6}"
enterkeyhint="go"
>
<p id="mfa-error" class="error hidden"></p>
<button type="submit" id="mfa-btn" class="auth-button login-button">
<span class="button-label">Anmelden</span>
</button>
<p class="login-hint mfa-actions">
<button type="button" id="mfa-resend" class="mfa-link">Code erneut senden</button>
·
<a href="/login.html" class="mfa-link">Abbrechen</a>
</p>
</form>
</div>
<p class="login-hint">
Zugangsdaten erhältst du von Webklar. Dein Passwort kannst du nach der Anmeldung selbst ändern (Profilbild oben rechts, „Passwort ändern“).
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env node
/**
* Mitarbeiter-2FA (E-Mail-Code) anzeigen, einschalten, abschalten.
*
* Usage (im Container, mit dem Server-Key):
* docker exec -it -w /app webklar-kundenbereich-server node scripts/mitarbeiter-2fa.mjs --status
* ... --enable <userId>|alle [--yes]
* ... --disable <userId> (Notfall: bei einer Person kommt keine Mail an)
*/
import 'dotenv/config'
import { createInterface } from 'node:readline/promises'
import { disableStaffMfa, enableStaffMfa, listStaffMfaStatus } from '../server/services/staffMfaAdmin.js'
const args = process.argv.slice(2)
const flag = (name) => {
const i = args.indexOf(name)
return i === -1 ? null : args[i + 1] || ''
}
function printTable(rows) {
for (const r of rows) {
console.log(
`${r.userId.padEnd(22)} ${r.name.padEnd(20)} ${r.email.padEnd(32)} E-Mail bestätigt: ${r.emailVerification ? 'ja ' : 'nein'} 2FA: ${r.mfa ? 'an' : 'aus'}`
)
}
}
async function confirm(question) {
if (args.includes('--yes')) return true
const rl = createInterface({ input: process.stdin, output: process.stdout })
const answer = await rl.question(`${question} (Ja/Nein) `)
rl.close()
return /^j(a)?$/i.test(answer.trim())
}
async function main() {
const rows = await listStaffMfaStatus()
if (args.includes('--status')) {
printTable(rows)
return
}
const enable = flag('--enable')
if (enable !== null) {
const targets = enable === 'alle' ? rows.filter((r) => !r.mfa) : rows.filter((r) => r.userId === enable)
if (!targets.length) throw new Error(enable === 'alle' ? 'Alle Mitarbeiter haben 2FA schon an.' : `Kein Mitarbeiter mit userId ${enable}`)
console.log('Der Code geht an diese Adressen – sie müssen Post empfangen:')
printTable(targets)
if (!(await confirm('2FA für diese Konten einschalten?'))) return console.log('Abgebrochen.')
for (const r of targets) {
await enableStaffMfa(r.userId)
console.log(`2FA an: ${r.name} <${r.email}>`)
}
return
}
const disable = flag('--disable')
if (disable) {
const target = rows.find((r) => r.userId === disable)
if (!target) throw new Error(`Kein Mitarbeiter mit userId ${disable}`)
await disableStaffMfa(disable)
console.log(`2FA aus: ${target.name} <${target.email}>`)
return
}
console.log('Usage: --status | --enable <userId>|alle [--yes] | --disable <userId>')
}
main().catch((err) => {
console.error(err.message)
process.exit(1)
})
+2
View File
@@ -22,6 +22,7 @@ import adminCustomersRoutes from './routes/admin/customers.js'
import adminEmployeesRoutes from './routes/admin/employees.js'
import adminInvoiceClientsRoutes from './routes/admin/invoiceClients.js'
import adminPreviewOpenRoutes from './routes/admin/previewOpen.js'
import adminSwitchLinkRoutes from './routes/admin/switchLink.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const publicDir = path.join(__dirname, '..', 'public')
@@ -65,6 +66,7 @@ app.use('/api/admin/customers', adminCustomersRoutes)
app.use('/api/admin/employees', adminEmployeesRoutes)
app.use('/api/admin/invoice-clients', adminInvoiceClientsRoutes)
app.use('/api/admin/preview-open', adminPreviewOpenRoutes)
app.use('/api/admin/switch-link', adminSwitchLinkRoutes)
app.use('/webhook', giteaWebhookRoutes)
app.get('/api/health', async (_req, res) => {
+50
View File
@@ -0,0 +1,50 @@
import { Router } from 'express'
import { config } from '../../config.js'
import { createStaffPreviewToken } from '../../services/staffPreviewTokens.js'
import { resolveStaffAccount } from '../../services/staffAccount.js'
const router = Router()
/**
* Wechsel Ticketsystem -> Projekt-Admin ohne zweiten Login. Wie requireAppwriteStaff wird das JWT
* ueber /account geprueft (eine Sitzung ohne zweiten Faktor bekommt dort 401), aber ohne Label-Pflicht:
* jeder Mitarbeiter darf rueber, wie beim normalen Portal-Login.
*/
async function appwriteAccountFromJwt(req) {
const match = (req.get('Authorization') || '').match(/^Bearer\s+(.+)$/i)
if (!match) return null
const response = await fetch(`${config.appwrite.endpoint}/account`, {
headers: {
'Content-Type': 'application/json',
'X-Appwrite-Project': config.appwrite.projectId,
'X-Appwrite-JWT': match[1],
},
})
if (!response.ok) return null
return response.json()
}
router.post('/', async (req, res) => {
try {
const user = await appwriteAccountFromJwt(req)
if (!user?.$id) return res.status(401).json({ error: 'Bitte im Ticketsystem neu anmelden.' })
const staff = await resolveStaffAccount(user.$id, user.email || '', user)
if (!staff) return res.status(403).json({ error: 'Kein Mitarbeiterkonto.' })
const token = createStaffPreviewToken({
purpose: 'switch',
userId: user.$id,
email: staff.email,
name: staff.name,
isAdmin: staff.isAdmin,
})
return res.json({
openUrl: `https://${config.preview.baseHost}/api/auth/staff-switch?token=${encodeURIComponent(token)}`,
})
} catch (err) {
return res.status(500).json({ error: err.message || 'Wechsel fehlgeschlagen' })
}
})
export default router
+97 -37
View File
@@ -4,15 +4,13 @@ import {
getCustomerByAppwriteUserId,
getCustomerByEmail,
getDocument,
getEmployeeByEmail,
getEmployeeByUserId,
getPortalAccessByCustomerId,
getUserById,
listDocuments,
updateDocument,
Query,
} from '../services/appwriteAdmin.js'
import { loginWithAppwrite, getLoginCooldownRemainingSec } from '../services/appwriteClient.js'
import { loginWithAppwrite, getLoginCooldownRemainingSec, sessionNeedsSecondFactor } from '../services/appwriteClient.js'
import {
SESSION_REVOKED_MESSAGE,
clearPortalSession,
@@ -29,6 +27,8 @@ import {
verifyPreviewSessionToken,
} from '../services/previewSession.js'
import { consumeStaffPreviewToken } from '../services/staffPreviewTokens.js'
import { resolveStaffAccount } from '../services/staffAccount.js'
import { MFA_COOKIE, MFA_TTL_MS, resendStaffMfa, startStaffMfa, verifyStaffMfa } from '../services/staffMfa.js'
const router = Router()
@@ -54,23 +54,6 @@ function sanitizeCustomer(customer) {
* Gibt null zurueck, wenn der Login kein Mitarbeiter ist (dann greift der
* normale Kunden-Login).
*/
async function resolveStaffAccount(appwriteUserId, email, account) {
const isAdmin = Array.isArray(account?.labels) && account.labels.includes('admin')
let employee = await getEmployeeByUserId(appwriteUserId).catch(() => null)
if (!employee) {
employee = await getEmployeeByEmail(account?.email || email).catch(() => null)
}
if (!employee && !isAdmin) return null
return {
isAdmin,
name: employee?.displayName || account?.name || 'Mitarbeiter',
email: account?.email || employee?.email || email,
}
}
async function validatePortalAccess(appwriteUserId, email) {
let customer = await getCustomerByAppwriteUserId(appwriteUserId)
if (!customer && email) {
@@ -185,23 +168,22 @@ router.post('/login', async (req, res) => {
// Ticketsystem-Mitarbeiter (employees-Collection) bekommen die Admin-Ansicht
const staff = await resolveStaffAccount(user.$id, email.trim(), account)
if (staff) {
setPortalSession(res, {
role: 'admin',
isAdmin: staff.isAdmin,
appwriteUserId: user.$id,
name: staff.name,
email: staff.email,
})
const staffPreviewToken = await createPreviewSessionToken({
userId: user.$id,
customerId: '',
email: staff.email,
name: staff.name,
role: 'admin',
labels: staff.isAdmin ? ['admin'] : [],
authSource: 'kundenbereich-admin',
})
res.cookie(PREVIEW_COOKIE_NAME, staffPreviewToken, previewSessionCookieOptions())
// Mitarbeiter mit Appwrite-2FA: erst der Code aus der Mail macht daraus eine Portal-Sitzung
// mfa-Flag ODER Appwrite verlangt einen zweiten Faktor: scheitert der Konto-Abruf, entscheidet Appwrite
if (account?.mfa === true || (await sessionNeedsSecondFactor(user.sessionCookie))) {
const { key, maskedEmail } = await startStaffMfa({ userId: user.$id, email: staff.email, sessionCookie: user.sessionCookie }).catch((err) => {
if (err.type === 'user_email_not_verified') {
err.status = 403
err.message = '2FA ist für dein Konto noch nicht eingerichtet – bitte Kenso Bescheid geben.'
} else if (err.status === 429) {
err.message = 'Zu viele Versuche. Bitte warte einige Minuten.'
}
throw err
})
res.cookie(MFA_COOKIE, key, { ...mfaCookieOptions(), maxAge: MFA_TTL_MS })
return res.json({ mfaRequired: true, email: maskedEmail })
}
await startStaffPortalSession(res, { userId: user.$id, staff })
return res.json({
success: true,
role: 'admin',
@@ -354,6 +336,19 @@ router.get('/staff-preview', async (req, res) => {
return res.redirect(previewUrl)
})
// Wechsel aus dem Ticketsystem (POST /api/admin/switch-link): Einmal-Link -> Admin-Sitzung
router.get('/staff-switch', async (req, res) => {
const tokenData = consumeStaffPreviewToken(String(req.query.token || ''), 'switch')
if (!tokenData) {
return res.redirect(`/login.html?error=${encodeURIComponent('Link abgelaufen, bitte anmelden')}`)
}
await startStaffPortalSession(res, {
userId: tokenData.userId,
staff: { isAdmin: Boolean(tokenData.isAdmin), name: tokenData.name, email: tokenData.email },
})
return res.redirect('/admin.html')
})
// Selbstheilung: Portal-Session lebt oft laenger als das preview_session-Cookie
// (bzw. das Cookie fehlt nach WebView-/Browser-Datenverlust). Ohne gueltiges
// Preview-Cookie laufen die *.project.webklar.com-Hosts in eine 302-Schleife.
@@ -461,4 +456,69 @@ router.get('/me', async (req, res) => {
}
})
function mfaCookieOptions() {
return { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', signed: true }
}
/** Portal-Sitzung + Preview-Cookie fuer einen Mitarbeiter (Login, Code-Bestaetigung, Wechsel aus dem Ticketsystem). */
async function startStaffPortalSession(res, { userId, staff }) {
setPortalSession(res, {
role: 'admin',
isAdmin: staff.isAdmin,
appwriteUserId: userId,
name: staff.name,
email: staff.email,
})
const staffPreviewToken = await createPreviewSessionToken({
userId,
customerId: '',
email: staff.email,
name: staff.name,
role: 'admin',
labels: staff.isAdmin ? ['admin'] : [],
authSource: 'kundenbereich-admin',
})
res.cookie(PREVIEW_COOKIE_NAME, staffPreviewToken, previewSessionCookieOptions())
}
const MFA_GONE = 'Bitte melde dich erneut an.'
router.post('/login/mfa', async (req, res) => {
const key = req.signedCookies?.[MFA_COOKIE]
try {
const result = await verifyStaffMfa(key, req.body?.otp)
if (!result.ok) {
if (result.reason === 'invalid') return res.status(401).json({ error: 'Code falsch oder abgelaufen.' })
res.clearCookie(MFA_COOKIE, mfaCookieOptions())
return res.status(410).json({ error: MFA_GONE })
}
res.clearCookie(MFA_COOKIE, mfaCookieOptions())
const account = await getUserById(result.userId).catch(() => null)
const staff = await resolveStaffAccount(result.userId, result.email, account)
if (!staff) return res.status(403).json({ error: 'Kein Mitarbeiterkonto.' })
await startStaffPortalSession(res, { userId: result.userId, staff })
return res.json({
success: true,
role: 'admin',
admin: { name: staff.name, email: staff.email, isAdmin: staff.isAdmin },
})
} catch (err) {
if (err.status === 429) return res.status(429).json({ error: 'Zu viele Versuche. Bitte warte einige Minuten.' })
return res.status(err.status || 500).json({ error: err.message || 'Anmeldung fehlgeschlagen' })
}
})
router.post('/login/mfa/resend', async (req, res) => {
try {
if (!(await resendStaffMfa(req.signedCookies?.[MFA_COOKIE]))) {
res.clearCookie(MFA_COOKIE, mfaCookieOptions())
return res.status(410).json({ error: MFA_GONE })
}
return res.json({ success: true })
} catch (err) {
if (err.status === 429) return res.status(429).json({ error: 'Zu viele Versuche. Bitte warte einige Minuten.' })
return res.status(err.status || 500).json({ error: err.message || 'Code konnte nicht gesendet werden' })
}
})
export default router
+11
View File
@@ -132,6 +132,17 @@ export async function updateAppwriteUserLabels(userId, labels) {
})
}
export async function updateAppwriteUserMfa(userId, mfa) {
return adminFetch(`/users/${userId}/mfa`, { method: 'PATCH', body: { mfa: Boolean(mfa) } })
}
export async function updateAppwriteUserEmailVerification(userId, emailVerification) {
return adminFetch(`/users/${userId}/verification`, {
method: 'PATCH',
body: { emailVerification: Boolean(emailVerification) },
})
}
export async function deleteAppwriteUser(userId) {
return adminFetch(`/users/${userId}`, { method: 'DELETE' })
}
+46 -3
View File
@@ -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,43 @@ 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',
},
})
}
/**
* Braucht die Sitzung noch einen zweiten Faktor (Appwrite-2FA)? Fragt Appwrite selbst statt des
* mfa-Flags aus dem Admin-Abruf, damit ein gestoerter Abruf nicht ohne Code durchlaesst. Andere
* Fehler werden geworfen (fail closed).
*/
export async function sessionNeedsSecondFactor(sessionCookie) {
try {
await appwriteSessionFetch('/account', { sessionCookie })
return false
} catch (err) {
if (err.type === 'user_more_factors_required') return true
throw err
}
}
/**
* Login via Appwrite Auth REST. userId kommt aus der Session – kein users.read nötig.
*/
@@ -69,10 +107,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 +131,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
+22
View File
@@ -0,0 +1,22 @@
import { getEmployeeByEmail, getEmployeeByUserId } from './appwriteAdmin.js'
/**
* Ticketsystem-Mitarbeiter (employees-Collection) oder Appwrite-Label "admin" -> Admin-Ansicht im Portal.
* isAdmin = Label "admin"; null = kein Mitarbeiter.
*/
export async function resolveStaffAccount(appwriteUserId, email, account) {
const isAdmin = Array.isArray(account?.labels) && account.labels.includes('admin')
let employee = await getEmployeeByUserId(appwriteUserId).catch(() => null)
if (!employee) {
employee = await getEmployeeByEmail(account?.email || email).catch(() => null)
}
if (!employee && !isAdmin) return null
return {
isAdmin,
name: employee?.displayName || account?.name || 'Mitarbeiter',
email: account?.email || employee?.email || email,
}
}
+80
View File
@@ -0,0 +1,80 @@
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 || entry.attempts >= MAX_ATTEMPTS) return { ok: false, reason: 'gone' }
// Versuch vor dem Appwrite-Aufruf reservieren, sonst zaehlen parallele Anfragen alle als erster Versuch
entry.attempts += 1
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') {
entry.attempts -= 1
throw err
}
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
}
+38
View File
@@ -0,0 +1,38 @@
import { config } from '../config.js'
import {
getUserById,
listDocuments,
Query,
updateAppwriteUserEmailVerification,
updateAppwriteUserMfa,
} from './appwriteAdmin.js'
/**
* Mitarbeiter-2FA verwalten (scripts/mitarbeiter-2fa.mjs). Appwrite verlangt den E-Mail-Code nur, wenn
* die Adresse bestaetigt ist - deshalb beim Einschalten erst bestaetigen, dann 2FA an.
*/
export async function listStaffMfaStatus() {
const employees = await listDocuments(config.collections.employees, [Query.limit(100)])
const rows = []
for (const employee of employees) {
if (!employee.userId) continue
const user = await getUserById(employee.userId)
rows.push({
userId: employee.userId,
name: employee.displayName || user.name || '',
email: user.email || employee.email || '',
emailVerification: Boolean(user.emailVerification),
mfa: Boolean(user.mfa),
})
}
return rows
}
export async function enableStaffMfa(userId) {
await updateAppwriteUserEmailVerification(userId, true)
await updateAppwriteUserMfa(userId, true)
}
export async function disableStaffMfa(userId) {
await updateAppwriteUserMfa(userId, false)
}
+6 -1
View File
@@ -20,11 +20,16 @@ export function createStaffPreviewToken(payload) {
return token
}
export function consumeStaffPreviewToken(token) {
/**
* Einmal einloesen. purpose trennt Preview-Links ('preview', Standard) vom Wechsel ins Projekt-Admin
* ('switch'); ein Token fuer den falschen Zweck ist trotzdem verbraucht.
*/
export function consumeStaffPreviewToken(token, purpose = 'preview') {
purgeExpired()
const entry = tokens.get(token)
if (!entry) return null
tokens.delete(token)
if (entry.expiresAt <= Date.now()) return null
if ((entry.purpose || 'preview') !== purpose) return null
return entry
}
+114
View File
@@ -0,0 +1,114 @@
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'
// Portal-Login mit E-Mail-Code fuer Mitarbeiter mit Appwrite-2FA; Kunden und Mitarbeiter ohne 2FA wie bisher
const aw = await startAppwriteMock()
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, post, rawGet } = await import('./support/testApp.js')
const { default: authRoutes } = await import('../server/routes/auth.js')
const app = await startTestApp({ '/api/auth': authRoutes })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
aw.addUser({ $id: 'u_kunde', email: 'kunde@example.invalid', password: 'kunde-kunde' })
aw.addUser({ $id: 'u_ohne', email: 'justin@webklar.com', password: 'ohne-ohne-ohne' })
aw.addUser({ $id: 'u_mfa', email: 'kenso@webklar.com', password: 'mit-mit-mit', mfa: true, emailVerification: true, labels: ['admin'] })
aw.seed('customers', { $id: 'c1', appwriteUserId: 'u_kunde', email: 'kunde@example.invalid', name: 'Kunde', customerStatus: 'active', portalAccessEnabled: true })
aw.seed('customerPortalAccess', { $id: 'pa1', customerId: 'c1', enabled: true })
aw.seed('employees', { $id: 'e1', userId: 'u_ohne', displayName: 'Justin', email: 'justin@webklar.com' })
aw.seed('employees', { $id: 'e2', userId: 'u_mfa', displayName: 'Kenso', email: 'kenso@webklar.com' })
const SESSION = config.cookieName
const setCookies = (res) => res.headers.getSetCookie()
const cookieNamed = (res, name) => setCookies(res).find((c) => c.startsWith(`${name}=`)) || null
const jar = (res) => setCookies(res).map((c) => c.split(';')[0]).join('; ')
const login = (email, password) => post('/api/auth/login', { email, password })
test('Kunde ohne 2FA: Anmeldung wie bisher', async () => {
const res = await login('kunde@example.invalid', 'kunde-kunde')
assert.equal(res.status, 200)
assert.equal(res.body.mfaRequired, undefined)
assert.ok(cookieNamed(res, SESSION))
})
test('Mitarbeiter ohne 2FA: Admin-Anmeldung wie bisher', async () => {
const res = await login('justin@webklar.com', 'ohne-ohne-ohne')
assert.equal(res.status, 200)
assert.equal(res.body.role, 'admin')
assert.ok(cookieNamed(res, SESSION))
})
test('Mitarbeiter mit 2FA: vor dem Code keine Portal-Sitzung', async () => {
const res = await login('kenso@webklar.com', 'mit-mit-mit')
assert.equal(res.status, 200)
assert.deepEqual(res.body, { mfaRequired: true, email: 'k…@webklar.com' })
assert.ok(cookieNamed(res, 'webklar_portal_mfa'))
assert.equal(cookieNamed(res, SESSION), null)
assert.equal(setCookies(res).length, 1)
const me = await rawGet('/api/auth/me', { cookie: jar(res) })
assert.equal(me.body.authenticated, false)
})
test('Richtiger Code: Admin-Sitzung, Code-Cookie geloescht', async () => {
const first = await login('kenso@webklar.com', 'mit-mit-mit')
const res = await post('/api/auth/login/mfa', { otp: aw.lastOtp('u_mfa') }, { cookie: jar(first) })
assert.equal(res.status, 200)
assert.equal(res.body.role, 'admin')
assert.equal(res.body.admin.isAdmin, true)
assert.ok(cookieNamed(res, SESSION))
assert.match(cookieNamed(res, 'webklar_portal_mfa'), /Expires=Thu, 01 Jan 1970/)
const me = await rawGet('/api/auth/me', { cookie: jar(res) })
assert.equal(me.body.role, 'admin')
})
test('Falscher Code: Hinweis, nach 5 Fehlversuchen neu anmelden', async () => {
const first = await login('kenso@webklar.com', 'mit-mit-mit')
for (let i = 0; i < 5; i++) {
const res = await post('/api/auth/login/mfa', { otp: '000000' }, { cookie: jar(first) })
assert.equal(res.status, 401)
assert.equal(res.body.error, 'Code falsch oder abgelaufen.')
assert.equal(cookieNamed(res, SESSION), null)
}
const res = await post('/api/auth/login/mfa', { otp: aw.lastOtp('u_mfa') }, { cookie: jar(first) })
assert.equal(res.status, 410)
assert.equal(res.body.error, 'Bitte melde dich erneut an.')
})
test('Ohne Code-Cookie: neu anmelden', async () => {
const res = await post('/api/auth/login/mfa', { otp: '123456' })
assert.equal(res.status, 410)
const resend = await post('/api/auth/login/mfa/resend', {})
assert.equal(resend.status, 410)
})
test('Code erneut senden: zweite Mail', async () => {
const first = await login('kenso@webklar.com', 'mit-mit-mit')
const before = aw.mailsSent('u_mfa')
const res = await post('/api/auth/login/mfa/resend', {}, { cookie: jar(first) })
assert.equal(res.status, 200)
assert.equal(aw.mailsSent('u_mfa'), before + 1)
})
test('2FA an, E-Mail nicht bestaetigt: Hinweis statt Sitzung', async () => {
aw.addUser({ $id: 'u_unbest', email: 'andrej@webklar.com', password: 'unbest-unbest', mfa: true, emailVerification: false })
aw.seed('employees', { $id: 'e3', userId: 'u_unbest', displayName: 'Andrej', email: 'andrej@webklar.com' })
const res = await login('andrej@webklar.com', 'unbest-unbest')
assert.equal(res.status, 403)
assert.equal(res.body.error, '2FA ist für dein Konto noch nicht eingerichtet – bitte Kenso Bescheid geben.')
assert.equal(cookieNamed(res, SESSION), null)
})
test('2FA-Pruefung schlaegt nicht offen fehl, wenn der Konto-Abruf scheitert', async () => {
aw.failUserLookups(true)
try {
const res = await login('kenso@webklar.com', 'mit-mit-mit')
assert.equal(cookieNamed(res, SESSION), null)
assert.equal(res.body.mfaRequired, true)
} finally {
aw.failUserLookups(false)
}
})
+89
View File
@@ -0,0 +1,89 @@
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'
)
})
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)
})
test('Parallele Fehlversuche: hoechstens 5 Versuche zaehlen', async () => {
const { key } = await start()
const results = await Promise.all(Array.from({ length: 10 }, () => verifyStaffMfa(key, '000000')))
assert.equal(results.filter((r) => r.reason === 'invalid').length, 5)
assert.equal(results.filter((r) => r.reason === 'gone').length, 5)
assert.deepEqual(await verifyStaffMfa(key, aw.lastOtp('u_staff')), { ok: false, reason: 'gone' })
})
+39
View File
@@ -0,0 +1,39 @@
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'
// Einrichtungs-Skript mitarbeiter-2fa.mjs: Status, einschalten (erst E-Mail bestaetigen, dann 2FA), abschalten
const aw = await startAppwriteMock()
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { listStaffMfaStatus, enableStaffMfa, disableStaffMfa } = await import('../server/services/staffMfaAdmin.js')
after(() => Promise.all([aw.close(), st.close()]))
aw.addUser({ $id: 'u1', email: 'kenso@webklar.com', name: 'Kenso' })
aw.addUser({ $id: 'u2', email: 'justin@webklar.com', name: 'Justin', emailVerification: true, mfa: true })
aw.seed('employees', { $id: 'e1', userId: 'u1', displayName: 'Kenso', email: 'kenso@webklar.com' })
aw.seed('employees', { $id: 'e2', userId: 'u2', displayName: 'Justin', email: 'justin@webklar.com' })
aw.seed('employees', { $id: 'e3', userId: '', displayName: 'Ohne Login', email: 'alt@webklar.com' })
test('Status: nur Mitarbeiter mit Appwrite-Konto', async () => {
assert.deepEqual(await listStaffMfaStatus(), [
{ userId: 'u1', name: 'Kenso', email: 'kenso@webklar.com', emailVerification: false, mfa: false },
{ userId: 'u2', name: 'Justin', email: 'justin@webklar.com', emailVerification: true, mfa: true },
])
})
test('Einschalten: erst E-Mail bestaetigen, dann 2FA', async () => {
await enableStaffMfa('u1')
assert.deepEqual(aw.userCalls(), ['verification:u1', 'mfa:u1'])
assert.equal(aw.user('u1').mfa, true)
assert.equal(aw.user('u1').emailVerification, true)
})
test('Abschalten: nur 2FA aus, E-Mail bleibt bestaetigt', async () => {
await disableStaffMfa('u2')
assert.equal(aw.user('u2').mfa, false)
assert.equal(aw.user('u2').emailVerification, true)
assert.equal(aw.userCalls().at(-1), 'mfa:u2')
})
+85
View File
@@ -0,0 +1,85 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
// Wechsel Ticketsystem -> Projekt-Admin per Einmal-Link (purpose 'switch'), Rueckweg-Button im Admin
const aw = await startAppwriteMock()
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, post, rawGet } = await import('./support/testApp.js')
const { default: authRoutes } = await import('../server/routes/auth.js')
const { default: switchLinkRoutes } = await import('../server/routes/admin/switchLink.js')
const { createStaffPreviewToken } = await import('../server/services/staffPreviewTokens.js')
const app = await startTestApp({ '/api/auth': authRoutes, '/api/admin/switch-link': switchLinkRoutes })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' })
const bearer = (jwt) => ({ headers: { authorization: `Bearer ${jwt}` } })
const switchLink = (jwt) => post('/api/admin/switch-link', {}, jwt ? bearer(jwt) : {})
const tokenOf = (openUrl) => new URL(openUrl).searchParams.get('token')
const portalSession = (res) => {
const cookie = res.headers.getSetCookie().find((c) => c.startsWith(`${config.cookieName}=`))
if (!cookie) return null
const raw = decodeURIComponent(cookie.split(';')[0].slice(config.cookieName.length + 1))
return JSON.parse(raw.slice(2, raw.lastIndexOf('.')))
}
test('switch-link: ohne, ungueltiges und halbes JWT -> 401', async () => {
assert.equal((await switchLink(null)).status, 401)
assert.equal((await switchLink('jwt-falsch')).status, 401)
assert.equal((await switchLink('jwt-halb')).status, 401)
})
test('switch-link: kein Mitarbeiter -> 403', async () => {
assert.equal((await switchLink('jwt-kunde')).status, 403)
})
test('switch-link: Mitarbeiter ohne Admin-Label bekommt einen Link', async () => {
const res = await switchLink('jwt-staff')
assert.equal(res.status, 200)
assert.match(res.body.openUrl, /^https:\/\/project\.webklar\.com\/api\/auth\/staff-switch\?token=[0-9a-f]{64}$/)
})
test('staff-switch: gueltig -> Admin-Sitzung und /admin.html, zweites Mal -> Login', async () => {
const { body } = await switchLink('jwt-staff')
const path = `/api/auth/staff-switch?token=${tokenOf(body.openUrl)}`
const res = await rawGet(path)
assert.equal(res.status, 302)
assert.equal(res.headers.get('location'), '/admin.html')
const session = portalSession(res)
assert.equal(session.role, 'admin')
assert.equal(session.isAdmin, false)
assert.equal(session.appwriteUserId, 'u_staff2')
const again = await rawGet(path)
assert.equal(again.status, 302)
assert.equal(again.headers.get('location'), '/login.html?error=Link%20abgelaufen%2C%20bitte%20anmelden')
assert.equal(portalSession(again), null)
})
test('staff-switch: nach 61 Sekunden abgelaufen', async (t) => {
const { body } = await switchLink('jwt-staff')
const now = Date.now()
t.mock.method(Date, 'now', () => now + 61 * 1000)
const res = await rawGet(`/api/auth/staff-switch?token=${tokenOf(body.openUrl)}`)
assert.match(res.headers.get('location'), /^\/login\.html\?error=/)
})
test('Tokens nicht vertauschbar: Switch ueber staff-preview, Preview ueber staff-switch', async () => {
const { body } = await switchLink('jwt-staff')
assert.equal((await rawGet(`/api/auth/staff-preview?token=${tokenOf(body.openUrl)}`)).status, 410)
const previewToken = createStaffPreviewToken({ userId: 'u_admin', email: 'kenso@webklar.com', name: 'Kenso', previewUrl: 'https://x.project.webklar.com/' })
const res = await rawGet(`/api/auth/staff-switch?token=${previewToken}`)
assert.match(res.headers.get('location'), /^\/login\.html\?error=/)
assert.equal(portalSession(res), null)
})
test('admin.html: Rueckweg ins Ticketsystem in der Kopfzeile', () => {
const html = readFileSync(new URL('../public/admin.html', import.meta.url), 'utf8')
const actions = html.slice(html.indexOf('portal-header-actions'), html.indexOf('id="logout-btn"'))
assert.match(actions, /<a class="portal-btn-outline" href="https:\/\/ticket\.webklar\.com\/tickets">← Ticketsystem<\/a>/)
})
+88 -3
View File
@@ -13,6 +13,14 @@ 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
let failUserLookups = false
// 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 +96,85 @@ 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') {
if (failUserLookups) return [503, { message: 'Nachbau: Konto-Abruf gestoert', code: 503, type: 'general_server_error' }]
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 +230,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 +248,15 @@ 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,
/** GET /v1/users/:id scheitert (503), bis wieder false gesetzt wird */
failUserLookups: (on) => (failUserLookups = Boolean(on)),
userCalls: () => [...userCalls],
close: () => new Promise((resolve) => server.close(resolve)),
}
}