diff --git a/requirements/base.txt b/requirements/base.txt index 22cdfee37..af80a644d 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -11,7 +11,6 @@ cffi>=1.7 coverage>=3.6 cryptography==40.0.1 decorator==5.1.1 -django-allauth==0.54.0 django-appconf==1.0.5 django-braces==1.15.0 django-celery-results==2.5.0 @@ -24,6 +23,7 @@ django-extensions==3.2.1 django-filter==23.1 django-model-utils==4.3.1 django-pipeline==2.1.0 +django-sesame==3.2.2 django-storages==1.13.2 django-svelte==0.2.1 django-webtest==1.9.10 diff --git a/ynr/account_adapter.py b/ynr/account_adapter.py deleted file mode 100644 index 21d33a288..000000000 --- a/ynr/account_adapter.py +++ /dev/null @@ -1,46 +0,0 @@ -import logging - -from allauth.account.adapter import DefaultAccountAdapter -from allauth.socialaccount.adapter import DefaultSocialAccountAdapter - -logger = logging.getLogger(__name__) - - -class NoNewUsersAccountAdapter(DefaultAccountAdapter): - def is_open_for_signup(self, request): - """ - Checks whether or not the site is open for signups. - - Next to simply returning True/False you can also intervene the - regular flow by raising an ImmediateHttpResponse - - (Comment reproduced from the overridden method.) - """ - return False - - -class LoggingSocialAccountAdapter(DefaultSocialAccountAdapter): - """ - Exactly the same as the DefaultSocialAccountAdapter, but logs authentication - errors to the default log. - - """ - - def authentication_error( - self, - request, - provider_id, - error=None, - exception=None, - extra_context=None, - ): - """ - Log errors to authenticating. This method in the parent class is - left blank and exists for overriding, so we can do what we want here. - - """ - logger.error( - "Error logging in with provider '{}' with error '{}' ({})".format( - provider_id, error, exception - ) - ) diff --git a/ynr/apps/elections/templates/elections/ballot_view.html b/ynr/apps/elections/templates/elections/ballot_view.html index 341b6d04d..69cbf4870 100644 --- a/ynr/apps/elections/templates/elections/ballot_view.html +++ b/ynr/apps/elections/templates/elections/ballot_view.html @@ -201,7 +201,7 @@

Candidates for {{ ballot.post.label }} on
{{ ballot.election.election_da {% elif not user.is_authenticated %}

- + Sign in to add a new candidate

diff --git a/ynr/apps/elections/uk/templates/candidates/person-view.html b/ynr/apps/elections/uk/templates/candidates/person-view.html index 6f50b439f..aaff94341 100644 --- a/ynr/apps/elections/uk/templates/candidates/person-view.html +++ b/ynr/apps/elections/uk/templates/candidates/person-view.html @@ -241,7 +241,7 @@

Improve this data!

Upload candidate photo {% endif %} {% else %} - Log in to edit + Log in to edit {% endif %} {% else %}

Edits disabled

diff --git a/ynr/apps/wombles/forms.py b/ynr/apps/wombles/forms.py new file mode 100644 index 000000000..03fbc7e32 --- /dev/null +++ b/ynr/apps/wombles/forms.py @@ -0,0 +1,42 @@ +from django import forms +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError + + +class LoginForm(forms.Form): + """ + Login form for a User. + """ + + email = forms.EmailField(required=True) + + def clean_email(self): + """ + Normalize the entered email + """ + email = self.cleaned_data["email"] + return User.objects.normalize_email(email) + + +class UserProfileForm(forms.ModelForm): + class Meta: + model = User + fields = ("username",) + + username = forms.CharField( + max_length=50, + help_text="Your username is displayed publicly. We don't accept email addresses or '@' symbols", + ) + + def clean_username(self): + username = self.cleaned_data["username"] + + if "@" in username: + raise ValidationError( + "Usernames can't be email addresses or contain an '@' symbol" + ) + + user = User.objects.filter(username__iexact=username) + if user: + raise ValidationError("A user with that username already exists.") + return username diff --git a/ynr/apps/wombles/middleware.py b/ynr/apps/wombles/middleware.py new file mode 100644 index 000000000..b6252bc4f --- /dev/null +++ b/ynr/apps/wombles/middleware.py @@ -0,0 +1,14 @@ +from django.shortcuts import redirect +from django.urls import reverse +from django.utils.deprecation import MiddlewareMixin + + +class CheckProfileDetailsMiddleware(MiddlewareMixin): + def process_request(self, request): + if request.user.is_authenticated and not request.user.username: + add_profile_url = reverse("wombles:add_profile_details") + + if request.path != add_profile_url: + return redirect(add_profile_url) + + return None diff --git a/ynr/apps/wombles/templates/wombles/authenticate.html b/ynr/apps/wombles/templates/wombles/authenticate.html new file mode 100644 index 000000000..9feec067c --- /dev/null +++ b/ynr/apps/wombles/templates/wombles/authenticate.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} +{% block content %} + +

Something went wrong

+

Your authentication token may have timed out. Please request a new magic link from the login page and try again.

+ +{% endblock %} diff --git a/ynr/apps/wombles/templates/wombles/email/login_message.txt b/ynr/apps/wombles/templates/wombles/email/login_message.txt new file mode 100644 index 000000000..dfc586cfc --- /dev/null +++ b/ynr/apps/wombles/templates/wombles/email/login_message.txt @@ -0,0 +1,3 @@ +Use the following URL to authenticate with the Democracy Club candidates site: + +{{ authenticate_url }} diff --git a/ynr/apps/wombles/templates/wombles/login.html b/ynr/apps/wombles/templates/wombles/login.html new file mode 100644 index 000000000..9af4cfff2 --- /dev/null +++ b/ynr/apps/wombles/templates/wombles/login.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} + +{% block title %} + Log in +{% endblock %} + +{% block content %} + {% if messages %} + + {% else %} +

Login

+ +

Please enter your email, and we will send you a magic link URL to authenticate your account.

+
+ {% csrf_token %} +
+ {{ form.as_p }} +
+ +
+

Contributions to this site are made public and form part of an openly licenced database. + Find out more about our data licencing.

+ + {% endif %} + +{% endblock %} diff --git a/ynr/apps/wombles/templates/wombles/logout.html b/ynr/apps/wombles/templates/wombles/logout.html new file mode 100644 index 000000000..eb54e4244 --- /dev/null +++ b/ynr/apps/wombles/templates/wombles/logout.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% block content %} + +

You have logged out

+

Next time you want to log in, request a new magic link from the login + page.

+ + +{% endblock %} diff --git a/ynr/apps/wombles/templates/wombles/my_profile.html b/ynr/apps/wombles/templates/wombles/my_profile.html index 03192daad..56ed6e4f1 100644 --- a/ynr/apps/wombles/templates/wombles/my_profile.html +++ b/ynr/apps/wombles/templates/wombles/my_profile.html @@ -2,7 +2,7 @@ {% block content %}

My profile

-

Your username is {{ user.username }} and your contact email is {{ user.email }}.

+

Your username is {{ user.username }} and your contact email is {{ user.email }}.

When you create an account we create an API key for you, this lets you use our API.

Your API key is {{ user.auth_token.key }}.

diff --git a/ynr/apps/wombles/templates/wombles/update_profile.html b/ynr/apps/wombles/templates/wombles/update_profile.html new file mode 100644 index 000000000..0565ba814 --- /dev/null +++ b/ynr/apps/wombles/templates/wombles/update_profile.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} + +{% block content %} +
+

Update Profile

+ {% csrf_token %} + {{ form.as_p }} + +
+ +{% endblock %} diff --git a/ynr/apps/wombles/urls.py b/ynr/apps/wombles/urls.py index 60595eb00..ca0cd4530 100644 --- a/ynr/apps/wombles/urls.py +++ b/ynr/apps/wombles/urls.py @@ -1,6 +1,17 @@ +from django.contrib.auth import views as auth_views from django.urls import path, re_path -from .views import MyProfile, SingleWombleView, WombleTagsView, WombleTagView +from .views import ( + AuthenticateView, + LoginView, + MyProfile, + SingleWombleView, + UpdateProfileDetailsView, + WombleTagsView, + WombleTagView, +) + +app_name = "wombles" urlpatterns = [ path("me", MyProfile.as_view(), name="my_profile"), @@ -11,4 +22,16 @@ re_path( r"^(?P[\d]+)/$", SingleWombleView.as_view(), name="single_womble" ), + path("login/", LoginView.as_view(), name="login"), + path( + "logout/", + auth_views.LogoutView.as_view(template_name="wombles/logout.html"), + name="logout", + ), + path("authenticate/", AuthenticateView.as_view(), name="authenticate"), + path( + "details", + UpdateProfileDetailsView.as_view(), + name="add_profile_details", + ), ] diff --git a/ynr/apps/wombles/views.py b/ynr/apps/wombles/views.py index d6b18fc81..4cc3a9515 100644 --- a/ynr/apps/wombles/views.py +++ b/ynr/apps/wombles/views.py @@ -1,8 +1,22 @@ from candidates.models import LoggedAction +from django.contrib import messages +from django.contrib.auth import login from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.models import User from django.db.models import Count -from django.views.generic import DetailView, ListView, TemplateView +from django.http import HttpResponseRedirect +from django.shortcuts import redirect +from django.template.loader import render_to_string +from django.urls import reverse +from django.views.generic import ( + DetailView, + FormView, + ListView, + TemplateView, + UpdateView, +) +from sesame.utils import get_query_string, get_user +from wombles.forms import LoginForm, UserProfileForm from wombles.models import WombleTags @@ -56,3 +70,81 @@ def get_queryset(self): "wombleprofile_set__tags", "wombleprofile_set__user__loggedaction_set", ) + + +class LoginView(FormView): + form_class = LoginForm + template_name = "wombles/login.html" + + def form_valid(self, form): + """ + Create or retrieve a user trigger the send login email + """ + user, created = User.objects.get_or_create( + email=form.cleaned_data["email"] + ) + if created: + user.set_unusable_password() + user.save() + + self.send_login_url(user=user) + messages.success( + self.request, + "Thank you, please check your email for your magic link to log in to your account.", + fail_silently=True, + ) + return HttpResponseRedirect(self.get_success_url()) + + def send_login_url(self, user): + """ + Send an email to the user with a link to authenticate and log in + """ + querystring = get_query_string(user=user) + domain = self.request.get_host() + path = reverse("wombles:authenticate") + url = f"{self.request.scheme}://{domain}{path}{querystring}" + subject = "Your magic link to log in to the Democracy Club API" + txt = render_to_string( + template_name="wombles/email/login_message.txt", + context={ + "authenticate_url": url, + "subject": subject, + }, + ) + return user.email_user(subject=subject, message=txt) + + def get_success_url(self): + """ + Redirect to same page where success message will be displayed + """ + return reverse("wombles:login") + + +class AuthenticateView(TemplateView): + template_name = "wombles/authenticate.html" + + def get(self, request, *args, **kwargs): + """ + Attempts to get user from the request, log them in, and redirect them to + their profile page. Renders an error message if django-sesame fails to + get a user from the request. + """ + user = get_user(request) + if not user: + return super().get(request, *args, **kwargs) + + login(request, user) + if not user.username: + return redirect("wombles:add_profile_details") + return redirect("/") + + +class UpdateProfileDetailsView(UpdateView): + form_class = UserProfileForm + template_name = "wombles/update_profile.html" + + def get_object(self, queryset=None): + return self.request.user + + def get_success_url(self): + return reverse("wombles:my_profile") diff --git a/ynr/forms.py b/ynr/forms.py deleted file mode 100644 index cc9b423e3..000000000 --- a/ynr/forms.py +++ /dev/null @@ -1,17 +0,0 @@ -from allauth.account.forms import LoginForm, SignupForm - - -class CustomLoginForm(LoginForm): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["login"].label = "Username or email address" - # Remove the placeholder text, which just adds noise: - for field in ("login", "password"): - del self.fields[field].widget.attrs["placeholder"] - - -class CustomSignupForm(SignupForm): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - for field in ("username", "email", "password1", "password2"): - del self.fields[field].widget.attrs["placeholder"] diff --git a/ynr/helpers.py b/ynr/helpers.py index f143e2263..4e8058a07 100644 --- a/ynr/helpers.py +++ b/ynr/helpers.py @@ -1,8 +1,6 @@ import errno import os -from django.core import validators - # Mimic the behaviour of 'mkdir -p', which just tries to ensure that # the directory (including any missing parent components of the path) # exists. This is from http://stackoverflow.com/a/600612/223092 @@ -16,12 +14,3 @@ def mkdir_p(path): pass else: raise - - -allauth_validators = [ - validators.RegexValidator( - regex=r"\@", - message="Usernames are made public, so shouldn't be email addresses", - inverse_match=True, - ) -] diff --git a/ynr/settings/base.py b/ynr/settings/base.py index e3a941d92..b0ec047b6 100644 --- a/ynr/settings/base.py +++ b/ynr/settings/base.py @@ -127,12 +127,6 @@ def root(*path): "template_timings_panel", "official_documents", "results", - "allauth", - "allauth.account", - "allauth.socialaccount", - "allauth.socialaccount.providers.google", - "allauth.socialaccount.providers.facebook", - "allauth.socialaccount.providers.twitter", "corsheaders", "uk_results", "bulk_adding", @@ -177,37 +171,20 @@ def root(*path): "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "candidates.middleware.DisableCachingForAuthenticatedUsers", + "wombles.middleware.CheckProfileDetailsMiddleware", ) -# django-allauth settings: AUTHENTICATION_BACKENDS = ( - # Needed to login by username in Django admin, regardless of `allauth` + "sesame.backends.ModelBackend", "django.contrib.auth.backends.ModelBackend", - # `allauth` specific authentication methods, such as login by e-mail - "allauth.account.auth_backends.AuthenticationBackend", ) -SOCIALACCOUNT_PROVIDERS = { - "google": { - "SCOPE": ["https://www.googleapis.com/auth/userinfo.profile"], - "AUTH_PARAMS": {"access_type": "online"}, - }, - "facebook": {"SCOPE": ["email"]}, -} +SESAME_MAX_AGE = 60 * 10 +SESAME_ONE_TIME = False +SESAME_TOKEN_NAME = "login_token" LOGIN_REDIRECT_URL = "/" -ACCOUNT_AUTHENTICATION_METHOD = "username_email" -SOCIALACCOUNT_ADAPTER = "ynr.account_adapter.LoggingSocialAccountAdapter" -ACCOUNT_EMAIL_VERIFICATION = "mandatory" -ACCOUNT_EMAIL_REQUIRED = True -ACCOUNT_FORMS = { - "login": "ynr.forms.CustomLoginForm", - "signup": "ynr.forms.CustomSignupForm", -} -ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION = True -ACCOUNT_USERNAME_REQUIRED = True -ACCOUNT_USERNAME_VALIDATORS = "ynr.helpers.allauth_validators" -SOCIALACCOUNT_AUTO_SIGNUP = True + ROOT_URLCONF = "ynr.urls" WSGI_APPLICATION = "ynr.wsgi.application" diff --git a/ynr/templates/account/login.html b/ynr/templates/account/login.html deleted file mode 100644 index 41e38f7b1..000000000 --- a/ynr/templates/account/login.html +++ /dev/null @@ -1,43 +0,0 @@ -{% extends "base.html" %} - - -{% load account socialaccount %} - -{% block head_title %}Sign In{% endblock %} - -{% block hero %} -

Sign in

-{% endblock %} - -{% block content %} - -{% get_providers as socialaccount_providers %} - -{% if socialaccount_providers %} -
- - -
- {% include "socialaccount/snippets/login_extra.html" %} -{% endif %} - -
- I have an account - I don’t have an account -
- -
- {% csrf_token %} - {{ form.as_p }} - {% if redirect_field_value %} - - {% endif %} -

- - Forgot password? -

-
- -{% endblock %} diff --git a/ynr/templates/base.html b/ynr/templates/base.html index d98a43a81..d06baf1e0 100644 --- a/ynr/templates/base.html +++ b/ynr/templates/base.html @@ -42,8 +42,8 @@
diff --git a/ynr/urls.py b/ynr/urls.py index 6a2b41e2b..8feb0f993 100644 --- a/ynr/urls.py +++ b/ynr/urls.py @@ -1,22 +1,10 @@ -from allauth.account.views import SignupView from django.conf import settings from django.conf.urls.static import static from django.contrib import admin from django.contrib.staticfiles.urls import staticfiles_urlpatterns from django.urls import include, path, re_path from django.views.generic import TemplateView - - -class CustomSignupView(SignupView): - """ - Custom signup view that adds the location the user was on before - signup to the session so that on completion of signup it can be - used to redirect the user back to - """ - - def post(self, request, *args, **kwargs): - request.session["next"] = request.POST.get("next") - return super().post(request, *args, **kwargs) +from sesame.views import LoginView as SesameLoginView def trigger_error(request): @@ -33,12 +21,11 @@ def trigger_error(request): re_path(r"^", include("search.urls")), re_path(r"^admin/doc/", include("django.contrib.admindocs.urls")), re_path(r"^admin/", admin.site.urls), - re_path(r"^accounts/signup/", view=CustomSignupView.as_view()), - re_path(r"^accounts/", include("allauth.urls")), + path("sesame/login/", SesameLoginView.as_view(), name="sesame-login"), re_path(r"^upload_document/", include("official_documents.urls")), re_path(r"^results/", include("results.urls")), re_path(r"^duplicates/", include("duplicates.urls")), - re_path(r"^wombles/", include("wombles.urls")), + re_path(r"^accounts/", include("wombles.urls", namespace="wombles")), re_path(r"^data/", include("data_exports.urls")), path( "volunteer/",