feat(auth): Anmeldung ueber Microsoft Entra (SSO)

OpenID Connect mit Authorization Code Flow und PKCE, vollstaendig
serverseitig - der Browser sieht weder Tenant- noch Client-ID. Genutzt wird
dieselbe App-Registrierung wie die Postfachanbindung, also kein zweites
Secret; delegiert statt app-only.

Nach dem Token-Tausch beweist ein Aufruf von /me bei Graph, dass das
Zugriffstoken gilt, und liefert die Objekt-ID. Sie landet in der neuen Spalte
crm_users.entra_oid und ist danach die stabile Verknuepfung - die E-Mail-
Adresse eines Menschen kann sich aendern, die Objekt-ID nicht.

Bewusst so entschieden:
  - Es werden keine Konten angelegt. Wer kein CRM-Konto mit derselben Adresse
    hat, wird abgewiesen; Zugriff vergibt weiter die Nutzerverwaltung.
  - Die Anmeldung mit Benutzername und Passwort bleibt fuer alle bestehen -
    Notzugang, falls Entra oder das Secret ausfaellt.
  - Das CRM-Token kommt als Einmal-Ticket zurueck, nicht in der Adresszeile:
    die Rueckleitung ist ein GET und stuende sonst in Browserverlauf und
    Apache-Log.
  - Keine Signaturpruefung des ID-Tokens und damit keine JWKS-Bibliothek. Es
    kommt ueber TLS direkt vom Token-Endpunkt (OIDC Core 3.1.3.7); geprueft
    werden aud, tid, nonce und exp, und der Graph-Aufruf belegt den Rest.

Ohne MS_SSO_ENABLED=true bleibt alles aus und die Anmeldemaske sieht aus wie
bisher.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-09-21 08:14:23 +02:00
co-authored by Claude Opus 5
parent 2d84bac91d
commit 419e57b55d
23 changed files with 1188 additions and 19 deletions
+57 -5
View File
@@ -1,5 +1,16 @@
import React, { useEffect, useRef, useState } from 'react';
import { useAuth } from '../lib/auth';
import { SSO_START_URL, ssoApi } from '../lib/api';
/** Microsoft-Logo - die vier Quadrate, in den Originalfarben. */
const MicrosoftMark: React.FC = () => (
<svg className="h-4 w-4 shrink-0" viewBox="0 0 23 23" aria-hidden="true">
<path fill="#f25022" d="M1 1h10v10H1z" />
<path fill="#7fba00" d="M12 1h10v10H12z" />
<path fill="#00a4ef" d="M1 12h10v10H1z" />
<path fill="#ffb900" d="M12 12h10v10H12z" />
</svg>
);
/**
* Knoten des dekorativen Netzwerks. Jeder Knoten driftet auf einer eigenen
@@ -109,15 +120,34 @@ const NetworkPattern: React.FC = () => {
};
export const LoginScreen: React.FC = () => {
const { login } = useAuth();
const { login, ssoError, clearSsoError } = useAuth();
const [identifier, setIdentifier] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [ssoEnabled, setSsoEnabled] = useState(false);
// Der Microsoft-Knopf erscheint nur, wenn die Anbindung eingerichtet ist -
// ein Knopf, der auf eine Fehlermeldung fuehrt, waere schlimmer als keiner.
useEffect(() => {
let cancelled = false;
ssoApi
.status()
.then(({ enabled }) => {
if (!cancelled) setSsoEnabled(enabled);
})
.catch(() => {
// Ist der Status nicht erreichbar, bleibt es beim Passwort-Login.
});
return () => {
cancelled = true;
};
}, []);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
clearSsoError();
setIsSubmitting(true);
try {
await login(identifier, password);
@@ -152,10 +182,30 @@ export const LoginScreen: React.FC = () => {
<h2 className="text-2xl text-ink-900">Anmelden</h2>
<p className="mt-2 text-sm text-ink-500">
Melde dich mit deinem Benutzernamen oder deiner E-Mail-Adresse an.
{ssoEnabled
? 'Melde dich mit deinem Microsoft-Konto an oder nutze Benutzername und Passwort.'
: 'Melde dich mit deinem Benutzernamen oder deiner E-Mail-Adresse an.'}
</p>
<form onSubmit={handleSubmit} className="mt-8 space-y-5">
{ssoEnabled && (
<>
<a
href={SSO_START_URL}
className="mt-8 flex w-full items-center justify-center gap-3 rounded-xl border border-ink-200 bg-surface px-4 py-3 text-sm font-bold text-ink-800 shadow-card transition hover:border-brand-500 hover:bg-brand-50"
>
<MicrosoftMark />
Mit Microsoft anmelden
</a>
<div className="my-6 flex items-center gap-3">
<span className="h-px flex-1 bg-ink-100" />
<span className="text-xs font-bold uppercase tracking-wider text-ink-400">oder</span>
<span className="h-px flex-1 bg-ink-100" />
</div>
</>
)}
<form onSubmit={handleSubmit} className={ssoEnabled ? 'space-y-5' : 'mt-8 space-y-5'}>
<div>
<label htmlFor="identifier" className="mb-1.5 block text-xs font-bold uppercase tracking-wider text-ink-500">
Benutzername oder E-Mail
@@ -187,7 +237,9 @@ export const LoginScreen: React.FC = () => {
/>
</div>
{error && (
{/* Fehler der Passwortanmeldung und der Rueckleitung von Microsoft
teilen sich diesen Kasten - es kann immer nur einer aktuell sein. */}
{(error || ssoError) && (
<div className="flex items-start gap-2 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm font-semibold text-red-700">
<svg className="mt-0.5 h-4 w-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@@ -197,7 +249,7 @@ export const LoginScreen: React.FC = () => {
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
/>
</svg>
<span>{error}</span>
<span>{error || ssoError}</span>
</div>
)}
+3
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import { UserManagement } from './UserManagement';
import { MailSettings } from './MailSettings';
import { SsoSettings } from './SsoSettings';
import { AcquisitionSettings } from './AcquisitionSettings';
import { ScannerSettings } from './ScannerSettings';
import { ColorPicker } from './ColorPicker';
@@ -17,6 +18,7 @@ const TABS: { id: SettingsTab; label: string }[] = [
{ id: 'account', label: 'Mein Konto' },
{ id: 'akquise', label: 'Akquise' },
{ id: 'mail', label: 'Postfach' },
{ id: 'sso', label: 'Anmeldung' },
{ id: 'scanner', label: 'Scanner' },
{ id: 'whisper', label: 'Whisper' },
];
@@ -223,6 +225,7 @@ export const Settings: React.FC<{ initialTab?: SettingsTab }> = ({ initialTab })
{tab === 'account' && <AccountTab />}
{tab === 'akquise' && <AcquisitionSettings />}
{tab === 'mail' && isAdmin && <MailSettings />}
{tab === 'sso' && isAdmin && <SsoSettings />}
{tab === 'scanner' && isAdmin && <ScannerSettings />}
{tab === 'whisper' && <WhisperSettings />}
</div>
+110
View File
@@ -0,0 +1,110 @@
import React, { useEffect, useState } from 'react';
import { ssoApi, type SsoConfig } from '../../lib/api';
import { useToast } from '../Toast';
/*
* Anmeldung über Microsoft Entra - Adminsicht.
*
* Hier lässt sich nichts umstellen, und das ist Absicht: Tenant, Client-ID,
* Secret und Umleitungsadresse stehen in server/.env und tauchen damit weder in
* der Oberfläche noch in einem Datenbank-Dump auf. Diese Seite zeigt nur, ob
* die Anbindung steht, und nennt die Werte, die in Entra eingetragen sein
* müssen.
*/
const Fact: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => (
<div className="flex justify-between gap-3 text-sm">
<dt className="shrink-0 text-ink-400">{label}</dt>
<dd className="truncate text-right font-semibold text-ink-800">{value}</dd>
</div>
);
export const SsoSettings: React.FC = () => {
const { addToast } = useToast();
const [config, setConfig] = useState<SsoConfig | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let cancelled = false;
ssoApi
.config()
.then((data) => {
if (!cancelled) setConfig(data);
})
.catch((err) => {
if (!cancelled) {
addToast(err instanceof Error ? err.message : 'Status nicht erreichbar.', 'error');
}
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (isLoading) return <p className="text-sm text-ink-400">Wird geladen...</p>;
if (!config) return null;
return (
<div className="grid gap-6 lg:grid-cols-2">
<section
className={`rounded-2xl border border-l-4 border-ink-200 bg-surface p-5 ${
config.enabled ? 'border-l-green-500' : 'border-l-amber-500'
}`}
>
<h3 className="text-base text-ink-900">Status</h3>
<p className="mt-1 text-xs text-ink-400">
{config.enabled
? 'Auf der Anmeldemaske erscheint der Knopf „Mit Microsoft anmelden“.'
: 'Die Anmeldung über Microsoft ist aus. Die Anmeldemaske zeigt nur Benutzername und Passwort.'}
</p>
<dl className="mt-4 space-y-2">
<Fact label="Anbindung" value={config.enabled ? 'aktiv' : 'inaktiv'} />
<Fact label="Umleitungsadresse" value={config.redirect_uri || '—'} />
<Fact
label="Erlaubte Domains"
value={config.allowed_domains.length ? config.allowed_domains.join(', ') : 'alle'}
/>
</dl>
{config.missing.length > 0 && (
<div className="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-800">
Fehlt in <code>server/.env</code>: {config.missing.join(', ')}
<span className="mt-1 block text-xs font-normal">
Die Werte werden beim Start gelesen - nach einer Änderung das Backend neu starten.
</span>
</div>
)}
</section>
<section className="rounded-2xl border border-ink-200 bg-surface p-5">
<h3 className="text-base text-ink-900">Wie es funktioniert</h3>
<ul className="mt-4 space-y-3 text-sm text-ink-600">
<li>
<span className="font-bold text-ink-800">Kein automatisches Anlegen.</span> Wer sich per
Microsoft anmeldet, braucht ein Konto in der Nutzerverwaltung mit derselben
E-Mail-Adresse. Fehlt es, wird die Anmeldung abgewiesen.
</li>
<li>
<span className="font-bold text-ink-800">Erste Anmeldung verknüpft.</span> Danach hängt
das Konto an der Objekt-ID des Microsoft-Kontos und überlebt einen Adresswechsel.
</li>
<li>
<span className="font-bold text-ink-800">Profilfoto.</span> Wer im CRM noch kein Foto
hat, bekommt bei der ersten Anmeldung das aus Microsoft 365 übernommen. Ein selbst
gesetztes Foto bleibt unangetastet.
</li>
<li>
<span className="font-bold text-ink-800">Passwort bleibt.</span> Die Anmeldung mit
Benutzername und Passwort funktioniert unverändert weiter - auch als Notzugang, falls
Entra einmal nicht erreichbar ist.
</li>
</ul>
</section>
</div>
);
};
+28
View File
@@ -110,6 +110,34 @@ export const authApi = {
}),
};
// ---------- Anmeldung über Microsoft (SSO) ----------
/**
* Ziel des Microsoft-Knopfes. Bewusst eine echte Umleitung des Browsers und
* kein fetch: die Anmeldung findet bei Microsoft statt, und Tenant- sowie
* Client-ID bleiben dabei im Backend.
*/
export const SSO_START_URL = '/api/auth/sso/start';
export interface SsoConfig {
enabled: boolean;
missing: string[];
redirect_uri: string | null;
allowed_domains: string[];
}
export const ssoApi = {
status: () => request<{ enabled: boolean }>('/auth/sso/status'),
/** Adminsicht fuer die Einstellungen. */
config: () => request<SsoConfig>('/auth/sso/config'),
/** Loest das Einmal-Ticket aus der Rueckleitung gegen das CRM-Token ein. */
redeemTicket: (ticket: string) =>
request<{ token: string; user: AppUser }>('/auth/sso/ticket', {
method: 'POST',
body: JSON.stringify({ ticket }),
}),
};
// ---------- Benutzerverwaltung (nur admin) ----------
export interface UserInput {
+61 -3
View File
@@ -1,6 +1,6 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import type { AppUser } from '../types';
import { authApi, clearToken, getToken, setToken, setUnauthorizedHandler } from './api';
import { authApi, clearToken, getToken, setToken, setUnauthorizedHandler, ssoApi } from './api';
interface AuthContextValue {
user: AppUser | null;
@@ -10,6 +10,32 @@ interface AuthContextValue {
logout: () => void;
/** Aktualisiert den lokalen Benutzer, z.B. nach Bearbeiten des eigenen Kontos. */
updateUser: (user: AppUser) => void;
/** Meldung aus einer gescheiterten Microsoft-Anmeldung, sonst null. */
ssoError: string | null;
clearSsoError: () => void;
}
/**
* Liest die Parameter der Rueckleitung von /api/auth/sso/callback und raeumt
* die Adresszeile sofort wieder auf - weder Ticket noch Fehlertext sollen im
* Browserverlauf oder in einem weitergegebenen Link stehen bleiben.
*/
function takeSsoParams(): { ticket: string | null; error: string | null } {
const params = new URLSearchParams(window.location.search);
const ticket = params.get('sso_ticket');
const error = params.get('sso_error');
if (!ticket && !error) return { ticket: null, error: null };
params.delete('sso_ticket');
params.delete('sso_error');
const query = params.toString();
window.history.replaceState(
{},
'',
`${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`
);
return { ticket, error };
}
const AuthContext = createContext<AuthContextValue | null>(null);
@@ -17,6 +43,7 @@ const AuthContext = createContext<AuthContextValue | null>(null);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<AppUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [ssoError, setSsoError] = useState<string | null>(null);
const logout = useCallback(() => {
clearToken();
@@ -30,11 +57,37 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
return () => setUnauthorizedHandler(null);
}, []);
// Beim Start pruefen, ob das gespeicherte Token noch gilt.
// Beim Start pruefen, ob das gespeicherte Token noch gilt. Kommt der Browser
// gerade von Microsoft zurueck, hat das Ticket Vorrang: es gehoert zu der
// Anmeldung, die der Benutzer eben ausgeloest hat.
useEffect(() => {
let cancelled = false;
const restore = async () => {
const { ticket, error } = takeSsoParams();
if (error) {
setSsoError(error);
clearToken();
setIsLoading(false);
return;
}
if (ticket) {
try {
const { token, user: loggedIn } = await ssoApi.redeemTicket(ticket);
setToken(token);
if (!cancelled) setUser(loggedIn);
} catch (err) {
if (!cancelled) {
setSsoError(err instanceof Error ? err.message : 'Anmeldung fehlgeschlagen.');
}
} finally {
if (!cancelled) setIsLoading(false);
}
return;
}
if (!getToken()) {
setIsLoading(false);
return;
@@ -58,9 +111,12 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const login = useCallback(async (identifier: string, password: string) => {
const { token, user: loggedIn } = await authApi.login(identifier, password);
setToken(token);
setSsoError(null);
setUser(loggedIn);
}, []);
const clearSsoError = useCallback(() => setSsoError(null), []);
const value = useMemo<AuthContextValue>(
() => ({
user,
@@ -69,8 +125,10 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
login,
logout,
updateUser: setUser,
ssoError,
clearSsoError,
}),
[user, isLoading, login, logout]
[user, isLoading, login, logout, ssoError, clearSsoError]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+9 -2
View File
@@ -24,9 +24,16 @@ export type ViewState =
* `account` und `whisper` stehen jedem offen - jeder pflegt sein eigenes Konto
* und richtet sein eigenes Diktat ein.
*/
export type SettingsTab = 'users' | 'account' | 'akquise' | 'mail' | 'scanner' | 'whisper';
export type SettingsTab =
| 'users'
| 'account'
| 'akquise'
| 'mail'
| 'sso'
| 'scanner'
| 'whisper';
export const ADMIN_SETTINGS_TABS: SettingsTab[] = ['users', 'mail', 'scanner'];
export const ADMIN_SETTINGS_TABS: SettingsTab[] = ['users', 'mail', 'sso', 'scanner'];
/**
* Reiter des Projekt Scanners. "Scanner" selbst ist das Dashboard und damit die