diff --git a/docs/docs/api/index.md b/docs/docs/api/index.md index a5e96d4506..bb10237eae 100644 --- a/docs/docs/api/index.md +++ b/docs/docs/api/index.md @@ -51,6 +51,9 @@ Users can authenticate against the API using basic authentication - specifically Basic authentication attempts might run into rate limits during authentication on busy instances, as this is a likely place of attacks. Prefer Token or OAuth2 authentication instead. +!!! warning "Multi factor authentication" + Basic authentication is a single factor, so it is refused for users who have [multi factor authentication](../settings/MFA.md) configured, and for all users while MFA is enforced. These users must use a token (generated in the user interface) or OAuth2 instead. See [error code INVE-W20](../settings/error_codes.md#inve-w20). + ### Tokens Each user is assigned an authentication token which can be used to access the API. This token is persistent for that user (unless invalidated by an administrator) and can be used across multiple sessions. @@ -69,6 +72,11 @@ There is a guided process to generate, view and revoke access tokens in the `Sec Alternatively the API also issues tokens. Perform a GET request to `/api/user/me/token/`. No data are required, but a valid username / password combination must be supplied in the authentication headers. It is recommended to also send a name that identifies the token. The Name is also used for re-issuance of tokens when tokens are re-requested. +!!! warning "Users with multi factor authentication" + Requesting a token with a basic authentication request is not possible for users with [MFA](../settings/MFA.md) configured, or while MFA is enforced (see [Basic Auth](#basic-auth)). These users should generate a token in the `Security` section of the user settings. + + Calls to this endpoint by a user with MFA configured also require a recent login, so a token cannot be used to issue a new token. Otherwise, the server responds with `HTTP 401` and the id `reauthentication_required`. + !!! info "Credentials" Ensure that a valid username:password combination are supplied as a **basic authorization header**. @@ -89,6 +97,9 @@ After reception of a valid authentication token, it can be subsequently used to The token value sent to the server must be of the format `Token ` (without the `<` and `>` characters). +!!! info "Tokens and MFA" + A token is a bearer credential - requests made with a valid token are not challenged for a second factor. While [MFA is enforced](../settings/MFA.md#configuration), tokens belonging to users who have not set up MFA are refused. They work again once the user sets up MFA. + **Example: Javascript** ```javascript var token = "MY-TOKEN-VALUE-HERE"; diff --git a/docs/docs/api/python/index.md b/docs/docs/api/python/index.md index 0229b20439..b833670a1f 100644 --- a/docs/docs/api/python/index.md +++ b/docs/docs/api/python/index.md @@ -44,6 +44,9 @@ MY_PASSWORD = 'not_my_real_password' api = InvenTreeAPI(SERVER_ADDRESS, username=MY_USERNAME, password=MY_PASSWORD) ``` +!!! warning "Multi factor authentication" + Username/password authentication is refused for users with [MFA](../../settings/MFA.md) configured, and for all users while MFA is enforced. Generate a token in the `Security` section of the user settings and use [token auth](#token-auth) instead. + #### Token Auth Alternatively, if you already have an access token: diff --git a/docs/docs/concepts/threat_model.md b/docs/docs/concepts/threat_model.md index 80dc9d566a..fe178464c2 100644 --- a/docs/docs/concepts/threat_model.md +++ b/docs/docs/concepts/threat_model.md @@ -29,7 +29,7 @@ Deploying InvenTree to production requires to knowledge of the security assumpti ## Possible Attack Vectors 1. Malicious plugins or templates can overwrite or delete files on the server, bypass security checks, or leak sensitive information. -2. Token phishing attacks can be used to impersonate users. Tokens are not scoped to specific IPs or devices. Limit their usage and use lowest possible user permissions. +2. Token phishing attacks can be used to impersonate users. Tokens are not scoped to specific IPs or devices. Limit their usage and use lowest possible user permissions. API tokens are bearer credentials and are not challenged for a second factor on use - a leaked token gives access without the user's MFA device until it is revoked or expires. 3. Malicious file uploads. Attachments are served (by default) under the same domain as the backend - this can lead to XSS attacks. There are various checks to gate against common attack vectors but above vectors are explicitly not addressed as they require organisational policies and procedures to mitigate. diff --git a/docs/docs/settings/MFA.md b/docs/docs/settings/MFA.md index ce19f540df..56262a75c3 100644 --- a/docs/docs/settings/MFA.md +++ b/docs/docs/settings/MFA.md @@ -18,4 +18,14 @@ To make MFA mandatory for all users: Password based [basic authentication](../api/index.md#basic-auth) against the API is refused for users with MFA configured, and for all users while MFA is enforced. API clients should use tokens (generated in the user interface) or OAuth2 instead. +While MFA is enforced: + +- Users without MFA must set it up before they can use the web interface or API with their session. +- [API tokens](../api/index.md#tokens) cannot be requested until MFA is set up. +- Existing API tokens of users without MFA are refused. They are not deleted, and work again once the user sets up MFA. + +Enabling enforcement ends all existing user sessions immediately. + +API tokens are bearer credentials: a valid token is not challenged for a second factor on each request. Users with MFA configured can only request a token via the API shortly after logging in (including their second factor). Treat tokens like passwords and revoke any that may have been exposed. Administrators who enable enforcement on an existing instance should review the issued tokens in the [Admin Center](../settings/admin.md#admin-center). + A user can lock themselves out if they lose access to both the device with their TOTP app and their backup tokens. An admin can delete their tokens from the Database Admin interface (they exist under the 'TOTP devices' / 'static devices' models). This should be a last resort and only done by people knowledgeable about the [Database Admin interface](../settings/db_admin.md), as changes there might circumvent InvenTree's business and security logic. diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index bf59311c2e..5a6391c8c7 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,15 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 569 +INVENTREE_API_VERSION = 570 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v570 -> 2026-11-03 : https://github.com/inventree/InvenTree/pull/13175 + - Token API (/api/user/tokens/): "revoked", "revoked_by", "issued_by", "token_version", "revocation_reason" and "last_seen" fields are read-only + - Token API (/api/user/tokens/): "expiry" must be between today and 365 days in the future + v569 -> 2026-10-30 : https://github.com/inventree/InvenTree/security/advisories - Hardens OAuth2 token usage in the API - OAuth2 tokens can not be used to issue or manage API tokens (/api/user/me/token/, /api/user/tokens/) diff --git a/src/backend/InvenTree/InvenTree/middleware.py b/src/backend/InvenTree/InvenTree/middleware.py index 8834a5ebea..c50ac745a2 100644 --- a/src/backend/InvenTree/InvenTree/middleware.py +++ b/src/backend/InvenTree/InvenTree/middleware.py @@ -18,7 +18,6 @@ from error_report.middleware import ExceptionProcessor from rest_framework.exceptions import AuthenticationFailed import InvenTree.helpers -from common.settings import get_global_setting from InvenTree.cache import create_session_cache, delete_session_cache from InvenTree.config import CONFIG_LOOKUPS, inventreeInstaller from InvenTree.version import ( @@ -26,7 +25,7 @@ from InvenTree.version import ( inventreePythonVersion, inventreeVersion, ) -from users.authentication import validate_api_token +from users.authentication import mfa_enforced, validate_api_token logger = structlog.get_logger('inventree') @@ -49,6 +48,32 @@ def get_token_from_request(request): return None +def get_api_token(request): + """Return the validated ApiToken provided in the request headers, if any. + + A valid token is cached on the request as 'request.token'. + This attribute is *only* set once the token has been fully validated. + """ + if token := getattr(request, 'token', None): + return token + + if not (raw_token := get_token_from_request(request)): + return None + + try: + token = validate_api_token(raw_token) + except AuthenticationFailed as exc: + logger.warning( + 'Access denied for token %s: %s', + InvenTree.helpers.sanitize_token(str(raw_token)), + exc.detail, + ) + return None + + request.token = token + return token + + def ensure_slashes(path: str): """Ensure that slashes are surrounding the passed path.""" if not path.startswith('/'): @@ -147,19 +172,7 @@ class AuthRequiredMiddleware: def check_token(self, request) -> bool: """Check if the user is authenticated via token.""" - if token := get_token_from_request(request): - request.token = token - # Does the provided token match a valid (active) user? - try: - token = validate_api_token(token) - except AuthenticationFailed as exc: - logger.warning( - 'Access denied for token %s: %s', - InvenTree.helpers.sanitize_token(str(token)), - exc.detail, - ) - return False - + if token := get_api_token(request): # Provide the user information to the request request.user = token.user return True @@ -271,9 +284,9 @@ class Check2FAMiddleware(MiddlewareMixin): return None if self.is_multifactor_logged_in(request): return None - if getattr( - request, 'token', get_token_from_request(request) - ): # Token based login can not do MFA + # Token based login can not do MFA - but the token must be valid, + # and belong to the same user (otherwise a junk header bypasses MFA) + if (token := get_api_token(request)) and token.user_id == request.user.pk: return None if self.enforce_2fa(request): @@ -282,9 +295,7 @@ class Check2FAMiddleware(MiddlewareMixin): def enforce_2fa(self, request): """Use setting to check if MFA should be enforced.""" - return get_global_setting( - 'LOGIN_ENFORCE_MFA', None, 'INVENTREE_LOGIN_ENFORCE_MFA' - ) + return mfa_enforced() class InvenTreeRemoteUserMiddleware(PersistentRemoteUserMiddleware): diff --git a/src/backend/InvenTree/InvenTree/test_middleware.py b/src/backend/InvenTree/InvenTree/test_middleware.py index 04485d7261..654b389895 100644 --- a/src/backend/InvenTree/InvenTree/test_middleware.py +++ b/src/backend/InvenTree/InvenTree/test_middleware.py @@ -2,6 +2,7 @@ from unittest.mock import patch +from django.contrib.auth.models import User from django.http import Http404, HttpRequest from django.urls import reverse @@ -104,6 +105,59 @@ class MiddlewareTests(InvenTreeTestCase): # Not enforced - token can be generated self.check_path(url) + def test_Check2FAMiddleware_token_header(self): + """Test that a token header only bypasses MFA if it validly authenticates the same user.""" + target = reverse('api-license') + token_url = reverse('api-token') + + other_user = User.objects.create_user(username='other', password='password') + other_token = ApiToken.objects.create(user=other_user, name='other').token + + with patch.dict('os.environ', {'INVENTREE_LOGIN_ENFORCE_MFA': 'True'}): + for header in [ + 'Bearer junk', + 'token: junk', + 'Token junk', + f'Token {other_token}', + f'Bearer {other_token}', + ]: + response = self.check_path(target, 401, auth_header=header) + self.assertContains(response, 'mfa_register', status_code=401) + + # Token issuance must not be reachable either + response = self.check_path(token_url, 401, auth_header=header) + self.assertContains(response, 'mfa_register', status_code=401) + + self.assertFalse(ApiToken.objects.filter(user=self.user).exists()) + + def test_token_auth_mfa_enforced(self): + """Test that tokens of users without MFA are refused while MFA is enforced.""" + api_target = reverse('api-license') + target = reverse('auth-check') + + raw_token = ApiToken.objects.create(user=self.user, name='mfa-test').token + self.client.logout() + + self.check_path(api_target, auth_header=f'Token {raw_token}') + self.check_path(target, auth_header=f'Token {raw_token}') + + with patch.dict('os.environ', {'INVENTREE_LOGIN_ENFORCE_MFA': 'True'}): + # User has no MFA set up - token is refused + response = self.check_path( + api_target, 401, auth_header=f'Token {raw_token}' + ) + self.assertContains( + response, + 'Multi-factor authentication must be enabled for this account', + status_code=401, + ) + self.check_path(target, 401, auth_header=f'Token {raw_token}') + + # User sets up MFA - token works again + get_codes(self.user) + self.check_path(api_target, auth_header=f'Token {raw_token}') + self.check_path(target, auth_header=f'Token {raw_token}') + def test_token_auth(self): """Test auth with token auth.""" target = reverse('api-license') diff --git a/src/backend/InvenTree/common/setting/system.py b/src/backend/InvenTree/common/setting/system.py index a14aad8c34..c9c3e8b28c 100644 --- a/src/backend/InvenTree/common/setting/system.py +++ b/src/backend/InvenTree/common/setting/system.py @@ -1192,7 +1192,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'validator': bool, 'confirm': True, 'confirm_text': _( - 'Enabling this setting will require all users to set up multifactor authentication. All sessions will be disconnected immediately.' + 'Enabling this setting will require all users to set up multifactor authentication. All sessions will be disconnected immediately, and API tokens of users without multifactor authentication will stop working.' ), 'flags': [SettingFlag.SECURITY], 'after_save': enforce_mfa, diff --git a/src/backend/InvenTree/users/authentication.py b/src/backend/InvenTree/users/authentication.py index 7591e083aa..9aa8214b9f 100644 --- a/src/backend/InvenTree/users/authentication.py +++ b/src/backend/InvenTree/users/authentication.py @@ -13,21 +13,26 @@ import users.models from common.settings import get_global_setting +def mfa_enforced() -> bool: + """Check if MFA is enforced globally for all users.""" + return bool( + settings.MFA_ENABLED + and get_global_setting('LOGIN_ENFORCE_MFA', None, 'INVENTREE_LOGIN_ENFORCE_MFA') + ) + + def password_auth_allowed(user) -> bool: """Check if a password alone is allowed to authenticate the user.""" if is_mfa_enabled(user): return False - return not ( - settings.MFA_ENABLED - and get_global_setting('LOGIN_ENFORCE_MFA', None, 'INVENTREE_LOGIN_ENFORCE_MFA') - ) + return not mfa_enforced() class InvenTreeBasicAuthentication(BasicAuthentication): """Custom basic auth class. Checks: - - If MFA is required and dissalows basic auth + - If MFA is required and disallows basic auth """ def authenticate_credentials(self, userid, password, request=None): @@ -49,7 +54,8 @@ def validate_api_token(raw_token: str) -> users.models.ApiToken: used by both the DRF authentication class and the middleware stack. Raises: - AuthenticationFailed: If the token is unknown, revoked, expired, or belongs to an inactive user + AuthenticationFailed: If the token is unknown, revoked, expired, belongs to an inactive user, + or belongs to a user without MFA while MFA is enforced """ token = users.models.ApiToken.get_from_string(raw_token) if raw_token else None @@ -65,6 +71,12 @@ def validate_api_token(raw_token: str) -> users.models.ApiToken: if token.expired: raise exceptions.AuthenticationFailed(_('Token has expired')) + # Tokens issued before MFA was enforced must not outlive the enforcement + if mfa_enforced() and not is_mfa_enabled(token.user): + raise exceptions.AuthenticationFailed( + _('Multi-factor authentication must be enabled for this account') + ) + today = InvenTree.helpers.current_date() if token.last_seen != today: diff --git a/src/backend/InvenTree/users/serializers.py b/src/backend/InvenTree/users/serializers.py index 11a1302e6c..c9d5a93926 100644 --- a/src/backend/InvenTree/users/serializers.py +++ b/src/backend/InvenTree/users/serializers.py @@ -10,13 +10,21 @@ from django.utils.translation import gettext_lazy as _ from rest_framework import serializers from rest_framework.exceptions import PermissionDenied +import InvenTree.helpers from InvenTree.serializers import ( FilterableSerializerMixin, InvenTreeModelSerializer, OptionalField, ) -from .models import ApiToken, Owner, RuleSet, TipResult, UserProfile +from .models import ( + ApiToken, + Owner, + RuleSet, + TipResult, + UserProfile, + default_token_expiry, +) from .permissions import check_user_role, prefetch_rule_sets from .ruleset import RULESET_CHOICES, RULESET_PERMISSIONS, RuleSetEnum @@ -259,6 +267,26 @@ class ApiTokenSerializer(InvenTreeModelSerializer): 'token_version', 'revocation_reason', ] + read_only_fields = [ + 'last_seen', + 'revoked', + 'revoked_by', + 'issued_by', + 'token_version', + 'revocation_reason', + ] + + def validate_expiry(self, expiry): + """Ensure that the expiry date is within the allowed token lifetime.""" + if expiry < InvenTree.helpers.current_date(): + raise serializers.ValidationError(_('Expiry date cannot be in the past')) + + if expiry > default_token_expiry(): + raise serializers.ValidationError( + _('Expiry date cannot be more than 365 days in the future') + ) + + return expiry def validate(self, data): """Validate the data for the serializer.""" diff --git a/src/backend/InvenTree/users/test_api.py b/src/backend/InvenTree/users/test_api.py index 3345438063..9a65617ca4 100644 --- a/src/backend/InvenTree/users/test_api.py +++ b/src/backend/InvenTree/users/test_api.py @@ -8,6 +8,7 @@ from django.urls import reverse from allauth.account.models import EmailAddress +import InvenTree.helpers from common.models import NotificationMessage from InvenTree.unit_test import InvenTreeAPITestCase from users.models import ApiToken, default_token @@ -720,6 +721,50 @@ class UserTokenTests(InvenTreeAPITestCase): self.assertEqual(ApiToken.objects.count(), 1) + def test_token_create_fields(self): + """Test that protected token fields cannot be set when creating a token.""" + url = reverse('api-token-list') + today = InvenTree.helpers.current_date() + other = User.objects.create_user(username='other', password='password') + + # Expiry date must be within the allowed token lifetime + for expiry in [today - datetime.timedelta(days=1), datetime.date(9999, 12, 31)]: + response = self.post( + url, data={'name': 'test', 'expiry': str(expiry)}, expected_code=400 + ) + self.assertIn('expiry', response.data) + + for expiry in [today, today + datetime.timedelta(days=365)]: + response = self.post( + url, + data={'name': f'test-{expiry}', 'expiry': str(expiry)}, + expected_code=201, + ) + self.assertEqual(response.data['expiry'], str(expiry)) + + # Read-only fields are ignored + response = self.post( + url, + data={ + 'name': 'protected', + 'revoked': True, + 'revoked_by': other.pk, + 'issued_by': other.pk, + 'revocation_reason': 'forged', + 'token_version': 1, + 'last_seen': str(today), + }, + expected_code=201, + ) + + token = ApiToken.objects.get(pk=response.data['id']) + self.assertFalse(token.revoked) + self.assertIsNone(token.revoked_by) + self.assertEqual(token.issued_by, self.user) + self.assertIsNone(token.revocation_reason) + self.assertEqual(token.token_version, 2) + self.assertIsNone(token.last_seen) + 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