core: add key field to token for easier rotation

This commit is contained in:
Jens Langhammer 2020-10-18 14:34:22 +02:00
parent 36e095671c
commit ee670d5e19
18 changed files with 168 additions and 361 deletions

View File

@ -158,7 +158,7 @@ class UserPasswordResetView(LoginRequiredMixin, PermissionRequiredMixin, DetailV
token, _ = Token.objects.get_or_create( token, _ = Token.objects.get_or_create(
identifier="password-reset-temp", user=self.object identifier="password-reset-temp", user=self.object
) )
querystring = urlencode({"token": token.token_uuid}) querystring = urlencode({"token": token.key})
link = request.build_absolute_uri( link = request.build_absolute_uri(
reverse("passbook_flows:default-recovery") + f"?{querystring}" reverse("passbook_flows:default-recovery") + f"?{querystring}"
) )

View File

@ -1,43 +1,54 @@
"""API Authentication""" """API Authentication"""
from base64 import b64decode from base64 import b64decode
from typing import Any, Tuple, Union from typing import Any, Optional, Tuple, Union
from django.utils.translation import gettext as _
from rest_framework import HTTP_HEADER_ENCODING, exceptions
from rest_framework.authentication import BaseAuthentication, get_authorization_header from rest_framework.authentication import BaseAuthentication, get_authorization_header
from rest_framework.request import Request from rest_framework.request import Request
from structlog import get_logger
from passbook.core.models import Token, TokenIntents, User from passbook.core.models import Token, TokenIntents, User
LOGGER = get_logger()
def token_from_header(raw_header: bytes) -> Optional[Token]:
"""raw_header in the Format of `Basic dGVzdDp0ZXN0`"""
auth_credentials = raw_header.decode()
# Accept headers with Type format and without
if " " in auth_credentials:
auth_type, auth_credentials = auth_credentials.split()
if auth_type.lower() != "basic":
LOGGER.debug(
"Unsupported authentication type, denying", type=auth_type.lower()
)
return None
auth_credentials = b64decode(auth_credentials.encode()).decode()
# Accept credentials with username and without
if ":" in auth_credentials:
_, password = auth_credentials.split(":")
else:
password = auth_credentials
if password == "":
return None
tokens = Token.filter_not_expired(key=password, intent=TokenIntents.INTENT_API)
if not tokens.exists():
LOGGER.debug("Token not found")
return None
return tokens.first()
class PassbookTokenAuthentication(BaseAuthentication): class PassbookTokenAuthentication(BaseAuthentication):
"""Token-based authentication using HTTP Basic authentication""" """Token-based authentication using HTTP Basic authentication"""
def authenticate(self, request: Request) -> Union[Tuple[User, Any], None]: def authenticate(self, request: Request) -> Union[Tuple[User, Any], None]:
"""Token-based authentication using HTTP Basic authentication""" """Token-based authentication using HTTP Basic authentication"""
auth = get_authorization_header(request).split() auth = get_authorization_header(request)
if not auth or auth[0].lower() != b"basic": token = token_from_header(auth)
if not token:
return None return None
if len(auth) == 1: return (token.user, None)
msg = _("Invalid basic header. No credentials provided.")
raise exceptions.AuthenticationFailed(msg)
if len(auth) > 2:
msg = _(
"Invalid basic header. Credentials string should not contain spaces."
)
raise exceptions.AuthenticationFailed(msg)
header_data = b64decode(auth[1]).decode(HTTP_HEADER_ENCODING).partition(":")
tokens = Token.filter_not_expired(
token_uuid=header_data[2], intent=TokenIntents.INTENT_API
)
if not tokens.exists():
raise exceptions.AuthenticationFailed(_("Invalid token."))
return (tokens.first().user, None)
def authenticate_header(self, request: Request) -> str: def authenticate_header(self, request: Request) -> str:
return 'Basic realm="passbook"' return 'Basic realm="passbook"'

View File

@ -1,7 +1,14 @@
"""Tokens API Viewset""" """Tokens API Viewset"""
from uuid import UUID
from django.http.response import Http404
from rest_framework.decorators import action
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from passbook.audit.models import Event, EventAction
from passbook.core.models import Token from passbook.core.models import Token
@ -17,6 +24,17 @@ class TokenSerializer(ModelSerializer):
class TokenViewSet(ModelViewSet): class TokenViewSet(ModelViewSet):
"""Token Viewset""" """Token Viewset"""
queryset = Token.objects.all()
lookup_field = "identifier" lookup_field = "identifier"
queryset = Token.filter_not_expired()
serializer_class = TokenSerializer serializer_class = TokenSerializer
@action(detail=True)
# pylint: disable=invalid-name
def view_key(self, request: Request, pk: UUID) -> Response:
"""Return token key and log access"""
tokens = Token.filter_not_expired(pk=pk)
if not tokens.exists():
raise Http404
token = tokens.first()
Event.new(EventAction.TOKEN_VIEW, token=token).from_http(request)
return Response({"key": token.key})

View File

@ -1,9 +1,9 @@
"""Channels base classes""" """Channels base classes"""
from channels.generic.websocket import JsonWebsocketConsumer from channels.generic.websocket import JsonWebsocketConsumer
from django.core.exceptions import ValidationError
from structlog import get_logger from structlog import get_logger
from passbook.core.models import Token, TokenIntents, User from passbook.api.auth import token_from_header
from passbook.core.models import User
LOGGER = get_logger() LOGGER = get_logger()
@ -20,19 +20,13 @@ class AuthJsonConsumer(JsonWebsocketConsumer):
self.close() self.close()
return False return False
token = headers[b"authorization"] raw_header = headers[b"authorization"]
try:
token_uuid = token.decode("utf-8") token = token_from_header(raw_header)
tokens = Token.filter_not_expired( if not token:
token_uuid=token_uuid, intent=TokenIntents.INTENT_API LOGGER.warning("Failed to authenticate")
)
if not tokens.exists():
LOGGER.warning("WS Request with invalid token")
self.close()
return False
except ValidationError:
LOGGER.warning("WS Invalid UUID")
self.close() self.close()
return False return False
self.user = tokens.first().user
self.user = token.user
return True return True

View File

@ -0,0 +1,50 @@
# Generated by Django 3.1.2 on 2020-10-18 11:58
from django.apps.registry import Apps
from django.db import migrations, models
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
import passbook.core.models
def set_default_token_key(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
db_alias = schema_editor.connection.alias
Token = apps.get_model("passbook_core", "Token")
for token in Token.objects.using(db_alias).all():
token.key = token.pk.hex
token.save()
class Migration(migrations.Migration):
dependencies = [
("passbook_core", "0013_auto_20201003_2132"),
]
operations = [
migrations.AddField(
model_name="token",
name="key",
field=models.TextField(default=passbook.core.models.default_token_key),
),
migrations.AlterUniqueTogether(
name="token",
unique_together=set(),
),
migrations.AlterField(
model_name="token",
name="identifier",
field=models.CharField(max_length=255),
),
migrations.AddIndex(
model_name="token",
index=models.Index(fields=["key"], name="passbook_co_key_e45007_idx"),
),
migrations.AddIndex(
model_name="token",
index=models.Index(
fields=["identifier"], name="passbook_co_identif_1a34a8_idx"
),
),
migrations.RunPython(set_default_token_key),
]

View File

@ -32,6 +32,11 @@ def default_token_duration():
return now() + timedelta(minutes=30) return now() + timedelta(minutes=30)
def default_token_key():
"""Default token key"""
return uuid4().hex
class Group(models.Model): class Group(models.Model):
"""Custom Group model which supports a basic hierarchy""" """Custom Group model which supports a basic hierarchy"""
@ -274,10 +279,8 @@ class ExpiringModel(models.Model):
def filter_not_expired(cls, **kwargs) -> QuerySet: def filter_not_expired(cls, **kwargs) -> QuerySet:
"""Filer for tokens which are not expired yet or are not expiring, """Filer for tokens which are not expired yet or are not expiring,
and match filters in `kwargs`""" and match filters in `kwargs`"""
query = Q(**kwargs) expired = Q(expires__lt=now(), expiring=True)
query_not_expired_yet = Q(expires__lt=now(), expiring=True) return cls.objects.exclude(expired).filter(**kwargs)
query_not_expiring = Q(expiring=False)
return cls.objects.filter(query & (query_not_expired_yet | query_not_expiring))
@property @property
def is_expired(self) -> bool: def is_expired(self) -> bool:
@ -298,6 +301,7 @@ class TokenIntents(models.TextChoices):
# Allow access to API # Allow access to API
INTENT_API = "api" INTENT_API = "api"
# Recovery use for the recovery app
INTENT_RECOVERY = "recovery" INTENT_RECOVERY = "recovery"
@ -305,7 +309,8 @@ class Token(ExpiringModel):
"""Token used to authenticate the User for API Access or confirm another Stage like Email.""" """Token used to authenticate the User for API Access or confirm another Stage like Email."""
token_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) token_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
identifier = models.TextField() identifier = models.CharField(max_length=255)
key = models.TextField(default=default_token_key)
intent = models.TextField( intent = models.TextField(
choices=TokenIntents.choices, default=TokenIntents.INTENT_VERIFICATION choices=TokenIntents.choices, default=TokenIntents.INTENT_VERIFICATION
) )
@ -313,13 +318,19 @@ class Token(ExpiringModel):
description = models.TextField(default="", blank=True) description = models.TextField(default="", blank=True)
def __str__(self): def __str__(self):
return f"Token {self.identifier} (expires={self.expires})" description = f"{self.identifier}"
if self.expiring:
description += f" (expires={self.expires})"
return description
class Meta: class Meta:
verbose_name = _("Token") verbose_name = _("Token")
verbose_name_plural = _("Tokens") verbose_name_plural = _("Tokens")
unique_together = (("identifier", "user"),) indexes = [
models.Index(fields=["identifier"]),
models.Index(fields=["key"]),
]
class PropertyMapping(models.Model): class PropertyMapping(models.Model):

View File

@ -30,7 +30,7 @@ class DockerController(BaseController):
return { return {
"PASSBOOK_HOST": self.outpost.config.passbook_host, "PASSBOOK_HOST": self.outpost.config.passbook_host,
"PASSBOOK_INSECURE": str(self.outpost.config.passbook_host_insecure), "PASSBOOK_INSECURE": str(self.outpost.config.passbook_host_insecure),
"PASSBOOK_TOKEN": self.outpost.token.token_uuid.hex, "PASSBOOK_TOKEN": self.outpost.token.key,
} }
def _comp_env(self, container: Container) -> bool: def _comp_env(self, container: Container) -> bool:
@ -136,7 +136,7 @@ class DockerController(BaseController):
"PASSBOOK_INSECURE": str( "PASSBOOK_INSECURE": str(
self.outpost.config.passbook_host_insecure self.outpost.config.passbook_host_insecure
), ),
"PASSBOOK_TOKEN": self.outpost.token.token_uuid.hex, "PASSBOOK_TOKEN": self.outpost.token.key,
}, },
} }
}, },

View File

@ -18,6 +18,7 @@ def fix_missing_token_identifier(apps: Apps, schema_editor: BaseDatabaseSchemaEd
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("passbook_core", "0014_auto_20201018_1158"),
("passbook_outposts", "0008_auto_20201014_1547"), ("passbook_outposts", "0008_auto_20201014_1547"),
] ]

View File

@ -24,7 +24,7 @@
<label class="pf-c-form__label" for="help-text-simple-form-name"> <label class="pf-c-form__label" for="help-text-simple-form-name">
<span class="pf-c-form__label-text">PASSBOOK_TOKEN</span> <span class="pf-c-form__label-text">PASSBOOK_TOKEN</span>
</label> </label>
<input class="pf-c-form-control" data-pb-fetch-key="pk" data-pb-fetch-fill="{% url 'passbook_api:token-detail' identifier=outpost.token_identifier %}" readonly type="text" value="" /> <input class="pf-c-form-control" data-pb-fetch-key="key" data-pb-fetch-fill="{% url 'passbook_api:token-view-key' identifier=outpost.token_identifier %}" readonly type="text" value="" />
</div> </div>
<h3>{% trans 'If your passbook Instance is using a self-signed certificate, set this value.' %}</h3> <h3>{% trans 'If your passbook Instance is using a self-signed certificate, set this value.' %}</h3>
<div class="pf-c-form__group"> <div class="pf-c-form__group">

View File

@ -1,68 +0,0 @@
{% load i18n %}
{% load static %}
<div class="pf-c-dropdown">
<button class="pf-c-button pf-m-tertiary pf-c-dropdown__toggle" type="button">
<span class="pf-c-dropdown__toggle-text">{% trans 'Setup with...' %}</span>
<i class="fas fa-caret-down pf-c-dropdown__toggle-icon" aria-hidden="true"></i>
</button>
<ul class="pf-c-dropdown__menu" hidden>
<li>
<button class="pf-c-dropdown__menu-item" data-target="modal" data-modal="docker-compose-{{ provider.pk }}">{% trans 'docker-compose' %}</button>
</li>
<li>
<button class="pf-c-dropdown__menu-item" data-target="modal" data-modal="k8s-{{ provider.pk }}">{% trans 'Kubernetes' %}</button>
</li>
</ul>
</div>
<div class="pf-c-backdrop" id="docker-compose-{{ provider.pk }}" hidden>
<div class="pf-l-bullseye">
<div class="pf-c-modal-box pf-m-lg" role="dialog">
<button data-modal-close class="pf-c-button pf-m-plain" type="button" aria-label="Close dialog">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
<div class="pf-c-modal-box__header">
<h1 class="pf-c-title pf-m-2xl">{% trans 'Setup with docker-compose' %}</h1>
</div>
<div class="pf-c-modal-box__body">
{% trans 'Add the following snippet to your docker-compose file.' %}
<textarea class="codemirror" readonly data-cm-mode="yaml">{{ docker_compose }}</textarea>
</div>
<footer class="pf-c-modal-box__footer pf-m-align-left">
<button data-modal-close class="pf-c-button pf-m-primary" type="button">{% trans 'Close' %}</button>
</footer>
</div>
</div>
</div>
<div class="pf-c-backdrop" id="k8s-{{ provider.pk }}" hidden>
<div class="pf-l-bullseye">
<div class="pf-c-modal-box pf-m-lg" role="dialog">
<button data-modal-close class="pf-c-button pf-m-plain" type="button" aria-label="Close dialog">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
<div class="pf-c-modal-box__header">
<h1 class="pf-c-title pf-m-2xl">{% trans 'Setup with Kubernetes' %}</h1>
</div>
<div class="pf-c-modal-box__body">
<p>{% trans 'Download the manifest to create the Proxy deployment and service:' %}</p>
<a href="{% url 'passbook_providers_proxy:k8s-manifest' provider=provider.pk %}">{% trans 'Here' %}</a>
<p>{% trans 'Afterwards, add the following annotations to the Ingress you want to secure:' %}</p>
<textarea class="codemirror" readonly data-cm-mode="yaml">
nginx.ingress.kubernetes.io/auth-signin: https://$host/oauth2/start?rd=$escaped_request_uri
nginx.ingress.kubernetes.io/auth-url: https://$host/oauth2/auth
nginx.ingress.kubernetes.io/configuration-snippet: |
auth_request_set $user_id $upstream_http_x_auth_request_user;
auth_request_set $email $upstream_http_x_auth_request_email;
auth_request_set $user_name $upstream_http_x_auth_request_preferred_username;
proxy_set_header X-User-Id $user_id;
proxy_set_header X-User $user_name;
proxy_set_header X-Email $email;
</textarea>
</div>
<footer class="pf-c-modal-box__footer pf-m-align-left">
<button data-modal-close class="pf-c-button pf-m-primary" type="button">{% trans 'Close' %}</button>
</footer>
</div>
</div>
</div>

View File

@ -1,59 +0,0 @@
{% extends "administration/base.html" %}
{% load i18n %}
{% load humanize %}
{% load passbook_utils %}
{% block head %}
{{ block.super }}
<style>
.pf-m-success {
color: var(--pf-global--success-color--100);
}
.pf-m-danger {
color: var(--pf-global--danger-color--100);
}
</style>
{% endblock %}
{% block content %}
<section class="pf-c-page__main-section pf-m-light">
<div class="pf-c-content">
<h1>
<i class="fas fa-map-marker"></i>
{% trans 'Outpost Setup' %}
</h1>
<p>{% trans "Outposts are deployments of passbook components to support different environments and protocols, like reverse proxies." %}</p>
</div>
</section>
<div class="pf-c-tabs pf-m-fill" id="filled-example">
<button class="pf-c-tabs__scroll-button" disabled aria-hidden="true" aria-label="Scroll left">
<i class="fas fa-angle-left" aria-hidden="true"></i>
</button>
<ul class="pf-c-tabs__list">
<li class="pf-c-tabs__item">
<button class="pf-c-tabs__link" id="filled-example-users-link">
<span class="pf-c-tabs__item-text">Users</span>
</button>
</li>
<li class="pf-c-tabs__item pf-m-current">
<button class="pf-c-tabs__link" id="filled-example-containers-link">
<span class="pf-c-tabs__item-text">Containers</span>
</button>
</li>
<li class="pf-c-tabs__item">
<button class="pf-c-tabs__link" id="filled-example-database-link">
<span class="pf-c-tabs__item-text">Database</span>
</button>
</li>
</ul>
<button class="pf-c-tabs__scroll-button" disabled aria-hidden="true" aria-label="Scroll right">
<i class="fas fa-angle-right" aria-hidden="true"></i>
</button>
</div>
<section class="pf-c-page__main-section pf-m-no-padding-mobile">
<div class="pf-c-card">
</div>
</section>
{% endblock %}

View File

@ -1,59 +0,0 @@
{% extends "administration/base.html" %}
{% load i18n %}
{% load humanize %}
{% load passbook_utils %}
{% block head %}
{{ block.super }}
<style>
.pf-m-success {
color: var(--pf-global--success-color--100);
}
.pf-m-danger {
color: var(--pf-global--danger-color--100);
}
</style>
{% endblock %}
{% block content %}
<section class="pf-c-page__main-section pf-m-light">
<div class="pf-c-content">
<h1>
<i class="fas fa-map-marker"></i>
{% trans 'Outpost Setup' %}
</h1>
<p>{% trans "Outposts are deployments of passbook components to support different environments and protocols, like reverse proxies." %}</p>
</div>
</section>
<div class="pf-c-tabs pf-m-fill" id="filled-example">
<button class="pf-c-tabs__scroll-button" disabled aria-hidden="true" aria-label="Scroll left">
<i class="fas fa-angle-left" aria-hidden="true"></i>
</button>
<ul class="pf-c-tabs__list">
<li class="pf-c-tabs__item">
<button class="pf-c-tabs__link" id="filled-example-users-link">
<span class="pf-c-tabs__item-text">Users</span>
</button>
</li>
<li class="pf-c-tabs__item pf-m-current">
<button class="pf-c-tabs__link" id="filled-example-containers-link">
<span class="pf-c-tabs__item-text">Containers</span>
</button>
</li>
<li class="pf-c-tabs__item">
<button class="pf-c-tabs__link" id="filled-example-database-link">
<span class="pf-c-tabs__item-text">Database</span>
</button>
</li>
</ul>
<button class="pf-c-tabs__scroll-button" disabled aria-hidden="true" aria-label="Scroll right">
<i class="fas fa-angle-right" aria-hidden="true"></i>
</button>
</div>
<section class="pf-c-page__main-section pf-m-no-padding-mobile">
<div class="pf-c-card">
</div>
</section>
{% endblock %}

View File

@ -1,96 +0,0 @@
{% extends "administration/base.html" %}
{% load i18n %}
{% load humanize %}
{% load passbook_utils %}
{% block content %}
<section class="pf-c-page__main-section pf-m-light">
<div class="pf-c-content">
<h1>
<i class="fas fa-map-marker"></i>
{% trans 'Outpost Setup' %}
</h1>
<p>{% trans "Outposts are deployments of passbook components to support different environments and protocols, like reverse proxies." %}</p>
</div>
</section>
<section class="pf-c-page__main-section pf-m-no-padding-mobile">
<div class="pf-c-card">
<pre>apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app.kubernetes.io/name: "passbook-{{ outpost.type }}"
app.kubernetes.io/instance: "{{ outpost.name }}"
passbook.beryju.org/outpost: "{{ outpost.pk.hex }}"
name: "passbook-{{ outpost.type }}-{{ outpost.name }}"
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: "passbook-{{ outpost.type }}"
app.kubernetes.io/instance: "{{ outpost.name }}"
passbook.beryju.org/outpost: "{{ outpost.pk.hex }}"
template:
metadata:
labels:
app.kubernetes.io/name: "passbook-{{ outpost.type }}"
app.kubernetes.io/instance: "{{ outpost.name }}"
passbook.beryju.org/outpost: "{{ outpost.pk.hex }}"
spec:
containers:
- env:
- name: PASSBOOK_HOST
value: "{{ host }}"
- name: PASSBOOK_TOKEN
value: "{{ outpost.token.pk.hex }}"
image: beryju/passbook-{{ outpost.type }}:{{ version }}
name: "passbook-{{ outpost.type }}"
ports:
- containerPort: 4180
protocol: TCP
name: http
- containerPort: 4443
protocol: TCP
name: https
---
apiVersion: v1
kind: Service
metadata:
labels:
app.kubernetes.io/name: "passbook-{{ outpost.type }}"
app.kubernetes.io/instance: "{{ outpost.name }}"
passbook.beryju.org/outpost: "{{ outpost.pk.hex }}"
name: "passbook-{{ outpost.type }}-{{ outpost.name }}"
spec:
ports:
- name: http
port: 4180
protocol: TCP
targetPort: 4180
- name: https
port: 4443
protocol: TCP
targetPort: 4443
selector:
app.kubernetes.io/name: "passbook-{{ outpost.type }}"
app.kubernetes.io/instance: "{{ outpost.name }}"
passbook.beryju.org/outpost: "{{ outpost.pk.hex }}"
---
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: "passbook-{{ outpost.type }}-{{ outpost.name }}"
spec:
rules:
- host: "{{ provider.external_host }}"
http:
paths:
- backend:
serviceName: "passbook-{{ outpost.type }}-{{ outpost.name }}"
servicePort: 4180
path: "/pbprox"
</pre>
</div>
</section>
{% endblock %}

View File

@ -9,7 +9,6 @@ from django.utils.translation import gettext as _
from structlog import get_logger from structlog import get_logger
from passbook.core.models import Token, TokenIntents, User from passbook.core.models import Token, TokenIntents, User
from passbook.lib.config import CONFIG
LOGGER = get_logger() LOGGER = get_logger()
@ -32,22 +31,17 @@ class Command(BaseCommand):
def get_url(self, token: Token) -> str: def get_url(self, token: Token) -> str:
"""Get full recovery link""" """Get full recovery link"""
path = reverse( return reverse("passbook_recovery:use-token", kwargs={"key": str(token.key)})
"passbook_recovery:use-token", kwargs={"uuid": str(token.token_uuid)}
)
return f"https://{CONFIG.y('domain')}{path}"
def handle(self, *args, **options): def handle(self, *args, **options):
"""Create Token used to recover access""" """Create Token used to recover access"""
duration = int(options.get("duration", 1)) duration = int(options.get("duration", 1))
delta = timedelta(days=duration * 365.2425)
_now = now() _now = now()
expiry = _now + delta expiry = _now + timedelta(days=duration * 365.2425)
user = User.objects.get(username=options.get("user")) user = User.objects.get(username=options.get("user"))
token = Token.objects.create( token = Token.objects.create(
expires=expiry, expires=expiry,
user=user, user=user,
identifier="recovery",
intent=TokenIntents.INTENT_RECOVERY, intent=TokenIntents.INTENT_RECOVERY,
description=f"Recovery Token generated by {getuser()} on {_now}", description=f"Recovery Token generated by {getuser()} on {_now}",
) )

View File

@ -5,8 +5,7 @@ from django.core.management import call_command
from django.shortcuts import reverse from django.shortcuts import reverse
from django.test import TestCase from django.test import TestCase
from passbook.core.models import Token, User from passbook.core.models import Token, TokenIntents, User
from passbook.lib.config import CONFIG
class TestRecovery(TestCase): class TestRecovery(TestCase):
@ -17,21 +16,19 @@ class TestRecovery(TestCase):
def test_create_key(self): def test_create_key(self):
"""Test creation of a new key""" """Test creation of a new key"""
CONFIG.update_from_dict({"domain": "testserver"})
out = StringIO() out = StringIO()
self.assertEqual(len(Token.objects.all()), 0) self.assertEqual(len(Token.objects.all()), 0)
call_command("create_recovery_key", "1", self.user.username, stdout=out) call_command("create_recovery_key", "1", self.user.username, stdout=out)
self.assertIn("https://testserver/recovery/use-token/", out.getvalue()) token = Token.objects.get(intent=TokenIntents.INTENT_RECOVERY, user=self.user)
self.assertIn(token.key, out.getvalue())
self.assertEqual(len(Token.objects.all()), 1) self.assertEqual(len(Token.objects.all()), 1)
def test_recovery_view(self): def test_recovery_view(self):
"""Test recovery view""" """Test recovery view"""
out = StringIO() out = StringIO()
call_command("create_recovery_key", "1", self.user.username, stdout=out) call_command("create_recovery_key", "1", self.user.username, stdout=out)
token = Token.objects.first() token = Token.objects.get(intent=TokenIntents.INTENT_RECOVERY, user=self.user)
self.client.get( self.client.get(
reverse( reverse("passbook_recovery:use-token", kwargs={"key": token.key})
"passbook_recovery:use-token", kwargs={"uuid": str(token.token_uuid)}
)
) )
self.assertEqual(int(self.client.session["_auth_user_id"]), token.user.pk) self.assertEqual(int(self.client.session["_auth_user_id"]), token.user.pk)

View File

@ -5,5 +5,5 @@ from django.urls import path
from passbook.recovery.views import UseTokenView from passbook.recovery.views import UseTokenView
urlpatterns = [ urlpatterns = [
path("use-token/<uuid:uuid>/", UseTokenView.as_view(), name="use-token"), path("use-token/<str:key>/", UseTokenView.as_view(), name="use-token"),
] ]

View File

@ -2,22 +2,22 @@
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import login from django.contrib.auth import login
from django.http import Http404, HttpRequest, HttpResponse from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import redirect
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.views import View from django.views import View
from passbook.core.models import Token from passbook.core.models import Token, TokenIntents
class UseTokenView(View): class UseTokenView(View):
"""Use token to login""" """Use token to login"""
def get(self, request: HttpRequest, uuid: str) -> HttpResponse: def get(self, request: HttpRequest, key: str) -> HttpResponse:
"""Check if token exists, log user in and delete token.""" """Check if token exists, log user in and delete token."""
token: Token = get_object_or_404(Token, pk=uuid) tokens = Token.filter_not_expired(key=key, intent=TokenIntents.INTENT_RECOVERY)
if token.is_expired: if not tokens.exists():
token.delete()
raise Http404 raise Http404
token = tokens.first()
login(request, token.user, backend="django.contrib.auth.backends.ModelBackend") login(request, token.user, backend="django.contrib.auth.backends.ModelBackend")
token.delete() token.delete()
messages.warning(request, _("Used recovery-link to authenticate.")) messages.warning(request, _("Used recovery-link to authenticate."))

View File

@ -529,6 +529,23 @@ paths:
in: path in: path
required: true required: true
type: string type: string
/core/tokens/{identifier}/view_key/:
get:
operationId: core_tokens_view_key
description: Return token key and log access
parameters: []
responses:
'200':
description: ''
schema:
$ref: '#/definitions/Token'
tags:
- core
parameters:
- name: identifier
in: path
required: true
type: string
/core/users/: /core/users/:
get: get:
operationId: core_users_list operationId: core_users_list
@ -6098,6 +6115,7 @@ definitions:
- user_write - user_write
- suspicious_request - suspicious_request
- password_set - password_set
- token_view
- invitation_created - invitation_created
- invitation_used - invitation_used
- authorize_application - authorize_application
@ -6108,11 +6126,6 @@ definitions:
- model_updated - model_updated
- model_deleted - model_deleted
- custom_ - custom_
date:
title: Date
type: string
format: date-time
readOnly: true
app: app:
title: App title: App
type: string type: string
@ -6214,7 +6227,6 @@ definitions:
type: object type: object
Token: Token:
required: required:
- identifier
- user - user
type: object type: object
properties: properties:
@ -6226,6 +6238,7 @@ definitions:
identifier: identifier:
title: Identifier title: Identifier
type: string type: string
readOnly: true
minLength: 1 minLength: 1
intent: intent:
title: Intent title: Intent