From d7af4cd80cdacc8b07cf84ed676dee2f5171488b Mon Sep 17 00:00:00 2001 From: Rodolphe Breard Date: Sat, 27 Jul 2019 17:17:21 +0200 Subject: [PATCH] Add a simple pawword sharing module --- Pipfile | 1 + khaganat/settings.py | 1 + khaganat/urls.py | 5 +- pwdb/__init__.py | 0 pwdb/admin.py | 26 ++++++ pwdb/apps.py | 5 + pwdb/forms.py | 75 +++++++++++++++ pwdb/locale/en/LC_MESSAGES/django.po | 73 +++++++++++++++ pwdb/locale/fr/LC_MESSAGES/django.po | 73 +++++++++++++++ pwdb/migrations/0001_initial.py | 45 +++++++++ pwdb/migrations/__init__.py | 0 pwdb/models.py | 117 ++++++++++++++++++++++++ pwdb/templates/pwdb/authenticate.html | 17 ++++ pwdb/templates/pwdb/list_passwords.html | 35 +++++++ pwdb/tests.py | 3 + pwdb/urls.py | 5 + pwdb/views.py | 23 +++++ 17 files changed, 502 insertions(+), 2 deletions(-) create mode 100644 pwdb/__init__.py create mode 100644 pwdb/admin.py create mode 100644 pwdb/apps.py create mode 100644 pwdb/forms.py create mode 100644 pwdb/locale/en/LC_MESSAGES/django.po create mode 100644 pwdb/locale/fr/LC_MESSAGES/django.po create mode 100644 pwdb/migrations/0001_initial.py create mode 100644 pwdb/migrations/__init__.py create mode 100644 pwdb/models.py create mode 100644 pwdb/templates/pwdb/authenticate.html create mode 100644 pwdb/templates/pwdb/list_passwords.html create mode 100644 pwdb/tests.py create mode 100644 pwdb/urls.py create mode 100644 pwdb/views.py diff --git a/Pipfile b/Pipfile index 56e7c6f..8417176 100644 --- a/Pipfile +++ b/Pipfile @@ -9,6 +9,7 @@ django-bulma = "*" django-npb = "*" markdown = "*" python-decouple = "*" +cryptography = ">=2.7,<2.8" [dev-packages] gunicorn = "*" diff --git a/khaganat/settings.py b/khaganat/settings.py index 8edde1a..a33d2ad 100644 --- a/khaganat/settings.py +++ b/khaganat/settings.py @@ -49,6 +49,7 @@ INSTALLED_APPS = [ "npb.apps.NpbConfig", "nsfw.apps.NsfwConfig", "pages.apps.PagesConfig", + "pwdb.apps.PwdbConfig", "static_extra.apps.KhaganatStaticFilesConfig", ] diff --git a/khaganat/urls.py b/khaganat/urls.py index d750463..b078dea 100644 --- a/khaganat/urls.py +++ b/khaganat/urls.py @@ -25,8 +25,9 @@ urlpatterns += i18n_patterns( path("", index), path("admin/", admin.site.urls), path("account/", include("neluser.urls")), - path("page/", include("pages.urls")), - path("paste/", include("npb.urls", namespace="npb")), path("chat/", include("chat.urls")), path("nsfw/", include("nsfw.urls")), + path("page/", include("pages.urls")), + path("paste/", include("npb.urls", namespace="npb")), + path("password_share/", include("pwdb.urls")), ) diff --git a/pwdb/__init__.py b/pwdb/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pwdb/admin.py b/pwdb/admin.py new file mode 100644 index 0000000..68f46c3 --- /dev/null +++ b/pwdb/admin.py @@ -0,0 +1,26 @@ +from django.contrib import admin +from .models import SharedPassword, SharedPasswordAccess +from .forms import NewSharedPasswordForm, EditSharedPasswordForm + + +class SharedPasswordAdmin(admin.ModelAdmin): + form = NewSharedPasswordForm + exclude = ["iv", "encrypted_password"] + list_display = ("name", "users") + + def get_form(self, request, obj=None, **kwargs): + if obj is None: + kwargs["form"] = NewSharedPasswordForm + else: + kwargs["form"] = EditSharedPasswordForm + return super().get_form(request, obj, **kwargs) + + +admin.site.register(SharedPassword, SharedPasswordAdmin) + + +class SharedPasswordAccessAdmin(admin.ModelAdmin): + list_display = ("password", "user") + + +admin.site.register(SharedPasswordAccess, SharedPasswordAccessAdmin) diff --git a/pwdb/apps.py b/pwdb/apps.py new file mode 100644 index 0000000..a92ed7c --- /dev/null +++ b/pwdb/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class PwdbConfig(AppConfig): + name = "pwdb" diff --git a/pwdb/forms.py b/pwdb/forms.py new file mode 100644 index 0000000..fef241f --- /dev/null +++ b/pwdb/forms.py @@ -0,0 +1,75 @@ +from django.utils.translation import gettext_lazy as _ +from django import forms +from .models import SharedPassword + + +class AuthForm(forms.Form): + pwdb_check = forms.CharField(widget=forms.PasswordInput, label="") + + +class NewSharedPasswordForm(forms.ModelForm): + name = forms.CharField(max_length=512, label=_("Name")) + url = forms.CharField( + max_length=512, widget=forms.URLInput, required=False, label="URL" + ) + description = forms.CharField( + widget=forms.Textarea, required=False, label=_("Description") + ) + password = forms.CharField( + max_length=1024, widget=forms.PasswordInput, label=_("Password") + ) + + def save_m2m(self): + pass + + def save(self, commit=True): + if self.errors: + raise ValueError( + "The %s could not be %s because the data didn't validate." + % ( + self.instance._meta.object_name, + "created" if self.instance._state.adding else "changed", + ) + ) + password = SharedPassword.new( + self.cleaned_data["name"], self.cleaned_data["password"] + ) + if self.cleaned_data["url"]: + password.url = self.cleaned_data["url"] + if self.cleaned_data["description"]: + password.description = self.cleaned_data["description"] + password.save() + return password + + class Meta: + model = SharedPassword + exclude = ["iv", "encrypted_password"] + + +class EditSharedPasswordForm(forms.ModelForm): + name = forms.CharField(max_length=512, label=_("Name")) + password = forms.CharField( + max_length=1024, widget=forms.PasswordInput, required=False, label=_("Password") + ) + + def save_m2m(self): + pass + + def save(self, commit=True): + if self.errors: + raise ValueError( + "The %s could not be %s because the data didn't validate." + % ( + self.instance._meta.object_name, + "created" if self.instance._state.adding else "changed", + ) + ) + password = self.instance + if self.cleaned_data["password"]: + password.set_password(self.cleaned_data["password"]) + password.save() + return password + + class Meta: + model = SharedPassword + exclude = ["iv", "encrypted_password"] diff --git a/pwdb/locale/en/LC_MESSAGES/django.po b/pwdb/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..b57a011 --- /dev/null +++ b/pwdb/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,73 @@ +msgid "" +msgstr "" +"Project-Id-Version: 1.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2019-07-27 16:41+0200\n" +"PO-Revision-Date: 2019-07-27 16:41+0200\n" +"Last-Translator: Khaganat \n" +"Language-Team: Khaganat \n" +"Language: en\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: forms.py:11 forms.py:46 templates/pwdb/list_passwords.html:12 +msgid "Name" +msgstr "" + +#: forms.py:13 templates/pwdb/list_passwords.html:13 +msgid "Description" +msgstr "" + +#: forms.py:15 forms.py:48 templates/pwdb/list_passwords.html:15 +msgid "Password" +msgstr "" + +#: models.py:102 +msgid "shared_password" +msgstr "Shared password" + +#: models.py:103 templates/pwdb/list_passwords.html:4 +msgid "shared_passwords" +msgstr "Shared passwords" + +#: models.py:116 +msgid "shared_password_access" +msgstr "Shared password access" + +#: models.py:117 +msgid "shared_passwords_access" +msgstr "Shared passwords access" + +#: templates/pwdb/authenticate.html:5 +msgid "authenticate" +msgstr "" + +#: templates/pwdb/authenticate.html:10 +msgid "safety_enter_password" +msgstr "For safety reasons, please enter your password." + +#: templates/pwdb/authenticate.html:15 +msgid "send" +msgstr "" + +#: templates/pwdb/list_passwords.html:16 +msgid "Actions" +msgstr "" + +#: templates/pwdb/list_passwords.html:24 +msgid "view_website" +msgstr "View website" + +#: templates/pwdb/list_passwords.html:26 +msgid "copy_password" +msgstr "Copy" + +#: templates/pwdb/list_passwords.html:27 +msgid "show_password" +msgstr "Show" + +#: templates/pwdb/list_passwords.html:28 +msgid "hide_password" +msgstr "Hide" diff --git a/pwdb/locale/fr/LC_MESSAGES/django.po b/pwdb/locale/fr/LC_MESSAGES/django.po new file mode 100644 index 0000000..7f6ccbb --- /dev/null +++ b/pwdb/locale/fr/LC_MESSAGES/django.po @@ -0,0 +1,73 @@ +msgid "" +msgstr "" +"Project-Id-Version: 1.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2019-07-27 16:41+0200\n" +"PO-Revision-Date: 2019-07-27 16:41+0200\n" +"Last-Translator: Khaganat \n" +"Language-Team: Khaganat \n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#: forms.py:11 forms.py:46 templates/pwdb/list_passwords.html:12 +msgid "Name" +msgstr "Nom" + +#: forms.py:13 templates/pwdb/list_passwords.html:13 +msgid "Description" +msgstr "Description" + +#: forms.py:15 forms.py:48 templates/pwdb/list_passwords.html:15 +msgid "Password" +msgstr "Mot de passe" + +#: models.py:102 +msgid "shared_password" +msgstr "Mot de passe partagé" + +#: models.py:103 templates/pwdb/list_passwords.html:4 +msgid "shared_passwords" +msgstr "Mots de passe partagés" + +#: models.py:116 +msgid "shared_password_access" +msgstr "Accès au mot de passe partagé" + +#: models.py:117 +msgid "shared_passwords_access" +msgstr "Accès aux mots de passe partagés" + +#: templates/pwdb/authenticate.html:5 +msgid "authenticate" +msgstr "Authentification" + +#: templates/pwdb/authenticate.html:10 +msgid "safety_enter_password" +msgstr "À des fins de sécurité, veuillez entrer votre mot de passe." + +#: templates/pwdb/authenticate.html:15 +msgid "send" +msgstr "Envoyer" + +#: templates/pwdb/list_passwords.html:16 +msgid "Actions" +msgstr "Actions" + +#: templates/pwdb/list_passwords.html:24 +msgid "view_website" +msgstr "Voir le site web" + +#: templates/pwdb/list_passwords.html:26 +msgid "copy_password" +msgstr "Copier" + +#: templates/pwdb/list_passwords.html:27 +msgid "show_password" +msgstr "Montrer" + +#: templates/pwdb/list_passwords.html:28 +msgid "hide_password" +msgstr "Masquer" diff --git a/pwdb/migrations/0001_initial.py b/pwdb/migrations/0001_initial.py new file mode 100644 index 0000000..5288636 --- /dev/null +++ b/pwdb/migrations/0001_initial.py @@ -0,0 +1,45 @@ +# Generated by Django 2.2.3 on 2019-07-27 15:16 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='SharedPassword', + fields=[ + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=512)), + ('url', models.CharField(blank=True, max_length=512)), + ('description', models.TextField(blank=True)), + ('iv', models.BinaryField(max_length=16)), + ('encrypted_password', models.BinaryField(max_length=2048)), + ], + options={ + 'verbose_name': 'shared_password', + 'verbose_name_plural': 'shared_passwords', + }, + ), + migrations.CreateModel( + name='SharedPasswordAccess', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pwdb.SharedPassword')), + ('user', models.ForeignKey(limit_choices_to={'is_staff': True}, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'shared_password_access', + 'verbose_name_plural': 'shared_passwords_access', + }, + ), + ] diff --git a/pwdb/migrations/__init__.py b/pwdb/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pwdb/models.py b/pwdb/models.py new file mode 100644 index 0000000..298eebf --- /dev/null +++ b/pwdb/models.py @@ -0,0 +1,117 @@ +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from cryptography.hazmat.primitives import hashes, padding +from cryptography.hazmat.backends import default_backend +from django.utils.translation import ugettext_lazy as _ +from django.conf import settings +from django.db import models +from neluser.models import NelUser +import secrets +import uuid + +KEY_LENGTH = 32 +IV_LENGTH = 16 +BLOCK_SIZE = 128 +ENCODING = "UTF-8" + + +class SharedPassword(models.Model): + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + name = models.CharField(max_length=512) + url = models.CharField(max_length=512, blank=True) + description = models.TextField(blank=True) + iv = models.BinaryField(max_length=IV_LENGTH) + encrypted_password = models.BinaryField(max_length=2048) + + @staticmethod + def get_key(pass_uuid, key=None): + backend = default_backend() + hkdf = HKDF( + algorithm=hashes.SHA256(), + length=KEY_LENGTH, + salt=pass_uuid.bytes, + backend=backend, + info=None, + ) + key = key or settings.SECRET_KEY + key = bytes(key, encoding=ENCODING) + return hkdf.derive(key) + + @staticmethod + def get_cipher(key, iv): + backend = default_backend() + return Cipher(algorithms.Camellia(key), modes.CBC(iv), backend=backend) + + @staticmethod + def padd_password(clear_password): + clear_password = bytes(clear_password, encoding=ENCODING) + padder = padding.PKCS7(BLOCK_SIZE).padder() + padded_password = padder.update(clear_password) + padder.finalize() + return padded_password + + @staticmethod + def unpadd_password(clear_password): + unpadder = padding.PKCS7(BLOCK_SIZE).unpadder() + unpadded_password = unpadder.update(clear_password) + unpadder.finalize() + return unpadded_password.decode() + + @staticmethod + def new(name, clear_password): + password = SharedPassword( + uuid=uuid.uuid4(), + name=name, + url="", + description="", + iv=secrets.token_bytes(IV_LENGTH), + encrypted_password=b"", + ) + password.set_password(clear_password) + return password + + def set_password(self, clear_password): + clear_password = SharedPassword.padd_password(clear_password) + key = SharedPassword.get_key(self.uuid) + cipher = SharedPassword.get_cipher(key, self.iv) + encryptor = cipher.encryptor() + self.encrypted_password = ( + encryptor.update(clear_password) + encryptor.finalize() + ) + + def decrypt_password(self): + key = SharedPassword.get_key(self.uuid) + cipher = SharedPassword.get_cipher(key, self.iv) + decryptor = cipher.decryptor() + clear_password = ( + decryptor.update(self.encrypted_password) + decryptor.finalize() + ) + clear_password = SharedPassword.unpadd_password(clear_password) + return clear_password + + def users(self): + lst = ( + SharedPasswordAccess.objects.select_related("user") + .filter(password=self) + .order_by("user__email") + ) + return [e.user for e in lst] + + def __str__(self): + return self.name + + class Meta: + verbose_name = _("shared_password") + verbose_name_plural = _("shared_passwords") + + +class SharedPasswordAccess(models.Model): + user = models.ForeignKey( + NelUser, on_delete=models.CASCADE, limit_choices_to={"is_staff": True} + ) + password = models.ForeignKey(SharedPassword, on_delete=models.CASCADE) + + def __str__(self): + return "{}: {}".format(self.password, self.user) + + class Meta: + verbose_name = _("shared_password_access") + verbose_name_plural = _("shared_passwords_access") diff --git a/pwdb/templates/pwdb/authenticate.html b/pwdb/templates/pwdb/authenticate.html new file mode 100644 index 0000000..7045d17 --- /dev/null +++ b/pwdb/templates/pwdb/authenticate.html @@ -0,0 +1,17 @@ +{% extends "khaganat/centered_dialog.html" %} +{% load bulma_tags %} +{% load i18n %} + +{% block title %}{% trans "authenticate"|capfirst %}{% endblock %} +{% block dialog_class %}is-link{% endblock %} + +{% block dialog %} +

+ {% trans "safety_enter_password" %} +

+
+ {% csrf_token %} + {{ form.as_p }} + +
+{% endblock %} diff --git a/pwdb/templates/pwdb/list_passwords.html b/pwdb/templates/pwdb/list_passwords.html new file mode 100644 index 0000000..ebd7ac5 --- /dev/null +++ b/pwdb/templates/pwdb/list_passwords.html @@ -0,0 +1,35 @@ +{% extends "khaganat/base.html" %} +{% load i18n %} + +{% block title %}{% trans "shared_passwords" %}{% endblock %} + +{% block content %} +
+
+ + + + + + + + + + + + {% for pass in passwords %} + + + + + + + + + + {% endfor %} + +
{% trans "Name" %}{% trans "Description" %}URL{% trans "Password" %}{% trans "Actions" %}
{{ pass.name }}{{ pass.description }}{% if pass.url %}{{ pass.url|truncatechars:30 }}{% endif %}{% trans "copy_password" %}{% trans "show_password" %}{% trans "hide_password" %}
+
+
+{% endblock %} diff --git a/pwdb/tests.py b/pwdb/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/pwdb/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/pwdb/urls.py b/pwdb/urls.py new file mode 100644 index 0000000..a7e0aa2 --- /dev/null +++ b/pwdb/urls.py @@ -0,0 +1,5 @@ +from django.urls import path +from . import views + + +urlpatterns = [path("", views.list_passwords, name="pwdb")] diff --git a/pwdb/views.py b/pwdb/views.py new file mode 100644 index 0000000..3a31183 --- /dev/null +++ b/pwdb/views.py @@ -0,0 +1,23 @@ +from django.contrib.admin.views.decorators import staff_member_required +from django.contrib.auth import authenticate +from django.shortcuts import render +from .models import SharedPasswordAccess +from .forms import AuthForm + + +@staff_member_required +def list_passwords(request): + try: + pwd = request.POST["pwdb_check"] + user = authenticate(username=request.user, password=pwd) + assert user is not None + lst = ( + SharedPasswordAccess.objects.select_related("password") + .filter(user__pk=user.pk) + .order_by("password__name") + ) + ctx = {"passwords": [e.password for e in lst]} + return render(request, "pwdb/list_passwords.html", ctx) + except (KeyError, AssertionError): + ctx = {"form": AuthForm()} + return render(request, "pwdb/authenticate.html", ctx)