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:
138
server.ts
138
server.ts
@ -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 }[];
|
||||
|
||||
Reference in New Issue
Block a user