Authenticate by email with a custom user model

This avoid the weird double username situation where we want to give the
users the choice of their displayed name but at the same time we need them to
never change their CAS username.

Now CAS matches on email so we do not have problems.
This commit is contained in:
Guillaume Bertholon 2020-12-24 14:19:35 +01:00
parent 0d838cafee
commit c836f642fa
25 changed files with 259 additions and 158 deletions

1
accounts/__init__.py Normal file
View file

@ -0,0 +1 @@
default_app_config = "accounts.apps.AccountsConfig"

38
accounts/admin.py Normal file
View file

@ -0,0 +1,38 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
from django.utils.translation import gettext_lazy as _
from .models import User
class UserAdmin(DjangoUserAdmin):
fieldsets = (
(None, {"fields": ("email", "password")}),
(_("Personal info"), {"fields": ("public_name",)}),
(
_("Permissions"),
{
"fields": (
"is_active",
"is_staff",
"is_superuser",
"groups",
"user_permissions",
),
},
),
)
add_fieldsets = (
(
None,
{
"classes": ("wide",),
"fields": ("email", "public_name", "password1", "password2"),
},
),
)
list_display = ("public_name", "email", "is_staff")
search_fields = ("public_name", "email")
ordering = ("email",)
admin.site.register(User, UserAdmin)

6
accounts/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
name = "accounts"
verbose_name = "Comptes utilisateur·ice·s"

7
accounts/backends.py Normal file
View file

@ -0,0 +1,7 @@
from django_cas_ng.backends import CASBackend as DjangoCasNgBackend
class CasBackend(DjangoCasNgBackend):
def clean_username(self, username):
# We need to build an email out of the CAS username
return username.lower() + "@clipper.ens.fr"

19
accounts/forms.py Normal file
View file

@ -0,0 +1,19 @@
from django.forms import ModelForm, ValidationError
from .models import User
class AccountSettingsForm(ModelForm):
class Meta:
model = User
fields = ["public_name"]
def clean_public_name(self):
public_name = self.cleaned_data["public_name"]
public_name = public_name.strip()
if (
User.objects.filter(public_name=public_name)
.exclude(pk=self.instance.pk)
.exists()
):
raise ValidationError("Un autre compte utilise déjà ce nom ou ce pseudo.")
return public_name

View file

@ -0,0 +1,34 @@
# Generated by Django 3.1.2 on 2020-12-24 00:21
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('email', models.EmailField(max_length=254, unique=True, verbose_name='adresse email')),
('public_name', models.CharField(help_text='Ce nom est utilisé pour toutes les interactions publiques sur GestioJeux. Il doit être unique.', max_length=150, unique=True, verbose_name='nom ou pseudo')),
('is_staff', models.BooleanField(default=False, help_text="Précise si lutilisateur peut se connecter à ce site d'administration.", verbose_name='statut équipe')),
('is_active', models.BooleanField(default=True, help_text='Précise si lutilisateur doit être considéré comme actif. Décochez ceci plutôt que de supprimer le compte.', verbose_name='actif')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'utilisateur·ice',
'verbose_name_plural': 'utilisateur·ice·s',
},
),
]

76
accounts/models.py Normal file
View file

@ -0,0 +1,76 @@
from django.db import models
from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager
from django.contrib.auth.models import PermissionsMixin
from django.core.mail import send_mail
class User(AbstractBaseUser, PermissionsMixin):
USERNAME_FIELD = "email"
EMAIL_FIELD = "email"
REQUIRED_FIELDS = ["public_name"]
MAX_NAME_LENGTH = 150
email = models.EmailField(verbose_name="adresse email", unique=True)
public_name = models.CharField(
verbose_name="nom ou pseudo",
max_length=MAX_NAME_LENGTH,
unique=True,
help_text="Ce nom est utilisé pour toutes les interactions publiques sur GestioJeux. Il doit être unique.",
)
is_staff = models.BooleanField(
"statut équipe",
default=False,
help_text="Précise si lutilisateur peut se connecter à ce site d'administration.",
)
is_active = models.BooleanField(
"actif",
default=True,
help_text="Précise si lutilisateur doit être considéré comme actif. Décochez ceci plutôt que de supprimer le compte.",
)
objects = BaseUserManager()
class Meta:
verbose_name = "utilisateur·ice"
verbose_name_plural = "utilisateur·ice·s"
@classmethod
def generate_unique_public_name(cls, base_name):
index = 0
public_name = base_name
while cls.objects.filter(public_name=public_name).exists():
index += 1
# ensure the resulting string is not too long
tail_length = len(str(index))
combined_length = len(base_name) + tail_length
if cls.MAX_NAME_LENGTH < combined_length:
base_name = base_name[: cls.MAX_NAME_LENGTH - tail_length]
public_name = base_name + str(index)
return public_name
def save(self, *args, **kwargs):
if not self.public_name:
# Fill the public name with a generated one from email address
base_name = self.email.split("@")[0]
self.public_name = User.generate_unique_public_name(base_name)
super().save(*args, **kwargs)
def get_full_name(self):
return self.public_name
def get_short_name(self):
return self.public_name
def email_user(self, subject, message, from_email=None, **kwargs):
"""Send an email to this user."""
send_mail(subject, message, from_email, [self.email], **kwargs)
@classmethod
def normalize_username(cls, username):
return super().normalize_username(username.lower())
def __str__(self):
return self.public_name

View file

@ -3,7 +3,7 @@
{% block "content" %}
<h1>Paramètres du compte</h1>
<p>Vous êtes connecté en tant que <tt>{{ request.user.username }}</tt></p>
<p>Vous êtes connecté en tant que <tt>{{ request.user.email }}</tt></p>
<form method="post">
{% csrf_token %}
@ -14,7 +14,7 @@
{% if request.user.password and request.user.has_usable_password %}
<hr/>
<a href="{% url "gestiojeux_auth:change_password" %}">Changer mon mot de passe</a>
<a href="{% url "accounts:change_password" %}">Changer mon mot de passe</a>
{% endif %}
{% endblock %}

View file

@ -3,7 +3,7 @@ import django.contrib.auth.views as dj_auth_views
from .views import LoginView, LogoutView, PasswordChangeView, AccountSettingsView
import django_cas_ng.views
app_name = "gestiojeux_auth"
app_name = "accounts"
cas_patterns = [
path("login/", django_cas_ng.views.LoginView.as_view(), name="cas_ng_login"),
@ -21,7 +21,7 @@ accounts_patterns = [
path("logout/", LogoutView.as_view(), name="logout"),
path("password_login/", dj_auth_views.LoginView.as_view(), name="password_login"),
path("change_password/", PasswordChangeView.as_view(), name="change_password"),
path("account_settings/", AccountSettingsView.as_view(), name="account_settings"),
path("settings/", AccountSettingsView.as_view(), name="account_settings"),
]
urlpatterns = [

View file

@ -19,12 +19,6 @@ class LoginView(TemplateView):
def dispatch(self, request, *args, **kwargs):
if request.user.is_authenticated:
messages.warning(
request,
"Vous êtes déjà connecté·e en tant que {}.".format(
request.user.username
),
)
return redirect(self.get_next_url() or "/")
return super().dispatch(request, *args, **kwargs)
@ -42,14 +36,14 @@ class LoginView(TemplateView):
next_url = self.get_next_url()
if next_url:
context["pass_url"] = "{}?next={}".format(
reverse("gestiojeux_auth:password_login"), urlquote(next_url, safe="")
reverse("accounts:password_login"), urlquote(next_url, safe="")
)
context["cas_url"] = "{}?next={}".format(
reverse("gestiojeux_auth:cas_ng_login"), urlquote(next_url, safe="")
reverse("accounts:cas_ng_login"), urlquote(next_url, safe="")
)
else:
context["pass_url"] = reverse("gestiojeux_auth:password_login")
context["cas_url"] = reverse("gestiojeux_auth:cas_ng_login")
context["pass_url"] = reverse("accounts:password_login")
context["cas_url"] = reverse("accounts:cas_ng_login")
return context
@ -58,7 +52,7 @@ class LogoutView(RedirectView):
permanent = False
def get_redirect_url(self, *args, **kwargs):
CAS_BACKEND_NAME = "django_cas_ng.backends.CASBackend"
CAS_BACKEND_NAME = "accounts.backends.CasBackend"
if self.request.session["_auth_user_backend"] != CAS_BACKEND_NAME:
auth_logout(self.request)
if "next" in self.request.GET:
@ -67,10 +61,10 @@ class LogoutView(RedirectView):
if "next" in self.request.GET:
return "{}?next={}".format(
reverse("gestiojeux_auth:cas_ng_logout"),
reverse("accounts:cas_ng_logout"),
urlquote(self.request.GET["next"], safe=""),
)
return reverse("gestiojeux_auth:cas_ng_logout")
return reverse("accounts:cas_ng_logout")
@receiver(user_logged_in)
@ -93,7 +87,7 @@ class PasswordChangeView(PasswordChangeView):
def get_success_url(self):
messages.info(self.request, "Mot de passe mis à jour")
return reverse("gestiojeux_auth:account_settings")
return reverse("accounts:account_settings")
class AccountSettingsView(LoginRequiredMixin, UpdateView):

View file

@ -30,7 +30,7 @@ INSTALLED_APPS = [
"mainsite",
"inventory",
"django_cas_ng",
"gestiojeux_auth",
"accounts",
]
MIDDLEWARE = [
@ -64,9 +64,11 @@ TEMPLATES = [
WSGI_APPLICATION = "gestiojeux.wsgi.application"
AUTH_USER_MODEL = "accounts.User"
AUTHENTICATION_BACKENDS = (
"django.contrib.auth.backends.ModelBackend",
"django_cas_ng.backends.CASBackend",
"accounts.backends.CasBackend",
)
# Password validation
@ -81,7 +83,7 @@ AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
LOGIN_URL = "gestiojeux_auth:login"
LOGIN_URL = "accounts:login"
# Use markdown extensions
MARKDOWNX_MARKDOWN_EXTENSIONS = [
@ -112,7 +114,6 @@ CAS_SERVER_URL = "https://cas.eleves.ens.fr/"
CAS_VERIFY_URL = "https://cas.eleves.ens.fr/"
CAS_VERSION = "CAS_2_SAML_1_0"
CAS_IGNORE_REFERER = True
CAS_FORCE_CHANGE_USERNAME_CASE = "lower"
CAS_LOGIN_MSG = None
CAS_LOGIN_URL_NAME = "gestiojeux_auth:cas_ng_login"
CAS_LOGOUT_URL_NAME = "gestiojeux_auth:cas_ng_logout"
CAS_LOGIN_URL_NAME = "accounts:cas_ng_login"
CAS_LOGOUT_URL_NAME = "accounts:cas_ng_logout"

View file

@ -22,7 +22,7 @@ urlpatterns = [
path("admin/", admin.site.urls),
path("markdownx/", include("markdownx.urls")),
path("inventory/", include("inventory.urls")),
path("auth/", include("gestiojeux_auth.urls")),
path("account/", include("accounts.urls")),
path("", include("mainsite.urls")),
]

View file

@ -1,5 +0,0 @@
from django.apps import AppConfig
class GestiojeuxAuthConfig(AppConfig):
name = "gestiojeux_auth"

View file

@ -1,24 +0,0 @@
from django.forms import ModelForm, ValidationError
from django.contrib.auth.models import User
class AccountSettingsForm(ModelForm):
class Meta:
model = User
fields = ["first_name"]
labels = {"first_name": "Nom ou pseudo"}
help_texts = {
"first_name": "Ce nom sera utilisé pour toutes vos interactions publiques sur GestioJeux. Si laissé vide, votre login sera utilisé à la place."
}
def clean_first_name(self):
""" Check there is no conflict that could lead to imprersonation """
public_name = self.cleaned_data["first_name"]
public_name = public_name.strip()
if public_name == self.instance.first_name or public_name == "":
return public_name
if User.objects.filter(first_name=public_name).count() > 0:
raise ValidationError("Un autre compte utilise déjà ce nom ou ce pseudo.")
if User.objects.filter(username=public_name).count() > 0:
raise ValidationError("Ce nom est déjà le login de quelqu'un.")
return public_name

View file

@ -1,6 +1,7 @@
# Generated by Django 3.1.2 on 2020-11-21 18:17
# Generated by Django 3.1.2 on 2020-12-21 01:20
import autoslug.fields
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
@ -10,6 +11,7 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
@ -22,6 +24,31 @@ class Migration(migrations.Migration):
],
options={
'verbose_name': 'catégorie',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Game',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=256, verbose_name='titre')),
('slug', autoslug.fields.AutoSlugField(editable=False, populate_from='title', unique=True)),
('nb_player_min', models.PositiveSmallIntegerField(verbose_name='nombre de joueur·se·s minimum')),
('nb_player_max', models.PositiveSmallIntegerField(verbose_name='nombre de joueur·se·s maximum')),
('player_range', models.CharField(blank=True, help_text='Affichage personnalisé pour le nombre de joueur·se·s', max_length=256, verbose_name='nombre de joueur·se·s')),
('duration', models.CharField(blank=True, max_length=256, verbose_name='durée de partie')),
('game_designer', models.CharField(blank=True, max_length=256, verbose_name='game designer')),
('illustrator', models.CharField(blank=True, max_length=256, verbose_name='illustrateur·trice')),
('editor', models.CharField(blank=True, max_length=256, verbose_name='éditeur')),
('description', models.TextField(blank=True, verbose_name='description')),
('image', models.ImageField(blank=True, upload_to='game_img/', verbose_name='image')),
('missing_elements', models.TextField(blank=True, verbose_name='pièces manquantes')),
('category', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, to='inventory.category', verbose_name='catégorie')),
],
options={
'verbose_name': 'jeu',
'verbose_name_plural': 'jeux',
'ordering': ['title'],
},
),
migrations.CreateModel(
@ -31,25 +58,29 @@ class Migration(migrations.Migration):
('name', models.CharField(max_length=256, verbose_name='nom')),
('slug', autoslug.fields.AutoSlugField(editable=False, populate_from='name', unique=True)),
],
),
migrations.CreateModel(
name='Game',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=256, verbose_name='titre')),
('slug', autoslug.fields.AutoSlugField(editable=False, populate_from='title', unique=True)),
('player_range', models.CharField(max_length=256, verbose_name='nombre de joueur·se·s')),
('duration', models.CharField(max_length=256, verbose_name='durée de partie')),
('editor', models.CharField(blank=True, max_length=256, verbose_name='éditeur')),
('game_designer', models.CharField(blank=True, max_length=256, verbose_name='game designer')),
('description', models.TextField(blank=True, verbose_name='description')),
('image', models.ImageField(blank=True, upload_to='game_img/', verbose_name='image')),
('category', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, to='inventory.category', verbose_name='catégorie')),
('tags', models.ManyToManyField(blank=True, to='inventory.Tag', verbose_name='tags')),
],
options={
'verbose_name': 'jeu',
'verbose_name_plural': 'jeux',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='GameComment',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('text', models.TextField(verbose_name='texte')),
('created_on', models.DateTimeField(auto_now_add=True, verbose_name='date de publication')),
('modified_on', models.DateTimeField(auto_now=True, verbose_name='date de modification')),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='auteur·ice')),
('game', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='inventory.game', verbose_name='jeu')),
],
options={
'verbose_name': 'commentaire sur un jeu',
'verbose_name_plural': 'commentaires sur des jeux',
'ordering': ['created_on'],
},
),
migrations.AddField(
model_name='game',
name='tags',
field=models.ManyToManyField(blank=True, to='inventory.Tag', verbose_name='tags'),
),
]

View file

@ -1,76 +0,0 @@
# Generated by Django 3.1.2 on 2020-12-12 21:33
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('inventory', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='category',
options={'ordering': ['name'], 'verbose_name': 'catégorie'},
),
migrations.AlterModelOptions(
name='game',
options={'ordering': ['title'], 'verbose_name': 'jeu', 'verbose_name_plural': 'jeux'},
),
migrations.AlterModelOptions(
name='tag',
options={'ordering': ['name']},
),
migrations.AddField(
model_name='game',
name='illustrator',
field=models.CharField(blank=True, max_length=256, verbose_name='illustrateur·trice'),
),
migrations.AddField(
model_name='game',
name='missing_elements',
field=models.TextField(blank=True, verbose_name='pièces manquantes'),
),
migrations.AddField(
model_name='game',
name='nb_player_max',
field=models.PositiveSmallIntegerField(default=20, verbose_name='nombre de joueur·se·s maximum'),
preserve_default=False,
),
migrations.AddField(
model_name='game',
name='nb_player_min',
field=models.PositiveSmallIntegerField(default=2, verbose_name='nombre de joueur·se·s minimum'),
preserve_default=False,
),
migrations.AlterField(
model_name='game',
name='duration',
field=models.CharField(blank=True, max_length=256, verbose_name='durée de partie'),
),
migrations.AlterField(
model_name='game',
name='player_range',
field=models.CharField(blank=True, help_text='Affichage personnalisé pour le nombre de joueur·se·s', max_length=256, verbose_name='nombre de joueur·se·s'),
),
migrations.CreateModel(
name='GameComment',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('text', models.TextField(verbose_name='texte')),
('created_on', models.DateTimeField(auto_now_add=True, verbose_name='date de publication')),
('modified_on', models.DateTimeField(auto_now=True, verbose_name='date de modification')),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='auteur·ice')),
('game', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='inventory.game', verbose_name='jeu')),
],
options={
'verbose_name': 'commentaire sur un jeu',
'verbose_name_plural': 'commentaires sur des jeux',
'ordering': ['created_on'],
},
),
]

View file

@ -1,8 +1,8 @@
from django.db import models
from django.urls import reverse
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from autoslug import AutoSlugField
from accounts.models import User
class Category(models.Model):

View file

@ -42,7 +42,7 @@
{% if comment == edited_comment %}
<form id="edited_comment" class="comment" method="post">
<div class="meta">
<span class="author">{% include "partials/user.html" with user=comment.author %}</span>
<span class="author">{{ comment.author }}</span>
<span class="date">posté le {{ comment.created_on|date }} à {{ comment.created_on|time }}</span>
<a href="{% url "inventory:game" game.slug %}" title="Annuler la modification"><i class="fa fa-ban" aria-hidden="true"></i></a>
</div>
@ -54,7 +54,7 @@
{% else %}
<div class="comment">
<div class="meta">
<span class="author">{% include "partials/user.html" with user=comment.author %}</span>
<span class="author">{{ comment.author }}</span>
<span class="date">posté le {{ comment.created_on|date }} à {{ comment.created_on|time }}{% if comment.created_on|date:"YmdHi" != comment.modified_on|date:"YmdHi" %}, dernière modification le {{ comment.modified_on|date }} à {{ comment.modified_on|time }}{% endif %}</span>
{% if comment.author == request.user %}
<a href="{% url "inventory:modify_game_comment" game.slug comment.id %}#edited_comment" title="Éditer mon commentaire"><i class="fa fa-pencil" aria-hidden="true"></i></a>
@ -74,7 +74,7 @@
<button type="submit"><i class="fa fa-paper-plane" aria-hidden="true"></i> Envoyer le commentaire</button>
</form>
{% else %}
<a href="{% url "gestiojeux_auth:login" %}?next={{ request.get_full_path }}">Connectez-vous</a> pour ajouter un commentaire.
<a href="{% url "accounts:login" %}?next={{ request.get_full_path }}">Connectez-vous</a> pour ajouter un commentaire.
{% endif %}
{% endif %}
{% endblock %}

View file

@ -5,7 +5,7 @@
<p>Vous n'avez pas la permission pour consulter cette page.</p>
{% if not user.is_authenticated %}
<p>Cet accès vous est probablement refusé car vous n'êtes actuellement pas connecté·e.
Vous pouvez vous rendre à la page de <a href="{% url "registration:login" %}">connexion</a>.</p>
Vous pouvez vous rendre à la page de <a href="{% url "accounts:login" %}?next={{ request.get_full_path }}">connexion</a>.</p>
{% endif %}
<p>Vous pouvez retourner sur la <a href="{% url "mainsite:home" %}">page d'accueil</a>.</p>
{% endblock %}

View file

@ -11,10 +11,10 @@
{% endif %}
</nav>
{% if request.user.is_authenticated %}
<a class="username" href="{% url "gestiojeux_auth:account_settings" %}">{% include "./user.html" with user=request.user %}</a>
<a class="login" href="{% url "gestiojeux_auth:logout" %}?next={{ request.get_full_path }}"><i class="fa fa-sign-out" aria-hidden="true"></i></a>
<a class="username" href="{% url "accounts:account_settings" %}">{{ request.user }}</a>
<a class="login" href="{% url "accounts:logout" %}?next={{ request.get_full_path }}"><i class="fa fa-sign-out" aria-hidden="true"></i></a>
{% else %}
<a class="login{% if url_name == "login" %} current{% endif %}" href="{% url "gestiojeux_auth:login" %}?next={{ request.get_full_path }}">Connexion</a>
<a class="login{% if url_name == "login" %} current{% endif %}" href="{% url "accounts:login" %}?next={{ request.get_full_path }}">Connexion</a>
{% endif %}
{% endwith %}
</header>

View file

@ -1 +0,0 @@
{% if user.first_name %}{{ user.first_name }}{% else %}{{ user.username }}{% endif %}