Files
Webklar-Kundenbereich/server/routes/auth.js
T

544 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Router } from 'express'
import { config } from '../config.js'
import {
getCustomerByAppwriteUserId,
getCustomerByEmail,
getDocument,
getPortalAccessByCustomerId,
getUserById,
listDocuments,
updateDocument,
Query,
} from '../services/appwriteAdmin.js'
import { loginWithAppwrite, getLoginCooldownRemainingSec, sessionNeedsSecondFactor } from '../services/appwriteClient.js'
import {
SESSION_REVOKED_MESSAGE,
clearPortalSession,
requireSession,
setPortalSession,
} from '../middleware/session.js'
import { changeCustomerPassword } from '../services/customerPassword.js'
import { isSessionPasswordCurrent, rememberPasswordVersion } from '../services/passwordVersion.js'
import {
PREVIEW_COOKIE_NAME,
clearPreviewSession,
createPreviewSessionToken,
previewSessionCookieOptions,
verifyPreviewSessionToken,
} from '../services/previewSession.js'
import { isPreviewProjectReady } from '../services/previewReadiness.js'
import { consumeStaffPreviewToken } from '../services/staffPreviewTokens.js'
import { getPortalAccessStatus } from '../services/portalAccessStatus.js'
import { startKundenansicht } from '../services/kundenansicht.js'
import { logViewAction } from '../services/customerViewLog.js'
import { resolveStaffAccount } from '../services/staffAccount.js'
import { MFA_COOKIE, MFA_TTL_MS, resendStaffMfa, startStaffMfa, verifyStaffMfa } from '../services/staffMfa.js'
const router = Router()
function sanitizeCustomer(customer) {
return {
id: customer.$id,
code: customer.code || '',
name: customer.name || '',
companyName: customer.companyName || '',
email: customer.email || '',
phone: customer.phone || '',
location: customer.location || '',
customerStatus: customer.customerStatus || '',
portalAccessEnabled: Boolean(customer.portalAccessEnabled),
}
}
/**
* Backoffice-Login: Jeder Mitarbeiter aus der employees-Collection darf ins
* Admin-Panel (/admin.html). Das Appwrite-Label "admin" ist ausschliesslich die
* WOMS-Adminrolle (Mitarbeiterverwaltung) und KEINE Voraussetzung fuer den
* Portalzugang - es wird nur als Flag in der Session mitgefuehrt.
* Gibt null zurueck, wenn der Login kein Mitarbeiter ist (dann greift der
* normale Kunden-Login).
*/
async function validatePortalAccess(appwriteUserId, email) {
let customer = await getCustomerByAppwriteUserId(appwriteUserId)
if (!customer && email) {
customer = await getCustomerByEmail(email)
}
if (!customer) {
const error = new Error(
`Kein Kundenkonto für diesen Login gefunden. Im Ticketsystem customers.appwriteUserId auf "${appwriteUserId}" setzen (E-Mail: ${email}).`
)
error.status = 403
throw error
}
if (!customer.portalAccessEnabled) {
const error = new Error('Portalzugang ist nicht freigeschaltet.')
error.status = 403
throw error
}
const portalAccess = await getPortalAccessByCustomerId(customer.$id)
if (!portalAccess || !portalAccess.enabled) {
const error = new Error('Portalzugang ist deaktiviert.')
error.status = 403
throw error
}
const status = (customer.customerStatus || '').toLowerCase()
if (!config.allowedCustomerStatuses.includes(status)) {
const error = new Error('Kundenkonto ist nicht aktiv.')
error.status = 403
throw error
}
return { customer, portalAccess }
}
async function syncCustomerAppwriteUserId(customer, appwriteUserId) {
if (!customer?.$id || !appwriteUserId) return customer
if (customer.appwriteUserId === appwriteUserId) return customer
const now = new Date().toISOString()
return updateDocument(config.collections.customers, customer.$id, {
appwriteUserId,
portalAccessEnabled: true,
updatedAt: now,
})
}
async function resolveSessionCustomer(session) {
if (!session?.customerId) return null
let customer = await getDocument(config.collections.customers, session.customerId).catch(() => null)
if (customer) return customer
if (session.appwriteUserId) {
customer = await getCustomerByAppwriteUserId(session.appwriteUserId)
if (customer) return customer
}
if (session.email) {
customer = await getCustomerByEmail(session.email)
}
return customer
}
function isAdminSession(session) {
return session?.role === 'admin' || (session?.labels || []).includes('admin')
}
function safePreviewRedirectUrl(url) {
try {
const parsed = new URL(String(url || ''))
if (
parsed.hostname.endsWith('.project.webklar.com') ||
parsed.hostname === 'project.webklar.com'
) {
return parsed.toString()
}
} catch {
/* ignore */
}
return null
}
router.get('/login-status', (_req, res) => {
const retryAfterSeconds = getLoginCooldownRemainingSec()
res.json({
blocked: retryAfterSeconds > 0,
retryAfterSeconds,
})
})
router.post('/login', async (req, res) => {
const { email, password } = req.body || {}
if (!email || !password) {
return res.status(400).json({ error: 'E-Mail und Passwort erforderlich' })
}
try {
const user = await loginWithAppwrite(email.trim(), password)
// Appwrite-Konto: Label "admin" und Passwortstand fuer die Kunden-Session (passwordVersion.js)
const account = await getUserById(user.$id).catch(() => null)
// Ticketsystem-Mitarbeiter (employees-Collection) bekommen die Admin-Ansicht
const staff = await resolveStaffAccount(user.$id, email.trim(), account)
if (staff) {
// 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',
admin: { name: staff.name, email: staff.email, isAdmin: staff.isAdmin },
})
}
let { customer, portalAccess } = await validatePortalAccess(user.$id, email.trim())
customer = await syncCustomerAppwriteUserId(customer, user.$id)
setPortalSession(res, {
customerId: customer.$id,
appwriteUserId: user.$id,
name: customer.name || user.name || '',
email: customer.email || user.email || email,
pwv: account?.passwordUpdate || undefined,
})
if (account) rememberPasswordVersion(user.$id, account.passwordUpdate)
const previewToken = await createPreviewSessionToken({
userId: user.$id,
customerId: customer.$id,
email: customer.email || user.email || email,
name: customer.name || user.name || '',
role: 'customer',
labels: [],
authSource: 'kundenbereich',
})
res.cookie(PREVIEW_COOKIE_NAME, previewToken, previewSessionCookieOptions())
try {
await updateDocument(config.collections.customerPortalAccess, portalAccess.$id, {
lastLoginAt: new Date().toISOString(),
})
} catch (err) {
console.warn('[auth] lastLoginAt update failed:', err.message)
}
return res.json({ success: true, role: 'customer', customer: sanitizeCustomer(customer) })
} catch (err) {
const status = err.status || 500
if (status === 429) {
return res.status(429).json({
error:
err.message ||
'Zu viele Anmeldeversuche. Bitte warte einige Minuten, bevor du es erneut versuchst.',
retryAfterSeconds: getLoginCooldownRemainingSec(),
})
}
if (err?.message?.includes('not authorized')) {
return res.status(500).json({
error:
'Server-Konfiguration: APPWRITE_API_KEY benötigt databases.read für woms-database (customers, customerPortalAccess).',
})
}
return res.status(status).json({ error: err.message || 'Anmeldung fehlgeschlagen' })
}
})
// Passwort aendern (Konto-Menue "Passwort ändern"). Die eigene Session bekommt den neuen
// Passwortstand; alle anderen Sessions des Kunden gelten danach nicht mehr.
router.post('/password', requireSession, async (req, res) => {
if (req.session.role === 'admin') {
return res.status(403).json({ error: 'Im Ansichtsmodus kann das Passwort nicht geändert werden.' })
}
try {
const { passwordVersion } = await changeCustomerPassword(req.session, req.body)
setPortalSession(res, { ...req.session, pwv: passwordVersion || undefined })
return res.json({ success: true })
} catch (err) {
return res.status(err.status || 500).json({
error: err.message || 'Das Passwort konnte nicht geändert werden.',
...(err.code ? { code: err.code } : {}),
...(err.retryAfterSeconds ? { retryAfterSeconds: err.retryAfterSeconds } : {}),
})
}
})
router.post('/logout', (_req, res) => {
clearPortalSession(res)
clearPreviewSession(res)
res.json({ success: true })
})
router.get('/preview-access', async (req, res) => {
const token = req.cookies?.[PREVIEW_COOKIE_NAME]
if (!token) {
return res.status(401).json({ allowed: false })
}
const session = await verifyPreviewSessionToken(token)
const admin = isAdminSession(session)
if (!session || (!session.customerId && !admin)) {
return res.status(401).json({ allowed: false })
}
const subdomain = String(req.query.subdomain || '')
.trim()
.toLowerCase()
if (!subdomain) {
return res.status(400).json({ allowed: false, error: 'subdomain required' })
}
try {
const queries = [Query.equal('subdomain', subdomain), Query.limit(1)]
if (!admin) {
queries.splice(1, 0, Query.equal('customerId', session.customerId))
}
const projects = await listDocuments(config.collections.websiteProjects, queries)
const project = projects[0]
if (!project) {
return res.json({ allowed: false, reason: 'no_project' })
}
if (!isPreviewProjectReady(project)) {
return res.json({ allowed: false, reason: 'not_ready', projectId: project.$id })
}
return res.json({ allowed: true, projectId: project.$id })
} catch (err) {
return res.status(500).json({ allowed: false, error: err.message || 'Fehler' })
}
})
router.get('/staff-preview', async (req, res) => {
const tokenValue = String(req.query.token || '').trim()
if (!tokenValue) {
return res.status(400).send('Token fehlt')
}
const tokenData = consumeStaffPreviewToken(tokenValue)
if (!tokenData) {
return res.status(410).send('Der Preview-Link ist abgelaufen. Bitte erneut aus dem Ticketsystem oeffnen.')
}
const previewUrl = safePreviewRedirectUrl(tokenData.previewUrl)
if (!previewUrl) {
return res.status(400).send('Ungueltige Preview-URL')
}
const previewToken = await createPreviewSessionToken({
userId: tokenData.userId,
customerId: tokenData.customerId || '',
email: tokenData.email || '',
name: tokenData.name || 'Admin',
role: 'admin',
labels: ['admin'],
authSource: 'ticket-admin',
})
res.cookie(PREVIEW_COOKIE_NAME, previewToken, previewSessionCookieOptions())
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')
})
// Kundenansicht aus dem Ticketsystem (POST /api/team/customers/:id/kundenansicht): Einmal-Link -> Portal des Kunden
router.get('/kundenansicht', async (req, res) => {
const gone = (text) => res.status(410).type('text/plain; charset=utf-8').send(text)
const tokenData = consumeStaffPreviewToken(String(req.query.token || ''), 'kundenansicht')
if (!tokenData) return gone('Der Link ist abgelaufen. Bitte die Kundenansicht im Ticketsystem erneut öffnen.')
try {
const { customer, hasAccess } = await getPortalAccessStatus(tokenData.customerId)
if (!customer || !hasAccess) return gone('Kein Portal-Zugang. Bitte die Kundenansicht im Ticketsystem erneut öffnen.')
const session = await startKundenansicht(res, { staff: tokenData.staff, customer })
await logViewAction({
customerId: customer.$id,
staffUserId: session.appwriteUserId,
staffName: session.adminName,
action: 'geoeffnet',
})
return res.redirect('/dashboard.html')
} catch (err) {
console.error('[kundenansicht] Öffnen fehlgeschlagen:', err.message)
return res.status(500).type('text/plain; charset=utf-8').send('Die Kundenansicht konnte nicht geöffnet werden.')
}
})
// 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.
// Deshalb stellt /me das Preview-Cookie automatisch neu aus, solange die
// Portal-Session gueltig ist.
async function refreshPreviewCookie(req, res, payload) {
try {
const existing = req.cookies?.[PREVIEW_COOKIE_NAME]
if (existing && (await verifyPreviewSessionToken(existing))) return
const token = await createPreviewSessionToken(payload)
res.cookie(PREVIEW_COOKIE_NAME, token, previewSessionCookieOptions())
} catch (err) {
console.warn('[auth] preview-cookie refresh failed:', err.message)
}
}
router.get('/me', async (req, res) => {
const raw = req.signedCookies?.[config.cookieName]
if (!raw) {
return res.json({ authenticated: false })
}
try {
const session = JSON.parse(raw)
// Admin-Session (Ticketsystem-Mitarbeiter), optional im "Als Kunde ansehen"-Modus
if (session.role === 'admin' && session.appwriteUserId) {
// Sessions von vor der Mitarbeiter-Freigabe kennen das Flag nicht - die
// konnten nur Admins bekommen, also als Admin werten.
const isAdmin = session.isAdmin === undefined ? true : Boolean(session.isAdmin)
let customer = null
if (session.customerId) {
const doc = await getDocument(config.collections.customers, session.customerId).catch(() => null)
if (doc) customer = sanitizeCustomer(doc)
}
await refreshPreviewCookie(req, res, {
userId: session.appwriteUserId,
customerId: '',
email: session.adminEmail || session.email || '',
name: session.adminName || session.name || 'Admin',
role: 'admin',
labels: isAdmin ? ['admin'] : [],
authSource: 'kundenbereich-admin',
})
return res.json({
authenticated: true,
role: 'admin',
viewAs: Boolean(session.viewAs && customer),
admin: {
name: session.adminName || session.name || 'Admin',
email: session.adminEmail || session.email || '',
isAdmin,
},
customer,
})
}
if (!session.customerId || !session.appwriteUserId) {
return res.json({ authenticated: false })
}
// Passwort inzwischen geaendert (anderes Geraet oder durch uns): neu anmelden
if (!(await isSessionPasswordCurrent(session))) {
clearPortalSession(res)
clearPreviewSession(res)
return res.json({ authenticated: false, reason: 'password_changed', message: SESSION_REVOKED_MESSAGE })
}
let customer = await resolveSessionCustomer(session)
if (!customer) {
clearPortalSession(res)
clearPreviewSession(res)
return res.json({ authenticated: false })
}
try {
await validatePortalAccess(session.appwriteUserId, session.email || customer.email)
} catch {
clearPortalSession(res)
clearPreviewSession(res)
return res.json({ authenticated: false })
}
if (customer.appwriteUserId !== session.appwriteUserId) {
customer = await syncCustomerAppwriteUserId(customer, session.appwriteUserId)
}
await refreshPreviewCookie(req, res, {
userId: session.appwriteUserId,
customerId: customer.$id,
email: customer.email || session.email || '',
name: customer.name || session.name || '',
role: 'customer',
labels: [],
authSource: 'kundenbereich',
})
return res.json({
authenticated: true,
role: 'customer',
customer: sanitizeCustomer(customer),
})
} catch (err) {
return res.status(500).json({ error: err.message || 'Fehler beim Laden' })
}
})
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