add Nonce (one-time links), add password reset function (missing e-mail verification), closes #7
This commit is contained in:
		@ -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 %}
 | 
				
			||||||
 | 
				
			|||||||
@ -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
 | 
				
			||||||
 | 
				
			|||||||
@ -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')
 | 
				
			||||||
 | 
				
			|||||||
@ -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.'))
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										31
									
								
								passbook/core/migrations/0012_nonce.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								passbook/core/migrations/0012_nonce.py
									
									
									
									
									
										Normal 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',
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
@ -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')
 | 
				
			||||||
 | 
				
			|||||||
@ -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" />
 | 
				
			||||||
 | 
				
			|||||||
@ -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
 | 
				
			||||||
 | 
				
			|||||||
@ -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')
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										17
									
								
								passbook/hibp_policy/migrations/0002_auto_20190225_1912.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								passbook/hibp_policy/migrations/0002_auto_20190225_1912.py
									
									
									
									
									
										Normal 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'},
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
@ -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
 | 
				
			||||||
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user