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:
1 parent
25f15c0ecb
commit
9b14a74fcc
6 files changed
+304
-36
No files matched your search
+82
-36
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user