Skip to content

Commit c63e60a

Browse files
committed
Add a token prefix
1 parent 82db8a9 commit c63e60a

File tree

10 files changed

+41
-45
lines changed

10 files changed

+41
-45
lines changed

contrib/openapi.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -213929,7 +213929,7 @@
213929213929
},
213930213930
"mark_utilized": {
213931213931
"type": "boolean",
213932-
"description": "Report space as 100% utilized"
213932+
"description": "Report space as fully utilized"
213933213933
}
213934213934
},
213935213935
"required": [
@@ -214038,7 +214038,7 @@
214038214038
},
214039214039
"mark_utilized": {
214040214040
"type": "boolean",
214041-
"description": "Report space as 100% utilized"
214041+
"description": "Report space as fully utilized"
214042214042
}
214043214043
},
214044214044
"required": [
@@ -231032,7 +231032,7 @@
231032231032
},
231033231033
"mark_utilized": {
231034231034
"type": "boolean",
231035-
"description": "Report space as 100% utilized"
231035+
"description": "Report space as fully utilized"
231036231036
}
231037231037
}
231038231038
},
@@ -251418,7 +251418,7 @@
251418251418
},
251419251419
"mark_utilized": {
251420251420
"type": "boolean",
251421-
"description": "Report space as 100% utilized"
251421+
"description": "Report space as fully utilized"
251422251422
}
251423251423
},
251424251424
"required": [

docs/features/api-integration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ NetBox's REST API, powered by the [Django REST Framework](https://www.django-res
88

99
```no-highlight
1010
curl -s -X POST \
11-
-H "Authorization: Token $TOKEN" \
11+
-H "Authorization: Bearer $TOKEN" \
1212
-H "Content-Type: application/json" \
1313
http://netbox/api/ipam/prefixes/ \
1414
--data '{"prefix": "192.0.2.0/24", "site": {"name": "Branch 12"}}'

docs/integrations/rest-api.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -682,13 +682,13 @@ It is possible to provision authentication tokens for other users via the REST A
682682

683683
### Authenticating to the API
684684

685-
An authentication token is included with a request in its `Authorization` header. The format of the header value depends on the version of token in use. v2 tokens use the following form, concatenating the token's key and plaintext value with a period:
685+
An authentication token is included with a request in its `Authorization` header. The format of the header value depends on the version of token in use. v2 tokens use the following form, concatenating the token's prefix (`nbt_`) and key with its plaintext value, separated by a period:
686686

687687
```
688-
Authorization: Bearer <key>.<token>
688+
Authorization: Bearer nbt_<key>.<token>
689689
```
690690

691-
v1 tokens use the prefix `Token` rather than `Bearer`, and include only the token plaintext. (v1 tokens do not have a key.)
691+
Legacy v1 tokens use the prefix `Token` rather than `Bearer`, and include only the token plaintext. (v1 tokens do not have a key.)
692692

693693
```
694694
Authorization: Token <token>
@@ -697,7 +697,7 @@ Authorization: Token <token>
697697
Below is an example REST API request utilizing a v2 token.
698698

699699
```
700-
$ curl -H "Authorization: Bearer <key>.<token>" \
700+
$ curl -H "Authorization: Bearer nbt_4F9DAouzURLb.zjebxBPzICiPbWz0Wtx0fTL7bCKXKGTYhNzkgC2S" \
701701
-H "Accept: application/json; indent=4" \
702702
https://netbox/api/dcim/sites/
703703
{

netbox/core/tests/test_api.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from rq.registry import FailedJobRegistry, StartedJobRegistry
99

1010
from rest_framework import status
11+
from users.constants import TOKEN_PREFIX
1112
from users.models import Token, User
1213
from utilities.testing import APITestCase, APIViewTestCases, TestCase
1314
from utilities.testing.utils import disable_logging
@@ -136,7 +137,7 @@ def setUp(self):
136137
# Create the test user and assign permissions
137138
self.user = User.objects.create_user(username='testuser', is_active=True)
138139
self.token = Token.objects.create(user=self.user)
139-
self.header = {'HTTP_AUTHORIZATION': f'Bearer {self.token.key}.{self.token.token}'}
140+
self.header = {'HTTP_AUTHORIZATION': f'Bearer {TOKEN_PREFIX}{self.token.key}.{self.token.token}'}
140141

141142
# Clear all queues prior to running each test
142143
get_queue('default').connection.flushall()

netbox/netbox/api/authentication.py

Lines changed: 13 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from rest_framework.permissions import BasePermission, DjangoObjectPermissions, SAFE_METHODS
99

1010
from netbox.config import get_config
11+
from users.constants import TOKEN_PREFIX
1112
from users.models import Token
1213
from utilities.request import get_client_ip
1314

@@ -22,40 +23,30 @@ class TokenAuthentication(BaseAuthentication):
2223
model = Token
2324

2425
def authenticate(self, request):
25-
# Ignore; Authorization header is not present
26+
# Authorization header is not present; ignore
2627
if not (auth := get_authorization_header(request).split()):
2728
return
28-
29-
# Infer token version from Token/Bearer keyword in HTTP header
30-
if auth[0].lower() == V1_KEYWORD.lower().encode():
31-
version = 1
32-
elif auth[0].lower() == V2_KEYWORD.lower().encode():
33-
version = 2
34-
else:
35-
# Ignore; unrecognized header value
29+
# Unrecognized header; ignore
30+
if auth[0].lower() not in (V1_KEYWORD.lower().encode(), V2_KEYWORD.lower().encode()):
3631
return
37-
38-
# Extract token from authorization header. This should be in one of the following two forms:
39-
# * Authorization: Token <token> (v1)
40-
# * Authorization: Bearer <key>.<token> (v2)
32+
# Check for extraneous token content
4133
if len(auth) != 2:
42-
if version == 1:
43-
raise exceptions.AuthenticationFailed(
44-
'Invalid authorization header: Must be in the form "Token <token>"'
45-
)
46-
else:
47-
raise exceptions.AuthenticationFailed(
48-
'Invalid authorization header: Must be in the form "Bearer <key>.<token>"'
49-
)
50-
34+
raise exceptions.AuthenticationFailed(
35+
'Invalid authorization header: Must be in the form "Bearer <key>.<token>" or "Token <token>"'
36+
)
5137
# Extract the key (if v2) & token plaintext from the auth header
5238
try:
5339
auth_value = auth[1].decode()
5440
except UnicodeError:
5541
raise exceptions.AuthenticationFailed("Invalid authorization header: Token contains invalid characters")
42+
43+
# Infer token version from presence or absence of prefix
44+
version = 2 if auth_value.startswith(TOKEN_PREFIX) else 1
45+
5646
if version == 1:
5747
key, plaintext = None, auth_value
5848
else:
49+
auth_value = auth_value.removeprefix(TOKEN_PREFIX)
5950
try:
6051
key, plaintext = auth_value.split('.', 1)
6152
except ValueError:

netbox/netbox/tests/test_authentication.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from core.models import ObjectType
1010
from dcim.models import Rack, Site
11+
from users.constants import TOKEN_PREFIX
1112
from users.models import Group, ObjectPermission, Token, User
1213
from utilities.testing import TestCase
1314
from utilities.testing.api import APITestCase
@@ -49,7 +50,7 @@ def test_v2_token_valid(self):
4950
token = Token.objects.create(version=2, user=self.user)
5051

5152
# Valid token should return a 200
52-
header = f'Bearer {token.key}.{token.token}'
53+
header = f'Bearer {TOKEN_PREFIX}{token.key}.{token.token}'
5354
response = self.client.get(reverse('dcim-api:site-list'), HTTP_AUTHORIZATION=header)
5455
self.assertEqual(response.status_code, 200, response.data)
5556

@@ -60,7 +61,7 @@ def test_v2_token_valid(self):
6061
@override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*'])
6162
def test_v2_token_invalid(self):
6263
# Invalid token should return a 403
63-
header = 'Bearer XXXXXXXXXX.XXXXXXXXXX'
64+
header = f'Bearer {TOKEN_PREFIX}XXXXXX.XXXXXXXXXX'
6465
response = self.client.get(reverse('dcim-api:site-list'), HTTP_AUTHORIZATION=header)
6566
self.assertEqual(response.status_code, 403)
6667
self.assertEqual(response.data['detail'], "Invalid v2 token")
@@ -77,7 +78,7 @@ def test_token_expiration(self):
7778
# Request with a non-expired token should succeed
7879
response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token1.token}')
7980
self.assertEqual(response.status_code, 200)
80-
response = self.client.get(url, HTTP_AUTHORIZATION=f'Bearer {token2.key}.{token2.token}')
81+
response = self.client.get(url, HTTP_AUTHORIZATION=f'Bearer {TOKEN_PREFIX}{token2.key}.{token2.token}')
8182
self.assertEqual(response.status_code, 200)
8283

8384
# Request with an expired token should fail
@@ -88,7 +89,7 @@ def test_token_expiration(self):
8889
token2.save()
8990
response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token1.key}')
9091
self.assertEqual(response.status_code, 403)
91-
response = self.client.get(url, HTTP_AUTHORIZATION=f'Bearer {token2.key}')
92+
response = self.client.get(url, HTTP_AUTHORIZATION=f'Bearer {TOKEN_PREFIX}{token2.key}')
9293
self.assertEqual(response.status_code, 403)
9394

9495
@override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*'])
@@ -111,7 +112,7 @@ def test_token_write_enabled(self):
111112
token2 = Token.objects.create(version=2, user=self.user, write_enabled=False)
112113

113114
token1_header = f'Token {token1.token}'
114-
token2_header = f'Bearer {token2.key}.{token2.token}'
115+
token2_header = f'Bearer {TOKEN_PREFIX}{token2.key}.{token2.token}'
115116

116117
# GET request with a write-disabled token should succeed
117118
response = self.client.get(url, HTTP_AUTHORIZATION=token1_header)
@@ -152,7 +153,7 @@ def test_token_allowed_ips(self):
152153
self.assertEqual(response.status_code, 403)
153154
response = self.client.get(
154155
url,
155-
HTTP_AUTHORIZATION=f'Bearer {token2.key}.{token2.token}',
156+
HTTP_AUTHORIZATION=f'Bearer {TOKEN_PREFIX}{token2.key}.{token2.token}',
156157
REMOTE_ADDR='127.0.0.1'
157158
)
158159
self.assertEqual(response.status_code, 403)
@@ -166,7 +167,7 @@ def test_token_allowed_ips(self):
166167
self.assertEqual(response.status_code, 200)
167168
response = self.client.get(
168169
url,
169-
HTTP_AUTHORIZATION=f'Bearer {token2.key}.{token2.token}',
170+
HTTP_AUTHORIZATION=f'Bearer {TOKEN_PREFIX}{token2.key}.{token2.token}',
170171
REMOTE_ADDR='192.0.2.1'
171172
)
172173
self.assertEqual(response.status_code, 200)
@@ -519,7 +520,7 @@ def setUp(self):
519520
"""
520521
self.user = User.objects.create(username='testuser')
521522
self.token = Token.objects.create(user=self.user)
522-
self.header = {'HTTP_AUTHORIZATION': f'Bearer {self.token.key}.{self.token.token}'}
523+
self.header = {'HTTP_AUTHORIZATION': f'Bearer {TOKEN_PREFIX}{self.token.key}.{self.token.token}'}
523524

524525
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
525526
def test_get_object(self):

netbox/users/constants.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
CONSTRAINT_TOKEN_USER = '$user'
1212

1313
# API tokens
14-
TOKEN_KEY_LENGTH = 16
14+
TOKEN_PREFIX = 'nbt_' # Used for v2 tokens only
15+
TOKEN_KEY_LENGTH = 12
1516
TOKEN_DEFAULT_LENGTH = 40
1617
TOKEN_CHARSET = string.ascii_letters + string.digits

netbox/users/migrations/0014_users_token_v2.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,10 @@ class Migration(migrations.Migration):
5656
name='key',
5757
field=models.CharField(
5858
blank=True,
59-
max_length=16,
59+
max_length=12,
6060
null=True,
6161
unique=True,
62-
validators=[django.core.validators.MinLengthValidator(16)]
62+
validators=[django.core.validators.MinLengthValidator(12)]
6363
),
6464
),
6565
migrations.AddField(

netbox/users/models/tokens.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
from ipam.fields import IPNetworkField
1717
from users.choices import TokenVersionChoices
18-
from users.constants import TOKEN_CHARSET, TOKEN_DEFAULT_LENGTH, TOKEN_KEY_LENGTH
18+
from users.constants import TOKEN_CHARSET, TOKEN_DEFAULT_LENGTH, TOKEN_KEY_LENGTH, TOKEN_PREFIX
1919
from users.utils import get_current_pepper
2020
from utilities.querysets import RestrictedQuerySet
2121

@@ -235,6 +235,7 @@ def validate(self, token):
235235
if self.v1:
236236
return token == self.token
237237
if self.v2:
238+
token = token.removeprefix(TOKEN_PREFIX)
238239
try:
239240
pepper = settings.API_TOKEN_PEPPERS[self.pepper_id]
240241
except KeyError:

netbox/utilities/testing/api.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from core.models import ObjectChange, ObjectType
1818
from ipam.graphql.types import IPAddressFamilyType
1919
from netbox.models.features import ChangeLoggingMixin
20+
from users.constants import TOKEN_PREFIX
2021
from users.models import ObjectPermission, Token, User
2122
from utilities.api import get_graphql_type_for_model
2223
from .base import ModelTestCase
@@ -50,7 +51,7 @@ def setUp(self):
5051
self.user = User.objects.create_user(username='testuser')
5152
self.add_permissions(*self.user_permissions)
5253
self.token = Token.objects.create(user=self.user)
53-
self.header = {'HTTP_AUTHORIZATION': f'Bearer {self.token.key}.{self.token.token}'}
54+
self.header = {'HTTP_AUTHORIZATION': f'Bearer {TOKEN_PREFIX}{self.token.key}.{self.token.token}'}
5455

5556
def _get_view_namespace(self):
5657
return f'{self.view_namespace or self.model._meta.app_label}-api'

0 commit comments

Comments
 (0)