Anwendungsspezifische Passwörter (#110)

Fixes #107.

Co-authored-by: Tobias Bölz <tobias@boelz.eu>
Co-authored-by: Tobias Bölz <tobias@boelz.eu>
Reviewed-on: foerderverein/antragsverwaltung#110
pull/112/head
Tobias Bölz 1 year ago
parent c3a1a91852
commit 0302e14e3f

@ -9,7 +9,6 @@ repos:
- id: end-of-file-fixer
- id: check-added-large-files
- id: detect-private-key
- id: requirements-txt-fixer
- repo: https://github.com/PyCQA/isort
rev: '5.10.1'
hooks:
@ -26,11 +25,22 @@ repos:
'flake8-print==4.0.0',
'flake8-quotes==3.3.1',
]
- repo: local
hooks:
- id: django-makemigrations-check
name: Check Django Migrations
entry: poetry run python manage.py makemigrations --dry-run --check --no-input
language: system
types: [python]
pass_filenames: false
- repo: https://github.com/PyCQA/bandit
rev: '1.7.4'
hooks:
- id: bandit
exclude: test_.*
- repo: https://github.com/Lucas-C/pre-commit-hooks-safety
rev: 'v1.2.4'
hooks:
- id: python-safety-dependencies-check
files: pyproject.toml
args: ['--ignore=47012,47853']

@ -1,3 +1,7 @@
{
"python.pythonPath": "venv/bin/python"
"python.testing.pytestArgs": [
"."
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
}

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2022-04-21 16:05+0200\n"
"POT-Creation-Date: 2022-05-23 15:35+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -55,8 +55,12 @@ msgid "Staff status"
msgstr "Mitarbeiter-Status"
#: templates/accounts/profile.html:24
msgid "Change password"
msgstr ""
msgid "Change Password"
msgstr "Passwort ändern"
#: templates/accounts/profile.html:27
msgid "Manage App Specific Password"
msgstr "Anwendungsspezifische Passwörter verwalten"
#: templates/registration/activation_complete.html:5
#: templates/registration/activation_complete.html:8

@ -21,5 +21,9 @@
<hr>
<a class="btn btn-primary" href="{% url 'password_change' %}" role="button">{% translate 'Change password' %}</a>
<a class="btn btn-primary" href="{% url 'password_change' %}" role="button">{% translate 'Change Password' %}</a>
{% if perms.appspecificpasswords.view_appspecificpassword %}
<a href="{% url 'appspecificpasswords:list' %}" class="btn btn-primary">{% translate 'Manage App Specific Password' %}</a>
{% endif %}
{% endblock %}

@ -3,7 +3,21 @@ from django.urls import include, path
from . import views
urlpatterns = [
path('', include('django.contrib.auth.urls')),
path('', include('registration.backends.default.urls')),
path('profile/', views.profile, name='profile'),
path(
'',
include('django.contrib.auth.urls')
),
path(
'',
include('registration.backends.default.urls')
),
path(
'profile/',
views.profile,
name='profile'
),
path(
'appspecificpasswords/',
include('appspecificpasswords.urls', namespace='appspecificpasswords')
),
]

@ -2,10 +2,10 @@ from datetime import date
from django.test import TestCase
from .models import ApplicationForFunding
from ..models import ApplicationForFunding
class ApplicationForFundingTestCase(TestCase):
class TestApplicationForFunding(TestCase):
def setUp(self):
ApplicationForFunding.objects.create(
id='86889FB2-5144-4331-A20A-0F829A6075FD',

@ -0,0 +1,35 @@
from django import forms
from django.contrib import admin
from django.contrib.auth.forms import ReadOnlyPasswordHashField
from django.utils.translation import gettext_lazy as _
from .models import AppSpecificPassword
class AppSpecificPasswordAddForm(forms.ModelForm):
class Meta:
model = AppSpecificPassword
fields = ['user', 'name', 'password']
class AppSpecificPasswordChangeForm(AppSpecificPasswordAddForm):
password = ReadOnlyPasswordHashField(
label=_('Password'),
)
@admin.register(AppSpecificPassword)
class AppSpecificPasswordAdmin(admin.ModelAdmin):
form = AppSpecificPasswordChangeForm
add_form = AppSpecificPasswordAddForm
list_display = ('name', 'user')
list_filter = ('user',)
raw_id_fields = ('user',)
def get_form(self, request, obj=None, **kwargs):
"""Switch form when adding a new object."""
defaults = {}
if not obj:
defaults['form'] = self.add_form
defaults.update(kwargs)
return super().get_form(request, obj, **defaults)

@ -0,0 +1,8 @@
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class AppSpecificPasswordsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'appspecificpasswords'
verbose_name = _('App Specific Passwords')

@ -0,0 +1,30 @@
from django.contrib.auth.password_validation import validate_password
from django.forms import ModelForm, ValidationError
from django.utils.translation import gettext_lazy as _
from .models import AppSpecificPassword
class AppSpecificPasswordForm(ModelForm):
def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user')
super().__init__(*args, **kwargs)
def clean_name(self):
name = self.cleaned_data.get('name')
if name and AppSpecificPassword.objects \
.filter(name=name, user=self.user).exists():
raise ValidationError(
_('An app specific password with this name already exists.')
)
return name
def clean_password(self):
password = self.cleaned_data.get('password')
if password: # pragma: no branch
validate_password(password, self.user)
return password
class Meta:
model = AppSpecificPassword
fields = ('name', 'password')

@ -0,0 +1,82 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) Tobias Bölz
# This file is distributed under the same license as the PACKAGE package.
# Tobias Bölz <tobias@boelz.eu>, 2022.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2022-05-15 16:20+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \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"
#: admin.py:17 models.py:39
msgid "Password"
msgstr "Passwort"
#: apps.py:8 models.py:48
#: templates/appspecificpasswords/appspecificpassword_list.html:5
#: templates/appspecificpasswords/appspecificpassword_list.html:9
msgid "App Specific Passwords"
msgstr "Anwendungs­spezifische Passwörter"
#: forms.py:18
msgid "An app specific password with this name already exists."
msgstr ""
"Ein anwendungsspezifisches Passwort mit dieser Bezeichnung existiert bereits."
#: models.py:30
msgid "User"
msgstr "Benutzer"
#: models.py:34 templates/appspecificpasswords/appspecificpassword_list.html:14
msgid "Name"
msgstr "Bezeichnung"
#: models.py:47
msgid "App Specific Password"
msgstr "Anwendungs­spezifische Passwörter"
#: templates/appspecificpasswords/appspecificpassword_confirm_delete.html:6
#: templates/appspecificpasswords/appspecificpassword_confirm_delete.html:10
msgid "Delete App Specific Password"
msgstr "Anwendungs­spezifisches Passwort löschen"
#: templates/appspecificpasswords/appspecificpassword_confirm_delete.html:14
#, python-format
msgid "Are you sure you want to delete the app specific password “%(object)s”?"
msgstr ""
"Bist du sicher, dass du das anwendungsspezifische Passwort „%(object)s“ "
"löschen willst?"
#: templates/appspecificpasswords/appspecificpassword_confirm_delete.html:16
#: templates/appspecificpasswords/appspecificpassword_list.html:25
msgid "Delete"
msgstr "Löschen"
#: templates/appspecificpasswords/appspecificpassword_confirm_delete.html:17
#: templates/appspecificpasswords/appspecificpassword_form.html:16
msgid "Cancel"
msgstr "Abbrechen"
#: templates/appspecificpasswords/appspecificpassword_form.html:6
#: templates/appspecificpasswords/appspecificpassword_form.html:10
#: templates/appspecificpasswords/appspecificpassword_list.html:43
msgid "Add App Specific Password"
msgstr "Anwendungs­spezifisches Passwort hinzufügen"
#: templates/appspecificpasswords/appspecificpassword_form.html:14
msgid "Add"
msgstr "Hinzufügen"
#: templates/appspecificpasswords/appspecificpassword_list.html:33
msgid "No App Specific Passwords"
msgstr "Keine anwendungsspezifischen Passwörter"

@ -0,0 +1,34 @@
# Generated by Django 3.2.13 on 2022-05-23 11:45
import appspecificpasswords.models
import appspecificpasswords.utils
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='AppSpecificPassword',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=255, verbose_name='Name')),
('password', appspecificpasswords.models.PasswordField(default=appspecificpasswords.utils.generate_password, max_length=255, verbose_name='Password')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
options={
'verbose_name': 'App Specific Password',
'verbose_name_plural': 'App Specific Passwords',
'unique_together': {('user', 'name')},
},
),
]

@ -0,0 +1,48 @@
import uuid
from django.conf import settings
from django.contrib.auth.hashers import make_password
from django.db import models
from django.utils.translation import gettext_lazy as _
from .utils import generate_password
class PasswordField(models.CharField):
def pre_save(self, model_instance, add):
if not add:
return super().pre_save(model_instance, add)
password = getattr(model_instance, self.attname)
encoded = make_password(password)
setattr(model_instance, self.attname, encoded)
return encoded
class AppSpecificPassword(models.Model):
id = models.UUIDField(
primary_key=True,
default=uuid.uuid4,
editable=False
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
verbose_name=_('User')
)
name = models.CharField(
max_length=255,
verbose_name=_('Name')
)
password = PasswordField(
max_length=255,
default=generate_password,
verbose_name=_('Password')
)
def __str__(self):
return self.name
class Meta:
unique_together = ['user', 'name']
verbose_name = _('App Specific Password')
verbose_name_plural = _('App Specific Passwords')

@ -0,0 +1,21 @@
{% extends 'base.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Delete App Specific Password' %}{% endblock %}
{% block content %}
<div class="content">
<h1>{% translate 'Delete App Specific Password' %}</h1>
<form method="post">{% csrf_token %}
<p>
{% blocktranslate %}Are you sure you want to delete the app specific password “{{ object }}”?{% endblocktranslate%}
</p>
<input type="submit" value="{% translate 'Delete' %}" class="btn btn-danger float-right ml-2">
<a href="{% url 'appspecificpasswords:list' %}" class="btn btn-light float-right" role="button">{% translate 'Cancel' %}</a>
</form>
</div>
{% endblock content %}

@ -0,0 +1,21 @@
{% extends 'base.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Add App Specific Password' %}{% endblock %}
{% block content %}
<div class="content">
<h1>{% translate 'Add App Specific Password' %}</h1>
<form method="post">{% csrf_token %}
{{ form|crispy }}
<input type="submit" value="{% translate 'Add' %}" class="btn btn-primary float-right ml-2">
<a href="{% url 'appspecificpasswords:list' %}" class="btn btn-light float-right" role="button">
{% translate 'Cancel' %}
</a>
</form>
</div>
{% endblock content %}

@ -0,0 +1,49 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% translate 'App Specific Passwords' %}{% endblock %}
{% block content %}
<div class="content">
<h1>{% translate 'App Specific Passwords' %}</h1>
<table class="table">
<thead>
<tr>
<th scope="col">{% translate 'Name' %}</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{% for object in object_list %}
<tr>
<td class="align-middle">{{ object.name }}</td>
<td>
<span style="float: right;">
{% if perms.appspecificpasswords.delete_appspecificpassword %}
<a href="{% url 'appspecificpasswords:delete' object.id %}" class="btn btn-danger" role="button">{% translate 'Delete' %}</a>
{% endif %}
</span>
</td>
</tr>
{% empty %}
<tr>
<td colspan="2" class="text-center">
{% translate 'No App Specific Passwords' %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if perms.appspecificpasswords.add_appspecificpassword %}
<p>
<a href="{% url 'appspecificpasswords:create' %}" class="btn btn-primary">
{% translate 'Add App Specific Password' %}
</a>
</p>
{% endif %}
</div>
{% endblock content %}

@ -0,0 +1,8 @@
import pytest
@pytest.fixture
def test_user(django_user_model):
"""User with username test."""
user = django_user_model.objects.create(username='test')
return user

@ -0,0 +1,60 @@
from unittest.mock import patch
from django.contrib.admin import AdminSite
from django.contrib.auth.forms import ReadOnlyPasswordHashField
from django.forms import CharField
from ..admin import (AppSpecificPasswordAddForm, AppSpecificPasswordAdmin,
AppSpecificPasswordChangeForm)
from ..models import AppSpecificPassword
def test_app_specific_password_add_form(test_user):
form = AppSpecificPasswordAddForm(data={
'user': test_user,
'name': 'foo',
'password': '92ml.(L_[U}-OF(M-eZ3B0515Kiym#!a',
})
assert isinstance(form.fields['password'], CharField)
assert form.is_valid()
def test_app_specific_password_change_form():
form = AppSpecificPasswordChangeForm()
assert isinstance(form.fields['password'], ReadOnlyPasswordHashField)
@patch('appspecificpasswords.admin.admin.ModelAdmin.get_form')
def test_app_specific_password_admin_get_change_form(mock_super_get_form,
test_user, rf):
app_specific_password = AppSpecificPassword.objects.create(
user=test_user,
name='Test',
password='password',
)
admin = AppSpecificPasswordAdmin(
model=AppSpecificPassword,
admin_site=AdminSite()
)
request = rf.get('/doesnt_matter')
admin.get_form(request, obj=app_specific_password)
mock_super_get_form.assert_called_once_with(
request,
app_specific_password
)
@patch('appspecificpasswords.admin.admin.ModelAdmin.get_form')
def test_app_specific_password_admin_get_add_form(mock_model_admin_get_form,
rf):
admin = AppSpecificPasswordAdmin(
model=AppSpecificPassword,
admin_site=AdminSite()
)
request = rf.get('/doesnt_matter')
admin.get_form(request, obj=None)
mock_model_admin_get_form.assert_called_once_with(
request,
None,
form=admin.add_form
)

@ -0,0 +1,47 @@
from unittest.mock import patch
from django.core.exceptions import ValidationError
from ..forms import AppSpecificPasswordForm
from ..models import AppSpecificPassword
def test_app_specific_password_form(test_user):
form = AppSpecificPasswordForm(data={
'name': 'foo',
'password': '92ml.(L_[U}-OF(M-eZ3B0515Kiym#!a'
},
user=test_user
)
assert form.is_valid()
@patch('appspecificpasswords.forms.validate_password')
def test_app_specific_password_form_validate_password(mock_validate_password,
test_user):
mock_validate_password.side_effect = ValidationError(['foobar'])
form = AppSpecificPasswordForm(data={
'name': 'foo',
'password': 'password'
},
user=test_user
)
assert not form.is_valid()
mock_validate_password.assert_called_once_with('password', test_user)
assert form.errors['password'] == ['foobar']
def test_app_specific_password_form_name_already_exists(test_user):
AppSpecificPassword.objects.create(
user=test_user,
name='foo',
password='password'
)
form = AppSpecificPasswordForm(data={
'name': 'foo',
'password': '92ml.(L_[U}-OF(M-eZ3B0515Kiym#!a'
},
user=test_user
)
assert not form.is_valid()
assert form.errors['name']

@ -0,0 +1,43 @@
import pytest
from django.contrib.auth.hashers import check_password
from ..models import AppSpecificPassword
pytestmark = pytest.mark.django_db
@pytest.fixture
def app_specific_password(test_user):
password = AppSpecificPassword.objects.create(
user=test_user,
name='foo',
password='password'
)
return password
def test_app_specific_password_str(app_specific_password):
assert str(app_specific_password) == 'foo'
def test_app_specific_password_user(app_specific_password):
assert app_specific_password.user.username == 'test'
def test_app_specific_password_name(app_specific_password):
assert app_specific_password.name == 'foo'
def test_app_specific_password_password_is_encoded(app_specific_password):
assert app_specific_password.password != 'password'
def test_app_specific_password_check_password(app_specific_password):
assert check_password('password', app_specific_password.password)
def test_app_specific_password_not_encoded_twice(app_specific_password):
encoded = app_specific_password.password
app_specific_password.name = 'bar'
app_specific_password.save()
assert app_specific_password.password == encoded

@ -0,0 +1,16 @@
import pytest
from ..utils import generate_password
def test_generate_password():
password = generate_password(length=42)
assert len(password) == 42
assert any(c.islower() for c in password)
assert any(c.isupper() for c in password)
assert any(c.isdigit() for c in password)
def test_generate_password_too_short():
with pytest.raises(ValueError):
generate_password(length=1)

@ -0,0 +1,204 @@
from uuid import UUID
import pytest
from django.contrib.auth.models import Permission
from django.urls import reverse
from pytest_django.asserts import assertQuerysetEqual, assertTemplateUsed
from ..models import AppSpecificPassword
from ..views import AppSpecificPasswordCreateView
@pytest.fixture
def app_specific_password(test_user):
password = AppSpecificPassword.objects.create(
id=UUID(int=0),
user=test_user,
name='foo',
password='password'
)
return password
@pytest.fixture
def get_permission():
def func(permission):
app_label, codename = permission.split('.', maxsplit=1)
return Permission.objects.get(
content_type__app_label=app_label,
codename=codename
)
return func
class TestAppSpecificPasswordsListView:
@pytest.mark.urls('appspecificpasswords.urls')
def test_get_view(self, client):
response = client.get('/')
assert response.status_code == 302
assert response.url.startswith('/accounts/login')
def test_get_view_by_name(self, client):
response = client.get(reverse('appspecificpasswords:list'))
assert response.status_code == 302
assert response.url.startswith('/accounts/login')
def test_get_view_access_denied(self, client, test_user):
client.force_login(test_user)
response = client.get(reverse('appspecificpasswords:list'))
assert response.status_code == 403
def test_get_view_with_permission(self, client, test_user, get_permission):
test_user.user_permissions.add(get_permission(
'appspecificpasswords.view_appspecificpassword'
))
client.force_login(test_user)
response = client.get(reverse('appspecificpasswords:list'))
assert response.status_code == 200
assertTemplateUsed(
response,
'appspecificpasswords/appspecificpassword_list.html'
)
def test_queryset(self, client, test_user, get_permission,
app_specific_password):
test_user.user_permissions.add(get_permission(
'appspecificpasswords.view_appspecificpassword'
))
client.force_login(test_user)
response = client.get(reverse('appspecificpasswords:list'))
assertQuerysetEqual(
response.context_data['object_list'],
[app_specific_password]
)
def test_queryset_other_user(self, client, test_user, get_permission,
app_specific_password, django_user_model):
other_user = django_user_model.objects.create(username='other')
app_specific_password.user = other_user
app_specific_password.save()
test_user.user_permissions.add(get_permission(
'appspecificpasswords.view_appspecificpassword'
))
client.force_login(test_user)
response = client.get(reverse('appspecificpasswords:list'))
assertQuerysetEqual(
response.context_data['object_list'],
[]
)
class TestAppSpecificPasswordCreateView:
@pytest.mark.urls('appspecificpasswords.urls')
def test_get_view(self, client):
response = client.get('/create/')
assert response.status_code == 302
assert response.url.startswith('/accounts/login')
def test_get_view_by_name(self, client):
response = client.get(reverse('appspecificpasswords:create'))
assert response.status_code == 302
assert response.url.startswith('/accounts/login')
def test_get_view_access_denied(self, client, test_user):
client.force_login(test_user)
response = client.get(reverse('appspecificpasswords:create'))
assert response.status_code == 403
def test_get_view_with_permission(self, client, test_user, get_permission):
client.force_login(test_user)
view_permission = get_permission(
'appspecificpasswords.add_appspecificpassword'
)
test_user.user_permissions.add(view_permission)
response = client.get(reverse('appspecificpasswords:create'))
assert response.status_code == 200
assertTemplateUsed(
response,
'appspecificpasswords/appspecificpassword_form.html'
)
@pytest.mark.django_db
def test_create(self, rf, test_user, get_permission):
test_user.user_permissions.add(
get_permission('appspecificpasswords.add_appspecificpassword')
)
request = rf.post(
reverse('appspecificpasswords:create'),
data={
'name': 'foo',
'password': '92ml.(L_[U}-OF(M-eZ3B0515Kiym#!a'
}
)
request.user = test_user
response = AppSpecificPasswordCreateView.as_view()(request)
assert response.status_code == 302
assert response.url == reverse('appspecificpasswords:list')
assert AppSpecificPassword.objects.filter(user=test_user, name='foo') \
.exists()
class TestAppSpecificPasswordDeleteView:
@pytest.mark.urls('appspecificpasswords.urls')
def test_get_view(self, client):
response = client.get('/00000000-0000-0000-0000-000000000000/delete/')
assert response.status_code == 302
assert response.url.startswith('/accounts/login')
def test_get_view_by_name(self, client):
response = client.get(
reverse('appspecificpasswords:delete', args=[UUID(int=42)])
)
assert response.status_code == 302
assert response.url.startswith('/accounts/login')
def test_get_view_access_denied(self, client, test_user,
app_specific_password):
client.force_login(test_user)
response = client.get(
reverse('appspecificpasswords:delete', args=[UUID(int=0)])
)
assert response.status_code == 403
def test_get_view_with_permission(self, client, test_user, get_permission,
app_specific_password):
test_user.user_permissions.add(
get_permission('appspecificpasswords.delete_appspecificpassword')
)
client.force_login(test_user)
response = client.get(
reverse('appspecificpasswords:delete', args=[UUID(int=0)])
)
assert response.status_code == 200
assertTemplateUsed(
response,
'appspecificpasswords/appspecificpassword_confirm_delete.html'
)
def test_delete(self, client, test_user, get_permission,
app_specific_password):
test_user.user_permissions.add(
get_permission('appspecificpasswords.delete_appspecificpassword')
)
client.force_login(test_user)
response = client.post(
reverse('appspecificpasswords:delete', args=[UUID(int=0)])
)
assert response.status_code == 302
assert response.url == reverse('appspecificpasswords:list')
assert app_specific_password not in AppSpecificPassword.objects.all()
def test_delete_other_user(self, client, test_user, get_permission,
app_specific_password, django_user_model):
other_user = django_user_model.objects.create(username='other')
app_specific_password.user = other_user
app_specific_password.save()
test_user.user_permissions.add(
get_permission('appspecificpasswords.delete_appspecificpassword')
)
client.force_login(test_user)
response = client.post(
reverse('appspecificpasswords:delete', args=[UUID(int=0)])
)
assert response.status_code == 404

@ -0,0 +1,23 @@
from django.urls import path
from . import views
app_name = 'appspecificpasswords'
urlpatterns = [
path(
'',
views.AppSpecificPasswordListView.as_view(),
name='list'
),
path(
'create/',
views.AppSpecificPasswordCreateView.as_view(),
name='create'
),
path(
'<uuid:pk>/delete/',
views.AppSpecificPasswordDeleteView.as_view(),
name='delete'
),
]

@ -0,0 +1,20 @@
import secrets
import string
def generate_password(length=32):
"""Generate random password of the specified length.
The password will contain at least one uppercase letter, one lowercase
letter and one digit.
"""
if length < 3:
raise ValueError('Length must be >= 3.')
# https://docs.python.org/3/library/secrets.html#recipes-and-best-practices
alphabet = string.ascii_letters + string.digits + string.punctuation
while True:
password = ''.join(secrets.choice(alphabet) for i in range(length))
if (any(c.islower() for c in password) # pragma: no branch
and any(c.isupper() for c in password)
and any(c.isdigit() for c in password)):
return password

@ -0,0 +1,46 @@
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.urls import reverse_lazy
from django.utils.decorators import method_decorator
from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic.edit import CreateView, DeleteView
from django.views.generic.list import ListView
from .forms import AppSpecificPasswordForm
from .models import AppSpecificPassword
class AppSpecificPasswordCreateView(PermissionRequiredMixin, CreateView):
form_class = AppSpecificPasswordForm
permission_required = 'appspecificpasswords.add_appspecificpassword'
template_name = 'appspecificpasswords/appspecificpassword_form.html'
success_url = reverse_lazy('appspecificpasswords:list')
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['user'] = self.request.user
return kwargs
def form_valid(self, form):
form.instance.user = self.request.user
return super().form_valid(form)
@method_decorator(sensitive_post_parameters('password'))
def dispatch(self, request, *args, **kwargs):
return super().dispatch(request, *args, **kwargs)
class AppSpecificPasswordListView(PermissionRequiredMixin, ListView):
model = AppSpecificPassword
permission_required = 'appspecificpasswords.view_appspecificpassword'
def get_queryset(self):
return AppSpecificPassword.objects.filter(user=self.request.user)
class AppSpecificPasswordDeleteView(PermissionRequiredMixin, DeleteView):
model = AppSpecificPassword
permission_required = 'appspecificpasswords.delete_appspecificpassword'
success_url = reverse_lazy('appspecificpasswords:list')
def get_queryset(self):
return AppSpecificPassword.objects.filter(user=self.request.user)

@ -2,46 +2,47 @@
## Entwicklungsumgebung einrichten
1. Python 3.7 oder neuer installieren.
1. Python 3.8 oder neuer, [Poetry](https://python-poetry.org/docs/#installation) und [pre-commit](https://pre-commit.com/#install) installieren.
2. Repository klonen:
$ git clone --recurse-submodules git@git.boelz.eu:foerderverein/antragsverwaltung.git
3. `venv` anlegen und aktivieren:
3. Abhängigkeiten installieren:
$ cd antragsverwaltung
$ python3 -m venv venv
$ source venv/bin/activate
$ poetry install
4. Abhängigkeiten installieren:
4. Git-Hook installieren:
(venv) $ pip install --upgrade pip
(venv) $ pip install -r requirements.txt
5. `pre-commit` installieren:
(venv) $ pip install pre-commit
(venv) $ pre-commit install
$ pre-commit install
## Entwicklungsserver starten
- Vor erstem Start bzw. nach Änderung am Datenbankschema:
(venv) $ python manage.py migrate
$ poetry run python manage.py migrate
- Vor ersten Start bzw. nach Änderungen an der Übersetzung:
(venv) $ cd antragsverwaltung
(venv) $ django-admin compilemessages
(venv) $ cd ..
$ poetry run python manage.py compilemessages
- Dann:
(venv) $ python manage.py runserver
$ poetry run python manage.py runserver
## Benutzer anlegen
(venv) $ python manage.py createsuperuser
$ poetry run python manage.py createsuperuser
Weitere Benutzer können dann über die Dajngo-Admin-Seite (`/admin`) angelegt werden.
## Tests ausführen
$ poetry run pytest
## Testabdeckung anzeigen
$ poetry run coverage run --branch -m pytest
$ poetry run coverage html
$ Start-Process .\htmlcov\index.html

@ -38,6 +38,7 @@ MANAGERS = ADMINS
INSTALLED_APPS = [
'accounts',
'appspecificpasswords',
'django.contrib.admin',
'django.contrib.auth',
'registration',

935
poetry.lock generated

@ -0,0 +1,935 @@
[[package]]
name = "argon2-cffi"
version = "21.3.0"
description = "The secure Argon2 password hashing algorithm."
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
argon2-cffi-bindings = "*"
[package.extras]
dev = ["pre-commit", "cogapp", "tomli", "coverage[toml] (>=5.0.2)", "hypothesis", "pytest", "sphinx", "sphinx-notfound-page", "furo"]
docs = ["sphinx", "sphinx-notfound-page", "furo"]
tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pytest"]
[[package]]
name = "argon2-cffi-bindings"
version = "21.2.0"
description = "Low-level CFFI bindings for Argon2"
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
cffi = ">=1.0.1"
[package.extras]
dev = ["pytest", "cogapp", "pre-commit", "wheel"]
tests = ["pytest"]
[[package]]
name = "asgiref"
version = "3.5.2"
description = "ASGI specs, helper code, and adapters"
category = "main"
optional = false
python-versions = ">=3.7"
[package.extras]
tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"]
[[package]]
name = "atomicwrites"
version = "1.4.0"
description = "Atomic file writes."
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
name = "attrs"
version = "21.4.0"
description = "Classes Without Boilerplate"
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[package.extras]
dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"]
docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"]
tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"]
tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"]
[[package]]
name = "bcrypt"
version = "3.2.2"
description = "Modern password hashing for your software and your servers"
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
cffi = ">=1.1"
[package.extras]
tests = ["pytest (>=3.2.1,!=3.3.0)"]
typecheck = ["mypy"]
[[package]]
name = "certifi"
version = "2022.5.18.1"
description = "Python package for providing Mozilla's CA Bundle."
category = "main"
optional = false
python-versions = ">=3.6"
[[package]]
name = "cffi"
version = "1.15.0"
description = "Foreign Function Interface for Python calling C code."
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
pycparser = "*"
[[package]]
name = "charset-normalizer"
version = "2.0.12"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
category = "main"
optional = false
python-versions = ">=3.5.0"
[package.extras]
unicode_backport = ["unicodedata2"]
[[package]]
name = "colorama"
version = "0.4.4"
description = "Cross-platform colored terminal text."
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "coverage"
version = "6.3.3"
description = "Code coverage measurement for Python"
category = "dev"
optional = false
python-versions = ">=3.7"
[package.dependencies]
tomli = {version = "*", optional = true, markers = "extra == \"toml\""}
[package.extras]
toml = ["tomli"]
[[package]]
name = "cryptography"
version = "3.3.2"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
category = "main"
optional = false
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*"
[package.dependencies]
cffi = ">=1.12"
six = ">=1.4.1"
[package.extras]
docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"]
docstest = ["doc8", "pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"]
pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"]
ssh = ["bcrypt (>=3.1.5)"]
test = ["pytest (>=3.6.0,!=3.9.0,!=3.9.1,!=3.9.2)", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"]
[[package]]
name = "deprecated"
version = "1.2.13"
description = "Python @deprecated decorator to deprecate old python classes, functions or methods."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[package.dependencies]
wrapt = ">=1.10,<2"
[package.extras]
dev = ["tox", "bump2version (<1)", "sphinx (<2)", "importlib-metadata (<3)", "importlib-resources (<4)", "configparser (<5)", "sphinxcontrib-websupport (<2)", "zipp (<2)", "PyTest (<5)", "PyTest-Cov (<2.6)", "pytest", "pytest-cov"]
[[package]]
name = "django"
version = "3.2.13"
description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design."
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
argon2-cffi = {version = ">=19.1.0", optional = true, markers = "extra == \"argon2\""}
asgiref = ">=3.3.2,<4"
bcrypt = {version = "*", optional = true, markers = "extra == \"bcrypt\""}
pytz = "*"
sqlparse = ">=0.2.2"
[package.extras]
argon2 = ["argon2-cffi (>=19.1.0)"]
bcrypt = ["bcrypt"]
[[package]]
name = "django-crispy-forms"
version = "1.11.2"
description = "Best way to have Django DRY forms"
category = "main"
optional = false
python-versions = ">=3.5"
[[package]]
name = "django-oauth-toolkit"
version = "1.7.1"
description = "OAuth2 Provider for Django"
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
django = ">=2.2,<4.0.0 || >4.0.0"
jwcrypto = ">=0.8.0"
oauthlib = ">=3.1.0"
requests = ">=2.13.0"
[[package]]
name = "django-registration-redux"
version = "2.10"
description = "An extensible user-registration application for Django"
category = "main"
optional = false
python-versions = "*"
[[package]]
name = "flake8"
version = "4.0.1"
description = "the modular source code checker: pep8 pyflakes and co"
category = "dev"
optional = false
python-versions = ">=3.6"
[package.dependencies]
mccabe = ">=0.6.0,<0.7.0"
pycodestyle = ">=2.8.0,<2.9.0"
pyflakes = ">=2.4.0,<2.5.0"
[[package]]
name = "flake8-bugbear"
version = "22.4.25"
description = "A plugin for flake8 finding likely bugs and design problems in your program. Contains warnings that don't belong in pyflakes and pycodestyle."
category = "dev"
optional = false
python-versions = ">=3.6"
[package.dependencies]
attrs = ">=19.2.0"
flake8 = ">=3.0.0"
[package.extras]
dev = ["coverage", "hypothesis", "hypothesmith (>=0.2)", "pre-commit"]
[[package]]
name = "flake8-debugger"
version = "4.1.2"
description = "ipdb/pdb statement checker plugin for flake8"
category = "dev"
optional = false
python-versions = ">=3.7"
[package.dependencies]
flake8 = ">=3.0"
pycodestyle = "*"
[[package]]
name = "flake8-pep3101"
version = "1.3.0"
description = "Checks for old string formatting."
category = "dev"
optional = false
python-versions = "*"
[package.dependencies]
flake8 = ">=3.0"
[package.extras]
test = ["pytest", "testfixtures"]
[[package]]
name = "flake8-print"
version = "5.0.0"
description = "print statement checker plugin for flake8"
category = "dev"
optional = false
python-versions = ">=3.7"
[package.dependencies]
flake8 = ">=3.0"
pycodestyle = "*"
[[package]]
name = "flake8-quotes"
version = "3.3.1"
description = "Flake8 lint for quotes."
category = "dev"
optional = false
python-versions = "*"
[package.dependencies]
flake8 = "*"
[[package]]
name = "idna"
version = "3.3"
description = "Internationalized Domain Names in Applications (IDNA)"
category = "main"
optional = false
python-versions = ">=3.5"
[[package]]
name = "iniconfig"
version = "1.1.1"
description = "iniconfig: brain-dead simple config-ini parsing"
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "iso3166"
version = "2.0.2"
description = "Self-contained ISO 3166-1 country definitions."
category = "main"
optional = false
python-versions = ">= 3.6"
[[package]]
name = "jwcrypto"
version = "1.3.1"
description = "Implementation of JOSE Web standards"
category = "main"
optional = false
python-versions = ">= 3.6"
[package.dependencies]
cryptography = ">=2.3"
deprecated = "*"
[[package]]
name = "mccabe"
version = "0.6.1"
description = "McCabe checker, plugin for flake8"
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "oauthlib"
version = "3.2.0"
description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic"
category = "main"
optional = false
python-versions = ">=3.6"
[package.extras]
rsa = ["cryptography (>=3.0.0)"]
signals = ["blinker (>=1.4.0)"]
signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"]
[[package]]
name = "packaging"
version = "21.3"
description = "Core utilities for Python packages"
category = "dev"
optional = false
python-versions = ">=3.6"
[package.dependencies]
pyparsing = ">=2.0.2,<3.0.5 || >3.0.5"
[[package]]
name = "pluggy"
version = "1.0.0"
description = "plugin and hook calling mechanisms for python"
category = "dev"
optional = false
python-versions = ">=3.6"
[package.extras]
dev = ["pre-commit", "tox"]
testing = ["pytest", "pytest-benchmark"]
[[package]]
name = "py"
version = "1.11.0"
description = "library with cross-python path, ini-parsing, io, code, log facilities"
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "pycodestyle"
version = "2.8.0"
description = "Python style guide checker"
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "pycountry"
version = "22.3.5"
description = "ISO country, subdivision, language, currency and script definitions and their translations"
category = "main"
optional = false
python-versions = ">=3.6, <4"
[[package]]
name = "pycparser"
version = "2.21"
description = "C parser in Python"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
name = "pyflakes"
version = "2.4.0"
description = "passive checker of Python programs"
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
name = "pyparsing"
version = "3.0.9"
description = "pyparsing module - Classes and methods to define and execute parsing grammars"
category = "dev"
optional = false
python-versions = ">=3.6.8"
[package.extras]
diagrams = ["railroad-diagrams", "jinja2"]
[[package]]
name = "pytest"
version = "7.1.2"
description = "pytest: simple powerful testing with Python"
category = "dev"
optional = false
python-versions = ">=3.7"
[package.dependencies]
atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""}
attrs = ">=19.2.0"
colorama = {version = "*", markers = "sys_platform == \"win32\""}
iniconfig = "*"
packaging = "*"
pluggy = ">=0.12,<2.0"
py = ">=1.8.2"
tomli = ">=1.0.0"
[package.extras]
testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
[[package]]
name = "pytest-django"
version = "4.5.2"
description = "A Django plugin for pytest."
category = "dev"
optional = false
python-versions = ">=3.5"
[package.dependencies]
pytest = ">=5.4.0"
[package.extras]
docs = ["sphinx", "sphinx-rtd-theme"]
testing = ["django", "django-configurations (>=2.0)"]
[[package]]
name = "python-dateutil"
version = "2.8.2"
description = "Extensions to the standard Python datetime module"
category = "main"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
[package.dependencies]
six = ">=1.5"
[[package]]
name = "pytz"
version = "2022.1"
description = "World timezone definitions, modern and historical"
category = "main"
optional = false
python-versions = "*"
[[package]]
name = "requests"
version = "2.27.1"
description = "Python HTTP for Humans."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
[package.dependencies]
certifi = ">=2017.4.17"
charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""}
idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""}
urllib3 = ">=1.21.1,<1.27"
[package.extras]
socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"]
use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"]
[[package]]
name = "schwifty"
version = "2021.6.1"
description = "Validate/generate IBANs and BICs"
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
iso3166 = "*"
pycountry = "*"
[[package]]
name = "six"
version = "1.16.0"
description = "Python 2 and 3 compatibility utilities"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
[[package]]
name = "sqlparse"
version = "0.4.2"
description = "A non-validating SQL parser."
category = "main"
optional = false
python-versions = ">=3.5"
[[package]]
name = "tomli"
version = "2.0.1"
description = "A lil' TOML parser"
category = "dev"
optional = false
python-versions = ">=3.7"
[[package]]
name = "urllib3"
version = "1.26.9"
description = "HTTP library with thread-safe connection pooling, file post, and more."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
[package.extras]
brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"]
secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
[[package]]
name = "wrapt"
version = "1.14.1"
description = "Module for decorators, wrappers and monkey patching."
category = "main"