Skip to content

Commit fa51081

Browse files
Christinarlongmarkstorygetsantry[bot]
authored
ref(control_silo): Move over Authenticator model to Users module (#75857)
Part of moving control silo user related resources into the users module. Includes adding of types for the user model functions. Apart of (#73856) --------- Co-authored-by: Mark Story <[email protected]> Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
1 parent ef2f329 commit fa51081

34 files changed

+327
-262
lines changed

src/sentry/api/endpoints/auth_index.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@
2121
from sentry.auth.providers.saml2.provider import handle_saml_single_logout
2222
from sentry.auth.services.auth.impl import promote_request_rpc_user
2323
from sentry.auth.superuser import SUPERUSER_ORG_ID
24-
from sentry.models.authenticator import Authenticator
2524
from sentry.organizations.services.organization import organization_service
25+
from sentry.users.models.authenticator import Authenticator
2626
from sentry.utils import auth, json, metrics
2727
from sentry.utils.auth import DISABLE_SSO_CHECK_FOR_LOCAL_DEV, has_completed_sso, initiate_login
2828
from sentry.utils.settings import is_self_hosted
@@ -74,7 +74,8 @@ def _verify_user_via_inputs(validator: AuthVerifyValidator, request: Request) ->
7474
# See if we have a u2f challenge/response
7575
if "challenge" in validator.validated_data and "response" in validator.validated_data:
7676
try:
77-
interface: U2fInterface = Authenticator.objects.get_interface(request.user, "u2f")
77+
interface = Authenticator.objects.get_interface(request.user, "u2f")
78+
assert isinstance(interface, U2fInterface)
7879
if not interface.is_enrolled():
7980
raise LookupError()
8081
challenge = json.loads(validator.validated_data["challenge"])

src/sentry/api/endpoints/authenticator_index.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
from sentry.api.api_owners import ApiOwner
88
from sentry.api.api_publish_status import ApiPublishStatus
99
from sentry.api.base import Endpoint, control_silo_endpoint
10-
from sentry.models.authenticator import Authenticator
10+
from sentry.auth.authenticators.base import ActivationChallengeResult
11+
from sentry.users.models.authenticator import Authenticator
1112

1213

1314
@control_silo_endpoint
@@ -30,9 +31,11 @@ def get(self, request: Request) -> Response:
3031
except LookupError:
3132
return Response([])
3233

33-
challenge = interface.activate(request._request).challenge
34+
activation_result = interface.activate(request._request)
35+
assert isinstance(activation_result, ActivationChallengeResult)
36+
challenge_bytes = activation_result.challenge
3437

35-
webAuthnAuthenticationData = b64encode(challenge)
38+
webAuthnAuthenticationData = b64encode(challenge_bytes)
3639
challenge = {}
3740
challenge["webAuthnAuthenticationData"] = webAuthnAuthenticationData
3841

src/sentry/api/endpoints/user_authenticator_details.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@
1010
from sentry.api.bases.user import OrganizationUserPermission, UserEndpoint
1111
from sentry.api.decorators import sudo_required
1212
from sentry.api.serializers import serialize
13-
from sentry.auth.authenticators.u2f import decode_credential_id
13+
from sentry.auth.authenticators.recovery_code import RecoveryCodeInterface
14+
from sentry.auth.authenticators.sms import SmsInterface
15+
from sentry.auth.authenticators.u2f import U2fInterface, decode_credential_id
1416
from sentry.auth.staff import has_staff_option, is_active_staff
1517
from sentry.auth.superuser import is_active_superuser
16-
from sentry.models.authenticator import Authenticator
1718
from sentry.models.user import User
1819
from sentry.security.utils import capture_security_activity
20+
from sentry.users.models.authenticator import Authenticator
1921
from sentry.utils.auth import MFA_SESSION_KEY
2022

2123

@@ -95,10 +97,19 @@ def get(self, request: Request, user, auth_id) -> Response:
9597
response = serialize(interface)
9698

9799
if interface.interface_id == "recovery":
100+
assert isinstance(
101+
interface, RecoveryCodeInterface
102+
), "Interace must be RecoveryCodeInterface to get unused codes"
98103
response["codes"] = interface.get_unused_codes()
99104
if interface.interface_id == "sms":
105+
assert isinstance(
106+
interface, SmsInterface
107+
), "Interace must be SmsInterface to get phone number"
100108
response["phone"] = interface.phone_number
101109
if interface.interface_id == "u2f":
110+
assert isinstance(
111+
interface, U2fInterface
112+
), "Interace must be U2fInterface to get registered devices"
102113
response["devices"] = interface.get_registered_devices()
103114

104115
return Response(response)
@@ -147,10 +158,15 @@ def delete(self, request: Request, user: User, auth_id, interface_device_id=None
147158
interface = authenticator.interface
148159
# Remove a single device and not entire authentication method
149160
if interface.interface_id == "u2f" and interface_device_id is not None:
161+
assert isinstance(
162+
interface, U2fInterface
163+
), "Interace must be U2fInterface to get registered devices"
150164
device_name = interface.get_device_name(interface_device_id)
151165
# Can't remove if this is the last device, will return False if so
152166
if not interface.remove_u2f_device(interface_device_id):
153167
return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR)
168+
169+
assert interface.authenticator, "Interface must have an authenticator to save"
154170
interface.authenticator.save()
155171

156172
capture_security_activity(
@@ -198,6 +214,7 @@ def delete(self, request: Request, user: User, auth_id, interface_device_id=None
198214
backup_interfaces = [x for x in interfaces if x.is_backup_interface]
199215
if len(backup_interfaces) == len(interfaces):
200216
for iface in backup_interfaces:
217+
assert iface.authenticator, "Interface must have an authenticator to delete"
201218
iface.authenticator.delete()
202219

203220
# wait to generate entries until all pending writes

src/sentry/api/endpoints/user_authenticator_enroll.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@
1717
from sentry.api.invite_helper import ApiInviteHelper, remove_invite_details_from_session
1818
from sentry.api.serializers import serialize
1919
from sentry.auth.authenticators.base import EnrollmentStatus, NewEnrollmentDisallowed
20-
from sentry.auth.authenticators.sms import SMSRateLimitExceeded
21-
from sentry.models.authenticator import Authenticator
20+
from sentry.auth.authenticators.sms import SmsInterface, SMSRateLimitExceeded
21+
from sentry.auth.authenticators.totp import TotpInterface
22+
from sentry.auth.authenticators.u2f import U2fInterface
2223
from sentry.models.user import User
2324
from sentry.organizations.services.organization import organization_service
2425
from sentry.security.utils import capture_security_activity
26+
from sentry.users.models.authenticator import Authenticator
2527
from sentry.utils.auth import MFA_SESSION_KEY
2628

2729
logger = logging.getLogger(__name__)
@@ -149,15 +151,19 @@ def get(self, request: Request, user, interface_id) -> HttpResponse:
149151
response["form"] = get_serializer_field_metadata(serializer_map[interface_id]())
150152

151153
# U2fInterface has no 'secret' attribute
152-
try:
154+
if hasattr(interface, "secret"):
153155
response["secret"] = interface.secret
154-
except AttributeError:
155-
pass
156156

157157
if interface_id == "totp":
158+
assert isinstance(
159+
interface, TotpInterface
160+
), "Interface must be a TotpInterface to get provision URL"
158161
response["qrcode"] = interface.get_provision_url(user.email)
159162

160163
if interface_id == "u2f":
164+
assert isinstance(
165+
interface, U2fInterface
166+
), "Interface must be a U2fInterface to start enrollement"
161167
publicKeyCredentialCreate, state = interface.start_enrollment(user)
162168
response["challenge"] = {}
163169
response["challenge"]["webAuthnRegisterData"] = b64encode(publicKeyCredentialCreate)
@@ -224,14 +230,15 @@ def post(self, request: Request, user, interface_id) -> HttpResponse:
224230
else:
225231
return Response(ALREADY_ENROLLED_ERR, status=status.HTTP_400_BAD_REQUEST)
226232

227-
try:
233+
if hasattr(interface, "secret"):
228234
interface.secret = request.data["secret"]
229-
except KeyError:
230-
pass
231235

232236
context = {}
233237
# Need to update interface with phone number before validating OTP
234238
if "phone" in request.data:
239+
assert isinstance(
240+
interface, SmsInterface
241+
), "Interface must be a SmsInterface to get phone number"
235242
interface.phone_number = serializer.data["phone"]
236243

237244
# Disregarding value of 'otp', if no OTP was provided,
@@ -263,6 +270,8 @@ def post(self, request: Request, user, interface_id) -> HttpResponse:
263270
if "webauthn_register_state" not in request.session:
264271
return Response(INVALID_AUTH_STATE, status=status.HTTP_400_BAD_REQUEST)
265272
state = request.session["webauthn_register_state"]
273+
274+
assert isinstance(interface, U2fInterface), "Interface must be a U2fInterface to enroll"
266275
interface.try_enroll(
267276
serializer.data["challenge"],
268277
serializer.data["response"],

src/sentry/api/endpoints/user_authenticator_index.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from sentry.api.base import control_silo_endpoint
77
from sentry.api.bases.user import UserEndpoint
88
from sentry.api.serializers import serialize
9-
from sentry.models.authenticator import Authenticator
9+
from sentry.users.models.authenticator import Authenticator
1010

1111

1212
@control_silo_endpoint

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
from sentry.app import env
1818
from sentry.auth.elevated_mode import has_elevated_mode
1919
from sentry.hybridcloud.services.organization_mapping import organization_mapping_service
20-
from sentry.models.authenticator import Authenticator
2120
from sentry.models.authidentity import AuthIdentity
2221
from sentry.models.avatars.user_avatar import UserAvatar
2322
from sentry.models.options.user_option import UserOption
@@ -29,6 +28,7 @@
2928
from sentry.models.userpermission import UserPermission
3029
from sentry.models.userrole import UserRoleUser
3130
from sentry.organizations.services.organization import RpcOrganizationSummary
31+
from sentry.users.models.authenticator import Authenticator
3232
from sentry.users.services.user import RpcUser
3333
from sentry.utils.avatar import get_gravatar_url
3434

src/sentry/auth/authenticators/base.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from typing import TYPE_CHECKING, Any, Literal, Self
55

66
from django.core.cache import cache
7+
from django.http import HttpRequest
78
from django.utils import timezone
89
from django.utils.translation import gettext_lazy as _
910
from rest_framework.request import Request
@@ -14,8 +15,8 @@
1415
if TYPE_CHECKING:
1516
from django.utils.functional import _StrPromise
1617

17-
from sentry.models.authenticator import Authenticator
1818
from sentry.models.user import User
19+
from sentry.users.models.authenticator import Authenticator
1920

2021

2122
class ActivationResult:
@@ -32,8 +33,8 @@ def __init__(
3233
self.type = type
3334
self.message = message
3435

35-
def __str__(self):
36-
return self.message
36+
def __str__(self) -> str:
37+
return str(self.message)
3738

3839
def __repr__(self) -> str:
3940
return f"<{type(self).__name__}: {self.message}>"
@@ -70,6 +71,9 @@ class AuthenticatorInterface:
7071
is_available = True
7172
allow_multi_enrollment = False
7273
allow_rotation_in_place = False
74+
authenticator: Authenticator | None
75+
status: EnrollmentStatus
76+
_unbound_config: dict[Any, Any]
7377

7478
def __init__(
7579
self, authenticator=None, status: EnrollmentStatus = EnrollmentStatus.EXISTING
@@ -136,21 +140,22 @@ def generate_new_config(self) -> dict[str, Any]:
136140
"""This method is invoked if a new config is required."""
137141
return {}
138142

139-
def activate(self, request: Request):
143+
def activate(self, request: HttpRequest) -> ActivationResult | None:
140144
"""If an authenticator overrides this then the method is called
141145
when the dialog for authentication is brought up. The returned string
142146
is then rendered in the UI.
143147
"""
144148
# This method needs to be empty for the default
145149
# `requires_activation` property to make sense.
150+
return None
146151

147152
def enroll(self, user: User) -> None:
148153
"""Invoked to enroll a user for this interface. If already enrolled
149154
an error is raised.
150155
151156
If `disallow_new_enrollment` is `True`, raises exception: `NewEnrollmentDisallowed`.
152157
"""
153-
from sentry.models.authenticator import Authenticator
158+
from sentry.users.models.authenticator import Authenticator
154159

155160
if self.disallow_new_enrollment:
156161
raise NewEnrollmentDisallowed
@@ -192,7 +197,7 @@ def validate_response(self, request: Request, challenge, response):
192197
return False
193198

194199

195-
class OtpMixin:
200+
class OtpMixin(AuthenticatorInterface):
196201
# mixed in from base class
197202
config: dict[str, Any]
198203
authenticator: Authenticator | None

src/sentry/auth/authenticators/recovery_code.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ def regenerate_codes(self, save=True):
4646
if not self.is_enrolled():
4747
raise RuntimeError("Interface is not enrolled")
4848
self.config.update(self.generate_new_config())
49+
50+
assert self.authenticator, "Cannot regenerate codes without self.authenticator"
4951
self.authenticator.reset_fields(save=False)
5052
if save:
5153
self.authenticator.save()

src/sentry/auth/authenticators/sms.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from sentry.utils.otp import TOTP
1313
from sentry.utils.sms import phone_number_as_e164, send_sms, sms_available
1414

15-
from .base import ActivationMessageResult, AuthenticatorInterface, OtpMixin
15+
from .base import ActivationMessageResult, OtpMixin
1616

1717
if TYPE_CHECKING:
1818
from django.utils.functional import _StrPromise
@@ -28,7 +28,7 @@ def __init__(self, phone_number, user_id, remote_ip):
2828
self.remote_ip = remote_ip
2929

3030

31-
class SmsInterface(OtpMixin, AuthenticatorInterface):
31+
class SmsInterface(OtpMixin):
3232
"""This interface sends OTP codes via text messages to the user."""
3333

3434
type = 2

src/sentry/auth/authenticators/totp.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
from django.utils.translation import gettext_lazy as _
22

3-
from .base import AuthenticatorInterface, OtpMixin
3+
from .base import OtpMixin
44

55

6-
class TotpInterface(OtpMixin, AuthenticatorInterface):
6+
class TotpInterface(OtpMixin):
77
"""This interface uses TOTP with an authenticator."""
88

99
type = 1

src/sentry/migrations/0001_squashed_0484_break_org_member_user_fk.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,14 @@
2828
import sentry.models.apiapplication
2929
import sentry.models.apigrant
3030
import sentry.models.apitoken
31-
import sentry.models.authenticator
3231
import sentry.models.broadcast
3332
import sentry.models.groupshare
3433
import sentry.models.integrations.sentry_app
3534
import sentry.models.integrations.sentry_app_installation
3635
import sentry.models.scheduledeletion
3736
import sentry.models.servicehook
3837
import sentry.models.user
38+
import sentry.users.models.authenticator
3939
import sentry.utils.security.hash
4040
from sentry.new_migrations.migrations import CheckedMigration
4141

@@ -9149,7 +9149,7 @@ class Migration(CheckedMigration):
91499149
migrations.AlterField(
91509150
model_name="authenticator",
91519151
name="config",
9152-
field=sentry.models.authenticator.AuthenticatorConfig(editable=False),
9152+
field=sentry.users.models.authenticator.AuthenticatorConfig(editable=False),
91539153
),
91549154
migrations.CreateModel(
91559155
name="MonitorEnvironment",

src/sentry/models/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from sentry.users.models.authenticator import * # NOQA
12
from sentry.users.models.email import * # NOQA
23

34
from .activity import * # NOQA
@@ -10,7 +11,6 @@
1011
from .artifactbundle import * # NOQA
1112
from .assistant import * # NOQA
1213
from .auditlogentry import * # NOQA
13-
from .authenticator import * # NOQA
1414
from .authidentity import * # NOQA
1515
from .authidentityreplica import * # NOQA
1616
from .authprovider import * # NOQA

0 commit comments

Comments
 (0)