1
1
from __future__ import annotations
2
2
3
+ import hashlib
3
4
import secrets
4
5
from collections .abc import Collection
5
6
from datetime import timedelta
22
23
from sentry .types .token import AuthTokenType
23
24
24
25
DEFAULT_EXPIRATION = timedelta (days = 30 )
26
+ TOKEN_REDACTED = "***REDACTED***"
25
27
26
28
27
29
def default_expiration ():
28
30
return timezone .now () + DEFAULT_EXPIRATION
29
31
30
32
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
+
32
37
return secrets .token_hex (nbytes = 32 )
33
38
34
39
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
+
35
102
@control_silo_only_model
36
103
class ApiToken (ReplicatedControlModel , HasApiScopes ):
37
104
__relocation_scope__ = {RelocationScope .Global , RelocationScope .Config }
@@ -50,7 +117,7 @@ class ApiToken(ReplicatedControlModel, HasApiScopes):
50
117
expires_at = models .DateTimeField (null = True , default = default_expiration )
51
118
date_added = models .DateTimeField (default = timezone .now )
52
119
53
- objects : ClassVar [ControlOutboxProducingManager [ApiToken ]] = ControlOutboxProducingManager (
120
+ objects : ClassVar [ControlOutboxProducingManager [ApiToken ]] = ApiTokenManager (
54
121
cache_fields = ("token" ,)
55
122
)
56
123
@@ -63,12 +130,117 @@ class Meta:
63
130
def __str__ (self ):
64
131
return force_str (self .token )
65
132
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
+
66
209
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
+
67
221
if options .get ("apitoken.auto-add-last-chars" ):
68
222
token_last_characters = self .token [- 4 :]
69
223
self .token_last_characters = token_last_characters
70
224
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 )
72
244
73
245
def outbox_region_names (self ) -> Collection [str ]:
74
246
return list (find_all_region_names ())
@@ -104,10 +276,16 @@ def get_allowed_origins(self):
104
276
return ()
105
277
106
278
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
+
107
282
if expires_at is None :
108
283
expires_at = timezone .now () + DEFAULT_EXPIRATION
109
284
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 )
111
289
112
290
def get_relocation_scope (self ) -> RelocationScope :
113
291
if self .application_id is not None :
@@ -125,9 +303,9 @@ def write_relocation_import(
125
303
)
126
304
existing = self .__class__ .objects .filter (query ).first ()
127
305
if existing :
128
- self .token = generate_token ()
306
+ self .token = generate_token (token_type = self . token_type )
129
307
if self .refresh_token is not None :
130
- self .refresh_token = generate_token ()
308
+ self .refresh_token = generate_token (token_type = self . token_type )
131
309
if self .expires_at is not None :
132
310
self .expires_at = timezone .now () + DEFAULT_EXPIRATION
133
311
0 commit comments