Token issuance updates (#13214)

- Ensure all token issuance endpoints are explicitly checked for MFA auth
- Additional backend unit tests
- Additional frontend unit tests
This commit is contained in:
Oliver authored and GitHub committed 2026-11-07 13:18:30 +11:00
1 parent 0cb1852e0d
commit 02d6a038bf
8 files changed
+576 -254

No files matched your search

+33 -10
View File
@@ -379,6 +379,34 @@ class RuleSetDetail(RuleSetMixin, RetrieveUpdateDestroyAPI):
"""Detail endpoint for a particular RuleSet instance."""
def check_token_reauthentication(request) -> Response | None:
"""Check that a user is allowed to be issued a new API token.
If the user has MFA enabled, they must have recently (re)authenticated
before a new token can be issued. Otherwise a stolen (or aged) session
could be converted into a long-lived API token without the second factor.
This check must be applied to *every* API endpoint which issues new tokens.
Returns:
None if the user may be issued a token, or a 401 response
describing the required reauthentication flows.
"""
user = request.user
if is_mfa_enabled(user) and not did_recently_authenticate(request):
return Response(
{
'id': 'reauthentication_required',
'detail': _('Reauthentication is required to generate a token'),
'data': {'flows': get_reauthentication_flows(user)},
},
status=status.HTTP_401_UNAUTHORIZED,
)
return None
class GetAuthToken(GenericAPIView):
"""Return authentication token for an authenticated user."""
@@ -409,16 +437,8 @@ class GetAuthToken(GenericAPIView):
if not user.is_authenticated:
raise exceptions.NotAuthenticated() # pragma: no cover
# does this user have MFA set up? if so: was he recently verified?
if is_mfa_enabled(user) and not did_recently_authenticate(request):
return Response(
{
'id': 'reauthentication_required',
'detail': _('Reauthentication is required to generate a token'),
'data': {'flows': get_reauthentication_flows(user)},
},
status=status.HTTP_401_UNAUTHORIZED,
)
if reauth_response := check_token_reauthentication(request):
return reauth_response
name = ApiToken.sanitize_name(name)
@@ -533,6 +553,9 @@ class TokenListView(TokenMixin, ListCreateAPI):
def create(self, request, *args, **kwargs):
"""Create token and show key to user."""
if reauth_response := check_token_reauthentication(request):
return reauth_response
resp = super().create(request, *args, **kwargs)
resp.data['token'] = self._created_token.token
+67
View File
@@ -1,12 +1,15 @@
"""API tests for various user / auth API endpoints."""
import datetime
import time
from unittest import mock
from django.contrib.auth.models import Group, User
from django.urls import reverse
from allauth.account.internal.flows.login import AUTHENTICATION_METHODS_SESSION_KEY
from allauth.account.models import EmailAddress
from allauth.mfa.models import Authenticator
import InvenTree.helpers
from common.models import NotificationMessage
@@ -765,6 +768,70 @@ class UserTokenTests(InvenTreeAPITestCase):
self.assertEqual(token.token_version, 2)
self.assertIsNone(token.last_seen)
def set_auth_time(self, age: float):
"""Record an allauth authentication event in the test client session.
Arguments:
age: How many seconds ago the authentication occurred
"""
session = self.client.session
session[AUTHENTICATION_METHODS_SESSION_KEY] = [
{'method': 'mfa', 'type': 'totp', 'at': time.time() - age}
]
session.save()
def test_token_mfa_reauthentication(self):
"""Test that MFA users must have recently authenticated to mint a token.
Every endpoint which issues a new API token must enforce this check.
Otherwise an aged (or stolen) session could be converted into a
long-lived API token without the second factor.
"""
Authenticator.objects.create(
user=self.user, type=Authenticator.Type.TOTP, data={'secret': 'abc'}
)
n_tokens = ApiToken.objects.filter(user=self.user).count()
endpoints = [
lambda name: self.get(
reverse('api-token'), data={'name': name}, expected_code=None
),
lambda name: self.get(
reverse('api-token_legacy'), data={'name': name}, expected_code=None
),
lambda name: self.post(
reverse('api-token-list'), data={'name': name}, expected_code=None
),
]
# No authentication record, or an aged one: reauthentication is required
for age in [None, 600]:
if age is not None:
self.set_auth_time(age)
for idx, mint in enumerate(endpoints):
response = mint(f'mfa-{age}-{idx}')
self.assertEqual(response.status_code, 401, response.data)
self.assertEqual(response.data['id'], 'reauthentication_required')
flows = [flow['id'] for flow in response.data['data']['flows']]
self.assertIn('mfa_reauthenticate', flows)
# No tokens were issued
self.assertEqual(ApiToken.objects.filter(user=self.user).count(), n_tokens)
# After recent (re)authentication, tokens can be issued
self.set_auth_time(10)
self.get(reverse('api-token'), data={'name': 'mfa-ok-0'}, expected_code=200)
response = self.post(
reverse('api-token-list'), data={'name': 'mfa-ok-1'}, expected_code=201
)
self.assertIn('token', response.data)
self.assertEqual(ApiToken.objects.filter(user=self.user).count(), n_tokens + 2)
def test_token_v1(self):
"""Test that v1 API tokens still work."""
# Create a v1 token via model - this is NOT recommended; use v2 tokens
@@ -706,6 +706,24 @@ export function ApiForm({
setNonFieldErrors(_nonFieldErrors);
props.onFormError?.(error, form);
break;
case 401:
// The server may require the user to re-authenticate (e.g. MFA)
// before the action can be performed
if (
error.response.data?.id === 'reauthentication_required' &&
!!props.onFormError
) {
setNonFieldErrors([
error.response.data?.detail ??
t`Reauthentication is required to continue`
]);
props.onFormError(error, form);
break;
}
invalidResponse(error.response.status);
props.onFormError?.(error, form);
break;
default:
// Unexpected state on form error
@@ -0,0 +1,271 @@
import { StylishText } from '@lib/components/StylishText';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { apiUrl } from '@lib/functions/Api';
import { FlowEnum } from '@lib/types/Auth';
import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import {
Alert,
Button,
Divider,
Group,
Modal,
PasswordInput,
Stack
} from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IconCircleCheck, IconExclamationCircle } from '@tabler/icons-react';
import { type ReactNode, useCallback, useState } from 'react';
import { api } from '../../App';
import { useServerApiState } from '../../states/ServerApiState';
/**
* Extract the required re-authentication flow from an error response
*/
export function getReauthFlow(err: any): FlowEnum | null {
const flows: any = err.response?.data?.data?.flows ?? [];
if (flows.find((flow: any) => flow.id == FlowEnum.MfaReauthenticate)) {
return FlowEnum.MfaReauthenticate;
} else if (flows.find((flow: any) => flow.id == FlowEnum.Reauthenticate)) {
return FlowEnum.Reauthenticate;
} else {
// No match found
return null;
}
}
/**
* Extract an error message from an allauth error response.
*
* The allauth flows have a particular response structure,
* which this function handles.
*/
export function extractErrorMessage(err: any, defaultMsg: string): string {
const backupMsg = `${defaultMsg}: ${err.status}`;
if (err.response?.data?.errors && err.response?.data?.errors.length > 0) {
return err.response?.data?.errors[0]?.message ?? backupMsg;
}
return backupMsg;
}
type ReauthInputProps = {
label: string;
name: string;
description: string;
url: string;
};
/**
* Internal modal component for re-authentication
*/
function ReauthenticateModalComponent({
inputProps,
setOpen
}: {
inputProps: ReauthInputProps;
setOpen: (open: boolean) => void;
}) {
const { setAuthContext } = useServerApiState.getState();
const [value, setValue] = useState<string>('');
const [error, setError] = useState<string>('');
const onSubmit = useCallback(
(value: string) => {
api
.post(inputProps.url, {
[inputProps.name]: value
})
.then((response) => {
setAuthContext(response.data?.data);
showNotification({
title: t`Reauthentication Succeeded`,
message: t`You have been reauthenticated successfully.`,
color: 'green',
icon: <IconCircleCheck />
});
setOpen(false);
})
.catch((error) => {
setError(
extractErrorMessage(error, t`Error during reauthentication`)
);
showNotification({
title: t`Reauthentication Failed`,
message: `${t`Failed to reauthenticate`}: ${error.status}`,
color: 'red',
icon: <IconExclamationCircle />
});
});
},
[inputProps]
);
return (
<Stack>
<Divider />
<Alert
color='yellow'
icon={<IconExclamationCircle />}
title={t`Reauthenticate`}
>
{t`Reauthentication is required to continue.`}
</Alert>
<PasswordInput
required
label={inputProps.label}
name={inputProps.name}
description={inputProps.description}
value={value}
error={error}
onChange={(event) => setValue(event.target.value)}
/>
<Group justify='right'>
<Button onClick={() => setOpen(false)} color='red'>
<Trans>Cancel</Trans>
</Button>
<Button onClick={() => onSubmit(value)} color='green'>
<Trans>Submit</Trans>
</Button>
</Group>
</Stack>
);
}
function ReauthenticateModal({
inputProps,
opened,
setOpen
}: {
inputProps: ReauthInputProps;
opened: boolean;
setOpen: (open: boolean) => void;
}) {
return (
<Modal
opened={opened}
zIndex={9999}
size='lg'
onClose={() => setOpen(false)}
title={<StylishText size='lg'>{t`Reauthenticate`}</StylishText>}
>
<ReauthenticateModalComponent inputProps={inputProps} setOpen={setOpen} />
</Modal>
);
}
/**
* Modal for re-authenticating with password
*/
export function ReauthenticatePasswordModal({
opened,
setOpen
}: {
opened: boolean;
setOpen: (open: boolean) => void;
}) {
return (
<ReauthenticateModal
opened={opened}
setOpen={setOpen}
inputProps={{
label: t`Password`,
name: 'password',
description: t`Enter your password`,
url: apiUrl(ApiEndpoints.auth_reauthenticate)
}}
/>
);
}
/**
* Modal for re-authenticating with TOTP code
*/
export function ReauthenticateTOTPModal({
opened,
setOpen
}: {
opened: boolean;
setOpen: (open: boolean) => void;
}) {
return (
<ReauthenticateModal
opened={opened}
setOpen={setOpen}
inputProps={{
label: t`TOTP Code`,
name: 'code',
description: t`Enter one of your TOTP codes`,
url: apiUrl(ApiEndpoints.auth_mfa_reauthenticate)
}}
/>
);
}
/**
* Hook providing re-authentication modals and helpers.
*
* Usage:
* - Render the returned `modals` element somewhere in the component tree
* - Call `reauthenticate(flow)` to open the matching modal directly, or
* - Call `handleReauthError(error)` from an API error handler; it returns true
* (and opens the appropriate modal) if the error indicates that the server
* requires the user to re-authenticate before continuing.
*/
export function useReauthenticate(): {
reauthenticate: (flow: FlowEnum) => void;
handleReauthError: (error: any) => boolean;
modals: ReactNode;
} {
const [passwordOpen, setPasswordOpen] = useState<boolean>(false);
const [totpOpen, setTotpOpen] = useState<boolean>(false);
const reauthenticate = useCallback((flow: FlowEnum) => {
switch (flow) {
case FlowEnum.Reauthenticate:
setPasswordOpen(true);
break;
case FlowEnum.MfaReauthenticate:
setTotpOpen(true);
break;
default:
// Un-handled reauthentication flow
break;
}
}, []);
const handleReauthError = useCallback(
(error: any): boolean => {
const status = error?.status ?? error?.response?.status;
if (status !== 401) {
return false;
}
const flow = getReauthFlow(error);
if (!flow) {
return false;
}
reauthenticate(flow);
return true;
},
[reauthenticate]
);
const modals = (
<>
<ReauthenticatePasswordModal
opened={passwordOpen}
setOpen={setPasswordOpen}
/>
<ReauthenticateTOTPModal opened={totpOpen} setOpen={setTotpOpen} />
</>
);
return { reauthenticate, handleReauthError, modals };
}
@@ -2,7 +2,7 @@ import { CopyButton } from '@lib/components/CopyButton';
import { StylishText } from '@lib/components/StylishText';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { apiUrl } from '@lib/functions/Api';
import { FlowEnum } from '@lib/types/Auth';
import type { FlowEnum } from '@lib/types/Auth';
import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import {
@@ -13,7 +13,6 @@ import {
Loader,
Modal,
Paper,
PasswordInput,
SimpleGrid,
Stack,
Table,
@@ -32,6 +31,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useShallow } from 'zustand/react/shallow';
import { api, queryClient } from '../../../../App';
import {
extractErrorMessage,
getReauthFlow,
useReauthenticate
} from '../../../../components/modals/ReauthenticateModal';
import { authApi, doLogout } from '../../../../functions/auth';
import { showApiErrorMessage } from '../../../../functions/notifications';
import { useServerApiState } from '../../../../states/ServerApiState';
@@ -39,192 +43,6 @@ import { useGlobalSettingsState } from '../../../../states/SettingsStates';
import { QrRegistrationForm } from './QrRegistrationForm';
import { parseDate } from './SecurityContent';
/**
* Extract the required re-authentication flow from an error response
*/
function getReauthFlow(err: any): FlowEnum | null {
const flows: any = err.response?.data?.data?.flows ?? [];
if (flows.find((flow: any) => flow.id == FlowEnum.MfaReauthenticate)) {
return FlowEnum.MfaReauthenticate;
} else if (flows.find((flow: any) => flow.id == FlowEnum.Reauthenticate)) {
return FlowEnum.Reauthenticate;
} else {
// No match found
return null;
}
}
/**
* Extract an error message from an allauth error response.
*
* The allauth flows have a particular response structure,
* which this function handles.
*/
function extractErrorMessage(err: any, defaultMsg: string): string {
const backupMsg = `${defaultMsg}: ${err.status}`;
if (err.response?.data?.errors && err.response?.data?.errors.length > 0) {
return err.response?.data?.errors[0]?.message ?? backupMsg;
}
return backupMsg;
}
type ReauthInputProps = {
label: string;
name: string;
description: string;
url: string;
};
/**
* Internal modal component for re-authentication
*/
function ReauthenticateModalComponent({
inputProps,
setOpen
}: {
inputProps: ReauthInputProps;
setOpen: (open: boolean) => void;
}) {
const { setAuthContext } = useServerApiState.getState();
const [value, setValue] = useState<string>('');
const [error, setError] = useState<string>('');
const onSubmit = useCallback(
(value: string) => {
api
.post(inputProps.url, {
[inputProps.name]: value
})
.then((response) => {
setAuthContext(response.data?.data);
showNotification({
title: t`Reauthentication Succeeded`,
message: t`You have been reauthenticated successfully.`,
color: 'green',
icon: <IconCircleCheck />
});
setOpen(false);
})
.catch((error) => {
setError(
extractErrorMessage(error, t`Error during reauthentication`)
);
showNotification({
title: t`Reauthentication Failed`,
message: `${t`Failed to reauthenticate`}: ${error.status}`,
color: 'red',
icon: <IconExclamationCircle />
});
});
},
[inputProps]
);
return (
<Stack>
<Divider />
<Alert
color='yellow'
icon={<IconExclamationCircle />}
title={t`Reauthenticate`}
>
{t`Reauthentiction is required to continue.`}
</Alert>
<PasswordInput
required
label={inputProps.label}
name={inputProps.name}
description={inputProps.description}
value={value}
error={error}
onChange={(event) => setValue(event.target.value)}
/>
<Group justify='right'>
<Button onClick={() => setOpen(false)} color='red'>
<Trans>Cancel</Trans>
</Button>
<Button onClick={() => onSubmit(value)} color='green'>
<Trans>Submit</Trans>
</Button>
</Group>
</Stack>
);
}
function ReauthenticateModal({
inputProps,
opened,
setOpen
}: {
inputProps: ReauthInputProps;
opened: boolean;
setOpen: (open: boolean) => void;
}) {
return (
<Modal
opened={opened}
zIndex={9999}
size='lg'
onClose={() => setOpen(false)}
title={<StylishText size='lg'>{t`Reauthenticate`}</StylishText>}
>
<ReauthenticateModalComponent inputProps={inputProps} setOpen={setOpen} />
</Modal>
);
}
/**
* Modal for re-authenticating with password
*/
function ReauthenticatePasswordModal({
opened,
setOpen
}: {
opened: boolean;
setOpen: (open: boolean) => void;
}) {
return (
<ReauthenticateModal
opened={opened}
setOpen={setOpen}
inputProps={{
label: t`Password`,
name: 'password',
description: t`Enter your password`,
url: apiUrl(ApiEndpoints.auth_reauthenticate)
}}
/>
);
}
/**
* Modal for re-authenticating with TOTP code
*/
function ReauthenticateTOTPModal({
opened,
setOpen
}: {
opened: boolean;
setOpen: (open: boolean) => void;
}) {
return (
<ReauthenticateModal
opened={opened}
setOpen={setOpen}
inputProps={{
label: t`TOTP Code`,
name: 'code',
description: t`Enter one of your TOTP codes`,
url: apiUrl(ApiEndpoints.auth_mfa_reauthenticate)
}}
/>
);
}
/**
* Modal for removing a registered WebAuthn credential:
* - Deletes the WebAuthn credential from the server
@@ -730,8 +548,6 @@ export default function MFASettings() {
const [webauthnToken, setWebauthnToken] = useState<number | null>(null);
const [recoveryCodesOpen, setRecoveryCodesOpen] = useState<boolean>(false);
const [reauthPassOpen, setReauthPassModalOpen] = useState<boolean>(false);
const [reauthTOTPOpen, setReauthTOTPModalOpen] = useState<boolean>(false);
const [registerTOTPModalOpen, setRegisterTOTPModalOpen] =
useState<boolean>(false);
const [removeTOTPModalOpen, setRemoveTOTPModalOpen] =
@@ -739,20 +555,8 @@ export default function MFASettings() {
const [removeWebauthnModalOpen, setRemoveWebauthnModalOpen] =
useState<boolean>(false);
// Callback function used to re-authenticate the user
const reauthenticate = useCallback((flow: FlowEnum) => {
switch (flow) {
case FlowEnum.Reauthenticate:
setReauthPassModalOpen(true);
break;
case FlowEnum.MfaReauthenticate:
setReauthTOTPModalOpen(true);
break;
default:
// Un-handled reauthentication flow
break;
}
}, []);
// Re-authentication modals and callback
const { reauthenticate, modals: reauthModals } = useReauthenticate();
const registerRecoveryCodes = useCallback(() => {
setRecoveryCodesOpen(true);
@@ -951,14 +755,7 @@ export default function MFASettings() {
onReauthFlow={reauthenticate}
onSuccess={refetch}
/>
<ReauthenticatePasswordModal
opened={reauthPassOpen}
setOpen={setReauthPassModalOpen}
/>
<ReauthenticateTOTPModal
opened={reauthTOTPOpen}
setOpen={setReauthTOTPModalOpen}
/>
{reauthModals}
<SimpleGrid cols={{ xs: 1, md: 2 }} spacing='sm'>
{mfaRows.length > 0 ? (
<Table stickyHeader striped highlightOnHover withTableBorder>
@@ -24,6 +24,7 @@ import { useDisclosure } from '@mantine/hooks';
import { IconAlertTriangle, IconCircleX } from '@tabler/icons-react';
import { useCallback, useMemo, useState } from 'react';
import { api } from '../../App';
import { useReauthenticate } from '../../components/modals/ReauthenticateModal';
import {
BooleanColumn,
UserColumn
@@ -42,12 +43,18 @@ export function ApiTokenTable({
const [revokeTokenId, setRevokeTokenId] = useState<string | null>(null);
const [revocationReason, setRevocationReason] = useState<string>('');
const { handleReauthError, modals: reauthModals } = useReauthenticate();
const generateToken = useCreateApiFormModal({
url: ApiEndpoints.user_me_token,
method: 'GET',
title: t`Generate Token`,
fields: { name: {} },
successMessage: t`Token generated`,
onFormError: (error: any) => {
// MFA users must have recently authenticated to generate a token
handleReauthError(error);
},
onFormSuccess: (data: any) => {
setToken(data.token);
setTokenWarning(data.warning ?? '');
@@ -224,6 +231,7 @@ export function ApiTokenTable({
{only_myself && (
<>
{generateToken.modal}
{reauthModals}
<Modal
opened={opened}
onClose={close}
+1
View File
@@ -164,6 +164,7 @@ export const test = baseTest.extend<TestFixtures, {}>({
!url.includes('/api/auth/v1/auth/login') &&
!url.includes('/api/auth/v1/auth/session') &&
!url.includes('/api/auth/v1/auth/2fa/authenticate') &&
!url.includes('/api/auth/v1/auth/2fa/reauthenticate') &&
!url.includes('/api/auth/v1/auth/provider/signup') &&
!url.includes('/api/auth/v1/account/authenticators/totp') &&
!url.includes('/api/auth/v1/account/password/change') &&
+169 -32
View File
@@ -1,4 +1,4 @@
import type { Page } from '@playwright/test';
import type { Locator, Page } from '@playwright/test';
import { TOTP } from 'otpauth';
import { expect, test } from './baseFixtures.js';
import { engineeruser, logoutUrl, mfauser, noaccessuser } from './defaults.js';
@@ -508,6 +508,76 @@ test('Login - MFA - TOTP', async ({ page }) => {
.waitFor();
});
/**
* Submit a TOTP code for the demo MFA user.
*
* Codes are single-use per period (server side), and the same user may be
* authenticating from another browser project concurrently.
* So, if a code is rejected, wait for the next period and try again.
*
* @param codeInput - The input field for the TOTP code
* @param submitButton - The button which submits the code
* @param successLocator - Visible once the code has been accepted
*/
async function submitTotpCode(
page: Page,
codeInput: Locator,
submitButton: Locator,
successLocator: Locator
) {
const totp = new TOTP({
secret: mfauser.totpSecret,
digits: 6,
period: 30
});
let lastToken = '';
for (let attempt = 0; attempt < 3; attempt++) {
while (totp.generate() == lastToken) {
await page.waitForTimeout(1000);
}
lastToken = totp.generate();
await codeInput.fill(lastToken);
await submitButton.click();
const accepted = await successLocator
.waitFor({ timeout: 5000 })
.then(() => true)
.catch(() => false);
if (accepted) {
return;
}
}
throw new Error('TOTP code was not accepted');
}
/**
* Log in as the demo MFA user (password + TOTP)
*/
async function doMfaLogin(page: Page) {
await page.context().clearCookies();
await navigate(page, logoutUrl, { waitUntil: 'load' });
await page.waitForURL('**/web/login');
await page.getByLabel('login-username').fill(mfauser.username);
await page.getByLabel('login-password').fill(mfauser.testcred);
await page.getByRole('button', { name: 'Log In' }).click();
await page.waitForURL('**/web/mfa');
await submitTotpCode(
page,
page.getByRole('textbox', { name: 'TOTP Code' }),
page.getByRole('button', { name: 'Log in' }),
page.getByRole('button', { name: 'navigation-menu' })
);
}
// Test login for a user with a pre-configured TOTP authenticator
test('Login - MFA - TOTP Login', async ({ page }) => {
await page.context().clearCookies();
@@ -533,39 +603,106 @@ test('Login - MFA - TOTP Login', async ({ page }) => {
// We should still be on the MFA page
await expect(page).toHaveURL(/\/web\/mfa/);
const totp = new TOTP({
secret: mfauser.totpSecret,
digits: 6,
period: 30
});
// Codes are single-use per period (server side), and the same user may be
// logging in from another browser project concurrently.
// So, if a code is rejected, wait for the next period and try again.
let lastToken = '';
for (let attempt = 0; attempt < 3; attempt++) {
while (totp.generate() == lastToken) {
await page.waitForTimeout(1000);
}
lastToken = totp.generate();
await codeInput.fill(lastToken);
await page.getByRole('button', { name: 'Log in' }).click();
const loggedIn = await page
.getByRole('button', { name: 'navigation-menu' })
.waitFor({ timeout: 5000 })
.then(() => true)
.catch(() => false);
if (loggedIn) {
break;
}
}
await submitTotpCode(
page,
codeInput,
page.getByRole('button', { name: 'Log in' }),
page.getByRole('button', { name: 'navigation-menu' })
);
await page.getByRole('tab', { name: 'Dashboard' }).waitFor();
await page.getByRole('button', { name: 'navigation-menu' }).waitFor();
await page.waitForURL(/\/web(\/home)?/);
});
/*
* Regression test: MFA users must re-authenticate to generate an API token,
* once their session is older than the reauthentication timeout.
*
* The server-side timeout (5 minutes) is too long to wait for here,
* so the first token request is answered with the "reauthentication_required"
* response which the server returns for an aged session.
* The reauthentication itself, and the final token request, hit the real server.
* The server-side check is covered by the backend unit tests.
*/
test('Login - MFA - Token Reauthentication', async ({ page }) => {
await doMfaLogin(page);
// Intercept the first token request only
let intercepted = 0;
await page.route('**/api/user/me/token/**', async (route) => {
const request = route.request();
if (request.method() !== 'GET' || intercepted > 0) {
return route.fallback();
}
intercepted += 1;
await route.fulfill({
status: 401,
contentType: 'application/json',
headers: {
'access-control-allow-origin': request.headers()['origin'] ?? '*',
'access-control-allow-credentials': 'true'
},
body: JSON.stringify({
id: 'reauthentication_required',
detail: 'Reauthentication is required to generate a token',
data: {
flows: [
{ id: 'reauthenticate' },
{ id: 'mfa_reauthenticate', types: ['totp', 'recovery_codes'] }
]
}
})
});
});
await navigate(page, 'settings/user/security', { waitUntil: 'networkidle' });
await page.getByRole('button', { name: 'Access Tokens' }).click();
await page
.getByRole('button', { name: 'action-button-generate-token' })
.click();
const tokenForm = page.getByRole('dialog', { name: 'Generate Token' });
const tokenName = `mfa-reauth-${Date.now()}`;
await tokenForm
.getByRole('textbox', { name: 'text-field-name' })
.fill(tokenName);
await tokenForm.getByRole('button', { name: 'Submit', exact: true }).click();
// The form reports the error, and the TOTP reauthentication modal opens
await tokenForm
.getByText('Reauthentication is required to generate a token')
.waitFor();
const reauthModal = page.getByRole('dialog', { name: 'Reauthenticate' });
await reauthModal
.getByText('Reauthentication is required to continue')
.waitFor();
await submitTotpCode(
page,
reauthModal.getByRole('textbox', { name: 'TOTP Code' }),
reauthModal.getByRole('button', { name: 'Submit', exact: true }),
page.getByText('You have been reauthenticated successfully')
);
await reauthModal.waitFor({ state: 'hidden' });
// The token form is still open - submit it again
await tokenForm.getByRole('button', { name: 'Submit', exact: true }).click();
await page.getByText('Tokens are only shown once').waitFor();
expect(intercepted).toBe(1);
// Close the token display, and check the new token is listed
await page
.getByTestId('generated-api-token')
.locator('.mantine-CloseButton-root')
.click();
await page.getByRole('cell', { name: tokenName }).waitFor();
});