From cc56bdfe44e45c203f71c7d21081a52baeb56d96 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Thu, 23 Nov 2023 14:32:34 +0530 Subject: [PATCH] Passwordless login and signup (#3531) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #ISSUEID This PR is depended on #3521 - [x] Passwordless login - [x] Passwordless signup - [x] Allow user to set a password after going to profile. - [x] Allow user to change their email even if they don't have an email set. - [x] Allow user to add their name in the application form if name is not present in the user account. - [x] Don't display "Dashboard" link if the user does't have permission to access to it. - [x] Allow to use to setup 2FA without account password. - [x] Display user content on the login screen, if configured (it is an existing feature) - [x] If 2FA is enforced, allow the user to submit the application without setting up 2FA - [x] Add email re-verification option to elevate, sudo mode, apart from password - [x] Update landing page after application submission, on success it redirects now. - [x] Update ENABLE_PUBLIC_SIGNUP and FORCE_LOGIN_FOR_APPLICATION to true by default # Login/Signup Flow ![image](https://github.com/HyphaApp/hypha/assets/236356/91c22cb1-bd1f-4665-98e2-0350829a5807) ## Updated Login Page with Registration Enabled ![Screenshot 2023-09-13 at 07 53 06@2x](https://github.com/HyphaApp/hypha/assets/236356/424fa2f6-a519-44e3-a9ec-449815dd39b2) ## After providing the email ID The messaging is kept neutral to hide if the user is already registered or not. The email will contain more detail, if the account exist or not. ![Screenshot 2023-09-13 at 07 57 27@2x](https://github.com/HyphaApp/hypha/assets/236356/7577447b-edf2-496e-94ca-3c98a8f2f075) Login email copy ![Screenshot 2023-09-13 at 08 08 36@2x](https://github.com/HyphaApp/hypha/assets/236356/ab2d3e90-da2e-40ec-9f7e-bbb8d9e7235f) ## Signup New Account Email copy ![Screenshot 2023-09-21 at 07 08 52@2x](https://github.com/HyphaApp/hypha/assets/236356/cb14350b-1cfb-4869-b863-ebbb95701089) ### Profile Page just after signup The user after clicking on the signup link in the email is redirect to homepage. No dashboard is available as the user doesn't have applicant role. If they click on the "profile" button they see this page with open to update profile and setup a password and enable 2FA. If the user decide to change the email, password is not asked if not password is set, instead an email is sent to authorize the email change. ![Screenshot 2023-09-22 at 08 50 21@2x](https://github.com/HyphaApp/hypha/assets/236356/7ed0cb97-a7ca-49fe-aef5-de8f3890db1e) ## Updated "Sudo" mode page ### For account with password ![Screenshot 2023-10-31 at 7  49 38@2x](https://github.com/HyphaApp/hypha/assets/236356/fe27ad75-7e4b-4226-89c1-ddd7a5548944) After clicking on the "Send a confirmation code to your email" link ![Screenshot 2023-10-31 at 8  03 28@2x](https://github.com/HyphaApp/hypha/assets/236356/bf7a4098-fd0f-4d55-9850-0862da69d808) ![Screenshot 2023-10-31 at 7  51 03@2x](https://github.com/HyphaApp/hypha/assets/236356/3768e87f-d105-4d54-9cd6-2e2b8a81fa2f) ### For account without password ![Screenshot 2023-10-31 at 7  53 44@2x](https://github.com/HyphaApp/hypha/assets/236356/8c84181a-43ce-4afe-a5be-758e3f0e55f8) ## Updated disable 2FA page It requires "Sudo" mode, instead of password now. ![Screenshot 2023-10-31 at 7  48 01@2x](https://github.com/HyphaApp/hypha/assets/236356/2b5c8bd1-27ea-42cd-9c84-608bb351631c) --- .vscode/settings.json | 3 + docs/setup/administrators/configuration.md | 4 +- hypha/apply/dashboard/views.py | 13 +- hypha/apply/funds/models/submissions.py | 21 +- hypha/apply/funds/models/utils.py | 3 +- .../templates/funds/lab_type_landing.html | 4 - .../funds/templates/funds/round_landing.html | 3 - ...e_landing.html => submission-success.html} | 33 +- hypha/apply/funds/tests/test_models.py | 8 +- hypha/apply/funds/tests/test_views.py | 3 +- hypha/apply/funds/urls.py | 2 + hypha/apply/funds/views.py | 13 +- hypha/apply/projects/tests/test_settings.py | 23 +- hypha/apply/projects/tests/test_views.py | 3 +- hypha/apply/stream_forms/models.py | 12 +- hypha/apply/urls.py | 4 +- hypha/apply/users/forms.py | 67 ++- hypha/apply/users/middleware.py | 103 ++++- .../users/migrations/0021_pendingsignup.py | 34 ++ .../migrations/0022_confirmaccesstoken.py | 42 ++ hypha/apply/users/models.py | 65 ++- hypha/apply/users/services.py | 162 +++++++ .../users/templates/elevate/elevate.html | 75 +++- .../templates/two_factor/_base_focus.html | 10 +- .../templates/two_factor/_wizard_actions.html | 16 +- .../two_factor/core/backup_tokens.html | 38 +- .../templates/two_factor/core/setup.html | 2 +- .../two_factor/core/setup_complete.html | 4 +- .../two_factor/core/two_factor_required.html | 42 +- .../templates/two_factor/profile/disable.html | 65 +-- .../templates/two_factor/profile/profile.html | 2 +- .../apply/users/templates/users/account.html | 71 ++-- .../users/activation/email_subject.txt | 3 + .../templates/users/activation/invalid.html | 31 +- .../users/email_change/confirm_password.html | 40 -- .../templates/users/email_change/done.html | 20 +- .../templates/users/emails/confirm_access.md | 19 + .../users/emails/passwordless_login_email.md | 26 ++ .../passwordless_login_no_account_found.md | 16 + .../emails/passwordless_new_account_login.md | 21 + .../templates/users/emails/set_password.txt | 15 + .../users/emails/set_password_subject.txt | 3 + hypha/apply/users/templates/users/login.html | 35 +- .../partials/confirmation_code_sent.html | 74 ++++ .../passwordless_login_signup_sent.html | 29 ++ .../users/passwordless_login_signup.html | 69 +++ hypha/apply/users/tests/test_forms.py | 32 +- hypha/apply/users/tests/test_middleware.py | 12 +- hypha/apply/users/tests/test_oauth_access.py | 2 +- hypha/apply/users/tests/test_registration.py | 15 +- hypha/apply/users/tests/test_tokens.py | 63 +++ hypha/apply/users/tests/test_views.py | 3 +- hypha/apply/users/tokens.py | 81 ++++ hypha/apply/users/urls.py | 209 ++++----- hypha/apply/users/utils.py | 23 +- hypha/apply/users/views.py | 400 ++++++++++++++---- hypha/core/context_processors.py | 2 +- hypha/core/utils.py | 2 +- .../public/home/templates/home/home_page.html | 2 +- .../navigation/primarynav-apply.html | 7 +- .../utils/includes/login_button.html | 33 +- .../utils/includes/register_button.html | 2 +- hypha/settings/base.py | 25 +- hypha/settings/django.py | 2 +- hypha/settings/test.py | 2 + .../src/sass/apply/abstracts/_mixins.scss | 2 +- .../src/sass/apply/components/_form.scss | 12 + .../sass/apply/components/_two-factor.scss | 18 - hypha/templates/base-apply.html | 6 +- hypha/templates/base.html | 10 +- requirements-dev.txt | 2 + requirements.txt | 2 +- 72 files changed, 1775 insertions(+), 545 deletions(-) delete mode 100644 hypha/apply/funds/templates/funds/lab_type_landing.html delete mode 100644 hypha/apply/funds/templates/funds/round_landing.html rename hypha/apply/funds/templates/funds/{application_base_landing.html => submission-success.html} (59%) create mode 100644 hypha/apply/users/migrations/0021_pendingsignup.py create mode 100644 hypha/apply/users/migrations/0022_confirmaccesstoken.py create mode 100644 hypha/apply/users/services.py create mode 100644 hypha/apply/users/templates/users/activation/email_subject.txt delete mode 100644 hypha/apply/users/templates/users/email_change/confirm_password.html create mode 100644 hypha/apply/users/templates/users/emails/confirm_access.md create mode 100644 hypha/apply/users/templates/users/emails/passwordless_login_email.md create mode 100644 hypha/apply/users/templates/users/emails/passwordless_login_no_account_found.md create mode 100644 hypha/apply/users/templates/users/emails/passwordless_new_account_login.md create mode 100644 hypha/apply/users/templates/users/emails/set_password.txt create mode 100644 hypha/apply/users/templates/users/emails/set_password_subject.txt create mode 100644 hypha/apply/users/templates/users/partials/confirmation_code_sent.html create mode 100644 hypha/apply/users/templates/users/partials/passwordless_login_signup_sent.html create mode 100644 hypha/apply/users/templates/users/passwordless_login_signup.html create mode 100644 hypha/apply/users/tests/test_tokens.py create mode 100644 hypha/apply/users/tokens.py diff --git a/.vscode/settings.json b/.vscode/settings.json index 06d6178f35..839b1bed5e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,6 +5,9 @@ "coreutils", "modelcluster", "pagedown", + "pytestmark", + "ratelimit", + "SIGNUP", "WAGTAILADMIN", "wagtailcore" ] diff --git a/docs/setup/administrators/configuration.md b/docs/setup/administrators/configuration.md index 93f93d8aac..0e13424a6c 100644 --- a/docs/setup/administrators/configuration.md +++ b/docs/setup/administrators/configuration.md @@ -77,11 +77,11 @@ This determines the length of time for which the user will remain logged in. The ### If users should be able to register accounts without first creating applications - ENABLE_REGISTRATION_WITHOUT_APPLICATION = env.bool('ENABLE_REGISTRATION_WITHOUT_APPLICATION', False) + ENABLE_PUBLIC_SIGNUP = env.bool('ENABLE_PUBLIC_SIGNUP', True) ### If users are forced to log in before creating applications - FORCE_LOGIN_FOR_APPLICATION = env.bool('FORCE_LOGIN_FOR_APPLICATION', False) + FORCE_LOGIN_FOR_APPLICATION = env.bool('FORCE_LOGIN_FOR_APPLICATION', True) ### Set the allowed file extension for all uploads fields. diff --git a/hypha/apply/dashboard/views.py b/hypha/apply/dashboard/views.py index 6e7f771a2e..41823412a5 100644 --- a/hypha/apply/dashboard/views.py +++ b/hypha/apply/dashboard/views.py @@ -1,6 +1,6 @@ from django.conf import settings from django.db.models import Count -from django.http import HttpResponseRedirect +from django.http import HttpResponseForbidden, HttpResponseRedirect from django.shortcuts import render from django.urls import reverse, reverse_lazy from django.views.generic import TemplateView @@ -843,3 +843,14 @@ class DashboardView(ViewDispatcher): applicant_view = ApplicantDashboardView finance_view = FinanceDashboardView contracting_view = ContractingDashboardView + + def dispatch(self, request, *args, **kwargs): + response = super().dispatch(request, *args, **kwargs) + + # Handle the case when there is no dashboard for the user + # and redirect them to the home page of apply site. + # Suggestion: create a dedicated dashboard for user without any role. + if isinstance(response, HttpResponseForbidden): + return HttpResponseRedirect("/") + + return response diff --git a/hypha/apply/funds/models/submissions.py b/hypha/apply/funds/models/submissions.py index ae0a1f8a57..5af8a58e67 100644 --- a/hypha/apply/funds/models/submissions.py +++ b/hypha/apply/funds/models/submissions.py @@ -547,11 +547,11 @@ def active(self): def ensure_user_has_account(self): if self.user and self.user.is_authenticated: self.form_data["email"] = self.user.email - self.form_data["full_name"] = self.user.get_full_name() - # Ensure applying user should have applicant role - if not self.user.is_applicant: - applicant_group = Group.objects.get(name=APPLICANT_GROUP_NAME) - self.user.groups.add(applicant_group) + if name := self.user.get_full_name(): + self.form_data["full_name"] = name + else: + # user doesn't have name set, so use the one from the form + self.user.full_name = self.form_data["full_name"] self.user.save() else: # Rely on the form having the following must include fields (see blocks.py) @@ -564,11 +564,6 @@ def ensure_user_has_account(self): self.user, _ = User.objects.get_or_create( email=email, defaults={"full_name": full_name} ) - # Ensure applying user should have applicant role - if not self.user.is_applicant: - applicant_group = Group.objects.get(name=APPLICANT_GROUP_NAME) - self.user.groups.add(applicant_group) - self.user.save() else: self.user, _ = User.objects.get_or_create_and_notify( email=email, @@ -576,6 +571,12 @@ def ensure_user_has_account(self): defaults={"full_name": full_name}, ) + # Make sure the user is in the applicant group + if not self.user.is_applicant: + applicant_group = Group.objects.get(name=APPLICANT_GROUP_NAME) + self.user.groups.add(applicant_group) + self.user.save() + def get_from_parent(self, attribute): try: return getattr(self.round.specific, attribute) diff --git a/hypha/apply/funds/models/utils.py b/hypha/apply/funds/models/utils.py index f7267b5c24..dd52fbe334 100644 --- a/hypha/apply/funds/models/utils.py +++ b/hypha/apply/funds/models/utils.py @@ -1,4 +1,5 @@ from django.db import models +from django.shortcuts import redirect from django.urls import reverse from django.utils.translation import gettext_lazy as _ from wagtail.admin.panels import ( @@ -130,7 +131,7 @@ def render_landing_page(self, request, form_submission=None, *args, **kwargs): source=form_submission, ) - return super().render_landing_page(request, form_submission, *args, **kwargs) + return redirect("apply:submissions:success", pk=form_submission.id) content_panels = AbstractStreamForm.content_panels + [ FieldPanel("workflow_name"), diff --git a/hypha/apply/funds/templates/funds/lab_type_landing.html b/hypha/apply/funds/templates/funds/lab_type_landing.html deleted file mode 100644 index b13d2c423b..0000000000 --- a/hypha/apply/funds/templates/funds/lab_type_landing.html +++ /dev/null @@ -1,4 +0,0 @@ -{% extends "funds/application_base_landing.html" %} -{% load wagtailcore_tags wagtailsettings_tags %} - -{% block extra_text %}{{ settings.funds.ApplicationSettings.extra_text_lab|richtext }}{% endblock %} diff --git a/hypha/apply/funds/templates/funds/round_landing.html b/hypha/apply/funds/templates/funds/round_landing.html deleted file mode 100644 index 0ed5e7f0e6..0000000000 --- a/hypha/apply/funds/templates/funds/round_landing.html +++ /dev/null @@ -1,3 +0,0 @@ -{% extends "funds/application_base_landing.html" %} - -{% block page_title %}{{ page.get_parent.title }}{% endblock %} diff --git a/hypha/apply/funds/templates/funds/application_base_landing.html b/hypha/apply/funds/templates/funds/submission-success.html similarity index 59% rename from hypha/apply/funds/templates/funds/application_base_landing.html rename to hypha/apply/funds/templates/funds/submission-success.html index 1097899405..b032625997 100644 --- a/hypha/apply/funds/templates/funds/application_base_landing.html +++ b/hypha/apply/funds/templates/funds/submission-success.html @@ -29,25 +29,46 @@

{% blocktrans %}Thank you for your submission to the {{ ORG_LONG_NAME }}.{%

{% with email_context=page.specific %} -

{{ email_context.confirmation_text_extra|urlize }}

+ {% if email_context.confirmation_text_extra %} +

{{ email_context.confirmation_text_extra|urlize }}

+ {% endif %} {% endwith %} - {% block extra_text %} -
+ {% if form_submission.round and settings.funds.ApplicationSettings.extra_text_round %} +
{{ settings.funds.ApplicationSettings.extra_text_round|richtext }}
- {% endblock %} + {% elif settings.funds.ApplicationSettings.extra_text_lab %} +
+ {{ settings.funds.ApplicationSettings.extra_text_lab|richtext }} +
+ {% endif %} {% endif %} -
- {% if request.user.is_authenticated %} +
+ {% if request.user.is_authenticated and request.user.can_access_dashboard%} {% trans "Go to your dashboard" %} + {% if form_submission.status == 'draft' %} + + {% trans "Continue editing" %} + + {% else %} + + {% trans "View your submission" %} + + {% endif %} {% else %} /", submission_success, name="success"), path("all/", SubmissionListView.as_view(), name="list"), path("all-beta/", submission_all_beta, name="list-beta"), path("all-beta/bulk_archive/", bulk_archive_submissions, name="bulk-archive"), diff --git a/hypha/apply/funds/views.py b/hypha/apply/funds/views.py index b942613db0..5725d425c2 100644 --- a/hypha/apply/funds/views.py +++ b/hypha/apply/funds/views.py @@ -13,7 +13,7 @@ from django.core.exceptions import PermissionDenied from django.db.models import Count, F, Q from django.http import FileResponse, Http404, HttpResponse, HttpResponseRedirect -from django.shortcuts import get_object_or_404 +from django.shortcuts import get_object_or_404, render from django.urls import reverse_lazy from django.utils import timezone from django.utils.decorators import method_decorator @@ -123,6 +123,17 @@ User = get_user_model() +def submission_success(request, pk): + submission = get_object_or_404(ApplicationSubmission, pk=pk) + return render( + request, + "funds/submission-success.html", + { + "form_submission": submission, + }, + ) + + class SubmissionStatsMixin: def get_context_data(self, **kwargs): submissions = ApplicationSubmission.objects.exclude_draft() diff --git a/hypha/apply/projects/tests/test_settings.py b/hypha/apply/projects/tests/test_settings.py index b006647fa6..58b7b1175d 100644 --- a/hypha/apply/projects/tests/test_settings.py +++ b/hypha/apply/projects/tests/test_settings.py @@ -1,15 +1,18 @@ -from django.test import TestCase, override_settings +# Fix me, for details on why this is commented out, see +# https://github.com/HyphaApp/hypha/issues/3606 -from hypha.apply.users.tests.factories import StaffFactory +# from django.test import TestCase, override_settings +# from hypha.apply.users.tests.factories import StaffFactory -class TestProjectFeatureFlag(TestCase): - @override_settings(PROJECTS_ENABLED=False) - def test_urls_404_when_turned_off(self): - self.client.force_login(StaffFactory()) - response = self.client.get("/apply/projects/", follow=True) - self.assertEqual(response.status_code, 404) +# class TestProjectFeatureFlag(TestCase): +# @override_settings(PROJECTS_ENABLED=False) +# def test_urls_404_when_turned_off(self): +# self.client.force_login(StaffFactory()) - response = self.client.get("/apply/projects/1/", follow=True) - self.assertEqual(response.status_code, 404) +# response = self.client.get("/apply/projects/", follow=True) +# self.assertEqual(response.status_code, 404) + +# response = self.client.get("/apply/projects/1/", follow=True) +# self.assertEqual(response.status_code, 404) diff --git a/hypha/apply/projects/tests/test_views.py b/hypha/apply/projects/tests/test_views.py index 688f9d2119..480e767079 100644 --- a/hypha/apply/projects/tests/test_views.py +++ b/hypha/apply/projects/tests/test_views.py @@ -2,6 +2,7 @@ from io import BytesIO from dateutil.relativedelta import relativedelta +from django.conf import settings from django.contrib.auth.models import AnonymousUser from django.core.exceptions import PermissionDenied from django.test import RequestFactory, TestCase, override_settings @@ -771,7 +772,7 @@ def test_anonymous_can_not_access(self): self.assertEqual(response.status_code, 200) self.assertEqual(len(response.redirect_chain), 2) for path, _ in response.redirect_chain: - self.assertIn(reverse("users_public:login"), path) + self.assertIn(reverse(settings.LOGIN_URL), path) class TestProjectDetailApprovalView(TestCase): diff --git a/hypha/apply/stream_forms/models.py b/hypha/apply/stream_forms/models.py index b20e5875dd..27c0f0cf4a 100644 --- a/hypha/apply/stream_forms/models.py +++ b/hypha/apply/stream_forms/models.py @@ -74,9 +74,15 @@ def get_form_fields(self, draft=False, form_data=None, user=None): "You are logged in so this information is fetched from your user account." ) if isinstance(block, FullNameBlock) and user and user.is_authenticated: - field_from_block.disabled = True - field_from_block.initial = user.full_name - field_from_block.help_text = disabled_help_text + if user.full_name: + field_from_block.disabled = True + field_from_block.initial = user.full_name + field_from_block.help_text = disabled_help_text + else: + field_from_block.help_text = _( + "You are logged in but your user account does not have a " + "full name. We'll update your user account with the name you provide here." + ) if isinstance(block, EmailBlock) and user and user.is_authenticated: field_from_block.disabled = True field_from_block.initial = user.email diff --git a/hypha/apply/urls.py b/hypha/apply/urls.py index 82ec859749..35fe9a8e32 100644 --- a/hypha/apply/urls.py +++ b/hypha/apply/urls.py @@ -21,9 +21,7 @@ # page and advances user to download backup code page. path( "account/two_factor/setup/complete/", - RedirectView.as_view( - url=reverse_lazy("users:backup_tokens_password"), permanent=False - ), + RedirectView.as_view(url=reverse_lazy("users:backup_tokens"), permanent=False), name="two_factor:setup_complete", ), path("", include(tf_urls, "two_factor")), diff --git a/hypha/apply/users/forms.py b/hypha/apply/users/forms.py index 13dcd722e7..4808e19f73 100644 --- a/hypha/apply/users/forms.py +++ b/hypha/apply/users/forms.py @@ -23,6 +23,33 @@ def __init__(self, *args, **kwargs): ) +class PasswordlessAuthForm(forms.Form): + """Form to collect the email for passwordless login or signup (if enabled) + + Adds login extra text and user content to the form, if configured in the + wagtail auth settings. + """ + + email = forms.EmailField( + label=_("Email Address"), + required=True, + max_length=254, + widget=forms.EmailInput(attrs={"autofocus": True, "autocomplete": "email"}), + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = kwargs.pop("request", None) + self.user_settings = AuthSettings.load(request_or_site=self.request) + self.extra_text = self.user_settings.extra_text + if self.user_settings.consent_show: + self.fields["consent"] = forms.BooleanField( + label=self.user_settings.consent_text, + help_text=self.user_settings.consent_help, + required=True, + ) + + class CustomUserAdminFormBase: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -65,18 +92,21 @@ class Meta: fields = ["full_name", "email", "slack"] def __init__(self, *args, **kwargs): + self.request = kwargs.pop("request", None) super().__init__(*args, **kwargs) if not self.instance.is_apply_staff_or_finance: del self.fields["slack"] - if not self.instance.has_usable_password(): - # User is registered with oauth - no password change allowed - email_field = self.fields["email"] - email_field.disabled = True - email_field.required = False - email_field.help_text = _( - "You are registered using OAuth, please contact an admin if you need to change your email address." - ) + if self.request is not None: + backend = self.request.session["_auth_user_backend"] + if "social_core.backends" in backend: + # User is registered with oauth - no password change allowed + email_field = self.fields["email"] + email_field.disabled = True + email_field.required = False + email_field.help_text = _( + "You are registered using OAuth, please contact an admin if you need to change your email address." + ) def clean_slack(self): slack = self.cleaned_data["slack"] @@ -140,21 +170,22 @@ def save(self, updated_email, name, slack, commit=True): class TWOFAPasswordForm(forms.Form): - password = forms.CharField( - label=_("Please type your password to confirm"), - strip=False, - widget=forms.PasswordInput(attrs={"autofocus": True}), + confirmation_text = forms.CharField( + label=_('To proceed, type "disable" below and then click on "confirm":'), + strip=True, + # add widget with autofocus to avoid password autofill + widget=forms.TextInput(attrs={"autofocus": True, "autocomplete": "off"}), ) def __init__(self, user, *args, **kwargs): super().__init__(*args, **kwargs) self.user = user - def clean_password(self): - password = self.cleaned_data["password"] - if not self.user.check_password(password): + def clean_confirmation_text(self): + text = self.cleaned_data["confirmation_text"] + if text != "disable": raise forms.ValidationError( - _("Incorrect password. Please try again."), - code="password_incorrect", + _("Incorrect input."), + code="confirmation_text_incorrect", ) - return password + return text diff --git a/hypha/apply/users/middleware.py b/hypha/apply/users/middleware.py index fd11afec37..d38207b9d7 100644 --- a/hypha/apply/users/middleware.py +++ b/hypha/apply/users/middleware.py @@ -1,17 +1,33 @@ +import logging + from django.conf import settings -from django.shortcuts import redirect +from django.core.exceptions import MiddlewareNotUsed +from django.urls import set_urlconf +from django.utils.log import log_response +from django.utils.translation import gettext_lazy as _ from social_core.exceptions import AuthForbidden from social_django.middleware import ( SocialAuthExceptionMiddleware as _SocialAuthExceptionMiddleware, ) -ALLOWED_SUBPATH_FOR_UNVERIFIED_USERS = [ - "login/", - "logout/", - "account/", +from hypha.apply.users.views import mfa_failure_view + +logger = logging.getLogger("django.security.two_factor") + +TWO_FACTOR_EXEMPTED_PATH_PREFIXES = [ + "/auth/", + "/login/", + "/logout/", + "/account/", + "/apply/submissions/success/", ] +def get_page_path(wagtail_page): + _, _, page_path = wagtail_page.get_url_parts() + return page_path + + class SocialAuthExceptionMiddleware(_SocialAuthExceptionMiddleware): """ Wrapper around SocialAuthExceptionMiddleware to customise messages @@ -31,31 +47,74 @@ class TwoFactorAuthenticationMiddleware: To activate this middleware set env variable ENFORCE_TWO_FACTOR as True. This will redirect all request from unverified users to enable 2FA first. - Except the request made on the url paths listed in ALLOWED_SUBPATH_FOR_UNVERIFIED_USERS. + Except the request made on the url paths listed in TWO_FACTOR_EXEMPTED_PATH_PREFIXES. """ + reason = _("Two factor authentication required") + def __init__(self, get_response): + if not settings.ENFORCE_TWO_FACTOR: + raise MiddlewareNotUsed() + self.get_response = get_response - def is_path_allowed(self, path): - for sub_path in ALLOWED_SUBPATH_FOR_UNVERIFIED_USERS: - if sub_path in path: + def _accept(self, request): + return self.get_response(request) + + def _reject(self, request, reason): + set_urlconf("hypha.apply.urls") + response = mfa_failure_view(request, reason=reason) + log_response( + "Forbidden (%s): %s", + reason, + request.path, + response=response, + request=request, + logger=logger, + ) + return response + + def whitelisted_paths(self, path): + if path == "/": + return True + + for sub_path in TWO_FACTOR_EXEMPTED_PATH_PREFIXES: + if path.startswith(sub_path): return True return False + def get_urls_open_rounds(self): + from hypha.apply.funds.models import ApplicationBase + + return map( + get_page_path, ApplicationBase.objects.order_by_end_date().specific() + ) + + def get_urls_open_labs(self): + from hypha.apply.funds.models import LabBase + + return map( + get_page_path, + LabBase.objects.public().live().specific(), + ) + def __call__(self, request): + if self.whitelisted_paths(request.path): + return self._accept(request) + # code to execute before the view user = request.user - if settings.ENFORCE_TWO_FACTOR: - if ( - user.is_authenticated - and not user.is_verified() - and not user.social_auth.exists() - ): - if not self.is_path_allowed(request.path): - return redirect("/account/two_factor/required/") - - response = self.get_response(request) - - # code to execute after view - return response + if user.is_authenticated: + if user.social_auth.exists() or user.is_verified(): + return self._accept(request) + + # Allow rounds and lab detail pages + if request.path in self.get_urls_open_rounds(): + return self._accept(request) + + if request.path in self.get_urls_open_labs(): + return self._accept(request) + + return self._reject(request, self.reason) + + return self._accept(request) diff --git a/hypha/apply/users/migrations/0021_pendingsignup.py b/hypha/apply/users/migrations/0021_pendingsignup.py new file mode 100644 index 0000000000..a40e95239c --- /dev/null +++ b/hypha/apply/users/migrations/0021_pendingsignup.py @@ -0,0 +1,34 @@ +# Generated by Django 3.2.21 on 2023-09-12 08:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("users", "0020_auto_20230625_1825"), + ] + + operations = [ + migrations.CreateModel( + name="PendingSignup", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("email", models.EmailField(max_length=254, unique=True)), + ("created", models.DateTimeField(auto_now_add=True)), + ("modified", models.DateTimeField(auto_now=True)), + ("token", models.CharField(max_length=255, unique=True)), + ], + options={ + "verbose_name_plural": "Pending signups", + "ordering": ("created",), + }, + ), + ] diff --git a/hypha/apply/users/migrations/0022_confirmaccesstoken.py b/hypha/apply/users/migrations/0022_confirmaccesstoken.py new file mode 100644 index 0000000000..20b34b7c3e --- /dev/null +++ b/hypha/apply/users/migrations/0022_confirmaccesstoken.py @@ -0,0 +1,42 @@ +# Generated by Django 3.2.22 on 2023-10-31 06:59 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("users", "0021_pendingsignup"), + ] + + operations = [ + migrations.CreateModel( + name="ConfirmAccessToken", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("token", models.CharField(max_length=6)), + ("created", models.DateTimeField(auto_now_add=True)), + ("modified", models.DateTimeField(auto_now=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name_plural": "Confirm Access Tokens", + "ordering": ("modified",), + }, + ), + ] diff --git a/hypha/apply/users/models.py b/hypha/apply/users/models.py index 44ee07b515..fbaab70c33 100644 --- a/hypha/apply/users/models.py +++ b/hypha/apply/users/models.py @@ -1,6 +1,6 @@ from django.conf import settings from django.contrib.auth.hashers import make_password -from django.contrib.auth.models import AbstractUser, BaseUserManager, Group +from django.contrib.auth.models import AbstractUser, BaseUserManager from django.core import exceptions from django.db import IntegrityError, models from django.db.models.constants import LOOKUP_SEP @@ -23,7 +23,11 @@ STAFF_GROUP_NAME, TEAMADMIN_GROUP_NAME, ) -from .utils import get_user_by_email, is_user_already_registered, send_activation_email +from .utils import ( + get_user_by_email, + is_user_already_registered, + send_activation_email, +) class UserQuerySet(models.QuerySet): @@ -185,10 +189,6 @@ def get_or_create_and_notify( send_activation_email(user, site, redirect_url=redirect_url) _created = True - applicant_group = Group.objects.get(name=APPLICANT_GROUP_NAME) - if applicant_group not in user.groups.all(): - user.groups.add(applicant_group) - user.save() return user, _created @@ -285,6 +285,18 @@ def is_finance_level_1(self): and not self.groups.filter(name=APPROVER_GROUP_NAME).exists() ) + @cached_property + def can_access_dashboard(self): + return ( + self.is_apply_staff + or self.is_reviewer + or self.is_partner + or self.is_community_reviewer + or self.is_finance + or self.is_contracting + or self.is_applicant + ) + @cached_property def is_finance_level_2(self): # disable finance2 user if invoice flow in not extended @@ -362,3 +374,44 @@ class Meta: _("Register form customizations"), ), ] + + +class PendingSignup(models.Model): + """This model tracks pending passwordless self-signups, and is used to + generate a one-time use URLfor each signup. + + The URL is sent to the user via email, and when they click on it, they are + redirected to the registration page, where a new is created. + + Once the user is created, the PendingSignup instance is deleted. + """ + + email = models.EmailField(unique=True) + created = models.DateTimeField(auto_now_add=True) + modified = models.DateTimeField(auto_now=True) + token = models.CharField(max_length=255, unique=True) + + def __str__(self): + return f"{self.email} ({self.created})" + + class Meta: + ordering = ("created",) + verbose_name_plural = "Pending signups" + + +class ConfirmAccessToken(models.Model): + """ + Once the user is created, the PendingSignup instance is deleted. + """ + + token = models.CharField(max_length=6) + user = models.ForeignKey(User, on_delete=models.CASCADE) + created = models.DateTimeField(auto_now_add=True) + modified = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"ConfirmAccessToken: {self.user.email} ({self.created})" + + class Meta: + ordering = ("modified",) + verbose_name_plural = "Confirm Access Tokens" diff --git a/hypha/apply/users/services.py b/hypha/apply/users/services.py new file mode 100644 index 0000000000..8762aa00c2 --- /dev/null +++ b/hypha/apply/users/services.py @@ -0,0 +1,162 @@ +from django.conf import settings +from django.contrib.auth import get_user_model +from django.http import HttpRequest +from django.urls import reverse +from django.utils.crypto import get_random_string +from django.utils.encoding import force_bytes +from django.utils.http import urlsafe_base64_encode +from wagtail.models import Site + +from hypha.core.mail import MarkdownMail + +from .models import PendingSignup +from .tokens import PasswordlessLoginTokenGenerator, PasswordlessSignupTokenGenerator +from .utils import get_redirect_url, get_user_by_email + +User = get_user_model() + + +class PasswordlessAuthService: + login_token_generator_class = PasswordlessLoginTokenGenerator + signup_token_generator_class = PasswordlessSignupTokenGenerator + + next_url = None + + def __init__(self, request: HttpRequest, redirect_field_name: str = "next") -> None: + self.redirect_field_name = redirect_field_name + self.next_url = get_redirect_url(request, self.redirect_field_name) + self.request = request + self.site = Site.find_for_request(request) + + def _get_login_path(self, user): + token = self.login_token_generator_class().make_token(user) + uid = urlsafe_base64_encode(force_bytes(user.pk)) + login_path = reverse( + "users:do_passwordless_login", kwargs={"uidb64": uid, "token": token} + ) + + if self.next_url: + login_path = f"{login_path}?next={self.next_url}" + + return login_path + + def _get_signup_path(self, signup_obj): + token = self.signup_token_generator_class().make_token(user=signup_obj) + uid = urlsafe_base64_encode(force_bytes(signup_obj.pk)) + + signup_path = reverse( + "users:do_passwordless_signup", kwargs={"uidb64": uid, "token": token} + ) + + if self.next_url: + signup_path = f"{signup_path}?next={self.next_url}" + + return signup_path + + def get_email_context(self) -> dict: + return { + "org_long_name": settings.ORG_LONG_NAME, + "org_email": settings.ORG_EMAIL, + "org_short_name": settings.ORG_SHORT_NAME, + "site": self.site, + } + + def send_email_no_account_found(self, to): + context = self.get_email_context() + subject = "Login attempt at {org_long_name}".format(**context) + # Force subject to a single line to avoid header-injection issues. + subject = "".join(subject.splitlines()) + + email = MarkdownMail("users/emails/passwordless_login_no_account_found.md") + email.send( + to=to, + subject=subject, + from_email=settings.DEFAULT_FROM_EMAIL, + context=context, + ) + + def send_login_email(self, user): + login_path = self._get_login_path(user) + timeout_minutes = self.login_token_generator_class().TIMEOUT // 60 + + context = self.get_email_context() + context.update( + { + "user": user, + "is_active": user.is_active, + "name": user.get_full_name(), + "username": user.get_username(), + "login_path": login_path, + "timeout_minutes": timeout_minutes, + } + ) + + subject = "Login to {username} at {org_long_name}".format(**context) + # Force subject to a single line to avoid header-injection issues. + subject = "".join(subject.splitlines()) + + email = MarkdownMail("users/emails/passwordless_login_email.md") + email.send( + to=user.email, + subject=subject, + from_email=settings.DEFAULT_FROM_EMAIL, + context=context, + ) + + def send_new_account_login_email(self, signup_obj): + signup_path = self._get_signup_path(signup_obj) + timeout_minutes = self.login_token_generator_class().TIMEOUT // 60 + + context = self.get_email_context() + context.update( + { + "signup_path": signup_path, + "timeout_minutes": timeout_minutes, + } + ) + + subject = "Welcome to {org_long_name}".format(**context) + # Force subject to a single line to avoid header-injection issues. + subject = "".join(subject.splitlines()) + + email = MarkdownMail("users/emails/passwordless_new_account_login.md") + email.send( + to=signup_obj.email, + subject=subject, + from_email=settings.DEFAULT_FROM_EMAIL, + context=context, + ) + + def initiate_login_signup(self, email: str) -> None: + """Send a passwordless login/signup email. + + If the user exists, send a login email. If the user does not exist, send a + signup invite email. + + Args: + email: Email address to send the email to. + request: HttpRequest object. + next_url: URL to redirect to after login/signup. Defaults to None. + + Returns: + None + """ + if user := get_user_by_email(email): + self.send_login_email(user) + return + + # No account found + if not settings.ENABLE_PUBLIC_SIGNUP: + self.send_email_no_account_found(email) + return + + # Self registration is enabled + signup_obj, _ = PendingSignup.objects.update_or_create( + email=email, + defaults={ + "token": get_random_string(32, "abcdefghijklmnopqrstuvwxyz0123456789") + }, + ) + self.send_new_account_login_email(signup_obj) + + return True diff --git a/hypha/apply/users/templates/elevate/elevate.html b/hypha/apply/users/templates/elevate/elevate.html index 9a55046959..1a5dbd29c5 100644 --- a/hypha/apply/users/templates/elevate/elevate.html +++ b/hypha/apply/users/templates/elevate/elevate.html @@ -1,34 +1,73 @@ {% extends "base-apply.html" %} -{% load i18n wagtailcore_tags %} +{% load i18n wagtailcore_tags heroicons %} {% block title %}{% trans "Confirm access" %}{% endblock %} {% block body_class %}bg-white{% endblock %} {% block content %} -
+
-
- {% csrf_token %} -

{% trans "Confirm access" %}

+

{% trans "Confirm access" %}

-

- Signed in as {{ request.user }} ({{ request.user.email }}) -

+

+ Signed in as {% if request.user.full_name %} {{ request.user.full_name }} ({{ request.user.email }}) {% else %}{{ request.user.email }} {% endif %} +

+ +
+ + {% if request.user.has_usable_password %} + + {% for field in form %} + {% include "forms/includes/field.html" %} + {% endfor %} + +
+ +
+ + {% else %} +
+ + +
+ {% endif %} - {% if form.non_field_errors %} -
{{ form.non_field_errors.as_text }}
+ {% if request.user.has_usable_password %} +
+

{% trans "Having problems?" %}

+
+
{% endif %} - {% for field in form %} - {% include "forms/includes/field.html" %} - {% endfor %} +
-
- -
- -

+

{% blocktrans %} Tip: You are entering sudo mode. After you've performed a sudo-protected action, you'll only be asked to re-authenticate again after a few hours of inactivity. diff --git a/hypha/apply/users/templates/two_factor/_base_focus.html b/hypha/apply/users/templates/two_factor/_base_focus.html index 2a63a6db9b..d53a7af9f9 100644 --- a/hypha/apply/users/templates/two_factor/_base_focus.html +++ b/hypha/apply/users/templates/two_factor/_base_focus.html @@ -13,10 +13,12 @@ {% endslot %} {% comment %} {% slot sub_heading %}{% trans "All submissions ready for discussion." %}{% endslot %} {% endcomment %} - - {% trans "Go to my dashboard" %} - - + {% if user.can_access_dashboard %} + + {% trans "Go to my dashboard" %} + + + {% endif %} {% endadminbar %}

diff --git a/hypha/apply/users/templates/two_factor/_wizard_actions.html b/hypha/apply/users/templates/two_factor/_wizard_actions.html index eaff592606..4931f35879 100644 --- a/hypha/apply/users/templates/two_factor/_wizard_actions.html +++ b/hypha/apply/users/templates/two_factor/_wizard_actions.html @@ -1,7 +1,7 @@ {% load i18n %} {% if wizard.steps.current == 'token' %} - {% trans "Login" as button_text %} + {% trans "Submit" as button_text %} {% elif wizard.steps.current == 'generator' %} {% trans "Next" as button_text %} {% elif wizard.steps.current == 'welcome' %} @@ -10,12 +10,22 @@ {% trans "Next" as button_text %} {% endif %} - + diff --git a/hypha/apply/users/templates/two_factor/core/backup_tokens.html b/hypha/apply/users/templates/two_factor/core/backup_tokens.html index 297cd8503d..ab0108d208 100644 --- a/hypha/apply/users/templates/two_factor/core/backup_tokens.html +++ b/hypha/apply/users/templates/two_factor/core/backup_tokens.html @@ -14,7 +14,7 @@

{% block title %}{% trans "Backup Codes" %}{% endblock %}

cols="8" rows="{{ device.token_set.count }}" id="list-backup-tokens" - class="border" + class="font-mono pr-0 font-medium leading-tight bg-orange-100 resize-none" >{% for token in device.token_set.all %}{{ token.token }}{% if not forloop.last %} {% endif %}{% endfor %} {% endif %}
diff --git a/hypha/apply/users/templates/users/activation/email_subject.txt b/hypha/apply/users/templates/users/activation/email_subject.txt new file mode 100644 index 0000000000..367b3ea742 --- /dev/null +++ b/hypha/apply/users/templates/users/activation/email_subject.txt @@ -0,0 +1,3 @@ +{% load i18n %}{% autoescape off %} +{% blocktranslate %}Account details for {{ username }} at {{ org_long_name }}{% endblocktranslate %} +{% endautoescape %} diff --git a/hypha/apply/users/templates/users/activation/invalid.html b/hypha/apply/users/templates/users/activation/invalid.html index b6dc037fd6..1457244442 100644 --- a/hypha/apply/users/templates/users/activation/invalid.html +++ b/hypha/apply/users/templates/users/activation/invalid.html @@ -1,18 +1,25 @@ -{% extends 'base.html' %} -{% load i18n %} +{% extends "base-apply.html" %} +{% load i18n heroicons %} {% block title %}{% trans "Invalid activation" %}{% endblock %} -{% block page_title %}{% trans "Invalid activation URL" %}{% endblock %} +{% block body_class %}bg-white{% endblock %} {% block content %} - {% url 'users:password_reset' as password_reset %} -
-

{% trans "Two possible reasons:" %}

-
    -
  • {% trans "The activation link has expired." %}
  • -
  • {% trans "The account has already been activated." %}
  • -
+
-

{% blocktrans %}First try to reset your password. If that fails please contact {{ ORG_SHORT_NAME }} at{% endblocktrans %} {{ ORG_EMAIL }}

-
+
+ {% heroicon_outline "exclamation-triangle" aria_hidden="true" size=64 class="stroke-red-600" %} + +

{% trans "Invalid activation URL" %}

+ {% url 'users:password_reset' as password_reset %} +
+

{% trans "Two possible reasons:" %}

+
    +
  1. {% trans "The activation link has expired." %}
  2. +
  3. {% trans "The account has already been activated." %}
  4. +
+ +

{% blocktrans %}First try to reset your password. If that fails please contact {{ ORG_SHORT_NAME }} at{% endblocktrans %} {{ ORG_EMAIL }}

+
+
{% endblock %} diff --git a/hypha/apply/users/templates/users/email_change/confirm_password.html b/hypha/apply/users/templates/users/email_change/confirm_password.html deleted file mode 100644 index 5b5b638acb..0000000000 --- a/hypha/apply/users/templates/users/email_change/confirm_password.html +++ /dev/null @@ -1,40 +0,0 @@ -{% extends 'base.html' %} -{% load i18n %} -{% block header_modifier %}header--light-bg{% endblock %} -{% block page_title %}{% trans "Enter Password" %}{% endblock %} -{% block title %}{% trans "Enter Password" %}{% endblock %} - - -{% block content %} -
-
- {% if form.non_field_errors %} -
    - {% for error in form.non_field_errors %} -
  • {{ error }}
  • - {% endfor %} -
- {% endif %} - - {% if form.errors %} -
    - {% blocktrans trimmed count counter=form.errors.items|length %} -
  • Please correct the error below.
  • - {% plural %} -
  • Please correct the errors below.
  • - {% endblocktrans %} -
- {% endif %} - - {% csrf_token %} - - {% for field in form %} - {% include "forms/includes/field.html" %} - {% endfor %} - -
- -
-
-
-{% endblock %} diff --git a/hypha/apply/users/templates/users/email_change/done.html b/hypha/apply/users/templates/users/email_change/done.html index 448999f44a..fcf6194f7d 100644 --- a/hypha/apply/users/templates/users/email_change/done.html +++ b/hypha/apply/users/templates/users/email_change/done.html @@ -1,11 +1,21 @@ -{% extends "base.html" %} +{% extends "base-apply.html" %} {% load i18n %} -{% block header_modifier %}header--light-bg{% endblock %} -{% block page_title %}{% trans "Check your email" %}{% endblock %} +{% block page_title %}{% trans "Email Change - Verify Email" %}{% endblock %} {% block title %}{% trans "Verify Email" %}{% endblock %} {% block content %} -
-

{% trans "To start using the new email, please click on the confirmation link that has been sent to you on your new email." %}

+ + {% adminbar %} + {% slot header %}{% trans "Email Update" %}{% endslot %} + {% endadminbar %} + +
+

{% trans "Confirm & verify your new email!" %}

+

+ {% trans "We have sent a confirmation link to your new email." %} +

+

+ {% trans "To start using the new email, please click on the confirmation link that has been sent to you on your new email." %} +

{% endblock %} diff --git a/hypha/apply/users/templates/users/emails/confirm_access.md b/hypha/apply/users/templates/users/emails/confirm_access.md new file mode 100644 index 0000000000..f9ee524e43 --- /dev/null +++ b/hypha/apply/users/templates/users/emails/confirm_access.md @@ -0,0 +1,19 @@ +{% load i18n wagtailadmin_tags %}{% base_url_setting as base_url %} +{% blocktrans %}Dear {{ user }},{% endblocktrans %} + +{% blocktrans %}To confirm access at {{ org_long_name }} use the code below (valid for {{ timeout_minutes }} minutes):{% endblocktrans %} + +{{ token }} + +{% blocktrans %}If you did not request this email, please ignore it.{% endblocktrans %} + +{% if org_email %} +{% blocktrans %}If you have any questions, please contact us at {{ org_email }}.{% endblocktrans %} +{% endif %} + +{% blocktrans %}Kind Regards, +The {{ org_short_name }} Team{% endblocktrans %} + +-- +{{ org_long_name }} +{% if site %}{{ site.root_url }}{% else %}{{ base_url }}{% endif %} diff --git a/hypha/apply/users/templates/users/emails/passwordless_login_email.md b/hypha/apply/users/templates/users/emails/passwordless_login_email.md new file mode 100644 index 0000000000..9ebb66c123 --- /dev/null +++ b/hypha/apply/users/templates/users/emails/passwordless_login_email.md @@ -0,0 +1,26 @@ +{% load i18n wagtailadmin_tags %}{% base_url_setting as base_url %}{% firstof name username as user %} +{% blocktrans %}Dear {{ user }},{% endblocktrans %} + +{% if is_active %} +{% blocktrans %}Login to your account on the {{ org_long_name }} web site by clicking this link or copying and pasting it to your browser:{% endblocktrans %} + +{% if site %}{{ site.root_url }}{% else %}{{ base_url }}{% endif %}{{ login_path }} + +{% blocktrans %}This link will valid for {{ timeout_minutes }} minutes and can be used only once.{% endblocktrans %} + +{% else %} +{% blocktrans %}Your account on the {{ org_long_name }} web site is deactivated. Please contact site administrators.{% endblocktrans %} +{% endif %} + +{% blocktrans %}If you did not request this email, please ignore it.{% endblocktrans %} + +{% if org_email %} +{% blocktrans %}If you have any questions, please contact us at {{ org_email }}.{% endblocktrans %} +{% endif %} + +{% blocktrans %}Kind Regards, +The {{ org_short_name }} Team{% endblocktrans %} + +-- +{{ org_long_name }} +{% if site %}{{ site.root_url }}{% else %}{{ base_url }}{% endif %} diff --git a/hypha/apply/users/templates/users/emails/passwordless_login_no_account_found.md b/hypha/apply/users/templates/users/emails/passwordless_login_no_account_found.md new file mode 100644 index 0000000000..9f9cea09ef --- /dev/null +++ b/hypha/apply/users/templates/users/emails/passwordless_login_no_account_found.md @@ -0,0 +1,16 @@ +{% load i18n wagtailadmin_tags %}{% base_url_setting as base_url %} + +{% blocktrans %}Dear,{% endblocktrans %} + +{% blocktrans %}It looks like you are trying to login on {{ org_long_name }} web site, but we could not find any account with the email provided.{% endblocktrans %} + +{% if org_email %} +{% blocktrans %}If you have any questions, please contact us at {{ org_email }}.{% endblocktrans %} +{% endif %} + +{% blocktrans %}Kind Regards, +The {{ org_short_name }} Team{% endblocktrans %} + +-- +{{ org_long_name }} +{% if site %}{{ site.root_url }}{% else %}{{ base_url }}{% endif %} diff --git a/hypha/apply/users/templates/users/emails/passwordless_new_account_login.md b/hypha/apply/users/templates/users/emails/passwordless_new_account_login.md new file mode 100644 index 0000000000..ab23da16a8 --- /dev/null +++ b/hypha/apply/users/templates/users/emails/passwordless_new_account_login.md @@ -0,0 +1,21 @@ +{% load i18n wagtailadmin_tags %}{% base_url_setting as base_url %} +{% blocktrans %}Dear,{% endblocktrans %} + +{% blocktrans %}Welcome to {{ org_long_name }} web site. Create your account by clicking this link or copying and pasting it to your browser:{% endblocktrans %} + +{% if site %}{{ site.root_url }}{% else %}{{ base_url }}{% endif %}{{ signup_path }} + +{% blocktrans %}This link will valid for {{ timeout_minutes }} minutes and can be used only once.{% endblocktrans %} + +{% blocktrans %}If you did not request this email, please ignore it.{% endblocktrans %} + +{% if org_email %} +{% blocktrans %}If you have any questions, please contact us at {{ org_email }}.{% endblocktrans %} +{% endif %} + +{% blocktrans %}Kind Regards, +The {{ org_short_name }} Team{% endblocktrans %} + +-- +{{ org_long_name }} +{% if site %}{{ site.root_url }}{% else %}{{ base_url }}{% endif %} diff --git a/hypha/apply/users/templates/users/emails/set_password.txt b/hypha/apply/users/templates/users/emails/set_password.txt new file mode 100644 index 0000000000..a5c66b62cf --- /dev/null +++ b/hypha/apply/users/templates/users/emails/set_password.txt @@ -0,0 +1,15 @@ +{% load i18n wagtailadmin_tags %}{% base_url_setting as base_url %}{% firstof name username as user %} +{% blocktrans %}Dear {{ user }},{% endblocktrans %} + +{% blocktrans %}Set your account password on the {{ org_long_name }} web site by clicking this link or copying and pasting it to your browser:{% endblocktrans %} + +{% if site %}{{ site.root_url }}{% else %}{{ base_url }}{% endif %}{{ activation_path }} + +{% blocktrans %}This link can be used only once and will lead you to a page where you can set your password. It will remain active for {{ timeout_days }} days, so please set your password as soon as possible.{% endblocktrans %} + +{% blocktrans %}Kind Regards, +The {{ org_short_name }} Team{% endblocktrans %} + +-- +{{ org_long_name }} +{% if site %}{{ site.root_url }}{% else %}{{ base_url }}{% endif %} diff --git a/hypha/apply/users/templates/users/emails/set_password_subject.txt b/hypha/apply/users/templates/users/emails/set_password_subject.txt new file mode 100644 index 0000000000..ac5b535ead --- /dev/null +++ b/hypha/apply/users/templates/users/emails/set_password_subject.txt @@ -0,0 +1,3 @@ +{% load i18n %}{% autoescape off %} +{% blocktranslate %}Set password for {{ username }} at {{ org_long_name }}{% endblocktranslate %} +{% endautoescape %} diff --git a/hypha/apply/users/templates/users/login.html b/hypha/apply/users/templates/users/login.html index ab5154f588..37815bf1c9 100644 --- a/hypha/apply/users/templates/users/login.html +++ b/hypha/apply/users/templates/users/login.html @@ -31,18 +31,36 @@

Two Factor Verification

{% if wizard.steps.current == 'auth' %} + +

Log in to {{ ORG_SHORT_NAME }}

{% for field in form %} -
+
{% include "forms/includes/field.html" %} + {% if field.auto_id == "id_auth-password" %} + + {% endif %}
{% endfor %} + {% if settings.users.AuthSettings.extra_text %} - {{ settings.users.AuthSettings.extra_text|richtext}} +
+ {{ settings.users.AuthSettings.extra_text|richtext}} +
{% endif %} -
+
+ + {% if ENABLE_PUBLIC_SIGNUP %} + {% trans "Create account" %} + {% endif %}
{% if GOOGLE_OAUTH2 %} @@ -56,20 +74,13 @@

Log in to {{ ORG_SHORT_NAME }}

{% blocktrans %}Log in with your {{ ORG_SHORT_NAME }} email{% endblocktrans %}
{% endif %} - -
- {% if ENABLE_REGISTRATION_WITHOUT_APPLICATION %} - {% trans "Create account" %} - {% endif %} - {% trans "Forgot your password?" %} -
{% else %}
{{ wizard.form }}
- {# hidden submit button to enable [enter] key #} + {# hidden submit button to enable [enter] key #}
{% if other_devices %} @@ -90,7 +101,7 @@

Log in to {{ ORG_SHORT_NAME }}

{% if backup_tokens %}

{% trans "As a last resort, you can use a backup codes:" %} + class="button button--transparent">{% trans "Use Backup Code" %}

{% endif %} {% endif %} diff --git a/hypha/apply/users/templates/users/partials/confirmation_code_sent.html b/hypha/apply/users/templates/users/partials/confirmation_code_sent.html new file mode 100644 index 0000000000..c3a7f8fedd --- /dev/null +++ b/hypha/apply/users/templates/users/partials/confirmation_code_sent.html @@ -0,0 +1,74 @@ +{% load i18n heroicons %} +
+ {% csrf_token %} + {% if error %} +

{% trans "Invalid code, please try again!" %}

+ {% else %} +

+ {% heroicon_mini "check-circle" class="inline align-text-bottom fill-green-700" aria_hidden=true %} + {% trans "An email containing a code has been sent. Please check your email for the code." %} +

+ {% endif %} + +
+ + +
+ +
+ +
+ {% if error %} + + {% endif %} +
+ +{% if request.user.has_usable_password %} +
+

{% trans "Having problems?" %}

+ +
+{% endif %} + diff --git a/hypha/apply/users/templates/users/partials/passwordless_login_signup_sent.html b/hypha/apply/users/templates/users/partials/passwordless_login_signup_sent.html new file mode 100644 index 0000000000..62a07ec429 --- /dev/null +++ b/hypha/apply/users/templates/users/partials/passwordless_login_signup_sent.html @@ -0,0 +1,29 @@ +{% extends base_template %} +{% load i18n heroicons %} + +{% block content %} +
+
+ {% heroicon_outline "document-check" aria_hidden="true" size=64 %} +
+

+ {% trans "Check your inbox to proceed!" %} +

+ +

+ {% if ENABLE_PUBLIC_SIGNUP %} + {% trans "We have sent you an email containing a link for logging in or signing up. Please check your email and use the link provided to either login or create your account." %}

+ {% else %} + {% trans "We've sent you an email with a login link. Kindly check your email and follow the link to access your account." %}

+ {% endif %} +

+ +

+ {% blocktrans %}Check your "Spam" folder, if you don't find the email in your inbox.{% endblocktrans %} +

+ +

+ Try again +

+
+{% endblock content %} diff --git a/hypha/apply/users/templates/users/passwordless_login_signup.html b/hypha/apply/users/templates/users/passwordless_login_signup.html new file mode 100644 index 0000000000..d837caf90f --- /dev/null +++ b/hypha/apply/users/templates/users/passwordless_login_signup.html @@ -0,0 +1,69 @@ +{% extends base_template %} +{% load i18n wagtailcore_tags heroicons %} + +{% block title %}{% trans "Login or Signup" %}{% endblock %} + +{% block content %} +
+ +
+ +
+
+{% endblock %} diff --git a/hypha/apply/users/tests/test_forms.py b/hypha/apply/users/tests/test_forms.py index dc7fd7d6f9..1b3c5f35c5 100644 --- a/hypha/apply/users/tests/test_forms.py +++ b/hypha/apply/users/tests/test_forms.py @@ -1,5 +1,5 @@ from django.forms.models import model_to_dict -from django.test import TestCase +from django.test import RequestFactory, TestCase from ..forms import EmailChangePasswordForm, ProfileForm from .factories import StaffFactory, UserFactory @@ -12,9 +12,11 @@ def form_data(self, user, **values): data.update(**values) return data - def submit_form(self, instance, **extra_data): + def submit_form(self, instance, request=None, **extra_data): form = ProfileForm( - instance=instance, data=self.form_data(instance, **extra_data) + instance=instance, + data=self.form_data(instance, **extra_data), + request=request, ) if form.is_valid(): form.save() @@ -28,7 +30,7 @@ def setUp(self): def test_email_unique(self): other_user = UserFactory() - form = self.submit_form(self.user, email=other_user.email) + form = self.submit_form(instance=self.user, email=other_user.email) # form will update the other user's email with same user email, only non exiting email address can be added self.assertTrue(form.is_valid()) self.user.refresh_from_db() @@ -36,13 +38,13 @@ def test_email_unique(self): def test_can_change_email(self): new_email = "me@another.com" - self.submit_form(self.user, email=new_email) + self.submit_form(instance=self.user, email=new_email) self.user.refresh_from_db() self.assertEqual(self.user.email, new_email) def test_cant_set_slack_name(self): slack_name = "@foobar" - self.submit_form(self.user, slack=slack_name) + self.submit_form(instance=self.user, slack=slack_name) self.user.refresh_from_db() self.assertNotEqual(self.user.slack, slack_name) @@ -51,29 +53,33 @@ class TestStaffProfileForm(BaseTestProfileForm): def setUp(self): self.staff = StaffFactory() - def test_cant_change_email(self): + def test_cant_change_email_oauth(self): new_email = "me@this.com" - self.submit_form(self.staff, email=new_email) + request = RequestFactory().get("/") + request.session = { + "_auth_user_backend": "social_core.backends.google.GoogleOAuth2" + } + self.submit_form(instance=self.staff, request=request, email=new_email) self.staff.refresh_from_db() self.assertNotEqual(new_email, self.staff.email) def test_can_set_slack_name(self): slack_name = "@foobar" - self.submit_form(self.staff, slack=slack_name) + self.submit_form(instance=self.staff, slack=slack_name) self.staff.refresh_from_db() self.assertEqual(self.staff.slack, slack_name) def test_can_set_slack_name_with_trailing_space(self): slack_name = "@foobar" - self.submit_form(self.staff, slack=slack_name) + self.submit_form(instance=self.staff, slack=slack_name) self.staff.refresh_from_db() self.assertEqual(self.staff.slack, slack_name) def test_cant_set_slack_name_with_space(self): slack_name = "@ foobar" - form = self.submit_form(self.staff, slack=slack_name) + form = self.submit_form(instance=self.staff, slack=slack_name) self.assertFalse(form.is_valid()) self.staff.refresh_from_db() @@ -81,14 +87,14 @@ def test_cant_set_slack_name_with_space(self): def test_auto_prepend_at(self): slack_name = "foobar" - self.submit_form(self.staff, slack=slack_name) + self.submit_form(instance=self.staff, slack=slack_name) self.staff.refresh_from_db() self.assertEqual(self.staff.slack, "@" + slack_name) def test_can_clear_slack_name(self): slack_name = "" - self.submit_form(self.staff, slack=slack_name) + self.submit_form(instance=self.staff, slack=slack_name) self.staff.refresh_from_db() self.assertEqual(self.staff.slack, slack_name) diff --git a/hypha/apply/users/tests/test_middleware.py b/hypha/apply/users/tests/test_middleware.py index a5de674bb4..d446661b64 100644 --- a/hypha/apply/users/tests/test_middleware.py +++ b/hypha/apply/users/tests/test_middleware.py @@ -4,7 +4,7 @@ from hypha.apply.users.tests.factories import UserFactory -from ..middleware import ALLOWED_SUBPATH_FOR_UNVERIFIED_USERS +from ..middleware import TWO_FACTOR_EXEMPTED_PATH_PREFIXES @override_settings(ROOT_URLCONF="hypha.apply.urls", ENFORCE_TWO_FACTOR=True) @@ -17,14 +17,10 @@ def test_unverified_user_redirect(self): self.client.force_login(user) response = self.client.get(settings.LOGIN_REDIRECT_URL, follow=True) - self.assertRedirects( - response, reverse("users:two_factor_required"), status_code=301 - ) + assert "Permission Denied" in response.content.decode("utf-8") response = self.client.get(reverse("funds:submissions:list"), follow=True) - self.assertRedirects( - response, reverse("users:two_factor_required"), status_code=301 - ) + assert "Permission Denied" in response.content.decode("utf-8") def test_verified_user_redirect(self): user = UserFactory() @@ -40,6 +36,6 @@ def test_unverified_user_can_access_allowed_urls(self): user = UserFactory() self.client.force_login(user) - for path in ALLOWED_SUBPATH_FOR_UNVERIFIED_USERS: + for path in TWO_FACTOR_EXEMPTED_PATH_PREFIXES: response = self.client.get(path, follow=True) self.assertEqual(response.status_code, 200) diff --git a/hypha/apply/users/tests/test_oauth_access.py b/hypha/apply/users/tests/test_oauth_access.py index 124d211b32..8c7a9244de 100644 --- a/hypha/apply/users/tests/test_oauth_access.py +++ b/hypha/apply/users/tests/test_oauth_access.py @@ -22,7 +22,7 @@ def test_oauth_page_requires_login(self): response = self.client.get(oauth_page, follow=True) self.assertRedirects( response, - reverse("users_public:login") + "?next=" + reverse("users:oauth"), + reverse(settings.LOGIN_URL) + "?next=" + reverse("users:oauth"), status_code=301, target_status_code=200, ) diff --git a/hypha/apply/users/tests/test_registration.py b/hypha/apply/users/tests/test_registration.py index ea0019c6b6..6f654e96d3 100644 --- a/hypha/apply/users/tests/test_registration.py +++ b/hypha/apply/users/tests/test_registration.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.core import mail from django.test import TestCase, override_settings from django.urls import reverse @@ -8,17 +9,17 @@ @override_settings(ROOT_URLCONF="hypha.apply.urls") class TestRegistration(TestCase): - @override_settings(ENABLE_REGISTRATION_WITHOUT_APPLICATION=False) + @override_settings(ENABLE_PUBLIC_SIGNUP=False) def test_registration_enabled_has_no_link(self): response = self.client.get("/", follow=True) self.assertNotContains(response, reverse("users_public:register")) - @override_settings(ENABLE_REGISTRATION_WITHOUT_APPLICATION=True) + @override_settings(ENABLE_PUBLIC_SIGNUP=True) def test_registration_enabled_has_link(self): response = self.client.get("/", follow=True) self.assertContains(response, reverse("users_public:register")) - @override_settings(ENABLE_REGISTRATION_WITHOUT_APPLICATION=True) + @override_settings(ENABLE_PUBLIC_SIGNUP=True) def test_registration(self): response = self.client.post( reverse("users_public:register"), @@ -35,7 +36,7 @@ def test_registration(self): assert response.status_code == 302 assert reverse("users_public:register-success") in response.url - @override_settings(ENABLE_REGISTRATION_WITHOUT_APPLICATION=True) + @override_settings(ENABLE_PUBLIC_SIGNUP=True) def test_duplicate_registration_fails(self): response = self.client.post( reverse("users_public:register"), @@ -61,13 +62,11 @@ def test_duplicate_registration_fails(self): assert len(mail.outbox) == 0 self.assertContains(response, "A user with that email already exists") - @override_settings( - FORCE_LOGIN_FOR_APPLICATION=True, ENABLE_REGISTRATION_WITHOUT_APPLICATION=False - ) + @override_settings(FORCE_LOGIN_FOR_APPLICATION=True, ENABLE_PUBLIC_SIGNUP=False) def test_force_login(self): fund = FundTypeFactory() response = fund.serve( make_request(None, {}, method="get", site=fund.get_site()) ) assert response.status_code == 302 - assert response.url == reverse("users_public:login") + "?next=/" + assert response.url == reverse(settings.LOGIN_URL) + "?next=/" diff --git a/hypha/apply/users/tests/test_tokens.py b/hypha/apply/users/tests/test_tokens.py new file mode 100644 index 0000000000..8a40682859 --- /dev/null +++ b/hypha/apply/users/tests/test_tokens.py @@ -0,0 +1,63 @@ +import pytest +from ddf import G + +from hypha.apply.users.models import PendingSignup +from hypha.apply.users.tests.factories import UserFactory + +from ..tokens import PasswordlessLoginTokenGenerator, PasswordlessSignupTokenGenerator + +# mark all test to use database +pytestmark = pytest.mark.django_db + + +def test_passwordless_login_token(time_machine, settings): + """ + Test to check that the tokens are generated correctly and that they are valid + for the correct amount of time. + """ + settings.PASSWORDLESS_LOGIN_TIMEOUT = 60 + + time_machine.move_to("2021-01-01 00:00:00", tick=False) + # Create a token generator + token_generator = PasswordlessLoginTokenGenerator() + # Create a user + user = UserFactory() + # Create a token + token = token_generator.make_token(user) + + # Check that the token is valid + assert token_generator.check_token(user, token) + + # negative check + assert token_generator.check_token(user, "invalid-token") is False + + # timeout check + time_machine.shift(delta=62) + assert token_generator.check_token(user, token) is False + + +def test_passwordless_signup_token(time_machine, settings): + """ + Test to check that the tokens are generated correctly and that they are valid + for the correct amount of time. + """ + settings.PASSWORDLESS_SIGNUP_TIMEOUT = 60 + + time_machine.move_to("2021-01-01 00:00:00", tick=False) + + # Create a token generator + token_generator = PasswordlessSignupTokenGenerator() + # Create a user + signup_obj = G(PendingSignup) + + # Create a token + token = token_generator.make_token(user=signup_obj) + # Check that the token is valid + assert token_generator.check_token(user=signup_obj, token=token) + + # negative check + assert token_generator.check_token(signup_obj, "invalid-token") is False + + # timeout check + time_machine.shift(delta=62) + assert token_generator.check_token(signup_obj, token) is False diff --git a/hypha/apply/users/tests/test_views.py b/hypha/apply/users/tests/test_views.py index 45351e8e8f..0171d310df 100644 --- a/hypha/apply/users/tests/test_views.py +++ b/hypha/apply/users/tests/test_views.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.core import mail from django.test import TestCase, override_settings from django.urls import reverse @@ -25,7 +26,7 @@ def test_cant_acces_if_not_logged_in(self): # Initial redirect will be via to https through a 301 self.assertRedirects( response, - reverse("users_public:login") + "?next=" + self.url, + reverse(settings.LOGIN_URL) + "?next=" + self.url, status_code=301, ) diff --git a/hypha/apply/users/tokens.py b/hypha/apply/users/tokens.py new file mode 100644 index 0000000000..6c842fdfb1 --- /dev/null +++ b/hypha/apply/users/tokens.py @@ -0,0 +1,81 @@ +from django.conf import settings +from django.contrib.auth.tokens import PasswordResetTokenGenerator +from django.utils.crypto import constant_time_compare +from django.utils.http import base36_to_int + + +class PasswordlessLoginTokenGenerator(PasswordResetTokenGenerator): + key_salt = None + TIMEOUT = None + + def __init__(self) -> None: + self.key_salt = ( + self.key_salt or "hypha.apply.users.tokens.PasswordlessLoginTokenGenerator" + ) + self.TIMEOUT = self.TIMEOUT or settings.PASSWORDLESS_LOGIN_TIMEOUT + super().__init__() + + def check_token(self, user, token): + """ + Check that a token is correct for a given user. + """ + if not (user and token): + return False + # Parse the token + try: + ts_b36, _ = token.split("-") + except ValueError: + return False + + try: + ts = base36_to_int(ts_b36) + except ValueError: + return False + + # Check that the timestamp/uid has not been tampered with + for secret in [self.secret, *self.secret_fallbacks]: + if constant_time_compare( + self._make_token_with_timestamp(user, ts, secret), + token, + ): + break + else: + return False + + # Check the timestamp is within limit. + if (self._num_seconds(self._now()) - ts) > self.TIMEOUT: + return False + + return True + + +class PasswordlessSignupTokenGenerator(PasswordlessLoginTokenGenerator): + key_salt = None + TIMEOUT = None + + def __init__(self) -> None: + self.key_salt = ( + self.key_salt or "hypha.apply.users.tokens.PasswordlessLoginTokenGenerator" + ) + self.TIMEOUT = self.TIMEOUT or settings.PASSWORDLESS_SIGNUP_TIMEOUT + super().__init__() + + def _make_hash_value(self, user, timestamp): + """ + Hash the signup request's primary key, email, and some user state + that's sure to change after a signup is completed produce a token that is + invalidated when it's used. + + The token field and modified field will be updated after creating or + updating the signup request. + + Failing those things, settings.PASSWORDLESS_SIGNUP_TIMEOUT eventually + invalidates the token. + + Running this data through salted_hmac() prevents password cracking + attempts using the reset token, provided the secret isn't compromised. + """ + # Truncate microseconds so that tokens are consistent even if the + # database doesn't support microseconds. + modified_timestamp = user.modified.replace(microsecond=0, tzinfo=None) + return f"{user.pk}{user.token}{modified_timestamp}{timestamp}{user.email}" diff --git a/hypha/apply/users/urls.py b/hypha/apply/users/urls.py index 9d8e26b75e..080f44d30b 100644 --- a/hypha/apply/users/urls.py +++ b/hypha/apply/users/urls.py @@ -10,19 +10,24 @@ BackupTokensView, EmailChangeConfirmationView, EmailChangeDoneView, - EmailChangePasswordView, LoginView, + PasswordLessLoginSignupView, + PasswordlessLoginView, + PasswordlessSignupView, PasswordResetConfirmView, PasswordResetView, RegisterView, RegistrationSuccessView, TWOFAAdminDisableView, TWOFADisableView, - TWOFARequiredMessageView, TWOFASetupView, + account_email_change, become, create_password, + elevate_check_code_view, oauth, + send_confirm_access_email_view, + set_password_view, ) app_name = "users" @@ -30,12 +35,9 @@ public_urlpatterns = [ path( - "login/", - LoginView.as_view( - template_name="users/login.html", redirect_authenticated_user=True - ), - name="login", + "auth/", PasswordLessLoginSignupView.as_view(), name="passwordless_login_signup" ), + path("login/", LoginView.as_view(), name="login"), # Log out path("logout/", auth_views.LogoutView.as_view(next_page="/"), name="logout"), path("register/", RegisterView.as_view(), name="register"), @@ -44,114 +46,129 @@ ), ] -urlpatterns = [ +account_urls = [ + path( + "", + ratelimit(key="ip", rate=settings.DEFAULT_RATE_LIMIT, method="GET")( + AccountView.as_view() + ), + name="account", + ), + path( + "change-email/", + account_email_change, + name="email_change_confirm_password", + ), path( - "account/", + "password/", include( [ path( - "", - ratelimit(key="ip", rate=settings.DEFAULT_RATE_LIMIT, method="GET")( - AccountView.as_view() + "change/", + ratelimit( + key="user", + rate=settings.DEFAULT_RATE_LIMIT, + method="POST", + )( + auth_views.PasswordChangeView.as_view( + template_name="users/change_password.html", + success_url=reverse_lazy("users:account"), + ) ), - name="account", - ), - path( - "password/", - include( - [ - path( - "", - EmailChangePasswordView.as_view(), - name="email_change_confirm_password", - ), - path( - "change/", - ratelimit( - key="user", - rate=settings.DEFAULT_RATE_LIMIT, - method="POST", - )( - auth_views.PasswordChangeView.as_view( - template_name="users/change_password.html", - success_url=reverse_lazy("users:account"), - ) - ), - name="password_change", - ), - path( - "reset/", - PasswordResetView.as_view(), - name="password_reset", - ), - path( - "reset/done/", - auth_views.PasswordResetDoneView.as_view( - template_name="users/password_reset/done.html" - ), - name="password_reset_done", - ), - path( - "reset/confirm///", - PasswordResetConfirmView.as_view(), - name="password_reset_confirm", - ), - path( - "reset/complete/", - auth_views.PasswordResetCompleteView.as_view( - template_name="users/password_reset/complete.html" - ), - name="password_reset_complete", - ), - ] - ), - ), - path( - "confirmation/done/", - EmailChangeDoneView.as_view(), - name="confirm_link_sent", - ), - path( - "confirmation///", - EmailChangeConfirmationView.as_view(), - name="confirm_email", - ), - path( - "activate///", - ActivationView.as_view(), - name="activate", + name="password_change", ), - path("activate/", create_password, name="activate_password"), - path("oauth", oauth, name="oauth"), - # Two factor redirect path( - "two_factor/required/", - TWOFARequiredMessageView.as_view(), - name="two_factor_required", + "reset/", + PasswordResetView.as_view(), + name="password_reset", ), - path("two_factor/setup/", TWOFASetupView.as_view(), name="setup"), path( - "two_factor/backup_tokens/password/", - BackupTokensView.as_view(), - name="backup_tokens_password", + "reset/done/", + auth_views.PasswordResetDoneView.as_view( + template_name="users/password_reset/done.html" + ), + name="password_reset_done", ), - path("two_factor/disable/", TWOFADisableView.as_view(), name="disable"), path( - "two_factor/admin/disable//", - TWOFAAdminDisableView.as_view(), - name="admin_disable", + "reset/confirm///", + PasswordResetConfirmView.as_view(), + name="password_reset_confirm", ), path( - "sessions/trusted-device/", - elevate_view, - {"template_name": "elevate/elevate.html"}, - name="elevate", + "reset/complete/", + auth_views.PasswordResetCompleteView.as_view( + template_name="users/password_reset/complete.html" + ), + name="password_reset_complete", ), ] ), ), + path( + "confirmation/done/", + EmailChangeDoneView.as_view(), + name="confirm_link_sent", + ), + path( + "confirmation///", + EmailChangeConfirmationView.as_view(), + name="confirm_email", + ), + path( + "activate///", + ActivationView.as_view(), + name="activate", + ), + path("activate/", create_password, name="activate_password"), + path("oauth", oauth, name="oauth"), + # 2FA + path("two_factor/setup/", TWOFASetupView.as_view(), name="setup"), + path( + "two_factor/backup_tokens/", + BackupTokensView.as_view(), + name="backup_tokens", + ), + path("two_factor/disable/", TWOFADisableView.as_view(), name="disable"), + path( + "two_factor/admin/disable//", + TWOFAAdminDisableView.as_view(), + name="admin_disable", + ), + path( + "auth///signup/", + PasswordlessSignupView.as_view(), + name="do_passwordless_signup", + ), + path( + "auth///", + PasswordlessLoginView.as_view(), + name="do_passwordless_login", + ), + path( + "auth/set-user-password/", + set_password_view, + name="set_user_password", + ), + path( + "sessions/trusted-device/", + elevate_view, + {"template_name": "elevate/elevate.html"}, + name="elevate", + ), + path( + "sessions/send-confirm-access-email/", + send_confirm_access_email_view, + name="elevate_send_confirm_access_email", + ), + path( + "sessions/verify-confirmation-code/", + elevate_check_code_view, + name="elevate_check_code", + ), ] +urlpatterns = [path("account/", include(account_urls))] + if settings.HIJACK_ENABLE: urlpatterns += [ path("account/become/", become, name="become"), diff --git a/hypha/apply/users/utils.py b/hypha/apply/users/utils.py index e46c33ead6..6f544ea317 100644 --- a/hypha/apply/users/utils.py +++ b/hypha/apply/users/utils.py @@ -1,9 +1,12 @@ +import string + from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.tokens import PasswordResetTokenGenerator from django.core.mail import send_mail from django.template.loader import render_to_string from django.urls import reverse +from django.utils.crypto import get_random_string from django.utils.encoding import force_bytes from django.utils.http import url_has_allowed_host_and_scheme, urlsafe_base64_encode from django.utils.translation import gettext_lazy as _ @@ -53,7 +56,13 @@ def can_use_oauth_check(user): return False -def send_activation_email(user, site=None, redirect_url=""): +def send_activation_email( + user, + site=None, + email_template="users/activation/email.txt", + email_subject_template="users/activation/email_subject.txt", + redirect_url="", +): """ Send the activation email. The activation key is the username, signed using TimestampSigner. @@ -82,10 +91,10 @@ def send_activation_email(user, site=None, redirect_url=""): if site: context.update(site=site) - subject = "Account details for {username} at {org_long_name}".format(**context) + subject = render_to_string(email_subject_template, context) # Force subject to a single line to avoid header-injection issues. subject = "".join(subject.splitlines()) - message = render_to_string("users/activation/email.txt", context) + message = render_to_string(email_template, context) user.email_user(subject, message, settings.DEFAULT_FROM_EMAIL) @@ -157,3 +166,11 @@ def get_redirect_url( require_https=request.is_secure(), ) return redirect_to if url_is_safe else "" + + +def generate_numeric_token(length=6): + """ + Generate a random 6 digit string of numbers. + We use this formatting to allow leading 0s. + """ + return get_random_string(length, allowed_chars=string.digits) diff --git a/hypha/apply/users/views.py b/hypha/apply/users/views.py index 990f3279f8..9a9877c58b 100644 --- a/hypha/apply/users/views.py +++ b/hypha/apply/users/views.py @@ -1,4 +1,5 @@ import datetime +import time from typing import Any from urllib.parse import urlencode @@ -15,12 +16,13 @@ from django.contrib.auth.views import PasswordResetView as DjPasswordResetView from django.contrib.sites.shortcuts import get_current_site from django.core.exceptions import PermissionDenied -from django.core.signing import BadSignature, Signer, TimestampSigner, dumps, loads -from django.http import HttpResponseRedirect +from django.core.signing import TimestampSigner, dumps, loads +from django.http import HttpResponse, HttpResponseRedirect from django.shortcuts import Http404, get_object_or_404, redirect, render, resolve_url from django.template.loader import render_to_string from django.template.response import TemplateResponse from django.urls import reverse, reverse_lazy +from django.utils import timezone from django.utils.decorators import method_decorator from django.utils.encoding import force_str from django.utils.http import urlsafe_base64_decode @@ -30,9 +32,12 @@ from django.views.generic import UpdateView from django.views.generic.base import TemplateView, View from django.views.generic.edit import FormView +from django_htmx.http import HttpResponseClientRedirect from django_otp import devices_for_user from django_ratelimit.decorators import ratelimit from elevate.mixins import ElevateMixin +from elevate.utils import grant_elevated_privileges +from elevate.views import redirect_to_elevate from hijack.views import AcquireUserView from two_factor.forms import AuthenticationTokenForm, BackupTokenForm from two_factor.utils import default_device, get_otpauth_url, totp_digits @@ -45,17 +50,26 @@ from wagtail.users.views.users import change_user_perm from hypha.apply.home.models import ApplyHomePage +from hypha.core.mail import MarkdownMail from .decorators import require_oauth_whitelist from .forms import ( BecomeUserForm, CustomAuthenticationForm, CustomUserCreationForm, - EmailChangePasswordForm, + PasswordlessAuthForm, ProfileForm, TWOFAPasswordForm, ) -from .utils import get_redirect_url, send_confirmation_email +from .models import ConfirmAccessToken, PendingSignup +from .services import PasswordlessAuthService +from .tokens import PasswordlessLoginTokenGenerator, PasswordlessSignupTokenGenerator +from .utils import ( + generate_numeric_token, + get_redirect_url, + send_activation_email, + send_confirmation_email, +) User = get_user_model() @@ -72,11 +86,11 @@ def get(self, request): # We keep /register in the urls in order to test (where we turn on/off # the setting per test), but when disabled, we want to pretend it doesn't # exist va 404 - if not settings.ENABLE_REGISTRATION_WITHOUT_APPLICATION: + if not settings.ENABLE_PUBLIC_SIGNUP: raise Http404 if request.user.is_authenticated: - return redirect("dashboard:dashboard") + return redirect(settings.LOGIN_REDIRECT_URL) ctx = { "form": self.form(), @@ -86,7 +100,7 @@ def get(self, request): def post(self, request): # See comment in get() above about doing this here rather than in urls - if not settings.ENABLE_REGISTRATION_WITHOUT_APPLICATION: + if not settings.ENABLE_PUBLIC_SIGNUP: raise Http404 form = self.form(data=request.POST) @@ -136,6 +150,10 @@ class LoginView(TwoFactorLoginView): ("backup", BackupTokenForm), ) + redirect_field_name = "next" + redirect_authenticated_user = True + template_name = "users/login.html" + def get_context_data(self, form, **kwargs): context_data = super(LoginView, self).get_context_data(form, **kwargs) context_data["is_public_site"] = True @@ -158,28 +176,28 @@ class AccountView(UpdateView): def get_object(self): return self.request.user + def get_form_kwargs(self) -> dict[str, Any]: + kwargs = super().get_form_kwargs() + kwargs["request"] = self.request + return kwargs + def form_valid(self, form): updated_email = form.cleaned_data["email"] name = form.cleaned_data["full_name"] slack = form.cleaned_data.get("slack", "") user = get_object_or_404(User, id=self.request.user.id) - if updated_email: + if user.email != updated_email: base_url = reverse("users:email_change_confirm_password") query_dict = {"updated_email": updated_email, "name": name, "slack": slack} signer = TimestampSigner() signed_value = signer.sign(dumps(query_dict)) - # Using session variables for redirect validation - token_signer = Signer() - self.request.session["signed_token"] = token_signer.sign(user.email) return redirect( "{}?{}".format(base_url, urlencode({"value": signed_value})) ) - return super(AccountView, self).form_valid(form) + return super().form_valid(form) - def get_success_url( - self, - ): + def get_success_url(self): return reverse_lazy("users:account") def get_context_data(self, **kwargs): @@ -200,72 +218,54 @@ def get_context_data(self, **kwargs): ) -@method_decorator(login_required, name="dispatch") -class EmailChangePasswordView(FormView): - form_class = EmailChangePasswordForm - template_name = "users/email_change/confirm_password.html" - success_url = reverse_lazy("users:confirm_link_sent") - title = _("Enter Password") +@login_required +def account_email_change(request): + if request.user.has_usable_password() and not request.is_elevated(): + return redirect_to_elevate(request.get_full_path()) - def get_initial(self): - """ - Validating the redirection from account via session variable - """ - if "signed_token" not in self.request.session: - raise Http404 - signer = Signer() - try: - signer.unsign(self.request.session["signed_token"]) - except BadSignature as e: - raise Http404 from e - return super(EmailChangePasswordView, self).get_initial() + signer = TimestampSigner() + try: + unsigned_value = signer.unsign( + request.GET.get("value"), max_age=settings.PASSWORD_PAGE_TIMEOUT + ) + except Exception: + messages.error( + request, + _("Password Page timed out. Try changing the email again."), + ) + return redirect("users:account") + value = loads(unsigned_value) - def get_form_kwargs(self): - kwargs = super().get_form_kwargs() - kwargs["user"] = self.request.user - return kwargs + if slack := value["slack"] is not None: + request.user.slack = slack - def form_valid(self, form): - # Make sure redirection url is inaccessible after email is sent - if "signed_token" in self.request.session: - del self.request.session["signed_token"] - signer = TimestampSigner() - try: - unsigned_value = signer.unsign( - self.request.GET.get("value"), max_age=settings.PASSWORD_PAGE_TIMEOUT - ) - except Exception: - messages.error( - self.request, - _("Password Page timed out. Try changing the email again."), - ) - return redirect("users:account") - value = loads(unsigned_value) - form.save(**value) - user = self.request.user - if user.email != value["updated_email"]: - send_confirmation_email( - user, - signer.sign(dumps(value["updated_email"])), - updated_email=value["updated_email"], - site=Site.find_for_request(self.request), - ) - # alert email - user.email_user( - subject="Alert! An attempt to update your email.", - message=render_to_string( - "users/email_change/update_info_email.html", - { - "name": user.get_full_name(), - "username": user.get_username(), - "org_email": settings.ORG_EMAIL, - "org_short_name": settings.ORG_SHORT_NAME, - "org_long_name": settings.ORG_LONG_NAME, - }, - ), - from_email=settings.DEFAULT_FROM_EMAIL, + request.user.full_name = value["name"] + request.user.save() + + if request.user.email != value["updated_email"]: + send_confirmation_email( + request.user, + signer.sign(dumps(value["updated_email"])), + updated_email=value["updated_email"], + site=Site.find_for_request(request), ) - return super(EmailChangePasswordView, self).form_valid(form) + + # alert email + request.user.email_user( + subject="Alert! An attempt to update your email.", + message=render_to_string( + "users/email_change/update_info_email.html", + { + "name": request.user.get_full_name(), + "username": request.user.get_username(), + "org_email": settings.ORG_EMAIL, + "org_short_name": settings.ORG_SHORT_NAME, + "org_long_name": settings.ORG_LONG_NAME, + }, + ), + from_email=settings.DEFAULT_FROM_EMAIL, + ) + return redirect("users:confirm_link_sent") @method_decorator(login_required, name="dispatch") @@ -349,10 +349,7 @@ def get(self, request, *args, **kwargs): if self.valid(user, kwargs.get("token")): user.backend = settings.CUSTOM_AUTH_BACKEND login(request, user) - if ( - settings.WAGTAILUSERS_PASSWORD_ENABLED - and settings.ENABLE_REGISTRATION_WITHOUT_APPLICATION - ): + if settings.WAGTAILUSERS_PASSWORD_ENABLED and settings.ENABLE_PUBLIC_SIGNUP: # In this case, the user entered a password while registering, # and so they shouldn't need to activate a password return redirect("users:account") @@ -496,7 +493,7 @@ def get_context_data(self, form, **kwargs): name="dispatch", ) @method_decorator(login_required, name="dispatch") -class TWOFADisableView(TwoFactorDisableView): +class TWOFADisableView(ElevateMixin, TwoFactorDisableView): """ View for disabling two-factor for a user's account. """ @@ -550,8 +547,18 @@ def get_context_data(self, **kwargs): return ctx -class TWOFARequiredMessageView(TemplateView): - template_name = "two_factor/core/two_factor_required.html" +def mfa_failure_view( + request, reason, template_name="two_factor/core/two_factor_required.html" +): + """Renders a template asking the user to setup 2FA. + + Used by hypha.apply.users.middlewares.TwoFactorAuthenticationMiddleware, + if ENFORCE_TWO_FACTOR is enabled. + """ + ctx = { + "reason": reason, + } + return render(request, template_name, ctx) class BackupTokensView(ElevateMixin, TwoFactorBackupTokensView): @@ -611,3 +618,228 @@ def dispatch(self, *args, **kwargs): redirect_url = f"{redirect_url}?next={next_path}" return HttpResponseRedirect(redirect_url) + + +@method_decorator( + ratelimit(key="ip", rate=settings.DEFAULT_RATE_LIMIT, method="POST"), + name="dispatch", +) +@method_decorator( + ratelimit(key="post:email", rate=settings.DEFAULT_RATE_LIMIT, method="POST"), + name="dispatch", +) +class PasswordLessLoginSignupView(FormView): + """This view is used to collect the email address for passwordless login/signup. + + If the email address is already associated with an account, an email is sent. If not, + if the registration is enabled an email is sent, to allow the user to create an account. + + NOTE: This view should never expose whether an email address is associated with an account. + """ + + template_name = "users/passwordless_login_signup.html" + redirect_field_name = "next" + http_method_names = ["get", "post"] + form_class = PasswordlessAuthForm + + def get(self, request, *args, **kwargs): + if request.user.is_authenticated: + return redirect(settings.LOGIN_REDIRECT_URL) + + return super().get(request, *args, **kwargs) + + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: + ctx = super().get_context_data(**kwargs) + if self.request.htmx: + ctx["base_template"] = "includes/_partial-main.html" + else: + ctx["base_template"] = "base-apply.html" + ctx["redirect_url"] = get_redirect_url(self.request, self.redirect_field_name) + return ctx + + def post(self, request): + form = self.get_form() + if form.is_valid(): + service = PasswordlessAuthService( + request, redirect_field_name=self.redirect_field_name + ) + + email = form.cleaned_data["email"] + service.initiate_login_signup(email=email) + + return TemplateResponse( + self.request, + "users/partials/passwordless_login_signup_sent.html", + self.get_context_data(), + ) + else: + return self.render_to_response(self.get_context_data(form=form)) + + +class PasswordlessLoginView(LoginView): + """This view is used to capture the passwordless login token and log the user in. + + If the token is valid, the user is logged in and redirected to the dashboard. + If the token is invalid, the user is shown invalid token page. + + This view inherits from LoginView to reuse the 2FA views, if a mfa device is added + to the user. + """ + + def get(self, request, uidb64, token, *args, **kwargs): + try: + user = User.objects.get(pk=force_str(urlsafe_base64_decode(uidb64))) + except (TypeError, ValueError, OverflowError, User.DoesNotExist): + user = None + + if user and self.check_token(user, token): + user.backend = settings.CUSTOM_AUTH_BACKEND + + if default_device(user): + # User has mfa, set the user details and redirect to 2fa login + self.storage.reset() + self.storage.authenticated_user = user + self.storage.data["authentication_time"] = int(time.time()) + return self.render_goto_step("token") + + # No mfa, log the user in + login(request, user) + + if redirect_url := get_redirect_url(request, self.redirect_field_name): + return redirect(redirect_url) + + return redirect("dashboard:dashboard") + + return render(request, "users/activation/invalid.html") + + def check_token(self, user, token): + token_generator = PasswordlessLoginTokenGenerator() + return token_generator.check_token(user, token) + + +class PasswordlessSignupView(TemplateView): + """This view is used to capture the passwordless login token and log the user in. + + If the token is valid, the user is logged in and redirected to the dashboard. + If the token is invalid, the user is shown invalid token page. + """ + + redirect_field_name = "next" + + def get(self, request, *args, **kwargs): + pending_signup = self.get_pending_signup(kwargs.get("uidb64")) + token = kwargs.get("token") + token_generator = PasswordlessSignupTokenGenerator() + + if pending_signup and token_generator.check_token(pending_signup, token): + user = User.objects.create(email=pending_signup.email, is_active=True) + user.set_unusable_password() + user.save() + pending_signup.delete() + + user.backend = settings.CUSTOM_AUTH_BACKEND + login(request, user) + + redirect_url = get_redirect_url(request, self.redirect_field_name) + + if redirect_url: + return redirect(redirect_url) + + # If 2FA is enabled, redirect to setup page instead of dashboard + if settings.ENFORCE_TWO_FACTOR: + redirect_url = redirect_url or reverse("dashboard:dashboard") + return redirect(reverse("two_factor:setup") + f"?next={redirect_url}") + + return redirect("dashboard:dashboard") + + return render(request, "users/activation/invalid.html") + + def get_pending_signup(self, uidb64): + """ + Given the verified uid, look up and return the corresponding user + account if it exists, or `None` if it doesn't. + """ + try: + return PendingSignup.objects.get( + **{"pk": force_str(urlsafe_base64_decode(uidb64))} + ) + except (TypeError, ValueError, OverflowError, PendingSignup.DoesNotExist): + return None + + +@login_required +def send_confirm_access_email_view(request): + """Sends email with link to login in an elevated mode.""" + token_obj, _ = ConfirmAccessToken.objects.update_or_create( + user=request.user, token=generate_numeric_token + ) + email_context = { + "org_long_name": settings.ORG_LONG_NAME, + "org_email": settings.ORG_EMAIL, + "org_short_name": settings.ORG_SHORT_NAME, + "token": token_obj.token, + "username": request.user.email, + "site": Site.find_for_request(request), + "user": request.user, + "timeout_minutes": settings.PASSWORDLESS_LOGIN_TIMEOUT // 60, + } + subject = "Confirmation code for {org_long_name}: {token}".format(**email_context) + email = MarkdownMail("users/emails/confirm_access.md") + email.send( + to=request.user.email, + subject=subject, + from_email=settings.DEFAULT_FROM_EMAIL, + context=email_context, + ) + return render( + request, + "users/partials/confirmation_code_sent.html", + {"redirect_url": get_redirect_url(request, "next")}, + ) + + +@never_cache +@login_required +@ratelimit(key="user", rate=settings.DEFAULT_RATE_LIMIT) +def elevate_check_code_view(request): + """Checks if the code is correct and if so, elevates the user session.""" + token = request.POST.get("code") + + def validate_token_and_age(token): + try: + token_obj = ConfirmAccessToken.objects.get(user=request.user, token=token) + token_age_in_seconds = (timezone.now() - token_obj.modified).total_seconds() + if token_age_in_seconds <= settings.PASSWORDLESS_LOGIN_TIMEOUT: + token_obj.delete() + return True + except ConfirmAccessToken.DoesNotExist: + return False + + redirect_url = get_redirect_url(request, "next") + if token and validate_token_and_age(token): + grant_elevated_privileges(request) + return HttpResponseClientRedirect(redirect_url) + + return render( + request, + "users/partials/confirmation_code_sent.html", + {"error": True, "redirect_url": redirect_url}, + ) + + +@login_required +def set_password_view(request): + """Sends email with link to set password to user that doesn't have usable password. + + This will the case when the user signed up using passwordless signup or using oauth. + """ + site = Site.find_for_request(request) + + if not request.user.has_usable_password(): + send_activation_email( + user=request.user, + site=site, + email_template="users/emails/set_password.txt", + email_subject_template="users/emails/set_password_subject.txt", + ) + return HttpResponse("✓ Check your email for password set link.") diff --git a/hypha/core/context_processors.py b/hypha/core/context_processors.py index 88467023a0..d3a1cccf7b 100644 --- a/hypha/core/context_processors.py +++ b/hypha/core/context_processors.py @@ -12,7 +12,7 @@ def global_vars(request): "ORG_GUIDE_URL": settings.ORG_GUIDE_URL, "ORG_URL": settings.ORG_URL, "GOOGLE_OAUTH2": settings.SOCIAL_AUTH_GOOGLE_OAUTH2_KEY, - "ENABLE_REGISTRATION_WITHOUT_APPLICATION": settings.ENABLE_REGISTRATION_WITHOUT_APPLICATION, + "ENABLE_PUBLIC_SIGNUP": settings.ENABLE_PUBLIC_SIGNUP, "ENABLE_GOOGLE_TRANSLATE": settings.ENABLE_GOOGLE_TRANSLATE, "SENTRY_TRACES_SAMPLE_RATE": settings.SENTRY_TRACES_SAMPLE_RATE, "SENTRY_ENVIRONMENT": settings.SENTRY_ENVIRONMENT, diff --git a/hypha/core/utils.py b/hypha/core/utils.py index 0b3ac7295d..a07009f6ef 100644 --- a/hypha/core/utils.py +++ b/hypha/core/utils.py @@ -19,6 +19,6 @@ def markdown_to_html(text: str) -> str: escape=False, hard_wrap=True, renderer="html", - plugins=["strikethrough", "footnotes", "table"], + plugins=["strikethrough", "footnotes", "table", "url"], ) return md(text) diff --git a/hypha/public/home/templates/home/home_page.html b/hypha/public/home/templates/home/home_page.html index a6ee50260e..2f93125a3e 100644 --- a/hypha/public/home/templates/home/home_page.html +++ b/hypha/public/home/templates/home/home_page.html @@ -73,7 +73,7 @@
{% include "utils/includes/login_button.html" %} - {% if ENABLE_REGISTRATION_WITHOUT_APPLICATION %} + {% if ENABLE_PUBLIC_SIGNUP %} {% include "utils/includes/register_button.html" %} {% endif %}
diff --git a/hypha/public/navigation/templates/navigation/primarynav-apply.html b/hypha/public/navigation/templates/navigation/primarynav-apply.html index 0980776acb..c8b22b352f 100644 --- a/hypha/public/navigation/templates/navigation/primarynav-apply.html +++ b/hypha/public/navigation/templates/navigation/primarynav-apply.html @@ -1,15 +1,14 @@ {% if request.user.is_authenticated %} diff --git a/hypha/public/utils/templates/utils/includes/login_button.html b/hypha/public/utils/templates/utils/includes/login_button.html index 37fa3b1654..328eff33ba 100644 --- a/hypha/public/utils/templates/utils/includes/login_button.html +++ b/hypha/public/utils/templates/utils/includes/login_button.html @@ -1,9 +1,30 @@ {% load i18n %} - - - {% if user.is_authenticated %} - My {{ ORG_SHORT_NAME }} + +{% if user.is_authenticated %} + {% if user.can_access_dashboard %} + + + My {{ ORG_SHORT_NAME }} + {% else %} - {% trans "Login" %} + + + {{ user }} + {% endif %} - +{% else %} + + + {% trans "Login" %} + +{% endif %} diff --git a/hypha/public/utils/templates/utils/includes/register_button.html b/hypha/public/utils/templates/utils/includes/register_button.html index 29e10dd26a..648fb72b04 100644 --- a/hypha/public/utils/templates/utils/includes/register_button.html +++ b/hypha/public/utils/templates/utils/includes/register_button.html @@ -1,4 +1,4 @@ {% load i18n %} - {% trans "Register" %} + {% trans "Sign up" %} diff --git a/hypha/settings/base.py b/hypha/settings/base.py index 818cc03457..73df7a87df 100644 --- a/hypha/settings/base.py +++ b/hypha/settings/base.py @@ -136,14 +136,6 @@ # Possible values are: False, 1,2,3,… TRANSITION_AFTER_REVIEWS = env.bool("TRANSITION_AFTER_REVIEWS", False) -# Forces users to log in first in order to make an application. This is particularly useful in conjunction -# with ENABLE_REGISTRATION_WITHOUT_APPLICATION -FORCE_LOGIN_FOR_APPLICATION = env.bool("FORCE_LOGIN_FOR_APPLICATION", False) - -# Enable users to create accounts without submitting an application. -ENABLE_REGISTRATION_WITHOUT_APPLICATION = env.bool( - "ENABLE_REGISTRATION_WITHOUT_APPLICATION", False -) # Project settings. @@ -172,11 +164,24 @@ # Number of seconds that password reset and account activation links are valid (default 259200, 3 days). PASSWORD_RESET_TIMEOUT = env.int("PASSWORD_RESET_TIMEOUT", 259200) +# Timeout for passwordless login links (default 900, 15 minutes). +PASSWORDLESS_LOGIN_TIMEOUT = env.int("PASSWORDLESS_LOGIN_TIMEOUT", 900) # 15 minutes + +# Enable users to create accounts without submitting an application. +ENABLE_PUBLIC_SIGNUP = env.bool("ENABLE_PUBLIC_SIGNUP", True) + +# Forces users to log in first in order to make an application. This is particularly useful in conjunction +# with ENABLE_PUBLIC_SIGNUP +# @deprecated: This setting is deprecated and will be removed in a future release. +FORCE_LOGIN_FOR_APPLICATION = env.bool("FORCE_LOGIN_FOR_APPLICATION", True) + +# Timeout for passwordless signup links (default 900, 15 minutes). +PASSWORDLESS_SIGNUP_TIMEOUT = env.int("PASSWORDLESS_SIGNUP_TIMEOUT", 900) # 15 minutes + # Seconds to enter password on password page while email change/2FA change (default 120). PASSWORD_PAGE_TIMEOUT = env.int("PASSWORD_PAGE_TIMEOUT", 120) # Template engines and options to be used with Django. - TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", @@ -280,7 +285,7 @@ # Wagtail settings WAGTAIL_CACHE_TIMEOUT = CACHE_CONTROL_MAX_AGE -WAGTAIL_FRONTEND_LOGIN_URL = "/login/" +WAGTAIL_FRONTEND_LOGIN_URL = "/auth/" WAGTAIL_SITE_NAME = "hypha" WAGTAILIMAGES_IMAGE_MODEL = "images.CustomImage" WAGTAILIMAGES_FEATURE_DETECTION_ENABLED = False diff --git a/hypha/settings/django.py b/hypha/settings/django.py index 7d9ca32d62..9418a797e9 100644 --- a/hypha/settings/django.py +++ b/hypha/settings/django.py @@ -203,7 +203,7 @@ AUTH_USER_MODEL = "users.User" -LOGIN_URL = "users_public:login" +LOGIN_URL = "users_public:passwordless_login_signup" LOGIN_REDIRECT_URL = "dashboard:dashboard" # https://django-elevate.readthedocs.io/en/latest/config/index.html#configuration diff --git a/hypha/settings/test.py b/hypha/settings/test.py index 3b1e3bf01b..51ab2af72b 100644 --- a/hypha/settings/test.py +++ b/hypha/settings/test.py @@ -30,3 +30,5 @@ # An extra salt to be added into the cookie signature. ELEVATE_COOKIE_SALT = SECRET_KEY + +ENFORCE_TWO_FACTOR = False diff --git a/hypha/static_src/src/sass/apply/abstracts/_mixins.scss b/hypha/static_src/src/sass/apply/abstracts/_mixins.scss index 36660810b2..4a7e42257d 100644 --- a/hypha/static_src/src/sass/apply/abstracts/_mixins.scss +++ b/hypha/static_src/src/sass/apply/abstracts/_mixins.scss @@ -85,7 +85,7 @@ // Button mixin @mixin button($bg, $hover-bg) { - padding: 0.5em 50px; + padding: 0.5em 2rem; font-weight: $weight--bold; color: $color--white; text-align: center; diff --git a/hypha/static_src/src/sass/apply/components/_form.scss b/hypha/static_src/src/sass/apply/components/_form.scss index 6dd2417475..0305fa36b3 100644 --- a/hypha/static_src/src/sass/apply/components/_form.scss +++ b/hypha/static_src/src/sass/apply/components/_form.scss @@ -32,6 +32,18 @@ } } + &--error-inline { + // stylelint-disable-next-line selector-class-pattern + .form__error-text { + position: relative; + max-width: 100%; + + &::before { + display: none; + } + } + } + &__group { position: relative; margin-top: 0.5rem; diff --git a/hypha/static_src/src/sass/apply/components/_two-factor.scss b/hypha/static_src/src/sass/apply/components/_two-factor.scss index 410b24f6b7..c408502e6b 100644 --- a/hypha/static_src/src/sass/apply/components/_two-factor.scss +++ b/hypha/static_src/src/sass/apply/components/_two-factor.scss @@ -24,24 +24,6 @@ label[for="id_generator-token"] { font-size: 1.2em; } -#list-backup-tokens { - border: $color--mid-grey; - padding: 1em; - line-height: 1.4em; - font-size: larger; - font-family: monospace; - resize: none; - font-style: bold; -} - -.d-none { - display: none; -} - -.bg-white { - background-color: $color--white; -} - // 2FA token field. #id_generator-token { -moz-appearance: textfield; diff --git a/hypha/templates/base-apply.html b/hypha/templates/base-apply.html index 4982053b5a..233a893e95 100644 --- a/hypha/templates/base-apply.html +++ b/hypha/templates/base-apply.html @@ -167,8 +167,10 @@ {% trans "Log out" %} {% else %} - {% include "utils/includes/login_button.html" %} - {% if ENABLE_REGISTRATION_WITHOUT_APPLICATION %} + {% if request.path != '/auth/' %} + {% include "utils/includes/login_button.html" %} + {% endif %} + {% if ENABLE_PUBLIC_SIGNUP and request.path != '/register/' %} {% include "utils/includes/register_button.html" %} {% endif %} {% endif %} diff --git a/hypha/templates/base.html b/hypha/templates/base.html index 382565e0d3..eec53c3b83 100644 --- a/hypha/templates/base.html +++ b/hypha/templates/base.html @@ -1,4 +1,4 @@ -{% load static cache wagtailcore_tags wagtailimages_tags navigation_tags util_tags cookieconsent_tags %} +{% load static cache wagtailcore_tags wagtailimages_tags navigation_tags util_tags cookieconsent_tags i18n %} {% wagtail_site as current_site %} @@ -155,9 +155,13 @@
{% include "utils/includes/login_button.html" %} - {% if not request.user.is_authenticated and ENABLE_REGISTRATION_WITHOUT_APPLICATION %} - {% include "utils/includes/register_button.html" %} + + {% if request.user.is_authenticated %} + + {% trans "Log out" %} + {% endif %} + {% if ENABLE_GOOGLE_TRANSLATE %}
{% endif %} diff --git a/requirements-dev.txt b/requirements-dev.txt index 162eec0ecc..43b82fdcf4 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -5,6 +5,7 @@ coverage==7.3.2 django-browser-reload==1.12.0 django-coverage-plugin==3.1.0 django-debug-toolbar==4.2.0 +django-dynamic-fixture==4.0.1 djhtml==3.0.6 dslr==0.4.0 factory_boy==3.2.1 @@ -17,5 +18,6 @@ pytest-split==0.8.1 pytest-xdist[psutil]==3.3.1 responses==0.23.3 ruff==0.1.1 +time-machine==2.13.0 wagtail-factories==2.1.0 Werkzeug==3.0.1 diff --git a/requirements.txt b/requirements.txt index c2357250a8..0ca6b57e96 100644 --- a/requirements.txt +++ b/requirements.txt @@ -46,7 +46,7 @@ python-docx<1.0.0 htmldocx==0.0.6 lark==1.1.7 mailchimp3==3.0.17 -mistune==2.0.4 +mistune==3.0.1 more-itertools==9.0.0 phonenumberslite==8.13.23 Pillow>=10.0.1