Fix #1039: setting to enforce email signup verification
This commit is contained in:
parent
67857d931c
commit
93f2c9f83c
16 changed files with 365 additions and 30 deletions
|
|
@ -1,3 +1,4 @@
|
|||
from django.conf import settings
|
||||
from django.utils.encoding import smart_text
|
||||
from django.utils.translation import ugettext as _
|
||||
from rest_framework import exceptions
|
||||
|
|
@ -5,7 +6,48 @@ from rest_framework_jwt import authentication
|
|||
from rest_framework_jwt.settings import api_settings
|
||||
|
||||
|
||||
class JSONWebTokenAuthenticationQS(authentication.BaseJSONWebTokenAuthentication):
|
||||
def should_verify_email(user):
|
||||
if user.is_superuser:
|
||||
return False
|
||||
has_unverified_email = not user.has_verified_primary_email
|
||||
mandatory_verification = settings.ACCOUNT_EMAIL_VERIFICATION != "optional"
|
||||
return has_unverified_email and mandatory_verification
|
||||
|
||||
|
||||
class BaseJsonWebTokenAuth(object):
|
||||
def authenticate_credentials(self, payload):
|
||||
"""
|
||||
We have to implement this method by hand to ensure we can check that the
|
||||
User has a verified email, if required
|
||||
"""
|
||||
User = authentication.get_user_model()
|
||||
username = authentication.jwt_get_username_from_payload(payload)
|
||||
|
||||
if not username:
|
||||
msg = _("Invalid payload.")
|
||||
raise exceptions.AuthenticationFailed(msg)
|
||||
|
||||
try:
|
||||
user = User.objects.get_by_natural_key(username)
|
||||
except User.DoesNotExist:
|
||||
msg = _("Invalid signature.")
|
||||
raise exceptions.AuthenticationFailed(msg)
|
||||
|
||||
if not user.is_active:
|
||||
msg = _("User account is disabled.")
|
||||
raise exceptions.AuthenticationFailed(msg)
|
||||
|
||||
if should_verify_email(user):
|
||||
|
||||
msg = _("You need to verify your email address.")
|
||||
raise exceptions.AuthenticationFailed(msg)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
class JSONWebTokenAuthenticationQS(
|
||||
BaseJsonWebTokenAuth, authentication.BaseJSONWebTokenAuthentication
|
||||
):
|
||||
|
||||
www_authenticate_realm = "api"
|
||||
|
||||
|
|
@ -22,7 +64,9 @@ class JSONWebTokenAuthenticationQS(authentication.BaseJSONWebTokenAuthentication
|
|||
)
|
||||
|
||||
|
||||
class BearerTokenHeaderAuth(authentication.BaseJSONWebTokenAuthentication):
|
||||
class BearerTokenHeaderAuth(
|
||||
BaseJsonWebTokenAuth, authentication.BaseJSONWebTokenAuthentication
|
||||
):
|
||||
"""
|
||||
For backward compatibility purpose, we used Authorization: JWT <token>
|
||||
but Authorization: Bearer <token> is probably better.
|
||||
|
|
@ -65,7 +109,9 @@ class BearerTokenHeaderAuth(authentication.BaseJSONWebTokenAuthentication):
|
|||
return auth
|
||||
|
||||
|
||||
class JSONWebTokenAuthentication(authentication.JSONWebTokenAuthentication):
|
||||
class JSONWebTokenAuthentication(
|
||||
BaseJsonWebTokenAuth, authentication.JSONWebTokenAuthentication
|
||||
):
|
||||
def authenticate(self, request):
|
||||
auth = super().authenticate(request)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import hashlib
|
|||
|
||||
from rest_framework import authentication, exceptions
|
||||
|
||||
from funkwhale_api.common import authentication as common_authentication
|
||||
from funkwhale_api.users.models import User
|
||||
|
||||
|
||||
|
|
@ -18,19 +19,26 @@ def authenticate(username, password):
|
|||
if password.startswith("enc:"):
|
||||
password = password.replace("enc:", "", 1)
|
||||
password = binascii.unhexlify(password).decode("utf-8")
|
||||
user = User.objects.select_related("actor").get(
|
||||
username__iexact=username, is_active=True, subsonic_api_token=password
|
||||
user = (
|
||||
User.objects.all()
|
||||
.for_auth()
|
||||
.get(username__iexact=username, is_active=True, subsonic_api_token=password)
|
||||
)
|
||||
except (User.DoesNotExist, binascii.Error):
|
||||
raise exceptions.AuthenticationFailed("Wrong username or password.")
|
||||
|
||||
if common_authentication.should_verify_email(user):
|
||||
raise exceptions.AuthenticationFailed("You need to verify your email.")
|
||||
|
||||
return (user, None)
|
||||
|
||||
|
||||
def authenticate_salt(username, salt, token):
|
||||
try:
|
||||
user = User.objects.select_related("actor").get(
|
||||
username=username, is_active=True, subsonic_api_token__isnull=False
|
||||
user = (
|
||||
User.objects.all()
|
||||
.for_auth()
|
||||
.get(username=username, is_active=True, subsonic_api_token__isnull=False)
|
||||
)
|
||||
except User.DoesNotExist:
|
||||
raise exceptions.AuthenticationFailed("Wrong username or password.")
|
||||
|
|
@ -38,6 +46,9 @@ def authenticate_salt(username, salt, token):
|
|||
if expected != token:
|
||||
raise exceptions.AuthenticationFailed("Wrong username or password.")
|
||||
|
||||
if common_authentication.should_verify_email(user):
|
||||
raise exceptions.AuthenticationFailed("You need to verify your email.")
|
||||
|
||||
return (user, None)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -22,3 +22,6 @@ class FunkwhaleAccountAdapter(DefaultAccountAdapter):
|
|||
def send_mail(self, template_prefix, email, context):
|
||||
context.update(get_email_context())
|
||||
return super().send_mail(template_prefix, email, context)
|
||||
|
||||
def get_login_redirect_url(self, request):
|
||||
return "noop"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,33 @@
|
|||
from django.contrib.auth import backends, get_user_model
|
||||
from allauth.account import auth_backends
|
||||
|
||||
from funkwhale_api.common import authentication
|
||||
|
||||
|
||||
# ugly but allauth doesn't offer an easy way to override the querysets
|
||||
# used to retrieve users, so we monkey patch
|
||||
def decorate_for_auth(func):
|
||||
def inner(*args, **kwargs):
|
||||
qs = func(*args, **kwargs)
|
||||
try:
|
||||
return qs.for_auth()
|
||||
except AttributeError:
|
||||
return (
|
||||
get_user_model()
|
||||
.objects.all()
|
||||
.for_auth()
|
||||
.filter(pk__in=[u.pk for u in qs])
|
||||
)
|
||||
|
||||
return inner
|
||||
|
||||
|
||||
auth_backends.filter_users_by_email = decorate_for_auth(
|
||||
auth_backends.filter_users_by_email
|
||||
)
|
||||
auth_backends.filter_users_by_username = decorate_for_auth(
|
||||
auth_backends.filter_users_by_username
|
||||
)
|
||||
|
||||
|
||||
class ModelBackend(backends.ModelBackend):
|
||||
|
|
@ -7,11 +36,17 @@ class ModelBackend(backends.ModelBackend):
|
|||
Select related to avoid two additional queries
|
||||
"""
|
||||
try:
|
||||
user = (
|
||||
get_user_model()
|
||||
._default_manager.select_related("actor__domain")
|
||||
.get(pk=user_id)
|
||||
)
|
||||
user = get_user_model().objects.all().for_auth().get(pk=user_id)
|
||||
except get_user_model().DoesNotExist:
|
||||
return None
|
||||
|
||||
return user if self.user_can_authenticate(user) else None
|
||||
|
||||
def user_can_authenticate(self, user):
|
||||
return super().user_can_authenticate(
|
||||
user
|
||||
) and not authentication.should_verify_email(user)
|
||||
|
||||
|
||||
class AllAuthBackend(auth_backends.AuthenticationBackend, ModelBackend):
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -31,6 +31,23 @@ class GroupFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
|||
self.permissions.add(*perms)
|
||||
|
||||
|
||||
@registry.register
|
||||
class EmailAddressFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
||||
verified = False
|
||||
primary = True
|
||||
|
||||
class Meta:
|
||||
model = "account.EmailAddress"
|
||||
|
||||
|
||||
@registry.register
|
||||
class EmailConfirmationFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
||||
email_address = factory.SubFactory(EmailAddressFactory)
|
||||
|
||||
class Meta:
|
||||
model = "account.EmailConfirmation"
|
||||
|
||||
|
||||
@registry.register
|
||||
class InvitationFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
||||
owner = factory.LazyFunction(lambda: UserFactory())
|
||||
|
|
@ -91,6 +108,16 @@ class UserFactory(factory.django.DjangoModelFactory):
|
|||
self.save(update_fields=["actor"])
|
||||
return self.actor
|
||||
|
||||
@factory.post_generation
|
||||
def verified_email(self, create, extracted, **kwargs):
|
||||
if not create or extracted is None:
|
||||
return
|
||||
return EmailConfirmationFactory(
|
||||
email_address__verified=extracted,
|
||||
email_address__email=self.email,
|
||||
email_address__user=self,
|
||||
)
|
||||
|
||||
|
||||
@registry.register(name="users.SuperUser")
|
||||
class SuperUserFactory(UserFactory):
|
||||
|
|
@ -129,6 +156,7 @@ class AccessTokenFactory(factory.django.DjangoModelFactory):
|
|||
user = factory.SubFactory(UserFactory)
|
||||
expires = factory.Faker("future_datetime", tzinfo=pytz.UTC)
|
||||
token = factory.Faker("uuid4")
|
||||
scope = "read"
|
||||
|
||||
class Meta:
|
||||
model = "users.AccessToken"
|
||||
|
|
|
|||
|
|
@ -9,13 +9,14 @@ import string
|
|||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.contrib.auth.models import AbstractUser, UserManager as BaseUserManager
|
||||
from django.db import models
|
||||
from django.dispatch import receiver
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from allauth.account.models import EmailAddress
|
||||
from django_auth_ldap.backend import populate_user as ldap_populate_user
|
||||
from oauth2_provider import models as oauth2_models
|
||||
from oauth2_provider import validators as oauth2_validators
|
||||
|
|
@ -94,6 +95,26 @@ def get_default_funkwhale_support_message_display_date():
|
|||
)
|
||||
|
||||
|
||||
class UserQuerySet(models.QuerySet):
|
||||
def for_auth(self):
|
||||
"""Optimization to avoid additional queries during authentication"""
|
||||
qs = self.select_related("actor__domain")
|
||||
verified_emails = EmailAddress.objects.filter(
|
||||
user=models.OuterRef("id"), primary=True
|
||||
).values("verified")[:1]
|
||||
subquery = models.Subquery(verified_emails)
|
||||
return qs.annotate(has_verified_primary_email=subquery)
|
||||
|
||||
|
||||
class UserManager(BaseUserManager):
|
||||
def get_queryset(self):
|
||||
return UserQuerySet(self.model, using=self._db)
|
||||
|
||||
def get_by_natural_key(self, key):
|
||||
obj = BaseUserManager.get_by_natural_key(self.all().for_auth(), key)
|
||||
return obj
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
|
||||
# First Name and Last Name do not cover name patterns
|
||||
|
|
@ -169,6 +190,8 @@ class User(AbstractUser):
|
|||
blank=True,
|
||||
)
|
||||
|
||||
objects = UserManager()
|
||||
|
||||
def __str__(self):
|
||||
return self.username
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,24 @@
|
|||
import urllib.parse
|
||||
import oauthlib.oauth2
|
||||
|
||||
from funkwhale_api.common import authentication
|
||||
|
||||
|
||||
def check(request):
|
||||
user = request.user
|
||||
request.user = user.__class__.objects.all().for_auth().get(pk=user.pk)
|
||||
if authentication.should_verify_email(request.user):
|
||||
setattr(request, "oauth2_error", {"error": "unverified_email"})
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class OAuth2Server(oauthlib.oauth2.Server):
|
||||
def verify_request(self, uri, *args, **kwargs):
|
||||
valid, request = super().verify_request(uri, *args, **kwargs)
|
||||
if valid:
|
||||
if not check(request):
|
||||
return False, request
|
||||
return valid, request
|
||||
|
||||
# maybe the token was given in the querystring?
|
||||
|
|
@ -21,5 +34,8 @@ class OAuth2Server(oauthlib.oauth2.Server):
|
|||
valid = self.request_validator.validate_bearer_token(
|
||||
token, request.scopes, request
|
||||
)
|
||||
if valid:
|
||||
if not check(request):
|
||||
return False, request
|
||||
|
||||
return valid, request
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue