diff --git a/CHANGELOG.md b/CHANGELOG.md index 04db896c3c..6cc02cc7db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [#11971](https://github.com/inventree/InvenTree/pull/11971) is a major refactor of how notes are handled. Notes are now stored in a separate database table (in line with how attachments are handled), and each model instance can have multiple notes associated with it. The `notes` field has been removed from the individual models (and their associated API endpoints), and notes are now accessed via the new `/api/note/` endpoint. Existing notes data (and any embedded images) are automatically migrated to the new notes table, with the markdown content converted to HTML. Any external client applications which read or write the `notes` field via the API will need to be updated to use the new endpoint. - [#12507](https://github.com/inventree/InvenTree/pull/12507) calling an invalid or repeated state transition now raises a ValidationError. Plugins implementing state transitions should evaluate the PR and adapt their usage of transitions to gain the new safeguards. - [#12672](https://github.com/inventree/InvenTree/pull/12672) renames the newly added `tags` filter from 1.4.0 (https://github.com/inventree/InvenTree/pull/12077) to `tag_name` to remove a nameclash. +- [#12850](https://github.com/inventree/InvenTree/pull/12850) Tokens are now issued in the v2 format and might be longer. Old tokens continue to work but should be considered for rotation ### Added diff --git a/docs/docs/api/index.md b/docs/docs/api/index.md index c651ef7876..55a8027c29 100644 --- a/docs/docs/api/index.md +++ b/docs/docs/api/index.md @@ -34,7 +34,13 @@ invoke dev.schema -help ## Authentication -Users must be authenticated to gain access to the InvenTree API. The API accepts either basic username:password authentication, or token authentication. Token authentication is recommended as it provides much faster API access. +Users must be authenticated to gain access to the InvenTree API. The API accepts either: + +- basic username:password authentication +- bearer token authentication +- OAuth2 authentication with scoped tokens + +Token authentication is recommended as it provides much faster API access and is persistent when users change authentication methods, multifactor setups or passwords. !!! warning "Permissions" API access is restricted based on the permissions assigned to the user or scope of the application. @@ -43,6 +49,8 @@ Users must be authenticated to gain access to the InvenTree API. The API accepts Users can authenticate against the API using basic authentication - specifically a valid combination of `username` and `password` credentials. +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. + ### 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. @@ -52,12 +60,17 @@ Each user is assigned an authentication token which can be used to access the AP #### Requesting a Token -If a user does not know their access token, it can be requested via the API interface itself, using a basic authentication request. +If a user does not already have an access token, they can request one via the user interface under the security user settings or the API interface, using a basic authentication request. -To obtain a valid token, 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. +!!! warning "Tokens are only available once" + Regardless of the request path used to obtain the token, it will only be displayed or provided once. Ensure that you copy and store it securely when it is first issued. Requesting a token with the same name will re-issue a new token, invalidating all previous ones of the same name. + +There is a guided process to generate, view and revoke access tokens in the `Security` section of the user settings. + +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. !!! info "Credentials" - Ensure that a valid username:password combination are supplied as basic authorization headers. + Ensure that a valid username:password combination are supplied as a **basic authorization header**. Once a valid token is received from the server, subsequent API requests should be performed using that token. @@ -96,9 +109,19 @@ data = { ... } headers = { 'AUTHORIZATION': f'Token {token}' } -response = request.get('http://localhost:8080/api/part/', data=data, headers=headers) +response = requests.get('http://localhost:8080/api/part/', data=data, headers=headers) ``` +### Token generation / version + +Starting with InvenTree 1.6.0, API tokens are generated in the v2 format. While most mechanisms are the same as the previous version, storage and handling of tokens was hardened. This results in token secret values not being stored anymore anywhere. They are only available in a variable immediately after creation. Storage of tokens is using one-way HMAC hashing. To protect against rainbow table attacks in case of a database breach, hashing is done with the addition of a cryptographic pepper that is calculated based on the [SECRET_KEY](../start/config.md#secret-key-material). + +!!! warning "Secure your cryptographic keys" + To enable usage of tokens in case of a database recovery on a new instance, it is very important that you also restore the cryptographic keys, including the [SECRET_KEY](../start/config.md#secret-key-material). All v2 access tokens will need to be re-issued if the cryptographic keys are not restored as they can not be validated without the correct material. + +!!! warning "Rotating cryptographic material can have availability implications" + Rotating cryptographic keys, including the [SECRET_KEY](../start/config.md#secret-key-material), will render existing v2 tokens invalid. Ensure that you understand the impact on token-based authentication before performing key rotation. + ### oAuth2 and OIDC InvenTree has built-in support for using [oAuth2](https://oauth.net/2/) and OpenID Connect (OIDC) for authentication to the API. This enables using the instance as a *very limited* identity provider. diff --git a/src/backend/InvenTree/InvenTree/api.py b/src/backend/InvenTree/InvenTree/api.py index e738d62881..6a30d8a722 100644 --- a/src/backend/InvenTree/InvenTree/api.py +++ b/src/backend/InvenTree/InvenTree/api.py @@ -351,10 +351,9 @@ class InfoView(APIView): if token := get_token_from_request(request): # Does the provided token match a valid user? try: - token = ApiToken.objects.get(key=token) - + token = ApiToken.get_from_string(token) # Check if the token is active and the user is a staff member - if token.active and token.user and token.user.is_staff: + if token and token.active and token.user and token.user.is_staff: return True except ApiToken.DoesNotExist: pass diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 1f3a122447..626de8847b 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,12 +1,15 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 546 +INVENTREE_API_VERSION = 547 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ -v546 -> 2026-09-12 : https://github.com/inventree/InvenTree/pull/12842/changes +v547 -> 2026-09-12 : https://github.com/inventree/InvenTree/pull/12850 + - Added more details to API token management + +v546 -> 2026-09-12 : https://github.com/inventree/InvenTree/pull/12842 - Added setting flags to the settings APIs (read-only) v545 -> 2026-09-08 : https://github.com/inventree/InvenTree/pull/12808 diff --git a/src/backend/InvenTree/InvenTree/helpers.py b/src/backend/InvenTree/InvenTree/helpers.py index c4f10fe742..75b0882544 100644 --- a/src/backend/InvenTree/InvenTree/helpers.py +++ b/src/backend/InvenTree/InvenTree/helpers.py @@ -1150,3 +1150,8 @@ def sanitize_token(token_value: str, front=8, back=12) -> str: """ middle = len(token_value) - (front + back) return token_value[:front] + '*' * middle + token_value[-back:] + + +def get_api_token_pepper() -> str: + """Return the secret 'pepper' used to compute v2 tokens.""" + return settings.SECRET_KEY diff --git a/src/backend/InvenTree/InvenTree/middleware.py b/src/backend/InvenTree/InvenTree/middleware.py index 7907eb1b80..40da4e1c98 100644 --- a/src/backend/InvenTree/InvenTree/middleware.py +++ b/src/backend/InvenTree/InvenTree/middleware.py @@ -150,9 +150,9 @@ class AuthRequiredMiddleware: request.token = token # Does the provided token match a valid user? try: - token = ApiToken.objects.get(key=token) + token = ApiToken.get_from_string(token) - if token.active and token.user: + if token and token.active and token.user: # Provide the user information to the request request.user = token.user return True diff --git a/src/backend/InvenTree/users/api.py b/src/backend/InvenTree/users/api.py index 135f95290e..df9dca3186 100644 --- a/src/backend/InvenTree/users/api.py +++ b/src/backend/InvenTree/users/api.py @@ -24,6 +24,7 @@ import InvenTree.permissions from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration from InvenTree.filters import SEARCH_ORDER_FILTER from InvenTree.mixins import ( + CleanBase, ListAPI, ListCreateAPI, OutputOptionsMixin, @@ -389,30 +390,43 @@ class GetAuthToken(GenericAPIView): - Existing tokens are *never* exposed again via the API - Once the token is provided, it can be used for auth until it expires """ - if not request.user.is_authenticated: - raise exceptions.NotAuthenticated() # pragma: no cover - user = request.user name = request.query_params.get('name', '') + if not user.is_authenticated: + raise exceptions.NotAuthenticated() # pragma: no cover + name = ApiToken.sanitize_name(name) today = datetime.date.today() + reissue_token = request.resolver_match.url_name == 'api-token' - # Find existing token, which has not expired token = ApiToken.objects.filter( user=user, name=name, revoked=False, expiry__gte=today ).first() - if not token: + if token and reissue_token: + token.revoked = True + token.revoked_by = user + token.revocation_reason = ( + 're-issued due to new token request to API with same name' + ) + token.save(update_fields=['revoked', 'revoked_by', 'revocation_reason']) + + if not token or reissue_token: # User is authenticated, and requesting a token against the provided name. - token = ApiToken.objects.create(user=request.user, name=name) + token = ApiToken.objects.create(user=user, name=name, issued_by=user) logger.info( "Created new API token for user '%s' (name='%s')", user.username, name ) - # Add some metadata about the request + if token.token_version == 2 and token.hmac_digest and not token._raw_secret: + raise exceptions.ValidationError( + 'Token is not newly created.' + ) # pragma: no cover + + # Add some metadata about the request token.set_metadata('user_agent', request.headers.get('user-agent', '')) token.set_metadata('remote_addr', request.META.get('REMOTE_ADDR', '')) token.set_metadata('remote_host', request.META.get('REMOTE_HOST', '')) @@ -420,7 +434,11 @@ class GetAuthToken(GenericAPIView): token.set_metadata('server_name', request.META.get('SERVER_NAME', '')) token.set_metadata('server_port', request.META.get('SERVER_PORT', '')) - data = {'token': token.key, 'name': token.name, 'expiry': token.expiry} + data = { + 'token': token.token if token.token_version == 2 else token.key, + 'name': token.name, + 'expiry': token.expiry, + } # Ensure that the users session is logged in if not get_user(request).is_authenticated: @@ -468,17 +486,32 @@ class TokenListView(TokenMixin, ListCreateAPI): 'user__first_name', 'user__last_name', 'user__email', + 'revocation_reason', ] - ordering_fields = ['created', 'expiry', 'last_seen', 'user', 'name', 'revoked'] - filterset_fields = ['revoked', 'user'] + ordering_fields = [ + 'created', + 'expiry', + 'last_seen', + 'user', + 'name', + 'revoked', + 'revoked_by', + 'issued_by', + 'token_version', + 'revocation_reason', + ] + filterset_fields = ['revoked', 'user', 'issued_by', 'revoked_by'] queryset = ApiToken.objects.none() + def perform_create(self, serializer): + """Save the new token and keep the secret (only available immediately after creation).""" + serializer.save(issued_by=self.request.user) + self._created_token = serializer.instance + def create(self, request, *args, **kwargs): """Create token and show key to user.""" resp = super().create(request, *args, **kwargs) - resp.data['token'] = self.serializer_class.Meta.model.objects.get( - id=resp.data['id'] - ).key + resp.data['token'] = self._created_token.token return resp def get(self, request, *args, **kwargs): @@ -486,13 +519,32 @@ class TokenListView(TokenMixin, ListCreateAPI): return super().get(request, *args, **kwargs) -class TokenDetailView(TokenMixin, DestroyAPIView, RetrieveAPI): +class TokenDetailView(CleanBase, TokenMixin, DestroyAPIView, RetrieveAPI): """Details for a user token.""" + @extend_schema( + parameters=[ + OpenApiParameter( + name='revocation_reason', + type=str, + description='Reason for revoking the token.', + default='', + ) + ] + ) + def delete(self, request, *args, **kwargs): + """Revoke this specific user token.""" + return super().delete(request, *args, **kwargs) + def perform_destroy(self, instance): """Revoke token.""" instance.revoked = True - instance.save() + instance.revoked_by = self.request.user + request_data = getattr(self.request, 'data', {}) + instance.revocation_reason = self.clean_string( + 'revocation_reason', str(request_data.get('revocation_reason', '')) + ) + instance.save(update_fields=['revoked', 'revoked_by', 'revocation_reason']) class LoginRedirect(RedirectView): diff --git a/src/backend/InvenTree/users/authentication.py b/src/backend/InvenTree/users/authentication.py index 7b486af2c0..0903553989 100644 --- a/src/backend/InvenTree/users/authentication.py +++ b/src/backend/InvenTree/users/authentication.py @@ -24,7 +24,13 @@ class ApiTokenAuthentication(TokenAuthentication): def authenticate_credentials(self, key): """Adds additional checks to the default token authentication method.""" # If this runs without error, then the token is valid (so far) - (user, token) = super().authenticate_credentials(key) + token = self.model.get_from_string(key) + if token is None: + raise exceptions.AuthenticationFailed(_('Invalid token.')) + + user = token.user + if not user.is_active: + raise exceptions.AuthenticationFailed(_('User inactive or deleted.')) if token.revoked: raise exceptions.AuthenticationFailed(_('Token has been revoked')) diff --git a/src/backend/InvenTree/users/migrations/0017_apitoken_hmac_digest_apitoken_issued_by_and_more.py b/src/backend/InvenTree/users/migrations/0017_apitoken_hmac_digest_apitoken_issued_by_and_more.py new file mode 100644 index 0000000000..989ccae1e2 --- /dev/null +++ b/src/backend/InvenTree/users/migrations/0017_apitoken_hmac_digest_apitoken_issued_by_and_more.py @@ -0,0 +1,59 @@ +# Generated by Django 5.2.17 on 2026-09-15 19:59 + +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +def mark_existing_tokens_as_v1(apps, schema_editor): + """Mark tokens created before v2 token support as legacy v1 tokens.""" + ApiToken = apps.get_model('users', 'ApiToken') + ApiToken.objects.all().update(token_version=1) + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0016_remove_legacy_user_sessions_table'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='apitoken', + name='hmac_digest', + field=models.CharField(blank=True, max_length=200, null=True), + ), + migrations.AddField( + model_name='apitoken', + name='issued_by', + field=models.ForeignKey(blank=True, help_text='User who issued the token', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issued_api_tokens', to=settings.AUTH_USER_MODEL, verbose_name='Issued By'), + ), + migrations.AddField( + model_name='apitoken', + name='pepper_id', + field=models.CharField(blank=True, help_text='Identifier for the pepper used in token hashing', max_length=100, null=True, verbose_name='Pepper ID'), + ), + migrations.AddField( + model_name='apitoken', + name='revocation_reason', + field=models.TextField(blank=True, help_text='As entered by the user or action during revocation', null=True, verbose_name='Revocation Reason'), + ), + migrations.AddField( + model_name='apitoken', + name='revoked_by', + field=models.ForeignKey(blank=True, help_text='User who revoked the token', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='revoked_api_tokens', to=settings.AUTH_USER_MODEL, verbose_name='Revoked By'), + ), + migrations.AddField( + model_name='apitoken', + name='token_version', + field=models.PositiveSmallIntegerField(default=2, help_text='Version of the API token', verbose_name='Token Version'), + ), + migrations.AlterField( + model_name='apitoken', + name='key', + field=models.CharField(blank=True, db_index=True, max_length=100, unique=True, validators=[django.core.validators.MinLengthValidator(8)], verbose_name='Key'), + ), + migrations.RunPython(mark_existing_tokens_as_v1, migrations.RunPython.noop), + ] diff --git a/src/backend/InvenTree/users/models.py b/src/backend/InvenTree/users/models.py index d37bf5734e..cc283874fe 100644 --- a/src/backend/InvenTree/users/models.py +++ b/src/backend/InvenTree/users/models.py @@ -1,6 +1,10 @@ """Database model definitions for the 'users' app.""" import datetime +import hashlib +import hmac +import secrets +from typing import Optional from django.conf import settings from django.contrib import admin @@ -68,6 +72,12 @@ if settings.LDAP_AUTH: # pragma: no cover EmailAddress.objects.create(user=user, email=user.email, primary=True) +API_TOKEN_PREFIX = 'inv-2-' +API_TOKEN_IDENTIFIER_LENGTH = 20 +API_TOKEN_SECRET_LENGTH = 40 + + +# legacy TODO @matmair remove in the next breaking def default_token(): """Generate a default value for the token.""" return ApiToken.generate_key() @@ -84,6 +94,10 @@ class ApiToken(AuthToken, InvenTree.models.MetadataMixin): Extensions: - Adds an 'expiry' date - tokens can be set to expire after a certain date - Adds a 'name' field - tokens can be given a custom name (in addition to the user information) + + Token storage: + - Old v1 tokens store the raw plaintext token value directly in the 'key' field. + - Current v2 tokens only store a identifier in the 'key' field, and the secret is only persisted as hmac digest """ class Meta: @@ -93,28 +107,143 @@ class ApiToken(AuthToken, InvenTree.models.MetadataMixin): verbose_name_plural = _('API Tokens') abstract = False + _raw_secret = None # Temp storage for secret + def __str__(self): """String representation uses the redacted token.""" return self.token @classmethod def generate_key(cls, prefix='inv-'): - """Generate a new token key - with custom prefix.""" + """Generate a new old token key - with custom prefix.""" # Suffix is the date of creation suffix = '-' + str(datetime.datetime.now().date().isoformat().replace('-', '')) return prefix + str(AuthToken.generate_key()) + suffix - # Override the 'key' field - force it to be unique + def generate_v2_token(self) -> None: + """Generate new v2 token.""" + identifier = secrets.token_hex(API_TOKEN_IDENTIFIER_LENGTH // 2) + secret = secrets.token_hex(API_TOKEN_SECRET_LENGTH // 2) + suffix = '-' + str(datetime.datetime.now().date().isoformat().replace('-', '')) + + self.key = identifier + self.hmac_digest = self.calculate_digest(secret) + self._raw_secret = f'{API_TOKEN_PREFIX}{identifier}.{secret}{suffix}' + + # metadata + self.token_version = 2 + self.pepper_id = self.calculate_pepper_id() + + @staticmethod + def calculate_digest(secret: str) -> str: + """Calculate the HMAC digest of the provided secret.""" + pepper = InvenTree.helpers.get_api_token_pepper() + return hmac.new( + pepper.encode('utf-8'), secret.encode('utf-8'), hashlib.sha256 + ).hexdigest() + + @classmethod + def calculate_pepper_id(cls, length=8) -> str: + """Calculate the first 8 characters of the current pepper hashed.""" + pepper = InvenTree.helpers.get_api_token_pepper() + return hashlib.sha256(pepper.encode('utf-8')).hexdigest()[:length] + + @staticmethod + def split_token(raw_token: str): + """Split a raw v2 token value into the required values.""" + if not raw_token: + return None # pragma: no cover + + value = ( + raw_token[len(API_TOKEN_PREFIX) :] + if raw_token.startswith(API_TOKEN_PREFIX) + else raw_token + ) + + if '.' not in value: + return None + + identifier, _sep, secret = value.partition('.') + if not identifier or not secret: + return None # pragma: no cover + + secret_parts = secret.rsplit('-', 1) + if ( + len(secret_parts) == 2 + and len(secret_parts[1]) == 8 + and secret_parts[1].isdigit() + ): + secret = secret_parts[0] + + return identifier, secret + + def match(self, raw_token: str) -> bool: + """Lightweight check for whether raw_token refers to *this* token instance.""" + if not raw_token: + return False # pragma: no cover + + # is a v2 token + if self.hmac_digest: + parts = self.split_token(raw_token) + return bool(parts) and parts[0] == self.key + + return raw_token == self.key + + def validate(self, raw_token: str) -> bool: + """Validate a raw token value against this token.""" + if not raw_token: + return False # pragma: no cover + + # is a v2 token + if self.hmac_digest: + parts = self.split_token(raw_token) + + # check id + if not parts or parts[0] != self.key: + return False # pragma: no cover + # check secret + return hmac.compare_digest( + self.calculate_digest(parts[1]), self.hmac_digest + ) + + # should be a v1 - compare directly + return raw_token == self.key + + @classmethod + def lookup(cls, raw_token: str) -> 'ApiToken': + """Look up an ApiToken instance from a token string (v1 or v2). Not a full validation.""" + parts = cls.split_token(raw_token) + return cls.objects.select_related('user').get( + key=parts[0] if parts else raw_token + ) + + @classmethod + def get_from_string(cls, raw_token: str) -> Optional['ApiToken']: + """Look up and fully validate an ApiToken from a raw token string.""" + try: + token = cls.lookup(raw_token) + except cls.DoesNotExist: + return None + + if not token.validate(raw_token): + return None # pragma: no cover + + return token + + # in v1: private token; in v2: public identifier key = models.CharField( - default=default_token, verbose_name=_('Key'), db_index=True, unique=True, max_length=100, - validators=[MinLengthValidator(50)], + blank=True, + validators=[MinLengthValidator(8)], ) + # in v2: HMAC digest of token; empty in v1 + hmac_digest = models.CharField(max_length=200, blank=True, null=True) + # Override the 'user' field, to allow multiple tokens per user user = models.ForeignKey( settings.AUTH_USER_MODEL, @@ -149,9 +278,51 @@ class ApiToken(AuthToken, InvenTree.models.MetadataMixin): default=False, verbose_name=_('Revoked'), help_text=_('Token has been revoked') ) + revocation_reason = models.TextField( + blank=True, + null=True, + verbose_name=_('Revocation Reason'), + help_text=_('As entered by the user or action during revocation'), + ) + + revoked_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + blank=True, + null=True, + verbose_name=_('Revoked By'), + help_text=_('User who revoked the token'), + related_name='revoked_api_tokens', + ) + + issued_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + blank=True, + null=True, + verbose_name=_('Issued By'), + help_text=_('User who issued the token'), + related_name='issued_api_tokens', + ) + + token_version = models.PositiveSmallIntegerField( + default=2, + verbose_name=_('Token Version'), + help_text=_('Version of the API token'), + ) + + pepper_id = models.CharField( + max_length=100, + blank=True, + null=True, + verbose_name=_('Pepper ID'), + help_text=_('Identifier for the pepper used in token hashing'), + ) + """By default, the first 8 characters of the pepper hashed.""" + @staticmethod def sanitize_name(name: str) -> str: - """Sanitize the provide name value.""" + """Sanitize the provided name value.""" name = str(name).strip() # Remove any non-printable chars @@ -166,6 +337,17 @@ class ApiToken(AuthToken, InvenTree.models.MetadataMixin): return name + def save(self, *args, **kwargs): + """Patch in token generations.""" + if self._state.adding and not self.key and not self.hmac_digest: + if self.token_version != 2: + raise ValueError( + 'Unsupported token version and scenario' + ) # pragma: no cover + self.generate_v2_token() + + super().save(*args, **kwargs) + @property @admin.display(description=_('Token')) def token(self) -> str: @@ -173,10 +355,18 @@ class ApiToken(AuthToken, InvenTree.models.MetadataMixin): The *raw* key value should never be displayed anywhere! """ - # If the token has not yet been saved, return the raw key + # on generation: v2 token + if self._raw_secret: + return self._raw_secret + + # on generation: v1 if self.pk is None: return self.key # pragma: no cover + # v2 default path + if self.hmac_digest: + return f'{API_TOKEN_PREFIX}{self.key}.{"*" * API_TOKEN_SECRET_LENGTH}' + return InvenTree.helpers.sanitize_token(self.key) @property diff --git a/src/backend/InvenTree/users/serializers.py b/src/backend/InvenTree/users/serializers.py index 1d0b20064c..404f8f94cd 100644 --- a/src/backend/InvenTree/users/serializers.py +++ b/src/backend/InvenTree/users/serializers.py @@ -222,7 +222,7 @@ class ApiTokenSerializer(InvenTreeModelSerializer): request = self.context.get('request') rq_token = get_token_from_request(request) - return token.key == rq_token + return token.match(rq_token) class Meta: """Meta options for ApiTokenSerializer.""" @@ -240,6 +240,12 @@ class ApiTokenSerializer(InvenTreeModelSerializer): 'user', 'user_detail', 'in_use', + 'revoked_by', + 'revoked_by_detail', + 'issued_by', + 'issued_by_detail', + 'token_version', + 'revocation_reason', ] def validate(self, data): @@ -262,6 +268,8 @@ class ApiTokenSerializer(InvenTreeModelSerializer): return super().validate(data) user_detail = UserSerializer(source='user', read_only=True) + revoked_by_detail = UserSerializer(source='revoked_by', read_only=True) + issued_by_detail = UserSerializer(source='issued_by', read_only=True) class GroupSerializer(FilterableSerializerMixin, InvenTreeModelSerializer): diff --git a/src/backend/InvenTree/users/test_api.py b/src/backend/InvenTree/users/test_api.py index 6861c5f08a..3088d7a55c 100644 --- a/src/backend/InvenTree/users/test_api.py +++ b/src/backend/InvenTree/users/test_api.py @@ -9,7 +9,7 @@ from django.urls import reverse from allauth.account.models import EmailAddress from InvenTree.unit_test import InvenTreeAPITestCase -from users.models import ApiToken +from users.models import ApiToken, default_token from users.ruleset import RULESET_NAMES, get_ruleset_models @@ -406,20 +406,20 @@ class UserTokenTests(InvenTreeAPITestCase): # Request the token with the same name data = self.get(url, data={'name': 'cat'}, expected_code=200).data - self.assertEqual(data['token'], token.key) + token.refresh_from_db() + self.assertNotEqual(data['token'], token.key) + self.assertTrue(data['token'].startswith('inv-2-')) + self.assertTrue(token.revoked) - self.assertEqual(ApiToken.objects.count(), 3) - - # Revoke the token, and then request again - token.revoked = True - token.save() + self.assertEqual(ApiToken.objects.count(), 4) + # Request again, which issues another replacement token data = self.get(url, data={'name': 'cat'}, expected_code=200).data self.assertNotEqual(data['token'], token.key) # A new token has been generated - self.assertEqual(ApiToken.objects.count(), 4) + self.assertEqual(ApiToken.objects.count(), 5) # Test with a really long name data = self.get(url, data={'name': 'cat' * 100}, expected_code=200).data @@ -460,7 +460,7 @@ class UserTokenTests(InvenTreeAPITestCase): # Grab the token, and update token = ApiToken.objects.first() assert token - self.assertEqual(token.key, token_key) + self.assertEqual(token.key, ApiToken.split_token(token_key)[0]) self.assertIsNotNone(token.last_seen) # Revoke the token @@ -501,7 +501,7 @@ class UserTokenTests(InvenTreeAPITestCase): url=reverse('api-token'), data={'name': 'race'}, expected_code=200 ).data['token'] - token = ApiToken.objects.get(key=token_key) + token = ApiToken.objects.get(key=ApiToken.split_token(token_key)[0]) # Force last_seen to be 'stale' so the auth backend attempts to update it ApiToken.objects.filter(pk=token.pk).update( @@ -537,24 +537,38 @@ class UserTokenTests(InvenTreeAPITestCase): # Get token response = self.get(reverse('api-token'), expected_code=200) self.assertIn('token', response.data) + raw_token = response.data['token'] + + self.client.logout() + self.client.credentials(HTTP_AUTHORIZATION=f'Token {raw_token}') # Now there should be one token response = self.get(url, expected_code=200) self.assertEqual(len(response.data), 1) self.assertEqual(response.data[0]['active'], True) self.assertEqual(response.data[0]['revoked'], False) - self.assertEqual(response.data[0]['in_use'], False) + self.assertEqual(response.data[0]['in_use'], True) + self.assertEqual(response.data[0]['issued_by'], self.user.pk) + self.assertIsNone(response.data[0]['revoked_by']) + self.assertIsNone(response.data[0]['revocation_reason']) expected_day = str( datetime.datetime.now().date() + datetime.timedelta(days=365) ) self.assertEqual(response.data[0]['expiry'], expected_day) # Destroy token + token_id = response.data[0]['id'] self.delete( - reverse('api-token-detail', kwargs={'pk': response.data[0]['id']}), + reverse('api-token-detail', kwargs={'pk': token_id}), + data={'revocation_reason': 'No longer needed'}, expected_code=204, ) + token = ApiToken.objects.get(pk=token_id) + self.assertTrue(token.revoked) + self.assertEqual(token.revoked_by, self.user) + self.assertEqual(token.revocation_reason, 'No longer needed') + # Get token without auth (should fail) self.client.logout() self.get(reverse('api-token'), expected_code=401) @@ -581,6 +595,40 @@ class UserTokenTests(InvenTreeAPITestCase): self.assertEqual(ApiToken.objects.count(), 1) + 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 + token = ApiToken.objects.create( + user=self.user, + key=default_token(), + token_version=1, + expiry=datetime.datetime.now() + datetime.timedelta(days=365), + ) + token_key = token.key + self.assertTrue(token_key.startswith('inv-')) + + # Check match and validate functions + self.assertTrue(token.match(token_key)) + self.assertTrue(token.validate(token_key)) + + # test api access with token + self.logout() + # false test - ensure that without the token, access is denied + self.get(reverse('api-user-me'), expected_code=401) + + # valid test + self.client.credentials(HTTP_AUTHORIZATION=f'Token {token_key}') + response = self.get(reverse('api-user-me'), expected_code=200) + self.assertEqual(response.data['username'], self.user.username) + + # check if info view also works + response_data = self.get( + reverse('api-inventree-info'), expected_code=200 + ).json() + # staff users are allowed to see the database field + self.assertIn('database', response_data) + self.assertIsNotNone(response_data.get('database')) + class GroupDetailTests(InvenTreeAPITestCase): """Tests for the GroupDetail API endpoint.""" diff --git a/src/backend/InvenTree/users/test_migrations.py b/src/backend/InvenTree/users/test_migrations.py index 9843b79177..d7c2f7f087 100644 --- a/src/backend/InvenTree/users/test_migrations.py +++ b/src/backend/InvenTree/users/test_migrations.py @@ -14,13 +14,30 @@ class TestForwardMigrations(MigratorTestCase): def prepare(self): """Setup the initial state of the database before migrations.""" User = self.old_state.apps.get_model('auth', 'user') + ApiToken = self.old_state.apps.get_model('users', 'ApiToken') - User.objects.create(username='fred', email='fred@fred.com', password='password') + fred = User.objects.create( + username='fred', email='fred@fred.com', password='password' + ) User.objects.create(username='brad', email='brad@fred.com', password='password') + ApiToken.objects.create(key='legacy-token', user=fred) + def test_users_exist(self): """Test that users exist in the database.""" User = self.new_state.apps.get_model('auth', 'user') self.assertEqual(User.objects.count(), 2) + + def test_existing_tokens_are_marked_as_v1(self): + """Test that tokens created before v2 are marked as v1.""" + ApiToken = self.new_state.apps.get_model('users', 'ApiToken') + + token = ApiToken.objects.get(key='legacy-token') + + self.assertEqual(token.token_version, 1) + + # a new token should be a v2 token now + new_token = ApiToken.objects.create(key='new-token', user=token.user) + self.assertEqual(new_token.token_version, 2) diff --git a/src/backend/InvenTree/users/tests.py b/src/backend/InvenTree/users/tests.py index 490889a3e0..0e1668f9f3 100644 --- a/src/backend/InvenTree/users/tests.py +++ b/src/backend/InvenTree/users/tests.py @@ -1,5 +1,7 @@ """Unit tests for the 'users' app.""" +import datetime + from django.apps import apps from django.contrib.auth.models import Group from django.test import TestCase @@ -298,7 +300,10 @@ class OwnerModelTest(InvenTreeTestCase): self.client.login(username=self.username, password=self.password) # token get response = self.do_request(reverse('api-token'), {}) - self.assertEqual(response['token'], token.first().key) + raw_token = response['token'] + self.assertTrue(raw_token.startswith('inv-2-')) + token = ApiToken.get_from_string(raw_token) + self.assertTrue(token.validate(raw_token)) # test user is associated with token response = self.do_request( @@ -404,6 +409,12 @@ class AdminTest(AdminTestCase): my_token = self.helper( model=ApiToken, model_kwargs={'user': self.user, 'name': 'test-token'} ) + self.assertTrue( + my_token.token.endswith( + f'-{datetime.datetime.now().date().isoformat().replace("-", "")}' + ) + ) + self.assertTrue(my_token.validate(my_token.token)) # Additionally test str fnc self.assertEqual(str(my_token), my_token.token) diff --git a/src/frontend/src/tables/settings/ApiTokenTable.tsx b/src/frontend/src/tables/settings/ApiTokenTable.tsx index be846f7457..30b18643df 100644 --- a/src/frontend/src/tables/settings/ApiTokenTable.tsx +++ b/src/frontend/src/tables/settings/ApiTokenTable.tsx @@ -8,7 +8,17 @@ import useTable from '@lib/hooks/UseTable'; import type { TableFilter } from '@lib/types/Filters'; import { t } from '@lingui/core/macro'; import { Trans } from '@lingui/react/macro'; -import { Badge, Code, Flex, Modal, Paper, Text } from '@mantine/core'; +import { + Badge, + Button, + Code, + Flex, + Group, + Modal, + Paper, + Text, + Textarea +} from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import { IconCircleX } from '@tabler/icons-react'; import { useCallback, useMemo, useState } from 'react'; @@ -27,6 +37,8 @@ export function ApiTokenTable({ }: Readonly<{ only_myself: boolean }>) { const [token, setToken] = useState(''); const [opened, { open, close }] = useDisclosure(false); + const [revokeTokenId, setRevokeTokenId] = useState(null); + const [revocationReason, setRevocationReason] = useState(''); const generateToken = useCreateApiFormModal({ url: ApiEndpoints.user_me_token, @@ -100,6 +112,26 @@ export function ApiTokenTable({ accessor: 'created', title: t`Created`, sortable: true + }, + UserColumn({ + accessor: 'issued_by_detail', + title: t`Issued By`, + filtering: true, + sortable: true + }), + UserColumn({ + accessor: 'revoked_by_detail', + title: t`Revoked By`, + filtering: true, + sortable: true + }), + { + accessor: 'token_version', + sortable: true + }, + { + accessor: 'revocation_reason', + sortable: false } ]; if (!only_myself) { @@ -129,6 +161,14 @@ export function ApiTokenTable({ name: 'user', label: t`User`, description: t`Filter by user` + }), + UserFilter({ + name: 'issued_by', + label: t`Issued By` + }), + UserFilter({ + name: 'revoked_by', + label: t`Revoked By` }) ); } @@ -143,21 +183,25 @@ export function ApiTokenTable({ hidden: !record.active || record.in_use, icon: , onClick: () => { - revokeToken(record.id); + setRevokeTokenId(record.id); } } ]; }, []); - const revokeToken = async (id: string) => { - let targetUrl = apiUrl(ApiEndpoints.user_tokens, id); + const revokeToken = async () => { + if (!revokeTokenId) return; + + let targetUrl = apiUrl(ApiEndpoints.user_tokens, revokeTokenId); if (!only_myself) { targetUrl += '?all_users=true'; } api - .delete(targetUrl) + .delete(targetUrl, { data: { revocation_reason: revocationReason } }) .then(() => { table.refreshTable(); + setRevokeTokenId(null); + setRevocationReason(''); }) .catch((error) => { showApiErrorMessage({ @@ -198,6 +242,38 @@ export function ApiTokenTable({ )} + { + setRevokeTokenId(null); + setRevocationReason(''); + }} + title={t`Revoke Token`} + centered + > +