From 3d8242be061005f1cf460803c360f6960d3c367c Mon Sep 17 00:00:00 2001 From: "Langhammer, Jens" Date: Thu, 10 Oct 2019 14:04:58 +0200 Subject: [PATCH 1/2] core(minor): add new, optional description field to nonce --- .../core/migrations/0002_nonce_description.py | 18 ++++++++++++++++++ passbook/core/models.py | 12 +++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 passbook/core/migrations/0002_nonce_description.py diff --git a/passbook/core/migrations/0002_nonce_description.py b/passbook/core/migrations/0002_nonce_description.py new file mode 100644 index 000000000..2e98fe1b8 --- /dev/null +++ b/passbook/core/migrations/0002_nonce_description.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.6 on 2019-10-10 11:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('passbook_core', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='nonce', + name='description', + field=models.TextField(blank=True, default=''), + ), + ] diff --git a/passbook/core/models.py b/passbook/core/models.py index ee433b7f5..4b413151e 100644 --- a/passbook/core/models.py +++ b/passbook/core/models.py @@ -57,6 +57,7 @@ class User(AbstractUser): self.password_change_date = now() return super().set_password(password) + class Provider(models.Model): """Application-independent Provider instance. For example SAML2 Remote, OAuth2 Application""" @@ -70,6 +71,7 @@ class Provider(models.Model): return getattr(self, 'name') return super().__str__() + class PolicyModel(UUIDModel, CreatedUpdatedModel): """Base model which can have policies applied to it""" @@ -255,21 +257,29 @@ class Invitation(UUIDModel): verbose_name = _('Invitation') verbose_name_plural = _('Invitations') + class Nonce(UUIDModel): """One-time link for password resets/sign-up-confirmations""" expires = models.DateTimeField(default=default_nonce_duration) user = models.ForeignKey('User', on_delete=models.CASCADE) expiring = models.BooleanField(default=True) + description = models.TextField(default='', blank=True) + + @property + def is_expired(self) -> bool: + """Check if nonce is expired yet.""" + return now() > self.expires def __str__(self): - return f"Nonce f{self.uuid.hex} (expires={self.expires})" + return f"Nonce f{self.uuid.hex} {self.description} (expires={self.expires})" class Meta: verbose_name = _('Nonce') verbose_name_plural = _('Nonces') + class PropertyMapping(UUIDModel): """User-defined key -> x mapping which can be used by providers to expose extra data.""" From b9991465ee56a35cec9f3f90114e390160ff723a Mon Sep 17 00:00:00 2001 From: "Langhammer, Jens" Date: Thu, 10 Oct 2019 14:05:16 +0200 Subject: [PATCH 2/2] recovery(new): add recovery app to create recovery links --- passbook/recovery/__init__.py | 0 passbook/recovery/apps.py | 11 +++++ passbook/recovery/management/__init__.py | 0 .../recovery/management/commands/__init__.py | 0 .../commands/create_recovery_key.py | 46 +++++++++++++++++++ passbook/recovery/urls.py | 9 ++++ passbook/recovery/views.py | 24 ++++++++++ passbook/root/settings.py | 1 + 8 files changed, 91 insertions(+) create mode 100644 passbook/recovery/__init__.py create mode 100644 passbook/recovery/apps.py create mode 100644 passbook/recovery/management/__init__.py create mode 100644 passbook/recovery/management/commands/__init__.py create mode 100644 passbook/recovery/management/commands/create_recovery_key.py create mode 100644 passbook/recovery/urls.py create mode 100644 passbook/recovery/views.py diff --git a/passbook/recovery/__init__.py b/passbook/recovery/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/passbook/recovery/apps.py b/passbook/recovery/apps.py new file mode 100644 index 000000000..0a6db1a06 --- /dev/null +++ b/passbook/recovery/apps.py @@ -0,0 +1,11 @@ +"""passbook Recovery app config""" +from django.apps import AppConfig + + +class PassbookRecoveryConfig(AppConfig): + """passbook Recovery app config""" + + name = 'passbook.recovery' + label = 'passbook_recovery' + verbose_name = 'passbook Recovery' + mountpoint = 'recovery/' diff --git a/passbook/recovery/management/__init__.py b/passbook/recovery/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/passbook/recovery/management/commands/__init__.py b/passbook/recovery/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/passbook/recovery/management/commands/create_recovery_key.py b/passbook/recovery/management/commands/create_recovery_key.py new file mode 100644 index 000000000..49a3fc3ef --- /dev/null +++ b/passbook/recovery/management/commands/create_recovery_key.py @@ -0,0 +1,46 @@ +"""passbook recovery createkey command""" +from datetime import timedelta +from getpass import getuser + +from django.core.management.base import BaseCommand +from django.urls import reverse +from django.utils.timezone import now +from django.utils.translation import gettext as _ +from structlog import get_logger + +from passbook.core.models import Nonce, User +from passbook.lib.config import CONFIG + +LOGGER = get_logger() + + +class Command(BaseCommand): + """Create Nonce used to recover access""" + + help = _('Create a Key which can be used to restore access to passbook.') + + def add_arguments(self, parser): + parser.add_argument('duration', default=1, action='store', + help='How long the token is valid for (in years).') + parser.add_argument('user', action='store', + help='Which user the Token gives access to.') + + def get_url(self, nonce: Nonce) -> str: + """Get full recovery link""" + path = reverse('passbook_recovery:use-nonce', kwargs={'uuid': str(nonce.uuid)}) + return f"https://{CONFIG.y('domain')}{path}" + + def handle(self, *args, **options): + """Create Nonce used to recover access""" + duration = int(options.get('duration', 1)) + delta = timedelta(days=duration * 365.2425) + _now = now() + expiry = _now + delta + user = User.objects.get(username=options.get('user')) + nonce = Nonce.objects.create( + expires=expiry, + user=user, + description=f'Recovery Nonce generated by {getuser()} on {_now}') + self.stdout.write((f"Store this link safely, as it will allow" + f" anyone to access passbook as {user}.")) + self.stdout.write(self.get_url(nonce)) diff --git a/passbook/recovery/urls.py b/passbook/recovery/urls.py new file mode 100644 index 000000000..36a3f93f6 --- /dev/null +++ b/passbook/recovery/urls.py @@ -0,0 +1,9 @@ +"""recovery views""" + +from django.urls import path + +from passbook.recovery.views import UseNonceView + +urlpatterns = [ + path('use-nonce//', UseNonceView.as_view(), name='use-nonce'), +] diff --git a/passbook/recovery/views.py b/passbook/recovery/views.py new file mode 100644 index 000000000..fd870138e --- /dev/null +++ b/passbook/recovery/views.py @@ -0,0 +1,24 @@ +"""recovery views""" +from django.contrib import messages +from django.contrib.auth import login +from django.http import Http404, HttpRequest, HttpResponse +from django.shortcuts import get_object_or_404, redirect +from django.utils.translation import gettext as _ +from django.views import View + +from passbook.core.models import Nonce + + +class UseNonceView(View): + """Use nonce to login""" + + def get(self, request: HttpRequest, uuid: str) -> HttpResponse: + """Check if nonce exists, log user in and delete nonce.""" + nonce: Nonce = get_object_or_404(Nonce, pk=uuid) + if nonce.is_expired: + nonce.delete() + raise Http404 + login(request, nonce.user, backend='django.contrib.auth.backends.ModelBackend') + nonce.delete() + messages.warning(request, _("Used recovery-link to authenticate.")) + return redirect('passbook_core:overview') diff --git a/passbook/root/settings.py b/passbook/root/settings.py index e09dfe6eb..0c7041798 100644 --- a/passbook/root/settings.py +++ b/passbook/root/settings.py @@ -72,6 +72,7 @@ INSTALLED_APPS = [ 'passbook.api.apps.PassbookAPIConfig', 'passbook.lib.apps.PassbookLibConfig', 'passbook.audit.apps.PassbookAuditConfig', + 'passbook.recovery.apps.PassbookRecoveryConfig', 'passbook.sources.ldap.apps.PassbookSourceLDAPConfig', 'passbook.sources.oauth.apps.PassbookSourceOAuthConfig',