From f6b2225eb89cd6a2bf907d3befaa81d18b2e3385 Mon Sep 17 00:00:00 2001 From: Hugo Levy-Falk Date: Thu, 23 Apr 2020 22:32:01 +0200 Subject: [PATCH] Fix #261 --- users/admin.py | 8 ++ users/api/serializers.py | 13 ++- users/api/urls.py | 6 +- users/api/views.py | 12 ++- users/forms.py | 45 +++++++---- users/migrations/0092_auto_20200423_1804.py | 54 +++++++++++++ users/migrations/0093_auto_20200423_2028.py | 18 +++++ users/migrations/0094_auto_20200423_2030.py | 23 ++++++ users/models.py | 88 ++++++++++++++++++--- users/templates/users/aff_sshkeys.html | 60 ++++++++++++++ users/templates/users/profil.html | 28 +++++++ users/urls.py | 9 ++- users/views.py | 52 ++++++++++++ 13 files changed, 380 insertions(+), 36 deletions(-) create mode 100644 users/migrations/0092_auto_20200423_1804.py create mode 100644 users/migrations/0093_auto_20200423_2028.py create mode 100644 users/migrations/0094_auto_20200423_2030.py create mode 100644 users/templates/users/aff_sshkeys.html diff --git a/users/admin.py b/users/admin.py index f3ec04f4..6d16bdd3 100644 --- a/users/admin.py +++ b/users/admin.py @@ -48,6 +48,7 @@ from .models import ( LdapServiceUser, LdapServiceUserGroup, LdapUserGroup, + SSHKey, ) from .forms import ( UserChangeForm, @@ -130,6 +131,12 @@ class WhitelistAdmin(VersionAdmin): pass +class SSHKeyAdmin(VersionAdmin): + """SSHKey model for admin.""" + + pass + + class UserAdmin(VersionAdmin, BaseUserAdmin): """Gestion d'un user : modification des champs perso, mot de passe, etc""" @@ -224,6 +231,7 @@ admin.site.register(Ban, BanAdmin) admin.site.register(EMailAddress, EMailAddressAdmin) admin.site.register(Whitelist, WhitelistAdmin) admin.site.register(Request, RequestAdmin) +admin.site.register(SSHKey, SSHKeyAdmin) # Now register the new UserAdmin... admin.site.unregister(User) admin.site.unregister(ServiceUser) diff --git a/users/api/serializers.py b/users/api/serializers.py index e4c6c7e0..2e51a43d 100644 --- a/users/api/serializers.py +++ b/users/api/serializers.py @@ -24,6 +24,7 @@ from rest_framework import serializers import users.models as users from api.serializers import NamespacedHRField, NamespacedHIField, NamespacedHMSerializer + class UserSerializer(NamespacedHMSerializer): """Serialize `users.models.User` objects. """ @@ -210,7 +211,7 @@ class EMailAddressSerializer(NamespacedHMSerializer): class Meta: model = users.EMailAddress fields = ("user", "local_part", "complete_email_address", "api_url") - + class LocalEmailUsersSerializer(NamespacedHMSerializer): email_address = EMailAddressSerializer(read_only=True, many=True) @@ -241,4 +242,12 @@ class MailingSerializer(ClubSerializer): admins = MailingMemberSerializer(source="administrators", many=True) class Meta(ClubSerializer.Meta): - fields = ("name", "members", "admins") \ No newline at end of file + fields = ("name", "members", "admins") + + +class SSHKeySerializer(NamespacedHMSerializer): + """Serialize an SSHKey.""" + + class Meta: + model = users.SSHKey + fields = "__all__" diff --git a/users/api/urls.py b/users/api/urls.py index ef2b01b1..2ae51bc9 100644 --- a/users/api/urls.py +++ b/users/api/urls.py @@ -34,16 +34,16 @@ urls_viewset = [ (r"users/shell", views.ShellViewSet, "shell"), (r"users/ban", views.BanViewSet, None), (r"users/whitelist", views.WhitelistViewSet, None), - (r"users/emailaddress", views.EMailAddressViewSet, None) + (r"users/emailaddress", views.EMailAddressViewSet, None), + (r"users/sshkey", views.SSHKeyViewSet, None), ] urls_view = [ (r"users/localemail", views.LocalEmailUsersView), (r"users/mailing-standard", views.StandardMailingView), (r"users/mailing-club", views.ClubMailingView), - # Deprecated (r"localemail/users", views.LocalEmailUsersView), (r"mailing/standard", views.StandardMailingView), (r"mailing/club", views.ClubMailingView), -] \ No newline at end of file +] diff --git a/users/api/views.py b/users/api/views.py index 57571f70..e3c8b8fc 100644 --- a/users/api/views.py +++ b/users/api/views.py @@ -169,7 +169,7 @@ class StandardMailingView(views.APIView): adherents_data = serializers.MailingMemberSerializer( all_has_access(), many=True ).data - + data = [{"name": "adherents", "members": adherents_data}] groups = Group.objects.all() for group in groups: @@ -189,4 +189,12 @@ class ClubMailingView(generics.ListAPIView): """ queryset = users.Club.objects.all() - serializer_class = serializers.MailingSerializer \ No newline at end of file + serializer_class = serializers.MailingSerializer + + +class SSHKeyViewSet(viewsets.ReadOnlyModelViewSet): + """Exposes list and details of `users.models.SSHKey` objects. + """ + + queryset = users.SSHKey.objects.all() + serializer_class = serializers.SSHKeySerializer diff --git a/users/forms.py b/users/forms.py index d9d68c88..b9712afb 100644 --- a/users/forms.py +++ b/users/forms.py @@ -38,7 +38,10 @@ from __future__ import unicode_literals from django import forms from django.forms import ModelForm, Form from django.contrib.auth.forms import ReadOnlyPasswordHashField -from django.contrib.auth.password_validation import validate_password, password_validators_help_text_html +from django.contrib.auth.password_validation import ( + validate_password, + password_validators_help_text_html, +) from django.core.validators import MinLengthValidator from django.utils import timezone from django.utils.functional import lazy @@ -69,6 +72,7 @@ from .models import ( Ban, Adherent, Club, + SSHKey, ) @@ -84,7 +88,7 @@ class PassForm(FormRevMixin, FieldPermissionFormMixin, forms.ModelForm): label=_("New password"), max_length=255, widget=forms.PasswordInput, - help_text=password_validators_help_text_html() + help_text=password_validators_help_text_html(), ) passwd2 = forms.CharField( label=_("New password confirmation"), @@ -133,12 +137,10 @@ class UserCreationForm(FormRevMixin, forms.ModelForm): label=_("Password"), widget=forms.PasswordInput, max_length=255, - help_text=password_validators_help_text_html() + help_text=password_validators_help_text_html(), ) password2 = forms.CharField( - label=_("Password confirmation"), - widget=forms.PasswordInput, - max_length=255, + label=_("Password confirmation"), widget=forms.PasswordInput, max_length=255, ) is_admin = forms.BooleanField(label=_("Is admin")) @@ -287,9 +289,7 @@ class MassArchiveForm(forms.Form): date = forms.DateTimeField(help_text="%d/%m/%y") full_archive = forms.BooleanField( - label=_( - "Fully archive users? WARNING: CRITICAL OPERATION IF TRUE" - ), + label=_("Fully archive users? WARNING: CRITICAL OPERATION IF TRUE"), initial=False, required=False, ) @@ -380,6 +380,7 @@ class AdherentCreationForm(AdherentForm): AdherentForm auquel on ajoute une checkbox afin d'éviter les doublons d'utilisateurs et, optionnellement, un champ mot de passe""" + # Champ pour choisir si un lien est envoyé par mail pour le mot de passe init_password_by_mail_info = _( "If this options is set, you will receive a link to set" @@ -392,9 +393,7 @@ class AdherentCreationForm(AdherentForm): ) init_password_by_mail = forms.BooleanField( - help_text=init_password_by_mail_info, - required=False, - initial=True + help_text=init_password_by_mail_info, required=False, initial=True ) init_password_by_mail.label = _("Send password reset link by email.") @@ -405,7 +404,7 @@ class AdherentCreationForm(AdherentForm): label=_("Password"), widget=forms.PasswordInput, max_length=255, - help_text=password_validators_help_text_html() + help_text=password_validators_help_text_html(), ) password2 = forms.CharField( required=False, @@ -482,8 +481,12 @@ class AdherentCreationForm(AdherentForm): # Save the provided password in hashed format user = super(AdherentForm, self).save(commit=False) - is_set_password_allowed = OptionalUser.get_cached_value("allow_set_password_during_user_creation") - set_passwd = is_set_password_allowed and not self.cleaned_data.get("init_password_by_mail") + is_set_password_allowed = OptionalUser.get_cached_value( + "allow_set_password_during_user_creation" + ) + set_passwd = is_set_password_allowed and not self.cleaned_data.get( + "init_password_by_mail" + ) if set_passwd: user.set_password(self.cleaned_data["password1"]) @@ -886,3 +889,15 @@ class InitialRegisterForm(forms.Form): if self.cleaned_data["register_machine"]: if self.mac_address and self.nas_type: self.user.autoregister_machine(self.mac_address, self.nas_type) + + +class SSHKeyForm(FormRevMixin, ModelForm): + """Create or edit an SSHKey""" + + def __init__(self, *args, **kwargs): + prefix = kwargs.pop("prefix", self.Meta.model.__name__) + super(SSHKeyForm, self).__init__(*args, prefix=prefix, **kwargs) + + class Meta: + model = SSHKey + exclude = ["user"] diff --git a/users/migrations/0092_auto_20200423_1804.py b/users/migrations/0092_auto_20200423_1804.py new file mode 100644 index 00000000..c49d65ed --- /dev/null +++ b/users/migrations/0092_auto_20200423_1804.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.28 on 2020-04-23 16:04 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import ldapdb.models.fields +import re2o.mixins + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0091_auto_20200423_1256"), + ] + + operations = [ + migrations.CreateModel( + name="SSHKey", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("key", models.TextField(blank=True, verbose_name="Public ssh key")), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "SSH key", + "verbose_name_plural": "SSH keys", + "permissions": (("view_sshkey", "Can view an SSHKey object"),), + }, + bases=(re2o.mixins.RevMixin, re2o.mixins.AclMixin, models.Model), + ), + migrations.AddField( + model_name="ldapuser", + name="ssh_keys", + field=ldapdb.models.fields.ListField( + blank=True, db_column="sshkeys", null=True + ), + ), + ] diff --git a/users/migrations/0093_auto_20200423_2028.py b/users/migrations/0093_auto_20200423_2028.py new file mode 100644 index 00000000..99010e6f --- /dev/null +++ b/users/migrations/0093_auto_20200423_2028.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.28 on 2020-04-23 18:28 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0092_auto_20200423_1804"), + ] + + operations = [ + migrations.RenameField( + model_name="ldapuser", old_name="ssh_keys", new_name="sshkeys", + ), + ] diff --git a/users/migrations/0094_auto_20200423_2030.py b/users/migrations/0094_auto_20200423_2030.py new file mode 100644 index 00000000..a642d823 --- /dev/null +++ b/users/migrations/0094_auto_20200423_2030.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.28 on 2020-04-23 18:30 +from __future__ import unicode_literals + +from django.db import migrations +import ldapdb.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0093_auto_20200423_2028"), + ] + + operations = [ + migrations.AlterField( + model_name="ldapuser", + name="sshkeys", + field=ldapdb.models.fields.ListField( + blank=True, db_column="sshkeys", max_length=200, null=True + ), + ), + ] diff --git a/users/models.py b/users/models.py index 9912893e..b76e1713 100755 --- a/users/models.py +++ b/users/models.py @@ -105,7 +105,7 @@ def linux_user_validator(login): pas les contraintes unix (maj, min, chiffres ou tiret)""" if not linux_user_check(login): raise forms.ValidationError( - _("The username \"%(label)s\" contains forbidden characters."), + _('The username "%(label)s" contains forbidden characters.'), params={"label": login}, ) @@ -405,7 +405,10 @@ class User( @cached_property def get_shadow_expire(self): """Return the shadow_expire value for the user""" - if self.state == self.STATE_DISABLED or self.email_state == self.EMAIL_STATE_UNVERIFIED: + if ( + self.state == self.STATE_DISABLED + or self.email_state == self.EMAIL_STATE_UNVERIFIED + ): return str(0) else: return None @@ -670,7 +673,12 @@ class User( self.full_archive() def ldap_sync( - self, base=True, access_refresh=True, mac_refresh=True, group_refresh=False + self, + base=True, + access_refresh=True, + mac_refresh=True, + group_refresh=False, + sshkeys_refresh=False, ): """ Synchronisation du ldap. Synchronise dans le ldap les attributs de self @@ -734,6 +742,10 @@ class User( for group in Group.objects.all(): if hasattr(group, "listright"): group.listright.ldap_sync() + if sshkeys_refresh: + user_ldap.sshkeys = [ + str(key.key) for key in SSHKey.objects.filter(user=self) + ] user_ldap.save() def ldap_del(self): @@ -1051,7 +1063,7 @@ class User( False, _( "Impossible to edit the organisation's" - " user without the \"change_all_users\" right." + ' user without the "change_all_users" right.' ), ("users.change_all_users",), ) @@ -1120,7 +1132,8 @@ class User( if not ( ( self.pk == user_request.pk - and OptionalUser.get_cached_value("self_room_policy") != OptionalUser.DISABLED + and OptionalUser.get_cached_value("self_room_policy") + != OptionalUser.DISABLED ) or user_request.has_perm("users.change_user") ): @@ -1263,7 +1276,7 @@ class User( can = user_request.is_superuser return ( can, - _("\"superuser\" right required to edit the superuser flag.") + _('"superuser" right required to edit the superuser flag.') if not can else None, [], @@ -1357,9 +1370,7 @@ class User( # Allow empty emails only if the user had an empty email before is_created = not self.pk if not self.email and (self.__original_email or is_created): - raise forms.ValidationError( - _("Email field cannot be empty.") - ) + raise forms.ValidationError(_("Email field cannot be empty.")) self.email = self.email.lower() @@ -1432,14 +1443,12 @@ class Adherent(User): a user or if the `options.all_can_create` is set. """ if not user_request.is_authenticated: - if not OptionalUser.get_cached_value( - "self_adhesion" - ): + if not OptionalUser.get_cached_value("self_adhesion"): return False, _("Self registration is disabled."), None else: return True, None, None else: - if OptionalUser.get_cached_value("all_can_create_adherent"): + if OptionalUser.get_cached_value("all_can_create_adherent"): return True, None, None else: can = user_request.has_perm("users.add_user") @@ -2000,6 +2009,9 @@ class LdapUser(ldapdb.models.Model): shadowexpire = ldapdb.models.fields.CharField( db_column="shadowExpire", blank=True, null=True ) + sshkeys = ldapdb.models.fields.ListField( + db_column="sshkeys", max_length=200, blank=True, null=True + ) def __str__(self): return self.name @@ -2248,3 +2260,53 @@ class EMailAddress(RevMixin, AclMixin, models.Model): if result: raise ValidationError(reason) super(EMailAddress, self).clean(*args, **kwargs) + + +class SSHKey(RevMixin, AclMixin, models.Model): + """Represents an SSH public key belonging to a user.""" + + key = models.TextField(blank=True, verbose_name=_("Public ssh key")) + user = models.ForeignKey(User, on_delete=models.CASCADE) + + class Meta: + permissions = (("view_sshkey", _("Can view an SSHKey object")),) + verbose_name = _("SSH key") + verbose_name_plural = _("SSH keys") + + def can_edit(self, user_request, *_args, **_kwargs): + """Check if a user can edit the SSH key + + Args: + user_request: The user who wants to edit the object. + + Returns: + a message and a boolean which is True if the user can edit + the local email account. + """ + if self.user == user_request or user_request.has_perm("users.edit_sshkey"): + return True, None, None + return ( + False, + _("You don't have the right to edit another user's SSHKey."), + ("users.edit_sshkey",), + ) + + +@receiver(post_save, sender=SSHKey) +def sshkey_post_save(**kwargs): + """Sync LDAP record for user when SSHKey is saved.""" + key = kwargs["instance"] + is_created = kwargs["created"] + user = key.user + user.ldap_sync( + base=False, access_refresh=False, mac_refresh=False, sshkeys_refresh=True + ) + + +@receiver(post_delete, sender=SSHKey) +def sshkey_post_delete(**kwargs): + """Sync LDAP record for user when SSHKey is deleted""" + user = kwargs["instance"].user + user.ldap_sync( + base=False, access_refresh=False, mac_refresh=False, sshkeys_refresh=True + ) diff --git a/users/templates/users/aff_sshkeys.html b/users/templates/users/aff_sshkeys.html new file mode 100644 index 00000000..a4f9c822 --- /dev/null +++ b/users/templates/users/aff_sshkeys.html @@ -0,0 +1,60 @@ +{% comment %} +Re2o est un logiciel d'administration développé initiallement au rezometz. Il +se veut agnostique au réseau considéré, de manière à être installable en +quelques clics. + +Copyright © 2017 Gabriel Détraz +Copyright © 2017 Lara Kermarec +Copyright © 2017 Augustin Lemesle + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along +with this program; if not, write to the Free Software Foundation, Inc., +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +{% endcomment %} + +{% load i18n %} + +{% load acl %} +{% load logs_extra %} + + +{% if sshkeys.paginator %} + {% include 'pagination.html' with list=sshkeys %} +{% endif %} + + + + + + + + + + {% for sshkey in sshkeys %} + + + + {% endfor %} +
{% trans "SSH key" %}
{{ sshkey.key }} + {% can_delete sshkey %} + {% include 'buttons/suppr.html' with href='users:del-sshkey' id=sshkey.id %} + {% acl_end %} + {% history_button sshkey %} + {% can_edit sshkey %} + {% include 'buttons/edit.html' with href='users:edit-sshkey' id=sshkey.id %} + {% acl_end %} +
+ +{% if sshkey.paginator %} + {% include 'pagination.html' with list=sshkey %} +{% endif %} \ No newline at end of file diff --git a/users/templates/users/profil.html b/users/templates/users/profil.html index 4fa780a7..576182fe 100644 --- a/users/templates/users/profil.html +++ b/users/templates/users/profil.html @@ -562,6 +562,34 @@ with this program; if not, write to the Free Software Foundation, Inc., +
+
+

+ + {% trans "SSH keys" %} +

+
+
+
+ {% can_edit users %} + {% can_create SSHKey %} + + + {% trans "Add an SSH key" %} + + {% acl_end %} + {% acl_end %} +
+
+ {% if sshkeys %} + {% include 'users/aff_sshkeys.html' with sshkeys=sshkeys %} + {% else %} +

{% trans "No SSH key" %}

+ {% endif %} +
+
+
+ {% for template in optionnal_templates_list %} {{ template }} {% endfor %} diff --git a/users/urls.py b/users/urls.py index 9ecb3967..4ceb05ad 100644 --- a/users/urls.py +++ b/users/urls.py @@ -44,7 +44,11 @@ urlpatterns = [ url(r"^state/(?P[0-9]+)$", views.state, name="state"), url(r"^groups/(?P[0-9]+)$", views.groups, name="groups"), url(r"^password/(?P[0-9]+)$", views.password, name="password"), - url(r"^confirm_email/(?P[0-9]+)$", views.resend_confirmation_email, name="resend-confirmation-email"), + url( + r"^confirm_email/(?P[0-9]+)$", + views.resend_confirmation_email, + name="resend-confirmation-email", + ), url( r"^del_group/(?P[0-9]+)/(?P[0-9]+)$", views.del_group, @@ -127,4 +131,7 @@ urlpatterns = [ url(r"^$", views.index, name="index"), url(r"^index_clubs/$", views.index_clubs, name="index-clubs"), url(r"^initial_register/$", views.initial_register, name="initial-register"), + url(r"^add_sshkey/(?P[0-9]+)$", views.add_sshkey, name="add-sshkey",), + url(r"^edit_sshkey/(?P[0-9]+)$", views.edit_sshkey, name="edit-sshkey",), + url(r"^del_sshkey/(?P[0-9]+)$", views.del_sshkey, name="del-sshkey",), ] diff --git a/users/views.py b/users/views.py index 8450b9c2..add3f075 100644 --- a/users/views.py +++ b/users/views.py @@ -86,6 +86,7 @@ from .models import ( Club, ListShell, EMailAddress, + SSHKey, ) from .forms import ( BanForm, @@ -110,6 +111,7 @@ from .forms import ( ClubAdminandMembersForm, GroupForm, InitialRegisterForm, + SSHKeyForm, ) @@ -932,6 +934,7 @@ def profil(request, users, **_kwargs): request.GET.get("order"), SortTable.USERS_INDEX_WHITE, ) + sshkeys = users.sshkey_set.all() try: balance = find_payment_method(Paiement.objects.get(is_balance=True)) except Paiement.DoesNotExist: @@ -956,6 +959,7 @@ def profil(request, users, **_kwargs): "local_email_accounts_enabled": ( OptionalUser.objects.first().local_email_accounts_enabled ), + "sshkeys": sshkeys, }, ) @@ -1101,3 +1105,51 @@ def initial_register(request): request, ) + +@login_required +@can_create(SSHKey) +@can_edit(User) +def add_sshkey(request, user, userid): + """Create an SSHKey for the given user.""" + sshkey_instance = SSHKey(user=user) + sshkey = SSHKeyForm(request.POST or None, instance=sshkey_instance) + + if sshkey.is_valid(): + sshkey.save() + messages.success(request, _("The SSH key was added.")) + return redirect(reverse("users:profil", kwargs={"userid": str(userid)})) + + return form( + {"userform": sshkey, "action_name": _("Add")}, "users/user.html", request + ) + + +@login_required +@can_edit(SSHKey) +def edit_sshkey(request, sshkey_instance, **_kwargs): + """Edit an SSHKey for the given user.""" + sshkey = SSHKeyForm(request.POST or None, instance=sshkey_instance) + sshkey.request = request + + if sshkey.is_valid(): + if sshkey.changed_data: + sshkey.save() + messages.success(request, _("The SSH Key was edited.")) + return redirect(reverse("users:profil", kwargs={"userid": str(userid)})) + + return form( + {"userform": sshkey, "action_name": _("Edit")}, "users/user.html", request + ) + + +@login_required +@can_delete(SSHKey) +def del_sshkey(request, sshkey, **_kwargs): + """Delete SSH key.""" + if request.method == "POST": + sshkey.delete() + messages.success(request, _("The SSH key was deleted.")) + return redirect(reverse("users:profil", kwargs={"userid": str(sshkey.user.id)})) + return form( + {"objet": sshkey, "objet_name": _("SSH key")}, "users/delete.html", request + )