add Nonce (one-time links), add password reset function (missing e-mail verification), closes #7

This commit is contained in:
Jens Langhammer 2019-02-25 20:46:23 +01:00
parent f2569b6424
commit a0d42092e3
No known key found for this signature in database
GPG Key ID: BEBC05297D92821B
11 changed files with 117 additions and 8 deletions

View File

@ -31,6 +31,8 @@
href="{% url 'passbook_admin:user-update' pk=user.pk %}?back={{ request.get_full_path }}">{% trans 'Edit' %}</a> href="{% url 'passbook_admin:user-update' pk=user.pk %}?back={{ request.get_full_path }}">{% trans 'Edit' %}</a>
<a class="btn btn-default btn-sm" <a class="btn btn-default btn-sm"
href="{% url 'passbook_admin:user-delete' pk=user.pk %}?back={{ request.get_full_path }}">{% trans 'Delete' %}</a> href="{% url 'passbook_admin:user-delete' pk=user.pk %}?back={{ request.get_full_path }}">{% trans 'Delete' %}</a>
<a class="btn btn-default btn-sm"
href="{% url 'passbook_admin:user-password-reset' pk=user.pk %}?back={{ request.get_full_path }}">{% trans 'Reset Password' %}</a>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@ -56,6 +56,8 @@ urlpatterns = [
users.UserUpdateView.as_view(), name='user-update'), users.UserUpdateView.as_view(), name='user-update'),
path('users/<int:pk>/delete/', path('users/<int:pk>/delete/',
users.UserDeleteView.as_view(), name='user-delete'), users.UserDeleteView.as_view(), name='user-delete'),
path('users/<int:pk>/reset/',
users.UserPasswordResetView.as_view(), name='user-password-reset'),
# Audit Log # Audit Log
path('audit/', audit.AuditEntryListView.as_view(), name='audit-log'), path('audit/', audit.AuditEntryListView.as_view(), name='audit-log'),
# Groups # Groups

View File

@ -1,12 +1,15 @@
"""passbook User administration""" """passbook User administration"""
from django.contrib import messages
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse_lazy from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.views import View
from django.views.generic import DeleteView, ListView, UpdateView from django.views.generic import DeleteView, ListView, UpdateView
from passbook.admin.mixins import AdminRequiredMixin from passbook.admin.mixins import AdminRequiredMixin
from passbook.core.forms.users import UserDetailForm from passbook.core.forms.users import UserDetailForm
from passbook.core.models import User from passbook.core.models import Nonce, User
class UserListView(AdminRequiredMixin, ListView): class UserListView(AdminRequiredMixin, ListView):
@ -34,3 +37,17 @@ class UserDeleteView(SuccessMessageMixin, AdminRequiredMixin, DeleteView):
success_url = reverse_lazy('passbook_admin:users') success_url = reverse_lazy('passbook_admin:users')
success_message = _('Successfully updated User') success_message = _('Successfully updated User')
class UserPasswordResetView(AdminRequiredMixin, View):
"""Get Password reset link for user"""
# pylint: disable=invalid-name
def get(self, request, pk):
"""Create nonce for user and return link"""
user = get_object_or_404(User, pk=pk)
nonce = Nonce.objects.create(user=user)
link = request.build_absolute_uri(reverse(
'passbook_core:auth-password-reset', kwargs={'nonce': nonce.uuid}))
messages.success(request, _('Password reset link: <pre>%(link)s</pre>' % {'link': link}))
return redirect('passbook_admin:users')

View File

@ -12,6 +12,7 @@ from django.views.generic import FormView
from passbook.core.auth.factor import AuthenticationFactor from passbook.core.auth.factor import AuthenticationFactor
from passbook.core.auth.view import AuthenticationView from passbook.core.auth.view import AuthenticationView
from passbook.core.forms.authentication import PasswordFactorForm from passbook.core.forms.authentication import PasswordFactorForm
from passbook.core.models import Nonce
from passbook.lib.config import CONFIG from passbook.lib.config import CONFIG
LOGGER = getLogger(__name__) LOGGER = getLogger(__name__)
@ -29,7 +30,8 @@ class PasswordFactor(FormView, AuthenticationFactor):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
if 'password-forgotten' in request.GET: if 'password-forgotten' in request.GET:
# TODO: Save nonce key in database for password reset nonce = Nonce.objects.create(user=self.pending_user)
LOGGER.debug("DEBUG %s", str(nonce.uuid))
# TODO: Send email to user # TODO: Send email to user
self.authenticator.cleanup() self.authenticator.cleanup()
messages.success(request, _('Check your E-Mails for a password reset link.')) messages.success(request, _('Check your E-Mails for a password reset link.'))

View File

@ -0,0 +1,31 @@
# Generated by Django 2.1.7 on 2019-02-25 19:12
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import passbook.core.models
class Migration(migrations.Migration):
dependencies = [
('passbook_core', '0011_auto_20190225_1438'),
]
operations = [
migrations.CreateModel(
name='Nonce',
fields=[
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('expires', models.DateTimeField(default=passbook.core.models.default_nonce_duration)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Nonce',
'verbose_name_plural': 'Nonces',
},
),
]

View File

@ -1,5 +1,6 @@
"""passbook core models""" """passbook core models"""
import re import re
from datetime import timedelta
from logging import getLogger from logging import getLogger
from random import SystemRandom from random import SystemRandom
from time import sleep from time import sleep
@ -18,6 +19,11 @@ from passbook.lib.models import CreatedUpdatedModel, UUIDModel
LOGGER = getLogger(__name__) LOGGER = getLogger(__name__)
def default_nonce_duration():
"""Default duration a Nonce is valid"""
return now() + timedelta(hours=4)
class Group(UUIDModel): class Group(UUIDModel):
"""Custom Group model which supports a basic hierarchy""" """Custom Group model which supports a basic hierarchy"""
@ -399,3 +405,17 @@ class Invitation(UUIDModel):
verbose_name = _('Invitation') verbose_name = _('Invitation')
verbose_name_plural = _('Invitations') verbose_name_plural = _('Invitations')
class Nonce(UUIDModel):
"""One-time link for password resets/signup-confirmations"""
expires = models.DateTimeField(default=default_nonce_duration)
user = models.ForeignKey('User', on_delete=models.CASCADE)
def __str__(self):
return "Nonce %s (expires=%s)" % (self.uuid.hex, self.expires)
class Meta:
verbose_name = _('Nonce')
verbose_name_plural = _('Nonces')

View File

@ -29,7 +29,7 @@
<div class="login-pf-page"> <div class="login-pf-page">
<div class="container-fluid"> <div class="container-fluid">
<div class="row"> <div class="row">
<div class="col-sm-6 col-sm-offset-3 col-md-6 col-md-offset-3 col-lg-4 col-lg-offset-4"> <div class="col-sm-12 col-md-8 col-md-offset-2 col-lg-4 col-lg-offset-4">
<header class="login-pf-page-header"> <header class="login-pf-page-header">
<img class="login-pf-brand" style="max-height: 10rem;" src="{% static 'img/logo.svg' %}" <img class="login-pf-brand" style="max-height: 10rem;" src="{% static 'img/logo.svg' %}"
alt="passbook logo" /> alt="passbook logo" />

View File

@ -19,7 +19,10 @@ core_urls = [
path('auth/login/', authentication.LoginView.as_view(), name='auth-login'), path('auth/login/', authentication.LoginView.as_view(), name='auth-login'),
path('auth/logout/', authentication.LogoutView.as_view(), name='auth-logout'), path('auth/logout/', authentication.LogoutView.as_view(), name='auth-logout'),
path('auth/sign_up/', authentication.SignUpView.as_view(), name='auth-sign-up'), path('auth/sign_up/', authentication.SignUpView.as_view(), name='auth-sign-up'),
# path('auth/sign_up/<uuid:nonce>/confirm/', , name='auth-sign-up-confirm'),
path('auth/process/denied/', view.FactorPermissionDeniedView.as_view(), name='auth-denied'), path('auth/process/denied/', view.FactorPermissionDeniedView.as_view(), name='auth-denied'),
path('auth/password/reset/<uuid:nonce>/', authentication.PasswordResetView.as_view(),
name='auth-password-reset'),
path('auth/process/', view.AuthenticationView.as_view(), name='auth-process'), path('auth/process/', view.AuthenticationView.as_view(), name='auth-process'),
path('auth/process/<slug:factor>/', view.AuthenticationView.as_view(), name='auth-process'), path('auth/process/<slug:factor>/', view.AuthenticationView.as_view(), name='auth-process'),
# User views # User views

View File

@ -3,17 +3,17 @@ from logging import getLogger
from typing import Dict from typing import Dict
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import logout from django.contrib.auth import login, logout
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.shortcuts import redirect, reverse from django.shortcuts import get_object_or_404, redirect, reverse
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.views import View from django.views import View
from django.views.generic import FormView from django.views.generic import FormView
from passbook.core.auth.view import AuthenticationView from passbook.core.auth.view import AuthenticationView
from passbook.core.forms.authentication import LoginForm, SignUpForm from passbook.core.forms.authentication import LoginForm, SignUpForm
from passbook.core.models import Invitation, Source, User from passbook.core.models import Invitation, Nonce, Source, User
from passbook.core.signals import invitation_used, user_signed_up from passbook.core.signals import invitation_used, user_signed_up
from passbook.lib.config import CONFIG from passbook.lib.config import CONFIG
@ -190,3 +190,18 @@ class SignUpView(UserPassesTestMixin, FormView):
# Create Account Confirmation UUID # Create Account Confirmation UUID
# AccountConfirmation.objects.create(user=new_user) # AccountConfirmation.objects.create(user=new_user)
return new_user return new_user
class PasswordResetView(View):
"""Temporarily authenticate User and allow them to reset their password"""
def get(self, request, nonce):
"""Authenticate user with nonce and redirect to password change view"""
# 3. (Optional) Trap user in password change view
nonce = get_object_or_404(Nonce, uuid=nonce)
# Workaround: hardcoded reference to ModelBackend, needs testing
nonce.user.backend = 'django.contrib.auth.backends.ModelBackend'
login(request, nonce.user)
nonce.delete()
messages.success(request, _(('Temporarily authenticated with Nonce, '
'please change your password')))
return redirect('passbook_core:user-change-password')

View File

@ -0,0 +1,17 @@
# Generated by Django 2.1.7 on 2019-02-25 19:12
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('passbook_hibp_policy', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='haveibeenpwendpolicy',
options={'verbose_name': 'have i been pwned Policy', 'verbose_name_plural': 'have i been pwned Policies'},
),
]

View File

@ -61,7 +61,7 @@ passbook:
# Specify which fields can be used to authenticate. Can be any combination of `username` and `email` # Specify which fields can be used to authenticate. Can be any combination of `username` and `email`
uid_fields: uid_fields:
- username - username
- e-mail - email
# Factors to load # Factors to load
factors: factors:
- passbook.core.auth.factors.backend - passbook.core.auth.factors.backend