Skip to content

Commit 56cf784

Browse files
ref: remove PickledObjectField from Authenticator model (#93515)
<!-- Describe your PR here. -->
1 parent a1e6691 commit 56cf784

File tree

12 files changed

+88
-29
lines changed

12 files changed

+88
-29
lines changed

migrations_lockfile.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ preprod: 0004_add_django_jsonfield
2525

2626
replays: 0001_squashed_0005_drop_replay_index
2727

28-
sentry: 0928_move_notifications_models
28+
sentry: 0929_no_pickle_authenticator
2929

3030
social_auth: 0001_squashed_0002_default_auto_field
3131

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ module = [
185185
"sentry.api.serializers.rest_framework.group_notes",
186186
"sentry.audit_log.services.*",
187187
"sentry.auth.access",
188+
"sentry.auth.authenticators.recovery_code",
188189
"sentry.auth.manager",
189190
"sentry.auth.services.*",
190191
"sentry.auth.view",

src/sentry/auth/authenticators/recovery_code.py

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
1+
from __future__ import annotations
2+
13
import hmac
24
from base64 import b32encode
35
from binascii import hexlify
46
from hashlib import sha1
57
from os import urandom
8+
from typing import TYPE_CHECKING, Any
69

7-
from django.utils.encoding import force_bytes
810
from django.utils.translation import gettext_lazy as _
911

1012
from .base import AuthenticatorInterface
1113

14+
if TYPE_CHECKING:
15+
from sentry.users.models.authenticator import Authenticator
16+
1217

1318
class RecoveryCodeInterface(AuthenticatorInterface):
1419
"""A backup interface that is based on static recovery codes."""
@@ -26,20 +31,20 @@ class RecoveryCodeInterface(AuthenticatorInterface):
2631
remove_button = None
2732
is_backup_interface = True
2833

29-
def __init__(self, authenticator=None) -> None:
30-
AuthenticatorInterface.__init__(self, authenticator)
34+
def __init__(self, authenticator: Authenticator | None = None) -> None:
35+
super().__init__(authenticator)
3136

32-
def get_codes(self):
37+
def get_codes(self) -> list[str]:
3338
rv = []
3439
if self.is_enrolled():
35-
h = hmac.new(key=force_bytes(self.config["salt"]), msg=None, digestmod=sha1)
40+
h = hmac.new(key=self.config["salt"].encode(), msg=None, digestmod=sha1)
3641
for x in range(10):
37-
h.update(("%s|" % x).encode("utf-8"))
38-
rv.append(b32encode(h.digest())[:8].decode("utf-8"))
42+
h.update(f"{x}|".encode())
43+
rv.append(b32encode(h.digest())[:8].decode())
3944
return rv
4045

41-
def generate_new_config(self):
42-
salt = hexlify(urandom(16))
46+
def generate_new_config(self) -> dict[str, Any]:
47+
salt = hexlify(urandom(16)).decode()
4348
return {"salt": salt, "used": 0}
4449

4550
def regenerate_codes(self, save: bool = True) -> None:
@@ -52,7 +57,7 @@ def regenerate_codes(self, save: bool = True) -> None:
5257
if save:
5358
self.authenticator.save()
5459

55-
def validate_otp(self, otp) -> bool:
60+
def validate_otp(self, otp: str) -> bool:
5661
mask = self.config["used"]
5762
code = otp.strip().replace("-", "").upper()
5863
for idx, ref_code in enumerate(self.get_codes()):
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Generated by Django 5.2.1 on 2025-06-13 15:44
2+
3+
from django.db import migrations
4+
5+
import sentry.users.models.authenticator
6+
from sentry.new_migrations.migrations import CheckedMigration
7+
from sentry.new_migrations.monkey.special import SafeRunSQL
8+
9+
10+
class Migration(CheckedMigration):
11+
# This flag is used to mark that a migration shouldn't be automatically run in production.
12+
# This should only be used for operations where it's safe to run the migration after your
13+
# code has deployed. So this should not be used for most operations that alter the schema
14+
# of a table.
15+
# Here are some things that make sense to mark as post deployment:
16+
# - Large data migrations. Typically we want these to be run manually so that they can be
17+
# monitored and not block the deploy for a long period of time while they run.
18+
# - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to
19+
# run this outside deployments so that we don't block them. Note that while adding an index
20+
# is a schema change, it's completely safe to run the operation after the code has deployed.
21+
# Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment
22+
23+
is_post_deployment = True
24+
25+
dependencies = [
26+
("sentry", "0928_move_notifications_models"),
27+
]
28+
29+
operations = [
30+
migrations.AlterField(
31+
model_name="authenticator",
32+
name="config",
33+
field=sentry.users.models.authenticator.AuthenticatorConfig(),
34+
),
35+
SafeRunSQL(
36+
"""
37+
ALTER TABLE auth_authenticator ALTER COLUMN config TYPE jsonb USING config::jsonb;
38+
""",
39+
reverse_sql="""
40+
ALTER TABLE auth_authenticator ALTER COLUMN config TYPE text;
41+
""",
42+
hints={"tables": ["auth_authenticator"]},
43+
),
44+
]

src/sentry/testutils/helpers/backups.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,7 @@ def create_exhaustive_user(
372372
first_seen=datetime(2012, 4, 5, 3, 29, 45, tzinfo=UTC),
373373
last_seen=datetime(2012, 4, 5, 3, 29, 45, tzinfo=UTC),
374374
)
375-
Authenticator.objects.create(user=user, type=1)
375+
Authenticator.objects.create(user=user, type=1, config={})
376376

377377
if is_admin:
378378
self.add_user_permission(user, "users.admin")

src/sentry/users/models/authenticator.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
from django.contrib.auth.models import AnonymousUser
88
from django.db import models
9+
from django.db.backends.base.base import BaseDatabaseWrapper
10+
from django.db.models.expressions import Expression
911
from django.utils import timezone
1012
from django.utils.functional import cached_property
1113
from django.utils.translation import gettext_lazy as _
@@ -28,7 +30,6 @@
2830
FlexibleForeignKey,
2931
control_silo_model,
3032
)
31-
from sentry.db.models.fields.picklefield import PickledObjectField
3233
from sentry.db.models.manager.base import BaseManager
3334
from sentry.hybridcloud.models.outbox import ControlOutboxBase
3435
from sentry.hybridcloud.outbox.base import ControlOutboxProducingModel
@@ -128,11 +129,11 @@ def bulk_users_have_2fa(self, user_ids: list[int]) -> dict[int, bool]:
128129
return {id: id in authenticators for id in user_ids}
129130

130131

131-
class AuthenticatorConfig(PickledObjectField):
132+
class AuthenticatorConfig(models.JSONField):
132133
def _is_devices_config(self, value: Any) -> bool:
133134
return isinstance(value, dict) and "devices" in value
134135

135-
def get_db_prep_value(self, value: Any, *args: Any, **kwargs: Any) -> str | None:
136+
def get_db_prep_value(self, value: Any, *args: Any, **kwargs: Any) -> Any:
136137
if self._is_devices_config(value):
137138
# avoid mutating the original object
138139
value = copy.deepcopy(value)
@@ -143,8 +144,10 @@ def get_db_prep_value(self, value: Any, *args: Any, **kwargs: Any) -> str | None
143144

144145
return super().get_db_prep_value(value, *args, **kwargs)
145146

146-
def to_python(self, value: Any) -> Any | None:
147-
ret = super().to_python(value)
147+
def from_db_value(
148+
self, value: str | None, expression: Expression, connection: BaseDatabaseWrapper
149+
) -> Any | None:
150+
ret = super().from_db_value(value, expression, connection)
148151
if self._is_devices_config(ret):
149152
for device in ret["devices"]:
150153
if isinstance(device["binding"], str):

tests/sentry/api/endpoints/test_organization_member_index.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -359,8 +359,8 @@ def test_2fa_enabled_query(self):
359359

360360
# Two authenticators to ensure the user list is distinct
361361
with assume_test_silo_mode(SiloMode.CONTROL):
362-
Authenticator.objects.create(user_id=member_2fa.user_id, type=1)
363-
Authenticator.objects.create(user_id=member_2fa.user_id, type=2)
362+
Authenticator.objects.create(user_id=member_2fa.user_id, type=1, config={})
363+
Authenticator.objects.create(user_id=member_2fa.user_id, type=2, config={})
364364

365365
response = self.get_success_response(
366366
self.organization.slug, qs_params={"query": "has2fa:true"}

tests/sentry/hybridcloud/rpc/test_rpc_model.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ def _get_rpc_model_subclasses(self) -> set[type[RpcModel]]:
2929

3030
def test_rpc_model_equals_method(self) -> None:
3131
orm_user = self.create_user()
32-
Authenticator.objects.create(user=orm_user, type=1)
32+
Authenticator.objects.create(user=orm_user, type=1, config={})
3333

3434
user1 = user_service.get_user(orm_user.id)
3535
user2 = user_service.get_user(orm_user.id)

tests/sentry/users/api/endpoints/test_user_authenticator_details.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ def test_delete_superuser(self):
294294
user = self.create_user(email="[email protected]", is_superuser=True)
295295

296296
with override_options({"sms.twilio-account": "twilio-account"}):
297-
auth = Authenticator.objects.create(type=2, user=user) # sms
297+
auth = Authenticator.objects.create(type=2, user=user, config={}) # sms
298298
available_auths = Authenticator.objects.all_interfaces_for_user(
299299
user, ignore_backup=True
300300
)
@@ -313,7 +313,7 @@ def test_delete_staff(self):
313313
staff_user = self.create_user(email="[email protected]", is_staff=True)
314314

315315
with override_options({"sms.twilio-account": "twilio-account"}):
316-
auth = Authenticator.objects.create(type=2, user=staff_user) # sms
316+
auth = Authenticator.objects.create(type=2, user=staff_user, config={}) # sms
317317
available_auths = Authenticator.objects.all_interfaces_for_user(
318318
staff_user, ignore_backup=True
319319
)
@@ -330,7 +330,7 @@ def test_delete_staff(self):
330330

331331
def test_cannot_delete_without_superuser_or_staff(self):
332332
user = self.create_user(email="[email protected]", is_superuser=False, is_staff=False)
333-
auth = Authenticator.objects.create(type=3, user=user) # u2f
333+
auth = Authenticator.objects.create(type=3, user=user, config={}) # u2f
334334

335335
actor = self.create_user(email="[email protected]", is_superuser=False, is_staff=False)
336336
self.login_as(user=actor)

tests/sentry/users/api/serializers/test_user.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ def test_simple(self):
2121
assert result["has2fa"] is False
2222

2323
Authenticator.objects.create(
24-
type=available_authenticators(ignore_backup=True)[0].type, user=user
24+
type=available_authenticators(ignore_backup=True)[0].type, user=user, config={}
2525
)
2626

2727
result = serialize(user)
@@ -63,7 +63,7 @@ def test_simple(self):
6363
auth_provider=auth_provider, ident=user.email, user=user
6464
)
6565
auth = Authenticator.objects.create(
66-
type=available_authenticators(ignore_backup=True)[0].type, user=user
66+
type=available_authenticators(ignore_backup=True)[0].type, user=user, config={}
6767
)
6868

6969
result = serialize(user, user, DetailedUserSerializer())
@@ -113,7 +113,7 @@ def setUp(self):
113113
auth_provider=auth_provider, ident=self.user.email, user=self.user
114114
)
115115
self.auth = Authenticator.objects.create(
116-
type=available_authenticators(ignore_backup=True)[0].type, user=self.user
116+
type=available_authenticators(ignore_backup=True)[0].type, user=self.user, config={}
117117
)
118118

119119
def test_simple(self):

tests/sentry/users/models/test_authenticator.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
from django.db import connection
2+
from django.db.models.expressions import Expression
13
from fido2.ctap2 import AuthenticatorData
24
from fido2.utils import sha256
35

46
from sentry.auth.authenticators.recovery_code import RecoveryCodeInterface
57
from sentry.auth.authenticators.totp import TotpInterface
68
from sentry.auth.authenticators.u2f import create_credential_object
79
from sentry.testutils.cases import TestCase
10+
from sentry.testutils.pytest.fixtures import django_db_all
811
from sentry.testutils.silo import control_silo_test
912
from sentry.users.models.authenticator import Authenticator, AuthenticatorConfig
1013

@@ -39,6 +42,7 @@ def test_bulk_users_have_2fa(self):
3942
}
4043

4144

45+
@django_db_all
4246
def test_authenticator_config_compatibility():
4347
field_json = AuthenticatorConfig()
4448

@@ -71,4 +75,6 @@ def test_authenticator_config_compatibility():
7175
]
7276
}
7377

74-
assert field_json.to_python(field_json.get_db_prep_value(value)) == value
78+
encoded = field_json.get_db_prep_value(value, connection=connection)
79+
encoded_s = encoded.dumps(encoded.adapted)
80+
assert field_json.from_db_value(encoded_s, Expression("config"), connection) == value

tests/sentry/users/models/test_user.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -225,8 +225,8 @@ def test_simple(self):
225225

226226
Authenticator.objects.get(user=from_user, type=1)
227227
to_auth_dup = Authenticator.objects.get(user=to_user, type=1)
228-
from_auth_uniq = Authenticator.objects.create(user=from_user, type=2)
229-
to_auth_uniq = Authenticator.objects.create(user=to_user, type=3)
228+
from_auth_uniq = Authenticator.objects.create(user=from_user, type=2, config={})
229+
to_auth_uniq = Authenticator.objects.create(user=to_user, type=3, config={})
230230

231231
from_user.merge_to(to_user)
232232

0 commit comments

Comments
 (0)