core: add key field to token for easier rotation
This commit is contained in:
parent
36e095671c
commit
ee670d5e19
|
@ -158,7 +158,7 @@ class UserPasswordResetView(LoginRequiredMixin, PermissionRequiredMixin, DetailV
|
|||
token, _ = Token.objects.get_or_create(
|
||||
identifier="password-reset-temp", user=self.object
|
||||
)
|
||||
querystring = urlencode({"token": token.token_uuid})
|
||||
querystring = urlencode({"token": token.key})
|
||||
link = request.build_absolute_uri(
|
||||
reverse("passbook_flows:default-recovery") + f"?{querystring}"
|
||||
)
|
||||
|
|
|
@ -1,43 +1,54 @@
|
|||
"""API Authentication"""
|
||||
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.request import Request
|
||||
from structlog import get_logger
|
||||
|
||||
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):
|
||||
"""Token-based authentication using HTTP Basic authentication"""
|
||||
|
||||
def authenticate(self, request: Request) -> Union[Tuple[User, Any], None]:
|
||||
"""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
|
||||
|
||||
if len(auth) == 1:
|
||||
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)
|
||||
return (token.user, None)
|
||||
|
||||
def authenticate_header(self, request: Request) -> str:
|
||||
return 'Basic realm="passbook"'
|
||||
|
|
|
@ -1,7 +1,14 @@
|
|||
"""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.viewsets import ModelViewSet
|
||||
|
||||
from passbook.audit.models import Event, EventAction
|
||||
from passbook.core.models import Token
|
||||
|
||||
|
||||
|
@ -17,6 +24,17 @@ class TokenSerializer(ModelSerializer):
|
|||
class TokenViewSet(ModelViewSet):
|
||||
"""Token Viewset"""
|
||||
|
||||
queryset = Token.objects.all()
|
||||
lookup_field = "identifier"
|
||||
queryset = Token.filter_not_expired()
|
||||
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})
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
"""Channels base classes"""
|
||||
from channels.generic.websocket import JsonWebsocketConsumer
|
||||
from django.core.exceptions import ValidationError
|
||||
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()
|
||||
|
||||
|
@ -20,19 +20,13 @@ class AuthJsonConsumer(JsonWebsocketConsumer):
|
|||
self.close()
|
||||
return False
|
||||
|
||||
token = headers[b"authorization"]
|
||||
try:
|
||||
token_uuid = token.decode("utf-8")
|
||||
tokens = Token.filter_not_expired(
|
||||
token_uuid=token_uuid, intent=TokenIntents.INTENT_API
|
||||
)
|
||||
if not tokens.exists():
|
||||
LOGGER.warning("WS Request with invalid token")
|
||||
self.close()
|
||||
return False
|
||||
except ValidationError:
|
||||
LOGGER.warning("WS Invalid UUID")
|
||||
raw_header = headers[b"authorization"]
|
||||
|
||||
token = token_from_header(raw_header)
|
||||
if not token:
|
||||
LOGGER.warning("Failed to authenticate")
|
||||
self.close()
|
||||
return False
|
||||
self.user = tokens.first().user
|
||||
|
||||
self.user = token.user
|
||||
return True
|
||||
|
|
50
passbook/core/migrations/0014_auto_20201018_1158.py
Normal file
50
passbook/core/migrations/0014_auto_20201018_1158.py
Normal 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),
|
||||
]
|
|
@ -32,6 +32,11 @@ def default_token_duration():
|
|||
return now() + timedelta(minutes=30)
|
||||
|
||||
|
||||
def default_token_key():
|
||||
"""Default token key"""
|
||||
return uuid4().hex
|
||||
|
||||
|
||||
class Group(models.Model):
|
||||
"""Custom Group model which supports a basic hierarchy"""
|
||||
|
||||
|
@ -274,10 +279,8 @@ class ExpiringModel(models.Model):
|
|||
def filter_not_expired(cls, **kwargs) -> QuerySet:
|
||||
"""Filer for tokens which are not expired yet or are not expiring,
|
||||
and match filters in `kwargs`"""
|
||||
query = Q(**kwargs)
|
||||
query_not_expired_yet = Q(expires__lt=now(), expiring=True)
|
||||
query_not_expiring = Q(expiring=False)
|
||||
return cls.objects.filter(query & (query_not_expired_yet | query_not_expiring))
|
||||
expired = Q(expires__lt=now(), expiring=True)
|
||||
return cls.objects.exclude(expired).filter(**kwargs)
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
|
@ -298,6 +301,7 @@ class TokenIntents(models.TextChoices):
|
|||
# Allow access to API
|
||||
INTENT_API = "api"
|
||||
|
||||
# Recovery use for the recovery app
|
||||
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_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(
|
||||
choices=TokenIntents.choices, default=TokenIntents.INTENT_VERIFICATION
|
||||
)
|
||||
|
@ -313,13 +318,19 @@ class Token(ExpiringModel):
|
|||
description = models.TextField(default="", blank=True)
|
||||
|
||||
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:
|
||||
|
||||
verbose_name = _("Token")
|
||||
verbose_name_plural = _("Tokens")
|
||||
unique_together = (("identifier", "user"),)
|
||||
indexes = [
|
||||
models.Index(fields=["identifier"]),
|
||||
models.Index(fields=["key"]),
|
||||
]
|
||||
|
||||
|
||||
class PropertyMapping(models.Model):
|
||||
|
|
|
@ -30,7 +30,7 @@ class DockerController(BaseController):
|
|||
return {
|
||||
"PASSBOOK_HOST": self.outpost.config.passbook_host,
|
||||
"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:
|
||||
|
@ -136,7 +136,7 @@ class DockerController(BaseController):
|
|||
"PASSBOOK_INSECURE": str(
|
||||
self.outpost.config.passbook_host_insecure
|
||||
),
|
||||
"PASSBOOK_TOKEN": self.outpost.token.token_uuid.hex,
|
||||
"PASSBOOK_TOKEN": self.outpost.token.key,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
|
|
@ -18,6 +18,7 @@ def fix_missing_token_identifier(apps: Apps, schema_editor: BaseDatabaseSchemaEd
|
|||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_core", "0014_auto_20201018_1158"),
|
||||
("passbook_outposts", "0008_auto_20201014_1547"),
|
||||
]
|
||||
|
||||
|
|
|
@ -24,7 +24,7 @@
|
|||
<label class="pf-c-form__label" for="help-text-simple-form-name">
|
||||
<span class="pf-c-form__label-text">PASSBOOK_TOKEN</span>
|
||||
</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>
|
||||
<h3>{% trans 'If your passbook Instance is using a self-signed certificate, set this value.' %}</h3>
|
||||
<div class="pf-c-form__group">
|
||||
|
|
|
@ -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>
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -9,7 +9,6 @@ from django.utils.translation import gettext as _
|
|||
from structlog import get_logger
|
||||
|
||||
from passbook.core.models import Token, TokenIntents, User
|
||||
from passbook.lib.config import CONFIG
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
@ -32,22 +31,17 @@ class Command(BaseCommand):
|
|||
|
||||
def get_url(self, token: Token) -> str:
|
||||
"""Get full recovery link"""
|
||||
path = reverse(
|
||||
"passbook_recovery:use-token", kwargs={"uuid": str(token.token_uuid)}
|
||||
)
|
||||
return f"https://{CONFIG.y('domain')}{path}"
|
||||
return reverse("passbook_recovery:use-token", kwargs={"key": str(token.key)})
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Create Token used to recover access"""
|
||||
duration = int(options.get("duration", 1))
|
||||
delta = timedelta(days=duration * 365.2425)
|
||||
_now = now()
|
||||
expiry = _now + delta
|
||||
expiry = _now + timedelta(days=duration * 365.2425)
|
||||
user = User.objects.get(username=options.get("user"))
|
||||
token = Token.objects.create(
|
||||
expires=expiry,
|
||||
user=user,
|
||||
identifier="recovery",
|
||||
intent=TokenIntents.INTENT_RECOVERY,
|
||||
description=f"Recovery Token generated by {getuser()} on {_now}",
|
||||
)
|
||||
|
|
|
@ -5,8 +5,7 @@ from django.core.management import call_command
|
|||
from django.shortcuts import reverse
|
||||
from django.test import TestCase
|
||||
|
||||
from passbook.core.models import Token, User
|
||||
from passbook.lib.config import CONFIG
|
||||
from passbook.core.models import Token, TokenIntents, User
|
||||
|
||||
|
||||
class TestRecovery(TestCase):
|
||||
|
@ -17,21 +16,19 @@ class TestRecovery(TestCase):
|
|||
|
||||
def test_create_key(self):
|
||||
"""Test creation of a new key"""
|
||||
CONFIG.update_from_dict({"domain": "testserver"})
|
||||
out = StringIO()
|
||||
self.assertEqual(len(Token.objects.all()), 0)
|
||||
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)
|
||||
|
||||
def test_recovery_view(self):
|
||||
"""Test recovery view"""
|
||||
out = StringIO()
|
||||
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(
|
||||
reverse(
|
||||
"passbook_recovery:use-token", kwargs={"uuid": str(token.token_uuid)}
|
||||
)
|
||||
reverse("passbook_recovery:use-token", kwargs={"key": token.key})
|
||||
)
|
||||
self.assertEqual(int(self.client.session["_auth_user_id"]), token.user.pk)
|
||||
|
|
|
@ -5,5 +5,5 @@ from django.urls import path
|
|||
from passbook.recovery.views import UseTokenView
|
||||
|
||||
urlpatterns = [
|
||||
path("use-token/<uuid:uuid>/", UseTokenView.as_view(), name="use-token"),
|
||||
path("use-token/<str:key>/", UseTokenView.as_view(), name="use-token"),
|
||||
]
|
||||
|
|
|
@ -2,22 +2,22 @@
|
|||
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.shortcuts import redirect
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views import View
|
||||
|
||||
from passbook.core.models import Token
|
||||
from passbook.core.models import Token, TokenIntents
|
||||
|
||||
|
||||
class UseTokenView(View):
|
||||
"""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."""
|
||||
token: Token = get_object_or_404(Token, pk=uuid)
|
||||
if token.is_expired:
|
||||
token.delete()
|
||||
tokens = Token.filter_not_expired(key=key, intent=TokenIntents.INTENT_RECOVERY)
|
||||
if not tokens.exists():
|
||||
raise Http404
|
||||
token = tokens.first()
|
||||
login(request, token.user, backend="django.contrib.auth.backends.ModelBackend")
|
||||
token.delete()
|
||||
messages.warning(request, _("Used recovery-link to authenticate."))
|
||||
|
|
25
swagger.yaml
25
swagger.yaml
|
@ -529,6 +529,23 @@ paths:
|
|||
in: path
|
||||
required: true
|
||||
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/:
|
||||
get:
|
||||
operationId: core_users_list
|
||||
|
@ -6098,6 +6115,7 @@ definitions:
|
|||
- user_write
|
||||
- suspicious_request
|
||||
- password_set
|
||||
- token_view
|
||||
- invitation_created
|
||||
- invitation_used
|
||||
- authorize_application
|
||||
|
@ -6108,11 +6126,6 @@ definitions:
|
|||
- model_updated
|
||||
- model_deleted
|
||||
- custom_
|
||||
date:
|
||||
title: Date
|
||||
type: string
|
||||
format: date-time
|
||||
readOnly: true
|
||||
app:
|
||||
title: App
|
||||
type: string
|
||||
|
@ -6214,7 +6227,6 @@ definitions:
|
|||
type: object
|
||||
Token:
|
||||
required:
|
||||
- identifier
|
||||
- user
|
||||
type: object
|
||||
properties:
|
||||
|
@ -6226,6 +6238,7 @@ definitions:
|
|||
identifier:
|
||||
title: Identifier
|
||||
type: string
|
||||
readOnly: true
|
||||
minLength: 1
|
||||
intent:
|
||||
title: Intent
|
||||
|
|
Reference in a new issue