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
@@ -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')
|
||||
|
||||
@@ -647,3 +647,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;
|
||||
}
|
||||
@@ -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“).
|
||||
|
||||
+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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
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)
|
||||
})
|
||||
Reference in new issue
Block a user