From 97c4aa1c6841d1a37db59e2b50e55ab2daa219bf Mon Sep 17 00:00:00 2001 From: Fidelis Huber Date: Fri, 21 Aug 2026 17:56:40 +0200 Subject: [PATCH] Add local username/password login as fallback when Microsoft login isn't available MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neues "users"-Table (bcrypt-Hash, nie Klartext), POST /api/auth/login gibt ein selbst-signiertes JWT (HS256) aus. requireAuth unterscheidet MSAL- (RS256) und lokale Tokens (HS256) anhand des alg-Headers. Server legt beim Start optional ein erstes lokales Konto an, wenn SEED_ADMIN_USERNAME/SEED_ADMIN_PASSWORD als Stack-Env gesetzt sind (idempotent, Klartext-Passwort landet nie im Repo). Frontend: Login-Screen hat jetzt einen Alternativ-Link zu Benutzername/Passwort, App.tsx kombiniert MSAL- und lokalen Auth-Status. Temporärer test-build-only.yml Workflow zur Docker-Build-Verifikation ohne Registry-Push (wird nach dem Test wieder entfernt). Co-Authored-By: Claude Sonnet 5 --- .gitea/workflows/test-build-only.yml | 12 +++++ README.md | 25 ++++++++++- client/src/App.tsx | 45 +++++++++++-------- client/src/components/Header.tsx | 14 ++++-- client/src/lib/api.ts | 5 +++ client/src/lib/localAuth.ts | 31 +++++++++++++ client/src/pages/Login.tsx | 44 ++++++++++++++++-- gitops/stack.yml | 4 ++ server/package.json | 2 + .../20260821170000_add_users/migration.sql | 13 ++++++ server/prisma/schema.prisma | 12 +++++ server/src/auth.ts | 25 ++++++++++- server/src/index.ts | 28 ++++++++++-- server/src/routes/auth.ts | 35 +++++++++++++++ 14 files changed, 263 insertions(+), 32 deletions(-) create mode 100644 .gitea/workflows/test-build-only.yml create mode 100644 client/src/lib/localAuth.ts create mode 100644 server/prisma/migrations/20260821170000_add_users/migration.sql create mode 100644 server/src/routes/auth.ts diff --git a/.gitea/workflows/test-build-only.yml b/.gitea/workflows/test-build-only.yml new file mode 100644 index 0000000..fb73e32 --- /dev/null +++ b/.gitea/workflows/test-build-only.yml @@ -0,0 +1,12 @@ +name: Test Build Only (temporary, no push) + +on: + workflow_dispatch: + +jobs: + build: + runs-on: linux + steps: + - uses: actions/checkout@v4 + - name: Docker build (no push) + run: docker build -t materialschein-test:local . diff --git a/README.md b/README.md index d51ff0d..cea8ef9 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,12 @@ Auf `https://10.1.10.111:9443` (Zugang laut deploy-standard bei "dominic"): - Compose path: `gitops/stack.yml` - Authentication: `portainer-deploy` + dessen PAT (Org-Repos sind über das `deploy-read`-Team bereits abgedeckt, nichts zusätzlich einzutragen) - - Environment-Variable setzen: `POSTGRES_PASSWORD` = ein zufälliges, sicheres Passwort + - Environment-Variablen setzen: + - `POSTGRES_PASSWORD` = ein zufälliges, sicheres Passwort + - `JWT_SECRET` = ein zufälliger, langer String (signiert die lokalen Login-Tokens – + niemals ins Repo, nur hier als Stack-Env). Beispiel zum Generieren: `openssl rand -base64 48` + - `SEED_ADMIN_USERNAME` / `SEED_ADMIN_PASSWORD` = legt beim ersten Start automatisch + ein lokales Fallback-Konto an (siehe "Lokales Konto" unten) - **Webhook aktivieren**, **ForceUpdate an**, `ForcePullImage` bleibt aus - Die angezeigte Webhook-URL (`.../api/stacks/webhooks/`) als Gitea-Secret `PORTAINER_WEBHOOK_URL` eintragen (Schritt 2). @@ -90,6 +95,24 @@ Auf `https://10.1.10.111:9443` (Zugang laut deploy-standard bei "dominic"): - Cleanup-Rule für den Package-Owner einmalig setzen (Org/User → Settings → Packages → Cleanup Rules): keep most recent 10 + keep matching `^latest$`. +## Lokales Konto (Fallback ohne Microsoft-Login) + +Auf dem Login-Screen gibt es unter dem Microsoft-Button den Link "Alternativ mit +Benutzername anmelden". Damit das funktioniert, muss im Portainer-Stack `JWT_SECRET` +gesetzt sein (Schritt 4). + +Ein erstes Konto legt der Server **automatisch beim Start** an, wenn +`SEED_ADMIN_USERNAME` und `SEED_ADMIN_PASSWORD` als Stack-Env gesetzt sind (idempotent – +läuft bei jedem Neustart mit, legt aber nur an, was noch nicht existiert). Das +Klartext-Passwort steht dabei nur in der Portainer-Stack-Konfiguration, nie im Git-Repo – +der Server speichert ausschließlich einen bcrypt-Hash in der Datenbank. + +Optional danach `SEED_ADMIN_PASSWORD` aus dem Stack wieder entfernen (Härtung), sobald +das Konto einmal erfolgreich angelegt wurde – `SEED_ADMIN_USERNAME` kann stehen bleiben, +ohne Passwort passiert dann nichts mehr. Weitere lokale Konten aktuell nur direkt in der +Datenbank anlegbar (`INSERT INTO users …` mit einem bcrypt-Hash) – ein Verwaltungs-UI +dafür gibt es noch nicht. + ### Danach: zum Homescreen hinzufügen Seite im Handy-Browser öffnen, anmelden, "Zum Home-Bildschirm hinzufügen" (iOS Safari) bzw. "App installieren" (Android Chrome) – die App ist als PWA installierbar diff --git a/client/src/App.tsx b/client/src/App.tsx index e729625..2e545d7 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,29 +1,38 @@ +import { useState } from 'react'; import { BrowserRouter, Routes, Route } from 'react-router-dom'; -import { AuthenticatedTemplate, UnauthenticatedTemplate } from '@azure/msal-react'; +import { useIsAuthenticated } from '@azure/msal-react'; import Login from './pages/Login'; import Header from './components/Header'; import Dashboard from './pages/Dashboard'; import ProjektDetail from './pages/ProjektDetail'; import Kabelrechner from './pages/Kabelrechner'; +import { clearLocalAuth, getLocalUser } from './lib/localAuth'; export default function App() { + const msalAuthenticated = useIsAuthenticated(); + const [localUser, setLocalUser] = useState(getLocalUser()); + + const isAuthenticated = msalAuthenticated || !!localUser; + + if (!isAuthenticated) { + return ; + } + + function handleLocalLogout() { + clearLocalAuth(); + setLocalUser(null); + } + return ( - <> - - - - - -
-
- - } /> - } /> - } /> - -
- - - + +
+
+ + } /> + } /> + } /> + +
+ ); } diff --git a/client/src/components/Header.tsx b/client/src/components/Header.tsx index 78122bd..4bec622 100644 --- a/client/src/components/Header.tsx +++ b/client/src/components/Header.tsx @@ -1,14 +1,20 @@ import { Link, useLocation } from 'react-router-dom'; import { useMsal } from '@azure/msal-react'; -export default function Header() { +export default function Header({ localUser, onLocalLogout }: { localUser: string | null; onLocalLogout: () => void }) { const { instance, accounts } = useMsal(); const location = useLocation(); const onKabelrechner = location.pathname.startsWith('/kabelrechner'); const account = accounts[0]; - const displayName = account?.name || account?.username || '–'; + const displayName = localUser ?? account?.name ?? account?.username ?? '–'; + const roleLine = localUser ? 'Lokales Konto' : (account?.username ?? ''); const initials = displayName.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase(); + function logout() { + if (account) instance.logoutPopup(); + onLocalLogout(); + } + return ( <>
@@ -22,10 +28,10 @@ export default function Header() {
{initials}
{displayName.split(' ')[0]}
-
{account?.username}
+
{roleLine}
- +
📋 MATERIALSCHEIN diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 1aace0c..cf862e4 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -1,7 +1,12 @@ import { msalInstance } from './authConfig'; +import { getLocalToken } from './localAuth'; import type { MaterialItem, Projekt } from './types'; async function authHeader(): Promise { + const localToken = getLocalToken(); + if (localToken) { + return { Authorization: `Bearer ${localToken}`, 'Content-Type': 'application/json' }; + } const account = msalInstance.getAllAccounts()[0]; if (!account) throw new Error('Nicht angemeldet.'); const result = await msalInstance.acquireTokenSilent({ scopes: ['openid', 'profile'], account }); diff --git a/client/src/lib/localAuth.ts b/client/src/lib/localAuth.ts new file mode 100644 index 0000000..112cf20 --- /dev/null +++ b/client/src/lib/localAuth.ts @@ -0,0 +1,31 @@ +const TOKEN_KEY = 'bv_local_token'; +const NAME_KEY = 'bv_local_name'; + +export function getLocalToken(): string | null { + return sessionStorage.getItem(TOKEN_KEY); +} + +export function getLocalUser(): string | null { + return sessionStorage.getItem(NAME_KEY); +} + +export async function localLogin(username: string, password: string): Promise { + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }) + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || 'Anmeldung fehlgeschlagen.'); + } + const data = await res.json(); + sessionStorage.setItem(TOKEN_KEY, data.token); + sessionStorage.setItem(NAME_KEY, data.name); + return data.name as string; +} + +export function clearLocalAuth() { + sessionStorage.removeItem(TOKEN_KEY); + sessionStorage.removeItem(NAME_KEY); +} diff --git a/client/src/pages/Login.tsx b/client/src/pages/Login.tsx index 1b917f0..386e386 100644 --- a/client/src/pages/Login.tsx +++ b/client/src/pages/Login.tsx @@ -1,12 +1,16 @@ -import { useState } from 'react'; +import { useState, type FormEvent } from 'react'; import { useMsal } from '@azure/msal-react'; +import { localLogin } from '../lib/localAuth'; -export default function Login() { +export default function Login({ onLocalLogin }: { onLocalLogin: (name: string) => void }) { const { instance } = useMsal(); const [busy, setBusy] = useState(false); const [err, setErr] = useState(''); + const [showLocal, setShowLocal] = useState(false); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); - async function login() { + async function loginMicrosoft() { setBusy(true); setErr(''); try { @@ -17,6 +21,19 @@ export default function Login() { setBusy(false); } + async function submitLocal(e: FormEvent) { + e.preventDefault(); + setBusy(true); + setErr(''); + try { + const name = await localLogin(username, password); + onLocalLogin(name); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } + setBusy(false); + } + return (
@@ -24,7 +41,7 @@ export default function Login() {
Elektrotechnik · Materialschein
Willkommen
Melde dich mit deinem BergVOLT
Office 365 Konto an.
- + + {!showLocal && ( + + )} + + {showLocal && ( +
+ + + +
+ )} + {err &&
{err}
}
diff --git a/gitops/stack.yml b/gitops/stack.yml index d0131a9..2bcc09c 100644 --- a/gitops/stack.yml +++ b/gitops/stack.yml @@ -21,6 +21,10 @@ services: MSAL_CLIENT_ID: b1cd974c-77ce-4d93-b6e8-00e41a31adc8 MSAL_TENANT_ID: d9adeaaf-853b-4c4e-a822-8f1bedbb84f6 PORT: "80" + # Lokaler Login-Fallback (siehe README "Lokales Konto"): + JWT_SECRET: ${JWT_SECRET} + SEED_ADMIN_USERNAME: ${SEED_ADMIN_USERNAME} + SEED_ADMIN_PASSWORD: ${SEED_ADMIN_PASSWORD} depends_on: - db diff --git a/server/package.json b/server/package.json index f9f8497..4af5edf 100644 --- a/server/package.json +++ b/server/package.json @@ -12,12 +12,14 @@ }, "dependencies": { "@prisma/client": "^5.20.0", + "bcryptjs": "^2.4.3", "cors": "^2.8.5", "express": "^4.21.0", "jwks-rsa": "^3.1.0", "jsonwebtoken": "^9.0.2" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/jsonwebtoken": "^9.0.7", diff --git a/server/prisma/migrations/20260821170000_add_users/migration.sql b/server/prisma/migrations/20260821170000_add_users/migration.sql new file mode 100644 index 0000000..d526f96 --- /dev/null +++ b/server/prisma/migrations/20260821170000_add_users/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE "users" ( + "id" TEXT NOT NULL, + "username" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "name" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "users_username_key" ON "users"("username"); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 276cb29..3253fa1 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -43,3 +43,15 @@ model Material { @@index([projektId]) @@map("material") } + +// Lokales Konto als Fallback, falls Microsoft-Login nicht verfügbar ist. +// passwordHash ist ein bcrypt-Hash, das Klartext-Passwort wird nie gespeichert. +model User { + id String @id @default(cuid()) + username String @unique + passwordHash String + name String? + createdAt DateTime @default(now()) + + @@map("users") +} diff --git a/server/src/auth.ts b/server/src/auth.ts index 1988cc3..cc734b1 100644 --- a/server/src/auth.ts +++ b/server/src/auth.ts @@ -4,6 +4,7 @@ import jwksClient from 'jwks-rsa'; const CLIENT_ID = process.env.MSAL_CLIENT_ID ?? ''; const TENANT_ID = process.env.MSAL_TENANT_ID ?? ''; +export const JWT_SECRET = process.env.JWT_SECRET ?? ''; const jwks = jwksClient({ jwksUri: `https://login.microsoftonline.com/${TENANT_ID}/discovery/v2.0/keys`, @@ -23,8 +24,10 @@ export interface AuthedRequest extends Request { user?: { email: string; name: string; oid: string }; } -// Verifiziert das MSAL-Access-/ID-Token (Entra ID) im Authorization-Header. -// Prüft Signatur, Tenant (tid) und dass das Token für unsere App ausgestellt wurde (aud). +// Zwei Anmeldearten werden akzeptiert: +// - Microsoft Entra ID (RS256, per JWKS von login.microsoftonline.com verifiziert) +// - Lokales Konto (HS256, mit unserem eigenen JWT_SECRET signiert, siehe routes/auth.ts) +// Unterschieden wird anhand des "alg" im (unverifizierten) Token-Header. export function requireAuth(req: AuthedRequest, res: Response, next: NextFunction) { const header = req.headers.authorization; if (!header?.startsWith('Bearer ')) { @@ -32,6 +35,24 @@ export function requireAuth(req: AuthedRequest, res: Response, next: NextFunctio return; } const token = header.slice('Bearer '.length); + const unverifiedHeader = jwt.decode(token, { complete: true })?.header; + + if (unverifiedHeader?.alg === 'HS256') { + if (!JWT_SECRET) { + res.status(500).json({ error: 'JWT_SECRET ist serverseitig nicht konfiguriert.' }); + return; + } + jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }, (err, decoded) => { + if (err || !decoded || typeof decoded === 'string') { + res.status(401).json({ error: 'Token ungültig: ' + (err?.message ?? 'unbekannt') }); + return; + } + const claims = decoded as jwt.JwtPayload & { username?: string; name?: string; oid?: string }; + req.user = { email: claims.username ?? '', name: claims.name ?? claims.username ?? '', oid: claims.oid ?? '' }; + next(); + }); + return; + } jwt.verify(token, getKey, { algorithms: ['RS256'] }, (err, decoded) => { if (err || !decoded || typeof decoded === 'string') { diff --git a/server/src/index.ts b/server/src/index.ts index 53c23b7..b4ab84a 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -2,9 +2,12 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import express from 'express'; import cors from 'cors'; +import bcrypt from 'bcryptjs'; import { requireAuth } from './auth.js'; +import { authRouter } from './routes/auth.js'; import { projekteRouter } from './routes/projekte.js'; import { materialRouter } from './routes/material.js'; +import { prisma } from './prisma.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PORT = Number(process.env.PORT ?? 80); @@ -16,6 +19,7 @@ app.use(express.json()); app.get('/api/health', (_req, res) => res.json({ ok: true })); +app.use('/api/auth', authRouter); app.use('/api/projekte', requireAuth, projekteRouter); app.use('/api/material', requireAuth, materialRouter); @@ -27,6 +31,24 @@ app.get(/^(?!\/api\/).*/, (_req, res) => { res.sendFile(path.join(PUBLIC_DIR, 'index.html')); }); -app.listen(PORT, () => { - console.log(`Materialschein-Server läuft auf Port ${PORT}`); -}); +// Legt beim ersten Start ein lokales Fallback-Konto an, falls SEED_ADMIN_USERNAME/ +// SEED_ADMIN_PASSWORD gesetzt sind und noch kein Konto mit diesem Namen existiert. +// Idempotent – kann bei jedem Neustart stehen bleiben, legt nichts doppelt an. +async function seedAdminIfConfigured() { + const username = process.env.SEED_ADMIN_USERNAME; + const password = process.env.SEED_ADMIN_PASSWORD; + if (!username || !password) return; + const existing = await prisma.user.findUnique({ where: { username } }); + if (existing) return; + const passwordHash = await bcrypt.hash(password, 12); + await prisma.user.create({ data: { username, passwordHash, name: username } }); + console.log(`Lokales Konto "${username}" angelegt.`); +} + +seedAdminIfConfigured() + .catch(err => console.error('Seed des lokalen Kontos fehlgeschlagen:', err)) + .finally(() => { + app.listen(PORT, () => { + console.log(`Materialschein-Server läuft auf Port ${PORT}`); + }); + }); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts new file mode 100644 index 0000000..67e0de5 --- /dev/null +++ b/server/src/routes/auth.ts @@ -0,0 +1,35 @@ +import { Router } from 'express'; +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; +import { prisma } from '../prisma.js'; +import { JWT_SECRET } from '../auth.js'; + +export const authRouter = Router(); + +authRouter.post('/login', async (req, res) => { + const { username, password } = req.body ?? {}; + if (!username || !password) { + res.status(400).json({ error: 'Benutzername und Passwort erforderlich.' }); + return; + } + if (!JWT_SECRET) { + res.status(500).json({ error: 'JWT_SECRET ist serverseitig nicht konfiguriert.' }); + return; + } + const user = await prisma.user.findUnique({ where: { username } }); + if (!user) { + res.status(401).json({ error: 'Benutzername oder Passwort falsch.' }); + return; + } + const ok = await bcrypt.compare(password, user.passwordHash); + if (!ok) { + res.status(401).json({ error: 'Benutzername oder Passwort falsch.' }); + return; + } + const token = jwt.sign( + { username: user.username, name: user.name ?? user.username }, + JWT_SECRET, + { algorithm: 'HS256', expiresIn: '30d' } + ); + res.json({ token, name: user.name ?? user.username }); +});