11from __future__ import annotations
22
3+ import hashlib
34import secrets
45from collections .abc import Collection
56from datetime import timedelta
2223from sentry .types .token import AuthTokenType
2324
2425DEFAULT_EXPIRATION = timedelta (days = 30 )
26+ TOKEN_REDACTED = "***REDACTED***"
2527
2628
2729def 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
36103class 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
0 commit comments