From cc56bdfe44e45c203f71c7d21081a52baeb56d96 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar <theskumar@users.noreply.github.com> 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  ## Updated Login Page with Registration Enabled  ## 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.  Login email copy  ## Signup New Account Email copy  ### 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.  ## Updated "Sudo" mode page ### For account with password  After clicking on the "Send a confirmation code to your email" link   ### For account without password  ## Updated disable 2FA page It requires "Sudo" mode, instead of password now.  --- .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 06d6178f3..839b1bed5 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 93f93d8aa..0e13424a6 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 6e7f771a2..41823412a 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 ae0a1f8a5..5af8a58e6 100644 --- a/hypha/apply/funds/models/submissions.py +++ b/hypha/apply/funds/models/submissions.py @@ -547,11 +547,11 @@ class ApplicationSubmission( 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 @@ class ApplicationSubmission( 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 @@ class ApplicationSubmission( 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 f7267b5c2..dd52fbe33 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 @@ class WorkflowStreamForm(WorkflowHelpers, AbstractStreamForm): # type: ignore 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 b13d2c423..000000000 --- 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 0ed5e7f0e..000000000 --- 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 109789940..b03262599 100644 --- a/hypha/apply/funds/templates/funds/application_base_landing.html +++ b/hypha/apply/funds/templates/funds/submission-success.html @@ -29,25 +29,46 @@ </p> {% with email_context=page.specific %} - <p>{{ email_context.confirmation_text_extra|urlize }}</p> + {% if email_context.confirmation_text_extra %} + <p data-testid="db-confirmation-text-extra">{{ email_context.confirmation_text_extra|urlize }}</p> + {% endif %} {% endwith %} - {% block extra_text %} - <div class="prose"> + {% if form_submission.round and settings.funds.ApplicationSettings.extra_text_round %} + <div class="prose" data-testid="db-extra-text"> {{ settings.funds.ApplicationSettings.extra_text_round|richtext }} </div> - {% endblock %} + {% elif settings.funds.ApplicationSettings.extra_text_lab %} + <div class="prose" data-testid="db-extra-text"> + {{ settings.funds.ApplicationSettings.extra_text_lab|richtext }} + </div> + {% endif %} {% endif %} - <div class="mt-4"> - {% if request.user.is_authenticated %} + <div class="mt-4 space-x-2"> + {% if request.user.is_authenticated and request.user.can_access_dashboard%} <a class="button button--primary" href="{% url 'dashboard:dashboard' %}" > {% trans "Go to your dashboard" %} </a> + {% if form_submission.status == 'draft' %} + <a + class="button button--secondary" + href="{% url 'apply:submissions:edit' form_submission.id %}" + > + {% trans "Continue editing" %} + </a> + {% else %} + <a + class="button button--secondary" + href="{% url 'apply:submissions:detail' form_submission.id %}" + > + {% trans "View your submission" %} + </a> + {% endif %} {% else %} <a class="button button--primary" diff --git a/hypha/apply/funds/tests/test_models.py b/hypha/apply/funds/tests/test_models.py index 6259dc7af..530217bc4 100644 --- a/hypha/apply/funds/tests/test_models.py +++ b/hypha/apply/funds/tests/test_models.py @@ -243,8 +243,10 @@ class TestFormSubmission(TestCase): response = page.serve(request) if not ignore_errors: - # Check the data we submit is correct - self.assertNotContains(response, "errors") + # check it is redirected + self.assertEqual(response.status_code, 302) + # check "success" is present in the redirect url location + self.assertIn("success", response.url) return response def test_workflow_and_draft(self): @@ -345,7 +347,7 @@ class TestFormSubmission(TestCase): self.assertEqual(self.User.objects.count(), 2) response = self.submit_form(email="", name="", user=user, ignore_errors=True) - self.assertEqual(response.status_code, 200) + assert response.status_code == 302 and "success" in response.url # Lead + applicant self.assertEqual(self.User.objects.count(), 2) diff --git a/hypha/apply/funds/tests/test_views.py b/hypha/apply/funds/tests/test_views.py index 054c9df11..4cc8d6345 100644 --- a/hypha/apply/funds/tests/test_views.py +++ b/hypha/apply/funds/tests/test_views.py @@ -2,6 +2,7 @@ import re from datetime import timedelta from bs4 import BeautifulSoup +from django.conf import settings from django.contrib.auth.models import AnonymousUser from django.core.exceptions import PermissionDenied from django.http import Http404 @@ -1644,7 +1645,7 @@ class TestAnonSubmissionFileView(BaseSubmissionFileViewTestCase): 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 BaseProjectDeleteTestCase(BaseViewTestCase): diff --git a/hypha/apply/funds/urls.py b/hypha/apply/funds/urls.py index 87809e3ec..6b11739e0 100644 --- a/hypha/apply/funds/urls.py +++ b/hypha/apply/funds/urls.py @@ -27,6 +27,7 @@ from .views import ( SubmissionSealedView, SubmissionStaffFlaggedView, SubmissionUserFlaggedView, + submission_success, ) from .views_beta import ( bulk_archive_submissions, @@ -74,6 +75,7 @@ app_name = "funds" submission_urls = ( [ path("", SubmissionOverviewView.as_view(), name="overview"), + path("success/<int:pk>/", 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 b942613db..5725d425c 100644 --- a/hypha/apply/funds/views.py +++ b/hypha/apply/funds/views.py @@ -13,7 +13,7 @@ from django.contrib.humanize.templatetags.humanize import intcomma 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 @@ from .workflow import ( 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 b006647fa..58b7b1175 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 688f9d211..480e76707 100644 --- a/hypha/apply/projects/tests/test_views.py +++ b/hypha/apply/projects/tests/test_views.py @@ -2,6 +2,7 @@ import json 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 @@ class TestAnonPacketView(BasePacketFileViewTestCase): 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 b20e5875d..27c0f0cf4 100644 --- a/hypha/apply/stream_forms/models.py +++ b/hypha/apply/stream_forms/models.py @@ -74,9 +74,15 @@ class BaseStreamForm: "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 82ec85974..35fe9a8e3 100644 --- a/hypha/apply/urls.py +++ b/hypha/apply/urls.py @@ -21,9 +21,7 @@ urlpatterns = [ # 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 13dcd722e..4808e19f7 100644 --- a/hypha/apply/users/forms.py +++ b/hypha/apply/users/forms.py @@ -23,6 +23,33 @@ class CustomAuthenticationForm(AuthenticationForm): ) +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 ProfileForm(forms.ModelForm): 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 @@ class EmailChangePasswordForm(forms.Form): 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 fd11afec3..d38207b9d 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 000000000..a40e95239 --- /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 000000000..20b34b7c3 --- /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 44ee07b51..fbaab70c3 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 @@ from .groups import ( 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 @@ class UserManager(BaseUserManager.from_queryset(UserQuerySet)): 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 @@ class User(AbstractUser): 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 AuthSettings(BaseGenericSetting): _("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 000000000..8762aa00c --- /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 9a5504695..1a5dbd29c 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 %} - <div class="max-w-lg px-4 pt-4 mx-auto md:mt-5 md:py-4"> + <div class="max-w-md px-4 pt-4 mx-auto md:mt-5 md:py-4"> - <form class="form" method="post" action="./" class="px-4 pt-4"> - {% csrf_token %} - <h2 class="text-2xl">{% trans "Confirm access" %}</h2> + <h2 class="text-2xl text-center">{% trans "Confirm access" %}</h2> - <p class="px-3 py-2 bg-orange-100 rounded mb-4"> - Signed in as <strong>{{ request.user }} ({{ request.user.email }})</strong> - </p> + <p class="text-center mb-4"> + Signed in as <strong>{% if request.user.full_name %} {{ request.user.full_name }} ({{ request.user.email }}) {% else %}{{ request.user.email }} {% endif %}</strong> + </p> + + <section id="section-form"> + + {% if request.user.has_usable_password %} + <form + class="form form--error-inline mb-4 px-4 pt-4 border rounded-sm bg-gray-50" + method="post" + action="./" + data-test-id="section-password-input" + id="form-password-input" + > + {% for field in form %} + {% include "forms/includes/field.html" %} + {% endfor %} + + <div class="form__group"> + <button class="button button--primary" type="submit">{% trans "Confirm" %}</button> + </div> + </form> + {% else %} + <section data-test-id="section-confirm" id="confirm-code-input" class="mb-4 px-4 pt-4 text-center"> + + <button + class="button button--primary" + type="submit" + hx-post="{% url 'users:elevate_send_confirm_access_email' %}{% if request.GET.next %}?next={{request.GET.next}}{% endif %}" + hx-swap="outerHTML" + hx-target="#confirm-code-input" + > + {% trans "Send a confirmation code to your email" %} + </button> + </section> + {% endif %} - {% if form.non_field_errors %} - <div class="wrapper wrapper--error">{{ form.non_field_errors.as_text }}</div> + {% if request.user.has_usable_password %} + <section data-test-id="section-send-email" class="px-4 border pt-2 pb-4"> + <p>{% trans "Having problems?" %}</p> + <ul class="list-disc ml-4"> + <li> + <a + class="m-0" + type="submit" + hx-post="{% url 'users:elevate_send_confirm_access_email' %}{% if request.GET.next %}?next={{request.GET.next}}{% endif %}" + hx-target="#section-form" + > + {% trans "Send a confirmation code to your email" %} + </a> + </li> + </ul> + </section> {% endif %} - {% for field in form %} - {% include "forms/includes/field.html" %} - {% endfor %} + </section> - <div class="form__group"> - <button class="button button--primary" type="submit">{% trans "Confirm" %}</button> - </div> - </form> - <p class="text-xs text-center max-w-sm mt-8 text-gray-500 mx-auto"> + <p class="text-xs text-center max-w-xs mt-8 text-gray-500 mx-auto leading-relaxed"> {% blocktrans %} <strong>Tip:</strong> 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 2a63a6db9..d53a7af9f 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 %} - <a href="{% url 'dashboard:dashboard' %}" class="button button--primary button--arrow-pixels-white" hx-boost='true'> - {% trans "Go to my dashboard" %} - <svg><use xlink:href="#arrow-head-pixels--solid"></use></svg> - </a> + {% if user.can_access_dashboard %} + <a href="{% url 'dashboard:dashboard' %}" class="button button--primary button--arrow-pixels-white" hx-boost='true'> + {% trans "Go to my dashboard" %} + <svg><use xlink:href="#arrow-head-pixels--solid"></use></svg> + </a> + {% endif %} {% endadminbar %} <div class="wrapper wrapper--inner-space-medium max-w-2xl two-factor"> diff --git a/hypha/apply/users/templates/two_factor/_wizard_actions.html b/hypha/apply/users/templates/two_factor/_wizard_actions.html index eaff59260..4931f3587 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 %} -<button type="submit" class="button button--primary">{{ button_text }}</button> +<button + type="submit" + class="button button--primary mb-4" +> + {{ button_text }} +</button> <script> - var lbl = document.querySelector("label[for=id_generator-token]"); + const lbl = document.querySelector("label[for=id_generator-token]"); + const otpInput = document.querySelector("#id_token-otp_token"); if (lbl) { lbl.textContent = "{% trans "Verification code" %}:"; } + if(otpInput){ + // set max-width to 6 characters + otpInput.style.maxWidth = "10ch"; + } </script> 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 297cd8503..ab0108d20 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 @@ 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 %}</textarea> <form method="post" class="actions actions-footer">{% csrf_token %}{{ form }} @@ -34,11 +34,14 @@ <p class="hide-print">{% blocktrans %}Once done, acknowledge you have stored the codes securely and then click "Finish".{% endblocktrans %}</p> <div class="form"> <ul class="errorlist hidden error-action-agree"><li>Please confirm you have stored the codes securely below.</li></ul> - <div class="form__group form__group--checkbox"> + <div class="form__item mb-4"> <input type="checkbox" id="action_agree" name="action_agree" value="action_agree"> - <label for="action_agree"> I have stored the backup codes securely.</label><br><br> + <label for="action_agree"> I have stored the backup codes securely.</label> </div> - <a class="btn btn-link btn-finish" href="{% url 'users:account' %}">{% trans "Finish" %}</a> + <div class="form__item"> + <a class="btn btn-link btn-finish" href="{% url 'users:account' %}">{% trans "Finish" %}</a> + </div> + </div> </form> {% else %} @@ -87,18 +90,21 @@ {# Instantiate clipboard by passing a HTML element, uses clipboard.js #} var clipboardBtn = document.querySelector('.btn-copy-to-clipboard'); - var clipboard = new ClipboardJS(clipboardBtn); - var tooltip = tippy(clipboardBtn, { - trigger: 'manual', - animation: 'fade' - }); + if (clipboardBtn) { + var clipboard = new ClipboardJS(clipboardBtn); + var tooltip = tippy(clipboardBtn, { + trigger: 'manual', + animation: 'fade' + }); + + clipboard.on('success', function (e) { + tooltip.show(); + }); + clipboard.on('error', function (e) { + tooltip.setContent("Use ctrl/cmd + C to copy the backup codes.") + tooltip.show(); + }); + } - clipboard.on('success', function (e) { - tooltip.show(); - }); - clipboard.on('error', function (e) { - tooltip.setContent("Use ctrl/cmd + C to copy the backup codes.") - tooltip.show(); - }); </script> {% endblock %} diff --git a/hypha/apply/users/templates/two_factor/core/setup.html b/hypha/apply/users/templates/two_factor/core/setup.html index 8d9066ab8..6cadae9ca 100644 --- a/hypha/apply/users/templates/two_factor/core/setup.html +++ b/hypha/apply/users/templates/two_factor/core/setup.html @@ -52,7 +52,7 @@ {% include "two_factor/_wizard_forms.html" %} {# hidden submit button to enable [enter] key #} - <input type="submit" value="" class="d-none" /> + <input type="submit" value="" class="hidden" /> {% include "two_factor/_wizard_actions.html" %} </form> diff --git a/hypha/apply/users/templates/two_factor/core/setup_complete.html b/hypha/apply/users/templates/two_factor/core/setup_complete.html index 973817d57..627f4a458 100644 --- a/hypha/apply/users/templates/two_factor/core/setup_complete.html +++ b/hypha/apply/users/templates/two_factor/core/setup_complete.html @@ -13,13 +13,13 @@ To get the backup codes you can continue to Show Codes.{% endblocktrans %}</p> {% if not phone_methods %} - <a href="{% url 'users:backup_tokens_password' %}" class="btn btn-link">{% trans "Show Codes" %}</a> + <a href="{% url 'users:backup_tokens' %}" class="btn btn-link">{% trans "Show Codes" %}</a> {% else %} <p>{% blocktrans trimmed %}However, it might happen that you don't have access to your primary token device. To enable account recovery, add a phone number.{% endblocktrans %}</p> - <p><a href="{% url 'users:backup_tokens_password' %}" class="btn btn-block">{% trans "Show Codes" %}</a></p> + <p><a href="{% url 'users:backup_tokens' %}" class="btn btn-block">{% trans "Show Codes" %}</a></p> <p><a href="{% url 'two_factor:phone_create' %}" class="btn btn-success">{% trans "Add Phone Number" %}</a></p> {% endif %} diff --git a/hypha/apply/users/templates/two_factor/core/two_factor_required.html b/hypha/apply/users/templates/two_factor/core/two_factor_required.html index ad0f7254c..d5cf73ae1 100644 --- a/hypha/apply/users/templates/two_factor/core/two_factor_required.html +++ b/hypha/apply/users/templates/two_factor/core/two_factor_required.html @@ -1,22 +1,38 @@ <!--Custom template to enforce 2FA and Copied from two_factor/core/otp_required.html--> - {% extends "two_factor/_base_focus.html" %} {% load i18n %} {% block content %} - <h1>{% block title %}{% trans "Permission Denied" %}{% endblock %}</h1> + <h1>{% block title %}{% trans "Permission Denied" %}: {{ reason }}{% endblock %}</h1> + + <div class="prose mb-4"> + <p> + {% blocktrans trimmed %} + The page you are trying to access requires users to verify their + identity using two-factor authentication for security reasons. + {% endblocktrans %} + </p> + + <p> + {% blocktrans trimmed %} + In order to access this page, you need to set up these security + features. Without setting them up, you will only be able to access + your account's profile section or log out from the system. + {% endblocktrans %} + </p> + + <p> + {% blocktrans trimmed %} + Two-factor authentication has not been set up + for your account yet. Please set it up to enhance + the security of your account. + {% endblocktrans %} + </p> + </div> - <p>{% blocktrans trimmed %}The page you requested, enforces users to verify using - two-factor authentication for security reasons. You need to set up these - security features in order to access this page. Without setting up these security features, - You can only access the account(Profile section) or can logout from the system.{% endblocktrans %}</p> - <p>{% blocktrans trimmed %}Two-factor authentication is not already set up for your - account. Set up two-factor authentication for enhanced account - security.{% endblocktrans %}</p> + <a href="{% url 'two_factor:setup' %}?next={{request.path}}" class="btn btn-primary"> + {% trans "Set up Two-Factor Authentication (2FA)" %} + </a> - <p> - <a href="{% url 'two_factor:setup' %}" class="btn btn-primary"> - {% trans "Set up Two-Factor Authentication (2FA)" %}</a> - </p> {% endblock %} diff --git a/hypha/apply/users/templates/two_factor/profile/disable.html b/hypha/apply/users/templates/two_factor/profile/disable.html index 683c8c303..b4dc0b43c 100644 --- a/hypha/apply/users/templates/two_factor/profile/disable.html +++ b/hypha/apply/users/templates/two_factor/profile/disable.html @@ -1,39 +1,40 @@ + {% extends "two_factor/_base_focus.html" %} {% load i18n %} {% block content %} - <p><a href="{% url 'users:account'%}" - class="btn btn-link">{% trans "Back to account" %}</a></p> <h1>{% block title %}{% trans "Disable Two-factor Authentication" %}{% endblock %}</h1> - <p>{% blocktrans trimmed %}Disabling Two-factor authentication weakens your account security. + <p class="mb-4">{% blocktrans trimmed %}Disabling Two-factor authentication weakens your account security. We recommend reenabling it when you can.{% endblocktrans %}</p> - <div class="wrapper wrapper--small wrapper--inner-space-medium"> - <form class="form" action="" method="POST" novalidate> - {% if form.non_field_errors %} - <ul class="errorlist"> - {% for error in form.non_field_errors %} - <li>{{ error }}</li> - {% endfor %} - </ul> - {% endif %} - - {% if form.errors %} - <ul class="errorlist"> - {% blocktrans trimmed count counter=form.errors.items|length %} - <li>Please correct the error below.</li> - {% plural %} - <li>Please correct the errors below.</li> - {% endblocktrans %} - </ul> - {% endif %} - - {% csrf_token %} - - {% for field in form %} - {% include "forms/includes/field.html" %} - {% endfor %} - - <button class="btn btn-danger" type="submit">{% trans 'Disable Two-factor Authentication' %}</button> - </form> - </div> + + <form class="form" action="" method="POST" novalidate> + {% if form.non_field_errors %} + <ul class="errorlist"> + {% for error in form.non_field_errors %} + <li>{{ error }}</li> + {% endfor %} + </ul> + {% endif %} + + {% if form.errors %} + <ul class="errorlist"> + {% blocktrans trimmed count counter=form.errors.items|length %} + <li>Please correct the error below.</li> + {% plural %} + <li>Please correct the errors below.</li> + {% endblocktrans %} + </ul> + {% endif %} + + {% csrf_token %} + + {% for field in form %} + {% include "forms/includes/field.html" %} + {% endfor %} + + <button class="btn btn-danger" type="submit"> + {% trans 'Confirm' %} + </button> + </form> + {% endblock %} diff --git a/hypha/apply/users/templates/two_factor/profile/profile.html b/hypha/apply/users/templates/two_factor/profile/profile.html index b5e73b325..21a9b92f2 100644 --- a/hypha/apply/users/templates/two_factor/profile/profile.html +++ b/hypha/apply/users/templates/two_factor/profile/profile.html @@ -48,7 +48,7 @@ You have {{ counter }} backup tokens remaining. {% endblocktrans %} </p> - <p><a href="{% url 'users:backup_tokens_password' %}" + <p><a href="{% url 'users:backup_tokens' %}" class="btn btn-info">{% trans "Show Codes" %}</a></p> <h2>{% trans "Disable Two-Factor Authentication" %}</h2> diff --git a/hypha/apply/users/templates/users/account.html b/hypha/apply/users/templates/users/account.html index 12346f36c..1e95d5032 100644 --- a/hypha/apply/users/templates/users/account.html +++ b/hypha/apply/users/templates/users/account.html @@ -8,10 +8,12 @@ {% slot header %}{% trans "Welcome" %} {{ user }}{% endslot %} {% slot sub_heading %}{% trans "Manage your account details and security." %}{% endslot %} - <a href="{% url 'dashboard:dashboard' %}" class="button button--primary button--arrow-pixels-white" hx-boost='true'> - {% trans "Go to my dashboard" %} - <svg><use xlink:href="#arrow-head-pixels--solid"></use></svg> - </a> + {% if user.can_access_dashboard %} + <a href="{% url 'dashboard:dashboard' %}" class="button button--primary button--arrow-pixels-white" hx-boost='true'> + {% trans "Go to my dashboard" %} + <svg><use xlink:href="#arrow-head-pixels--solid"></use></svg> + </a> + {% endif %} {% endadminbar %} <div class="profile"> @@ -28,23 +30,38 @@ </form> </div> - {% if show_change_password and user.has_usable_password and not backends.associated %} - <div class="profile__column"> - <h2 class="text-2xl">{% trans "Account Security" %}</h2> - <h3 class="text-base">{% trans "Password" %}</h3> - <p><a class="button button--primary" href="{% url 'users:password_change' %}">{% trans "Update password" %}</a></p> - <h4 class="text-base mt-8">{% trans "Two-Factor Authentication (2FA)" %}</h4> - {% if default_device %} - <div> - <p><a class="button button--primary" href="{% url 'users:backup_tokens_password' %}">{% trans "Backup codes" %}</a></p> - <p><a class="button button--primary button--warning" href="{% url 'two_factor:disable' %}">{% trans "Disable 2FA" %}</a></p> - </div> - {% else %} - <p><a class="button button--primary" href="{% url 'two_factor:setup' %}">{% trans "Enable 2FA" %}</a></p> - {% endif %} - </div> - {% endif %} + <div class="profile__column"> + <h2 class="text-2xl">{% trans "Account Security" %}</h2> + + {% if show_change_password %} + <div class="block_manage_password mb-8"> + <h3 class="text-base mb-0">{% trans "Password" %}</h3> + <p> + {% if user.has_usable_password %} + <a class="button button--primary" href="{% url 'users:password_change' %}"> + {% trans "Update password" %} + </a> + {% else %} + <button class="button button--primary" + hx-post="{% url 'users:set_user_password' %}" + hx-swap="outerHTML" + > + {% trans "Set Password" %} + </button> + {% endif %} + </p> + </div> + {% endif %} + + <h3 class="text-base mb-2">{% trans "Two-Factor Authentication (2FA)" %}</h3> + {% if default_device %} + <a class="button button--primary mb-2" href="{% url 'users:backup_tokens' %}">{% trans "Backup codes" %}</a> + <a class="button button--primary button--warning mb-2" href="{% url 'two_factor:disable' %}">{% trans "Disable 2FA" %}</a> + {% else %} + <a class="button button--primary" href="{% url 'two_factor:setup' %}">{% trans "Enable 2FA" %}</a> + {% endif %} + </div> {% if swappable_form %} @@ -63,13 +80,13 @@ </form> {% endif %} - {# Remove the comment block tags below when such need arises. e.g. adding new providers #} - {% comment %} - {% can_use_oauth as show_oauth_link %} - {% if show_oauth_link %} - <a href="{% url 'users:oauth' %}">{% trans "Manage OAuth" %}</a> - {% endif %} - {% endcomment %} + {# Remove the comment block tags below when such need arises. e.g. adding new providers #} + {% comment %} + {% can_use_oauth as show_oauth_link %} + {% if show_oauth_link %} + <a href="{% url 'users:oauth' %}">{% trans "Manage OAuth" %}</a> + {% endif %} + {% endcomment %} </div> {% endif %} </div> 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 000000000..367b3ea74 --- /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 b6dc037fd..145724444 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 %} - <div class="wrapper wrapper--small wrapper--bottom-space"> - <p><strong>{% trans "Two possible reasons:" %}</strong></p> - <ul> - <li>{% trans "The activation link has expired." %}</li> - <li>{% trans "The account has already been activated." %}</li> - </ul> + <div class="w-full bg-white mt-5 md:py-4"> - <p>{% blocktrans %}First try to <a href="{{ password_reset }}">reset your password</a>. If that fails please contact {{ ORG_SHORT_NAME }} at{% endblocktrans %} <a href="mailto:{{ ORG_EMAIL }}">{{ ORG_EMAIL }}</a></p> - </div> + <section class="max-w-2xl"> + {% heroicon_outline "exclamation-triangle" aria_hidden="true" size=64 class="stroke-red-600" %} + + <h2 class="text-2xl">{% trans "Invalid activation URL" %}</h2> + {% url 'users:password_reset' as password_reset %} + <div class="wrapper wrapper--small wrapper--bottom-space"> + <p><strong>{% trans "Two possible reasons:" %}</strong></p> + <ol class="list-decimal pl-6"> + <li>{% trans "The activation link has expired." %}</li> + <li>{% trans "The account has already been activated." %}</li> + </ol> + + <p>{% blocktrans %}First try to <a href="{{ password_reset }}">reset your password</a>. If that fails please contact {{ ORG_SHORT_NAME }} at{% endblocktrans %} <a href="mailto:{{ ORG_EMAIL }}">{{ ORG_EMAIL }}</a></p> + </section> + </div> {% 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 5b5b638ac..000000000 --- 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 %} - <div class="wrapper wrapper--small wrapper--inner-space-medium"> - <form class="form" action="" method="POST" novalidate> - {% if form.non_field_errors %} - <ul class="errorlist"> - {% for error in form.non_field_errors %} - <li>{{ error }}</li> - {% endfor %} - </ul> - {% endif %} - - {% if form.errors %} - <ul class="errorlist"> - {% blocktrans trimmed count counter=form.errors.items|length %} - <li>Please correct the error below.</li> - {% plural %} - <li>Please correct the errors below.</li> - {% endblocktrans %} - </ul> - {% endif %} - - {% csrf_token %} - - {% for field in form %} - {% include "forms/includes/field.html" %} - {% endfor %} - - <div class="form__group"> - <button class="button button--primary" type="submit">{% trans 'Submit' %}</button> - </div> - </form> - </div> -{% endblock %} diff --git a/hypha/apply/users/templates/users/email_change/done.html b/hypha/apply/users/templates/users/email_change/done.html index 448999f44..fcf6194f7 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 %} - <div class="wrapper wrapper--small wrapper--bottom-space"> - <p>{% trans "To start using the new email, please click on the confirmation link that has been sent to you on your new email." %}</p> + + {% adminbar %} + {% slot header %}{% trans "Email Update" %}{% endslot %} + {% endadminbar %} + + <div class="wrapper mt-6 prose"> + <h2>{% trans "Confirm & verify your new email!" %} </h2> + <p> + {% trans "We have sent a confirmation link to your new email." %} + </p> + <p> + {% trans "To start using the new email, please click on the confirmation link that has been sent to you on your new email." %} + </p> </div> {% 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 000000000..f9ee524e4 --- /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 000000000..9ebb66c12 --- /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 000000000..9f9cea09e --- /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 000000000..ab23da16a --- /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 000000000..a5c66b62c --- /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 000000000..ac5b535ea --- /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 ab5154f58..37815bf1c 100644 --- a/hypha/apply/users/templates/users/login.html +++ b/hypha/apply/users/templates/users/login.html @@ -31,18 +31,36 @@ {% if wizard.steps.current == 'auth' %} + <style> + .id_auth-password { + margin-bottom: 0.25rem; + } + </style> + <h2 class="text-2xl">Log in to {{ ORG_SHORT_NAME }}</h2> {% for field in form %} - <div class="form__group"> + <div class="relative max-w-sm {% if field.auto_id == "id_auth-password" %}mb-4{% endif %}"> {% include "forms/includes/field.html" %} + {% if field.auto_id == "id_auth-password" %} + <div class="text-right"> + <a class="link text-sm hover:opacity-75" href="{% url 'users:password_reset' %}{% if redirect_url %}?next={{ redirect_url }}{% endif %}" hx-boost="true">{% trans "Forgot your password?" %}</a> + </div> + {% endif %} </div> {% endfor %} + {% if settings.users.AuthSettings.extra_text %} - {{ settings.users.AuthSettings.extra_text|richtext}} + <div class="prose prose-sm mb-6 rounded-sm bg-slate-50 p-4"> + {{ settings.users.AuthSettings.extra_text|richtext}} + </div> {% endif %} - <div class="form__group"> + <div class="form__group max-w-sm flex items-center justify-between gap-4"> <button class="link link--button link--button-secondary" type="submit">{% trans "Log in" %}</button> + + {% if ENABLE_PUBLIC_SIGNUP %} + <a class="hover:opacity-75" href="{% url 'users_public:register' %}{% if redirect_url %}?next={{ redirect_url }}{% endif %}" hx-boost="true"> {% trans "Create account" %}</a> + {% endif %} </div> {% if GOOGLE_OAUTH2 %} @@ -56,20 +74,13 @@ <a class="link link--button link--button-tertiary" href="{% url "social:begin" "google-oauth2" %}{% if next %}?next={{ next }}{% endif %}">{% blocktrans %}Log in with your {{ ORG_SHORT_NAME }} email{% endblocktrans %}</a> </div> {% endif %} - - <div class="inline-flex gap-8 w-full mt-4"> - {% if ENABLE_REGISTRATION_WITHOUT_APPLICATION %} - <a href="{% url 'users_public:register' %}{% if redirect_url %}?next={{ redirect_url }}{% endif %}" hx-boost="true"> {% trans "Create account" %}</a> - {% endif %} - <a class="link" href="{% url 'users:password_reset' %}{% if redirect_url %}?next={{ redirect_url }}{% endif %}" hx-boost="true">{% trans "Forgot your password?" %}</a> - </div> {% else %} <div class="form__group"> {{ wizard.form }} </div> - {# hidden submit button to enable [enter] key #} + {# hidden submit button to enable [enter] key #} <div class="sr-only"><input type="submit" value=""/></div> {% if other_devices %} @@ -90,7 +101,7 @@ {% if backup_tokens %} <p>{% trans "As a last resort, you can use a backup codes:" %} <button name="wizard_goto_step" type="submit" value="backup" - class="link link--button link--button-tertiary">{% trans "Use Backup Code" %}</button> + class="button button--transparent">{% trans "Use Backup Code" %}</button> </p> {% 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 000000000..c3a7f8fed --- /dev/null +++ b/hypha/apply/users/templates/users/partials/confirmation_code_sent.html @@ -0,0 +1,74 @@ +{% load i18n heroicons %} +<form + class="form form--error-inline px-4 py-4 mb-4 border rounded-sm bg-gray-50 w-full text-center" + id="elevate-check-code-form" + x-data="{ code: '' }" +> + {% csrf_token %} + {% if error %} + <p class="mb-4 font-bold text-red-700">{% trans "Invalid code, please try again!" %}</p> + {% else %} + <p class="mb-4"> + {% heroicon_mini "check-circle" class="inline align-text-bottom fill-green-700" aria_hidden=true %} + <em>{% trans "An email containing a code has been sent. Please check your email for the code." %}</em> + </p> + {% endif %} + + <div class="mb-4"> + <label class="font-bold mr-1" for="id_code">{% trans "Enter Code" %}: </label> + <input + name='code' + id="id_code" + autofocus + required + type='text' + maxlength='6' + class="mb-2 !w-28 placeholder:text-gray-400 text-center tracking-wider" + x-model="code" + autocomplete="off" + placeholder="_ _ _ _ _ _" + data-1p-ignore + > + </div> + + <div> + <button + class="button button-primary block mb-4" + type="submit" + hx-post="{% url 'users:elevate_check_code' %}{% if redirect_url %}?next={{ redirect_url }}{% endif %}" + hx-validate="true" + hx-target="#section-form" + x-bind:disabled="code ? false : true" + > + {% trans "Confirm" %} + </button> + </div> + {% if error %} + <button + class="link hover:underline" + hx-post="{% url 'users:elevate_send_confirm_access_email' %}{% if request.GET.next %}?next={{request.GET.next}}{% endif %}" + hx-target="#section-form" + > + {% trans "Re-send code?" %} + </button> + {% endif %} +</form> + +{% if request.user.has_usable_password %} + <section data-test-id="section-send-email" class="px-4 border pt-2 pb-4"> + <p>{% trans "Having problems?" %}</p> + <ul class="list-disc ml-4"> + <li> + <a + class="m-0" + type="submit" + hx-boost="true" + href="{% url 'users:elevate' %}{% if redirect_url %}?next={{ redirect_url }}{% endif %}" + > + {% trans "Use your password" %} + </a> + </li> + </ul> + </section> +{% 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 000000000..62a07ec42 --- /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 %} + <section class="prose mt-8"> + <div> + {% heroicon_outline "document-check" aria_hidden="true" size=64 %} + </div> + <h2 class="mt-4"> + {% trans "Check your inbox to proceed!" %} + </h2> + + <p> + {% 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." %}</p> + {% else %} + {% trans "We've sent you an email with a login link. Kindly check your email and follow the link to access your account." %}</p> + {% endif %} + </p> + + <p> + {% blocktrans %}Check your "Spam" folder, if you don't find the email in your inbox.{% endblocktrans %} + </p> + + <p> + <a href="{% url 'users_public:passwordless_login_signup' %}{% if redirect_url %}?next={{ redirect_url }}{% endif %}" class="font-medium hover:underline">Try again</a> + </p> + </section> +{% 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 000000000..d837caf90 --- /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 %} + <div class="max-w-2xl bg-white mt-5 md:py-4"> + + <section class="pt-4 px-5"> + <form class="form form--user-login" method="post" hx-post="./" hx-swap="outerHTML" hx-target="#main"> + {% csrf_token %} + + {% if redirect_url %} + <input type="hidden" name="next" value="{{ redirect_url }}"> + {% endif %} + + <h2 class="text-2xl">Login {% if ENABLE_PUBLIC_SIGNUP %}or Signup {% endif %}to {{ ORG_SHORT_NAME }}</h2> + + <div> + {% for hidden in form.hidden_fields %} + {{ hidden }} + {% endfor %} + {% for field in form.visible_fields %} + {% if field.field %} + {% include "forms/includes/field.html" %} + {% else %} + {{ field }} + {% endif %} + {% endfor %} + </div> + + {% if settings.users.AuthSettings.extra_text %} + <div class="prose prose-sm mb-6 p-4 rounded-sm bg-slate-50"> + {{ settings.users.AuthSettings.extra_text|richtext}} + </div> + {% endif %} + + <div class="form__group"> + <button class="link link--button link--button-secondary" type="submit">{% trans "Next" %}</button> + </div> + + <div class="flex items-center justify-start relative mb-4"> + <hr class="inline w-32 h-px my-6 bg-gray-300 border-0"> + <span class="px-3 text-gray-400 font-medium">or</span> + <hr class="inline w-32 h-px my-6 bg-gray-300 border-0"> + </div> + + <section> + {% if GOOGLE_OAUTH2 %} + <a + class="link link--button link--button-tertiary" + href="{% url "social:begin" "google-oauth2" %}{% if next %}?next={{ next }}{% endif %}" + > + {% blocktrans %}Log in with your {{ ORG_SHORT_NAME }} email{% endblocktrans %} + </a> + {% endif %} + + <a + class="link link--button link--button-tertiary" + href="{% url 'users_public:login' %}{% if next %}?next={{next}}{% endif %}" + > + {% heroicon_mini "key" size=18 class="inline align-text-bottom mr-1" aria_hidden=true %} + {% trans "Login with Password" %} + </a> + </section> + </form> + </section> + </div> +{% endblock %} diff --git a/hypha/apply/users/tests/test_forms.py b/hypha/apply/users/tests/test_forms.py index dc7fd7d6f..1b3c5f35c 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 @@ class BaseTestProfileForm(TestCase): 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 @@ class TestProfileForm(BaseTestProfileForm): 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 @@ class TestProfileForm(BaseTestProfileForm): 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 @@ class TestStaffProfileForm(BaseTestProfileForm): 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 a5de674bb..d446661b6 100644 --- a/hypha/apply/users/tests/test_middleware.py +++ b/hypha/apply/users/tests/test_middleware.py @@ -4,7 +4,7 @@ from django.urls import reverse 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 @@ class TestTwoFactorAuthenticationMiddleware(TestCase): 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 @@ class TestTwoFactorAuthenticationMiddleware(TestCase): 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 124d211b3..8c7a9244d 100644 --- a/hypha/apply/users/tests/test_oauth_access.py +++ b/hypha/apply/users/tests/test_oauth_access.py @@ -22,7 +22,7 @@ class TestOAuthAccess(TestCase): 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 ea0019c6b..6f654e96d 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 @@ from hypha.apply.utils.testing import make_request @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 @@ class TestRegistration(TestCase): 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 @@ class TestRegistration(TestCase): 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 000000000..8a4068285 --- /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 45351e8e8..0171d310d 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 @@ class TestProfileView(BaseTestProfielView): # 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 000000000..6c842fdfb --- /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 9d8e26b75..080f44d30 100644 --- a/hypha/apply/users/urls.py +++ b/hypha/apply/users/urls.py @@ -10,19 +10,24 @@ from .views import ( 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 @@ app_name = "users" 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 @@ public_urlpatterns = [ ), ] -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/<uidb64>/<token>/", - 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/<uidb64>/<token>/", - EmailChangeConfirmationView.as_view(), - name="confirm_email", - ), - path( - "activate/<uidb64>/<token>/", - 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/<str:user_id>/", - TWOFAAdminDisableView.as_view(), - name="admin_disable", + "reset/confirm/<uidb64>/<token>/", + 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/<uidb64>/<token>/", + EmailChangeConfirmationView.as_view(), + name="confirm_email", + ), + path( + "activate/<uidb64>/<token>/", + 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/<str:user_id>/", + TWOFAAdminDisableView.as_view(), + name="admin_disable", + ), + path( + "auth/<uidb64>/<token>/signup/", + PasswordlessSignupView.as_view(), + name="do_passwordless_signup", + ), + path( + "auth/<uidb64>/<token>/", + 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 e46c33ead..6f544ea31 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 990f3279f..9a9877c58 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 ( 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.decorators.debug import sensitive_post_parameters 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.models import Site 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 @@ class RegisterView(View): # 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 @@ class RegisterView(View): 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 @@ class AccountView(UpdateView): ) -@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 @@ class ActivationView(TemplateView): 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 @@ class TWOFASetupView(TwoFactorSetupView): 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 @@ class TWOFAAdminDisableView(FormView): 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 @@ class PasswordResetConfirmView(DjPasswordResetConfirmView): 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 88467023a..d3a1cccf7 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 0b3ac7295..a07009f6e 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 a6ee50260..2f93125a3 100644 --- a/hypha/public/home/templates/home/home_page.html +++ b/hypha/public/home/templates/home/home_page.html @@ -73,7 +73,7 @@ <div class="header__button-container"> {% include "utils/includes/login_button.html" %} - {% if ENABLE_REGISTRATION_WITHOUT_APPLICATION %} + {% if ENABLE_PUBLIC_SIGNUP %} {% include "utils/includes/register_button.html" %} {% endif %} </div> diff --git a/hypha/public/navigation/templates/navigation/primarynav-apply.html b/hypha/public/navigation/templates/navigation/primarynav-apply.html index 0980776ac..c8b22b352 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 %} <nav role="navigation" aria-label="Primary" class="w-full"> <ul class="nav nav--primary" role="menubar"> - {% if request.user.is_apply_staff %} + {% if request.user.can_access_dashboard %} {% include "navigation/primarynav-apply-item.html" with name="My dashboard" url="dashboard:dashboard" %} + {% endif %} + {% if request.user.is_apply_staff %} {% include "navigation/primarynav-apply-item.html" with name="Submissions" url="funds:submissions:overview" %} {% include "navigation/primarynav-apply-item.html" with name="Projects" url="apply:projects:overview" %} {% elif request.user.is_finance or request.user.is_contracting %} - {% include "navigation/primarynav-apply-item.html" with name="My dashboard" url="dashboard:dashboard" %} {% include "navigation/primarynav-apply-item.html" with name="Projects" url="apply:projects:overview" %} - {% else %} - {% include "navigation/primarynav-apply-item.html" with name="My dashboard" url="dashboard:dashboard" %} {% endif %} </ul> </nav> diff --git a/hypha/public/utils/templates/utils/includes/login_button.html b/hypha/public/utils/templates/utils/includes/login_button.html index 37fa3b165..328eff33b 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 %} -<a href="{{ APPLY_SITE.root_url }}{% url 'users_public:login' %}{% if redirect_url %}?next={{ redirect_url }}{% endif %}" class="button button--transparent button--contains-icons {{ class }}"> - <svg class="icon icon--person"><use xlink:href="#person-icon"></use></svg> - {% if user.is_authenticated %} - My {{ ORG_SHORT_NAME }} + +{% if user.is_authenticated %} + {% if user.can_access_dashboard %} + <a + class="button button--transparent button--contains-icons {{ class }}" + href="{{ APPLY_SITE.root_url }}{% url 'dashboard:dashboard' %}" + > + <svg class="icon icon--person"><use xlink:href="#person-icon"></use></svg> + My {{ ORG_SHORT_NAME }} + </a> {% else %} - {% trans "Login" %} + <a + class="button button--transparent button--contains-icons {{ class }}" + href="{{ APPLY_SITE.root_url }}{% url 'users:account' %}" + title="Goto your account" + > + <svg class="icon icon--person"><use xlink:href="#person-icon"></use></svg> + {{ user }} + </a> {% endif %} -</a> +{% else %} + <a + class="button button--transparent button--contains-icons {{ class }}" + href="{{ APPLY_SITE.root_url }}{% url 'users_public:passwordless_login_signup' %}{% if redirect_url %}?next={{ redirect_url }}{% endif %}" + > + <svg class="icon icon--person"><use xlink:href="#person-icon"></use></svg> + {% trans "Login" %} + </a> +{% endif %} diff --git a/hypha/public/utils/templates/utils/includes/register_button.html b/hypha/public/utils/templates/utils/includes/register_button.html index 29e10dd26..648fb72b0 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 %} <a href="{{ APPLY_SITE.root_url }}{% url 'users_public:register' %}{% if redirect_url %}?next={{ redirect_url }}{% endif %}" class="button button--transparent {{ class }}"> - {% trans "Register" %} + {% trans "Sign up" %} </a> diff --git a/hypha/settings/base.py b/hypha/settings/base.py index 818cc0345..73df7a87d 100644 --- a/hypha/settings/base.py +++ b/hypha/settings/base.py @@ -136,14 +136,6 @@ TRANSITION_AFTER_ASSIGNED = env.bool("TRANSITION_AFTER_ASSIGNED", False) # 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 @@ LANGUAGE_CODE = env.str("LANGUAGE_CODE", "en") # 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 @@ MEDIA_URL = env.str("MEDIA_URL", "/media/") # 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 7d9ca32d6..9418a797e 100644 --- a/hypha/settings/django.py +++ b/hypha/settings/django.py @@ -203,7 +203,7 @@ DATETIME_INPUT_FORMATS = [ 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 3b1e3bf01..51ab2af72 100644 --- a/hypha/settings/test.py +++ b/hypha/settings/test.py @@ -30,3 +30,5 @@ TEMPLATES[0]["OPTIONS"]["debug"] = True # 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 36660810b..4a7e42257 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 6dd241747..0305fa36b 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 410b24f6b..c408502e6 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 4982053b5..233a893e9 100644 --- a/hypha/templates/base-apply.html +++ b/hypha/templates/base-apply.html @@ -167,8 +167,10 @@ {% trans "Log out" %} </a> {% 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 382565e0d..eec53c3b8 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 %}<!doctype html> +{% load static cache wagtailcore_tags wagtailimages_tags navigation_tags util_tags cookieconsent_tags i18n %}<!doctype html> {% wagtail_site as current_site %} <html class="no-js" lang="en"> <head> @@ -155,9 +155,13 @@ <div class="header__button-container"> {% 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 %} + <a href="{% url 'users_public:logout' %}" class="button button--transparent button--narrow"> + {% trans "Log out" %} + </a> {% endif %} + {% if ENABLE_GOOGLE_TRANSLATE %} <div class="button button--google-translate" id="google_translate_element"></div> {% endif %} diff --git a/requirements-dev.txt b/requirements-dev.txt index 162eec0ec..43b82fdcf 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 c2357250a..0ca6b57e9 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 -- GitLab