Add local username/password login as fallback when Microsoft login isn't available
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
7bdf4f2897
commit
97c4aa1c68
@@ -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 .
|
||||||
@@ -76,7 +76,12 @@ Auf `https://10.1.10.111:9443` (Zugang laut deploy-standard bei "dominic"):
|
|||||||
- Compose path: `gitops/stack.yml`
|
- Compose path: `gitops/stack.yml`
|
||||||
- Authentication: `portainer-deploy` + dessen PAT (Org-Repos sind über das
|
- Authentication: `portainer-deploy` + dessen PAT (Org-Repos sind über das
|
||||||
`deploy-read`-Team bereits abgedeckt, nichts zusätzlich einzutragen)
|
`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
|
- **Webhook aktivieren**, **ForceUpdate an**, `ForcePullImage` bleibt aus
|
||||||
- Die angezeigte Webhook-URL (`.../api/stacks/webhooks/<uuid>`) als Gitea-Secret
|
- Die angezeigte Webhook-URL (`.../api/stacks/webhooks/<uuid>`) als Gitea-Secret
|
||||||
`PORTAINER_WEBHOOK_URL` eintragen (Schritt 2).
|
`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-Rule für den Package-Owner einmalig setzen (Org/User → Settings → Packages →
|
||||||
Cleanup Rules): keep most recent 10 + keep matching `^latest$`.
|
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
|
### Danach: zum Homescreen hinzufügen
|
||||||
Seite im Handy-Browser öffnen, anmelden, "Zum Home-Bildschirm hinzufügen" (iOS Safari)
|
Seite im Handy-Browser öffnen, anmelden, "Zum Home-Bildschirm hinzufügen" (iOS Safari)
|
||||||
bzw. "App installieren" (Android Chrome) – die App ist als PWA installierbar
|
bzw. "App installieren" (Android Chrome) – die App ist als PWA installierbar
|
||||||
|
|||||||
+18
-9
@@ -1,20 +1,31 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
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 Login from './pages/Login';
|
||||||
import Header from './components/Header';
|
import Header from './components/Header';
|
||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
import ProjektDetail from './pages/ProjektDetail';
|
import ProjektDetail from './pages/ProjektDetail';
|
||||||
import Kabelrechner from './pages/Kabelrechner';
|
import Kabelrechner from './pages/Kabelrechner';
|
||||||
|
import { clearLocalAuth, getLocalUser } from './lib/localAuth';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const msalAuthenticated = useIsAuthenticated();
|
||||||
|
const [localUser, setLocalUser] = useState<string | null>(getLocalUser());
|
||||||
|
|
||||||
|
const isAuthenticated = msalAuthenticated || !!localUser;
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Login onLocalLogin={setLocalUser} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLocalLogout() {
|
||||||
|
clearLocalAuth();
|
||||||
|
setLocalUser(null);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<UnauthenticatedTemplate>
|
|
||||||
<Login />
|
|
||||||
</UnauthenticatedTemplate>
|
|
||||||
<AuthenticatedTemplate>
|
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Header />
|
<Header localUser={localUser} onLocalLogout={handleLocalLogout} />
|
||||||
<main>
|
<main>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
@@ -23,7 +34,5 @@ export default function App() {
|
|||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</AuthenticatedTemplate>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { useMsal } from '@azure/msal-react';
|
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 { instance, accounts } = useMsal();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const onKabelrechner = location.pathname.startsWith('/kabelrechner');
|
const onKabelrechner = location.pathname.startsWith('/kabelrechner');
|
||||||
const account = accounts[0];
|
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();
|
const initials = displayName.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase();
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
if (account) instance.logoutPopup();
|
||||||
|
onLocalLogout();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header>
|
<header>
|
||||||
@@ -22,10 +28,10 @@ export default function Header() {
|
|||||||
<div className="hdr-av">{initials}</div>
|
<div className="hdr-av">{initials}</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="hdr-name">{displayName.split(' ')[0]}</div>
|
<div className="hdr-name">{displayName.split(' ')[0]}</div>
|
||||||
<div className="hdr-role">{account?.username}</div>
|
<div className="hdr-role">{roleLine}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn-logout" onClick={() => instance.logoutPopup()}>Abmelden</button>
|
<button className="btn-logout" onClick={logout}>Abmelden</button>
|
||||||
</header>
|
</header>
|
||||||
<div className="tabs">
|
<div className="tabs">
|
||||||
<Link to="/" className={'tab' + (!onKabelrechner ? ' active' : '')}>📋 MATERIALSCHEIN</Link>
|
<Link to="/" className={'tab' + (!onKabelrechner ? ' active' : '')}>📋 MATERIALSCHEIN</Link>
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { msalInstance } from './authConfig';
|
import { msalInstance } from './authConfig';
|
||||||
|
import { getLocalToken } from './localAuth';
|
||||||
import type { MaterialItem, Projekt } from './types';
|
import type { MaterialItem, Projekt } from './types';
|
||||||
|
|
||||||
async function authHeader(): Promise<HeadersInit> {
|
async function authHeader(): Promise<HeadersInit> {
|
||||||
|
const localToken = getLocalToken();
|
||||||
|
if (localToken) {
|
||||||
|
return { Authorization: `Bearer ${localToken}`, 'Content-Type': 'application/json' };
|
||||||
|
}
|
||||||
const account = msalInstance.getAllAccounts()[0];
|
const account = msalInstance.getAllAccounts()[0];
|
||||||
if (!account) throw new Error('Nicht angemeldet.');
|
if (!account) throw new Error('Nicht angemeldet.');
|
||||||
const result = await msalInstance.acquireTokenSilent({ scopes: ['openid', 'profile'], account });
|
const result = await msalInstance.acquireTokenSilent({ scopes: ['openid', 'profile'], account });
|
||||||
|
|||||||
@@ -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<string> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import { useState } from 'react';
|
import { useState, type FormEvent } from 'react';
|
||||||
import { useMsal } from '@azure/msal-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 { instance } = useMsal();
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [err, setErr] = useState('');
|
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);
|
setBusy(true);
|
||||||
setErr('');
|
setErr('');
|
||||||
try {
|
try {
|
||||||
@@ -17,6 +21,19 @@ export default function Login() {
|
|||||||
setBusy(false);
|
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 (
|
return (
|
||||||
<div className="login-screen">
|
<div className="login-screen">
|
||||||
<div className="lcard">
|
<div className="lcard">
|
||||||
@@ -24,7 +41,7 @@ export default function Login() {
|
|||||||
<div className="lsub">Elektrotechnik · Materialschein</div>
|
<div className="lsub">Elektrotechnik · Materialschein</div>
|
||||||
<div className="ltitle">Willkommen</div>
|
<div className="ltitle">Willkommen</div>
|
||||||
<div className="ldesc">Melde dich mit deinem BergVOLT<br />Office 365 Konto an.</div>
|
<div className="ldesc">Melde dich mit deinem BergVOLT<br />Office 365 Konto an.</div>
|
||||||
<button className="btn-ms" onClick={login} disabled={busy}>
|
<button className="btn-ms" onClick={loginMicrosoft} disabled={busy}>
|
||||||
<svg viewBox="0 0 21 21" width="18" height="18">
|
<svg viewBox="0 0 21 21" width="18" height="18">
|
||||||
<rect x="0" y="0" width="10" height="10" fill="#f25022" />
|
<rect x="0" y="0" width="10" height="10" fill="#f25022" />
|
||||||
<rect x="11" y="0" width="10" height="10" fill="#7fba00" />
|
<rect x="11" y="0" width="10" height="10" fill="#7fba00" />
|
||||||
@@ -33,6 +50,25 @@ export default function Login() {
|
|||||||
</svg>
|
</svg>
|
||||||
Mit Microsoft anmelden
|
Mit Microsoft anmelden
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{!showLocal && (
|
||||||
|
<button className="btn btn-ghost" style={{ width: '100%', marginTop: 14 }} onClick={() => setShowLocal(true)}>
|
||||||
|
Alternativ mit Benutzername anmelden
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showLocal && (
|
||||||
|
<form onSubmit={submitLocal} style={{ marginTop: 18, textAlign: 'left', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<label className="field">Benutzername
|
||||||
|
<input value={username} onChange={e => setUsername(e.target.value)} autoComplete="username" autoFocus />
|
||||||
|
</label>
|
||||||
|
<label className="field">Passwort
|
||||||
|
<input type="password" value={password} onChange={e => setPassword(e.target.value)} autoComplete="current-password" />
|
||||||
|
</label>
|
||||||
|
<button className="btn btn-cyan" style={{ width: '100%' }} disabled={busy} type="submit">Anmelden</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
{err && <div className="lerr">{err}</div>}
|
{err && <div className="lerr">{err}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ services:
|
|||||||
MSAL_CLIENT_ID: b1cd974c-77ce-4d93-b6e8-00e41a31adc8
|
MSAL_CLIENT_ID: b1cd974c-77ce-4d93-b6e8-00e41a31adc8
|
||||||
MSAL_TENANT_ID: d9adeaaf-853b-4c4e-a822-8f1bedbb84f6
|
MSAL_TENANT_ID: d9adeaaf-853b-4c4e-a822-8f1bedbb84f6
|
||||||
PORT: "80"
|
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:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
|
|
||||||
|
|||||||
@@ -12,12 +12,14 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.20.0",
|
"@prisma/client": "^5.20.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"jwks-rsa": "^3.1.0",
|
"jwks-rsa": "^3.1.0",
|
||||||
"jsonwebtoken": "^9.0.2"
|
"jsonwebtoken": "^9.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/jsonwebtoken": "^9.0.7",
|
"@types/jsonwebtoken": "^9.0.7",
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -43,3 +43,15 @@ model Material {
|
|||||||
@@index([projektId])
|
@@index([projektId])
|
||||||
@@map("material")
|
@@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")
|
||||||
|
}
|
||||||
|
|||||||
+23
-2
@@ -4,6 +4,7 @@ import jwksClient from 'jwks-rsa';
|
|||||||
|
|
||||||
const CLIENT_ID = process.env.MSAL_CLIENT_ID ?? '';
|
const CLIENT_ID = process.env.MSAL_CLIENT_ID ?? '';
|
||||||
const TENANT_ID = process.env.MSAL_TENANT_ID ?? '';
|
const TENANT_ID = process.env.MSAL_TENANT_ID ?? '';
|
||||||
|
export const JWT_SECRET = process.env.JWT_SECRET ?? '';
|
||||||
|
|
||||||
const jwks = jwksClient({
|
const jwks = jwksClient({
|
||||||
jwksUri: `https://login.microsoftonline.com/${TENANT_ID}/discovery/v2.0/keys`,
|
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 };
|
user?: { email: string; name: string; oid: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verifiziert das MSAL-Access-/ID-Token (Entra ID) im Authorization-Header.
|
// Zwei Anmeldearten werden akzeptiert:
|
||||||
// Prüft Signatur, Tenant (tid) und dass das Token für unsere App ausgestellt wurde (aud).
|
// - 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) {
|
export function requireAuth(req: AuthedRequest, res: Response, next: NextFunction) {
|
||||||
const header = req.headers.authorization;
|
const header = req.headers.authorization;
|
||||||
if (!header?.startsWith('Bearer ')) {
|
if (!header?.startsWith('Bearer ')) {
|
||||||
@@ -32,6 +35,24 @@ export function requireAuth(req: AuthedRequest, res: Response, next: NextFunctio
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const token = header.slice('Bearer '.length);
|
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) => {
|
jwt.verify(token, getKey, { algorithms: ['RS256'] }, (err, decoded) => {
|
||||||
if (err || !decoded || typeof decoded === 'string') {
|
if (err || !decoded || typeof decoded === 'string') {
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ import path from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
import { requireAuth } from './auth.js';
|
import { requireAuth } from './auth.js';
|
||||||
|
import { authRouter } from './routes/auth.js';
|
||||||
import { projekteRouter } from './routes/projekte.js';
|
import { projekteRouter } from './routes/projekte.js';
|
||||||
import { materialRouter } from './routes/material.js';
|
import { materialRouter } from './routes/material.js';
|
||||||
|
import { prisma } from './prisma.js';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const PORT = Number(process.env.PORT ?? 80);
|
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.get('/api/health', (_req, res) => res.json({ ok: true }));
|
||||||
|
|
||||||
|
app.use('/api/auth', authRouter);
|
||||||
app.use('/api/projekte', requireAuth, projekteRouter);
|
app.use('/api/projekte', requireAuth, projekteRouter);
|
||||||
app.use('/api/material', requireAuth, materialRouter);
|
app.use('/api/material', requireAuth, materialRouter);
|
||||||
|
|
||||||
@@ -27,6 +31,24 @@ app.get(/^(?!\/api\/).*/, (_req, res) => {
|
|||||||
res.sendFile(path.join(PUBLIC_DIR, 'index.html'));
|
res.sendFile(path.join(PUBLIC_DIR, 'index.html'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Materialschein-Server läuft auf Port ${PORT}`);
|
console.log(`Materialschein-Server läuft auf Port ${PORT}`);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user