diff --git a/.vscode/settings.json b/.vscode/settings.json index 06d6178f35b640c810350cb1f0d275b2b416e854..839b1bed5e071e933505494720e1529387d1c395 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 93f93d8aac705dd80cf46af895d0ea407b9910de..0e13424a6c33f47047f47074e8f80d1cd3095ea8 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 6e7f771a2e16a14a64b4db6b8351f7b73feb1c5c..41823412a5cee38bef482c76c4e89995c5626ffd 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 ae0a1f8a573db084fe74783e7cd0c9b3d447713c..5af8a58e67b19b5b74dca2c099c33d52bea3c35f 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 f7267b5c24131f4bd02b27baeeb421c6314f8bb6..dd52fbe334097e54ea86cd1a07eedee8f6693994 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 b13d2c423b3ce21c2514adc6f1d628cfadf9eec0..0000000000000000000000000000000000000000 --- 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 0ed5e7f0e678b2c322fe7499bc599a42c822b686..0000000000000000000000000000000000000000 --- 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 1097899405aa599618c02bce4d53bf90da8b76f0..b032625997f310680e55861d2b1220e6c1afde70 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 6259dc7afbb7e39cbcd9947a89fac3db90b11c39..530217bc472acf4eba123601e44356af0bbc69ea 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 054c9df1121a24f2843f868a1c07ce05a5d9c4fb..4cc8d634572ac71df2b13ea64ec1db8024d5164d 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 87809e3ec21201d688c934a7e032e91c34b2ac87..6b11739e0634c7961615f30e161f836ea0efc769 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 b942613db05e05c7e5718ac7cc3b37ac67ecaf61..5725d425c20bab81b7b8345577734272ac3d9d5a 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 b006647fa687c296692cf97ab098e946ee3a7efe..58b7b1175d946370f4bf953ae986863bee0b4036 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 688f9d2119feefaac2ef88bafd5f54c3dd2c57b7..480e767079a55fb4d219abf13f265a25a7240b99 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 b20e5875dde3936c8a47b6bb32647a6b6998888f..27c0f0cf4a4676a81b5fe71acc0eb4affd60a039 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 82ec8597497719a5014a39ce792f2b2209980271..35fe9a8e322e04c604b4630836672462989827e8 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 13dcd722e7c1e7baffd5d66aa7058f41de46314f..4808e19f7333a0559b93b4d4128389cf09b816ec 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 fd11afec3749570a842a75c533280b4cd9b72beb..d38207b9d744153a72a9c82ab8da536353c53ab3 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 0000000000000000000000000000000000000000..a40e95239c435f609af7dce4433f1f0f4bb9ce6d --- /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 0000000000000000000000000000000000000000..20b34b7c3e6efb5dc744c87201b79c686f65cc74 --- /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 44ee07b515f51bc6ec66a59e1c23e2fedb6eba8e..fbaab70c3351111063b05a927c63465b16d7df9e 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 0000000000000000000000000000000000000000..8762aa00c2be7f7f98cdb9d85da5258de084d8c4 --- /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 9a55046959590ced3e9d0930aa3768f83282b2f9..1a5dbd29c5358dbad817da7c1bb589127abccff0 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 2a63a6db9b5c4f98a178ce0c12b6010155e33bf6..d53a7af9f9951f7d36cb0786b2ffd52c55e75a83 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 eaff592606c70899396093c95d943ef8af1c4fd3..4931f35879c1668653f7b17930babe08a6097b24 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 297cd8503d7b596883c0986bce7de540be3f0348..ab0108d20862eca1f12acfa0039a777dbddbdcaf 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 8d9066ab8774f8388d62bba6040cb47dc591c24a..6cadae9cae5e4d72689fdd4a0ff7a1594caa2d7c 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 973817d57fa0db4815959aba1204496546b52515..627f4a458b13ed7ea79ba5bac6af5be15a88cc26 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 ad0f7254cfd5dfae947dcfff4154a734a7a09605..d5cf73ae1f854559030c0773abed73333add3460 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 683c8c3038ab994b56ae598d088a9e48ca79c191..b4dc0b43c91adc7701d8b77bb0f4f913bad60e6c 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 b5e73b325406dcb7aea85a353a9155bfed54a5a6..21a9b92f27d5312326142c79e3df2981c7399468 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 12346f36c0d47ba705d27d8c49c788f36bed56c3..1e95d50328ecaa964f55b5e5456a729fe9860aa7 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 0000000000000000000000000000000000000000..367b3ea742df0f1ed6cd65b4b11200dd0200c2ef --- /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 b6dc037fd6cbd9c33a8cdd5522b7cd24020eff66..1457244442cf9a197bd2e2b2d9f7c7543e67506d 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 5b5b638acbcacf8ac9383593c66d99c898b7c8a3..0000000000000000000000000000000000000000 --- 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 448999f44acd2263458bddf4bd395cc18d72a808..fcf6194f7d99a48b74807fef036e626f90c186a6 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 0000000000000000000000000000000000000000..f9ee524e436afbca82a1d4f7b9f0757c9d0a1d3f --- /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 0000000000000000000000000000000000000000..9ebb66c1239049c64c8f6775e7b3880b1403c54c --- /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 0000000000000000000000000000000000000000..9f9cea09efc7e17da8f00933bddf8202b8018f3b --- /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 0000000000000000000000000000000000000000..ab23da16a81d6029fddbaead4f700e67e02623b7 --- /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 0000000000000000000000000000000000000000..a5c66b62cfe42b7d71ff20dfd087bebaf8a360f9 --- /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 0000000000000000000000000000000000000000..ac5b535ead2106a5f0f52654d08c7c664879d9cd --- /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 ab5154f588e5752417c054591d395864625eb119..37815bf1c946c5109372dc02775b8a17ca68c6f3 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 0000000000000000000000000000000000000000..c3a7f8feddf4826289f8bb3cde1219ba756a9639 --- /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 0000000000000000000000000000000000000000..62a07ec4299d848c42b834ee948c056826d4fd8c --- /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 0000000000000000000000000000000000000000..d837caf90f50a786d2149d69991b4dc771dfa7b0 --- /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 dc7fd7d6f958e6bb141074b3b6c1cc631d4baa9d..1b3c5f35c575fe1bf2128fc3de6e8524682a7fb3 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 a5de674bb4cd1480baf8ee7e182822f6517bd96b..d446661b641bcb19c3930bb2c366cf15f3f39bf8 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 124d211b32e441247ce38595e8ba23c463cf2f34..8c7a9244de1b6b9a744647eea0c3bfdc83e8fcb5 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 ea0019c6b6967b4c223064cae1d58d55ca3b2a47..6f654e96d310bb52f5bfb6da6aa4f90184df38bc 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 0000000000000000000000000000000000000000..8a406828590491c7e4dcb918da80bd492ed38c72 --- /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 45351e8e8f5825c461320166fa9590f51a914d2e..0171d310df02ef444aa218cdcbbd7d8c8a04a642 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 0000000000000000000000000000000000000000..6c842fdfb122079bcd4e285ba467a795637f46db --- /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 9d8e26b75e1aa0fec29349feebd2d22a6751e6c8..080f44d30be7f7e2f75fffabe6c1ef29c62d40c5 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 e46c33ead6d40ac669555dd3114c76dee09bcc0a..6f544ea3174d5680d850a97891395b19abea3e44 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 990f3279f80a05ae1d41d7695708ab34421b6982..9a9877c58b7d9117a0f2871a5a1155efd6acf8c9 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 88467023a0dfd2fa03284d77eb4c5cfe2c0ebbe7..d3a1cccf7be64f8581267ca15639268080323f1b 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 0b3ac7295d14483d15ed9cf009e2e57f08d3a058..a07009f6ef11fa97e17ac6f54f3de42cda2d34b3 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 a6ee50260e26f88beee258f2c154c5ca9e990c62..2f93125a3e409794166cdf0882343aaa0219fd0c 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 0980776acb4ffaf2606d912b74fa15fca2b94516..c8b22b352f6290a05a9ed878d35a12a73d245505 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 37fa3b165450795281550edff9baebde90196853..328eff33ba58c4ffd45d172b363ec5fbb42914a0 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 29e10dd26a74120e40d54f491d3e38398947c496..648fb72b04833bd0ba466b5bed78184b147af02d 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 818cc03457032fda9dea0f2a77694f210658e239..73df7a87df1d8f949b42f88397eb434e9b7e25ae 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 7d9ca32d6268e36ef5d2b9c75d3062d85867f3df..9418a797e90da8f6dfb461ba11c5c99962968800 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 3b1e3bf01b7c9d10628b21e66de1864f7471c0b9..51ab2af72b2745ea386888c305bbd8caff48c6f6 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 36660810b28f278076114b1630d7c3683993456e..4a7e42257d17d90d03f87b229009b7f0f22ed488 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 6dd2417475800bed4af6dbd78e245c8675a3740d..0305fa36b3fbcf8cf310663fe2315c995119d93c 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 410b24f6b73fd5363d7c967c677c48ee04e9e806..c408502e6bb063d20a265ecb8ece4eedfdbfef27 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 4982053b5aa3053d3c696be010eb80b984fd6982..233a893e955ab0198a7135029f078d018e36bc2d 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 382565e0d3b6e29181bdb1aa33d8aa7cc0e759c8..eec53c3b83f5828ac1468002d6caf0478d29fb6d 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 162eec0ecc0e7cbef2a7a2208bb70922d9cc422d..43b82fdcf4dc71f52c1b2a6ac4c2550cb080bef0 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 c2357250a8225407e3bbd2e3b52414268edf6df9..0ca6b57e96bc0cc60294d8c47cf286a2c299dad8 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