feat: Entra ID login + settings page for integrations

- Add SQLite settings table with getSetting/setSetting/getAllSettings helpers
- Implement Azure OAuth2 authorization code flow via @azure/msal-node
- Add public GET /api/auth/config endpoint for frontend activation check
- Add admin-only GET/PUT /api/settings API with masked secret fields
- CheckMK sync reads credentials from DB settings (env vars as fallback)
- New Settings.tsx: Entra ID and CheckMK configuration cards
- LoginPage: "Sign in with Microsoft" button, shown only when Azure is active
- App.tsx: OAuth callback handling (?token=/?auth_error=), Settings tab for admins
This commit is contained in:
Brückner
2026-06-03 16:02:47 +02:00
parent eed01b9665
commit d364aea4c1
5 changed files with 569 additions and 11 deletions

138
server.ts
View File

@ -4,7 +4,8 @@ import path from 'path';
import { createServer as createViteServer } from 'vite';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import db from './server-db';
import { ConfidentialClientApplication } from '@azure/msal-node';
import db, { getSetting, setSetting, getAllSettings } from './server-db';
import { Device, LabTemplate, Booking, LogEntry, User, QuickLink } from './src/types';
const uid = (prefix: string) => `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
@ -41,6 +42,35 @@ function requireAuth(req: Request, res: Response, next: NextFunction) {
}
}
function requireAdmin(req: Request, res: Response, next: NextFunction) {
const row = db.prepare('SELECT role FROM users WHERE id = ?').get(req.user!.userId) as { role: string } | undefined;
if (!row || row.role.toLowerCase() !== 'admin') {
res.status(403).json({ error: 'Admin access required.' });
return;
}
next();
}
function getMsalClient(): ConfidentialClientApplication | null {
const clientId = getSetting('azure_client_id');
const tenantId = getSetting('azure_tenant_id');
const secret = getSetting('azure_client_secret');
if (getSetting('azure_enabled') !== 'true' || !clientId || !tenantId || !secret) return null;
return new ConfidentialClientApplication({
auth: { clientId, authority: `https://login.microsoftonline.com/${tenantId}`, clientSecret: secret },
});
}
const SECRET_KEYS = ['azure_client_secret', 'checkmk_api_secret'];
function maskSettings(raw: Record<string, string>): Record<string, string> {
const out = { ...raw };
for (const k of SECRET_KEYS) {
if (out[k]) out[k] = '__SET__';
}
return out;
}
async function startServer() {
const app = express();
const PORT = Number(process.env.PORT) || 3000;
@ -113,6 +143,104 @@ async function startServer() {
}
});
// Public: frontend checks this before rendering the Azure login button
app.get('/api/auth/config', (_req, res) => {
const enabled = getSetting('azure_enabled') === 'true';
const clientId = getSetting('azure_client_id');
const tenantId = getSetting('azure_tenant_id');
const secret = getSetting('azure_client_secret');
res.json({ azureEnabled: enabled && Boolean(clientId) && Boolean(tenantId) && Boolean(secret) });
});
// Start Azure OAuth flow
app.get('/api/auth/azure', async (_req, res) => {
const msalClient = getMsalClient();
if (!msalClient) {
return res.redirect('/?auth_error=Azure+Login+nicht+konfiguriert');
}
const appUrl = process.env.APP_URL || `http://localhost:${PORT}`;
const redirectUri = getSetting('azure_redirect_uri') || `${appUrl}/api/auth/azure/callback`;
try {
const authCodeUrl = await msalClient.getAuthCodeUrl({
scopes: ['openid', 'profile', 'email'],
redirectUri,
});
res.redirect(authCodeUrl);
} catch (err: any) {
console.error('[Azure Auth] getAuthCodeUrl error:', err);
res.redirect('/?auth_error=Microsoft+Login+konnte+nicht+gestartet+werden');
}
});
// Azure OAuth callback
app.get('/api/auth/azure/callback', async (req, res) => {
const { code, error, error_description } = req.query;
if (error) {
const msg = encodeURIComponent(String(error_description || error));
return res.redirect(`/?auth_error=${msg}`);
}
if (!code) {
return res.redirect('/?auth_error=Kein+Autorisierungscode+erhalten');
}
const msalClient = getMsalClient();
if (!msalClient) {
return res.redirect('/?auth_error=Azure+Login+nicht+konfiguriert');
}
const appUrl = process.env.APP_URL || `http://localhost:${PORT}`;
const redirectUri = getSetting('azure_redirect_uri') || `${appUrl}/api/auth/azure/callback`;
try {
const result = await msalClient.acquireTokenByCode({
code: String(code),
scopes: ['openid', 'profile', 'email'],
redirectUri,
});
const email = (result.account?.username ?? '').toLowerCase();
const name = result.account?.name || email;
if (!email) {
return res.redirect('/?auth_error=Keine+E-Mail+von+Microsoft+erhalten');
}
let user = db.prepare('SELECT id, name, role, email FROM users WHERE email = ?').get(email) as User | undefined;
if (!user) {
const id = uid("u");
db.prepare('INSERT INTO users (id, name, role, email, password_hash) VALUES (?, ?, ?, ?, ?)')
.run(id, name, 'User', email, '');
user = db.prepare('SELECT id, name, role, email FROM users WHERE id = ?').get(id) as User;
}
const token = jwt.sign({ userId: user.id, email: user.email }, JWT_SECRET, { expiresIn: JWT_EXPIRY });
res.redirect(`/?token=${encodeURIComponent(token)}`);
} catch (err: any) {
console.error('[Azure Auth] acquireTokenByCode error:', err);
res.redirect('/?auth_error=Authentifizierung+fehlgeschlagen');
}
});
// -------------------------------------------------------------
// RESTFUL API: Settings (admin only)
// -------------------------------------------------------------
app.get('/api/settings', requireAuth, requireAdmin, (_req, res) => {
try {
res.json(maskSettings(getAllSettings()));
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
app.put('/api/settings', requireAuth, requireAdmin, (req, res) => {
try {
const allowed = ['azure_enabled', 'azure_client_id', 'azure_tenant_id', 'azure_client_secret',
'azure_redirect_uri', 'checkmk_api_url', 'checkmk_api_secret', 'checkmk_sync_interval_ms'];
const updates = req.body as Record<string, string>;
for (const key of allowed) {
if (key in updates && updates[key] !== '__SET__') {
setSetting(key, String(updates[key]));
}
}
res.json(maskSettings(getAllSettings()));
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// -------------------------------------------------------------
// RESTFUL API: Users
// -------------------------------------------------------------
@ -511,11 +639,13 @@ async function startServer() {
// Until CHECKMK_API_URL/SECRET are configured, devices stay 'unknown' (and
// therefore not bookable) - which is the intended safe default.
// -------------------------------------------------------------
const CHECKMK_SYNC_INTERVAL_MS = Number(process.env.CHECKMK_SYNC_INTERVAL_MS) || 60_000;
const CHECKMK_API_URL = process.env.CHECKMK_API_URL; // e.g. https://checkmk.internal/<site>/check_mk/api/1.0
const CHECKMK_API_SECRET = process.env.CHECKMK_API_SECRET; // automation user secret
// Sync interval: DB setting takes precedence over env var
const CHECKMK_SYNC_INTERVAL_MS = Number(getSetting('checkmk_sync_interval_ms') || process.env.CHECKMK_SYNC_INTERVAL_MS) || 60_000;
async function syncCheckMkStatuses() {
// DB settings take precedence; fall back to env vars for backwards compatibility
const CHECKMK_API_URL = getSetting('checkmk_api_url') || process.env.CHECKMK_API_URL;
const CHECKMK_API_SECRET = getSetting('checkmk_api_secret') || process.env.CHECKMK_API_SECRET;
if (!CHECKMK_API_URL || !CHECKMK_API_SECRET) return; // not configured - leave statuses as 'unknown'
const rows = db.prepare("SELECT id, hostname, checkMkUrl FROM devices WHERE checkMkUrl != ''")
.all() as { id: string; hostname: string; checkMkUrl: string }[];