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(
|
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}"
|
||||||
)
|
)
|
||||||
|
|
|
@ -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"'
|
||||||
|
|
|
@ -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})
|
||||||
|
|
|
@ -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()
|
self.close()
|
||||||
return False
|
return False
|
||||||
except ValidationError:
|
|
||||||
LOGGER.warning("WS Invalid UUID")
|
self.user = token.user
|
||||||
self.close()
|
|
||||||
return False
|
|
||||||
self.user = tokens.first().user
|
|
||||||
return True
|
return True
|
||||||
|
|
|
@ -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)
|
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):
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -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"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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 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}",
|
||||||
)
|
)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -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."))
|
||||||
|
|
25
swagger.yaml
25
swagger.yaml
|
@ -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
|
||||||
|
|
Reference in New Issue