Repository: netbox-community/netbox
Pull request: Closes #9536: Last Used field on API tokens
<!--
    Thank you for your interest in contributing to NetBox! Please note
    that our contribution policy requires that a feature request or bug
    report be opened for approval prior to filing a pull request. This
    helps avoid wasting time and effort on something that we might not
    be able to accept.

    Please indicate the relevant feature request or bug report below.
    IF YOUR PULL REQUEST DOES NOT REFERENCE AN ACCEPTED BUG REPORT OR
    FEATURE REQUEST, IT WILL BE MARKED AS INVALID AND CLOSED.
-->
### Fixes: #9536: Last Used field on API tokens
<!--
    Please include a summary of the proposed changes below.
-->
Added a new field to the Token Model : last_used (DateTime)
The field is updated each time a token is authenticated (max 1x per minute to reduce db write load)

Problem statement:
Last Used field on API tokens
### NetBox version

v3.2.3

### Feature type

Change to existing functionality

### Proposed functionality

Add a 'Last used' property to the API token

### Use case

I want to know if a token is actively being used.

### Database changes

Add 'Last used' field (datetime) to 'users_token'

### External dependencies

_No response_

Hints from issue discussion:
This is an interesting idea. My only concern is the introduction of an additional database query, but I wonder if it can be combined somehow with the existing auth query. :thinking: 
@jeremystretch I have PR ready.
It adds the new field to the model, and updates the last_used when the TokenAuthentication.authenticate_credentials is called.

It does introduce a write for each API call., so it only update once a minute..
Does it stil work when netbox is run in maintenance/readonly mode?
@sdktr Any request with an API key will update the last_used field, independent of the accesslevel of the API token or state of Netbox

But when reading the docs https://docs.netbox.dev/en/stable/configuration/dynamic-settings/#maintenance_mode and this comment https://github.com/netbox-community/netbox/discussions/6219 , I think we should not update the last_used field when in maintenance mode. 

Thank you for pointing that out, I'll update the PR

Commit message: Show LastUsed in /user/api-tokens/

Patch to review:
diff --git a/netbox/netbox/api/authentication.py b/netbox/netbox/api/authentication.py
index 5e177bfcb1b..847bcbfd9f2 100644
--- a/netbox/netbox/api/authentication.py
+++ b/netbox/netbox/api/authentication.py
@@ -1,7 +1,11 @@
+import logging
+
 from django.conf import settings
+from django.utils import timezone
 from rest_framework import authentication, exceptions
 from rest_framework.permissions import BasePermission, DjangoObjectPermissions, SAFE_METHODS
 
+from netbox.config import get_config
 from users.models import Token
 
 
@@ -18,6 +22,17 @@ def authenticate_credentials(self, key):
         except model.DoesNotExist:
             raise exceptions.AuthenticationFailed("Invalid token")
 
+        # Update last used, but only once a minute. This reduces the write load on the db
+        if not token.last_used or (timezone.now() - token.last_used).total_seconds() > 60:
+            # If maintenance mode is enabled, assume the database is read-only, and disable updating the token's
+            # last_used time upon authentication.
+            if get_config().MAINTENANCE_MODE:
+                logger = logging.getLogger('netbox.auth.login')
+                logger.warning("Maintenance mode enabled: disabling update of token's last used timestamp")
+            else:
+                token.last_used = timezone.now()
+                token.save()
+
         # Enforce the Token's expiration time, if one has been set.
         if token.is_expired:
             raise exceptions.AuthenticationFailed("Token expired")
diff --git a/netbox/templates/users/api_tokens.html b/netbox/templates/users/api_tokens.html
index 01ffec23a45..a019cbd1fbb 100644
--- a/netbox/templates/users/api_tokens.html
+++ b/netbox/templates/users/api_tokens.html
@@ -22,11 +22,11 @@
                     </div>
                     <div class="card-body">
                         <div class="row">
-                            <div class="col col-md-4">
+                            <div class="col col-md-3">
                                 <small class="text-muted">Created</small><br />
                                 {{ token.created|annotated_date }}
                             </div>
-                            <div class="col col-md-4">
+                            <div class="col col-md-3">
                                 <small class="text-muted">Expires</small><br />
                                 {% if token.expires %}
                                     {{ token.expires|annotated_date }}
@@ -34,7 +34,15 @@
                                     <span>Never</span>
                                 {% endif %}
                             </div>
-                            <div class="col col-md-4">
+                            <div class="col col-md-3">
+                                <small class="text-muted">Last Used</small><br />
+                                {% if token.last_used %}
+                                    {{ token.last_used|annotated_date }}
+                                {% else %}
+                                    <span>Never</span>
+                                {% endif %}
+                            </div>
+                            <div class="col col-md-3">
                                 <small class="text-muted">Create/Edit/Delete Operations</small><br />
                                 {% if token.write_enabled %}
                                     <span class="badge bg-success">Enabled</span>
diff --git a/netbox/users/admin/__init__.py b/netbox/users/admin/__init__.py
index 1b163ed0691..320c28df2c0 100644
--- a/netbox/users/admin/__init__.py
+++ b/netbox/users/admin/__init__.py
@@ -58,7 +58,7 @@ def get_inlines(self, request, obj):
 class TokenAdmin(admin.ModelAdmin):
     form = forms.TokenAdminForm
     list_display = [
-        'key', 'user', 'created', 'expires', 'write_enabled', 'description'
+        'key', 'user', 'created', 'expires', 'last_used', 'write_enabled', 'description'
     ]
 
 
diff --git a/netbox/users/migrations/0003_token_last_used.py b/netbox/users/migrations/0003_token_last_used.py
new file mode 100644
index 00000000000..cc014e59c28
--- /dev/null
+++ b/netbox/users/migrations/0003_token_last_used.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.0.4 on 2022-06-16 15:26
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('users', '0002_standardize_id_fields'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='token',
+            name='last_used',
+            field=models.DateTimeField(blank=True, null=True),
+        ),
+    ]
diff --git a/netbox/users/models.py b/netbox/users/models.py
index 23068442e66..a0055914b7d 100644
--- a/netbox/users/models.py
+++ b/netbox/users/models.py
@@ -203,6 +203,10 @@ class Token(models.Model):
         blank=True,
         null=True
     )
+    last_used = models.DateTimeField(
+        blank=True,
+        null=True
+    )
     key = models.CharField(
         max_length=40,
         unique=True,


Code review comments:
diff_hunk: @@ -18,6 +22,17 @@ def authenticate_credentials(self, key):
         except model.DoesNotExist:
             raise exceptions.AuthenticationFailed("Invalid token")
 
+        # Update last used, but only once a minute. This reduces the write load on the db
+        if not token.last_used or (timezone.now() - token.last_used).total_seconds() > 60:
+            # If maintenance mode is enabled, assume the database is read-only, and disable updating the token's
+            # last_used time upon authentication.
+            if get_config().MAINTENANCE_MODE:
+                logger = logging.getLogger('netbox.auth.login')
+                logger.warning("Maintenance mode enabled: disabling update of token's last used timestamp")
path: netbox/netbox/api/authentication.py
text: @user1:
This seems like it might result in a lot of log spam. Maybe use debug level instead of warning?

diff_hunk: @@ -18,6 +22,17 @@ def authenticate_credentials(self, key):
         except model.DoesNotExist:
             raise exceptions.AuthenticationFailed("Invalid token")
 
+        # Update last used, but only once a minute. This reduces the write load on the db
+        if not token.last_used or (timezone.now() - token.last_used).total_seconds() > 60:
+            # If maintenance mode is enabled, assume the database is read-only, and disable updating the token's
+            # last_used time upon authentication.
+            if get_config().MAINTENANCE_MODE:
+                logger = logging.getLogger('netbox.auth.login')
+                logger.warning("Maintenance mode enabled: disabling update of token's last used timestamp")
+            else:
+                token.last_used = timezone.now()
+                token.save()
path: netbox/netbox/api/authentication.py
text: @user1:
It might be a bit faster to make a raw `update()` query to update the time.

```suggestion
                Token.objects.filter(pk=token.pk).update(last_used=timezone.now())
```