@micha.bigler/ui-core-micha 2.4.3 → 2.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/AuthContext.js +4 -3
- package/dist/auth/ReauthModal.js +41 -0
- package/dist/auth/apiClient.js +20 -3
- package/dist/auth/authApi.js +26 -0
- package/dist/auth/reauth.js +45 -0
- package/dist/i18n/authTranslations.js +48 -0
- package/dist/pages/LoginPage.js +109 -8
- package/dist/pages/PasswordInvitePage.js +27 -2
- package/package.json +1 -1
- package/src/auth/AuthContext.jsx +2 -0
- package/src/auth/ReauthModal.jsx +79 -0
- package/src/auth/apiClient.jsx +20 -1
- package/src/auth/authApi.jsx +25 -0
- package/src/auth/reauth.js +47 -0
- package/src/i18n/authTranslations.ts +48 -0
- package/src/pages/LoginPage.jsx +121 -9
- package/src/pages/PasswordInvitePage.jsx +28 -2
package/dist/auth/AuthContext.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
// src/auth/AuthContext.jsx
|
|
3
3
|
import React, { createContext, useState, useEffect, } from 'react';
|
|
4
4
|
import { ensureCsrfToken } from './apiClient'; // <--- IMPORT ADDED
|
|
5
5
|
import { fetchAuthMethods, fetchCurrentUser, logoutSession, } from './authApi';
|
|
6
|
+
import { ReauthModal } from './ReauthModal';
|
|
6
7
|
export const AuthContext = createContext(null);
|
|
7
8
|
const DEFAULT_AUTH_METHODS = {
|
|
8
9
|
password_login: true,
|
|
@@ -80,11 +81,11 @@ export const AuthProvider = ({ children }) => {
|
|
|
80
81
|
setUser(null);
|
|
81
82
|
}
|
|
82
83
|
};
|
|
83
|
-
return (
|
|
84
|
+
return (_jsxs(AuthContext.Provider, { value: {
|
|
84
85
|
user,
|
|
85
86
|
authMethods,
|
|
86
87
|
loading,
|
|
87
88
|
login,
|
|
88
89
|
logout,
|
|
89
|
-
}, children: children }));
|
|
90
|
+
}, children: [children, _jsx(ReauthModal, {})] }));
|
|
90
91
|
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React, { useEffect, useState } from 'react';
|
|
3
|
+
import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField, } from '@mui/material';
|
|
4
|
+
import { useTranslation } from 'react-i18next';
|
|
5
|
+
import apiClient from './apiClient';
|
|
6
|
+
import { HEADLESS_BASE } from './authConfig.jsx';
|
|
7
|
+
import { rejectReauth, resolveReauth, subscribe } from './reauth';
|
|
8
|
+
export function ReauthModal() {
|
|
9
|
+
const { t } = useTranslation();
|
|
10
|
+
const [open, setOpen] = useState(false);
|
|
11
|
+
const [password, setPassword] = useState('');
|
|
12
|
+
const [loading, setLoading] = useState(false);
|
|
13
|
+
const [error, setError] = useState(null);
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
return subscribe((active) => {
|
|
16
|
+
setOpen(active);
|
|
17
|
+
if (!active) {
|
|
18
|
+
setPassword('');
|
|
19
|
+
setError(null);
|
|
20
|
+
setLoading(false);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
}, []);
|
|
24
|
+
const handleSubmit = async (e) => {
|
|
25
|
+
e.preventDefault();
|
|
26
|
+
setLoading(true);
|
|
27
|
+
setError(null);
|
|
28
|
+
try {
|
|
29
|
+
await apiClient.post(`${HEADLESS_BASE}/auth/reauthenticate`, { password });
|
|
30
|
+
resolveReauth();
|
|
31
|
+
}
|
|
32
|
+
catch (_a) {
|
|
33
|
+
setError(t('Auth.REAUTH_FAILED'));
|
|
34
|
+
setLoading(false);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
const handleCancel = () => {
|
|
38
|
+
rejectReauth(new Error('cancelled'));
|
|
39
|
+
};
|
|
40
|
+
return (_jsx(Dialog, { open: open, onClose: handleCancel, maxWidth: "xs", fullWidth: true, children: _jsxs("form", { onSubmit: handleSubmit, children: [_jsx(DialogTitle, { children: t('Auth.REAUTH_TITLE') }), _jsxs(DialogContent, { children: [error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, children: error }), _jsx(TextField, { autoFocus: true, fullWidth: true, type: "password", label: t('Auth.LOGIN_PASSWORD_LABEL'), value: password, onChange: (e) => setPassword(e.target.value), disabled: loading, sx: { mt: 1 } })] }), _jsxs(DialogActions, { children: [_jsx(Button, { onClick: handleCancel, disabled: loading, children: t('Auth.MFA_TOTP_CANCEL_BUTTON') }), _jsx(Button, { type: "submit", variant: "contained", disabled: loading || !password, children: loading ? t('Auth.MFA_TOTP_VERIFY_BUTTON_LOADING') : t('Auth.MFA_VERIFY') })] })] }) }));
|
|
41
|
+
}
|
package/dist/auth/apiClient.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import axios from "axios";
|
|
2
2
|
import { CSRF_URL } from "./authConfig";
|
|
3
|
+
import { requestReauth } from "./reauth";
|
|
3
4
|
const apiClient = axios.create({
|
|
4
5
|
withCredentials: true,
|
|
5
6
|
xsrfCookieName: "csrftoken",
|
|
@@ -109,18 +110,34 @@ function extractAuthSignal(data) {
|
|
|
109
110
|
}
|
|
110
111
|
return { code: null, i18nKey: null };
|
|
111
112
|
}
|
|
112
|
-
apiClient.interceptors.response.use((response) => response, (error) => {
|
|
113
|
-
var _a, _b, _c, _d, _e;
|
|
113
|
+
apiClient.interceptors.response.use((response) => response, async (error) => {
|
|
114
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j;
|
|
114
115
|
const status = (_b = (_a = error === null || error === void 0 ? void 0 : error.response) === null || _a === void 0 ? void 0 : _a.status) !== null && _b !== void 0 ? _b : null;
|
|
115
116
|
const data = (_d = (_c = error === null || error === void 0 ? void 0 : error.response) === null || _c === void 0 ? void 0 : _c.data) !== null && _d !== void 0 ? _d : {};
|
|
116
117
|
const { code, i18nKey } = extractAuthSignal(data);
|
|
117
118
|
const isAuthStatus = status === 401 || status === 403;
|
|
118
119
|
const isNotAuthenticated = code === "not_authenticated" || i18nKey === "auth.not_authenticated";
|
|
120
|
+
// Reauthentication gate: allauth returns 401 + flows[{id:"reauthenticate"}]
|
|
121
|
+
// when the session is stale before a sensitive operation (e.g. adding a passkey).
|
|
122
|
+
// Show the password-confirm modal, wait for it to complete, then retry once.
|
|
123
|
+
const flows = (_g = (_f = (_e = data === null || data === void 0 ? void 0 : data.data) === null || _e === void 0 ? void 0 : _e.flows) !== null && _f !== void 0 ? _f : data === null || data === void 0 ? void 0 : data.flows) !== null && _g !== void 0 ? _g : [];
|
|
124
|
+
const needsReauth = status === 401 &&
|
|
125
|
+
Array.isArray(flows) &&
|
|
126
|
+
flows.some((f) => f.id === "reauthenticate");
|
|
127
|
+
if (needsReauth && !((_h = error.config) === null || _h === void 0 ? void 0 : _h._reauthRetried)) {
|
|
128
|
+
try {
|
|
129
|
+
await requestReauth();
|
|
130
|
+
return apiClient(Object.assign(Object.assign({}, error.config), { _reauthRetried: true }));
|
|
131
|
+
}
|
|
132
|
+
catch (_k) {
|
|
133
|
+
return Promise.reject(error);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
119
136
|
// Per-request opt-out: bootstrap probes (e.g. fetchCurrentUser on app start)
|
|
120
137
|
// expect to handle 401 silently and must not trigger a redirect-on-mount.
|
|
121
138
|
// Carried as an axios config property, so it never travels to the backend
|
|
122
139
|
// (would otherwise trigger a CORS preflight on cross-origin requests).
|
|
123
|
-
const skipRedirect = ((
|
|
140
|
+
const skipRedirect = ((_j = error === null || error === void 0 ? void 0 : error.config) === null || _j === void 0 ? void 0 : _j.skipAuthRedirect) === true;
|
|
124
141
|
if (isAuthStatus && isNotAuthenticated && !skipRedirect) {
|
|
125
142
|
redirectToLoginOnce();
|
|
126
143
|
}
|
package/dist/auth/authApi.js
CHANGED
|
@@ -410,6 +410,32 @@ export async function rejectRecoveryRequest(id, supportNote) {
|
|
|
410
410
|
});
|
|
411
411
|
return res.data;
|
|
412
412
|
}
|
|
413
|
+
// S164: server-side session handoff. After the email-link redirect lands the
|
|
414
|
+
// SPA on `/login#recovery=ok`, the LoginPage fetches the plaintext recovery
|
|
415
|
+
// token from the user's server session (set by `recovery_complete_view`).
|
|
416
|
+
// The endpoint pops the token on every read — a single shot per browser
|
|
417
|
+
// navigation — so a navigated-away tab cannot reuse the entry.
|
|
418
|
+
export async function fetchRecoverySessionToken() {
|
|
419
|
+
var _a, _b;
|
|
420
|
+
try {
|
|
421
|
+
const res = await apiClient.get('/api/auth/recovery/session-token/', {
|
|
422
|
+
// Visiting `/login` without a pending recovery session is the common
|
|
423
|
+
// case; the 404 it returns must not bubble up as a global "session
|
|
424
|
+
// expired" auth error in the app shell.
|
|
425
|
+
skipAuthRedirect: true,
|
|
426
|
+
});
|
|
427
|
+
return ((_a = res === null || res === void 0 ? void 0 : res.data) === null || _a === void 0 ? void 0 : _a.token) || null;
|
|
428
|
+
}
|
|
429
|
+
catch (err) {
|
|
430
|
+
if (((_b = err === null || err === void 0 ? void 0 : err.response) === null || _b === void 0 ? void 0 : _b.status) === 404) {
|
|
431
|
+
// No pending recovery in this session — treat as a non-error so the
|
|
432
|
+
// LoginPage can decide whether to surface a "link expired" message
|
|
433
|
+
// based on the `#recovery=` hash sentinel it already inspects.
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
throw normaliseApiError(err, 'Auth.RECOVERY_TOKEN_INVALID');
|
|
437
|
+
}
|
|
438
|
+
}
|
|
413
439
|
export async function loginWithRecoveryPassword(email, password, token) {
|
|
414
440
|
try {
|
|
415
441
|
// S50 (django-core-micha >=2.13.1): token wird im POST-Body übergeben,
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Promise broker for the reauthentication gate.
|
|
2
|
+
//
|
|
3
|
+
// When the response interceptor encounters a 401+reauthenticate, it calls
|
|
4
|
+
// requestReauth() and awaits the returned promise. The ReauthModal resolves
|
|
5
|
+
// or rejects that promise after the user submits (or cancels) the password
|
|
6
|
+
// dialog. Concurrent 401s share the same pending promise so only one modal
|
|
7
|
+
// ever appears.
|
|
8
|
+
let _pendingResolve = null;
|
|
9
|
+
let _pendingReject = null;
|
|
10
|
+
let _reauthPromise = null;
|
|
11
|
+
let _listeners = [];
|
|
12
|
+
export function requestReauth() {
|
|
13
|
+
if (_reauthPromise)
|
|
14
|
+
return _reauthPromise;
|
|
15
|
+
_reauthPromise = new Promise((resolve, reject) => {
|
|
16
|
+
_pendingResolve = resolve;
|
|
17
|
+
_pendingReject = reject;
|
|
18
|
+
_listeners.forEach(fn => fn(true));
|
|
19
|
+
});
|
|
20
|
+
return _reauthPromise;
|
|
21
|
+
}
|
|
22
|
+
export function resolveReauth() {
|
|
23
|
+
const resolve = _pendingResolve;
|
|
24
|
+
_pendingResolve = null;
|
|
25
|
+
_pendingReject = null;
|
|
26
|
+
_reauthPromise = null;
|
|
27
|
+
_listeners.forEach(fn => fn(false));
|
|
28
|
+
if (resolve)
|
|
29
|
+
resolve();
|
|
30
|
+
}
|
|
31
|
+
export function rejectReauth(error) {
|
|
32
|
+
const reject = _pendingReject;
|
|
33
|
+
_pendingResolve = null;
|
|
34
|
+
_pendingReject = null;
|
|
35
|
+
_reauthPromise = null;
|
|
36
|
+
_listeners.forEach(fn => fn(false));
|
|
37
|
+
if (reject)
|
|
38
|
+
reject(error || new Error('Reauthentication cancelled'));
|
|
39
|
+
}
|
|
40
|
+
export function subscribe(fn) {
|
|
41
|
+
_listeners = [..._listeners, fn];
|
|
42
|
+
return () => {
|
|
43
|
+
_listeners = _listeners.filter(l => l !== fn);
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -6,6 +6,36 @@ export const authTranslations = {
|
|
|
6
6
|
"en": "Email address or password is incorrect.",
|
|
7
7
|
"sw": "Barua pepe au nenosiri sio sahihi."
|
|
8
8
|
},
|
|
9
|
+
"Auth.RECOVERY_TOKEN_INVALID": {
|
|
10
|
+
"de": "Der Recovery-Link ist ungültig oder wurde bereits verwendet.",
|
|
11
|
+
"fr": "Le lien de récupération est invalide ou a déjà été utilisé.",
|
|
12
|
+
"en": "The recovery link is invalid or has already been used.",
|
|
13
|
+
"sw": "Kiungo cha urejesho si halali au tayari kimetumika."
|
|
14
|
+
},
|
|
15
|
+
"Auth.RECOVERY_TOKEN_EXPIRED": {
|
|
16
|
+
"de": "Der Recovery-Link ist abgelaufen. Bitte fordere einen neuen an.",
|
|
17
|
+
"fr": "Le lien de récupération a expiré. Veuillez en demander un nouveau.",
|
|
18
|
+
"en": "The recovery link has expired. Please request a new one.",
|
|
19
|
+
"sw": "Kiungo cha urejesho kimeisha muda wake. Tafadhali omba kipya."
|
|
20
|
+
},
|
|
21
|
+
"Auth.RECOVERY_LINK_VALIDATING": {
|
|
22
|
+
"de": "Recovery-Link wird geprüft…",
|
|
23
|
+
"fr": "Vérification du lien de récupération…",
|
|
24
|
+
"en": "Validating recovery link…",
|
|
25
|
+
"sw": "Inathibitisha kiungo cha urejesho..."
|
|
26
|
+
},
|
|
27
|
+
"Auth.RECOVERY_LOGIN_WARNING": {
|
|
28
|
+
"de": "Recovery-Link validiert. Bitte gib dein Passwort ein.",
|
|
29
|
+
"fr": "Lien de récupération validé. Veuillez saisir votre mot de passe.",
|
|
30
|
+
"en": "Recovery link validated. Please enter your password.",
|
|
31
|
+
"sw": "Kiungo cha urejesho kimethibitishwa. Tafadhali weka nenosiri lako."
|
|
32
|
+
},
|
|
33
|
+
"Auth.TWO_FACTOR_REQUIRED_HINT": {
|
|
34
|
+
"de": "Diese App benötigt zwei Authentifizierungsfaktoren für vollen Zugriff.",
|
|
35
|
+
"fr": "Cette application requiert deux facteurs d’authentification pour un accès complet.",
|
|
36
|
+
"en": "This app requires two authentication factors for full access.",
|
|
37
|
+
"sw": "Programu hii inahitaji vipengele viwili vya uthibitisho kwa ufikiaji kamili."
|
|
38
|
+
},
|
|
9
39
|
"Auth.PASSKEY_FAILED": {
|
|
10
40
|
"de": "Die Anmeldung mit Passkey ist fehlgeschlagen.",
|
|
11
41
|
"fr": "La connexion avec passkey a échoué.",
|
|
@@ -1757,5 +1787,23 @@ export const authTranslations = {
|
|
|
1757
1787
|
"fr": "Politique de code d'accès enregistrée.",
|
|
1758
1788
|
"en": "Access code policy saved.",
|
|
1759
1789
|
"sw": "Sera ya msimbo wa ufikiaji imehifadhiwa."
|
|
1790
|
+
},
|
|
1791
|
+
"Auth.REAUTH_TITLE": {
|
|
1792
|
+
"de": "Identität bestätigen",
|
|
1793
|
+
"fr": "Confirmer votre identité",
|
|
1794
|
+
"en": "Confirm your identity",
|
|
1795
|
+
"sw": "Thibitisha utambulisho wako"
|
|
1796
|
+
},
|
|
1797
|
+
"Auth.REAUTH_SUBTITLE": {
|
|
1798
|
+
"de": "Bitte gib dein Passwort ein, um fortzufahren.",
|
|
1799
|
+
"fr": "Veuillez saisir votre mot de passe pour continuer.",
|
|
1800
|
+
"en": "Please enter your password to continue.",
|
|
1801
|
+
"sw": "Tafadhali ingiza nenosiri lako ili kuendelea."
|
|
1802
|
+
},
|
|
1803
|
+
"Auth.REAUTH_FAILED": {
|
|
1804
|
+
"de": "Das Passwort ist falsch.",
|
|
1805
|
+
"fr": "Le mot de passe est incorrect.",
|
|
1806
|
+
"en": "The password is incorrect.",
|
|
1807
|
+
"sw": "Nenosiri si sahihi."
|
|
1760
1808
|
}
|
|
1761
1809
|
};
|
package/dist/pages/LoginPage.js
CHANGED
|
@@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next';
|
|
|
8
8
|
import { NarrowPage } from '../layout/PageLayout';
|
|
9
9
|
import { AuthContext } from '../auth/AuthContext';
|
|
10
10
|
// API & Services (Clean Architecture)
|
|
11
|
-
import {
|
|
11
|
+
import { fetchRecoverySessionToken, loginWithPassword, loginWithRecoveryPassword, } from '../auth/authApi';
|
|
12
12
|
import { loginWithPasskey, startSocialLogin } from '../utils/authService';
|
|
13
13
|
// Components
|
|
14
14
|
import { LoginForm } from '../components/LoginForm';
|
|
@@ -23,13 +23,18 @@ export function LoginPage() {
|
|
|
23
23
|
const [submitting, setSubmitting] = useState(false);
|
|
24
24
|
const [errorKey, setErrorKey] = useState(null);
|
|
25
25
|
const [mfaState, setMfaState] = useState(null); // { availableTypes: [...], identifier }
|
|
26
|
+
// S164: token never travels through the URL/fragment anymore. It is held
|
|
27
|
+
// in component state only, fetched from the server-side session handoff
|
|
28
|
+
// endpoint when the redirect lands the SPA here with `#recovery=ok`.
|
|
29
|
+
const [recoveryToken, setRecoveryToken] = useState(null);
|
|
30
|
+
const [recoveryFetching, setRecoveryFetching] = useState(false);
|
|
26
31
|
// URL Params parsing
|
|
27
32
|
const params = new URLSearchParams(location.search);
|
|
28
33
|
const hashParams = new URLSearchParams(String(location.hash || "").startsWith("#") ? String(location.hash).slice(1) : String(location.hash || ""));
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
34
|
+
// After S164 the hash carries only a status sentinel: `ok`, `invalid`, or
|
|
35
|
+
// `expired`. We also accept the same key from query params as a defensive
|
|
36
|
+
// fallback for proxies that strip fragments.
|
|
37
|
+
const recoveryStatus = String(hashParams.get('recovery') || params.get('recovery') || '').toLowerCase();
|
|
33
38
|
// Backward-compatible fallback for legacy links using query parameters.
|
|
34
39
|
const recoveryEmail = hashParams.get('email') || params.get('email') || '';
|
|
35
40
|
const requestedNext = params.get('next');
|
|
@@ -42,8 +47,29 @@ export function LoginPage() {
|
|
|
42
47
|
if (requiresExtra) {
|
|
43
48
|
return '/account?tab=security&from=weak_login';
|
|
44
49
|
}
|
|
45
|
-
|
|
46
|
-
|
|
50
|
+
// Same-origin check via URL parser — `startsWith('/')` alone allows
|
|
51
|
+
// bypasses like `/\evil.com`, which WHATWG-parses to `https://evil.com/`
|
|
52
|
+
// for special schemes. Resolve against the current origin and only accept
|
|
53
|
+
// results whose origin matches.
|
|
54
|
+
// Require leading-slash absolute path on the raw input (rejects relative
|
|
55
|
+
// inputs like `account/settings` and protocol-relative `//host` before
|
|
56
|
+
// they reach the URL parser, which would normalise both into a same-
|
|
57
|
+
// origin pathname starting with `/`).
|
|
58
|
+
if (requestedNext &&
|
|
59
|
+
typeof requestedNext === 'string' &&
|
|
60
|
+
requestedNext.startsWith('/') &&
|
|
61
|
+
!requestedNext.startsWith('//')) {
|
|
62
|
+
try {
|
|
63
|
+
const parsed = new URL(requestedNext, window.location.origin);
|
|
64
|
+
// Catches WHATWG bypasses like `/\evil.com` that resolve to a
|
|
65
|
+
// different origin even though the raw input started with `/`.
|
|
66
|
+
if (parsed.origin === window.location.origin) {
|
|
67
|
+
return parsed.pathname + parsed.search + parsed.hash;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch (_b) {
|
|
71
|
+
// fall through to default redirect
|
|
72
|
+
}
|
|
47
73
|
}
|
|
48
74
|
return '/';
|
|
49
75
|
};
|
|
@@ -53,6 +79,78 @@ export function LoginPage() {
|
|
|
53
79
|
setErrorKey('Auth.SOCIAL_LOGIN_FAILED');
|
|
54
80
|
}
|
|
55
81
|
}, [location.search]);
|
|
82
|
+
// S164 handoff: on `#recovery=ok`, pull the plaintext token out of the
|
|
83
|
+
// server-side session (one-shot). On `invalid`/`expired`, or on any
|
|
84
|
+
// other non-empty value (legacy pre-S164 email links that carried the
|
|
85
|
+
// token directly in the hash), surface an invalid-link error so the
|
|
86
|
+
// user gets feedback instead of a silent login screen.
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
var _a;
|
|
89
|
+
if (!recoveryStatus) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
// Strip the `recovery=…` sentinel from BOTH hash and query string so a
|
|
93
|
+
// bookmark or page refresh does not re-trigger this effect against an
|
|
94
|
+
// already-popped session entry (which would render a confusing
|
|
95
|
+
// "invalid" error to a user who is mid-login).
|
|
96
|
+
//
|
|
97
|
+
// We use `history.replaceState` rather than `navigate(..., {replace})`
|
|
98
|
+
// intentionally: react-router's `navigate` would trigger an immediate
|
|
99
|
+
// re-render with an updated `useLocation()`, which would cause this
|
|
100
|
+
// effect's cleanup to set `cancelled=true` and discard the in-flight
|
|
101
|
+
// recovery-token fetch result. `replaceState` only mutates the browser
|
|
102
|
+
// URL bar; `useLocation()` stays at `recovery=ok` for this render, the
|
|
103
|
+
// fetch resolves, and `recoveryToken` gets set normally. The trade-off
|
|
104
|
+
// is that `useLocation().hash` is stale for the page lifetime — fine
|
|
105
|
+
// because nothing else in this component reads it.
|
|
106
|
+
if (typeof window !== 'undefined' && ((_a = window.history) === null || _a === void 0 ? void 0 : _a.replaceState)) {
|
|
107
|
+
const cleanParams = new URLSearchParams(window.location.search);
|
|
108
|
+
cleanParams.delete('recovery');
|
|
109
|
+
const cleanSearch = cleanParams.toString();
|
|
110
|
+
const cleanUrl = window.location.pathname + (cleanSearch ? `?${cleanSearch}` : '');
|
|
111
|
+
window.history.replaceState(null, '', cleanUrl);
|
|
112
|
+
}
|
|
113
|
+
if (recoveryStatus === 'expired') {
|
|
114
|
+
setErrorKey('Auth.RECOVERY_TOKEN_EXPIRED');
|
|
115
|
+
setRecoveryToken(null);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (recoveryStatus !== 'ok') {
|
|
119
|
+
// Catches `invalid` and any unknown value (including legacy email
|
|
120
|
+
// links that carried the plaintext token before S164).
|
|
121
|
+
setErrorKey('Auth.RECOVERY_TOKEN_INVALID');
|
|
122
|
+
setRecoveryToken(null);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
let cancelled = false;
|
|
126
|
+
setRecoveryFetching(true);
|
|
127
|
+
fetchRecoverySessionToken()
|
|
128
|
+
.then((token) => {
|
|
129
|
+
if (cancelled)
|
|
130
|
+
return;
|
|
131
|
+
if (!token) {
|
|
132
|
+
// Session entry missing or already consumed — treat as invalid.
|
|
133
|
+
setErrorKey('Auth.RECOVERY_TOKEN_INVALID');
|
|
134
|
+
setRecoveryToken(null);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
setRecoveryToken(token);
|
|
138
|
+
})
|
|
139
|
+
.catch((err) => {
|
|
140
|
+
if (cancelled)
|
|
141
|
+
return;
|
|
142
|
+
setErrorKey((err === null || err === void 0 ? void 0 : err.code) || 'Auth.RECOVERY_TOKEN_INVALID');
|
|
143
|
+
setRecoveryToken(null);
|
|
144
|
+
})
|
|
145
|
+
.finally(() => {
|
|
146
|
+
if (!cancelled) {
|
|
147
|
+
setRecoveryFetching(false);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
return () => {
|
|
151
|
+
cancelled = true;
|
|
152
|
+
};
|
|
153
|
+
}, [recoveryStatus]);
|
|
56
154
|
useEffect(() => {
|
|
57
155
|
if (loading || !user)
|
|
58
156
|
return;
|
|
@@ -146,5 +244,8 @@ export function LoginPage() {
|
|
|
146
244
|
const twoFactorRequired = Boolean(authMethods === null || authMethods === void 0 ? void 0 : authMethods.two_factor_required)
|
|
147
245
|
|| Number((authMethods === null || authMethods === void 0 ? void 0 : authMethods.required_auth_factor_count) || 1) >= 2;
|
|
148
246
|
// --- Render ---
|
|
149
|
-
return (_jsxs(NarrowPage, { title: t('Auth.PAGE_LOGIN_TITLE'), subtitle: t('Auth.PAGE_LOGIN_SUBTITLE'), children: [_jsx(Helmet, { children: _jsxs("title", { children: [t('App.NAME'), " \u2013 ", t('Auth.PAGE_LOGIN_TITLE')] }) }), errorKey && (_jsx(Alert, { severity: "error", sx: { mb: 2 }, children: t(errorKey) })), recoveryToken && !errorKey && (_jsx(Alert, { severity: "info", sx: { mb: 2 }, children: t('Auth.RECOVERY_LOGIN_WARNING', 'Recovery link validated. Please enter your password.') })), twoFactorRequired && !recoveryToken && (_jsx(Alert, { severity: "info", sx: { mb: 2 }, children: t('Auth.TWO_FACTOR_REQUIRED_HINT', 'This app requires two authentication factors for full access.') })), step === 'credentials' && (_jsx(LoginForm, { onSubmit: passwordLoginEnabled ? handleSubmitCredentials : null, onForgotPassword: passwordResetEnabled ? () => navigate('/reset-request-password') : null, onSocialLogin: socialLoginEnabled ? (provider) => startSocialLogin(provider) : null, socialProviders: socialProviders, onPasskeyLogin: passkeyLoginEnabled ? handlePasskeyLoginInitial : null, onSignUp: signupEnabled ? () => navigate('/signup') : null,
|
|
247
|
+
return (_jsxs(NarrowPage, { title: t('Auth.PAGE_LOGIN_TITLE'), subtitle: t('Auth.PAGE_LOGIN_SUBTITLE'), children: [_jsx(Helmet, { children: _jsxs("title", { children: [t('App.NAME'), " \u2013 ", t('Auth.PAGE_LOGIN_TITLE')] }) }), errorKey && (_jsx(Alert, { severity: "error", sx: { mb: 2 }, children: t(errorKey) })), recoveryFetching && !errorKey && (_jsx(Alert, { severity: "info", sx: { mb: 2 }, children: t('Auth.RECOVERY_LINK_VALIDATING', 'Validating recovery link…') })), recoveryToken && !errorKey && (_jsx(Alert, { severity: "info", sx: { mb: 2 }, children: t('Auth.RECOVERY_LOGIN_WARNING', 'Recovery link validated. Please enter your password.') })), twoFactorRequired && !recoveryToken && !recoveryFetching && (_jsx(Alert, { severity: "info", sx: { mb: 2 }, children: t('Auth.TWO_FACTOR_REQUIRED_HINT', 'This app requires two authentication factors for full access.') })), step === 'credentials' && (_jsx(LoginForm, { onSubmit: passwordLoginEnabled ? handleSubmitCredentials : null, onForgotPassword: passwordResetEnabled ? () => navigate('/reset-request-password') : null, onSocialLogin: socialLoginEnabled ? (provider) => startSocialLogin(provider) : null, socialProviders: socialProviders, onPasskeyLogin: passkeyLoginEnabled ? handlePasskeyLoginInitial : null, onSignUp: signupEnabled ? () => navigate('/signup') : null,
|
|
248
|
+
// Block submit while the handoff is in flight so the user does not
|
|
249
|
+
// race the recovery-token fetch with a normal password POST.
|
|
250
|
+
disabled: submitting || recoveryFetching, initialIdentifier: recoveryEmail })), step === 'mfa' && mfaState && (_jsx(Box, { children: _jsx(MfaLoginComponent, { availableTypes: mfaState.availableTypes, identifier: mfaState.identifier, onSuccess: handleMfaSuccess, onCancel: handleMfaCancel }) }))] }));
|
|
150
251
|
}
|
|
@@ -59,8 +59,33 @@ export function PasswordInvitePage() {
|
|
|
59
59
|
setSuccessKey(isInvite
|
|
60
60
|
? 'Auth.RESET_PASSWORD_SUCCESS_INVITE'
|
|
61
61
|
: 'Auth.RESET_PASSWORD_SUCCESS_RESET');
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
// Same-origin check via URL parser — startsWith('/') alone misses
|
|
63
|
+
// bypasses like `/\evil.com`, which WHATWG-parses to `https://evil.com/`
|
|
64
|
+
// for special schemes. The parser normalises backslashes and protocol-
|
|
65
|
+
// relative forms, so any cross-origin result is caught here.
|
|
66
|
+
let safeNextPath = null;
|
|
67
|
+
// Require leading-slash absolute path on the raw input (rejects
|
|
68
|
+
// relative inputs like `account/settings` and protocol-relative `//host`
|
|
69
|
+
// before they reach the URL parser, which would normalise both into a
|
|
70
|
+
// same-origin pathname starting with `/`).
|
|
71
|
+
if (nextPath &&
|
|
72
|
+
typeof nextPath === 'string' &&
|
|
73
|
+
nextPath.startsWith('/') &&
|
|
74
|
+
!nextPath.startsWith('//')) {
|
|
75
|
+
try {
|
|
76
|
+
const parsed = new URL(nextPath, window.location.origin);
|
|
77
|
+
// Catches WHATWG bypasses like `/\evil.com` that resolve to a
|
|
78
|
+
// different origin even though the raw input started with `/`.
|
|
79
|
+
if (parsed.origin === window.location.origin) {
|
|
80
|
+
safeNextPath = parsed.pathname + parsed.search + parsed.hash;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch (_a) {
|
|
84
|
+
// fall through to default redirect
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const target = safeNextPath
|
|
88
|
+
? `/login?next=${encodeURIComponent(safeNextPath)}`
|
|
64
89
|
: '/login';
|
|
65
90
|
navigate(target);
|
|
66
91
|
}
|
package/package.json
CHANGED
package/src/auth/AuthContext.jsx
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
fetchCurrentUser,
|
|
11
11
|
logoutSession,
|
|
12
12
|
} from './authApi';
|
|
13
|
+
import { ReauthModal } from './ReauthModal';
|
|
13
14
|
|
|
14
15
|
export const AuthContext = createContext(null);
|
|
15
16
|
|
|
@@ -136,6 +137,7 @@ export const AuthProvider = ({ children }) => {
|
|
|
136
137
|
}}
|
|
137
138
|
>
|
|
138
139
|
{children}
|
|
140
|
+
<ReauthModal />
|
|
139
141
|
</AuthContext.Provider>
|
|
140
142
|
);
|
|
141
143
|
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Alert,
|
|
4
|
+
Button,
|
|
5
|
+
Dialog,
|
|
6
|
+
DialogActions,
|
|
7
|
+
DialogContent,
|
|
8
|
+
DialogTitle,
|
|
9
|
+
TextField,
|
|
10
|
+
} from '@mui/material';
|
|
11
|
+
import { useTranslation } from 'react-i18next';
|
|
12
|
+
import apiClient from './apiClient';
|
|
13
|
+
import { HEADLESS_BASE } from './authConfig.jsx';
|
|
14
|
+
import { rejectReauth, resolveReauth, subscribe } from './reauth';
|
|
15
|
+
|
|
16
|
+
export function ReauthModal() {
|
|
17
|
+
const { t } = useTranslation();
|
|
18
|
+
const [open, setOpen] = useState(false);
|
|
19
|
+
const [password, setPassword] = useState('');
|
|
20
|
+
const [loading, setLoading] = useState(false);
|
|
21
|
+
const [error, setError] = useState(null);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
return subscribe((active) => {
|
|
25
|
+
setOpen(active);
|
|
26
|
+
if (!active) {
|
|
27
|
+
setPassword('');
|
|
28
|
+
setError(null);
|
|
29
|
+
setLoading(false);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
}, []);
|
|
33
|
+
|
|
34
|
+
const handleSubmit = async (e) => {
|
|
35
|
+
e.preventDefault();
|
|
36
|
+
setLoading(true);
|
|
37
|
+
setError(null);
|
|
38
|
+
try {
|
|
39
|
+
await apiClient.post(`${HEADLESS_BASE}/auth/reauthenticate`, { password });
|
|
40
|
+
resolveReauth();
|
|
41
|
+
} catch {
|
|
42
|
+
setError(t('Auth.REAUTH_FAILED'));
|
|
43
|
+
setLoading(false);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const handleCancel = () => {
|
|
48
|
+
rejectReauth(new Error('cancelled'));
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<Dialog open={open} onClose={handleCancel} maxWidth="xs" fullWidth>
|
|
53
|
+
<form onSubmit={handleSubmit}>
|
|
54
|
+
<DialogTitle>{t('Auth.REAUTH_TITLE')}</DialogTitle>
|
|
55
|
+
<DialogContent>
|
|
56
|
+
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
|
57
|
+
<TextField
|
|
58
|
+
autoFocus
|
|
59
|
+
fullWidth
|
|
60
|
+
type="password"
|
|
61
|
+
label={t('Auth.LOGIN_PASSWORD_LABEL')}
|
|
62
|
+
value={password}
|
|
63
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
64
|
+
disabled={loading}
|
|
65
|
+
sx={{ mt: 1 }}
|
|
66
|
+
/>
|
|
67
|
+
</DialogContent>
|
|
68
|
+
<DialogActions>
|
|
69
|
+
<Button onClick={handleCancel} disabled={loading}>
|
|
70
|
+
{t('Auth.MFA_TOTP_CANCEL_BUTTON')}
|
|
71
|
+
</Button>
|
|
72
|
+
<Button type="submit" variant="contained" disabled={loading || !password}>
|
|
73
|
+
{loading ? t('Auth.MFA_TOTP_VERIFY_BUTTON_LOADING') : t('Auth.MFA_VERIFY')}
|
|
74
|
+
</Button>
|
|
75
|
+
</DialogActions>
|
|
76
|
+
</form>
|
|
77
|
+
</Dialog>
|
|
78
|
+
);
|
|
79
|
+
}
|
package/src/auth/apiClient.jsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import axios from "axios";
|
|
2
2
|
import { CSRF_URL } from "./authConfig";
|
|
3
|
+
import { requestReauth } from "./reauth";
|
|
3
4
|
|
|
4
5
|
const apiClient = axios.create({
|
|
5
6
|
withCredentials: true,
|
|
@@ -130,7 +131,7 @@ function extractAuthSignal(data) {
|
|
|
130
131
|
|
|
131
132
|
apiClient.interceptors.response.use(
|
|
132
133
|
(response) => response,
|
|
133
|
-
(error) => {
|
|
134
|
+
async (error) => {
|
|
134
135
|
const status = error?.response?.status ?? null;
|
|
135
136
|
const data = error?.response?.data ?? {};
|
|
136
137
|
const { code, i18nKey } = extractAuthSignal(data);
|
|
@@ -139,6 +140,24 @@ apiClient.interceptors.response.use(
|
|
|
139
140
|
const isNotAuthenticated =
|
|
140
141
|
code === "not_authenticated" || i18nKey === "auth.not_authenticated";
|
|
141
142
|
|
|
143
|
+
// Reauthentication gate: allauth returns 401 + flows[{id:"reauthenticate"}]
|
|
144
|
+
// when the session is stale before a sensitive operation (e.g. adding a passkey).
|
|
145
|
+
// Show the password-confirm modal, wait for it to complete, then retry once.
|
|
146
|
+
const flows = data?.data?.flows ?? data?.flows ?? [];
|
|
147
|
+
const needsReauth =
|
|
148
|
+
status === 401 &&
|
|
149
|
+
Array.isArray(flows) &&
|
|
150
|
+
flows.some((f) => f.id === "reauthenticate");
|
|
151
|
+
|
|
152
|
+
if (needsReauth && !error.config?._reauthRetried) {
|
|
153
|
+
try {
|
|
154
|
+
await requestReauth();
|
|
155
|
+
return apiClient({ ...error.config, _reauthRetried: true });
|
|
156
|
+
} catch {
|
|
157
|
+
return Promise.reject(error);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
142
161
|
// Per-request opt-out: bootstrap probes (e.g. fetchCurrentUser on app start)
|
|
143
162
|
// expect to handle 401 silently and must not trigger a redirect-on-mount.
|
|
144
163
|
// Carried as an axios config property, so it never travels to the backend
|
package/src/auth/authApi.jsx
CHANGED
|
@@ -433,6 +433,31 @@ export async function rejectRecoveryRequest(id, supportNote) {
|
|
|
433
433
|
return res.data;
|
|
434
434
|
}
|
|
435
435
|
|
|
436
|
+
// S164: server-side session handoff. After the email-link redirect lands the
|
|
437
|
+
// SPA on `/login#recovery=ok`, the LoginPage fetches the plaintext recovery
|
|
438
|
+
// token from the user's server session (set by `recovery_complete_view`).
|
|
439
|
+
// The endpoint pops the token on every read — a single shot per browser
|
|
440
|
+
// navigation — so a navigated-away tab cannot reuse the entry.
|
|
441
|
+
export async function fetchRecoverySessionToken() {
|
|
442
|
+
try {
|
|
443
|
+
const res = await apiClient.get('/api/auth/recovery/session-token/', {
|
|
444
|
+
// Visiting `/login` without a pending recovery session is the common
|
|
445
|
+
// case; the 404 it returns must not bubble up as a global "session
|
|
446
|
+
// expired" auth error in the app shell.
|
|
447
|
+
skipAuthRedirect: true,
|
|
448
|
+
});
|
|
449
|
+
return res?.data?.token || null;
|
|
450
|
+
} catch (err) {
|
|
451
|
+
if (err?.response?.status === 404) {
|
|
452
|
+
// No pending recovery in this session — treat as a non-error so the
|
|
453
|
+
// LoginPage can decide whether to surface a "link expired" message
|
|
454
|
+
// based on the `#recovery=` hash sentinel it already inspects.
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
throw normaliseApiError(err, 'Auth.RECOVERY_TOKEN_INVALID');
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
436
461
|
export async function loginWithRecoveryPassword(email, password, token) {
|
|
437
462
|
try {
|
|
438
463
|
// S50 (django-core-micha >=2.13.1): token wird im POST-Body übergeben,
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Promise broker for the reauthentication gate.
|
|
2
|
+
//
|
|
3
|
+
// When the response interceptor encounters a 401+reauthenticate, it calls
|
|
4
|
+
// requestReauth() and awaits the returned promise. The ReauthModal resolves
|
|
5
|
+
// or rejects that promise after the user submits (or cancels) the password
|
|
6
|
+
// dialog. Concurrent 401s share the same pending promise so only one modal
|
|
7
|
+
// ever appears.
|
|
8
|
+
|
|
9
|
+
let _pendingResolve = null;
|
|
10
|
+
let _pendingReject = null;
|
|
11
|
+
let _reauthPromise = null;
|
|
12
|
+
let _listeners = [];
|
|
13
|
+
|
|
14
|
+
export function requestReauth() {
|
|
15
|
+
if (_reauthPromise) return _reauthPromise;
|
|
16
|
+
_reauthPromise = new Promise((resolve, reject) => {
|
|
17
|
+
_pendingResolve = resolve;
|
|
18
|
+
_pendingReject = reject;
|
|
19
|
+
_listeners.forEach(fn => fn(true));
|
|
20
|
+
});
|
|
21
|
+
return _reauthPromise;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function resolveReauth() {
|
|
25
|
+
const resolve = _pendingResolve;
|
|
26
|
+
_pendingResolve = null;
|
|
27
|
+
_pendingReject = null;
|
|
28
|
+
_reauthPromise = null;
|
|
29
|
+
_listeners.forEach(fn => fn(false));
|
|
30
|
+
if (resolve) resolve();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function rejectReauth(error) {
|
|
34
|
+
const reject = _pendingReject;
|
|
35
|
+
_pendingResolve = null;
|
|
36
|
+
_pendingReject = null;
|
|
37
|
+
_reauthPromise = null;
|
|
38
|
+
_listeners.forEach(fn => fn(false));
|
|
39
|
+
if (reject) reject(error || new Error('Reauthentication cancelled'));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function subscribe(fn) {
|
|
43
|
+
_listeners = [..._listeners, fn];
|
|
44
|
+
return () => {
|
|
45
|
+
_listeners = _listeners.filter(l => l !== fn);
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -6,6 +6,36 @@ export const authTranslations = {
|
|
|
6
6
|
"en": "Email address or password is incorrect.",
|
|
7
7
|
"sw": "Barua pepe au nenosiri sio sahihi."
|
|
8
8
|
},
|
|
9
|
+
"Auth.RECOVERY_TOKEN_INVALID": {
|
|
10
|
+
"de": "Der Recovery-Link ist ungültig oder wurde bereits verwendet.",
|
|
11
|
+
"fr": "Le lien de récupération est invalide ou a déjà été utilisé.",
|
|
12
|
+
"en": "The recovery link is invalid or has already been used.",
|
|
13
|
+
"sw": "Kiungo cha urejesho si halali au tayari kimetumika."
|
|
14
|
+
},
|
|
15
|
+
"Auth.RECOVERY_TOKEN_EXPIRED": {
|
|
16
|
+
"de": "Der Recovery-Link ist abgelaufen. Bitte fordere einen neuen an.",
|
|
17
|
+
"fr": "Le lien de récupération a expiré. Veuillez en demander un nouveau.",
|
|
18
|
+
"en": "The recovery link has expired. Please request a new one.",
|
|
19
|
+
"sw": "Kiungo cha urejesho kimeisha muda wake. Tafadhali omba kipya."
|
|
20
|
+
},
|
|
21
|
+
"Auth.RECOVERY_LINK_VALIDATING": {
|
|
22
|
+
"de": "Recovery-Link wird geprüft…",
|
|
23
|
+
"fr": "Vérification du lien de récupération…",
|
|
24
|
+
"en": "Validating recovery link…",
|
|
25
|
+
"sw": "Inathibitisha kiungo cha urejesho..."
|
|
26
|
+
},
|
|
27
|
+
"Auth.RECOVERY_LOGIN_WARNING": {
|
|
28
|
+
"de": "Recovery-Link validiert. Bitte gib dein Passwort ein.",
|
|
29
|
+
"fr": "Lien de récupération validé. Veuillez saisir votre mot de passe.",
|
|
30
|
+
"en": "Recovery link validated. Please enter your password.",
|
|
31
|
+
"sw": "Kiungo cha urejesho kimethibitishwa. Tafadhali weka nenosiri lako."
|
|
32
|
+
},
|
|
33
|
+
"Auth.TWO_FACTOR_REQUIRED_HINT": {
|
|
34
|
+
"de": "Diese App benötigt zwei Authentifizierungsfaktoren für vollen Zugriff.",
|
|
35
|
+
"fr": "Cette application requiert deux facteurs d’authentification pour un accès complet.",
|
|
36
|
+
"en": "This app requires two authentication factors for full access.",
|
|
37
|
+
"sw": "Programu hii inahitaji vipengele viwili vya uthibitisho kwa ufikiaji kamili."
|
|
38
|
+
},
|
|
9
39
|
"Auth.PASSKEY_FAILED": {
|
|
10
40
|
"de": "Die Anmeldung mit Passkey ist fehlgeschlagen.",
|
|
11
41
|
"fr": "La connexion avec passkey a échoué.",
|
|
@@ -1805,5 +1835,23 @@ export const authTranslations = {
|
|
|
1805
1835
|
"fr": "Politique de code d'accès enregistrée.",
|
|
1806
1836
|
"en": "Access code policy saved.",
|
|
1807
1837
|
"sw": "Sera ya msimbo wa ufikiaji imehifadhiwa."
|
|
1838
|
+
},
|
|
1839
|
+
"Auth.REAUTH_TITLE": {
|
|
1840
|
+
"de": "Identität bestätigen",
|
|
1841
|
+
"fr": "Confirmer votre identité",
|
|
1842
|
+
"en": "Confirm your identity",
|
|
1843
|
+
"sw": "Thibitisha utambulisho wako"
|
|
1844
|
+
},
|
|
1845
|
+
"Auth.REAUTH_SUBTITLE": {
|
|
1846
|
+
"de": "Bitte gib dein Passwort ein, um fortzufahren.",
|
|
1847
|
+
"fr": "Veuillez saisir votre mot de passe pour continuer.",
|
|
1848
|
+
"en": "Please enter your password to continue.",
|
|
1849
|
+
"sw": "Tafadhali ingiza nenosiri lako ili kuendelea."
|
|
1850
|
+
},
|
|
1851
|
+
"Auth.REAUTH_FAILED": {
|
|
1852
|
+
"de": "Das Passwort ist falsch.",
|
|
1853
|
+
"fr": "Le mot de passe est incorrect.",
|
|
1854
|
+
"en": "The password is incorrect.",
|
|
1855
|
+
"sw": "Nenosiri si sahihi."
|
|
1808
1856
|
}
|
|
1809
1857
|
};
|
package/src/pages/LoginPage.jsx
CHANGED
|
@@ -9,7 +9,11 @@ import { NarrowPage } from '../layout/PageLayout';
|
|
|
9
9
|
import { AuthContext } from '../auth/AuthContext';
|
|
10
10
|
|
|
11
11
|
// API & Services (Clean Architecture)
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
fetchRecoverySessionToken,
|
|
14
|
+
loginWithPassword,
|
|
15
|
+
loginWithRecoveryPassword,
|
|
16
|
+
} from '../auth/authApi';
|
|
13
17
|
import { loginWithPasskey, startSocialLogin } from '../utils/authService';
|
|
14
18
|
|
|
15
19
|
// Components
|
|
@@ -27,16 +31,23 @@ export function LoginPage() {
|
|
|
27
31
|
const [submitting, setSubmitting] = useState(false);
|
|
28
32
|
const [errorKey, setErrorKey] = useState(null);
|
|
29
33
|
const [mfaState, setMfaState] = useState(null); // { availableTypes: [...], identifier }
|
|
34
|
+
// S164: token never travels through the URL/fragment anymore. It is held
|
|
35
|
+
// in component state only, fetched from the server-side session handoff
|
|
36
|
+
// endpoint when the redirect lands the SPA here with `#recovery=ok`.
|
|
37
|
+
const [recoveryToken, setRecoveryToken] = useState(null);
|
|
38
|
+
const [recoveryFetching, setRecoveryFetching] = useState(false);
|
|
30
39
|
|
|
31
40
|
// URL Params parsing
|
|
32
41
|
const params = new URLSearchParams(location.search);
|
|
33
42
|
const hashParams = new URLSearchParams(
|
|
34
43
|
String(location.hash || "").startsWith("#") ? String(location.hash).slice(1) : String(location.hash || ""),
|
|
35
44
|
);
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
45
|
+
// After S164 the hash carries only a status sentinel: `ok`, `invalid`, or
|
|
46
|
+
// `expired`. We also accept the same key from query params as a defensive
|
|
47
|
+
// fallback for proxies that strip fragments.
|
|
48
|
+
const recoveryStatus = String(
|
|
49
|
+
hashParams.get('recovery') || params.get('recovery') || '',
|
|
50
|
+
).toLowerCase();
|
|
40
51
|
// Backward-compatible fallback for legacy links using query parameters.
|
|
41
52
|
const recoveryEmail = hashParams.get('email') || params.get('email') || '';
|
|
42
53
|
const requestedNext = params.get('next');
|
|
@@ -51,8 +62,30 @@ export function LoginPage() {
|
|
|
51
62
|
return '/account?tab=security&from=weak_login';
|
|
52
63
|
}
|
|
53
64
|
|
|
54
|
-
|
|
55
|
-
|
|
65
|
+
// Same-origin check via URL parser — `startsWith('/')` alone allows
|
|
66
|
+
// bypasses like `/\evil.com`, which WHATWG-parses to `https://evil.com/`
|
|
67
|
+
// for special schemes. Resolve against the current origin and only accept
|
|
68
|
+
// results whose origin matches.
|
|
69
|
+
// Require leading-slash absolute path on the raw input (rejects relative
|
|
70
|
+
// inputs like `account/settings` and protocol-relative `//host` before
|
|
71
|
+
// they reach the URL parser, which would normalise both into a same-
|
|
72
|
+
// origin pathname starting with `/`).
|
|
73
|
+
if (
|
|
74
|
+
requestedNext &&
|
|
75
|
+
typeof requestedNext === 'string' &&
|
|
76
|
+
requestedNext.startsWith('/') &&
|
|
77
|
+
!requestedNext.startsWith('//')
|
|
78
|
+
) {
|
|
79
|
+
try {
|
|
80
|
+
const parsed = new URL(requestedNext, window.location.origin);
|
|
81
|
+
// Catches WHATWG bypasses like `/\evil.com` that resolve to a
|
|
82
|
+
// different origin even though the raw input started with `/`.
|
|
83
|
+
if (parsed.origin === window.location.origin) {
|
|
84
|
+
return parsed.pathname + parsed.search + parsed.hash;
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
// fall through to default redirect
|
|
88
|
+
}
|
|
56
89
|
}
|
|
57
90
|
|
|
58
91
|
return '/';
|
|
@@ -65,6 +98,77 @@ export function LoginPage() {
|
|
|
65
98
|
}
|
|
66
99
|
}, [location.search]);
|
|
67
100
|
|
|
101
|
+
// S164 handoff: on `#recovery=ok`, pull the plaintext token out of the
|
|
102
|
+
// server-side session (one-shot). On `invalid`/`expired`, or on any
|
|
103
|
+
// other non-empty value (legacy pre-S164 email links that carried the
|
|
104
|
+
// token directly in the hash), surface an invalid-link error so the
|
|
105
|
+
// user gets feedback instead of a silent login screen.
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (!recoveryStatus) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
// Strip the `recovery=…` sentinel from BOTH hash and query string so a
|
|
111
|
+
// bookmark or page refresh does not re-trigger this effect against an
|
|
112
|
+
// already-popped session entry (which would render a confusing
|
|
113
|
+
// "invalid" error to a user who is mid-login).
|
|
114
|
+
//
|
|
115
|
+
// We use `history.replaceState` rather than `navigate(..., {replace})`
|
|
116
|
+
// intentionally: react-router's `navigate` would trigger an immediate
|
|
117
|
+
// re-render with an updated `useLocation()`, which would cause this
|
|
118
|
+
// effect's cleanup to set `cancelled=true` and discard the in-flight
|
|
119
|
+
// recovery-token fetch result. `replaceState` only mutates the browser
|
|
120
|
+
// URL bar; `useLocation()` stays at `recovery=ok` for this render, the
|
|
121
|
+
// fetch resolves, and `recoveryToken` gets set normally. The trade-off
|
|
122
|
+
// is that `useLocation().hash` is stale for the page lifetime — fine
|
|
123
|
+
// because nothing else in this component reads it.
|
|
124
|
+
if (typeof window !== 'undefined' && window.history?.replaceState) {
|
|
125
|
+
const cleanParams = new URLSearchParams(window.location.search);
|
|
126
|
+
cleanParams.delete('recovery');
|
|
127
|
+
const cleanSearch = cleanParams.toString();
|
|
128
|
+
const cleanUrl =
|
|
129
|
+
window.location.pathname + (cleanSearch ? `?${cleanSearch}` : '');
|
|
130
|
+
window.history.replaceState(null, '', cleanUrl);
|
|
131
|
+
}
|
|
132
|
+
if (recoveryStatus === 'expired') {
|
|
133
|
+
setErrorKey('Auth.RECOVERY_TOKEN_EXPIRED');
|
|
134
|
+
setRecoveryToken(null);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (recoveryStatus !== 'ok') {
|
|
138
|
+
// Catches `invalid` and any unknown value (including legacy email
|
|
139
|
+
// links that carried the plaintext token before S164).
|
|
140
|
+
setErrorKey('Auth.RECOVERY_TOKEN_INVALID');
|
|
141
|
+
setRecoveryToken(null);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
let cancelled = false;
|
|
145
|
+
setRecoveryFetching(true);
|
|
146
|
+
fetchRecoverySessionToken()
|
|
147
|
+
.then((token) => {
|
|
148
|
+
if (cancelled) return;
|
|
149
|
+
if (!token) {
|
|
150
|
+
// Session entry missing or already consumed — treat as invalid.
|
|
151
|
+
setErrorKey('Auth.RECOVERY_TOKEN_INVALID');
|
|
152
|
+
setRecoveryToken(null);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
setRecoveryToken(token);
|
|
156
|
+
})
|
|
157
|
+
.catch((err) => {
|
|
158
|
+
if (cancelled) return;
|
|
159
|
+
setErrorKey(err?.code || 'Auth.RECOVERY_TOKEN_INVALID');
|
|
160
|
+
setRecoveryToken(null);
|
|
161
|
+
})
|
|
162
|
+
.finally(() => {
|
|
163
|
+
if (!cancelled) {
|
|
164
|
+
setRecoveryFetching(false);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
return () => {
|
|
168
|
+
cancelled = true;
|
|
169
|
+
};
|
|
170
|
+
}, [recoveryStatus]);
|
|
171
|
+
|
|
68
172
|
useEffect(() => {
|
|
69
173
|
if (loading || !user) return;
|
|
70
174
|
navigate(getRedirectTarget(user), { replace: true });
|
|
@@ -185,13 +289,19 @@ export function LoginPage() {
|
|
|
185
289
|
</Alert>
|
|
186
290
|
)}
|
|
187
291
|
|
|
292
|
+
{recoveryFetching && !errorKey && (
|
|
293
|
+
<Alert severity="info" sx={{ mb: 2 }}>
|
|
294
|
+
{t('Auth.RECOVERY_LINK_VALIDATING', 'Validating recovery link…')}
|
|
295
|
+
</Alert>
|
|
296
|
+
)}
|
|
297
|
+
|
|
188
298
|
{recoveryToken && !errorKey && (
|
|
189
299
|
<Alert severity="info" sx={{ mb: 2 }}>
|
|
190
300
|
{t('Auth.RECOVERY_LOGIN_WARNING', 'Recovery link validated. Please enter your password.')}
|
|
191
301
|
</Alert>
|
|
192
302
|
)}
|
|
193
303
|
|
|
194
|
-
{twoFactorRequired && !recoveryToken && (
|
|
304
|
+
{twoFactorRequired && !recoveryToken && !recoveryFetching && (
|
|
195
305
|
<Alert severity="info" sx={{ mb: 2 }}>
|
|
196
306
|
{t('Auth.TWO_FACTOR_REQUIRED_HINT', 'This app requires two authentication factors for full access.')}
|
|
197
307
|
</Alert>
|
|
@@ -207,7 +317,9 @@ export function LoginPage() {
|
|
|
207
317
|
socialProviders={socialProviders}
|
|
208
318
|
onPasskeyLogin={passkeyLoginEnabled ? handlePasskeyLoginInitial : null}
|
|
209
319
|
onSignUp={signupEnabled ? () => navigate('/signup') : null}
|
|
210
|
-
|
|
320
|
+
// Block submit while the handoff is in flight so the user does not
|
|
321
|
+
// race the recovery-token fetch with a normal password POST.
|
|
322
|
+
disabled={submitting || recoveryFetching}
|
|
211
323
|
initialIdentifier={recoveryEmail}
|
|
212
324
|
/>
|
|
213
325
|
)}
|
|
@@ -70,8 +70,34 @@ export function PasswordInvitePage() {
|
|
|
70
70
|
? 'Auth.RESET_PASSWORD_SUCCESS_INVITE'
|
|
71
71
|
: 'Auth.RESET_PASSWORD_SUCCESS_RESET',
|
|
72
72
|
);
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
// Same-origin check via URL parser — startsWith('/') alone misses
|
|
74
|
+
// bypasses like `/\evil.com`, which WHATWG-parses to `https://evil.com/`
|
|
75
|
+
// for special schemes. The parser normalises backslashes and protocol-
|
|
76
|
+
// relative forms, so any cross-origin result is caught here.
|
|
77
|
+
let safeNextPath = null;
|
|
78
|
+
// Require leading-slash absolute path on the raw input (rejects
|
|
79
|
+
// relative inputs like `account/settings` and protocol-relative `//host`
|
|
80
|
+
// before they reach the URL parser, which would normalise both into a
|
|
81
|
+
// same-origin pathname starting with `/`).
|
|
82
|
+
if (
|
|
83
|
+
nextPath &&
|
|
84
|
+
typeof nextPath === 'string' &&
|
|
85
|
+
nextPath.startsWith('/') &&
|
|
86
|
+
!nextPath.startsWith('//')
|
|
87
|
+
) {
|
|
88
|
+
try {
|
|
89
|
+
const parsed = new URL(nextPath, window.location.origin);
|
|
90
|
+
// Catches WHATWG bypasses like `/\evil.com` that resolve to a
|
|
91
|
+
// different origin even though the raw input started with `/`.
|
|
92
|
+
if (parsed.origin === window.location.origin) {
|
|
93
|
+
safeNextPath = parsed.pathname + parsed.search + parsed.hash;
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
// fall through to default redirect
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const target = safeNextPath
|
|
100
|
+
? `/login?next=${encodeURIComponent(safeNextPath)}`
|
|
75
101
|
: '/login';
|
|
76
102
|
navigate(target);
|
|
77
103
|
} catch (err) {
|