|
@@ -1,7 +1,7 @@
|
|
|
from __future__ import annotations
|
|
|
|
|
|
from enum import Enum
|
|
|
-from typing import TYPE_CHECKING, Any
|
|
|
+from typing import TYPE_CHECKING, Any, Literal, Self
|
|
|
|
|
|
from django.core.cache import cache
|
|
|
from django.utils import timezone
|
|
@@ -15,6 +15,7 @@ if TYPE_CHECKING:
|
|
|
from django.utils.functional import _StrPromise
|
|
|
|
|
|
from sentry.models.authenticator import Authenticator
|
|
|
+ from sentry.models.user import User
|
|
|
|
|
|
|
|
|
class ActivationResult:
|
|
@@ -22,7 +23,11 @@ class ActivationResult:
|
|
|
|
|
|
|
|
|
class ActivationMessageResult(ActivationResult):
|
|
|
- def __init__(self, message, type="info"):
|
|
|
+ def __init__(
|
|
|
+ self,
|
|
|
+ message: str | _StrPromise,
|
|
|
+ type: Literal["error", "warning", "info"] = "info",
|
|
|
+ ) -> None:
|
|
|
assert type in ("error", "warning", "info")
|
|
|
self.type = type
|
|
|
self.message = message
|
|
@@ -30,14 +35,14 @@ class ActivationMessageResult(ActivationResult):
|
|
|
def __str__(self):
|
|
|
return self.message
|
|
|
|
|
|
- def __repr__(self):
|
|
|
+ def __repr__(self) -> str:
|
|
|
return f"<{type(self).__name__}: {self.message}>"
|
|
|
|
|
|
|
|
|
class ActivationChallengeResult(ActivationResult):
|
|
|
type = "challenge"
|
|
|
|
|
|
- def __init__(self, challenge):
|
|
|
+ def __init__(self, challenge: bytes) -> None:
|
|
|
self.challenge = challenge
|
|
|
|
|
|
|
|
@@ -66,25 +71,27 @@ class AuthenticatorInterface:
|
|
|
allow_multi_enrollment = False
|
|
|
allow_rotation_in_place = False
|
|
|
|
|
|
- def __init__(self, authenticator=None, status=EnrollmentStatus.EXISTING):
|
|
|
+ def __init__(
|
|
|
+ self, authenticator=None, status: EnrollmentStatus = EnrollmentStatus.EXISTING
|
|
|
+ ) -> None:
|
|
|
self.authenticator = authenticator
|
|
|
self.status = status
|
|
|
|
|
|
@classmethod
|
|
|
- def generate(cls, status):
|
|
|
+ def generate(cls, status: EnrollmentStatus) -> Self:
|
|
|
# Convenience method to build new instances either from the
|
|
|
# class or existing instances. That is, it's nicer than doing
|
|
|
# `type(interface)()`.
|
|
|
return cls(status=status)
|
|
|
|
|
|
- def is_enrolled(self):
|
|
|
+ def is_enrolled(self) -> bool:
|
|
|
"""Returns `True` if the interfaces is enrolled (eg: has an
|
|
|
authenticator for a user attached).
|
|
|
"""
|
|
|
return self.authenticator is not None
|
|
|
|
|
|
@property
|
|
|
- def disallow_new_enrollment(self):
|
|
|
+ def disallow_new_enrollment(self) -> bool:
|
|
|
"""If new enrollments of this 2FA interface type are no allowed
|
|
|
this returns `True`.
|
|
|
|
|
@@ -95,7 +102,7 @@ class AuthenticatorInterface:
|
|
|
return bool(options.get(f"{self.interface_id}.disallow-new-enrollment"))
|
|
|
|
|
|
@property
|
|
|
- def requires_activation(self):
|
|
|
+ def requires_activation(self) -> bool:
|
|
|
"""If the interface has an activation method that needs to be
|
|
|
called this returns `True`.
|
|
|
"""
|
|
@@ -109,7 +116,7 @@ class AuthenticatorInterface:
|
|
|
return type(self).validate_otp is not AuthenticatorInterface.validate_otp
|
|
|
|
|
|
@property
|
|
|
- def config(self):
|
|
|
+ def config(self) -> dict[str, Any]:
|
|
|
"""Returns the configuration dictionary for this interface. If
|
|
|
the interface is registered with an authenticator (eg: it is
|
|
|
enrolled) then the authenticator's config is returned, otherwise
|
|
@@ -125,7 +132,7 @@ class AuthenticatorInterface:
|
|
|
rv = self._unbound_config = self.generate_new_config()
|
|
|
return rv
|
|
|
|
|
|
- def generate_new_config(self):
|
|
|
+ def generate_new_config(self) -> dict[str, Any]:
|
|
|
"""This method is invoked if a new config is required."""
|
|
|
return {}
|
|
|
|
|
@@ -137,7 +144,7 @@ class AuthenticatorInterface:
|
|
|
# This method needs to be empty for the default
|
|
|
# `requires_activation` property to make sense.
|
|
|
|
|
|
- def enroll(self, user):
|
|
|
+ def enroll(self, user: User) -> None:
|
|
|
"""Invoked to enroll a user for this interface. If already enrolled
|
|
|
an error is raised.
|
|
|
|
|
@@ -158,7 +165,7 @@ class AuthenticatorInterface:
|
|
|
self.authenticator.config = self.config
|
|
|
self.authenticator.save()
|
|
|
|
|
|
- def rotate_in_place(self):
|
|
|
+ def rotate_in_place(self) -> None:
|
|
|
if not self.allow_rotation_in_place:
|
|
|
raise Exception("This interface does not allow rotation in place")
|
|
|
if self.authenticator is None:
|
|
@@ -169,7 +176,7 @@ class AuthenticatorInterface:
|
|
|
self.authenticator.last_used_at = None
|
|
|
self.authenticator.save()
|
|
|
|
|
|
- def validate_otp(self, otp):
|
|
|
+ def validate_otp(self, otp: str) -> bool:
|
|
|
"""This method is invoked for an OTP response and has to return
|
|
|
`True` or `False` based on the validity of the OTP response. Note
|
|
|
that this can be called with otp responses from other interfaces.
|
|
@@ -190,25 +197,27 @@ class OtpMixin:
|
|
|
config: dict[str, Any]
|
|
|
authenticator: Authenticator | None
|
|
|
|
|
|
- def generate_new_config(self):
|
|
|
+ def generate_new_config(self) -> dict[str, Any]:
|
|
|
return {"secret": generate_secret_key()}
|
|
|
|
|
|
@property
|
|
|
- def secret(self):
|
|
|
+ def secret(self) -> str:
|
|
|
return self.config["secret"]
|
|
|
|
|
|
@secret.setter
|
|
|
- def secret(self, secret):
|
|
|
+ def secret(self, secret: str) -> None:
|
|
|
self.config["secret"] = secret
|
|
|
|
|
|
- def make_otp(self):
|
|
|
+ def make_otp(self) -> TOTP:
|
|
|
return TOTP(self.secret)
|
|
|
|
|
|
- def _get_otp_counter_cache_key(self, counter):
|
|
|
+ def _get_otp_counter_cache_key(self, counter: int) -> str | None:
|
|
|
if self.authenticator is not None:
|
|
|
return f"used-otp-counters:{self.authenticator.user_id}:{counter}"
|
|
|
+ else:
|
|
|
+ return None
|
|
|
|
|
|
- def check_otp_counter(self, counter):
|
|
|
+ def check_otp_counter(self, counter: int) -> bool:
|
|
|
# OTP uses an internal counter that increments every 30 seconds.
|
|
|
# A hash function generates a six digit code based on the counter
|
|
|
# and a secret key. If the generated PIN was used it is marked in
|
|
@@ -217,13 +226,13 @@ class OtpMixin:
|
|
|
cache_key = self._get_otp_counter_cache_key(counter)
|
|
|
return cache_key is None or cache.get(cache_key) != "1"
|
|
|
|
|
|
- def mark_otp_counter_used(self, counter):
|
|
|
+ def mark_otp_counter_used(self, counter: int) -> None:
|
|
|
cache_key = self._get_otp_counter_cache_key(counter)
|
|
|
if cache_key is not None:
|
|
|
# Mark us used for three windows
|
|
|
cache.set(cache_key, "1", timeout=120)
|
|
|
|
|
|
- def validate_otp(self, otp):
|
|
|
+ def validate_otp(self, otp: str) -> bool:
|
|
|
if not otp:
|
|
|
return False
|
|
|
otp = otp.strip().replace("-", "").replace(" ", "")
|