Erstanmeldung fragt nach, statt still ein zweites Konto anzulegen
Wer sich zum ersten Mal ueber den Anmeldedienst anmeldet und hier noch kein Konto hat, bekam bisher wortlos ein neues - mit Rolle "Benutzer" und ohne Verlauf. Wer schon ein Konto hatte, wollte aber fast immer genau das verknuepfen und stand dann mit zwei Konten da. Jetzt zeigt der Rueckweg eine Entscheidungsseite: bestehendes Konto verknuepfen oder ein neues anlegen. Das Verknuepfen verlangt einmalig Benutzername und Passwort des bestehenden Kontos. Genau dieser Nachweis ist der Grund, warum der Anmeldeweg selbst nie fragen darf, welches Konto gemeint ist - ohne ihn koennte sich jeder mit einem beliebigen Konto beim Anmeldedienst an ein fremdes Vorrania-Konto haengen. Die geprueften Angaben liegen dafuer kurzlebig und einmalig in oidc_pending_links; vom Code steht dort nur der SHA-256-Hash, wie bei den API-Tokens. Ein falsch getipptes Passwort verbraucht den Vorgang nicht, und unbekannter Benutzername und falsches Passwort antworten gleich - sonst liessen sich vorhandene Konten abzaehlen. AUTO_CREATE=false blendet den Knopf zum Anlegen aus und laesst sich auch ueber den Dialog nicht umgehen. Der App-Weg bleibt unveraendert, weil er keinen Zwischenschritt anzeigen kann. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a134b2aad5
commit
eb29143f97
@@ -7,6 +7,7 @@ import ErrorBoundary from "./components/ErrorBoundary";
|
||||
import BrandMark from "./components/BrandMark";
|
||||
import Login from "./pages/Login";
|
||||
import OidcCallback from "./pages/OidcCallback";
|
||||
import SsoNeu from "./pages/SsoNeu";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import Products from "./pages/Products";
|
||||
import ItemList from "./pages/ItemList";
|
||||
@@ -203,6 +204,8 @@ export default function App() {
|
||||
diesem Moment noch nicht angemeldet) und MUSS vor dem Auffangmuster
|
||||
unten stehen – sonst schluckt "*" die Weiterleitung samt Token. */}
|
||||
<Route path="/auth/callback" element={<OidcCallback />} />
|
||||
{/* Ebenfalls oeffentlich: hier ist man noch nicht angemeldet. */}
|
||||
<Route path="/sso-neu" element={<SsoNeu />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
+14
-1
@@ -21,6 +21,8 @@ export function setToken(token) {
|
||||
// Pfade, bei denen ein 401 NICHT „Sitzung abgelaufen" heisst: beim Anmelden
|
||||
// selbst heisst er „Passwort falsch", und die Anbieterliste ist oeffentlich.
|
||||
const OHNE_ABMELDUNG = ["/auth/login", "/auth/oidc/providers"];
|
||||
// Beim Verknuepfen einer Erstanmeldung heisst 401 „Passwort falsch".
|
||||
const OHNE_ABMELDUNG_PRAEFIX = ["/auth/oidc/pending/"];
|
||||
|
||||
let beiAbmeldung = null;
|
||||
export function setUnauthorizedHandler(fn) {
|
||||
@@ -55,7 +57,9 @@ async function request(path, { method = "GET", body, form, formData } = {}) {
|
||||
|
||||
// Abgelaufene Anmeldung: EINMAL zentral behandeln statt in jeder Seite.
|
||||
// Beim Anmelden selbst nicht – dort heisst 401 schlicht „Passwort falsch".
|
||||
if (resp.status === 401 && !OHNE_ABMELDUNG.includes(path)) {
|
||||
const ausgenommen = OHNE_ABMELDUNG.includes(path)
|
||||
|| OHNE_ABMELDUNG_PRAEFIX.some((p) => path.startsWith(p));
|
||||
if (resp.status === 401 && !ausgenommen) {
|
||||
beiAbmeldung?.();
|
||||
}
|
||||
|
||||
@@ -160,6 +164,15 @@ export const api = {
|
||||
// laufenden Sitzung mitgeht - bei einer Seitennavigation ginge das nicht.
|
||||
oidcLinkStart: (slug) => request(`/auth/oidc/${slug}/link`, { method: "POST" }),
|
||||
oidcUnlink: (slug) => request(`/auth/oidc/${slug}/link`, { method: "DELETE" }),
|
||||
// Erstanmeldung ohne passendes Konto: der Einmalcode aus der Adresse ist der
|
||||
// Nachweis, dass die Anmeldung beim Dienst stattgefunden hat.
|
||||
oidcPending: (code) => request(`/auth/oidc/pending/${code}`),
|
||||
oidcPendingAnlegen: (code) =>
|
||||
request(`/auth/oidc/pending/${code}/anlegen`, { method: "POST" }),
|
||||
oidcPendingVerknuepfen: (code, username, password) =>
|
||||
request(`/auth/oidc/pending/${code}/verknuepfen`, {
|
||||
method: "POST", body: { username, password },
|
||||
}),
|
||||
|
||||
// Produkte
|
||||
listProducts: (q, categoryId) => {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { api } from "../api";
|
||||
import { useAuth } from "../auth";
|
||||
import Icon from "../components/Icon";
|
||||
import BrandMark from "../components/BrandMark";
|
||||
|
||||
/**
|
||||
* Erstanmeldung über einen Anmeldedienst, zu der es noch kein Vorrania-Konto gibt.
|
||||
*
|
||||
* Der Anmeldeweg selbst fragt bewusst nie „welches Konto?" – sonst könnte sich
|
||||
* jeder mit einem beliebigen Konto beim Anmeldedienst an ein fremdes
|
||||
* Vorrania-Konto hängen. Hier ist die Frage sicher, weil das Verknüpfen den
|
||||
* Nachweis über Benutzername und Passwort verlangt.
|
||||
*
|
||||
* Der Einmalcode steht im Adressfragment und belegt, dass die Anmeldung beim
|
||||
* Dienst tatsächlich stattgefunden hat.
|
||||
*/
|
||||
export default function SsoNeu() {
|
||||
const { tokenUebernehmen } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [code, setCode] = useState(null);
|
||||
const [info, setInfo] = useState(null);
|
||||
const [fehler, setFehler] = useState(null);
|
||||
const [modus, setModus] = useState(null); // null | "verknuepfen"
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const gelesen = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (gelesen.current) return;
|
||||
gelesen.current = true;
|
||||
|
||||
const fragment = new URLSearchParams(window.location.hash.replace(/^#/, ""));
|
||||
const wert = fragment.get("code");
|
||||
// Sofort aus der Adresszeile räumen, egal wie es weitergeht.
|
||||
window.history.replaceState(null, "", window.location.pathname);
|
||||
|
||||
if (!wert) {
|
||||
setFehler("Dieser Anmeldevorgang ist nicht mehr gültig.");
|
||||
return;
|
||||
}
|
||||
setCode(wert);
|
||||
api.oidcPending(wert)
|
||||
.then((daten) => {
|
||||
setInfo(daten);
|
||||
setUsername(daten.username_vorschlag || "");
|
||||
// Darf gar kein Konto angelegt werden, ist Verknüpfen der einzige Weg.
|
||||
if (!daten.anlegen_erlaubt) setModus("verknuepfen");
|
||||
})
|
||||
.catch((err) => setFehler(err.message));
|
||||
}, []);
|
||||
|
||||
async function anlegen() {
|
||||
setFehler(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const token = await api.oidcPendingAnlegen(code);
|
||||
await tokenUebernehmen(token.access_token);
|
||||
navigate("/", { replace: true });
|
||||
} catch (err) {
|
||||
setFehler(err.message);
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function verknuepfen(e) {
|
||||
e.preventDefault();
|
||||
setFehler(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const token = await api.oidcPendingVerknuepfen(code, username, password);
|
||||
await tokenUebernehmen(token.access_token);
|
||||
navigate("/", { replace: true });
|
||||
} catch (err) {
|
||||
setFehler(err.message);
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (fehler && !info) {
|
||||
return (
|
||||
<div className="login-wrap">
|
||||
<div className="card login-card">
|
||||
<div className="alert error"><Icon name="alert" size={16} />{fehler}</div>
|
||||
<button className="btn primary" style={{ width: "100%", justifyContent: "center" }}
|
||||
onClick={() => navigate("/login", { replace: true })}>
|
||||
Zur Anmeldung
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!info) {
|
||||
return <div className="center muted" style={{ padding: 60 }}>Einen Moment…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-wrap">
|
||||
<div className="card login-card">
|
||||
<div className="sidebar-brand"><BrandMark /></div>
|
||||
|
||||
<div className="alert info">
|
||||
<Icon name="alert" size={16} />
|
||||
<div>
|
||||
Die Anmeldung über <strong>{info.display_name}</strong> hat geklappt
|
||||
{info.email && <> ({info.email})</>} – zu diesem Zugang gibt es hier
|
||||
aber noch kein Konto.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{fehler && <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>}
|
||||
|
||||
{modus !== "verknuepfen" ? (
|
||||
<>
|
||||
<p className="muted small mt-0">
|
||||
Hast du in Vorrania schon ein Konto, verknüpfe es – dann behältst du
|
||||
Rolle und Verlauf.
|
||||
</p>
|
||||
<button className="btn primary" disabled={busy}
|
||||
style={{ width: "100%", justifyContent: "center" }}
|
||||
onClick={() => { setFehler(null); setModus("verknuepfen"); }}>
|
||||
Bestehendes Konto verknüpfen
|
||||
</button>
|
||||
<div className="login-trenner">oder</div>
|
||||
<button className="btn" disabled={busy}
|
||||
style={{ width: "100%", justifyContent: "center" }}
|
||||
onClick={anlegen}>
|
||||
{busy ? "Wird angelegt…" : `Neues Konto „${info.username_vorschlag}" anlegen`}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<form onSubmit={verknuepfen}>
|
||||
<p className="muted small mt-0">
|
||||
Melde dich einmal mit deinem bisherigen Benutzernamen und Passwort
|
||||
an. Danach genügt {info.display_name}.
|
||||
</p>
|
||||
<label>
|
||||
Benutzername
|
||||
<input value={username} onChange={(e) => setUsername(e.target.value)}
|
||||
autoFocus autoComplete="username" />
|
||||
</label>
|
||||
<label>
|
||||
Passwort
|
||||
<input type="password" value={password} autoComplete="current-password"
|
||||
onChange={(e) => setPassword(e.target.value)} />
|
||||
</label>
|
||||
<button className="btn primary" disabled={busy || !username || !password}
|
||||
style={{ width: "100%", justifyContent: "center" }}>
|
||||
{busy ? "Wird verknüpft…" : "Verknüpfen und anmelden"}
|
||||
</button>
|
||||
{info.anlegen_erlaubt && (
|
||||
<>
|
||||
<div className="login-trenner">oder</div>
|
||||
<button type="button" className="btn" disabled={busy}
|
||||
style={{ width: "100%", justifyContent: "center" }}
|
||||
onClick={() => { setFehler(null); setModus(null); }}>
|
||||
Zurück
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user