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(
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}"
)

View File

@ -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"'

View File

@ -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})

View File

@ -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")
raw_header = headers[b"authorization"]
token = token_from_header(raw_header)
if not token:
LOGGER.warning("Failed to authenticate")
self.close()
return False
except ValidationError:
LOGGER.warning("WS Invalid UUID")
self.close()
return False
self.user = tokens.first().user
self.user = token.user
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)
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):

View File

@ -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,
},
}
},

View File

@ -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"),
]

View File

@ -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">

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 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}",
)

View File

@ -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)

View File

@ -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"),
]

View File

@ -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."))

View File

@ -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