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:
co-authored by
Claude Opus 5
parent
2d84bac91d
commit
419e57b55d
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user