From 772095ab19913a2c3d9a0c9ed3c01d9c25b382e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 19:23:57 +0000 Subject: [PATCH] Team-Eingang: /api/team mit Appwrite-JWT fuer alle Mitarbeiter, CORS-Header fuer Uploads Co-Authored-By: Claude Opus 5.5 --- server/index.js | 16 ++----- server/middleware/cors.js | 17 +++++++ server/middleware/staffJwt.js | 59 +++++++++++++++++++++++ server/routes/admin/switchLink.js | 15 +----- server/routes/team/index.js | 8 ++++ test/support/appwriteMock.js | 3 ++ test/teamAuth.test.js | 78 +++++++++++++++++++++++++++++++ 7 files changed, 170 insertions(+), 26 deletions(-) create mode 100644 server/middleware/cors.js create mode 100644 server/middleware/staffJwt.js create mode 100644 server/routes/team/index.js create mode 100644 test/teamAuth.test.js diff --git a/server/index.js b/server/index.js index f0bf0ab..d7e94d7 100644 --- a/server/index.js +++ b/server/index.js @@ -39,6 +39,8 @@ import adminAnalyseRoutes from './routes/admin/analyse.js' import { analyseLegalRouter } from './services/analyseLegal.js' import adminSwitchLinkRoutes from './routes/admin/switchLink.js' import adminFinanzenRoutes from './routes/admin/finanzen.js' +import teamRoutes from './routes/team/index.js' +import { corsMiddleware } from './middleware/cors.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const publicDir = path.join(__dirname, '..', 'public') @@ -55,18 +57,7 @@ app.use('/api/abo/takeover-credits', requireSession, express.json({ limit: '15mb // rawBody wird fuer die HMAC-Verifikation des Gitea-Webhooks benoetigt app.use(express.json({ limit: '2mb', verify: (req, _res, buf) => { req.rawBody = buf } })) -app.use((req, res, next) => { - const origin = req.get('Origin') - if (origin && config.cors.allowedOrigins.includes(origin)) { - res.setHeader('Access-Control-Allow-Origin', origin) - res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type') - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS') - if (req.method === 'OPTIONS') { - return res.sendStatus(204) - } - } - return next() -}) +app.use(corsMiddleware(config.cors.allowedOrigins)) app.use('/api/auth', authRoutes) app.use('/api/projects', projectsRoutes) @@ -79,6 +70,7 @@ app.use('/api/abo/takeover-credits', takeoverCreditRoutes) app.use('/api/abo', aboRoutes) app.use('/api/funktionen', funktionenRoutes) app.use('/api/portal-admin', portalAdminRoutes) +app.use('/api/team', teamRoutes) app.use('/api/admin/gitea', adminGiteaRoutes) app.use('/api/admin/website-projects', adminPreviewRoutes) app.use('/api/admin/website-projects', adminWebsiteProjectsRoutes) diff --git a/server/middleware/cors.js b/server/middleware/cors.js new file mode 100644 index 0000000..b5562c2 --- /dev/null +++ b/server/middleware/cors.js @@ -0,0 +1,17 @@ +// CORS fuer das Ticketsystem (Bearer-JWT, keine Cookies). Die X-*-Header braucht der Chat-Upload. +const ALLOW_HEADERS = 'Authorization, Content-Type, X-File-Name, X-Attachment-Kind, X-Duration-Ms, X-Image-Width, X-Image-Height' + +export function corsMiddleware(allowedOrigins) { + return (req, res, next) => { + const origin = req.get('Origin') + if (origin && allowedOrigins.includes(origin)) { + res.setHeader('Access-Control-Allow-Origin', origin) + res.setHeader('Access-Control-Allow-Headers', ALLOW_HEADERS) + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS') + if (req.method === 'OPTIONS') { + return res.sendStatus(204) + } + } + return next() + } +} diff --git a/server/middleware/staffJwt.js b/server/middleware/staffJwt.js new file mode 100644 index 0000000..ac89d2f --- /dev/null +++ b/server/middleware/staffJwt.js @@ -0,0 +1,59 @@ +import { config } from '../config.js' +import { getEmployeeByEmail, getEmployeeByUserId } from '../services/appwriteAdmin.js' + +/** + * Appwrite-Konto zum Bearer-JWT aus dem Ticketsystem (GET /account). null ohne Header oder wenn Appwrite + * ablehnt - auch bei einer Sitzung ohne zweiten Faktor (401 user_more_factors_required). + */ +export 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() +} + +/** + * Mitarbeiter fuer den Team-Eingang: employees-Eintrag zur Konto-ID, Label "admin" oder - nur mit bestaetigter + * Konto-Mail - ein employees-Eintrag zur Mail. Kunden haben Konten im selben Appwrite-Projekt und koennen ihre + * Mail ohne Bestaetigung aendern; eine unbestaetigte Mail zaehlt deshalb nie. + */ +export async function resolveTeamStaff(account) { + if (!account?.$id) return null + const isAdmin = Array.isArray(account.labels) && account.labels.includes('admin') + let employee = await getEmployeeByUserId(account.$id).catch(() => null) + if (!employee && !isAdmin && account.emailVerification === true && account.email) { + employee = await getEmployeeByEmail(account.email).catch(() => null) + } + if (!employee && !isAdmin) return null + return { + isAdmin, + name: employee?.displayName || account.name || 'Mitarbeiter', + email: account.email || employee?.email || '', + } +} + +/** + * Team-Eingang (/api/team): jeder Mitarbeiter mit gueltiger Ticketsystem-Anmeldung. req.session hat dieselbe Form + * wie die Admin-Sitzung des Portals, damit die bestehenden Handler unveraendert laufen. + */ +export async function requireStaffJwt(req, res, next) { + try { + const account = await appwriteAccountFromJwt(req) + if (!account?.$id) return res.status(401).json({ error: 'Bitte im Ticketsystem neu anmelden.' }) + const staff = await resolveTeamStaff(account) + if (!staff) return res.status(403).json({ error: 'Kein Mitarbeiterkonto.' }) + const session = { role: 'admin', isAdmin: staff.isAdmin, appwriteUserId: account.$id, name: staff.name, email: staff.email } + req.session = session + req.staff = session + return next() + } catch (err) { + return res.status(500).json({ error: err.message || 'Anmeldung konnte nicht geprüft werden' }) + } +} diff --git a/server/routes/admin/switchLink.js b/server/routes/admin/switchLink.js index 4d659e6..bcd9c9a 100644 --- a/server/routes/admin/switchLink.js +++ b/server/routes/admin/switchLink.js @@ -1,5 +1,6 @@ import { Router } from 'express' import { config } from '../../config.js' +import { appwriteAccountFromJwt } from '../../middleware/staffJwt.js' import { createStaffPreviewToken } from '../../services/staffPreviewTokens.js' import { resolveStaffAccount } from '../../services/staffAccount.js' @@ -10,20 +11,6 @@ const router = Router() * 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) diff --git a/server/routes/team/index.js b/server/routes/team/index.js new file mode 100644 index 0000000..883a829 --- /dev/null +++ b/server/routes/team/index.js @@ -0,0 +1,8 @@ +import { Router } from 'express' +import { requireStaffJwt } from '../../middleware/staffJwt.js' + +// Team-Eingang fuer das Ticketsystem: alle Mitarbeiter, Appwrite-JWT (Spec 2026-10-01 Admin-Panel ins Ticketsystem) +const router = Router() +router.use(requireStaffJwt) + +export default router diff --git a/test/support/appwriteMock.js b/test/support/appwriteMock.js index fe51213..26b5f1d 100644 --- a/test/support/appwriteMock.js +++ b/test/support/appwriteMock.js @@ -173,6 +173,9 @@ export async function startAppwriteMock({ schema = {} } = {}) { // 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 + // Team-Eingang: Konto-Mail gleicht einem Mitarbeiter, ist aber unbestaetigt bzw. bestaetigt (ohne userId-Treffer) + if (jwt === 'jwt-mail-ungeprueft') return [200, { $id: 'u_fremd', email: 'andrej@webklar.com', emailVerification: false, labels: [] }] + if (jwt === 'jwt-mail-geprueft') return [200, { $id: 'u_neu', email: 'nico@webklar.com', emailVerification: true, name: 'Nico', labels: [] }] 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' }] diff --git a/test/teamAuth.test.js b/test/teamAuth.test.js new file mode 100644 index 0000000..845f50b --- /dev/null +++ b/test/teamAuth.test.js @@ -0,0 +1,78 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { Router } from 'express' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' + +// Team-Eingang /api/team: Appwrite-JWT aus dem Ticketsystem, jeder Mitarbeiter (nicht nur Label admin) +const aw = await startAppwriteMock() +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { startTestApp, rawGet } = await import('./support/testApp.js') +const { requireStaffJwt } = await import('../server/middleware/staffJwt.js') +const { corsMiddleware } = await import('../server/middleware/cors.js') + +const probeRouter = Router() +probeRouter.use(requireStaffJwt) +probeRouter.get('/', (req, res) => res.json(req.session)) + +const app = await startTestApp({ + '/': corsMiddleware(['https://ticket.webklar.com']), + '/probe': probeRouter, +}) +after(() => Promise.all([app.close(), aw.close(), st.close()])) + +aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' }) +aw.seed('employees', { $id: 'e_nico', userId: '', displayName: 'Nico', email: 'nico@webklar.com' }) + +const probe = (jwt) => rawGet('/probe', jwt ? { headers: { authorization: `Bearer ${jwt}` } } : {}) + +test('ohne, ungueltiges und halbes JWT -> 401', async () => { + for (const jwt of [null, 'jwt-falsch', 'jwt-halb']) assert.equal((await probe(jwt)).status, 401, String(jwt)) +}) + +test('Kunde (kein Mitarbeiter, kein Label) -> 403', async () => { + const res = await probe('jwt-kunde') + assert.equal(res.status, 403) + assert.equal(res.body.error, 'Kein Mitarbeiterkonto.') +}) + +test('Mail gleicht Mitarbeiter, ist aber unbestaetigt -> 403', async () => { + assert.equal((await probe('jwt-mail-ungeprueft')).status, 403) +}) + +test('bestaetigte Mail eines Mitarbeiters ohne userId -> erlaubt', async () => { + const res = await probe('jwt-mail-geprueft') + assert.equal(res.status, 200) + assert.equal(res.body.name, 'Nico') + assert.equal(res.body.appwriteUserId, 'u_neu') +}) + +test('Mitarbeiter ohne Admin-Label -> erlaubt, Sitzung in Admin-Form', async () => { + const res = await probe('jwt-staff') + assert.equal(res.status, 200) + assert.deepEqual(res.body, { role: 'admin', isAdmin: false, appwriteUserId: 'u_staff2', name: 'Andrej', email: 'andrej@webklar.com' }) +}) + +test('Label admin ohne employees-Eintrag -> erlaubt, isAdmin true', async () => { + const res = await probe('jwt-admin') + assert.equal(res.status, 200) + assert.equal(res.body.isAdmin, true) +}) + +test('CORS: erlaubte Herkunft bekommt die Upload-Header, fremde nichts', async () => { + const ok = await rawGet('/probe', { headers: { origin: 'https://ticket.webklar.com' } }) + assert.equal(ok.headers.get('access-control-allow-origin'), 'https://ticket.webklar.com') + assert.equal( + ok.headers.get('access-control-allow-headers'), + 'Authorization, Content-Type, X-File-Name, X-Attachment-Kind, X-Duration-Ms, X-Image-Width, X-Image-Height' + ) + const bad = await rawGet('/probe', { headers: { origin: 'https://evil.example' } }) + assert.equal(bad.headers.get('access-control-allow-origin'), null) +}) + +test('CORS: Preflight der erlaubten Herkunft -> 204 ohne JWT-Pruefung', async () => { + const res = await fetch(`${app.url}/probe`, { method: 'OPTIONS', headers: { origin: 'https://ticket.webklar.com' } }) + assert.equal(res.status, 204) +})