Portal-Login: E-Mail-Code für Mitarbeiter mit 2FA

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-30 10:29:36 +00:00
1 parent 25f15c0ecb
commit 9b14a74fcc
6 files changed
+304 -36

No files matched your search

+82 -36
View File
@@ -4,8 +4,6 @@ import {
getCustomerByAppwriteUserId,
getCustomerByEmail,
getDocument,
getEmployeeByEmail,
getEmployeeByUserId,
getPortalAccessByCustomerId,
getUserById,
listDocuments,
@@ -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,21 @@ 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
if (account?.mfa === true) {
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',
@@ -461,4 +442,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
+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,
}
}