Compare commits

..
6 Commits
19 changed files with 944 additions and 43 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> <a class="portal-logo" href="/admin.html">Webklar <span style="opacity:.6">Admin</span></a>
<div class="portal-header-actions"> <div class="portal-header-actions">
<span class="portal-meta" id="admin-meta"></span> <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> <button type="button" class="portal-btn-outline" id="logout-btn">Abmelden</button>
</div> </div>
</header> </header>
+57
View File
@@ -548,6 +548,10 @@ async function initLoginPage() {
password: document.getElementById('password').value, password: document.getElementById('password').value,
}), }),
}) })
if (result.mfaRequired) {
showMfaStep(result.email)
return
}
if (result.role === 'admin') { if (result.role === 'admin') {
window.location.href = adminLoginTarget() window.location.href = adminLoginTarget()
return 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 --- */ /* --------------------------------------------------------------- View-as --- */
function renderViewAsBanner(me) { function renderViewAsBanner(me) {
const banner = document.getElementById('viewas-banner') const banner = document.getElementById('viewas-banner')
+22
View File
@@ -265,6 +265,13 @@
border-color 0.25s var(--ease-out); 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 { .portal-btn-outline:hover {
background: #fff; background: #fff;
color: #1c1c1c; 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> </svg>
</button> </button>
</form> </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> </div>
<p class="login-hint"> <p class="login-hint">
Zugangsdaten erhältst du von Webklar. Dein Passwort kannst du nach der Anmeldung selbst ändern (Profilbild oben rechts, „Passwort ändern“). 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 adminEmployeesRoutes from './routes/admin/employees.js'
import adminInvoiceClientsRoutes from './routes/admin/invoiceClients.js' import adminInvoiceClientsRoutes from './routes/admin/invoiceClients.js'
import adminPreviewOpenRoutes from './routes/admin/previewOpen.js' import adminPreviewOpenRoutes from './routes/admin/previewOpen.js'
import adminSwitchLinkRoutes from './routes/admin/switchLink.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
const publicDir = path.join(__dirname, '..', 'public') 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/employees', adminEmployeesRoutes)
app.use('/api/admin/invoice-clients', adminInvoiceClientsRoutes) app.use('/api/admin/invoice-clients', adminInvoiceClientsRoutes)
app.use('/api/admin/preview-open', adminPreviewOpenRoutes) app.use('/api/admin/preview-open', adminPreviewOpenRoutes)
app.use('/api/admin/switch-link', adminSwitchLinkRoutes)
app.use('/webhook', giteaWebhookRoutes) app.use('/webhook', giteaWebhookRoutes)
app.get('/api/health', async (_req, res) => { 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
+96 -36
View File
@@ -4,15 +4,13 @@ import {
getCustomerByAppwriteUserId, getCustomerByAppwriteUserId,
getCustomerByEmail, getCustomerByEmail,
getDocument, getDocument,
getEmployeeByEmail,
getEmployeeByUserId,
getPortalAccessByCustomerId, getPortalAccessByCustomerId,
getUserById, getUserById,
listDocuments, listDocuments,
updateDocument, updateDocument,
Query, Query,
} from '../services/appwriteAdmin.js' } from '../services/appwriteAdmin.js'
import { loginWithAppwrite, getLoginCooldownRemainingSec } from '../services/appwriteClient.js' import { loginWithAppwrite, getLoginCooldownRemainingSec, sessionNeedsSecondFactor } from '../services/appwriteClient.js'
import { import {
SESSION_REVOKED_MESSAGE, SESSION_REVOKED_MESSAGE,
clearPortalSession, clearPortalSession,
@@ -29,6 +27,8 @@ import {
verifyPreviewSessionToken, verifyPreviewSessionToken,
} from '../services/previewSession.js' } from '../services/previewSession.js'
import { consumeStaffPreviewToken } from '../services/staffPreviewTokens.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() const router = Router()
@@ -54,23 +54,6 @@ function sanitizeCustomer(customer) {
* Gibt null zurueck, wenn der Login kein Mitarbeiter ist (dann greift der * Gibt null zurueck, wenn der Login kein Mitarbeiter ist (dann greift der
* normale Kunden-Login). * 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) { async function validatePortalAccess(appwriteUserId, email) {
let customer = await getCustomerByAppwriteUserId(appwriteUserId) let customer = await getCustomerByAppwriteUserId(appwriteUserId)
if (!customer && email) { if (!customer && email) {
@@ -185,23 +168,22 @@ router.post('/login', async (req, res) => {
// Ticketsystem-Mitarbeiter (employees-Collection) bekommen die Admin-Ansicht // Ticketsystem-Mitarbeiter (employees-Collection) bekommen die Admin-Ansicht
const staff = await resolveStaffAccount(user.$id, email.trim(), account) const staff = await resolveStaffAccount(user.$id, email.trim(), account)
if (staff) { if (staff) {
setPortalSession(res, { // Mitarbeiter mit Appwrite-2FA: erst der Code aus der Mail macht daraus eine Portal-Sitzung
role: 'admin', // mfa-Flag ODER Appwrite verlangt einen zweiten Faktor: scheitert der Konto-Abruf, entscheidet Appwrite
isAdmin: staff.isAdmin, if (account?.mfa === true || (await sessionNeedsSecondFactor(user.sessionCookie))) {
appwriteUserId: user.$id, const { key, maskedEmail } = await startStaffMfa({ userId: user.$id, email: staff.email, sessionCookie: user.sessionCookie }).catch((err) => {
name: staff.name, if (err.type === 'user_email_not_verified') {
email: staff.email, 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
}) })
const staffPreviewToken = await createPreviewSessionToken({ res.cookie(MFA_COOKIE, key, { ...mfaCookieOptions(), maxAge: MFA_TTL_MS })
userId: user.$id, return res.json({ mfaRequired: true, email: maskedEmail })
customerId: '', }
email: staff.email, await startStaffPortalSession(res, { userId: user.$id, staff })
name: staff.name,
role: 'admin',
labels: staff.isAdmin ? ['admin'] : [],
authSource: 'kundenbereich-admin',
})
res.cookie(PREVIEW_COOKIE_NAME, staffPreviewToken, previewSessionCookieOptions())
return res.json({ return res.json({
success: true, success: true,
role: 'admin', role: 'admin',
@@ -354,6 +336,19 @@ router.get('/staff-preview', async (req, res) => {
return res.redirect(previewUrl) 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 // Selbstheilung: Portal-Session lebt oft laenger als das preview_session-Cookie
// (bzw. das Cookie fehlt nach WebView-/Browser-Datenverlust). Ohne gueltiges // (bzw. das Cookie fehlt nach WebView-/Browser-Datenverlust). Ohne gueltiges
// Preview-Cookie laufen die *.project.webklar.com-Hosts in eine 302-Schleife. // 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 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) { export async function deleteAppwriteUser(userId) {
return adminFetch(`/users/${userId}`, { method: 'DELETE' }) 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 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,43 @@ 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',
},
})
}
/**
* 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. * 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 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 +131,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
+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 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() purgeExpired()
const entry = tokens.get(token) const entry = tokens.get(token)
if (!entry) return null if (!entry) return null
tokens.delete(token) tokens.delete(token)
if (entry.expiresAt <= Date.now()) return null if (entry.expiresAt <= Date.now()) return null
if ((entry.purpose || 'preview') !== purpose) return null
return entry 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() 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
let failUserLookups = false
// 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 +96,85 @@ 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') {
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, // 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 +230,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 +248,15 @@ 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,
/** 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)), close: () => new Promise((resolve) => server.close(resolve)),
} }
} }