Add a simple pawword sharing module

This commit is contained in:
Rodolphe Breard 2019-07-27 17:17:21 +02:00
parent 8c601c23a2
commit d7af4cd80c
17 changed files with 502 additions and 2 deletions

View file

@ -9,6 +9,7 @@ django-bulma = "*"
django-npb = "*"
markdown = "*"
python-decouple = "*"
cryptography = ">=2.7,<2.8"
[dev-packages]
gunicorn = "*"

View file

@ -49,6 +49,7 @@ INSTALLED_APPS = [
"npb.apps.NpbConfig",
"nsfw.apps.NsfwConfig",
"pages.apps.PagesConfig",
"pwdb.apps.PwdbConfig",
"static_extra.apps.KhaganatStaticFilesConfig",
]

View file

@ -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")),
)

0
pwdb/__init__.py Normal file
View file

26
pwdb/admin.py Normal file
View file

@ -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)

5
pwdb/apps.py Normal file
View file

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

75
pwdb/forms.py Normal file
View file

@ -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"]

View file

@ -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 <assoc@khaganat.net>\n"
"Language-Team: Khaganat <assoc@khaganat.net>\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"

View file

@ -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 <assoc@khaganat.net>\n"
"Language-Team: Khaganat <assoc@khaganat.net>\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"

View file

@ -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',
},
),
]

View file

117
pwdb/models.py Normal file
View file

@ -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")

View file

@ -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 %}
<p>
{% trans "safety_enter_password" %}
</p>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="button is-link">{% trans "send"|capfirst %}</button>
</form>
{% endblock %}

View file

@ -0,0 +1,35 @@
{% extends "khaganat/base.html" %}
{% load i18n %}
{% block title %}{% trans "shared_passwords" %}{% endblock %}
{% block content %}
<div class="content-bloc">
<div class="table-container">
<table class="table is-striped is-fullwidth">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Description" %}</th>
<th>URL</th>
<th>{% trans "Password" %}</th>
<th colspan="3">{% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for pass in passwords %}
<tr>
<td class="has-text-weight-semibold">{{ pass.name }}</td>
<td class="has-text-weight-light">{{ pass.description }}</td>
<td>{% if pass.url %}<a href="{{ pass.url }}" target="_blank">{{ pass.url|truncatechars:30 }}</a>{% endif %}</td>
<td><input class="input" type="password" id="password_{{ pass.uuid }}" name="password_{{ pass.uuid }}" value="{{ pass.decrypt_password }}" /></td>
<td><a class="button is-primary" onclick="navigator.clipboard.writeText(document.getElementById('password_{{ pass.uuid }}').value).then(function() { console.log('Password copied to clipboard'); }, function(err) { console.error('Unable to copy password to clipboard'); });">{% trans "copy_password" %}</a></td>
<td><a id="show_{{ pass.uuid }}" class="button is-warning" onclick="document.getElementById('password_{{ pass.uuid }}').type = 'text';">{% trans "show_password" %}</a></td>
<td><a id="hide_{{ pass.uuid }}" class="button is-success" onclick="document.getElementById('password_{{ pass.uuid }}').type = 'password';">{% trans "hide_password" %}</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

3
pwdb/tests.py Normal file
View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

5
pwdb/urls.py Normal file
View file

@ -0,0 +1,5 @@
from django.urls import path
from . import views
urlpatterns = [path("", views.list_passwords, name="pwdb")]

23
pwdb/views.py Normal file
View file

@ -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)