Skip to content

Commit 18a308a

Browse files
authored
Merge pull request #20477 from netbox-community/20210-new-token-auth
Closes #20210: Implement new version of API token
2 parents 6c723df + c63e60a commit 18a308a

File tree

32 files changed

+1017
-360
lines changed

32 files changed

+1017
-360
lines changed

contrib/openapi.json

Lines changed: 199 additions & 73 deletions
Large diffs are not rendered by default.

docs/configuration/required-parameters.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,31 @@ ALLOWED_HOSTS = ['*']
2323

2424
---
2525

26+
## API_TOKEN_PEPPERS
27+
28+
!!! info "This parameter was introduced in NetBox v4.5."
29+
30+
[Cryptographic peppers](https://en.wikipedia.org/wiki/Pepper_(cryptography)) are employed to generate hashes of sensitive values on the server. This parameter defines the peppers used to hash v2 API tokens in NetBox. You must define at least one pepper before creating a v2 API token. See the [API documentation](../integrations/rest-api.md#authentication) for further information about how peppers are used.
31+
32+
```python
33+
API_TOKEN_PEPPERS = {
34+
# DO NOT USE THIS EXAMPLE PEPPER IN PRODUCTION
35+
1: 'kp7ht*76fiQAhUi5dHfASLlYUE_S^gI^(7J^K5M!LfoH@vl&b_',
36+
}
37+
```
38+
39+
!!! warning "Peppers are sensitive"
40+
Treat pepper values as extremely sensitive. Consider populating peppers from environment variables at initialization time rather than defining them in the configuration file, if feasible.
41+
42+
Peppers must be at least 50 characters in length and should comprise a random string with a diverse character set. Consider using the Python script at `$INSTALL_ROOT/netbox/generate_secret_key.py` to generate a pepper value.
43+
44+
It is recommended to start with a pepper ID of `1`. Additional peppers can be introduced later as needed to begin rotating token hashes.
45+
46+
!!! tip
47+
Although NetBox will run without `API_TOKEN_PEPPERS` defined, the use of v2 API tokens will be unavailable.
48+
49+
---
50+
2651
## DATABASE
2752

2853
!!! warning "Legacy Configuration Parameter"

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/installation/3-netbox.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,23 @@ If you are not yet sure what the domain name and/or IP address of the NetBox ins
120120
ALLOWED_HOSTS = ['*']
121121
```
122122

123+
### API_TOKEN_PEPPERS
124+
125+
Define at least one random cryptographic pepper, identified by a numeric ID starting at 1. This will be used to generate SHA256 checksums for API tokens.
126+
127+
```python
128+
API_TOKEN_PEPPERS = {
129+
# DO NOT USE THIS EXAMPLE PEPPER IN PRODUCTION
130+
1: 'kp7ht*76fiQAhUi5dHfASLlYUE_S^gI^(7J^K5M!LfoH@vl&b_',
131+
}
132+
```
133+
134+
!!! tip
135+
As with [`SECRET_KEY`](#secret_key) below, you can use the `generate_secret_key.py` script to generate a random pepper:
136+
```no-highlight
137+
python3 ../generate_secret_key.py
138+
```
139+
123140
### DATABASES
124141

125142
This parameter holds the PostgreSQL database configuration details. The default database must be defined; additional databases may be defined as needed e.g. by plugins.

docs/integrations/rest-api.md

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -653,18 +653,19 @@ The NetBox REST API primarily employs token-based authentication. For convenienc
653653

654654
### Tokens
655655

656-
A token is a unique identifier mapped to a NetBox user account. Each user may have one or more tokens which he or she can use for authentication when making REST API requests. To create a token, navigate to the API tokens page under your user profile.
656+
A token is a secret, unique identifier mapped to a NetBox user account. Each user may have one or more tokens which he or she can use for authentication when making REST API requests. To create a token, navigate to the API tokens page under your user profile. When creating a token, NetBox will automatically populate a randomly-generated token value.
657657

658658
By default, all users can create and manage their own REST API tokens under the user control panel in the UI or via the REST API. This ability can be disabled by overriding the [`DEFAULT_PERMISSIONS`](../configuration/security.md#default_permissions) configuration parameter.
659659

660-
Each token contains a 160-bit key represented as 40 hexadecimal characters. When creating a token, you'll typically leave the key field blank so that a random key will be automatically generated. However, NetBox allows you to specify a key in case you need to restore a previously deleted token to operation.
661-
662660
Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox.
663661

664-
!!! info "Restricting Token Retrieval"
665-
The ability to retrieve the key value of a previously-created API token can be restricted by disabling the [`ALLOW_TOKEN_RETRIEVAL`](../configuration/security.md#allow_token_retrieval) configuration parameter.
662+
#### v1 and v2 Tokens
663+
664+
Beginning with NetBox v4.5, two versions of API token are supported, denoted as v1 and v2. Users are strongly encouraged to create only v2 tokens and to discontinue the use of v1 tokens. Support for v1 tokens will be removed in a future NetBox release.
665+
666+
v2 API tokens offer much stronger security. The token plaintext given at creation time is hashed together with a configured [cryptographic pepper](../configuration/required-parameters.md#api_token_peppers) to generate a unique checksum. This checksum is irreversible; the token plaintext is never stored on the server and thus cannot be retrieved.
666667

667-
### Restricting Write Operations
668+
#### Restricting Write Operations
668669

669670
By default, a token can be used to perform all actions via the API that a user would be permitted to do via the web UI. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only.
670671

@@ -681,10 +682,22 @@ It is possible to provision authentication tokens for other users via the REST A
681682

682683
### Authenticating to the API
683684

684-
An authentication token is attached to a request by setting the `Authorization` header to the string `Token` followed by a space and the user's token:
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:
686+
687+
```
688+
Authorization: Bearer nbt_<key>.<token>
689+
```
690+
691+
Legacy v1 tokens use the prefix `Token` rather than `Bearer`, and include only the token plaintext. (v1 tokens do not have a key.)
692+
693+
```
694+
Authorization: Token <token>
695+
```
696+
697+
Below is an example REST API request utilizing a v2 token.
685698

686699
```
687-
$ curl -H "Authorization: Token $TOKEN" \
700+
$ curl -H "Authorization: Bearer nbt_4F9DAouzURLb.zjebxBPzICiPbWz0Wtx0fTL7bCKXKGTYhNzkgC2S" \
688701
-H "Accept: application/json; indent=4" \
689702
https://netbox/api/dcim/sites/
690703
{

netbox/account/tables.py

Lines changed: 0 additions & 57 deletions
This file was deleted.

netbox/account/views.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@
2626
from netbox.authentication import get_auth_backend_display, get_saml_idps
2727
from netbox.config import get_config
2828
from netbox.views import generic
29-
from users import forms, tables
29+
from users import forms
3030
from users.models import UserConfig
31+
from users.tables import TokenTable
3132
from utilities.request import safe_for_redirect
3233
from utilities.string import remove_linebreaks
3334
from utilities.views import register_model_view
@@ -328,7 +329,8 @@ class UserTokenListView(LoginRequiredMixin, View):
328329

329330
def get(self, request):
330331
tokens = UserToken.objects.filter(user=request.user)
331-
table = tables.UserTokenTable(tokens)
332+
table = TokenTable(tokens)
333+
table.columns.hide('user')
332334
table.configure(request)
333335

334336
return render(request, 'account/token_list.html', {
@@ -343,11 +345,9 @@ class UserTokenView(LoginRequiredMixin, View):
343345

344346
def get(self, request, pk):
345347
token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk)
346-
key = token.key if settings.ALLOW_TOKEN_RETRIEVAL else None
347348

348349
return render(request, 'account/token.html', {
349350
'object': token,
350-
'key': key,
351351
})
352352

353353

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'Token {self.token.key}'}
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: 85 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,47 +2,90 @@
22

33
from django.conf import settings
44
from django.utils import timezone
5-
from rest_framework import authentication, exceptions
5+
from drf_spectacular.extensions import OpenApiAuthenticationExtension
6+
from rest_framework import exceptions
7+
from rest_framework.authentication import BaseAuthentication, get_authorization_header
68
from rest_framework.permissions import BasePermission, DjangoObjectPermissions, SAFE_METHODS
79

810
from netbox.config import get_config
11+
from users.constants import TOKEN_PREFIX
912
from users.models import Token
1013
from utilities.request import get_client_ip
1114

15+
V1_KEYWORD = 'Token'
16+
V2_KEYWORD = 'Bearer'
1217

13-
class TokenAuthentication(authentication.TokenAuthentication):
18+
19+
class TokenAuthentication(BaseAuthentication):
1420
"""
1521
A custom authentication scheme which enforces Token expiration times and source IP restrictions.
1622
"""
1723
model = Token
1824

1925
def authenticate(self, request):
20-
result = super().authenticate(request)
21-
22-
if result:
23-
token = result[1]
24-
25-
# Enforce source IP restrictions (if any) set on the token
26-
if token.allowed_ips:
27-
client_ip = get_client_ip(request)
28-
if client_ip is None:
29-
raise exceptions.AuthenticationFailed(
30-
"Client IP address could not be determined for validation. Check that the HTTP server is "
31-
"correctly configured to pass the required header(s)."
32-
)
33-
if not token.validate_client_ip(client_ip):
34-
raise exceptions.AuthenticationFailed(
35-
f"Source IP {client_ip} is not permitted to authenticate using this token."
36-
)
37-
38-
return result
39-
40-
def authenticate_credentials(self, key):
41-
model = self.get_model()
26+
# Authorization header is not present; ignore
27+
if not (auth := get_authorization_header(request).split()):
28+
return
29+
# Unrecognized header; ignore
30+
if auth[0].lower() not in (V1_KEYWORD.lower().encode(), V2_KEYWORD.lower().encode()):
31+
return
32+
# Check for extraneous token content
33+
if len(auth) != 2:
34+
raise exceptions.AuthenticationFailed(
35+
'Invalid authorization header: Must be in the form "Bearer <key>.<token>" or "Token <token>"'
36+
)
37+
# Extract the key (if v2) & token plaintext from the auth header
38+
try:
39+
auth_value = auth[1].decode()
40+
except UnicodeError:
41+
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+
46+
if version == 1:
47+
key, plaintext = None, auth_value
48+
else:
49+
auth_value = auth_value.removeprefix(TOKEN_PREFIX)
50+
try:
51+
key, plaintext = auth_value.split('.', 1)
52+
except ValueError:
53+
raise exceptions.AuthenticationFailed(
54+
"Invalid authorization header: Could not parse key from v2 token. Did you mean to use 'Token' "
55+
"instead of 'Bearer'?"
56+
)
57+
58+
# Look for a matching token in the database
4259
try:
43-
token = model.objects.prefetch_related('user').get(key=key)
44-
except model.DoesNotExist:
45-
raise exceptions.AuthenticationFailed("Invalid token")
60+
qs = Token.objects.prefetch_related('user')
61+
if version == 1:
62+
# Fetch v1 token by querying plaintext value directly
63+
token = qs.get(version=version, plaintext=plaintext)
64+
else:
65+
# Fetch v2 token by key, then validate the plaintext
66+
token = qs.get(version=version, key=key)
67+
if not token.validate(plaintext):
68+
# Key is valid but plaintext is not. Raise DoesNotExist to guard against key enumeration.
69+
raise Token.DoesNotExist()
70+
except Token.DoesNotExist:
71+
raise exceptions.AuthenticationFailed(f"Invalid v{version} token")
72+
73+
# Enforce source IP restrictions (if any) set on the token
74+
if token.allowed_ips:
75+
client_ip = get_client_ip(request)
76+
if client_ip is None:
77+
raise exceptions.AuthenticationFailed(
78+
"Client IP address could not be determined for validation. Check that the HTTP server is "
79+
"correctly configured to pass the required header(s)."
80+
)
81+
if not token.validate_client_ip(client_ip):
82+
raise exceptions.AuthenticationFailed(
83+
f"Source IP {client_ip} is not permitted to authenticate using this token."
84+
)
85+
86+
# Enforce the Token's expiration time, if one has been set.
87+
if token.is_expired:
88+
raise exceptions.AuthenticationFailed("Token expired")
4689

4790
# Update last used, but only once per minute at most. This reduces write load on the database
4891
if not token.last_used or (timezone.now() - token.last_used).total_seconds() > 60:
@@ -54,11 +97,8 @@ def authenticate_credentials(self, key):
5497
else:
5598
Token.objects.filter(pk=token.pk).update(last_used=timezone.now())
5699

57-
# Enforce the Token's expiration time, if one has been set.
58-
if token.is_expired:
59-
raise exceptions.AuthenticationFailed("Token expired")
60-
61100
user = token.user
101+
62102
# When LDAP authentication is active try to load user data from LDAP directory
63103
if 'netbox.authentication.LDAPBackend' in settings.REMOTE_AUTH_BACKEND:
64104
from netbox.authentication import LDAPBackend
@@ -132,3 +172,17 @@ def has_permission(self, request, view):
132172
if not settings.LOGIN_REQUIRED:
133173
return True
134174
return request.user.is_authenticated
175+
176+
177+
class TokenScheme(OpenApiAuthenticationExtension):
178+
target_class = 'netbox.api.authentication.TokenAuthentication'
179+
name = 'tokenAuth'
180+
match_subclasses = True
181+
182+
def get_security_definition(self, auto_schema):
183+
return {
184+
'type': 'apiKey',
185+
'in': 'header',
186+
'name': 'Authorization',
187+
'description': '`Token <token>` (v1) or `Bearer <key>.<token>` (v2)',
188+
}

netbox/netbox/configuration_example.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,16 @@
6868
# https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-SECRET_KEY
6969
SECRET_KEY = ''
7070

71+
# Define a mapping of cryptographic peppers to use when hashing API tokens. A minimum of one pepper is required to
72+
# enable v2 API tokens (NetBox v4.5+). Define peppers as a mapping of numeric ID to pepper value, as shown below. Each
73+
# pepper must be at least 50 characters in length.
74+
#
75+
# API_TOKEN_PEPPERS = {
76+
# 1: "<random string>",
77+
# 2: "<random string>",
78+
# }
79+
API_TOKEN_PEPPERS = {}
80+
7181

7282
#########################
7383
# #

0 commit comments

Comments
 (0)