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.
+
+ 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 %}
+
+
+{% 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/provider_list.html" with process="login" %}
-
-
Or
-
- {% include "socialaccount/snippets/login_extra.html" %}
-{% endif %}
-
-
-
-
-
-{% 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/",