mirror of
https://github.com/inventree/InvenTree.git
synced 2026-11-12 03:40:30 +00:00
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:
1 parent
0cb1852e0d
commit
02d6a038bf
8 files changed
+576
-254
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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') &&
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user