The checkbox had no `checked`, no `onChange`, and no place in the login request; `rememberMe` existed only as an i18n label. On the backend establishAdminSession hardcoded `expiresIn: '24h'` and the cookie always got DEFAULT_MAX_AGE_MS, so there was nothing to receive it anyway. Wired end to end: state on the page, `remember_me` in the login body, and a 30-day JWT plus a matching 30-day cookie when it is set. Opt-in on purpose. An absent or malformed value means "no", so a client that never sends it keeps exactly the 24h session it always had, and a stolen cookie is still worth a day by default. The JWT and the cookie take their lifetime from the same flag. If they can disagree the session either dies early (long cookie, short token) or outlives what the user consented to, so the tests assert them against each other. Review found the feature was non-functional as written, which is the important part: sessionTimeoutMiddleware and isSessionExpired enforce security_session_timeout_minutes — 60 minutes by default — against a session's idle time regardless of how long its token lives, so a remembered admin was logged out within the hour with a 30-day token sitting unused. rememberMe now travels in the JWT payload and both checks exempt a remembered session from the IDLE timeout. Not from expiry: the token still dies on its own 30-day exp, and revocation, deactivation and password-change invalidation are untouched. Also: /api/admin/auth/change-password reissued a hardcoded 24h token without the flag, so a remembered admin dropped back to 24h the moment they changed their password — which is mandatory for new and reset accounts. It now inherits the choice from the session it replaces, carried on req.admin.rememberMe. Through MFA the choice rides inside the signed mfa_pending token rather than being resent, so the second leg cannot ask for longer than the first agreed to. The tests drive POST /api/auth/admin/login and read the real Set-Cookie and token rather than minting a local clone of the ternary they are meant to be checking, boot one database per file before anything reads it, and generate their credential per run so no literal that looks like a password lands in the repository. No visual change — the checkbox was uncontrolled, so it already toggled on click; it just did nothing. Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
co-authored by
Paul Nothaft
parent
95e7301909
commit
d3e9a7cf0d
@@ -25,6 +25,7 @@ export const AdminLoginPage: React.FC = () => {
|
||||
email: '',
|
||||
password: '',
|
||||
});
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
@@ -121,7 +122,8 @@ export const AdminLoginPage: React.FC = () => {
|
||||
try {
|
||||
const response = await authService.adminLogin({
|
||||
...formData,
|
||||
recaptchaToken
|
||||
recaptchaToken,
|
||||
rememberMe
|
||||
});
|
||||
// MFA enabled → move to the second step instead of logging in.
|
||||
if (isMfaChallenge(response)) {
|
||||
@@ -345,6 +347,8 @@ export const AdminLoginPage: React.FC = () => {
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
className="w-4 h-4 text-accent border-neutral-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-neutral-700">{t('adminLogin.rememberMe')}</span>
|
||||
|
||||
@@ -14,13 +14,22 @@ const normalizeGalleryResponse = (response: GalleryAuthResponse): GalleryAuthRes
|
||||
|
||||
export const authService = {
|
||||
// Admin authentication
|
||||
async adminLogin(credentials: { email: string; password: string; recaptchaToken?: string | null }): Promise<AdminLoginResponse> {
|
||||
async adminLogin(credentials: {
|
||||
email: string;
|
||||
password: string;
|
||||
recaptchaToken?: string | null;
|
||||
rememberMe?: boolean;
|
||||
}): Promise<AdminLoginResponse> {
|
||||
// Backend expects 'username' field, but we accept email.
|
||||
// Returns either { user } (session set) or an MFA challenge { mfaRequired, mfaToken }.
|
||||
const response = await api.post<AdminLoginResponse>('/auth/admin/login', {
|
||||
username: credentials.email,
|
||||
password: credentials.password,
|
||||
recaptchaToken: credentials.recaptchaToken
|
||||
recaptchaToken: credentials.recaptchaToken,
|
||||
// Only sent when checked (#1186). Omitted otherwise, so the backend's
|
||||
// default 24h session is what an untouched form still gets. The MFA
|
||||
// step does not resend it — it rides along inside the mfa_pending token.
|
||||
remember_me: credentials.rememberMe === true
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user