diff --git a/api/desecapi/authentication.py b/api/desecapi/authentication.py index 4391fdee5..c5202b212 100644 --- a/api/desecapi/authentication.py +++ b/api/desecapi/authentication.py @@ -158,6 +158,8 @@ class EmailPasswordPayloadAuthentication(BaseAuthentication): def authenticate(self, request): serializer = EmailPasswordSerializer(data=request.data) serializer.is_valid(raise_exception=True) + if "password" not in serializer.data: + return None return self.authenticate_credentials( serializer.data["email"], serializer.data["password"], request ) diff --git a/api/desecapi/models/authenticated_actions.py b/api/desecapi/models/authenticated_actions.py index c74d06065..2138c1342 100644 --- a/api/desecapi/models/authenticated_actions.py +++ b/api/desecapi/models/authenticated_actions.py @@ -8,6 +8,7 @@ from .domains import Domain from .mfa import TOTPFactor +from .tokens import Token class AuthenticatedAction(models.Model): @@ -112,6 +113,15 @@ def _state_fields(self): return super()._state_fields + [str(self.user.id)] +class AuthenticatedCreateLoginTokenAction(AuthenticatedBasicUserAction): + """ + Action to create a login token. + """ + + def _act(self): + return Token.create_login_token(self.user) + + class AuthenticatedEmailUserAction(AuthenticatedBasicUserAction): """ Abstract AuthenticatedAction involving a user instance with unmodified email address. diff --git a/api/desecapi/models/tokens.py b/api/desecapi/models/tokens.py index a3b859039..814f13609 100644 --- a/api/desecapi/models/tokens.py +++ b/api/desecapi/models/tokens.py @@ -17,6 +17,8 @@ from django_prometheus.models import ExportModelOperationsMixin from netfields import CidrAddressField, NetManager +from .users import User + class Token(ExportModelOperationsMixin("Token"), rest_framework.authtoken.models.Token): @staticmethod @@ -100,6 +102,17 @@ def delete(self): self.tokendomainpolicy_set.filter(domain__isnull=True).delete() return super().delete() + @classmethod + def create_login_token(cls, user: User): + token = cls.objects.create( + user=user, + perm_manage_tokens=True, + max_age=timedelta(days=7), + max_unused_period=timedelta(hours=1), + mfa=False, + ) + return token + @pgtrigger.register( # Ensure that token_user is consistent with token diff --git a/api/desecapi/models/users.py b/api/desecapi/models/users.py index 66f89e99b..eedac103d 100644 --- a/api/desecapi/models/users.py +++ b/api/desecapi/models/users.py @@ -135,6 +135,7 @@ def send_email( "delete-account": fast_lane, "domain-dyndns": fast_lane, "renew-domain": immediate_lane, + "create-login-token": immediate_lane, } if reason not in lanes: raise ValueError( diff --git a/api/desecapi/serializers/__init__.py b/api/desecapi/serializers/__init__.py index c64d79348..6234f2e4d 100644 --- a/api/desecapi/serializers/__init__.py +++ b/api/desecapi/serializers/__init__.py @@ -4,6 +4,7 @@ AuthenticatedChangeEmailUserActionSerializer, AuthenticatedChangeOutreachPreferenceUserActionSerializer, AuthenticatedConfirmAccountUserActionSerializer, + AuthenticatedCreateLoginTokenActionSerializer, AuthenticatedCreateTOTPFactorUserActionSerializer, AuthenticatedDeleteUserActionSerializer, AuthenticatedRenewDomainBasicUserActionSerializer, diff --git a/api/desecapi/serializers/authenticated_actions.py b/api/desecapi/serializers/authenticated_actions.py index 2ae3aeb3d..231b9d986 100644 --- a/api/desecapi/serializers/authenticated_actions.py +++ b/api/desecapi/serializers/authenticated_actions.py @@ -265,6 +265,17 @@ class Meta(AuthenticatedBasicUserActionSerializer.Meta): model = models.AuthenticatedDeleteUserAction +class AuthenticatedCreateLoginTokenActionSerializer( + AuthenticatedBasicUserActionSerializer +): + reason = "create-login-token" + validity_period = timedelta(minutes=10) + + class Meta(AuthenticatedBasicUserActionSerializer.Meta): + model = models.AuthenticatedCreateLoginTokenAction + fields = AuthenticatedBasicUserActionSerializer.Meta.fields + + class AuthenticatedDomainBasicUserActionSerializer( AuthenticatedBasicUserActionSerializer ): diff --git a/api/desecapi/serializers/users.py b/api/desecapi/serializers/users.py index a2f2f2660..e7777355b 100644 --- a/api/desecapi/serializers/users.py +++ b/api/desecapi/serializers/users.py @@ -12,7 +12,7 @@ class EmailSerializer(serializers.Serializer): class EmailPasswordSerializer(EmailSerializer): - password = serializers.CharField() + password = serializers.CharField(required=False) class ChangeEmailSerializer(serializers.Serializer): diff --git a/api/desecapi/templates/emails/create-login-token/content.txt b/api/desecapi/templates/emails/create-login-token/content.txt new file mode 100644 index 000000000..b42f8b273 --- /dev/null +++ b/api/desecapi/templates/emails/create-login-token/content.txt @@ -0,0 +1,12 @@ +{% extends "emails/content.txt" %} +{% block content %}{% load action_extras %}Hi, + +someone request a login link for your account. To log in, please use the following link (valid for {% action_link_expiration_hours action_serializer %} hours): + +{% action_link action_serializer %} + +If you did not request this, please contact support@desec.io. + +Stay secure, +The deSEC Team +{% endblock %} diff --git a/api/desecapi/templates/emails/create-login-token/subject.txt b/api/desecapi/templates/emails/create-login-token/subject.txt new file mode 100644 index 000000000..f5e1e5425 --- /dev/null +++ b/api/desecapi/templates/emails/create-login-token/subject.txt @@ -0,0 +1 @@ +[deSEC] Login information diff --git a/api/desecapi/tests/test_user_management.py b/api/desecapi/tests/test_user_management.py index 2fee5f961..04da39e8d 100644 --- a/api/desecapi/tests/test_user_management.py +++ b/api/desecapi/tests/test_user_management.py @@ -51,14 +51,11 @@ def register(self, email, password, captcha=None, **kwargs): reverse("v1:register"), {"email": email, "password": password, **kwargs} ) - def login_user(self, email, password): - return self.post( - reverse("v1:login"), - { - "email": email, - "password": password, - }, - ) + def login_user(self, email, password=None): + payload = {"email": email} + if password is not None: + payload["password"] = password + return self.post(reverse("v1:login"), payload) def logout(self, token): return self.post(reverse("v1:logout"), HTTP_AUTHORIZATION=f"Token {token}") @@ -127,7 +124,7 @@ def register_user(self, email=None, password=None, late_captcha=False, **kwargs) self.client.register(email, password, captcha, **kwargs), ) - def login_user(self, email, password): + def login_user(self, email, password=None): return self.client.login_user(email, password) def logout(self, token): @@ -550,6 +547,52 @@ def _test_delete_account(self, email, password): self.assertUserDoesNotExist(email) +class PasswordlessUserTestCase(UserManagementTestCase): + + def assertLoginSuccessResponse(self, response): + return self.assertContains( + response=response, text="instructions", status_code=status.HTTP_202_ACCEPTED + ) + + def assertCreateLoginTokenVerificationEmail(self, reset=True): + return self.assertEmailSent( + subject_contains="Login information", + body_contains="login link for your account", + recipient=[self.email], + reset=reset, + pattern=r"following link[^:]*:\s+([^\s]*)", + ) + + def assertCreateLoginTokenVerificationSuccessResponse(self, response): + return self.assertContains( + response=response, + text=f"token", + status_code=status.HTTP_200_OK, + ) + + def setUp(self): + self.email, _, _ = self.register_user(self.random_username()) + confirmation_link = self.assertRegistrationEmail(self.email) + self.assertConfirmationLinkRedirect(confirmation_link) + response = self.client.verify(confirmation_link) + self.assertRegistrationVerificationSuccessResponse(response) + self.assertTrue(User.objects.get(email=self.email).is_active) + self.assertEmailSent(reset=True) + + def test_login_successful(self): + response = self.login_user(self.email, None) + self.assertLoginSuccessResponse(response) + link = self.assertCreateLoginTokenVerificationEmail() + self.assertCreateLoginTokenVerificationSuccessResponse( + self.client.verify(link) + ) + + def test_login_unsuccessful(self): + response = self.login_user("doesnotexist@example.com", None) + self.assertLoginSuccessResponse(response) # no disclosure that this account does not exist! + self.assertNoEmailSent() + + class UserLifeCycleTestCase(UserManagementTestCase): def test_life_cycle(self): self.email, self.password = self._test_registration( diff --git a/api/desecapi/urls/version_1.py b/api/desecapi/urls/version_1.py index a0ade1e8a..f25651688 100644 --- a/api/desecapi/urls/version_1.py +++ b/api/desecapi/urls/version_1.py @@ -122,6 +122,11 @@ views.AuthenticatedRenewDomainBasicUserActionView.as_view(), name="confirm-renew-domain", ), + path( + "v/create-login-token//", + views.AuthenticatedCreateLoginTokenActionView.as_view(), + name="confirm-create-login-token", + ), # CAPTCHA path("captcha/", views.CaptchaView.as_view(), name="captcha"), ] diff --git a/api/desecapi/views/__init__.py b/api/desecapi/views/__init__.py index 3f5ca4d10..81084b97f 100644 --- a/api/desecapi/views/__init__.py +++ b/api/desecapi/views/__init__.py @@ -4,6 +4,7 @@ AuthenticatedChangeEmailUserActionView, AuthenticatedChangeOutreachPreferenceUserActionView, AuthenticatedConfirmAccountUserActionView, + AuthenticatedCreateLoginTokenActionView, AuthenticatedCreateTOTPFactorUserActionView, AuthenticatedDeleteUserActionView, AuthenticatedRenewDomainBasicUserActionView, diff --git a/api/desecapi/views/authenticated_actions.py b/api/desecapi/views/authenticated_actions.py index e389be12e..40ef26bcf 100644 --- a/api/desecapi/views/authenticated_actions.py +++ b/api/desecapi/views/authenticated_actions.py @@ -216,6 +216,17 @@ def post(self, request, *args, **kwargs): return Response(serializer.data) +class AuthenticatedCreateLoginTokenActionView(AuthenticatedActionView): + html_url = "/confirm/create-login-token/{code}/" + serializer_class = serializers.AuthenticatedCreateLoginTokenActionSerializer + + def post(self, request, *args, **kwargs): + super().post(request, *args, **kwargs) + token = self.authenticated_action.act() + serializer = serializers.TokenSerializer(token, include_plain=True) + return Response(serializer.data) + + class AuthenticatedResetPasswordUserActionView(AuthenticatedActionView): html_url = "/confirm/reset-password/{code}/" serializer_class = serializers.AuthenticatedResetPasswordUserActionSerializer diff --git a/api/desecapi/views/users.py b/api/desecapi/views/users.py index 8c3e73fa2..b0a6636df 100644 --- a/api/desecapi/views/users.py +++ b/api/desecapi/views/users.py @@ -1,5 +1,3 @@ -from datetime import timedelta - from django.conf import settings from django.contrib.auth import user_logged_in from rest_framework import generics, mixins, status @@ -93,23 +91,32 @@ def post(self, request, *args, **kwargs): class AccountLoginView(generics.GenericAPIView): authentication_classes = (authentication.EmailPasswordPayloadAuthentication,) - permission_classes = (IsAuthenticated,) serializer_class = serializers.TokenSerializer throttle_scope = "account_management_passive" def post(self, request, *args, **kwargs): - user = self.request.user - token = Token.objects.create( - user=user, - perm_manage_tokens=True, - max_age=timedelta(days=7), - max_unused_period=timedelta(hours=1), - mfa=False, - ) - user_logged_in.send(sender=user.__class__, request=self.request, user=user) - - data = self.get_serializer(token, include_plain=True).data - return Response(data) + if request.user and request.user.is_authenticated: + # password was provided + user = self.request.user + data = self.get_serializer( + Token.create_login_token(user), include_plain=True + ).data + user_logged_in.send(sender=user.__class__, request=request, user=user) + return Response(data) + else: + # password was not provided + message = "Login instructions have been sent to the email address associated with your user account." + response = Response(data={"detail": message}, status=status.HTTP_202_ACCEPTED) + + # TODO how to determine user ID properly? Use a serializer? + try: + user = User.objects.get(email=request.data.get('email')) + except User.DoesNotExist: + pass + else: + serializers.AuthenticatedCreateLoginTokenActionSerializer.build_and_save(user=user) + + return response class AccountLogoutView(APIView, mixins.DestroyModelMixin): diff --git a/www/webapp/src/components/CreateLoginTokenActionHandler.vue b/www/webapp/src/components/CreateLoginTokenActionHandler.vue new file mode 100644 index 000000000..85345bdfd --- /dev/null +++ b/www/webapp/src/components/CreateLoginTokenActionHandler.vue @@ -0,0 +1,24 @@ + diff --git a/www/webapp/src/views/Login.vue b/www/webapp/src/views/Login.vue index 6412a32e2..84cf28806 100644 --- a/www/webapp/src/views/Login.vue +++ b/www/webapp/src/views/Login.vue @@ -39,13 +39,11 @@ /> @@ -104,9 +102,6 @@ export default { useSessionStorage: false, email_rules: [v => !!v || 'Please enter the email address associated with your account'], email_errors: [], - password_rules: [ - v => !!v || 'Enter your password to log in', - ], hide_password: true, errors: [], }), @@ -115,10 +110,13 @@ export default { this.working = true; this.errors.splice(0, this.errors.length); try { - const response = await HTTP.post('auth/login/', { - email: this.email, - password: this.password, - }); + let payload = { + email: this.email + }; + if (this.password) { + payload['password'] = this.password; + } + const response = await HTTP.post('auth/login/', payload); HTTP.defaults.headers.common.Authorization = `Token ${response.data.token}`; this.$store.commit('login', response.data); if (this.useSessionStorage) {