Skip to content

Commit c1d6984

Browse files
authored
feat: improved user auth tokens (#68148)
Supports getsentry/rfcs#32. - Newly created _user auth tokens_ will be prefixed with `sntryu_`. - Introduce a custom model manager for `ApiToken` to handle the unique creation logic where we need to hash the token values and store them. - Use the `token_type` (currently optional) parameter/field on `ApiToken` when creating user auth tokens to let the model do the heavy lifting on generating and hashing the values. This will keep the logic out of views and simplify calls to just `new_token = ApiToken.objects.create(user=user, token_type=AuthTokenType.USER)`. - I've [changed some behavior](https://github.com/getsentry/sentry/pull/68148/files#diff-e68bf726258b71dbfe6c6a3dcb9a959faba4e9585762067078a38bed5bad4812R36-R37) where we only return the `refreshToken` on applicable token types. I introduce a "read once" pattern in this PR for the token secrets to prevent leaking of them in logs, exceptions, etc. It works like this when creating a new `ApiToken`: 1. The model manager sets temporary fields `__plaintext_token` and `__plaintext_refresh_token` that store the respective plaintext values for temporary reading. 2. When reading the value through the `plaintext_token` property on `ApiToken` (notice the single prepended underscore) the string value is returned and `__plaintext_token` is immediately set to `None`. 3. If you attempt to read the `_plaintext_token` property again, an exception will be raised, `PlaintextSecretAlreadyRead`.
1 parent 2749a17 commit c1d6984

File tree

10 files changed

+649
-330
lines changed

10 files changed

+649
-330
lines changed

src/sentry/api/endpoints/api_tokens.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from sentry.models.apitoken import ApiToken
2020
from sentry.models.outbox import outbox_context
2121
from sentry.security.utils import capture_security_activity
22+
from sentry.types.token import AuthTokenType
2223

2324

2425
class ApiTokenSerializer(serializers.Serializer):
@@ -78,8 +79,8 @@ def post(self, request: Request) -> Response:
7879
token = ApiToken.objects.create(
7980
user_id=request.user.id,
8081
name=result.get("name", None),
82+
token_type=AuthTokenType.USER,
8183
scope_list=result["scopes"],
82-
refresh_token=None,
8384
expires_at=None,
8485
)
8586

src/sentry/api/serializers/models/apitoken.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from sentry.api.serializers import Serializer, register, serialize
22
from sentry.models.apitoken import ApiToken
3+
from sentry.types.token import AuthTokenType
34

45

56
@register(ApiToken)
@@ -30,9 +31,10 @@ def serialize(self, obj, attrs, user, **kwargs):
3031
if not attrs["application"]:
3132
include_token = kwargs.get("include_token", True)
3233
if include_token:
33-
data["token"] = obj.token
34+
data["token"] = obj.plaintext_token
3435

35-
data["refreshToken"] = obj.refresh_token
36+
if not obj.token_type == AuthTokenType.USER:
37+
data["refreshToken"] = obj.plaintext_refresh_token
3638

3739
"""
3840
While this is a nullable column at the db level, this should never be empty. If it is, it's a sign that the

src/sentry/models/apitoken.py

Lines changed: 184 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import hashlib
34
import secrets
45
from collections.abc import Collection
56
from datetime import timedelta
@@ -22,16 +23,82 @@
2223
from sentry.types.token import AuthTokenType
2324

2425
DEFAULT_EXPIRATION = timedelta(days=30)
26+
TOKEN_REDACTED = "***REDACTED***"
2527

2628

2729
def default_expiration():
2830
return timezone.now() + DEFAULT_EXPIRATION
2931

3032

31-
def generate_token():
33+
def generate_token(token_type: AuthTokenType | str | None = AuthTokenType.__empty__) -> str:
34+
if token_type:
35+
return f"{token_type}{secrets.token_hex(nbytes=32)}"
36+
3237
return secrets.token_hex(nbytes=32)
3338

3439

40+
class PlaintextSecretAlreadyRead(Exception):
41+
"""the secret you are trying to read is read-once and cannot be accessed directly again"""
42+
43+
pass
44+
45+
46+
class NotSupported(Exception):
47+
"""the method you called is not supported by this token type"""
48+
49+
pass
50+
51+
52+
class ApiTokenManager(ControlOutboxProducingManager):
53+
def create(self, *args, **kwargs):
54+
token_type: AuthTokenType | None = kwargs.get("token_type", None)
55+
56+
# Typically the .create() method is called with `refresh_token=None` as an
57+
# argument when we specifically do not want a refresh_token.
58+
#
59+
# But if it is not None or not specified, we should generate a token since
60+
# that is the expected behavior... the refresh_token field on ApiToken has
61+
# a default of generate_token()
62+
#
63+
# TODO(mdtro): All of these if/else statements will be cleaned up at a later time
64+
# to use a match statment on the AuthTokenType. Move each of the various token type
65+
# create calls one at a time.
66+
if "refresh_token" in kwargs:
67+
plaintext_refresh_token = kwargs["refresh_token"]
68+
else:
69+
plaintext_refresh_token = generate_token()
70+
71+
if token_type == AuthTokenType.USER:
72+
plaintext_token = generate_token(token_type=AuthTokenType.USER)
73+
plaintext_refresh_token = None # user auth tokens do not have refresh tokens
74+
else:
75+
# to maintain compatibility with current
76+
# code that currently calls create with token= specified
77+
if "token" in kwargs:
78+
plaintext_token = kwargs["token"]
79+
else:
80+
plaintext_token = generate_token()
81+
82+
if options.get("apitoken.save-hash-on-create"):
83+
kwargs["hashed_token"] = hashlib.sha256(plaintext_token.encode()).hexdigest()
84+
85+
if plaintext_refresh_token:
86+
kwargs["hashed_refresh_token"] = hashlib.sha256(
87+
plaintext_refresh_token.encode()
88+
).hexdigest()
89+
90+
kwargs["token"] = plaintext_token
91+
kwargs["refresh_token"] = plaintext_refresh_token
92+
93+
api_token = super().create(*args, **kwargs)
94+
95+
# Store the plaintext tokens for one-time retrieval
96+
api_token._set_plaintext_token(token=plaintext_token)
97+
api_token._set_plaintext_refresh_token(token=plaintext_refresh_token)
98+
99+
return api_token
100+
101+
35102
@control_silo_only_model
36103
class ApiToken(ReplicatedControlModel, HasApiScopes):
37104
__relocation_scope__ = {RelocationScope.Global, RelocationScope.Config}
@@ -50,7 +117,7 @@ class ApiToken(ReplicatedControlModel, HasApiScopes):
50117
expires_at = models.DateTimeField(null=True, default=default_expiration)
51118
date_added = models.DateTimeField(default=timezone.now)
52119

53-
objects: ClassVar[ControlOutboxProducingManager[ApiToken]] = ControlOutboxProducingManager(
120+
objects: ClassVar[ControlOutboxProducingManager[ApiToken]] = ApiTokenManager(
54121
cache_fields=("token",)
55122
)
56123

@@ -63,12 +130,117 @@ class Meta:
63130
def __str__(self):
64131
return force_str(self.token)
65132

133+
def _set_plaintext_token(self, token: str) -> None:
134+
"""Set the plaintext token for one-time reading
135+
This function should only be called from the model's
136+
manager class.
137+
138+
:param token: A plaintext string of the token
139+
:raises PlaintextSecretAlreadyRead: when the token has already been read once
140+
"""
141+
existing_token: str | None = None
142+
try:
143+
existing_token = self.__plaintext_token
144+
except AttributeError:
145+
self.__plaintext_token: str = token
146+
147+
if existing_token == TOKEN_REDACTED:
148+
raise PlaintextSecretAlreadyRead()
149+
150+
def _set_plaintext_refresh_token(self, token: str) -> None:
151+
"""Set the plaintext refresh token for one-time reading
152+
This function should only be called from the model's
153+
manager class.
154+
155+
:param token: A plaintext string of the refresh token
156+
:raises PlaintextSecretAlreadyRead: if the token has already been read once
157+
"""
158+
existing_refresh_token: str | None = None
159+
try:
160+
existing_refresh_token = self.__plaintext_refresh_token
161+
except AttributeError:
162+
self.__plaintext_refresh_token: str = token
163+
164+
if existing_refresh_token == TOKEN_REDACTED:
165+
raise PlaintextSecretAlreadyRead()
166+
167+
@property
168+
def plaintext_token(self) -> str:
169+
"""The plaintext value of the token
170+
To be called immediately after creation of a new `ApiToken` to return the
171+
plaintext token to the user. After reading the token, the plaintext token
172+
string will be set to `TOKEN_REDACTED` to prevent future accidental leaking
173+
of the token in logs, exceptions, etc.
174+
175+
:raises PlaintextSecretAlreadyRead: if the token has already been read once
176+
:return: the plaintext value of the token
177+
"""
178+
token = self.__plaintext_token
179+
if token == TOKEN_REDACTED:
180+
raise PlaintextSecretAlreadyRead()
181+
182+
self.__plaintext_token = TOKEN_REDACTED
183+
184+
return token
185+
186+
@property
187+
def plaintext_refresh_token(self) -> str:
188+
"""The plaintext value of the refresh token
189+
To be called immediately after creation of a new `ApiToken` to return the
190+
plaintext token to the user. After reading the token, the plaintext token
191+
string will be set to `TOKEN_REDACTED` to prevent future accidental leaking
192+
of the token in logs, exceptions, etc.
193+
194+
:raises PlaintextSecretAlreadyRead: if the refresh token has already been read once
195+
:raises NotSupported: if called on a User Auth Token
196+
:return: the plaintext value of the refresh token
197+
"""
198+
if not self.refresh_token and not self.hashed_refresh_token:
199+
raise NotSupported("This API token type does not support refresh tokens")
200+
201+
token = self.__plaintext_refresh_token
202+
if token == TOKEN_REDACTED:
203+
raise PlaintextSecretAlreadyRead()
204+
205+
self.__plaintext_refresh_token = TOKEN_REDACTED
206+
207+
return token
208+
66209
def save(self, *args: Any, **kwargs: Any) -> None:
210+
if options.get("apitoken.save-hash-on-create"):
211+
self.hashed_token = hashlib.sha256(self.token.encode()).hexdigest()
212+
213+
if self.refresh_token:
214+
self.hashed_refresh_token = hashlib.sha256(self.refresh_token.encode()).hexdigest()
215+
else:
216+
# The backup tests create a token with a refresh_token and then clear it out.
217+
# So if the refresh_token is None, wipe out any hashed value that may exist too.
218+
# https://github.com/getsentry/sentry/blob/1fc699564e79c62bff6cc3c168a49bfceadcac52/tests/sentry/backup/test_imports.py#L1306
219+
self.hashed_refresh_token = None
220+
67221
if options.get("apitoken.auto-add-last-chars"):
68222
token_last_characters = self.token[-4:]
69223
self.token_last_characters = token_last_characters
70224

71-
return super().save(**kwargs)
225+
return super().save(*args, **kwargs)
226+
227+
def update(self, *args: Any, **kwargs: Any) -> int:
228+
# if the token or refresh_token was updated, we need to
229+
# re-calculate the hashed values
230+
if options.get("apitoken.save-hash-on-create"):
231+
if "token" in kwargs:
232+
kwargs["hashed_token"] = hashlib.sha256(kwargs["token"].encode()).hexdigest()
233+
234+
if "refresh_token" in kwargs:
235+
kwargs["hashed_refresh_token"] = hashlib.sha256(
236+
kwargs["refresh_token"].encode()
237+
).hexdigest()
238+
239+
if options.get("apitoken.auto-add-last-chars"):
240+
if "token" in kwargs:
241+
kwargs["token_last_characters"] = kwargs["token"][-4:]
242+
243+
return super().update(*args, **kwargs)
72244

73245
def outbox_region_names(self) -> Collection[str]:
74246
return list(find_all_region_names())
@@ -104,10 +276,16 @@ def get_allowed_origins(self):
104276
return ()
105277

106278
def refresh(self, expires_at=None):
279+
if self.token_type == AuthTokenType.USER:
280+
raise NotSupported("User auth tokens do not support refreshing the token")
281+
107282
if expires_at is None:
108283
expires_at = timezone.now() + DEFAULT_EXPIRATION
109284

110-
self.update(token=generate_token(), refresh_token=generate_token(), expires_at=expires_at)
285+
new_token = generate_token(token_type=self.token_type)
286+
new_refresh_token = generate_token(token_type=self.token_type)
287+
288+
self.update(token=new_token, refresh_token=new_refresh_token, expires_at=expires_at)
111289

112290
def get_relocation_scope(self) -> RelocationScope:
113291
if self.application_id is not None:
@@ -125,9 +303,9 @@ def write_relocation_import(
125303
)
126304
existing = self.__class__.objects.filter(query).first()
127305
if existing:
128-
self.token = generate_token()
306+
self.token = generate_token(token_type=self.token_type)
129307
if self.refresh_token is not None:
130-
self.refresh_token = generate_token()
308+
self.refresh_token = generate_token(token_type=self.token_type)
131309
if self.expires_at is not None:
132310
self.expires_at = timezone.now() + DEFAULT_EXPIRATION
133311

src/sentry/testutils/factories.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@
148148
from sentry.types.activity import ActivityType
149149
from sentry.types.integrations import ExternalProviders
150150
from sentry.types.region import Region, get_local_region, get_region_by_name
151+
from sentry.types.token import AuthTokenType
151152
from sentry.utils import json, loremipsum
152153
from sentry.utils.performance_issues.performance_problem import PerformanceProblem
153154
from social_auth.models import UserSocialAuth
@@ -423,6 +424,7 @@ def create_user_auth_token(user, scope_list: list[str] | None = None, **kwargs)
423424
return ApiToken.objects.create(
424425
user=user,
425426
scope_list=scope_list,
427+
token_type=AuthTokenType.USER,
426428
**kwargs,
427429
)
428430

src/sentry/testutils/helpers/backups.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@
9999
from sentry.testutils.cases import TransactionTestCase
100100
from sentry.testutils.factories import get_fixture_path
101101
from sentry.testutils.silo import assume_test_silo_mode
102+
from sentry.types.token import AuthTokenType
102103
from sentry.utils import json
103104
from sentry.utils.json import JSONData
104105

@@ -632,7 +633,10 @@ def create_exhaustive_global_configs(self, owner: User):
632633
ControlOption.objects.create(key="bar", value="b")
633634
ApiAuthorization.objects.create(user=owner)
634635
ApiToken.objects.create(
635-
user=owner, expires_at=None, name="create_exhaustive_global_configs"
636+
user=owner,
637+
expires_at=None,
638+
name="create_exhaustive_global_configs",
639+
token_type=AuthTokenType.USER,
636640
)
637641

638642
@assume_test_silo_mode(SiloMode.REGION)

src/sentry/web/frontend/setup_wizard.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from sentry.services.hybrid_cloud.project_key.model import ProjectKeyRole
2525
from sentry.services.hybrid_cloud.project_key.service import project_key_service
2626
from sentry.services.hybrid_cloud.user.model import RpcUser
27+
from sentry.types.token import AuthTokenType
2728
from sentry.utils.http import absolute_uri
2829
from sentry.utils.security.orgauthtoken_token import (
2930
SystemUrlPrefixMissingException,
@@ -159,7 +160,7 @@ def get_token(mappings: list[OrganizationMapping], user: RpcUser):
159160
token = ApiToken.objects.create(
160161
user_id=user.id,
161162
scope_list=["project:releases"],
162-
refresh_token=None,
163+
token_type=AuthTokenType.USER,
163164
expires_at=None,
164165
)
165166
return serialize(token)

tests/sentry/api/serializers/test_apitoken.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
from sentry.api.serializers import ApiTokenSerializer
2+
from sentry.models.apitoken import ApiToken
3+
from sentry.silo.base import SiloMode
24
from sentry.testutils.cases import TestCase
5+
from sentry.testutils.helpers.options import override_options
6+
from sentry.testutils.silo import assume_test_silo_mode
37

48

59
class TestApiTokenSerializer(TestCase):
@@ -38,6 +42,33 @@ def test_when_flag_is_false(self) -> None:
3842
assert "token" not in serialized_object
3943

4044

45+
class TestRefreshTokens(TestApiTokenSerializer):
46+
def setUp(self) -> None:
47+
super().setUp()
48+
attrs = self._serializer.get_attrs(item_list=[self._token], user=self._user)
49+
attrs["application"] = None
50+
self._attrs = attrs
51+
52+
def test_no_refresh_token_on_user_token(self) -> None:
53+
serialized_object = self._serializer.serialize(
54+
obj=self._token, user=self._user, attrs=self._attrs
55+
)
56+
57+
assert "refreshToken" not in serialized_object
58+
59+
@override_options({"apitoken.save-hash-on-create": True})
60+
def test_refresh_token_on_non_user_token(self) -> None:
61+
with assume_test_silo_mode(SiloMode.CONTROL):
62+
token = ApiToken.objects.create(user=self._user)
63+
assert token.hashed_refresh_token is not None
64+
65+
serialized_object = self._serializer.serialize(
66+
obj=token, user=self._user, attrs=self._attrs
67+
)
68+
69+
assert "refreshToken" in serialized_object
70+
71+
4172
class TestLastTokenCharacters(TestApiTokenSerializer):
4273
def test_field_is_returned(self) -> None:
4374
attrs = self._serializer.get_attrs(item_list=[self._token], user=self._user)

tests/sentry/api/test_authentication.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,11 +181,11 @@ def setUp(self):
181181

182182
self.auth = UserAuthTokenAuthentication()
183183
self.org = self.create_organization(owner=self.user)
184-
self.token = "abc123"
185184
self.api_token = ApiToken.objects.create(
186-
token=self.token,
185+
token_type=AuthTokenType.USER,
187186
user=self.user,
188187
)
188+
self.token = self.api_token.plaintext_token
189189

190190
def test_authenticate(self):
191191
request = HttpRequest()

0 commit comments

Comments
 (0)