From 19708bc67b0379b6f4359a026aaedcc51d6d9729 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sun, 2 May 2021 14:43:26 +0200 Subject: [PATCH 01/10] core: add additional_data to UILoginButton to pass additional data Signed-off-by: Jens Langhammer --- authentik/core/types.py | 8 ++++++-- authentik/stages/identification/tests.py | 2 ++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/authentik/core/types.py b/authentik/core/types.py index 988a9aea6..3901350a3 100644 --- a/authentik/core/types.py +++ b/authentik/core/types.py @@ -1,8 +1,8 @@ """authentik core dataclasses""" from dataclasses import dataclass -from typing import Optional +from typing import Any, Optional -from rest_framework.fields import CharField +from rest_framework.fields import CharField, DictField from authentik.core.api.utils import PassiveSerializer @@ -20,6 +20,9 @@ class UILoginButton: # Icon URL, used as-is icon_url: Optional[str] = None + # Additional data, optional + additional_data: Any = None + class UILoginButtonSerializer(PassiveSerializer): """Serializer for Login buttons of sources""" @@ -27,6 +30,7 @@ class UILoginButtonSerializer(PassiveSerializer): name = CharField() url = CharField() icon_url = CharField(required=False, allow_null=True) + additional_data = DictField(required=False, allow_null=True) class UserSettingSerializer(PassiveSerializer): diff --git a/authentik/stages/identification/tests.py b/authentik/stages/identification/tests.py index 1c8fe74ac..bb99ee947 100644 --- a/authentik/stages/identification/tests.py +++ b/authentik/stages/identification/tests.py @@ -115,6 +115,7 @@ class TestIdentificationStage(TestCase): "title": self.flow.title, "sources": [ { + "additional_data": None, "icon_url": "/static/authentik/sources/.svg", "name": "test", "url": "/source/oauth/login/test/", @@ -158,6 +159,7 @@ class TestIdentificationStage(TestCase): "title": self.flow.title, "sources": [ { + "additional_data": None, "icon_url": "/static/authentik/sources/.svg", "name": "test", "url": "/source/oauth/login/test/", From f1b100c8a59314791a9a66dd836495ff5c6fd7eb Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sun, 2 May 2021 14:43:51 +0200 Subject: [PATCH 02/10] sources/plex: initial plex source implementation Signed-off-by: Jens Langhammer --- authentik/api/v2/urls.py | 2 + authentik/root/settings.py | 1 + authentik/sources/oauth/apps.py | 14 +- authentik/sources/oauth/models.py | 10 - authentik/sources/oauth/settings.py | 13 - authentik/sources/plex/__init__.py | 0 authentik/sources/plex/api.py | 21 ++ authentik/sources/plex/apps.py | 10 + .../sources/plex/migrations/0001_initial.py | 45 +++ authentik/sources/plex/migrations/__init__.py | 0 authentik/sources/plex/models.py | 42 +++ .../sources/{oauth/types => plex}/plex.py | 4 +- swagger.yaml | 269 ++++++++++++++++++ web/src/flows/sources/plex/API.ts | 65 +++++ web/src/flows/sources/plex/PlexLoginInit.ts | 11 + web/src/pages/sources/SourcesListPage.ts | 1 + web/src/pages/sources/plex/PlexSourceForm.ts | 193 +++++++++++++ 17 files changed, 675 insertions(+), 26 deletions(-) delete mode 100644 authentik/sources/oauth/settings.py create mode 100644 authentik/sources/plex/__init__.py create mode 100644 authentik/sources/plex/api.py create mode 100644 authentik/sources/plex/apps.py create mode 100644 authentik/sources/plex/migrations/0001_initial.py create mode 100644 authentik/sources/plex/migrations/__init__.py create mode 100644 authentik/sources/plex/models.py rename authentik/sources/{oauth/types => plex}/plex.py (97%) create mode 100644 web/src/flows/sources/plex/API.ts create mode 100644 web/src/flows/sources/plex/PlexLoginInit.ts create mode 100644 web/src/pages/sources/plex/PlexSourceForm.ts diff --git a/authentik/api/v2/urls.py b/authentik/api/v2/urls.py index 0b71a2857..c3738e00b 100644 --- a/authentik/api/v2/urls.py +++ b/authentik/api/v2/urls.py @@ -63,6 +63,7 @@ from authentik.sources.oauth.api.source import OAuthSourceViewSet from authentik.sources.oauth.api.source_connection import ( UserOAuthSourceConnectionViewSet, ) +from authentik.sources.plex.api import PlexSourceViewSet from authentik.sources.saml.api import SAMLSourceViewSet from authentik.stages.authenticator_static.api import ( AuthenticatorStaticStageViewSet, @@ -136,6 +137,7 @@ router.register("sources/oauth_user_connections", UserOAuthSourceConnectionViewS router.register("sources/ldap", LDAPSourceViewSet) router.register("sources/saml", SAMLSourceViewSet) router.register("sources/oauth", OAuthSourceViewSet) +router.register("sources/plex", PlexSourceViewSet) router.register("policies/all", PolicyViewSet) router.register("policies/bindings", PolicyBindingViewSet) diff --git a/authentik/root/settings.py b/authentik/root/settings.py index 238f42b49..4fa9d6355 100644 --- a/authentik/root/settings.py +++ b/authentik/root/settings.py @@ -107,6 +107,7 @@ INSTALLED_APPS = [ "authentik.recovery", "authentik.sources.ldap", "authentik.sources.oauth", + "authentik.sources.plex", "authentik.sources.saml", "authentik.stages.authenticator_static", "authentik.stages.authenticator_totp", diff --git a/authentik/sources/oauth/apps.py b/authentik/sources/oauth/apps.py index 7aad40515..657a5d942 100644 --- a/authentik/sources/oauth/apps.py +++ b/authentik/sources/oauth/apps.py @@ -2,11 +2,21 @@ from importlib import import_module from django.apps import AppConfig -from django.conf import settings from structlog.stdlib import get_logger LOGGER = get_logger() +AUTHENTIK_SOURCES_OAUTH_TYPES = [ + "authentik.sources.oauth.types.discord", + "authentik.sources.oauth.types.facebook", + "authentik.sources.oauth.types.github", + "authentik.sources.oauth.types.google", + "authentik.sources.oauth.types.reddit", + "authentik.sources.oauth.types.twitter", + "authentik.sources.oauth.types.azure_ad", + "authentik.sources.oauth.types.oidc", +] + class AuthentikSourceOAuthConfig(AppConfig): """authentik source.oauth config""" @@ -18,7 +28,7 @@ class AuthentikSourceOAuthConfig(AppConfig): def ready(self): """Load source_types from config file""" - for source_type in settings.AUTHENTIK_SOURCES_OAUTH_TYPES: + for source_type in AUTHENTIK_SOURCES_OAUTH_TYPES: try: import_module(source_type) LOGGER.debug("Loaded OAuth Source Type", type=source_type) diff --git a/authentik/sources/oauth/models.py b/authentik/sources/oauth/models.py index c14caa43f..9ba1dc864 100644 --- a/authentik/sources/oauth/models.py +++ b/authentik/sources/oauth/models.py @@ -163,16 +163,6 @@ class OpenIDOAuthSource(OAuthSource): verbose_name_plural = _("OpenID OAuth Sources") -class PlexOAuthSource(OAuthSource): - """Login using plex.tv.""" - - class Meta: - - abstract = True - verbose_name = _("Plex OAuth Source") - verbose_name_plural = _("Plex OAuth Sources") - - class UserOAuthSourceConnection(UserSourceConnection): """Authorized remote OAuth provider.""" diff --git a/authentik/sources/oauth/settings.py b/authentik/sources/oauth/settings.py deleted file mode 100644 index 8585167fd..000000000 --- a/authentik/sources/oauth/settings.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Oauth2 Client Settings""" - -AUTHENTIK_SOURCES_OAUTH_TYPES = [ - "authentik.sources.oauth.types.discord", - "authentik.sources.oauth.types.facebook", - "authentik.sources.oauth.types.github", - "authentik.sources.oauth.types.google", - "authentik.sources.oauth.types.reddit", - "authentik.sources.oauth.types.twitter", - "authentik.sources.oauth.types.azure_ad", - "authentik.sources.oauth.types.oidc", - "authentik.sources.oauth.types.plex", -] diff --git a/authentik/sources/plex/__init__.py b/authentik/sources/plex/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/authentik/sources/plex/api.py b/authentik/sources/plex/api.py new file mode 100644 index 000000000..01b3c1961 --- /dev/null +++ b/authentik/sources/plex/api.py @@ -0,0 +1,21 @@ +"""Plex Source Serializer""" +from rest_framework.viewsets import ModelViewSet + +from authentik.core.api.sources import SourceSerializer +from authentik.sources.plex.models import PlexSource + + +class PlexSourceSerializer(SourceSerializer): + """Plex Source Serializer""" + + class Meta: + model = PlexSource + fields = SourceSerializer.Meta.fields + ["client_id", "allowed_servers"] + + +class PlexSourceViewSet(ModelViewSet): + """Plex source Viewset""" + + queryset = PlexSource.objects.all() + serializer_class = PlexSourceSerializer + lookup_field = "slug" diff --git a/authentik/sources/plex/apps.py b/authentik/sources/plex/apps.py new file mode 100644 index 000000000..a8c89447b --- /dev/null +++ b/authentik/sources/plex/apps.py @@ -0,0 +1,10 @@ +"""authentik plex config""" +from django.apps import AppConfig + + +class AuthentikSourcePlexConfig(AppConfig): + """authentik source plex config""" + + name = "authentik.sources.plex" + label = "authentik_sources_plex" + verbose_name = "authentik Sources.Plex" diff --git a/authentik/sources/plex/migrations/0001_initial.py b/authentik/sources/plex/migrations/0001_initial.py new file mode 100644 index 000000000..16032d37b --- /dev/null +++ b/authentik/sources/plex/migrations/0001_initial.py @@ -0,0 +1,45 @@ +# Generated by Django 3.2 on 2021-05-02 12:34 + +import django.contrib.postgres.fields +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_core", "0019_source_managed"), + ] + + operations = [ + migrations.CreateModel( + name="PlexSource", + fields=[ + ( + "source_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_core.source", + ), + ), + ("client_id", models.TextField()), + ( + "allowed_servers", + django.contrib.postgres.fields.ArrayField( + base_field=models.TextField(), size=None + ), + ), + ], + options={ + "verbose_name": "Plex Source", + "verbose_name_plural": "Plex Sources", + }, + bases=("authentik_core.source",), + ), + ] diff --git a/authentik/sources/plex/migrations/__init__.py b/authentik/sources/plex/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/authentik/sources/plex/models.py b/authentik/sources/plex/models.py new file mode 100644 index 000000000..7a61c801a --- /dev/null +++ b/authentik/sources/plex/models.py @@ -0,0 +1,42 @@ +"""Plex source""" +from django.contrib.postgres.fields import ArrayField +from django.db import models +from django.templatetags.static import static +from django.utils.translation import gettext_lazy as _ +from rest_framework.serializers import BaseSerializer + +from authentik.core.models import Source +from authentik.core.types import UILoginButton + + +class PlexSource(Source): + """Authenticate against plex.tv""" + + client_id = models.TextField() + allowed_servers = ArrayField(models.TextField()) + + @property + def component(self) -> str: + return "ak-source-plex-form" + + @property + def serializer(self) -> BaseSerializer: + from authentik.sources.plex.api import PlexSourceSerializer + + return PlexSourceSerializer + + @property + def ui_login_button(self) -> UILoginButton: + return UILoginButton( + url="", + icon_url=static("authentik/sources/plex.svg"), + name=self.name, + additional_data={ + "client_id": self.client_id, + }, + ) + + class Meta: + + verbose_name = _("Plex Source") + verbose_name_plural = _("Plex Sources") diff --git a/authentik/sources/oauth/types/plex.py b/authentik/sources/plex/plex.py similarity index 97% rename from authentik/sources/oauth/types/plex.py rename to authentik/sources/plex/plex.py index d6e9914df..1f6b394a8 100644 --- a/authentik/sources/oauth/types/plex.py +++ b/authentik/sources/plex/plex.py @@ -85,6 +85,7 @@ class PlexOAuthClient(OAuth2Client): def get_profile_info(self, token: dict[str, str]) -> Optional[dict[str, Any]]: "Fetch user profile information." qs = {"X-Plex-Token": token["plex_token"]} + print(token) try: response = self.do_request( "get", f"https://plex.tv/users/account.json?{urlencode(qs)}" @@ -94,7 +95,8 @@ class PlexOAuthClient(OAuth2Client): LOGGER.warning("Unable to fetch user profile", exc=exc) return None else: - return response.json().get("user", {}) + info = response.json() + return info.get("user", {}) class PlexOAuth2Callback(OAuthCallback): diff --git a/swagger.yaml b/swagger.yaml index b5ee65cab..4819cb3b3 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -10213,6 +10213,205 @@ paths: description: A unique integer value identifying this User OAuth Source Connection. required: true type: integer + /sources/plex/: + get: + operationId: sources_plex_list + description: Plex source Viewset + parameters: + - name: ordering + in: query + description: Which field to use when ordering the results. + required: false + type: string + - name: search + in: query + description: A search term. + required: false + type: string + - name: page + in: query + description: Page Index + required: false + type: integer + - name: page_size + in: query + description: Page Size + required: false + type: integer + responses: + '200': + description: '' + schema: + required: + - results + - pagination + type: object + properties: + pagination: + required: + - next + - previous + - count + - current + - total_pages + - start_index + - end_index + type: object + properties: + next: + type: number + previous: + type: number + count: + type: number + current: + type: number + total_pages: + type: number + start_index: + type: number + end_index: + type: number + results: + type: array + items: + $ref: '#/definitions/PlexSource' + '403': + description: Authentication credentials were invalid, absent or insufficient. + schema: + $ref: '#/definitions/GenericError' + tags: + - sources + post: + operationId: sources_plex_create + description: Plex source Viewset + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/PlexSource' + responses: + '201': + description: '' + schema: + $ref: '#/definitions/PlexSource' + '400': + description: Invalid input. + schema: + $ref: '#/definitions/ValidationError' + '403': + description: Authentication credentials were invalid, absent or insufficient. + schema: + $ref: '#/definitions/GenericError' + tags: + - sources + parameters: [] + /sources/plex/{slug}/: + get: + operationId: sources_plex_read + description: Plex source Viewset + parameters: [] + responses: + '200': + description: '' + schema: + $ref: '#/definitions/PlexSource' + '403': + description: Authentication credentials were invalid, absent or insufficient. + schema: + $ref: '#/definitions/GenericError' + '404': + description: Object does not exist or caller has insufficient permissions + to access it. + schema: + $ref: '#/definitions/APIException' + tags: + - sources + put: + operationId: sources_plex_update + description: Plex source Viewset + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/PlexSource' + responses: + '200': + description: '' + schema: + $ref: '#/definitions/PlexSource' + '400': + description: Invalid input. + schema: + $ref: '#/definitions/ValidationError' + '403': + description: Authentication credentials were invalid, absent or insufficient. + schema: + $ref: '#/definitions/GenericError' + '404': + description: Object does not exist or caller has insufficient permissions + to access it. + schema: + $ref: '#/definitions/APIException' + tags: + - sources + patch: + operationId: sources_plex_partial_update + description: Plex source Viewset + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/PlexSource' + responses: + '200': + description: '' + schema: + $ref: '#/definitions/PlexSource' + '400': + description: Invalid input. + schema: + $ref: '#/definitions/ValidationError' + '403': + description: Authentication credentials were invalid, absent or insufficient. + schema: + $ref: '#/definitions/GenericError' + '404': + description: Object does not exist or caller has insufficient permissions + to access it. + schema: + $ref: '#/definitions/APIException' + tags: + - sources + delete: + operationId: sources_plex_delete + description: Plex source Viewset + parameters: [] + responses: + '204': + description: '' + '403': + description: Authentication credentials were invalid, absent or insufficient. + schema: + $ref: '#/definitions/GenericError' + '404': + description: Object does not exist or caller has insufficient permissions + to access it. + schema: + $ref: '#/definitions/APIException' + tags: + - sources + parameters: + - name: slug + in: path + description: Internal source name, used in URLs. + required: true + type: string + format: slug + pattern: ^[-a-zA-Z0-9_]+$ /sources/saml/: get: operationId: sources_saml_list @@ -16210,6 +16409,7 @@ definitions: - authentik.recovery - authentik.sources.ldap - authentik.sources.oauth + - authentik.sources.plex - authentik.sources.saml - authentik.stages.authenticator_static - authentik.stages.authenticator_totp @@ -17386,6 +17586,75 @@ definitions: type: string maxLength: 255 minLength: 1 + PlexSource: + required: + - name + - slug + - client_id + - allowed_servers + type: object + properties: + pk: + title: Pbm uuid + type: string + format: uuid + readOnly: true + name: + title: Name + description: Source's display Name. + type: string + minLength: 1 + slug: + title: Slug + description: Internal source name, used in URLs. + type: string + format: slug + pattern: ^[-a-zA-Z0-9_]+$ + maxLength: 50 + minLength: 1 + enabled: + title: Enabled + type: boolean + authentication_flow: + title: Authentication flow + description: Flow to use when authenticating existing users. + type: string + format: uuid + x-nullable: true + enrollment_flow: + title: Enrollment flow + description: Flow to use when enrolling new users. + type: string + format: uuid + x-nullable: true + component: + title: Component + type: string + readOnly: true + verbose_name: + title: Verbose name + type: string + readOnly: true + verbose_name_plural: + title: Verbose name plural + type: string + readOnly: true + policy_engine_mode: + title: Policy engine mode + type: string + enum: + - all + - any + client_id: + title: Client id + type: string + minLength: 1 + allowed_servers: + type: array + items: + title: Allowed servers + type: string + minLength: 1 SAMLSource: required: - name diff --git a/web/src/flows/sources/plex/API.ts b/web/src/flows/sources/plex/API.ts new file mode 100644 index 000000000..26e0ce362 --- /dev/null +++ b/web/src/flows/sources/plex/API.ts @@ -0,0 +1,65 @@ +import { VERSION } from "../../../constants"; + +export interface PlexPinResponse { + // Only has the fields we care about + authToken?: string; + code: string; + id: number; +} + +export interface PlexResource { + name: string; + provides: string; + clientIdentifier: string; +} + +export const DEFAULT_HEADERS = { + "Accept": "application/json", + "Content-Type": "application/json", + "X-Plex-Product": "authentik", + "X-Plex-Version": VERSION, + "X-Plex-Device-Vendor": "BeryJu.org", +}; + +export class PlexAPIClient { + + token: string; + + constructor(token: string) { + this.token = token; + } + + static async getPin(clientIdentifier: string): Promise<{ authUrl: string, pin: PlexPinResponse }> { + const headers = { ...DEFAULT_HEADERS, ...{ + "X-Plex-Client-Identifier": clientIdentifier + }}; + const pinResponse = await fetch("https://plex.tv/api/v2/pins.json?strong=true", { + method: "POST", + headers: headers + }); + const pin: PlexPinResponse = await pinResponse.json(); + return { + authUrl: `https://app.plex.tv/auth#!?clientID=${encodeURIComponent(clientIdentifier)}&code=${pin.code}`, + pin: pin + }; + } + + static async pinStatus(id: number): Promise { + const pinResponse = await fetch(`https://plex.tv/api/v2/pins/${id}`, { + headers: DEFAULT_HEADERS + }); + const pin: PlexPinResponse = await pinResponse.json(); + return pin.authToken || ""; + } + + async getServers(): Promise { + const resourcesResponse = await fetch(`https://plex.tv/api/v2/resources?X-Plex-Token=${this.token}&X-Plex-Client-Identifier=authentik`, { + headers: DEFAULT_HEADERS + }); + const resources: PlexResource[] = await resourcesResponse.json(); + return resources.filter(r => { + return r.provides === "server"; + }); + } + +} diff --git a/web/src/flows/sources/plex/PlexLoginInit.ts b/web/src/flows/sources/plex/PlexLoginInit.ts new file mode 100644 index 000000000..e357b3355 --- /dev/null +++ b/web/src/flows/sources/plex/PlexLoginInit.ts @@ -0,0 +1,11 @@ +import {customElement, LitElement} from "lit-element"; +import {html, TemplateResult} from "lit-html"; + +@customElement("ak-flow-sources-plex") +export class PlexLoginInit extends LitElement { + + render(): TemplateResult { + return html``; + } + +} diff --git a/web/src/pages/sources/SourcesListPage.ts b/web/src/pages/sources/SourcesListPage.ts index c88241a9a..e00d7443d 100644 --- a/web/src/pages/sources/SourcesListPage.ts +++ b/web/src/pages/sources/SourcesListPage.ts @@ -17,6 +17,7 @@ import { ifDefined } from "lit-html/directives/if-defined"; import "./ldap/LDAPSourceForm"; import "./saml/SAMLSourceForm"; import "./oauth/OAuthSourceForm"; +import "./plex/PlexSourceForm"; @customElement("ak-source-list") export class SourceListPage extends TablePage { diff --git a/web/src/pages/sources/plex/PlexSourceForm.ts b/web/src/pages/sources/plex/PlexSourceForm.ts new file mode 100644 index 000000000..bc963aee5 --- /dev/null +++ b/web/src/pages/sources/plex/PlexSourceForm.ts @@ -0,0 +1,193 @@ +import { PlexSource, SourcesApi, FlowsApi, FlowDesignationEnum } from "authentik-api"; +import { t } from "@lingui/macro"; +import { customElement, property } from "lit-element"; +import { html, TemplateResult } from "lit-html"; +import { DEFAULT_CONFIG } from "../../../api/Config"; +import { Form } from "../../../elements/forms/Form"; +import "../../../elements/forms/FormGroup"; +import "../../../elements/forms/HorizontalFormElement"; +import { ifDefined } from "lit-html/directives/if-defined"; +import { until } from "lit-html/directives/until"; +import { first, randomString } from "../../../utils"; +import { PlexAPIClient, PlexResource} from "../../../flows/sources/plex/API"; + + +function popupCenterScreen(url: string, title: string, w: number, h: number): Window | null { + const top = (screen.height - h) / 4, left = (screen.width - w) / 2; + const popup = window.open(url, title, `scrollbars=yes,width=${w},height=${h},top=${top},left=${left}`); + return popup; +} + +@customElement("ak-source-plex-form") +export class PlexSourceForm extends Form { + + set sourceSlug(value: string) { + new SourcesApi(DEFAULT_CONFIG).sourcesPlexRead({ + slug: value, + }).then(source => { + this.source = source; + }); + } + + @property({attribute: false}) + source: PlexSource = { + clientId: randomString(40) + } as PlexSource; + + @property() + plexToken?: string; + + @property({attribute: false}) + plexResources?: PlexResource[]; + + getSuccessMessage(): string { + if (this.source) { + return t`Successfully updated source.`; + } else { + return t`Successfully created source.`; + } + } + + send = (data: PlexSource): Promise => { + if (this.source.slug) { + return new SourcesApi(DEFAULT_CONFIG).sourcesPlexUpdate({ + slug: this.source.slug, + data: data + }); + } else { + return new SourcesApi(DEFAULT_CONFIG).sourcesPlexCreate({ + data: data + }); + } + }; + + async doAuth(): Promise { + const authInfo = await PlexAPIClient.getPin(this.source?.clientId); + const authWindow = popupCenterScreen(authInfo.authUrl, "plex auth", 550, 700); + const timer = setInterval(() => { + if (authWindow?.closed) { + clearInterval(timer); + PlexAPIClient.pinStatus(authInfo.pin.id).then((token: string) => { + this.plexToken = token; + this.loadServers(); + }); + } + }, 500); + } + + async loadServers(): Promise { + if (!this.plexToken) { + return; + } + this.plexResources = await new PlexAPIClient(this.plexToken).getServers(); + } + + renderForm(): TemplateResult { + return html`
+ + + + + + + +
+ + +
+
+ + + + ${t`Protocol settings`} + +
+ + + + + +

${t`Select which server a user has to be a member of to be allowed to authenticate.`}

+

${t`Hold control/command to select multiple items.`}

+

+ +

+
+
+
+ + + ${t`Flow settings`} + +
+ + +

${t`Flow to use when authenticating existing users.`}

+
+ + +

${t`Flow to use when enrolling new users.`}

+
+
+
+
`; + } + +} From 55250e88e5050f9b4ba23f91de57c0f33c4b2683 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sun, 2 May 2021 16:46:13 +0200 Subject: [PATCH 03/10] sources/*: rewrite UILoginButton to return challenge instead Signed-off-by: Jens Langhammer --- authentik/core/types.py | 13 +++++-------- authentik/sources/oauth/models.py | 12 +++++++++--- authentik/sources/saml/models.py | 13 ++++++++++--- authentik/stages/identification/stage.py | 4 +++- authentik/stages/identification/tests.py | 12 ++++++++---- web/src/authentik.css | 2 +- web/src/flows/stages/base.ts | 2 ++ .../stages/identification/IdentificationStage.ts | 15 +++++++++++---- 8 files changed, 49 insertions(+), 24 deletions(-) diff --git a/authentik/core/types.py b/authentik/core/types.py index 3901350a3..d8ba7cadf 100644 --- a/authentik/core/types.py +++ b/authentik/core/types.py @@ -1,10 +1,11 @@ """authentik core dataclasses""" from dataclasses import dataclass -from typing import Any, Optional +from typing import Optional from rest_framework.fields import CharField, DictField from authentik.core.api.utils import PassiveSerializer +from authentik.flows.challenge import Challenge @dataclass @@ -14,23 +15,19 @@ class UILoginButton: # Name, ran through i18n name: str - # URL Which Button points to - url: str + # Challenge which is presented to the user when they click the button + challenge: Challenge # Icon URL, used as-is icon_url: Optional[str] = None - # Additional data, optional - additional_data: Any = None - class UILoginButtonSerializer(PassiveSerializer): """Serializer for Login buttons of sources""" name = CharField() - url = CharField() + challenge = DictField() icon_url = CharField(required=False, allow_null=True) - additional_data = DictField(required=False, allow_null=True) class UserSettingSerializer(PassiveSerializer): diff --git a/authentik/sources/oauth/models.py b/authentik/sources/oauth/models.py index 9ba1dc864..b44ff57f1 100644 --- a/authentik/sources/oauth/models.py +++ b/authentik/sources/oauth/models.py @@ -9,6 +9,7 @@ from rest_framework.serializers import Serializer from authentik.core.models import Source, UserSourceConnection from authentik.core.types import UILoginButton, UserSettingSerializer +from authentik.flows.challenge import ChallengeTypes, RedirectChallenge if TYPE_CHECKING: from authentik.sources.oauth.types.manager import SourceType @@ -67,9 +68,14 @@ class OAuthSource(Source): @property def ui_login_button(self) -> UILoginButton: return UILoginButton( - url=reverse( - "authentik_sources_oauth:oauth-client-login", - kwargs={"source_slug": self.slug}, + challenge=RedirectChallenge( + instance={ + "type": ChallengeTypes.REDIRECT.value, + "to": reverse( + "authentik_sources_oauth:oauth-client-login", + kwargs={"source_slug": self.slug}, + ), + } ), icon_url=static(f"authentik/sources/{self.provider_type}.svg"), name=self.name, diff --git a/authentik/sources/saml/models.py b/authentik/sources/saml/models.py index d35685aac..bd7087173 100644 --- a/authentik/sources/saml/models.py +++ b/authentik/sources/saml/models.py @@ -10,6 +10,7 @@ from rest_framework.serializers import Serializer from authentik.core.models import Source from authentik.core.types import UILoginButton from authentik.crypto.models import CertificateKeyPair +from authentik.flows.challenge import ChallengeTypes, RedirectChallenge from authentik.flows.models import Flow from authentik.lib.utils.time import timedelta_string_validator from authentik.sources.saml.processors.constants import ( @@ -169,10 +170,16 @@ class SAMLSource(Source): @property def ui_login_button(self) -> UILoginButton: return UILoginButton( - name=self.name, - url=reverse( - "authentik_sources_saml:login", kwargs={"source_slug": self.slug} + challenge=RedirectChallenge( + instance={ + "type": ChallengeTypes.REDIRECT.value, + "to": reverse( + "authentik_sources_saml:login", + kwargs={"source_slug": self.slug}, + ), + } ), + name=self.name, ) def __str__(self): diff --git a/authentik/stages/identification/stage.py b/authentik/stages/identification/stage.py index f5b3bfc90..625546c0f 100644 --- a/authentik/stages/identification/stage.py +++ b/authentik/stages/identification/stage.py @@ -112,7 +112,9 @@ class IdentificationStageView(ChallengeStageView): for source in sources: ui_login_button = source.ui_login_button if ui_login_button: - ui_sources.append(asdict(ui_login_button)) + button = asdict(ui_login_button) + button["challenge"] = ui_login_button.challenge.data + ui_sources.append(button) challenge.initial_data["sources"] = ui_sources return challenge diff --git a/authentik/stages/identification/tests.py b/authentik/stages/identification/tests.py index bb99ee947..64c051ddc 100644 --- a/authentik/stages/identification/tests.py +++ b/authentik/stages/identification/tests.py @@ -115,10 +115,12 @@ class TestIdentificationStage(TestCase): "title": self.flow.title, "sources": [ { - "additional_data": None, "icon_url": "/static/authentik/sources/.svg", "name": "test", - "url": "/source/oauth/login/test/", + "challenge": { + "to": "/source/oauth/login/test/", + "type": "redirect", + }, } ], }, @@ -159,10 +161,12 @@ class TestIdentificationStage(TestCase): "title": self.flow.title, "sources": [ { - "additional_data": None, + "challenge": { + "to": "/source/oauth/login/test/", + "type": "redirect", + }, "icon_url": "/static/authentik/sources/.svg", "name": "test", - "url": "/source/oauth/login/test/", } ], }, diff --git a/web/src/authentik.css b/web/src/authentik.css index afcebb269..35b0caea0 100644 --- a/web/src/authentik.css +++ b/web/src/authentik.css @@ -272,7 +272,7 @@ body { .pf-c-login__main-header-desc { color: var(--ak-dark-foreground); } - .pf-c-login__main-footer-links-item-link > img { + .pf-c-login__main-footer-links-item img { filter: invert(1); } .pf-c-login__main-footer-band { diff --git a/web/src/flows/stages/base.ts b/web/src/flows/stages/base.ts index 56ef98498..6815d652c 100644 --- a/web/src/flows/stages/base.ts +++ b/web/src/flows/stages/base.ts @@ -1,6 +1,8 @@ +import { Challenge } from "authentik-api"; import { LitElement } from "lit-element"; export interface StageHost { + challenge?: Challenge; submit(formData?: T): Promise; } diff --git a/web/src/flows/stages/identification/IdentificationStage.ts b/web/src/flows/stages/identification/IdentificationStage.ts index 8dc3035a6..a4e7ba371 100644 --- a/web/src/flows/stages/identification/IdentificationStage.ts +++ b/web/src/flows/stages/identification/IdentificationStage.ts @@ -35,7 +35,7 @@ export interface IdentificationChallenge extends Challenge { export interface UILoginButton { name: string; - url: string; + challenge: Challenge; icon_url?: string; } @@ -49,7 +49,11 @@ export class IdentificationStage extends BaseStage { return [PFBase, PFLogin, PFForm, PFFormControl, PFTitle, PFButton, AKGlobal].concat( css` /* login page's icons */ - .pf-c-login__main-footer-links-item-link img { + .pf-c-login__main-footer-links-item button { + background-color: transparent; + border: 0; + } + .pf-c-login__main-footer-links-item img { fill: var(--pf-c-login__main-footer-links-item-link-svg--Fill); width: 100px; max-width: var(--pf-c-login__main-footer-links-item-link-svg--Width); @@ -131,9 +135,12 @@ export class IdentificationStage extends BaseStage { icon = html`${source.name}`; } return html``; } From 01d29134b96d96e3181e75cdb43f4eaede44eeba Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sun, 2 May 2021 16:47:20 +0200 Subject: [PATCH 04/10] sources/plex: add API to redeem token Signed-off-by: Jens Langhammer --- authentik/sources/plex/api.py | 81 +++++++++++++++++++- authentik/sources/plex/models.py | 21 ++++- swagger.yaml | 78 +++++++++++++++++++ web/src/flows/FlowExecutor.ts | 4 + web/src/flows/sources/plex/API.ts | 34 +++++++- web/src/flows/sources/plex/PlexLoginInit.ts | 42 +++++++++- web/src/pages/sources/plex/PlexSourceForm.ts | 22 ++---- 7 files changed, 255 insertions(+), 27 deletions(-) diff --git a/authentik/sources/plex/api.py b/authentik/sources/plex/api.py index 01b3c1961..a9501132c 100644 --- a/authentik/sources/plex/api.py +++ b/authentik/sources/plex/api.py @@ -1,9 +1,27 @@ """Plex Source Serializer""" -from rest_framework.viewsets import ModelViewSet +from urllib.parse import urlencode +from django.http import Http404 +from django.shortcuts import get_object_or_404 +from drf_yasg import openapi +from drf_yasg.utils import swagger_auto_schema +from requests import RequestException, get +from rest_framework.decorators import action +from rest_framework.fields import CharField +from rest_framework.permissions import AllowAny +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet +from structlog.stdlib import get_logger + +from authentik.api.decorators import permission_required from authentik.core.api.sources import SourceSerializer +from authentik.core.api.utils import PassiveSerializer +from authentik.flows.challenge import ChallengeTypes, RedirectChallenge from authentik.sources.plex.models import PlexSource +LOGGER = get_logger() + class PlexSourceSerializer(SourceSerializer): """Plex Source Serializer""" @@ -13,9 +31,70 @@ class PlexSourceSerializer(SourceSerializer): fields = SourceSerializer.Meta.fields + ["client_id", "allowed_servers"] +class PlexTokenRedeemSerializer(PassiveSerializer): + """Serializer to redeem a plex token""" + + plex_token = CharField() + + class PlexSourceViewSet(ModelViewSet): """Plex source Viewset""" queryset = PlexSource.objects.all() serializer_class = PlexSourceSerializer lookup_field = "slug" + + @permission_required(None) + @swagger_auto_schema( + request_body=PlexTokenRedeemSerializer(), + responses={200: RedirectChallenge(), 404: "Token not found"}, + manual_parameters=[ + openapi.Parameter( + name="slug", + in_=openapi.IN_QUERY, + type=openapi.TYPE_STRING, + ) + ], + ) + @action( + methods=["POST"], + detail=False, + pagination_class=None, + filter_backends=[], + permission_classes=[AllowAny], + ) + def redeem_token(self, request: Request) -> Response: + """Redeem a plex token, check it's access to resources against what's allowed + for the source, and redirect to an authentication/enrollment flow.""" + source: PlexSource = get_object_or_404( + PlexSource, slug=request.query_params.get("slug", "") + ) + plex_token = request.data.get("plex_token", None) + if not plex_token: + raise Http404 + qs = {"X-Plex-Token": plex_token, "X-Plex-Client-Identifier": source.client_id} + try: + response = get( + f"https://plex.tv/api/v2/resources?{urlencode(qs)}", + headers={"Accept": "application/json"}, + ) + response.raise_for_status() + except RequestException as exc: + LOGGER.warning("Unable to fetch user resources", exc=exc) + raise Http404 + else: + resources: list[dict] = response.json() + for resource in resources: + if resource["provides"] != "server": + continue + if resource["clientIdentifier"] in source.allowed_servers: + LOGGER.info( + "Plex allowed access from server", name=resource["name"] + ) + request.session["foo"] = "bar" + break + return Response( + RedirectChallenge( + {"type": ChallengeTypes.REDIRECT.value, "to": ""} + ).data + ) diff --git a/authentik/sources/plex/models.py b/authentik/sources/plex/models.py index 7a61c801a..da66c1902 100644 --- a/authentik/sources/plex/models.py +++ b/authentik/sources/plex/models.py @@ -3,10 +3,19 @@ from django.contrib.postgres.fields import ArrayField from django.db import models from django.templatetags.static import static from django.utils.translation import gettext_lazy as _ +from rest_framework.fields import CharField from rest_framework.serializers import BaseSerializer from authentik.core.models import Source from authentik.core.types import UILoginButton +from authentik.flows.challenge import Challenge, ChallengeTypes + + +class PlexAuthenticationChallenge(Challenge): + """Challenge shown to the user in identification stage""" + + client_id = CharField() + slug = CharField() class PlexSource(Source): @@ -28,12 +37,16 @@ class PlexSource(Source): @property def ui_login_button(self) -> UILoginButton: return UILoginButton( - url="", + challenge=PlexAuthenticationChallenge( + { + "type": ChallengeTypes.NATIVE.value, + "component": "ak-flow-sources-plex", + "client_id": self.client_id, + "slug": self.slug, + } + ), icon_url=static("authentik/sources/plex.svg"), name=self.name, - additional_data={ - "client_id": self.client_id, - }, ) class Meta: diff --git a/swagger.yaml b/swagger.yaml index 4819cb3b3..21486dcf4 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -10307,6 +10307,39 @@ paths: tags: - sources parameters: [] + /sources/plex/redeem_token/: + post: + operationId: sources_plex_redeem_token + description: |- + Redeem a plex token, check it's access to resources against what's allowed + for the source, and redirect to an authentication/enrollment flow. + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/PlexTokenRedeem' + - name: slug + in: query + type: string + responses: + '200': + description: '' + schema: + $ref: '#/definitions/RedirectChallenge' + '404': + description: Token not found + '400': + description: Invalid input. + schema: + $ref: '#/definitions/ValidationError' + '403': + description: Authentication credentials were invalid, absent or insufficient. + schema: + $ref: '#/definitions/GenericError' + tags: + - sources + parameters: [] /sources/plex/{slug}/: get: operationId: sources_plex_read @@ -17655,6 +17688,51 @@ definitions: title: Allowed servers type: string minLength: 1 + PlexTokenRedeem: + required: + - plex_token + type: object + properties: + plex_token: + title: Plex token + type: string + minLength: 1 + RedirectChallenge: + required: + - type + - to + type: object + properties: + type: + title: Type + type: string + enum: + - native + - shell + - redirect + component: + title: Component + type: string + minLength: 1 + title: + title: Title + type: string + minLength: 1 + background: + title: Background + type: string + minLength: 1 + response_errors: + title: Response errors + type: object + additionalProperties: + type: array + items: + $ref: '#/definitions/ErrorDetail' + to: + title: To + type: string + minLength: 1 SAMLSource: required: - name diff --git a/web/src/flows/FlowExecutor.ts b/web/src/flows/FlowExecutor.ts index 2e44b01e7..6338450ba 100644 --- a/web/src/flows/FlowExecutor.ts +++ b/web/src/flows/FlowExecutor.ts @@ -23,6 +23,7 @@ import "./stages/email/EmailStage"; import "./stages/identification/IdentificationStage"; import "./stages/password/PasswordStage"; import "./stages/prompt/PromptStage"; +import "./sources/plex/PlexLoginInit"; import { ShellChallenge, RedirectChallenge } from "../api/Flows"; import { IdentificationChallenge } from "./stages/identification/IdentificationStage"; import { PasswordChallenge } from "./stages/password/PasswordStage"; @@ -44,6 +45,7 @@ import { AccessDeniedChallenge } from "./access_denied/FlowAccessDenied"; import { PFSize } from "../elements/Spinner"; import { TITLE_DEFAULT } from "../constants"; import { configureSentry } from "../api/Sentry"; +import { PlexAuthenticationChallenge } from "./sources/plex/PlexLoginInit"; @customElement("ak-flow-executor") export class FlowExecutor extends LitElement implements StageHost { @@ -223,6 +225,8 @@ export class FlowExecutor extends LitElement implements StageHost { return html``; case "ak-stage-authenticator-validate": return html``; + case "ak-flow-sources-plex": + return html``; default: break; } diff --git a/web/src/flows/sources/plex/API.ts b/web/src/flows/sources/plex/API.ts index 26e0ce362..a3dc2306f 100644 --- a/web/src/flows/sources/plex/API.ts +++ b/web/src/flows/sources/plex/API.ts @@ -21,6 +21,12 @@ export const DEFAULT_HEADERS = { "X-Plex-Device-Vendor": "BeryJu.org", }; +export function popupCenterScreen(url: string, title: string, w: number, h: number): Window | null { + const top = (screen.height - h) / 4, left = (screen.width - w) / 2; + const popup = window.open(url, title, `scrollbars=yes,width=${w},height=${h},top=${top},left=${left}`); + return popup; +} + export class PlexAPIClient { token: string; @@ -44,14 +50,38 @@ export class PlexAPIClient { }; } - static async pinStatus(id: number): Promise { + static async pinStatus(clientIdentifier: string, id: number): Promise { + const headers = { ...DEFAULT_HEADERS, ...{ + "X-Plex-Client-Identifier": clientIdentifier + }}; const pinResponse = await fetch(`https://plex.tv/api/v2/pins/${id}`, { - headers: DEFAULT_HEADERS + headers: headers }); const pin: PlexPinResponse = await pinResponse.json(); return pin.authToken || ""; } + static async pinPoll(clientIdentifier: string, id: number): Promise { + const executePoll = async ( + resolve: (authToken: string) => void, + reject: (e: Error) => void + ) => { + try { + const response = await PlexAPIClient.pinStatus(clientIdentifier, id) + + if (response) { + resolve(response); + } else { + setTimeout(executePoll, 500, resolve, reject); + } + } catch (e) { + reject(e); + } + }; + + return new Promise(executePoll); + } + async getServers(): Promise { const resourcesResponse = await fetch(`https://plex.tv/api/v2/resources?X-Plex-Token=${this.token}&X-Plex-Client-Identifier=authentik`, { headers: DEFAULT_HEADERS diff --git a/web/src/flows/sources/plex/PlexLoginInit.ts b/web/src/flows/sources/plex/PlexLoginInit.ts index e357b3355..5a134933a 100644 --- a/web/src/flows/sources/plex/PlexLoginInit.ts +++ b/web/src/flows/sources/plex/PlexLoginInit.ts @@ -1,11 +1,45 @@ -import {customElement, LitElement} from "lit-element"; +import { Challenge } from "authentik-api"; +import {customElement, property} from "lit-element"; import {html, TemplateResult} from "lit-html"; +import { PFSize } from "../../../elements/Spinner"; +import { BaseStage } from "../../stages/base"; +import {PlexAPIClient, popupCenterScreen} from "./API"; +import {DEFAULT_CONFIG} from "../../../api/Config"; +import { SourcesApi } from "authentik-api"; + +export interface PlexAuthenticationChallenge extends Challenge { + + client_id: string; + slug: string; + +} @customElement("ak-flow-sources-plex") -export class PlexLoginInit extends LitElement { +export class PlexLoginInit extends BaseStage { - render(): TemplateResult { - return html``; + @property({ attribute: false }) + challenge?: PlexAuthenticationChallenge; + + async firstUpdated(): Promise { + const authInfo = await PlexAPIClient.getPin(this.challenge?.client_id || ""); + const authWindow = popupCenterScreen(authInfo.authUrl, "plex auth", 550, 700); + PlexAPIClient.pinPoll(this.challenge?.client_id || "", authInfo.pin.id).then(token => { + authWindow?.close(); + new SourcesApi(DEFAULT_CONFIG).sourcesPlexRedeemToken({ + data: { + plexToken: token, + }, + slug: this.challenge?.slug || "", + }).then(r => { + window.location.assign(r.to); + }); + }); + } + + renderLoading(): TemplateResult { + return html`
+ +
`; } } diff --git a/web/src/pages/sources/plex/PlexSourceForm.ts b/web/src/pages/sources/plex/PlexSourceForm.ts index bc963aee5..10c7da501 100644 --- a/web/src/pages/sources/plex/PlexSourceForm.ts +++ b/web/src/pages/sources/plex/PlexSourceForm.ts @@ -9,15 +9,9 @@ import "../../../elements/forms/HorizontalFormElement"; import { ifDefined } from "lit-html/directives/if-defined"; import { until } from "lit-html/directives/until"; import { first, randomString } from "../../../utils"; -import { PlexAPIClient, PlexResource} from "../../../flows/sources/plex/API"; +import { PlexAPIClient, PlexResource, popupCenterScreen} from "../../../flows/sources/plex/API"; -function popupCenterScreen(url: string, title: string, w: number, h: number): Window | null { - const top = (screen.height - h) / 4, left = (screen.width - w) / 2; - const popup = window.open(url, title, `scrollbars=yes,width=${w},height=${h},top=${top},left=${left}`); - return popup; -} - @customElement("ak-source-plex-form") export class PlexSourceForm extends Form { @@ -64,15 +58,11 @@ export class PlexSourceForm extends Form { async doAuth(): Promise { const authInfo = await PlexAPIClient.getPin(this.source?.clientId); const authWindow = popupCenterScreen(authInfo.authUrl, "plex auth", 550, 700); - const timer = setInterval(() => { - if (authWindow?.closed) { - clearInterval(timer); - PlexAPIClient.pinStatus(authInfo.pin.id).then((token: string) => { - this.plexToken = token; - this.loadServers(); - }); - } - }, 500); + PlexAPIClient.pinPoll(this.source?.clientId || "", authInfo.pin.id).then(token => { + authWindow?.close(); + this.plexToken = token; + this.loadServers(); + }); } async loadServers(): Promise { From 35faf269db2dc1c63226cf03ceb7dd4718dcaeef Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Mon, 3 May 2021 20:27:52 +0200 Subject: [PATCH 05/10] sources: rewrite onboarding Signed-off-by: Jens Langhammer --- authentik/core/api/sources.py | 1 + .../0020_source_user_matching_mode.py | 40 +++ authentik/core/models.py | 37 +++ authentik/core/sources/__init__.py | 0 authentik/core/sources/flow_manager.py | 261 ++++++++++++++++++ .../views/flows.py => core/sources/stage.py} | 22 +- .../0013_alter_eventmatcherpolicy_app.py | 84 ++++++ authentik/sources/oauth/auth.py | 23 -- .../sources/oauth/tests/test_type_discord.py | 6 +- .../sources/oauth/tests/test_type_github.py | 6 +- .../sources/oauth/tests/test_type_google.py | 6 +- .../sources/oauth/tests/test_type_twitter.py | 6 +- authentik/sources/oauth/types/azure_ad.py | 5 +- authentik/sources/oauth/types/discord.py | 3 - authentik/sources/oauth/types/facebook.py | 3 - authentik/sources/oauth/types/github.py | 3 - authentik/sources/oauth/types/google.py | 3 - authentik/sources/oauth/types/oidc.py | 6 +- authentik/sources/oauth/types/reddit.py | 3 - authentik/sources/oauth/types/twitter.py | 3 - authentik/sources/oauth/views/callback.py | 200 ++------------ authentik/sources/plex/api.py | 39 +-- .../migrations/0002_plexsourceconnection.py | 38 +++ authentik/sources/plex/models.py | 14 +- authentik/sources/plex/plex.py | 205 ++++++-------- swagger.yaml | 66 +++++ 26 files changed, 689 insertions(+), 394 deletions(-) create mode 100644 authentik/core/migrations/0020_source_user_matching_mode.py create mode 100644 authentik/core/sources/__init__.py create mode 100644 authentik/core/sources/flow_manager.py rename authentik/{sources/oauth/views/flows.py => core/sources/stage.py} (53%) create mode 100644 authentik/policies/event_matcher/migrations/0013_alter_eventmatcherpolicy_app.py delete mode 100644 authentik/sources/oauth/auth.py create mode 100644 authentik/sources/plex/migrations/0002_plexsourceconnection.py diff --git a/authentik/core/api/sources.py b/authentik/core/api/sources.py index a9c481a07..e1da3a2fb 100644 --- a/authentik/core/api/sources.py +++ b/authentik/core/api/sources.py @@ -45,6 +45,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer): "verbose_name", "verbose_name_plural", "policy_engine_mode", + "user_matching_mode", ] diff --git a/authentik/core/migrations/0020_source_user_matching_mode.py b/authentik/core/migrations/0020_source_user_matching_mode.py new file mode 100644 index 000000000..68f6f9524 --- /dev/null +++ b/authentik/core/migrations/0020_source_user_matching_mode.py @@ -0,0 +1,40 @@ +# Generated by Django 3.2 on 2021-05-03 17:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_core", "0019_source_managed"), + ] + + operations = [ + migrations.AddField( + model_name="source", + name="user_matching_mode", + field=models.TextField( + choices=[ + ("identifier", "Use the source-specific identifier"), + ( + "email_link", + "Link to a user with identical email address. Can have security implications when a source doesn't validate email addresses.", + ), + ( + "email_deny", + "Use the user's email address, but deny enrollment when the email address already exists.", + ), + ( + "username_link", + "Link to a user with identical username address. Can have security implications when a username is used with another source.", + ), + ( + "username_deny", + "Use the user's username, but deny enrollment when the username already exists.", + ), + ], + default="identifier", + help_text="How the source determines if an existing user should be authenticated or a new user enrolled.", + ), + ), + ] diff --git a/authentik/core/models.py b/authentik/core/models.py index 501b556c7..b1f8fc2af 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -240,6 +240,30 @@ class Application(PolicyBindingModel): verbose_name_plural = _("Applications") +class SourceUserMatchingModes(models.TextChoices): + """Different modes a source can handle new/returning users""" + + IDENTIFIER = "identifier", _("Use the source-specific identifier") + EMAIL_LINK = "email_link", _( + ( + "Link to a user with identical email address. Can have security implications " + "when a source doesn't validate email addresses." + ) + ) + EMAIL_DENY = "email_deny", _( + "Use the user's email address, but deny enrollment when the email address already exists." + ) + USERNAME_LINK = "username_link", _( + ( + "Link to a user with identical username address. Can have security implications " + "when a username is used with another source." + ) + ) + USERNAME_DENY = "username_deny", _( + "Use the user's username, but deny enrollment when the username already exists." + ) + + class Source(ManagedModel, SerializerModel, PolicyBindingModel): """Base Authentication source, i.e. an OAuth Provider, SAML Remote or LDAP Server""" @@ -272,6 +296,17 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel): related_name="source_enrollment", ) + user_matching_mode = models.TextField( + choices=SourceUserMatchingModes.choices, + default=SourceUserMatchingModes.IDENTIFIER, + help_text=_( + ( + "How the source determines if an existing user should be authenticated or " + "a new user enrolled." + ) + ), + ) + objects = InheritanceManager() @property @@ -301,6 +336,8 @@ class UserSourceConnection(CreatedUpdatedModel): user = models.ForeignKey(User, on_delete=models.CASCADE) source = models.ForeignKey(Source, on_delete=models.CASCADE) + objects = InheritanceManager() + class Meta: unique_together = (("user", "source"),) diff --git a/authentik/core/sources/__init__.py b/authentik/core/sources/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/authentik/core/sources/flow_manager.py b/authentik/core/sources/flow_manager.py new file mode 100644 index 000000000..c6f5b9795 --- /dev/null +++ b/authentik/core/sources/flow_manager.py @@ -0,0 +1,261 @@ +"""Source decision helper""" +from enum import Enum +from typing import Any, Optional, Type + +from django.contrib import messages +from django.db.models.query_utils import Q +from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest +from django.shortcuts import redirect +from django.urls import reverse +from django.utils.translation import gettext as _ +from structlog.stdlib import get_logger + +from authentik.core.models import ( + Source, + SourceUserMatchingModes, + User, + UserSourceConnection, +) +from authentik.core.sources.stage import ( + PLAN_CONTEXT_SOURCES_CONNECTION, + PostUserEnrollmentStage, +) +from authentik.events.models import Event, EventAction +from authentik.flows.models import Flow, Stage, in_memory_stage +from authentik.flows.planner import ( + PLAN_CONTEXT_PENDING_USER, + PLAN_CONTEXT_REDIRECT, + PLAN_CONTEXT_SOURCE, + PLAN_CONTEXT_SSO, + FlowPlanner, +) +from authentik.flows.views import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN +from authentik.lib.utils.urls import redirect_with_qs +from authentik.policies.utils import delete_none_keys +from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND +from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT + + +class Action(Enum): + """Actions that can be decided based on the request + and source settings""" + + LINK = "link" + AUTH = "auth" + ENROLL = "enroll" + DENY = "deny" + + +class SourceFlowManager: + """Help sources decide what they should do after authorization. Based on source settings and + previous connections, authenticate the user, enroll a new user, link to an existing user + or deny the request.""" + + source: Source + request: HttpRequest + + identifier: str + + connection_type: Type[UserSourceConnection] = UserSourceConnection + + def __init__( + self, + source: Source, + request: HttpRequest, + identifier: str, + enroll_info: dict[str, Any], + ) -> None: + self.source = source + self.request = request + self.identifier = identifier + self.enroll_info = enroll_info + self._logger = get_logger().bind(source=source, identifier=identifier) + + # pylint: disable=too-many-return-statements + def get_action(self, **kwargs) -> tuple[Action, Optional[UserSourceConnection]]: + """decide which action should be taken""" + new_connection = self.connection_type( + source=self.source, identifier=self.identifier + ) + # When request is authenticated, always link + if self.request.user.is_authenticated: + new_connection.user = self.request.user + new_connection = self.update_connection(new_connection, **kwargs) + new_connection.save() + return Action.LINK, new_connection + + existing_connections = self.connection_type.objects.filter( + source=self.source, identifier=self.identifier + ) + if existing_connections.exists(): + connection = existing_connections.first() + return Action.AUTH, self.update_connection(connection, **kwargs) + # No connection exists, but we match on identifier, so enroll + if self.source.user_matching_mode == SourceUserMatchingModes.IDENTIFIER: + # We don't save the connection here cause it doesn't have a user assigned yet + return Action.ENROLL, self.update_connection(new_connection, **kwargs) + + # Check for existing users with matching attributes + query = Q() + # Either query existing user based on email or username + if self.source.user_matching_mode in [ + SourceUserMatchingModes.EMAIL_LINK, + SourceUserMatchingModes.EMAIL_DENY, + ]: + if not self.enroll_info.get("email", None): + self._logger.warning("Refusing to use none email", source=self.source) + return Action.DENY, None + query = Q(email__exact=self.enroll_info.get("email", None)) + if self.source.user_matching_mode in [ + SourceUserMatchingModes.USERNAME_LINK, + SourceUserMatchingModes.USERNAME_DENY, + ]: + if not self.enroll_info.get("username", None): + self._logger.warning( + "Refusing to use none username", source=self.source + ) + return Action.DENY, None + query = Q(username__exact=self.enroll_info.get("username", None)) + matching_users = User.objects.filter(query) + # No matching users, always enroll + if not matching_users.exists(): + return Action.ENROLL, self.update_connection(new_connection, **kwargs) + + user = matching_users.first() + if self.source.user_matching_mode in [ + SourceUserMatchingModes.EMAIL_LINK, + SourceUserMatchingModes.USERNAME_LINK, + ]: + new_connection.user = user + new_connection = self.update_connection(new_connection, **kwargs) + new_connection.save() + return Action.LINK, new_connection + if self.source.user_matching_mode in [ + SourceUserMatchingModes.EMAIL_DENY, + SourceUserMatchingModes.USERNAME_DENY, + ]: + return Action.DENY, None + return Action.DENY, None + + def update_connection( + self, connection: UserSourceConnection, **kwargs + ) -> UserSourceConnection: + """Optionally make changes to the connection after it is looked up/created.""" + return connection + + def get_flow(self, **kwargs) -> HttpResponse: + """Get the flow response based on user_matching_mode""" + action, connection = self.get_action() + if action == Action.LINK: + self._logger.debug("Linking existing user") + return self.handle_existing_user_link() + if not connection: + return redirect("/") + if action == Action.AUTH: + self._logger.debug("Handling auth user") + return self.handle_auth_user(connection) + if action == Action.ENROLL: + self._logger.debug("Handling enrollment of new user") + return self.handle_enroll(connection) + return redirect("/") + + # pylint: disable=unused-argument + def get_stages_to_append(self, flow: Flow) -> list[Stage]: + """Hook to override stages which are appended to the flow""" + if flow.slug == self.source.enrollment_flow.slug: + return [ + in_memory_stage(PostUserEnrollmentStage), + ] + return [] + + def _handle_login_flow(self, flow: Flow, **kwargs) -> HttpResponse: + """Prepare Authentication Plan, redirect user FlowExecutor""" + # Ensure redirect is carried through when user was trying to + # authorize application + final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get( + NEXT_ARG_NAME, "authentik_core:if-admin" + ) + kwargs.update( + { + # Since we authenticate the user by their token, they have no backend set + PLAN_CONTEXT_AUTHENTICATION_BACKEND: "django.contrib.auth.backends.ModelBackend", + PLAN_CONTEXT_SSO: True, + PLAN_CONTEXT_SOURCE: self.source, + PLAN_CONTEXT_REDIRECT: final_redirect, + } + ) + if not flow: + return HttpResponseBadRequest() + # We run the Flow planner here so we can pass the Pending user in the context + planner = FlowPlanner(flow) + plan = planner.plan(self.request, kwargs) + for stage in self.get_stages_to_append(flow): + plan.append(stage) + self.request.session[SESSION_KEY_PLAN] = plan + return redirect_with_qs( + "authentik_core:if-flow", + self.request.GET, + flow_slug=flow.slug, + ) + + # pylint: disable=unused-argument + def handle_auth_user( + self, + connection: UserSourceConnection, + ) -> HttpResponse: + """Login user and redirect.""" + messages.success( + self.request, + _( + "Successfully authenticated with %(source)s!" + % {"source": self.source.name} + ), + ) + flow_kwargs = {PLAN_CONTEXT_PENDING_USER: connection.user} + return self._handle_login_flow(self.source.authentication_flow, **flow_kwargs) + + def handle_existing_user_link( + self, + ) -> HttpResponse: + """Handler when the user was already authenticated and linked an external source + to their account.""" + Event.new( + EventAction.SOURCE_LINKED, + message="Linked Source", + source=self.source, + ).from_http(self.request) + messages.success( + self.request, + _("Successfully linked %(source)s!" % {"source": self.source.name}), + ) + return redirect( + reverse( + "authentik_core:if-admin", + ) + + f"#/user;page-{self.source.slug}" + ) + + def handle_enroll( + self, + connection: UserSourceConnection, + ) -> HttpResponse: + """User was not authenticated and previous request was not authenticated.""" + messages.success( + self.request, + _( + "Successfully authenticated with %(source)s!" + % {"source": self.source.name} + ), + ) + + # We run the Flow planner here so we can pass the Pending user in the context + if not self.source.enrollment_flow: + self._logger.warning("source has no enrollment flow") + return HttpResponseBadRequest() + return self._handle_login_flow( + self.source.enrollment_flow, + **{ + PLAN_CONTEXT_PROMPT: delete_none_keys(self.enroll_info), + PLAN_CONTEXT_SOURCES_CONNECTION: connection, + }, + ) diff --git a/authentik/sources/oauth/views/flows.py b/authentik/core/sources/stage.py similarity index 53% rename from authentik/sources/oauth/views/flows.py rename to authentik/core/sources/stage.py index 1dc239aed..bcc37adf0 100644 --- a/authentik/sources/oauth/views/flows.py +++ b/authentik/core/sources/stage.py @@ -1,32 +1,30 @@ -"""OAuth Stages""" +"""Source flow manager stages""" from django.http import HttpRequest, HttpResponse -from authentik.core.models import User +from authentik.core.models import User, UserSourceConnection from authentik.events.models import Event, EventAction from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER from authentik.flows.stage import StageView -from authentik.sources.oauth.models import UserOAuthSourceConnection -PLAN_CONTEXT_SOURCES_OAUTH_ACCESS = "sources_oauth_access" +PLAN_CONTEXT_SOURCES_CONNECTION = "goauthentik.io/sources/connection" class PostUserEnrollmentStage(StageView): - """Dynamically injected stage which saves the OAuth Connection after + """Dynamically injected stage which saves the Connection after the user has been enrolled.""" # pylint: disable=unused-argument def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: """Stage used after the user has been enrolled""" - access: UserOAuthSourceConnection = self.executor.plan.context[ - PLAN_CONTEXT_SOURCES_OAUTH_ACCESS + connection: UserSourceConnection = self.executor.plan.context[ + PLAN_CONTEXT_SOURCES_CONNECTION ] user: User = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] - access.user = user - access.save() - UserOAuthSourceConnection.objects.filter(pk=access.pk).update(user=user) + connection.user = user + connection.save() Event.new( EventAction.SOURCE_LINKED, - message="Linked OAuth Source", - source=access.source, + message="Linked Source", + source=connection.source, ).from_http(self.request) return self.executor.stage_ok() diff --git a/authentik/policies/event_matcher/migrations/0013_alter_eventmatcherpolicy_app.py b/authentik/policies/event_matcher/migrations/0013_alter_eventmatcherpolicy_app.py new file mode 100644 index 000000000..46cc8443a --- /dev/null +++ b/authentik/policies/event_matcher/migrations/0013_alter_eventmatcherpolicy_app.py @@ -0,0 +1,84 @@ +# Generated by Django 3.2 on 2021-05-02 17:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_policies_event_matcher", "0012_auto_20210323_1339"), + ] + + operations = [ + migrations.AlterField( + model_name="eventmatcherpolicy", + name="app", + field=models.TextField( + blank=True, + choices=[ + ("authentik.admin", "authentik Admin"), + ("authentik.api", "authentik API"), + ("authentik.events", "authentik Events"), + ("authentik.crypto", "authentik Crypto"), + ("authentik.flows", "authentik Flows"), + ("authentik.outposts", "authentik Outpost"), + ("authentik.lib", "authentik lib"), + ("authentik.policies", "authentik Policies"), + ("authentik.policies.dummy", "authentik Policies.Dummy"), + ( + "authentik.policies.event_matcher", + "authentik Policies.Event Matcher", + ), + ("authentik.policies.expiry", "authentik Policies.Expiry"), + ("authentik.policies.expression", "authentik Policies.Expression"), + ("authentik.policies.hibp", "authentik Policies.HaveIBeenPwned"), + ("authentik.policies.password", "authentik Policies.Password"), + ("authentik.policies.reputation", "authentik Policies.Reputation"), + ("authentik.providers.proxy", "authentik Providers.Proxy"), + ("authentik.providers.oauth2", "authentik Providers.OAuth2"), + ("authentik.providers.saml", "authentik Providers.SAML"), + ("authentik.recovery", "authentik Recovery"), + ("authentik.sources.ldap", "authentik Sources.LDAP"), + ("authentik.sources.oauth", "authentik Sources.OAuth"), + ("authentik.sources.plex", "authentik Sources.Plex"), + ("authentik.sources.saml", "authentik Sources.SAML"), + ( + "authentik.stages.authenticator_static", + "authentik Stages.Authenticator.Static", + ), + ( + "authentik.stages.authenticator_totp", + "authentik Stages.Authenticator.TOTP", + ), + ( + "authentik.stages.authenticator_validate", + "authentik Stages.Authenticator.Validate", + ), + ( + "authentik.stages.authenticator_webauthn", + "authentik Stages.Authenticator.WebAuthn", + ), + ("authentik.stages.captcha", "authentik Stages.Captcha"), + ("authentik.stages.consent", "authentik Stages.Consent"), + ("authentik.stages.deny", "authentik Stages.Deny"), + ("authentik.stages.dummy", "authentik Stages.Dummy"), + ("authentik.stages.email", "authentik Stages.Email"), + ( + "authentik.stages.identification", + "authentik Stages.Identification", + ), + ("authentik.stages.invitation", "authentik Stages.User Invitation"), + ("authentik.stages.password", "authentik Stages.Password"), + ("authentik.stages.prompt", "authentik Stages.Prompt"), + ("authentik.stages.user_delete", "authentik Stages.User Delete"), + ("authentik.stages.user_login", "authentik Stages.User Login"), + ("authentik.stages.user_logout", "authentik Stages.User Logout"), + ("authentik.stages.user_write", "authentik Stages.User Write"), + ("authentik.core", "authentik Core"), + ("authentik.managed", "authentik Managed"), + ], + default="", + help_text="Match events created by selected application. When left empty, all applications are matched.", + ), + ), + ] diff --git a/authentik/sources/oauth/auth.py b/authentik/sources/oauth/auth.py deleted file mode 100644 index 62836f574..000000000 --- a/authentik/sources/oauth/auth.py +++ /dev/null @@ -1,23 +0,0 @@ -"""authentik oauth_client Authorization backend""" -from typing import Optional - -from django.contrib.auth.backends import ModelBackend -from django.http import HttpRequest - -from authentik.core.models import User -from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection - - -class AuthorizedServiceBackend(ModelBackend): - "Authentication backend for users registered with remote OAuth provider." - - def authenticate( - self, request: HttpRequest, source: OAuthSource, identifier: str - ) -> Optional[User]: - "Fetch user for a given source by id." - access = UserOAuthSourceConnection.objects.filter( - source=source, identifier=identifier - ).select_related("user") - if not access.exists(): - return None - return access.first().user diff --git a/authentik/sources/oauth/tests/test_type_discord.py b/authentik/sources/oauth/tests/test_type_discord.py index 6b337d1bd..86340afed 100644 --- a/authentik/sources/oauth/tests/test_type_discord.py +++ b/authentik/sources/oauth/tests/test_type_discord.py @@ -1,7 +1,7 @@ """Discord Type tests""" from django.test import TestCase -from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection +from authentik.sources.oauth.models import OAuthSource from authentik.sources.oauth.types.discord import DiscordOAuth2Callback # https://discord.com/developers/docs/resources/user#user-object @@ -33,9 +33,7 @@ class TestTypeDiscord(TestCase): def test_enroll_context(self): """Test discord Enrollment context""" - ak_context = DiscordOAuth2Callback().get_user_enroll_context( - self.source, UserOAuthSourceConnection(), DISCORD_USER - ) + ak_context = DiscordOAuth2Callback().get_user_enroll_context(DISCORD_USER) self.assertEqual(ak_context["username"], DISCORD_USER["username"]) self.assertEqual(ak_context["email"], DISCORD_USER["email"]) self.assertEqual(ak_context["name"], DISCORD_USER["username"]) diff --git a/authentik/sources/oauth/tests/test_type_github.py b/authentik/sources/oauth/tests/test_type_github.py index 3acce60fc..50a699b9c 100644 --- a/authentik/sources/oauth/tests/test_type_github.py +++ b/authentik/sources/oauth/tests/test_type_github.py @@ -1,7 +1,7 @@ """GitHub Type tests""" from django.test import TestCase -from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection +from authentik.sources.oauth.models import OAuthSource from authentik.sources.oauth.types.github import GitHubOAuth2Callback # https://developer.github.com/v3/users/#get-the-authenticated-user @@ -63,9 +63,7 @@ class TestTypeGitHub(TestCase): def test_enroll_context(self): """Test GitHub Enrollment context""" - ak_context = GitHubOAuth2Callback().get_user_enroll_context( - self.source, UserOAuthSourceConnection(), GITHUB_USER - ) + ak_context = GitHubOAuth2Callback().get_user_enroll_context(GITHUB_USER) self.assertEqual(ak_context["username"], GITHUB_USER["login"]) self.assertEqual(ak_context["email"], GITHUB_USER["email"]) self.assertEqual(ak_context["name"], GITHUB_USER["name"]) diff --git a/authentik/sources/oauth/tests/test_type_google.py b/authentik/sources/oauth/tests/test_type_google.py index 6f43812ad..6f79f8a2d 100644 --- a/authentik/sources/oauth/tests/test_type_google.py +++ b/authentik/sources/oauth/tests/test_type_google.py @@ -1,7 +1,7 @@ """google Type tests""" from django.test import TestCase -from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection +from authentik.sources.oauth.models import OAuthSource from authentik.sources.oauth.types.google import GoogleOAuth2Callback # https://developers.google.com/identity/protocols/oauth2/openid-connect?hl=en @@ -32,9 +32,7 @@ class TestTypeGoogle(TestCase): def test_enroll_context(self): """Test Google Enrollment context""" - ak_context = GoogleOAuth2Callback().get_user_enroll_context( - self.source, UserOAuthSourceConnection(), GOOGLE_USER - ) + ak_context = GoogleOAuth2Callback().get_user_enroll_context(GOOGLE_USER) self.assertEqual(ak_context["username"], GOOGLE_USER["email"]) self.assertEqual(ak_context["email"], GOOGLE_USER["email"]) self.assertEqual(ak_context["name"], GOOGLE_USER["name"]) diff --git a/authentik/sources/oauth/tests/test_type_twitter.py b/authentik/sources/oauth/tests/test_type_twitter.py index b0918fa62..84fdd0f80 100644 --- a/authentik/sources/oauth/tests/test_type_twitter.py +++ b/authentik/sources/oauth/tests/test_type_twitter.py @@ -1,7 +1,7 @@ """Twitter Type tests""" from django.test import Client, TestCase -from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection +from authentik.sources.oauth.models import OAuthSource from authentik.sources.oauth.types.twitter import TwitterOAuthCallback # https://developer.twitter.com/en/docs/twitter-api/v1/accounts-and-users/manage-account-settings/ \ @@ -104,9 +104,7 @@ class TestTypeGitHub(TestCase): def test_enroll_context(self): """Test Twitter Enrollment context""" - ak_context = TwitterOAuthCallback().get_user_enroll_context( - self.source, UserOAuthSourceConnection(), TWITTER_USER - ) + ak_context = TwitterOAuthCallback().get_user_enroll_context(TWITTER_USER) self.assertEqual(ak_context["username"], TWITTER_USER["screen_name"]) self.assertEqual(ak_context["email"], TWITTER_USER.get("email", None)) self.assertEqual(ak_context["name"], TWITTER_USER["name"]) diff --git a/authentik/sources/oauth/types/azure_ad.py b/authentik/sources/oauth/types/azure_ad.py index fbd81f08f..7d5dc02fb 100644 --- a/authentik/sources/oauth/types/azure_ad.py +++ b/authentik/sources/oauth/types/azure_ad.py @@ -2,7 +2,6 @@ from typing import Any, Optional from uuid import UUID -from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection from authentik.sources.oauth.types.manager import MANAGER, SourceType from authentik.sources.oauth.views.callback import OAuthCallback @@ -10,7 +9,7 @@ from authentik.sources.oauth.views.callback import OAuthCallback class AzureADOAuthCallback(OAuthCallback): """AzureAD OAuth2 Callback""" - def get_user_id(self, source: OAuthSource, info: dict[str, Any]) -> Optional[str]: + def get_user_id(self, info: dict[str, Any]) -> Optional[str]: try: return str(UUID(info.get("objectId")).int) except TypeError: @@ -18,8 +17,6 @@ class AzureADOAuthCallback(OAuthCallback): def get_user_enroll_context( self, - source: OAuthSource, - access: UserOAuthSourceConnection, info: dict[str, Any], ) -> dict[str, Any]: mail = info.get("mail", None) or info.get("otherMails", [None])[0] diff --git a/authentik/sources/oauth/types/discord.py b/authentik/sources/oauth/types/discord.py index 00bac79fd..a97cca546 100644 --- a/authentik/sources/oauth/types/discord.py +++ b/authentik/sources/oauth/types/discord.py @@ -1,7 +1,6 @@ """Discord OAuth Views""" from typing import Any -from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection from authentik.sources.oauth.types.manager import MANAGER, SourceType from authentik.sources.oauth.views.callback import OAuthCallback from authentik.sources.oauth.views.redirect import OAuthRedirect @@ -21,8 +20,6 @@ class DiscordOAuth2Callback(OAuthCallback): def get_user_enroll_context( self, - source: OAuthSource, - access: UserOAuthSourceConnection, info: dict[str, Any], ) -> dict[str, Any]: return { diff --git a/authentik/sources/oauth/types/facebook.py b/authentik/sources/oauth/types/facebook.py index ab27d6b6f..8efe16102 100644 --- a/authentik/sources/oauth/types/facebook.py +++ b/authentik/sources/oauth/types/facebook.py @@ -4,7 +4,6 @@ from typing import Any, Optional from facebook import GraphAPI from authentik.sources.oauth.clients.oauth2 import OAuth2Client -from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection from authentik.sources.oauth.types.manager import MANAGER, SourceType from authentik.sources.oauth.views.callback import OAuthCallback from authentik.sources.oauth.views.redirect import OAuthRedirect @@ -34,8 +33,6 @@ class FacebookOAuth2Callback(OAuthCallback): def get_user_enroll_context( self, - source: OAuthSource, - access: UserOAuthSourceConnection, info: dict[str, Any], ) -> dict[str, Any]: return { diff --git a/authentik/sources/oauth/types/github.py b/authentik/sources/oauth/types/github.py index c830d4919..791e98912 100644 --- a/authentik/sources/oauth/types/github.py +++ b/authentik/sources/oauth/types/github.py @@ -1,7 +1,6 @@ """GitHub OAuth Views""" from typing import Any -from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection from authentik.sources.oauth.types.manager import MANAGER, SourceType from authentik.sources.oauth.views.callback import OAuthCallback @@ -11,8 +10,6 @@ class GitHubOAuth2Callback(OAuthCallback): def get_user_enroll_context( self, - source: OAuthSource, - access: UserOAuthSourceConnection, info: dict[str, Any], ) -> dict[str, Any]: return { diff --git a/authentik/sources/oauth/types/google.py b/authentik/sources/oauth/types/google.py index e69004254..ee6bdf63f 100644 --- a/authentik/sources/oauth/types/google.py +++ b/authentik/sources/oauth/types/google.py @@ -1,7 +1,6 @@ """Google OAuth Views""" from typing import Any -from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection from authentik.sources.oauth.types.manager import MANAGER, SourceType from authentik.sources.oauth.views.callback import OAuthCallback from authentik.sources.oauth.views.redirect import OAuthRedirect @@ -21,8 +20,6 @@ class GoogleOAuth2Callback(OAuthCallback): def get_user_enroll_context( self, - source: OAuthSource, - access: UserOAuthSourceConnection, info: dict[str, Any], ) -> dict[str, Any]: return { diff --git a/authentik/sources/oauth/types/oidc.py b/authentik/sources/oauth/types/oidc.py index e2acf4b63..01fae8dcd 100644 --- a/authentik/sources/oauth/types/oidc.py +++ b/authentik/sources/oauth/types/oidc.py @@ -1,7 +1,7 @@ """OpenID Connect OAuth Views""" from typing import Any -from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection +from authentik.sources.oauth.models import OAuthSource from authentik.sources.oauth.types.manager import MANAGER, SourceType from authentik.sources.oauth.views.callback import OAuthCallback from authentik.sources.oauth.views.redirect import OAuthRedirect @@ -19,13 +19,11 @@ class OpenIDConnectOAuthRedirect(OAuthRedirect): class OpenIDConnectOAuth2Callback(OAuthCallback): """OpenIDConnect OAuth2 Callback""" - def get_user_id(self, source: OAuthSource, info: dict[str, str]) -> str: + def get_user_id(self, info: dict[str, str]) -> str: return info.get("sub", "") def get_user_enroll_context( self, - source: OAuthSource, - access: UserOAuthSourceConnection, info: dict[str, Any], ) -> dict[str, Any]: return { diff --git a/authentik/sources/oauth/types/reddit.py b/authentik/sources/oauth/types/reddit.py index 74c777e6d..53757b38e 100644 --- a/authentik/sources/oauth/types/reddit.py +++ b/authentik/sources/oauth/types/reddit.py @@ -4,7 +4,6 @@ from typing import Any from requests.auth import HTTPBasicAuth from authentik.sources.oauth.clients.oauth2 import OAuth2Client -from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection from authentik.sources.oauth.types.manager import MANAGER, SourceType from authentik.sources.oauth.views.callback import OAuthCallback from authentik.sources.oauth.views.redirect import OAuthRedirect @@ -36,8 +35,6 @@ class RedditOAuth2Callback(OAuthCallback): def get_user_enroll_context( self, - source: OAuthSource, - access: UserOAuthSourceConnection, info: dict[str, Any], ) -> dict[str, Any]: return { diff --git a/authentik/sources/oauth/types/twitter.py b/authentik/sources/oauth/types/twitter.py index df1ed1a9f..b4df3d607 100644 --- a/authentik/sources/oauth/types/twitter.py +++ b/authentik/sources/oauth/types/twitter.py @@ -1,7 +1,6 @@ """Twitter OAuth Views""" from typing import Any -from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection from authentik.sources.oauth.types.manager import MANAGER, SourceType from authentik.sources.oauth.views.callback import OAuthCallback @@ -11,8 +10,6 @@ class TwitterOAuthCallback(OAuthCallback): def get_user_enroll_context( self, - source: OAuthSource, - access: UserOAuthSourceConnection, info: dict[str, Any], ) -> dict[str, Any]: return { diff --git a/authentik/sources/oauth/views/callback.py b/authentik/sources/oauth/views/callback.py index 5ca72c85e..036652f26 100644 --- a/authentik/sources/oauth/views/callback.py +++ b/authentik/sources/oauth/views/callback.py @@ -4,35 +4,14 @@ from typing import Any, Optional from django.conf import settings from django.contrib import messages from django.http import Http404, HttpRequest, HttpResponse -from django.http.response import HttpResponseBadRequest from django.shortcuts import redirect -from django.urls import reverse from django.utils.translation import gettext as _ from django.views.generic import View from structlog.stdlib import get_logger -from authentik.core.models import User -from authentik.events.models import Event, EventAction -from authentik.flows.models import Flow, in_memory_stage -from authentik.flows.planner import ( - PLAN_CONTEXT_PENDING_USER, - PLAN_CONTEXT_REDIRECT, - PLAN_CONTEXT_SOURCE, - PLAN_CONTEXT_SSO, - FlowPlanner, -) -from authentik.flows.views import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN -from authentik.lib.utils.urls import redirect_with_qs -from authentik.policies.utils import delete_none_keys -from authentik.sources.oauth.auth import AuthorizedServiceBackend +from authentik.core.sources.flow_manager import SourceFlowManager from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection from authentik.sources.oauth.views.base import OAuthClientMixin -from authentik.sources.oauth.views.flows import ( - PLAN_CONTEXT_SOURCES_OAUTH_ACCESS, - PostUserEnrollmentStage, -) -from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND -from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT LOGGER = get_logger() @@ -40,8 +19,7 @@ LOGGER = get_logger() class OAuthCallback(OAuthClientMixin, View): "Base OAuth callback view." - source_id = None - source = None + source: OAuthSource # pylint: disable=too-many-return-statements def get(self, request: HttpRequest, *_, **kwargs) -> HttpResponse: @@ -60,47 +38,27 @@ class OAuthCallback(OAuthClientMixin, View): # Fetch access token token = client.get_access_token() if token is None: - return self.handle_login_failure(self.source, "Could not retrieve token.") + return self.handle_login_failure("Could not retrieve token.") if "error" in token: - return self.handle_login_failure(self.source, token["error"]) + return self.handle_login_failure(token["error"]) # Fetch profile info - info = client.get_profile_info(token) - if info is None: - return self.handle_login_failure(self.source, "Could not retrieve profile.") - identifier = self.get_user_id(self.source, info) + raw_info = client.get_profile_info(token) + if raw_info is None: + return self.handle_login_failure("Could not retrieve profile.") + identifier = self.get_user_id(raw_info) if identifier is None: - return self.handle_login_failure(self.source, "Could not determine id.") + return self.handle_login_failure("Could not determine id.") # Get or create access record - defaults = { - "access_token": token.get("access_token"), - } - existing = UserOAuthSourceConnection.objects.filter( - source=self.source, identifier=identifier + enroll_info = self.get_user_enroll_context(raw_info) + sfm = OAuthSourceFlowManager( + source=self.source, + request=self.request, + identifier=identifier, + enroll_info=enroll_info, ) - - if existing.exists(): - connection = existing.first() - connection.access_token = token.get("access_token") - UserOAuthSourceConnection.objects.filter(pk=connection.pk).update( - **defaults - ) - else: - connection = UserOAuthSourceConnection( - source=self.source, - identifier=identifier, - access_token=token.get("access_token"), - ) - user = AuthorizedServiceBackend().authenticate( - source=self.source, identifier=identifier, request=request + return sfm.get_flow( + token=token, ) - if user is None: - if self.request.user.is_authenticated: - LOGGER.debug("Linking existing user", source=self.source) - return self.handle_existing_user_link(self.source, connection, info) - LOGGER.debug("Handling enrollment of new user", source=self.source) - return self.handle_enroll(self.source, connection, info) - LOGGER.debug("Handling existing user", source=self.source) - return self.handle_existing_user(self.source, user, connection, info) # pylint: disable=unused-argument def get_callback_url(self, source: OAuthSource) -> str: @@ -114,132 +72,34 @@ class OAuthCallback(OAuthClientMixin, View): def get_user_enroll_context( self, - source: OAuthSource, - access: UserOAuthSourceConnection, info: dict[str, Any], ) -> dict[str, Any]: """Create a dict of User data""" raise NotImplementedError() # pylint: disable=unused-argument - def get_user_id( - self, source: UserOAuthSourceConnection, info: dict[str, Any] - ) -> Optional[str]: + def get_user_id(self, info: dict[str, Any]) -> Optional[str]: """Return unique identifier from the profile info.""" if "id" in info: return info["id"] return None - def handle_login_failure(self, source: OAuthSource, reason: str) -> HttpResponse: + def handle_login_failure(self, reason: str) -> HttpResponse: "Message user and redirect on error." LOGGER.warning("Authentication Failure", reason=reason) messages.error(self.request, _("Authentication Failed.")) - return redirect(self.get_error_redirect(source, reason)) + return redirect(self.get_error_redirect(self.source, reason)) - def handle_login_flow( - self, flow: Flow, *stages_to_append, **kwargs - ) -> HttpResponse: - """Prepare Authentication Plan, redirect user FlowExecutor""" - # Ensure redirect is carried through when user was trying to - # authorize application - final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get( - NEXT_ARG_NAME, "authentik_core:if-admin" - ) - kwargs.update( - { - # Since we authenticate the user by their token, they have no backend set - PLAN_CONTEXT_AUTHENTICATION_BACKEND: "django.contrib.auth.backends.ModelBackend", - PLAN_CONTEXT_SSO: True, - PLAN_CONTEXT_SOURCE: self.source, - PLAN_CONTEXT_REDIRECT: final_redirect, - } - ) - if not flow: - return HttpResponseBadRequest() - # We run the Flow planner here so we can pass the Pending user in the context - planner = FlowPlanner(flow) - plan = planner.plan(self.request, kwargs) - for stage in stages_to_append: - plan.append(stage) - self.request.session[SESSION_KEY_PLAN] = plan - return redirect_with_qs( - "authentik_core:if-flow", - self.request.GET, - flow_slug=flow.slug, - ) - # pylint: disable=unused-argument - def handle_existing_user( - self, - source: OAuthSource, - user: User, - access: UserOAuthSourceConnection, - info: dict[str, Any], - ) -> HttpResponse: - "Login user and redirect." - messages.success( - self.request, - _( - "Successfully authenticated with %(source)s!" - % {"source": self.source.name} - ), - ) - flow_kwargs = {PLAN_CONTEXT_PENDING_USER: user} - return self.handle_login_flow(source.authentication_flow, **flow_kwargs) +class OAuthSourceFlowManager(SourceFlowManager): + """Flow manager for oauth sources""" - def handle_existing_user_link( - self, - source: OAuthSource, - access: UserOAuthSourceConnection, - info: dict[str, Any], - ) -> HttpResponse: - """Handler when the user was already authenticated and linked an external source - to their account.""" - # there's already a user logged in, just link them up - user = self.request.user - access.user = user - access.save() - UserOAuthSourceConnection.objects.filter(pk=access.pk).update(user=user) - Event.new( - EventAction.SOURCE_LINKED, message="Linked OAuth Source", source=source - ).from_http(self.request) - messages.success( - self.request, - _("Successfully linked %(source)s!" % {"source": self.source.name}), - ) - return redirect( - reverse( - "authentik_core:if-admin", - ) - + f"#/user;page-{self.source.slug}" - ) + connection_type = UserOAuthSourceConnection - def handle_enroll( - self, - source: OAuthSource, - access: UserOAuthSourceConnection, - info: dict[str, Any], - ) -> HttpResponse: - """User was not authenticated and previous request was not authenticated.""" - messages.success( - self.request, - _( - "Successfully authenticated with %(source)s!" - % {"source": self.source.name} - ), - ) - - # We run the Flow planner here so we can pass the Pending user in the context - if not source.enrollment_flow: - LOGGER.warning("source has no enrollment flow", source=source) - return HttpResponseBadRequest() - return self.handle_login_flow( - source.enrollment_flow, - in_memory_stage(PostUserEnrollmentStage), - **{ - PLAN_CONTEXT_PROMPT: delete_none_keys( - self.get_user_enroll_context(source, access, info) - ), - PLAN_CONTEXT_SOURCES_OAUTH_ACCESS: access, - }, - ) + def update_connection( + self, connection: UserOAuthSourceConnection, token: dict[str, Any] + ) -> UserOAuthSourceConnection: + """Set the access_token on the connection""" + connection.access_token = token.get("access_token") + connection.save() + return connection diff --git a/authentik/sources/plex/api.py b/authentik/sources/plex/api.py index a9501132c..2a923d383 100644 --- a/authentik/sources/plex/api.py +++ b/authentik/sources/plex/api.py @@ -1,26 +1,22 @@ """Plex Source Serializer""" -from urllib.parse import urlencode - from django.http import Http404 from django.shortcuts import get_object_or_404 from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema -from requests import RequestException, get from rest_framework.decorators import action from rest_framework.fields import CharField from rest_framework.permissions import AllowAny from rest_framework.request import Request from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet -from structlog.stdlib import get_logger from authentik.api.decorators import permission_required from authentik.core.api.sources import SourceSerializer from authentik.core.api.utils import PassiveSerializer -from authentik.flows.challenge import ChallengeTypes, RedirectChallenge +from authentik.flows.challenge import RedirectChallenge +from authentik.flows.views import to_stage_response from authentik.sources.plex.models import PlexSource - -LOGGER = get_logger() +from authentik.sources.plex.plex import PlexAuth class PlexSourceSerializer(SourceSerializer): @@ -72,29 +68,8 @@ class PlexSourceViewSet(ModelViewSet): plex_token = request.data.get("plex_token", None) if not plex_token: raise Http404 - qs = {"X-Plex-Token": plex_token, "X-Plex-Client-Identifier": source.client_id} - try: - response = get( - f"https://plex.tv/api/v2/resources?{urlencode(qs)}", - headers={"Accept": "application/json"}, - ) - response.raise_for_status() - except RequestException as exc: - LOGGER.warning("Unable to fetch user resources", exc=exc) + auth_api = PlexAuth(source, plex_token) + if not auth_api.check_server_overlap(): raise Http404 - else: - resources: list[dict] = response.json() - for resource in resources: - if resource["provides"] != "server": - continue - if resource["clientIdentifier"] in source.allowed_servers: - LOGGER.info( - "Plex allowed access from server", name=resource["name"] - ) - request.session["foo"] = "bar" - break - return Response( - RedirectChallenge( - {"type": ChallengeTypes.REDIRECT.value, "to": ""} - ).data - ) + response = auth_api.get_user_url(request) + return to_stage_response(request, response) diff --git a/authentik/sources/plex/migrations/0002_plexsourceconnection.py b/authentik/sources/plex/migrations/0002_plexsourceconnection.py new file mode 100644 index 000000000..3f139a0ff --- /dev/null +++ b/authentik/sources/plex/migrations/0002_plexsourceconnection.py @@ -0,0 +1,38 @@ +# Generated by Django 3.2 on 2021-05-03 17:06 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_core", "0020_source_user_matching_mode"), + ("authentik_sources_plex", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="PlexSourceConnection", + fields=[ + ( + "usersourceconnection_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_core.usersourceconnection", + ), + ), + ("plex_token", models.TextField()), + ("identifier", models.TextField()), + ], + options={ + "verbose_name": "User Plex Source Connection", + "verbose_name_plural": "User Plex Source Connections", + }, + bases=("authentik_core.usersourceconnection",), + ), + ] diff --git a/authentik/sources/plex/models.py b/authentik/sources/plex/models.py index da66c1902..4ad8d0901 100644 --- a/authentik/sources/plex/models.py +++ b/authentik/sources/plex/models.py @@ -6,7 +6,7 @@ from django.utils.translation import gettext_lazy as _ from rest_framework.fields import CharField from rest_framework.serializers import BaseSerializer -from authentik.core.models import Source +from authentik.core.models import Source, UserSourceConnection from authentik.core.types import UILoginButton from authentik.flows.challenge import Challenge, ChallengeTypes @@ -53,3 +53,15 @@ class PlexSource(Source): verbose_name = _("Plex Source") verbose_name_plural = _("Plex Sources") + + +class PlexSourceConnection(UserSourceConnection): + """Connect user and plex source""" + + plex_token = models.TextField() + identifier = models.TextField() + + class Meta: + + verbose_name = _("User Plex Source Connection") + verbose_name_plural = _("User Plex Source Connections") diff --git a/authentik/sources/plex/plex.py b/authentik/sources/plex/plex.py index 1f6b394a8..c51ef83bf 100644 --- a/authentik/sources/plex/plex.py +++ b/authentik/sources/plex/plex.py @@ -1,136 +1,113 @@ -"""Plex OAuth Views""" -from typing import Any, Optional +"""Plex Views""" from urllib.parse import urlencode -from django.http.response import Http404 -from requests import post -from requests.api import get +import requests +from django.http.request import HttpRequest +from django.http.response import Http404, HttpResponse from requests.exceptions import RequestException from structlog.stdlib import get_logger from authentik import __version__ -from authentik.sources.oauth.clients.oauth2 import OAuth2Client -from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection -from authentik.sources.oauth.types.manager import MANAGER, SourceType -from authentik.sources.oauth.views.callback import OAuthCallback -from authentik.sources.oauth.views.redirect import OAuthRedirect +from authentik.core.sources.flow_manager import SourceFlowManager +from authentik.sources.plex.models import PlexSource, PlexSourceConnection LOGGER = get_logger() SESSION_ID_KEY = "PLEX_ID" SESSION_CODE_KEY = "PLEX_CODE" -DEFAULT_PAYLOAD = { - "X-Plex-Product": "authentik", - "X-Plex-Version": __version__, - "X-Plex-Device-Vendor": "BeryJu.org", -} -class PlexRedirect(OAuthRedirect): - """Plex Auth redirect, get a pin then redirect to a URL to claim it""" +class PlexAuth: + """Plex authentication utilities""" - headers = {} + _source: PlexSource + _token: str - def get_pin(self, **data) -> dict: - """Get plex pin that the user will claim - https://forums.plex.tv/t/authenticating-with-plex/609370""" - return post( - "https://plex.tv/api/v2/pins.json?strong=true", - data=data, - headers=self.headers, - ).json() - - def get_redirect_url(self, **kwargs) -> str: - slug = kwargs.get("source_slug", "") - self.headers = {"Origin": self.request.build_absolute_uri("/")} - try: - source: OAuthSource = OAuthSource.objects.get(slug=slug) - except OAuthSource.DoesNotExist: - raise Http404(f"Unknown OAuth source '{slug}'.") - else: - payload = DEFAULT_PAYLOAD.copy() - payload["X-Plex-Client-Identifier"] = source.consumer_key - # Get a pin first - pin = self.get_pin(**payload) - LOGGER.debug("Got pin", **pin) - self.request.session[SESSION_ID_KEY] = pin["id"] - self.request.session[SESSION_CODE_KEY] = pin["code"] - qs = { - "clientID": source.consumer_key, - "code": pin["code"], - "forwardUrl": self.request.build_absolute_uri( - self.get_callback_url(source) - ), - } - return f"https://app.plex.tv/auth#!?{urlencode(qs)}" - - -class PlexOAuthClient(OAuth2Client): - """Retrive the plex token after authentication, then ask the plex API about user info""" - - def check_application_state(self) -> bool: - return SESSION_ID_KEY in self.request.session - - def get_access_token(self, **request_kwargs) -> Optional[dict[str, Any]]: - payload = dict(DEFAULT_PAYLOAD) - payload["X-Plex-Client-Identifier"] = self.source.consumer_key - payload["Accept"] = "application/json" - response = get( - f"https://plex.tv/api/v2/pins/{self.request.session[SESSION_ID_KEY]}", - headers=payload, + def __init__(self, source: PlexSource, token: str): + self._source = source + self._token = token + self._session = requests.Session() + self._session.headers.update( + {"Accept": "application/json", "Content-Type": "application/json"} ) - response.raise_for_status() - token = response.json()["authToken"] - return {"plex_token": token} + self._session.headers.update(self.headers) - def get_profile_info(self, token: dict[str, str]) -> Optional[dict[str, Any]]: - "Fetch user profile information." - qs = {"X-Plex-Token": token["plex_token"]} - print(token) - try: - response = self.do_request( - "get", f"https://plex.tv/users/account.json?{urlencode(qs)}" - ) - response.raise_for_status() - except RequestException as exc: - LOGGER.warning("Unable to fetch user profile", exc=exc) - return None - else: - info = response.json() - return info.get("user", {}) - - -class PlexOAuth2Callback(OAuthCallback): - """Plex OAuth2 Callback""" - - client_class = PlexOAuthClient - - def get_user_id( - self, source: UserOAuthSourceConnection, info: dict[str, Any] - ) -> Optional[str]: - return info.get("uuid") - - def get_user_enroll_context( - self, - source: OAuthSource, - access: UserOAuthSourceConnection, - info: dict[str, Any], - ) -> dict[str, Any]: + @property + def headers(self) -> dict[str, str]: + """Get common headers""" return { - "username": info.get("username"), - "email": info.get("email"), - "name": info.get("title"), + "X-Plex-Product": "authentik", + "X-Plex-Version": __version__, + "X-Plex-Device-Vendor": "BeryJu.org", } + def get_resources(self) -> list[dict]: + """Get all resources the plex-token has access to""" + qs = { + "X-Plex-Token": self._token, + "X-Plex-Client-Identifier": self._source.client_id, + } + response = self._session.get( + f"https://plex.tv/api/v2/resources?{urlencode(qs)}", + ) + response.raise_for_status() + return response.json() -@MANAGER.type() -class PlexType(SourceType): - """Plex Type definition""" + def get_user_info(self) -> tuple[dict, int]: + """Get user info of the plex token""" + qs = { + "X-Plex-Token": self._token, + "X-Plex-Client-Identifier": self._source.client_id, + } + response = self._session.get( + f"https://plex.tv/api/v2/user?{urlencode(qs)}", + ) + response.raise_for_status() + raw_user_info = response.json() + return { + "username": raw_user_info.get("username"), + "email": raw_user_info.get("email"), + "name": raw_user_info.get("title"), + }, raw_user_info.get("id") - redirect_view = PlexRedirect - callback_view = PlexOAuth2Callback - name = "Plex" - slug = "plex" + def check_server_overlap(self) -> bool: + """Check if the plex-token has any server overlap with our configured servers""" + try: + resources = self.get_resources() + except RequestException as exc: + LOGGER.warning("Unable to fetch user resources", exc=exc) + raise Http404 + else: + for resource in resources: + if resource["provides"] != "server": + continue + if resource["clientIdentifier"] in self._source.allowed_servers: + LOGGER.info( + "Plex allowed access from server", name=resource["name"] + ) + return True + return False - authorization_url = "" - access_token_url = "" # nosec - profile_url = "" + def get_user_url(self, request: HttpRequest) -> HttpResponse: + """Get a URL to a flow executor for either enrollment or authentication""" + user_info, identifier = self.get_user_info() + sfm = PlexSourceFlowManager( + source=self._source, + request=request, + identifier=str(identifier), + enroll_info=user_info, + ) + return sfm.get_flow(plex_token=self._token) + + +class PlexSourceFlowManager(SourceFlowManager): + """Flow manager for plex sources""" + + connection_type = PlexSourceConnection + + def update_connection( + self, connection: PlexSourceConnection, plex_token: str + ) -> PlexSourceConnection: + """Set the access_token on the connection""" + connection.plex_token = plex_token + connection.save() + return connection diff --git a/swagger.yaml b/swagger.yaml index e0bf34a22..e6db70d15 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -17289,6 +17289,17 @@ definitions: enum: - all - any + user_matching_mode: + title: User matching mode + description: How the source determines if an existing user should be authenticated + or a new user enrolled. + type: string + enum: + - identifier + - email_link + - email_deny + - username_link + - username_deny UserSetting: required: - object_uid @@ -17369,6 +17380,17 @@ definitions: enum: - all - any + user_matching_mode: + title: User matching mode + description: How the source determines if an existing user should be authenticated + or a new user enrolled. + type: string + enum: + - identifier + - email_link + - email_deny + - username_link + - username_deny server_uri: title: Server URI type: string @@ -17549,6 +17571,17 @@ definitions: enum: - all - any + user_matching_mode: + title: User matching mode + description: How the source determines if an existing user should be authenticated + or a new user enrolled. + type: string + enum: + - identifier + - email_link + - email_deny + - username_link + - username_deny provider_type: title: Provider type type: string @@ -17678,6 +17711,17 @@ definitions: enum: - all - any + user_matching_mode: + title: User matching mode + description: How the source determines if an existing user should be authenticated + or a new user enrolled. + type: string + enum: + - identifier + - email_link + - email_deny + - username_link + - username_deny client_id: title: Client id type: string @@ -17792,6 +17836,17 @@ definitions: enum: - all - any + user_matching_mode: + title: User matching mode + description: How the source determines if an existing user should be authenticated + or a new user enrolled. + type: string + enum: + - identifier + - email_link + - email_deny + - username_link + - username_deny pre_authentication_flow: title: Pre authentication flow description: Flow used before authentication. @@ -18537,6 +18592,17 @@ definitions: enabled: title: Enabled type: boolean + user_matching_mode: + title: User matching mode + description: How the source determines if an existing user should + be authenticated or a new user enrolled. + type: string + enum: + - identifier + - email_link + - email_deny + - username_link + - username_deny authentication_flow: title: Authentication flow description: Flow to use when authenticating existing users. From 6fc38436f4ef19a17672209b80d136c9554879f8 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Mon, 3 May 2021 21:00:16 +0200 Subject: [PATCH 06/10] sources/plex: set better defaults on model Signed-off-by: Jens Langhammer --- authentik/core/tests/test_models.py | 42 ++++++++- authentik/flows/tests/test_stage_model.py | 19 ++-- .../sources/plex/migrations/0001_initial.py | 40 ++++++++- .../migrations/0002_plexsourceconnection.py | 38 -------- authentik/sources/plex/models.py | 17 +++- authentik/sources/plex/plex.py | 4 +- web/src/flows/sources/plex/API.ts | 2 +- web/src/locales/en.po | 87 ++++++++++++------- web/src/locales/pseudo-LOCALE.po | 87 ++++++++++++------- 9 files changed, 217 insertions(+), 119 deletions(-) delete mode 100644 authentik/sources/plex/migrations/0002_plexsourceconnection.py diff --git a/authentik/core/tests/test_models.py b/authentik/core/tests/test_models.py index cb0444632..513452808 100644 --- a/authentik/core/tests/test_models.py +++ b/authentik/core/tests/test_models.py @@ -1,11 +1,14 @@ """authentik core models tests""" from time import sleep +from typing import Callable, Type from django.test import TestCase from django.utils.timezone import now from guardian.shortcuts import get_anonymous_user -from authentik.core.models import Token +from authentik.core.models import Provider, Source, Token +from authentik.flows.models import Stage +from authentik.lib.utils.reflection import all_subclasses class TestModels(TestCase): @@ -24,3 +27,40 @@ class TestModels(TestCase): ) sleep(0.5) self.assertFalse(token.is_expired) + + +def source_tester_factory(test_model: Type[Stage]) -> Callable: + """Test source""" + + def tester(self: TestModels): + model_class = None + if test_model._meta.abstract: + model_class = test_model.__bases__[0]() + else: + model_class = test_model() + model_class.slug = "test" + self.assertIsNotNone(model_class.component) + _ = model_class.ui_login_button + _ = model_class.ui_user_settings + + return tester + + +def provider_tester_factory(test_model: Type[Stage]) -> Callable: + """Test provider""" + + def tester(self: TestModels): + model_class = None + if test_model._meta.abstract: + model_class = test_model.__bases__[0]() + else: + model_class = test_model() + self.assertIsNotNone(model_class.component) + + return tester + + +for model in all_subclasses(Source): + setattr(TestModels, f"test_model_{model.__name__}", source_tester_factory(model)) +for model in all_subclasses(Provider): + setattr(TestModels, f"test_model_{model.__name__}", provider_tester_factory(model)) diff --git a/authentik/flows/tests/test_stage_model.py b/authentik/flows/tests/test_stage_model.py index fe887ee89..05db743f6 100644 --- a/authentik/flows/tests/test_stage_model.py +++ b/authentik/flows/tests/test_stage_model.py @@ -16,17 +16,14 @@ def model_tester_factory(test_model: Type[Stage]) -> Callable: """Test a form""" def tester(self: TestModels): - try: - model_class = None - if test_model._meta.abstract: - model_class = test_model.__bases__[0]() - else: - model_class = test_model() - self.assertTrue(issubclass(model_class.type, StageView)) - self.assertIsNotNone(test_model.component) - _ = test_model.ui_user_settings - except NotImplementedError: - pass + model_class = None + if test_model._meta.abstract: + model_class = test_model.__bases__[0]() + else: + model_class = test_model() + self.assertTrue(issubclass(model_class.type, StageView)) + self.assertIsNotNone(test_model.component) + _ = test_model.ui_user_settings return tester diff --git a/authentik/sources/plex/migrations/0001_initial.py b/authentik/sources/plex/migrations/0001_initial.py index 16032d37b..c5b87959b 100644 --- a/authentik/sources/plex/migrations/0001_initial.py +++ b/authentik/sources/plex/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2 on 2021-05-02 12:34 +# Generated by Django 3.2 on 2021-05-03 18:59 import django.contrib.postgres.fields import django.db.models.deletion @@ -10,7 +10,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ("authentik_core", "0019_source_managed"), + ("authentik_core", "0020_source_user_matching_mode"), ] operations = [ @@ -28,11 +28,20 @@ class Migration(migrations.Migration): to="authentik_core.source", ), ), - ("client_id", models.TextField()), + ( + "client_id", + models.TextField( + default="yOuPQQvgNfBGreZZ38WoOY1d3qk3Xso2AuQHi6RG", + help_text="Client identifier used to talk to Plex.", + ), + ), ( "allowed_servers", django.contrib.postgres.fields.ArrayField( - base_field=models.TextField(), size=None + base_field=models.TextField(), + default=list, + help_text="Which servers a user has to be a member of to be granted access. Empty list allows every server.", + size=None, ), ), ], @@ -42,4 +51,27 @@ class Migration(migrations.Migration): }, bases=("authentik_core.source",), ), + migrations.CreateModel( + name="PlexSourceConnection", + fields=[ + ( + "usersourceconnection_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_core.usersourceconnection", + ), + ), + ("plex_token", models.TextField()), + ("identifier", models.TextField()), + ], + options={ + "verbose_name": "User Plex Source Connection", + "verbose_name_plural": "User Plex Source Connections", + }, + bases=("authentik_core.usersourceconnection",), + ), ] diff --git a/authentik/sources/plex/migrations/0002_plexsourceconnection.py b/authentik/sources/plex/migrations/0002_plexsourceconnection.py deleted file mode 100644 index 3f139a0ff..000000000 --- a/authentik/sources/plex/migrations/0002_plexsourceconnection.py +++ /dev/null @@ -1,38 +0,0 @@ -# Generated by Django 3.2 on 2021-05-03 17:06 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("authentik_core", "0020_source_user_matching_mode"), - ("authentik_sources_plex", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="PlexSourceConnection", - fields=[ - ( - "usersourceconnection_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="authentik_core.usersourceconnection", - ), - ), - ("plex_token", models.TextField()), - ("identifier", models.TextField()), - ], - options={ - "verbose_name": "User Plex Source Connection", - "verbose_name_plural": "User Plex Source Connections", - }, - bases=("authentik_core.usersourceconnection",), - ), - ] diff --git a/authentik/sources/plex/models.py b/authentik/sources/plex/models.py index 4ad8d0901..f7d48a146 100644 --- a/authentik/sources/plex/models.py +++ b/authentik/sources/plex/models.py @@ -9,6 +9,7 @@ from rest_framework.serializers import BaseSerializer from authentik.core.models import Source, UserSourceConnection from authentik.core.types import UILoginButton from authentik.flows.challenge import Challenge, ChallengeTypes +from authentik.providers.oauth2.generators import generate_client_id class PlexAuthenticationChallenge(Challenge): @@ -21,8 +22,20 @@ class PlexAuthenticationChallenge(Challenge): class PlexSource(Source): """Authenticate against plex.tv""" - client_id = models.TextField() - allowed_servers = ArrayField(models.TextField()) + client_id = models.TextField( + default=generate_client_id(), + help_text=_("Client identifier used to talk to Plex."), + ) + allowed_servers = ArrayField( + models.TextField(), + default=list, + help_text=_( + ( + "Which servers a user has to be a member of to be granted access. " + "Empty list allows every server." + ) + ), + ) @property def component(self) -> str: diff --git a/authentik/sources/plex/plex.py b/authentik/sources/plex/plex.py index c51ef83bf..ba39997ab 100644 --- a/authentik/sources/plex/plex.py +++ b/authentik/sources/plex/plex.py @@ -1,9 +1,9 @@ """Plex Views""" from urllib.parse import urlencode -import requests from django.http.request import HttpRequest from django.http.response import Http404, HttpResponse +from requests import Session from requests.exceptions import RequestException from structlog.stdlib import get_logger @@ -25,7 +25,7 @@ class PlexAuth: def __init__(self, source: PlexSource, token: str): self._source = source self._token = token - self._session = requests.Session() + self._session = Session() self._session.headers.update( {"Accept": "application/json", "Content-Type": "application/json"} ) diff --git a/web/src/flows/sources/plex/API.ts b/web/src/flows/sources/plex/API.ts index a3dc2306f..be7a46d59 100644 --- a/web/src/flows/sources/plex/API.ts +++ b/web/src/flows/sources/plex/API.ts @@ -67,7 +67,7 @@ export class PlexAPIClient { reject: (e: Error) => void ) => { try { - const response = await PlexAPIClient.pinStatus(clientIdentifier, id) + const response = await PlexAPIClient.pinStatus(clientIdentifier, id); if (response) { resolve(response); diff --git a/web/src/locales/en.po b/web/src/locales/en.po index 40906aa58..b958c8b02 100644 --- a/web/src/locales/en.po +++ b/web/src/locales/en.po @@ -156,6 +156,10 @@ msgstr "Allow users to use Applications based on properties, enforce Password Cr msgid "Allowed count" msgstr "Allowed count" +#: src/pages/sources/plex/PlexSourceForm.ts:119 +msgid "Allowed servers" +msgstr "Allowed servers" + #: src/pages/sources/saml/SAMLSourceForm.ts:144 msgid "Allows authentication flows initiated by the IdP. This can be a security risk, as no validation of the request ID is done." msgstr "Allows authentication flows initiated by the IdP. This can be a security risk, as no validation of the request ID is done." @@ -282,6 +286,7 @@ msgid "Authentication" msgstr "Authentication" #: src/pages/sources/oauth/OAuthSourceForm.ts:189 +#: src/pages/sources/plex/PlexSourceForm.ts:149 #: src/pages/sources/saml/SAMLSourceForm.ts:245 msgid "Authentication flow" msgstr "Authentication flow" @@ -395,8 +400,8 @@ msgstr "Binding Type" msgid "Build hash: {0}" msgstr "Build hash: {0}" -#: src/pages/sources/SourcesListPage.ts:103 -#: src/pages/sources/SourcesListPage.ts:105 +#: src/pages/sources/SourcesListPage.ts:104 +#: src/pages/sources/SourcesListPage.ts:106 msgid "Built-in" msgstr "Built-in" @@ -544,6 +549,7 @@ msgstr "Click to copy token" #: src/pages/providers/oauth2/OAuth2ProviderForm.ts:107 #: src/pages/providers/oauth2/OAuth2ProviderViewPage.ts:99 +#: src/pages/sources/plex/PlexSourceForm.ts:113 msgid "Client ID" msgstr "Client ID" @@ -744,8 +750,8 @@ msgstr "Copy Key" #: src/pages/providers/ProviderListPage.ts:116 #: src/pages/providers/RelatedApplicationButton.ts:27 #: src/pages/providers/RelatedApplicationButton.ts:35 -#: src/pages/sources/SourcesListPage.ts:113 -#: src/pages/sources/SourcesListPage.ts:122 +#: src/pages/sources/SourcesListPage.ts:114 +#: src/pages/sources/SourcesListPage.ts:123 #: src/pages/stages/StageListPage.ts:119 #: src/pages/stages/StageListPage.ts:128 #: src/pages/stages/invitation/InvitationListPage.ts:77 @@ -842,7 +848,7 @@ msgstr "Create provider" #: src/pages/policies/PolicyListPage.ts:136 #: src/pages/property-mappings/PropertyMappingListPage.ts:125 #: src/pages/providers/ProviderListPage.ts:119 -#: src/pages/sources/SourcesListPage.ts:125 +#: src/pages/sources/SourcesListPage.ts:126 #: src/pages/stages/StageListPage.ts:131 msgid "Create {0}" msgstr "Create {0}" @@ -898,7 +904,7 @@ msgstr "Define how notifications are sent to users, like Email or Webhook." #: src/pages/policies/PolicyListPage.ts:115 #: src/pages/property-mappings/PropertyMappingListPage.ts:104 #: src/pages/providers/ProviderListPage.ts:98 -#: src/pages/sources/SourcesListPage.ts:94 +#: src/pages/sources/SourcesListPage.ts:95 #: src/pages/stages/StageListPage.ts:110 #: src/pages/stages/invitation/InvitationListPage.ts:68 #: src/pages/stages/prompt/PromptListPage.ts:87 @@ -1008,7 +1014,7 @@ msgstr "Disable Static Tokens" msgid "Disable Time-based OTP" msgstr "Disable Time-based OTP" -#: src/pages/sources/SourcesListPage.ts:63 +#: src/pages/sources/SourcesListPage.ts:64 msgid "Disabled" msgstr "Disabled" @@ -1049,7 +1055,7 @@ msgstr "Each provider has a different issuer, based on the application slug." #: src/pages/providers/oauth2/OAuth2ProviderViewPage.ts:128 #: src/pages/providers/proxy/ProxyProviderViewPage.ts:127 #: src/pages/providers/saml/SAMLProviderViewPage.ts:121 -#: src/pages/sources/SourcesListPage.ts:82 +#: src/pages/sources/SourcesListPage.ts:83 #: src/pages/sources/ldap/LDAPSourceViewPage.ts:105 #: src/pages/sources/oauth/OAuthSourceViewPage.ts:125 #: src/pages/sources/saml/SAMLSourceViewPage.ts:111 @@ -1086,7 +1092,7 @@ msgstr "Edit User" msgid "Either no applications are defined, or you don't have access to any." msgstr "Either no applications are defined, or you don't have access to any." -#: src/flows/stages/identification/IdentificationStage.ts:138 +#: src/flows/stages/identification/IdentificationStage.ts:146 #: src/pages/events/TransportForm.ts:46 #: src/pages/stages/identification/IdentificationStageForm.ts:81 #: src/pages/user-settings/UserDetailsPage.ts:71 @@ -1099,7 +1105,7 @@ msgstr "Email" msgid "Email address" msgstr "Email address" -#: src/flows/stages/identification/IdentificationStage.ts:145 +#: src/flows/stages/identification/IdentificationStage.ts:153 msgid "Email or username" msgstr "Email or username" @@ -1136,6 +1142,7 @@ msgstr "Enable this if you don't want to use this provider as a proxy, and want #: src/pages/policies/PolicyBindingForm.ts:199 #: src/pages/sources/ldap/LDAPSourceForm.ts:69 #: src/pages/sources/oauth/OAuthSourceForm.ts:115 +#: src/pages/sources/plex/PlexSourceForm.ts:102 #: src/pages/sources/saml/SAMLSourceForm.ts:69 msgid "Enabled" msgstr "Enabled" @@ -1145,6 +1152,7 @@ msgid "Enrollment" msgstr "Enrollment" #: src/pages/sources/oauth/OAuthSourceForm.ts:210 +#: src/pages/sources/plex/PlexSourceForm.ts:170 #: src/pages/sources/saml/SAMLSourceForm.ts:266 #: src/pages/stages/identification/IdentificationStageForm.ts:106 msgid "Enrollment flow" @@ -1357,16 +1365,19 @@ msgid "Flow Overview" msgstr "Flow Overview" #: src/pages/sources/oauth/OAuthSourceForm.ts:185 +#: src/pages/sources/plex/PlexSourceForm.ts:145 #: src/pages/sources/saml/SAMLSourceForm.ts:220 msgid "Flow settings" msgstr "Flow settings" #: src/pages/sources/oauth/OAuthSourceForm.ts:207 +#: src/pages/sources/plex/PlexSourceForm.ts:167 #: src/pages/sources/saml/SAMLSourceForm.ts:263 msgid "Flow to use when authenticating existing users." msgstr "Flow to use when authenticating existing users." #: src/pages/sources/oauth/OAuthSourceForm.ts:228 +#: src/pages/sources/plex/PlexSourceForm.ts:188 #: src/pages/sources/saml/SAMLSourceForm.ts:284 msgid "Flow to use when enrolling new users." msgstr "Flow to use when enrolling new users." @@ -1410,7 +1421,7 @@ msgstr "Force the user to configure an authenticator" msgid "Forgot password?" msgstr "Forgot password?" -#: src/flows/stages/identification/IdentificationStage.ts:124 +#: src/flows/stages/identification/IdentificationStage.ts:132 msgid "Forgot username or password?" msgstr "Forgot username or password?" @@ -1510,6 +1521,7 @@ msgstr "Hide managed mappings" #: src/pages/providers/saml/SAMLProviderForm.ts:177 #: src/pages/sources/ldap/LDAPSourceForm.ts:167 #: src/pages/sources/ldap/LDAPSourceForm.ts:193 +#: src/pages/sources/plex/PlexSourceForm.ts:132 #: src/pages/stages/authenticator_validate/AuthenticatorValidateStageForm.ts:114 #: src/pages/stages/identification/IdentificationStageForm.ts:85 #: src/pages/stages/password/PasswordStageForm.ts:86 @@ -1692,9 +1704,13 @@ msgstr "Let the user identify themselves with their username or Email address." msgid "Library" msgstr "Library" +#: src/pages/sources/plex/PlexSourceForm.ts:137 +msgid "Load servers" +msgstr "Load servers" + #: src/elements/table/Table.ts:120 -#: src/flows/FlowExecutor.ts:167 -#: src/flows/FlowExecutor.ts:213 +#: src/flows/FlowExecutor.ts:168 +#: src/flows/FlowExecutor.ts:216 #: src/flows/access_denied/FlowAccessDenied.ts:27 #: src/flows/stages/authenticator_static/AuthenticatorStaticStage.ts:43 #: src/flows/stages/authenticator_totp/AuthenticatorTOTPStage.ts:33 @@ -1705,7 +1721,7 @@ msgstr "Library" #: src/flows/stages/consent/ConsentStage.ts:28 #: src/flows/stages/dummy/DummyStage.ts:27 #: src/flows/stages/email/EmailStage.ts:26 -#: src/flows/stages/identification/IdentificationStage.ts:171 +#: src/flows/stages/identification/IdentificationStage.ts:179 #: src/flows/stages/password/PasswordStage.ts:31 #: src/flows/stages/prompt/PromptStage.ts:126 #: src/pages/applications/ApplicationViewPage.ts:43 @@ -1750,6 +1766,8 @@ msgstr "Loading" #: src/pages/sources/oauth/OAuthSourceForm.ts:177 #: src/pages/sources/oauth/OAuthSourceForm.ts:205 #: src/pages/sources/oauth/OAuthSourceForm.ts:226 +#: src/pages/sources/plex/PlexSourceForm.ts:165 +#: src/pages/sources/plex/PlexSourceForm.ts:186 #: src/pages/sources/saml/SAMLSourceForm.ts:126 #: src/pages/sources/saml/SAMLSourceForm.ts:240 #: src/pages/sources/saml/SAMLSourceForm.ts:261 @@ -1780,7 +1798,7 @@ msgstr "Log the currently pending user in." msgid "Login password is synced from LDAP into authentik automatically. Enable this option only to write password changes in authentik back to LDAP." msgstr "Login password is synced from LDAP into authentik automatically. Enable this option only to write password changes in authentik back to LDAP." -#: src/flows/stages/identification/IdentificationStage.ts:183 +#: src/flows/stages/identification/IdentificationStage.ts:191 msgid "Login to continue to {0}." msgstr "Login to continue to {0}." @@ -1913,11 +1931,12 @@ msgstr "Monitor" #: src/pages/providers/saml/SAMLProviderForm.ts:53 #: src/pages/providers/saml/SAMLProviderImportForm.ts:38 #: src/pages/providers/saml/SAMLProviderViewPage.ts:66 -#: src/pages/sources/SourcesListPage.ts:51 +#: src/pages/sources/SourcesListPage.ts:52 #: src/pages/sources/ldap/LDAPSourceForm.ts:54 #: src/pages/sources/ldap/LDAPSourceViewPage.ts:64 #: src/pages/sources/oauth/OAuthSourceForm.ts:100 #: src/pages/sources/oauth/OAuthSourceViewPage.ts:64 +#: src/pages/sources/plex/PlexSourceForm.ts:87 #: src/pages/sources/saml/SAMLSourceForm.ts:54 #: src/pages/sources/saml/SAMLSourceViewPage.ts:66 #: src/pages/stages/StageListPage.ts:65 @@ -1957,7 +1976,7 @@ msgstr "NameID Policy" msgid "NameID Property Mapping" msgstr "NameID Property Mapping" -#: src/flows/stages/identification/IdentificationStage.ts:119 +#: src/flows/stages/identification/IdentificationStage.ts:127 msgid "Need an account?" msgstr "Need an account?" @@ -2348,7 +2367,7 @@ msgstr "Post binding" msgid "Post binding (auto-submit)" msgstr "Post binding (auto-submit)" -#: src/flows/FlowExecutor.ts:255 +#: src/flows/FlowExecutor.ts:258 msgid "Powered by authentik" msgstr "Powered by authentik" @@ -2412,6 +2431,7 @@ msgstr "Property mappings used to user creation." #: src/pages/providers/proxy/ProxyProviderForm.ts:123 #: src/pages/providers/saml/SAMLProviderForm.ts:78 #: src/pages/sources/oauth/OAuthSourceForm.ts:122 +#: src/pages/sources/plex/PlexSourceForm.ts:109 #: src/pages/sources/saml/SAMLSourceForm.ts:76 msgid "Protocol settings" msgstr "Protocol settings" @@ -2602,7 +2622,7 @@ msgstr "Retry Task" msgid "Retry authentication" msgstr "Retry authentication" -#: src/flows/FlowExecutor.ts:145 +#: src/flows/FlowExecutor.ts:146 msgid "Return" msgstr "Return" @@ -2710,7 +2730,7 @@ msgstr "Select all rows" msgid "Select an identification method." msgstr "Select an identification method." -#: src/flows/stages/identification/IdentificationStage.ts:134 +#: src/flows/stages/identification/IdentificationStage.ts:142 msgid "Select one of the sources below to login." msgstr "Select one of the sources below to login." @@ -2722,6 +2742,10 @@ msgstr "Select users to add" msgid "Select which scopes can be used by the client. The client stil has to specify the scope to access the data." msgstr "Select which scopes can be used by the client. The client stil has to specify the scope to access the data." +#: src/pages/sources/plex/PlexSourceForm.ts:131 +msgid "Select which server a user has to be a member of to be allowed to authenticate." +msgstr "Select which server a user has to be a member of to be allowed to authenticate." + #: src/pages/events/RuleForm.ts:92 msgid "Select which transports should be used to notify the user. If none are selected, the notification will only be shown in the authentik UI." msgstr "Select which transports should be used to notify the user. If none are selected, the notification will only be shown in the authentik UI." @@ -2820,7 +2844,7 @@ msgstr "Show matched user" msgid "Shown as the Title in Flow pages." msgstr "Shown as the Title in Flow pages." -#: src/flows/stages/identification/IdentificationStage.ts:120 +#: src/flows/stages/identification/IdentificationStage.ts:128 msgid "Sign up." msgstr "Sign up." @@ -2854,16 +2878,17 @@ msgstr "Skip path regex" #: src/pages/flows/FlowForm.ts:94 #: src/pages/sources/ldap/LDAPSourceForm.ts:60 #: src/pages/sources/oauth/OAuthSourceForm.ts:106 +#: src/pages/sources/plex/PlexSourceForm.ts:93 #: src/pages/sources/saml/SAMLSourceForm.ts:60 msgid "Slug" msgstr "Slug" -#: src/flows/FlowExecutor.ts:138 +#: src/flows/FlowExecutor.ts:139 msgid "Something went wrong! Please try again later." msgstr "Something went wrong! Please try again later." #: src/pages/providers/ProviderListPage.ts:91 -#: src/pages/sources/SourcesListPage.ts:87 +#: src/pages/sources/SourcesListPage.ts:88 msgid "Source" msgstr "Source" @@ -2872,11 +2897,11 @@ msgid "Source {0}" msgstr "Source {0}" #: src/interfaces/AdminInterface.ts:20 -#: src/pages/sources/SourcesListPage.ts:30 +#: src/pages/sources/SourcesListPage.ts:31 msgid "Sources" msgstr "Sources" -#: src/pages/sources/SourcesListPage.ts:33 +#: src/pages/sources/SourcesListPage.ts:34 msgid "Sources of identities, which can either be synced into authentik's database, like LDAP, or can be used by users to authenticate and enroll themselves, like OAuth and social logins" msgstr "Sources of identities, which can either be synced into authentik's database, like LDAP, or can be used by users to authenticate and enroll themselves, like OAuth and social logins" @@ -3073,6 +3098,7 @@ msgstr "Successfully created service-connection." #: src/pages/sources/ldap/LDAPSourceForm.ts:47 #: src/pages/sources/oauth/OAuthSourceForm.ts:51 +#: src/pages/sources/plex/PlexSourceForm.ts:60 #: src/pages/sources/saml/SAMLSourceForm.ts:47 msgid "Successfully created source." msgstr "Successfully created source." @@ -3209,6 +3235,7 @@ msgstr "Successfully updated service-connection." #: src/pages/sources/ldap/LDAPSourceForm.ts:44 #: src/pages/sources/oauth/OAuthSourceForm.ts:48 +#: src/pages/sources/plex/PlexSourceForm.ts:57 #: src/pages/sources/saml/SAMLSourceForm.ts:44 msgid "Successfully updated source." msgstr "Successfully updated source." @@ -3464,7 +3491,7 @@ msgstr "Transports" #: src/pages/policies/PolicyListPage.ts:57 #: src/pages/property-mappings/PropertyMappingListPage.ts:55 #: src/pages/providers/ProviderListPage.ts:54 -#: src/pages/sources/SourcesListPage.ts:52 +#: src/pages/sources/SourcesListPage.ts:53 #: src/pages/stages/prompt/PromptForm.ts:97 #: src/pages/stages/prompt/PromptListPage.ts:48 msgid "Type" @@ -3543,7 +3570,7 @@ msgstr "Up-to-date!" #: src/pages/providers/oauth2/OAuth2ProviderViewPage.ts:118 #: src/pages/providers/proxy/ProxyProviderViewPage.ts:117 #: src/pages/providers/saml/SAMLProviderViewPage.ts:111 -#: src/pages/sources/SourcesListPage.ts:69 +#: src/pages/sources/SourcesListPage.ts:70 #: src/pages/sources/ldap/LDAPSourceViewPage.ts:95 #: src/pages/sources/oauth/OAuthSourceViewPage.ts:115 #: src/pages/sources/saml/SAMLSourceViewPage.ts:101 @@ -3646,7 +3673,7 @@ msgstr "Update details" #: src/pages/policies/PolicyListPage.ts:80 #: src/pages/property-mappings/PropertyMappingListPage.ts:69 #: src/pages/providers/ProviderListPage.ts:76 -#: src/pages/sources/SourcesListPage.ts:72 +#: src/pages/sources/SourcesListPage.ts:73 #: src/pages/stages/StageListPage.ts:88 #: src/pages/users/UserActiveForm.ts:41 msgid "Update {0}" @@ -3750,7 +3777,7 @@ msgstr "User/Group Attribute used for the user part of the HTTP-Basic Header. If msgid "Userinfo URL" msgstr "Userinfo URL" -#: src/flows/stages/identification/IdentificationStage.ts:142 +#: src/flows/stages/identification/IdentificationStage.ts:150 #: src/pages/stages/identification/IdentificationStageForm.ts:78 #: src/pages/user-settings/UserDetailsPage.ts:57 #: src/pages/users/UserForm.ts:47 @@ -3903,7 +3930,7 @@ msgstr "When selected, incoming assertion's Signatures will be validated against msgid "When this option is enabled, all executions of this policy will be logged. By default, only execution errors are logged." msgstr "When this option is enabled, all executions of this policy will be logged. By default, only execution errors are logged." -#: src/flows/FlowExecutor.ts:134 +#: src/flows/FlowExecutor.ts:135 msgid "Whoops!" msgstr "Whoops!" diff --git a/web/src/locales/pseudo-LOCALE.po b/web/src/locales/pseudo-LOCALE.po index 857a494bc..117acd43b 100644 --- a/web/src/locales/pseudo-LOCALE.po +++ b/web/src/locales/pseudo-LOCALE.po @@ -156,6 +156,10 @@ msgstr "" msgid "Allowed count" msgstr "" +#: src/pages/sources/plex/PlexSourceForm.ts:119 +msgid "Allowed servers" +msgstr "" + #: src/pages/sources/saml/SAMLSourceForm.ts:144 msgid "Allows authentication flows initiated by the IdP. This can be a security risk, as no validation of the request ID is done." msgstr "" @@ -278,6 +282,7 @@ msgid "Authentication" msgstr "" #: src/pages/sources/oauth/OAuthSourceForm.ts:189 +#: src/pages/sources/plex/PlexSourceForm.ts:149 #: src/pages/sources/saml/SAMLSourceForm.ts:245 msgid "Authentication flow" msgstr "" @@ -391,8 +396,8 @@ msgstr "" msgid "Build hash: {0}" msgstr "" -#: src/pages/sources/SourcesListPage.ts:103 -#: src/pages/sources/SourcesListPage.ts:105 +#: src/pages/sources/SourcesListPage.ts:104 +#: src/pages/sources/SourcesListPage.ts:106 msgid "Built-in" msgstr "" @@ -538,6 +543,7 @@ msgstr "" #: src/pages/providers/oauth2/OAuth2ProviderForm.ts:107 #: src/pages/providers/oauth2/OAuth2ProviderViewPage.ts:99 +#: src/pages/sources/plex/PlexSourceForm.ts:113 msgid "Client ID" msgstr "" @@ -738,8 +744,8 @@ msgstr "" #: src/pages/providers/ProviderListPage.ts:116 #: src/pages/providers/RelatedApplicationButton.ts:27 #: src/pages/providers/RelatedApplicationButton.ts:35 -#: src/pages/sources/SourcesListPage.ts:113 -#: src/pages/sources/SourcesListPage.ts:122 +#: src/pages/sources/SourcesListPage.ts:114 +#: src/pages/sources/SourcesListPage.ts:123 #: src/pages/stages/StageListPage.ts:119 #: src/pages/stages/StageListPage.ts:128 #: src/pages/stages/invitation/InvitationListPage.ts:77 @@ -836,7 +842,7 @@ msgstr "" #: src/pages/policies/PolicyListPage.ts:136 #: src/pages/property-mappings/PropertyMappingListPage.ts:125 #: src/pages/providers/ProviderListPage.ts:119 -#: src/pages/sources/SourcesListPage.ts:125 +#: src/pages/sources/SourcesListPage.ts:126 #: src/pages/stages/StageListPage.ts:131 msgid "Create {0}" msgstr "" @@ -892,7 +898,7 @@ msgstr "" #: src/pages/policies/PolicyListPage.ts:115 #: src/pages/property-mappings/PropertyMappingListPage.ts:104 #: src/pages/providers/ProviderListPage.ts:98 -#: src/pages/sources/SourcesListPage.ts:94 +#: src/pages/sources/SourcesListPage.ts:95 #: src/pages/stages/StageListPage.ts:110 #: src/pages/stages/invitation/InvitationListPage.ts:68 #: src/pages/stages/prompt/PromptListPage.ts:87 @@ -1000,7 +1006,7 @@ msgstr "" msgid "Disable Time-based OTP" msgstr "" -#: src/pages/sources/SourcesListPage.ts:63 +#: src/pages/sources/SourcesListPage.ts:64 msgid "Disabled" msgstr "" @@ -1041,7 +1047,7 @@ msgstr "" #: src/pages/providers/oauth2/OAuth2ProviderViewPage.ts:128 #: src/pages/providers/proxy/ProxyProviderViewPage.ts:127 #: src/pages/providers/saml/SAMLProviderViewPage.ts:121 -#: src/pages/sources/SourcesListPage.ts:82 +#: src/pages/sources/SourcesListPage.ts:83 #: src/pages/sources/ldap/LDAPSourceViewPage.ts:105 #: src/pages/sources/oauth/OAuthSourceViewPage.ts:125 #: src/pages/sources/saml/SAMLSourceViewPage.ts:111 @@ -1078,7 +1084,7 @@ msgstr "" msgid "Either no applications are defined, or you don't have access to any." msgstr "" -#: src/flows/stages/identification/IdentificationStage.ts:138 +#: src/flows/stages/identification/IdentificationStage.ts:146 #: src/pages/events/TransportForm.ts:46 #: src/pages/stages/identification/IdentificationStageForm.ts:81 #: src/pages/user-settings/UserDetailsPage.ts:71 @@ -1091,7 +1097,7 @@ msgstr "" msgid "Email address" msgstr "" -#: src/flows/stages/identification/IdentificationStage.ts:145 +#: src/flows/stages/identification/IdentificationStage.ts:153 msgid "Email or username" msgstr "" @@ -1128,6 +1134,7 @@ msgstr "" #: src/pages/policies/PolicyBindingForm.ts:199 #: src/pages/sources/ldap/LDAPSourceForm.ts:69 #: src/pages/sources/oauth/OAuthSourceForm.ts:115 +#: src/pages/sources/plex/PlexSourceForm.ts:102 #: src/pages/sources/saml/SAMLSourceForm.ts:69 msgid "Enabled" msgstr "" @@ -1137,6 +1144,7 @@ msgid "Enrollment" msgstr "" #: src/pages/sources/oauth/OAuthSourceForm.ts:210 +#: src/pages/sources/plex/PlexSourceForm.ts:170 #: src/pages/sources/saml/SAMLSourceForm.ts:266 #: src/pages/stages/identification/IdentificationStageForm.ts:106 msgid "Enrollment flow" @@ -1349,16 +1357,19 @@ msgid "Flow Overview" msgstr "" #: src/pages/sources/oauth/OAuthSourceForm.ts:185 +#: src/pages/sources/plex/PlexSourceForm.ts:145 #: src/pages/sources/saml/SAMLSourceForm.ts:220 msgid "Flow settings" msgstr "" #: src/pages/sources/oauth/OAuthSourceForm.ts:207 +#: src/pages/sources/plex/PlexSourceForm.ts:167 #: src/pages/sources/saml/SAMLSourceForm.ts:263 msgid "Flow to use when authenticating existing users." msgstr "" #: src/pages/sources/oauth/OAuthSourceForm.ts:228 +#: src/pages/sources/plex/PlexSourceForm.ts:188 #: src/pages/sources/saml/SAMLSourceForm.ts:284 msgid "Flow to use when enrolling new users." msgstr "" @@ -1402,7 +1413,7 @@ msgstr "" msgid "Forgot password?" msgstr "" -#: src/flows/stages/identification/IdentificationStage.ts:124 +#: src/flows/stages/identification/IdentificationStage.ts:132 msgid "Forgot username or password?" msgstr "" @@ -1502,6 +1513,7 @@ msgstr "" #: src/pages/providers/saml/SAMLProviderForm.ts:177 #: src/pages/sources/ldap/LDAPSourceForm.ts:167 #: src/pages/sources/ldap/LDAPSourceForm.ts:193 +#: src/pages/sources/plex/PlexSourceForm.ts:132 #: src/pages/stages/authenticator_validate/AuthenticatorValidateStageForm.ts:114 #: src/pages/stages/identification/IdentificationStageForm.ts:85 #: src/pages/stages/password/PasswordStageForm.ts:86 @@ -1684,9 +1696,13 @@ msgstr "" msgid "Library" msgstr "" +#: src/pages/sources/plex/PlexSourceForm.ts:137 +msgid "Load servers" +msgstr "" + #: src/elements/table/Table.ts:120 -#: src/flows/FlowExecutor.ts:167 -#: src/flows/FlowExecutor.ts:213 +#: src/flows/FlowExecutor.ts:168 +#: src/flows/FlowExecutor.ts:216 #: src/flows/access_denied/FlowAccessDenied.ts:27 #: src/flows/stages/authenticator_static/AuthenticatorStaticStage.ts:43 #: src/flows/stages/authenticator_totp/AuthenticatorTOTPStage.ts:33 @@ -1697,7 +1713,7 @@ msgstr "" #: src/flows/stages/consent/ConsentStage.ts:28 #: src/flows/stages/dummy/DummyStage.ts:27 #: src/flows/stages/email/EmailStage.ts:26 -#: src/flows/stages/identification/IdentificationStage.ts:171 +#: src/flows/stages/identification/IdentificationStage.ts:179 #: src/flows/stages/password/PasswordStage.ts:31 #: src/flows/stages/prompt/PromptStage.ts:126 #: src/pages/applications/ApplicationViewPage.ts:43 @@ -1742,6 +1758,8 @@ msgstr "" #: src/pages/sources/oauth/OAuthSourceForm.ts:177 #: src/pages/sources/oauth/OAuthSourceForm.ts:205 #: src/pages/sources/oauth/OAuthSourceForm.ts:226 +#: src/pages/sources/plex/PlexSourceForm.ts:165 +#: src/pages/sources/plex/PlexSourceForm.ts:186 #: src/pages/sources/saml/SAMLSourceForm.ts:126 #: src/pages/sources/saml/SAMLSourceForm.ts:240 #: src/pages/sources/saml/SAMLSourceForm.ts:261 @@ -1772,7 +1790,7 @@ msgstr "" msgid "Login password is synced from LDAP into authentik automatically. Enable this option only to write password changes in authentik back to LDAP." msgstr "" -#: src/flows/stages/identification/IdentificationStage.ts:183 +#: src/flows/stages/identification/IdentificationStage.ts:191 msgid "Login to continue to {0}." msgstr "" @@ -1905,11 +1923,12 @@ msgstr "" #: src/pages/providers/saml/SAMLProviderForm.ts:53 #: src/pages/providers/saml/SAMLProviderImportForm.ts:38 #: src/pages/providers/saml/SAMLProviderViewPage.ts:66 -#: src/pages/sources/SourcesListPage.ts:51 +#: src/pages/sources/SourcesListPage.ts:52 #: src/pages/sources/ldap/LDAPSourceForm.ts:54 #: src/pages/sources/ldap/LDAPSourceViewPage.ts:64 #: src/pages/sources/oauth/OAuthSourceForm.ts:100 #: src/pages/sources/oauth/OAuthSourceViewPage.ts:64 +#: src/pages/sources/plex/PlexSourceForm.ts:87 #: src/pages/sources/saml/SAMLSourceForm.ts:54 #: src/pages/sources/saml/SAMLSourceViewPage.ts:66 #: src/pages/stages/StageListPage.ts:65 @@ -1949,7 +1968,7 @@ msgstr "" msgid "NameID Property Mapping" msgstr "" -#: src/flows/stages/identification/IdentificationStage.ts:119 +#: src/flows/stages/identification/IdentificationStage.ts:127 msgid "Need an account?" msgstr "" @@ -2340,7 +2359,7 @@ msgstr "" msgid "Post binding (auto-submit)" msgstr "" -#: src/flows/FlowExecutor.ts:255 +#: src/flows/FlowExecutor.ts:258 msgid "Powered by authentik" msgstr "" @@ -2404,6 +2423,7 @@ msgstr "" #: src/pages/providers/proxy/ProxyProviderForm.ts:123 #: src/pages/providers/saml/SAMLProviderForm.ts:78 #: src/pages/sources/oauth/OAuthSourceForm.ts:122 +#: src/pages/sources/plex/PlexSourceForm.ts:109 #: src/pages/sources/saml/SAMLSourceForm.ts:76 msgid "Protocol settings" msgstr "" @@ -2594,7 +2614,7 @@ msgstr "" msgid "Retry authentication" msgstr "" -#: src/flows/FlowExecutor.ts:145 +#: src/flows/FlowExecutor.ts:146 msgid "Return" msgstr "" @@ -2702,7 +2722,7 @@ msgstr "" msgid "Select an identification method." msgstr "" -#: src/flows/stages/identification/IdentificationStage.ts:134 +#: src/flows/stages/identification/IdentificationStage.ts:142 msgid "Select one of the sources below to login." msgstr "" @@ -2714,6 +2734,10 @@ msgstr "" msgid "Select which scopes can be used by the client. The client stil has to specify the scope to access the data." msgstr "" +#: src/pages/sources/plex/PlexSourceForm.ts:131 +msgid "Select which server a user has to be a member of to be allowed to authenticate." +msgstr "" + #: src/pages/events/RuleForm.ts:92 msgid "Select which transports should be used to notify the user. If none are selected, the notification will only be shown in the authentik UI." msgstr "" @@ -2812,7 +2836,7 @@ msgstr "" msgid "Shown as the Title in Flow pages." msgstr "" -#: src/flows/stages/identification/IdentificationStage.ts:120 +#: src/flows/stages/identification/IdentificationStage.ts:128 msgid "Sign up." msgstr "" @@ -2846,16 +2870,17 @@ msgstr "" #: src/pages/flows/FlowForm.ts:94 #: src/pages/sources/ldap/LDAPSourceForm.ts:60 #: src/pages/sources/oauth/OAuthSourceForm.ts:106 +#: src/pages/sources/plex/PlexSourceForm.ts:93 #: src/pages/sources/saml/SAMLSourceForm.ts:60 msgid "Slug" msgstr "" -#: src/flows/FlowExecutor.ts:138 +#: src/flows/FlowExecutor.ts:139 msgid "Something went wrong! Please try again later." msgstr "" #: src/pages/providers/ProviderListPage.ts:91 -#: src/pages/sources/SourcesListPage.ts:87 +#: src/pages/sources/SourcesListPage.ts:88 msgid "Source" msgstr "" @@ -2864,11 +2889,11 @@ msgid "Source {0}" msgstr "" #: src/interfaces/AdminInterface.ts:20 -#: src/pages/sources/SourcesListPage.ts:30 +#: src/pages/sources/SourcesListPage.ts:31 msgid "Sources" msgstr "" -#: src/pages/sources/SourcesListPage.ts:33 +#: src/pages/sources/SourcesListPage.ts:34 msgid "Sources of identities, which can either be synced into authentik's database, like LDAP, or can be used by users to authenticate and enroll themselves, like OAuth and social logins" msgstr "" @@ -3065,6 +3090,7 @@ msgstr "" #: src/pages/sources/ldap/LDAPSourceForm.ts:47 #: src/pages/sources/oauth/OAuthSourceForm.ts:51 +#: src/pages/sources/plex/PlexSourceForm.ts:60 #: src/pages/sources/saml/SAMLSourceForm.ts:47 msgid "Successfully created source." msgstr "" @@ -3201,6 +3227,7 @@ msgstr "" #: src/pages/sources/ldap/LDAPSourceForm.ts:44 #: src/pages/sources/oauth/OAuthSourceForm.ts:48 +#: src/pages/sources/plex/PlexSourceForm.ts:57 #: src/pages/sources/saml/SAMLSourceForm.ts:44 msgid "Successfully updated source." msgstr "" @@ -3452,7 +3479,7 @@ msgstr "" #: src/pages/policies/PolicyListPage.ts:57 #: src/pages/property-mappings/PropertyMappingListPage.ts:55 #: src/pages/providers/ProviderListPage.ts:54 -#: src/pages/sources/SourcesListPage.ts:52 +#: src/pages/sources/SourcesListPage.ts:53 #: src/pages/stages/prompt/PromptForm.ts:97 #: src/pages/stages/prompt/PromptListPage.ts:48 msgid "Type" @@ -3531,7 +3558,7 @@ msgstr "" #: src/pages/providers/oauth2/OAuth2ProviderViewPage.ts:118 #: src/pages/providers/proxy/ProxyProviderViewPage.ts:117 #: src/pages/providers/saml/SAMLProviderViewPage.ts:111 -#: src/pages/sources/SourcesListPage.ts:69 +#: src/pages/sources/SourcesListPage.ts:70 #: src/pages/sources/ldap/LDAPSourceViewPage.ts:95 #: src/pages/sources/oauth/OAuthSourceViewPage.ts:115 #: src/pages/sources/saml/SAMLSourceViewPage.ts:101 @@ -3634,7 +3661,7 @@ msgstr "" #: src/pages/policies/PolicyListPage.ts:80 #: src/pages/property-mappings/PropertyMappingListPage.ts:69 #: src/pages/providers/ProviderListPage.ts:76 -#: src/pages/sources/SourcesListPage.ts:72 +#: src/pages/sources/SourcesListPage.ts:73 #: src/pages/stages/StageListPage.ts:88 #: src/pages/users/UserActiveForm.ts:41 msgid "Update {0}" @@ -3738,7 +3765,7 @@ msgstr "" msgid "Userinfo URL" msgstr "" -#: src/flows/stages/identification/IdentificationStage.ts:142 +#: src/flows/stages/identification/IdentificationStage.ts:150 #: src/pages/stages/identification/IdentificationStageForm.ts:78 #: src/pages/user-settings/UserDetailsPage.ts:57 #: src/pages/users/UserForm.ts:47 @@ -3891,7 +3918,7 @@ msgstr "" msgid "When this option is enabled, all executions of this policy will be logged. By default, only execution errors are logged." msgstr "" -#: src/flows/FlowExecutor.ts:134 +#: src/flows/FlowExecutor.ts:135 msgid "Whoops!" msgstr "" From ea2f62395572e12454430dc9f38c775755e29937 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Mon, 3 May 2021 21:40:45 +0200 Subject: [PATCH 07/10] tests/e2e: update e2e tests for new source login button Signed-off-by: Jens Langhammer --- tests/e2e/test_source_oauth.py | 16 ++++++++-------- tests/e2e/test_source_saml.py | 12 ++++++------ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/e2e/test_source_oauth.py b/tests/e2e/test_source_oauth.py index faeeea221..5e5d52b7e 100644 --- a/tests/e2e/test_source_oauth.py +++ b/tests/e2e/test_source_oauth.py @@ -147,11 +147,11 @@ class TestSourceOAuth2(SeleniumTestCase): wait.until( ec.presence_of_element_located( - (By.CLASS_NAME, "pf-c-login__main-footer-links-item-link") + (By.CSS_SELECTOR, "pf-c-login__main-footer-links-item > button") ) ) identification_stage.find_element( - By.CLASS_NAME, "pf-c-login__main-footer-links-item-link" + By.CSS_SELECTOR, "pf-c-login__main-footer-links-item > button" ).click() # Now we should be at the IDP, wait for the login field @@ -206,11 +206,11 @@ class TestSourceOAuth2(SeleniumTestCase): wait.until( ec.presence_of_element_located( - (By.CLASS_NAME, "pf-c-login__main-footer-links-item-link") + (By.CSS_SELECTOR, "pf-c-login__main-footer-links-item > button") ) ) identification_stage.find_element( - By.CLASS_NAME, "pf-c-login__main-footer-links-item-link" + By.CSS_SELECTOR, "pf-c-login__main-footer-links-item > button" ).click() # Now we should be at the IDP, wait for the login field @@ -245,11 +245,11 @@ class TestSourceOAuth2(SeleniumTestCase): wait.until( ec.presence_of_element_located( - (By.CLASS_NAME, "pf-c-login__main-footer-links-item-link") + (By.CSS_SELECTOR, "pf-c-login__main-footer-links-item > button") ) ) identification_stage.find_element( - By.CLASS_NAME, "pf-c-login__main-footer-links-item-link" + By.CSS_SELECTOR, "pf-c-login__main-footer-links-item > button" ).click() # Now we should be at the IDP, wait for the login field @@ -338,11 +338,11 @@ class TestSourceOAuth1(SeleniumTestCase): wait.until( ec.presence_of_element_located( - (By.CLASS_NAME, "pf-c-login__main-footer-links-item-link") + (By.CSS_SELECTOR, "pf-c-login__main-footer-links-item > button") ) ) identification_stage.find_element( - By.CLASS_NAME, "pf-c-login__main-footer-links-item-link" + By.CSS_SELECTOR, "pf-c-login__main-footer-links-item > button" ).click() # Now we should be at the IDP, wait for the login field diff --git a/tests/e2e/test_source_saml.py b/tests/e2e/test_source_saml.py index 8fbfbfccd..9c5240462 100644 --- a/tests/e2e/test_source_saml.py +++ b/tests/e2e/test_source_saml.py @@ -140,11 +140,11 @@ class TestSourceSAML(SeleniumTestCase): wait.until( ec.presence_of_element_located( - (By.CLASS_NAME, "pf-c-login__main-footer-links-item-link") + (By.CSS_SELECTOR, "pf-c-login__main-footer-links-item > button") ) ) identification_stage.find_element( - By.CLASS_NAME, "pf-c-login__main-footer-links-item-link" + By.CSS_SELECTOR, "pf-c-login__main-footer-links-item > button" ).click() # Now we should be at the IDP, wait for the username field @@ -208,11 +208,11 @@ class TestSourceSAML(SeleniumTestCase): wait.until( ec.presence_of_element_located( - (By.CLASS_NAME, "pf-c-login__main-footer-links-item-link") + (By.CSS_SELECTOR, "pf-c-login__main-footer-links-item > button") ) ) identification_stage.find_element( - By.CLASS_NAME, "pf-c-login__main-footer-links-item-link" + By.CSS_SELECTOR, "pf-c-login__main-footer-links-item > button" ).click() sleep(1) @@ -289,11 +289,11 @@ class TestSourceSAML(SeleniumTestCase): wait.until( ec.presence_of_element_located( - (By.CLASS_NAME, "pf-c-login__main-footer-links-item-link") + (By.CSS_SELECTOR, "pf-c-login__main-footer-links-item > button") ) ) identification_stage.find_element( - By.CLASS_NAME, "pf-c-login__main-footer-links-item-link" + By.CSS_SELECTOR, "pf-c-login__main-footer-links-item > button" ).click() # Now we should be at the IDP, wait for the username field From be21a5d17208e658abc677e55256b6cb06bc3966 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Mon, 3 May 2021 21:40:57 +0200 Subject: [PATCH 08/10] sources/plex: add general tests Signed-off-by: Jens Langhammer --- Pipfile | 1 + Pipfile.lock | 138 ++++++++++++++++++++-- authentik/sources/oauth/views/callback.py | 8 +- authentik/sources/plex/plex.py | 1 - authentik/sources/plex/tests.py | 64 ++++++++++ swagger.yaml | 5 +- tests/e2e/test_source_oauth.py | 17 +-- tests/e2e/test_source_saml.py | 12 +- 8 files changed, 216 insertions(+), 30 deletions(-) create mode 100644 authentik/sources/plex/tests.py diff --git a/Pipfile b/Pipfile index c4bb09316..8857dfb65 100644 --- a/Pipfile +++ b/Pipfile @@ -59,3 +59,4 @@ pylint-django = "*" pytest = "*" pytest-django = "*" selenium = "*" +requests-mock = "*" diff --git a/Pipfile.lock b/Pipfile.lock index a2e910476..e50aaf2a7 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "24f00363590649f2442c6ac28dfe8692f0f317e0a5b91c0696b84610cef299d2" + "sha256": "17be2923cf8d281e430ec1467aea723806ac6f7c58fc6553ede92317e43f4d14" }, "pipfile-spec": 6, "requires": { @@ -56,6 +56,7 @@ "sha256:f881853d2643a29e643609da57b96d5f9c9b93f62429dcc1cbb413c7d07f0e1a", "sha256:fe60131d21b31fd1a14bd43e6bb88256f69dfc3188b3a89d736d6c71ed43ec95" ], + "markers": "python_version >= '3.6'", "version": "==3.7.4.post0" }, "aioredis": { @@ -70,6 +71,7 @@ "sha256:03e16e94f2b34c31f8bf1206d8ddd3ccaa4c315f7f6a1879b7b1210d229568c2", "sha256:493a2ac6788ce270a2f6a765b017299f60c1998f5a8617908ee9be082f7300fb" ], + "markers": "python_version >= '3.6'", "version": "==5.0.6" }, "asgiref": { @@ -77,6 +79,7 @@ "sha256:92906c611ce6c967347bbfea733f13d6313901d54dcca88195eaeb52b2a8e8ee", "sha256:d1216dfbdfb63826470995d31caed36225dcaf34f182e0fa257a4dd9e86f1b78" ], + "markers": "python_version >= '3.6'", "version": "==3.3.4" }, "async-timeout": { @@ -84,6 +87,7 @@ "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f", "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3" ], + "markers": "python_full_version >= '3.5.3'", "version": "==3.0.1" }, "attrs": { @@ -91,6 +95,7 @@ "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6", "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==20.3.0" }, "autobahn": { @@ -98,6 +103,7 @@ "sha256:9195df8af03b0ff29ccd4b7f5abbde957ee90273465942205f9a1bad6c3f07ac", "sha256:e126c1f583e872fb59e79d36977cfa1f2d0a8a79f90ae31f406faae7664b8e03" ], + "markers": "python_version >= '3.7'", "version": "==21.3.1" }, "automat": { @@ -127,6 +133,7 @@ "sha256:e4f8cb923edf035c2ae5f6169c70e77e31df70b88919b92b826a6b9bd14511b1", "sha256:f7c2c5c5ed5212b2628d8fb1c587b31c6e8d413ecbbd1a1cdf6f96ed6f5c8d5e" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", "version": "==1.20.62" }, "cachetools": { @@ -134,6 +141,7 @@ "sha256:2cc0b89715337ab6dbba85b5b50effe2b0c74e035d83ee8ed637cf52f12ae001", "sha256:61b5ed1e22a0924aed1d23b478f37e8d52549ff8a961de2909c69bf950020cff" ], + "markers": "python_version ~= '3.5'", "version": "==4.2.2" }, "cbor2": { @@ -220,6 +228,7 @@ "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==4.0.0" }, "click": { @@ -227,6 +236,7 @@ "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==7.1.2" }, "click-didyoumean": { @@ -300,6 +310,7 @@ "sha256:76ffae916ba3aa66b46996c14fa713e46004788167a4873d647544e750e0e99f", "sha256:a9af943c79717bc52fe64a3c236ae5d3adccc8b5be19c881b442d2c3db233393" ], + "markers": "python_version >= '3.6'", "version": "==3.0.2" }, "defusedxml": { @@ -425,6 +436,7 @@ "hashes": [ "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d" ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.18.2" }, "geoip2": { @@ -440,6 +452,7 @@ "sha256:588bdb03a41ecb4978472b847881e5518b5d9ec6153d3d679aa127a55e13b39f", "sha256:9ad25fba07f46a628ad4d0ca09f38dcb262830df2ac95b217f9b0129c9e42206" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", "version": "==1.30.0" }, "gunicorn": { @@ -455,6 +468,7 @@ "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6", "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042" ], + "markers": "python_version >= '3.6'", "version": "==0.12.0" }, "hiredis": { @@ -501,6 +515,7 @@ "sha256:f52010e0a44e3d8530437e7da38d11fb822acfb0d5b12e9cd5ba655509937ca0", "sha256:f8196f739092a78e4f6b1b2172679ed3343c39c61a3e9d722ce6fcf1dac2824a" ], + "markers": "python_version >= '3.6'", "version": "==2.0.0" }, "httptools": { @@ -549,6 +564,7 @@ "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2" ], + "markers": "python_version >= '3.5'", "version": "==0.5.1" }, "itypes": { @@ -563,6 +579,7 @@ "sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419", "sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.11.3" }, "jmespath": { @@ -570,6 +587,7 @@ "sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9", "sha256:cdf6525904cc597730141d61b36f2e4b8ecc257c420fa2f4549bac2c2d0cb72f" ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.10.0" }, "jsonschema": { @@ -584,6 +602,7 @@ "sha256:6dc509178ac4269b0e66ab4881f70a2035c33d3a622e20585f965986a5182006", "sha256:f4965fba0a4718d47d470beeb5d6446e3357a62402b16c510b6a2f251e05ac3c" ], + "markers": "python_version >= '3.6'", "version": "==5.0.2" }, "kubernetes": { @@ -596,8 +615,11 @@ }, "ldap3": { "hashes": [ + "sha256:afc6fc0d01f02af82cd7bfabd3bbfd5dc96a6ae91e97db0a2dab8a0f1b436056", "sha256:18c3ee656a6775b9b0d60f7c6c5b094d878d1d90fc03d56731039f0a4b546a91", - "sha256:c1df41d89459be6f304e0ceec4b00fdea533dbbcd83c802b1272dcdb94620b57" + "sha256:c1df41d89459be6f304e0ceec4b00fdea533dbbcd83c802b1272dcdb94620b57", + "sha256:8c949edbad2be8a03e719ba48bd6779f327ec156929562814b3e84ab56889c8c", + "sha256:4139c91f0eef9782df7b77c8cbc6243086affcb6a8a249b768a9658438e5da59" ], "index": "pypi", "version": "==2.9" @@ -709,12 +731,14 @@ "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be", "sha256:feb7b34d6325451ef96bc0e36e1a6c0c1c64bc1fbec4b854f4529e51887b1621" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.1" }, "maxminddb": { "hashes": [ "sha256:47e86a084dd814fac88c99ea34ba3278a74bc9de5a25f4b815b608798747c7dc" ], + "markers": "python_version >= '3.6'", "version": "==2.0.3" }, "msgpack": { @@ -790,6 +814,7 @@ "sha256:f21756997ad8ef815d8ef3d34edd98804ab5ea337feedcd62fb52d22bf531281", "sha256:fc13a9524bc18b6fb6e0dbec3533ba0496bbed167c56d0aabefd965584557d80" ], + "markers": "python_version >= '3.6'", "version": "==5.1.0" }, "oauthlib": { @@ -797,6 +822,7 @@ "sha256:bee41cc35fcca6e988463cacc3bcb8a96224f470ca547e697b604cc697b2f889", "sha256:df884cd6cbe20e32633f1db1072e9356f53638e4361bef4e8b03c9127c9328ea" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==3.1.0" }, "packaging": { @@ -812,6 +838,7 @@ "sha256:030e4f9df5f53db2292eec37c6255957eb76168c6f974e4176c711cf91ed34aa", "sha256:b6c5a9643e3545bcbfd9451766cbaa5d9c67e7303c7bc32c750b6fa70ecb107d" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.10.1" }, "prompt-toolkit": { @@ -819,6 +846,7 @@ "sha256:bf00f22079f5fadc949f42ae8ff7f05702826a97059ffcc6281036ad40ac6f04", "sha256:e1b4f11b9336a28fa11810bc623c357420f69dfdb6d2dac41ca2c21a55c033bc" ], + "markers": "python_full_version >= '3.6.1'", "version": "==3.0.18" }, "psycopg2-binary": { @@ -864,15 +892,37 @@ }, "pyasn1": { "hashes": [ + "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8", + "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", + "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359", + "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2", "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", - "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba" + "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00", + "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776", + "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12", + "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf", + "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7", + "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576", + "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3", + "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86" ], "version": "==0.4.8" }, "pyasn1-modules": { "hashes": [ + "sha256:f39edd8c4ecaa4556e989147ebf219227e2cd2e8a43c7e7fcb1f1c18c5fd6a3d", + "sha256:426edb7a5e8879f1ec54a1864f16b882c2837bfd06eee62f2c982315ee2473ed", + "sha256:15b7c67fabc7fc240d87fb9aabf999cf82311a6d6fb2c70d00d3d0604878c811", + "sha256:fe0644d9ab041506b62782e92b06b8c68cca799e1a9636ec398675459e031405", + "sha256:b80486a6c77252ea3a3e9b1e360bc9cf28eaac41263d173c032581ad2f20fe45", + "sha256:65cebbaffc913f4fe9e4808735c95ea22d7a7775646ab690518c056784bc21b4", "sha256:905f84c712230b2c592c19470d3ca8d552de726050d1d1716282a1f6146be65e", - "sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74" + "sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74", + "sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd", + "sha256:a99324196732f53093a84c4369c996713eb8c89d360a496b599fb1a9c47fc3eb", + "sha256:0fe1b68d1e486a1ed5473f1302bd991c1611d319bba158e98b106ff86e1d7199", + "sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0", + "sha256:0845a5582f6a02bb3e1bde9ecfc4bfcae6ec3210dd270522fee602365430c3f8" ], "version": "==0.2.8" }, @@ -881,6 +931,7 @@ "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.20" }, "pycryptodome": { @@ -924,6 +975,7 @@ "sha256:412e00137858f04bde0729913874a48485665f2d36fe9ee449f26be864af9316", "sha256:7ead136e03655af85069b6f47b23eb7c3e5c221aa9f022a4fbb499f5b7308f29" ], + "markers": "python_version >= '3.5'", "version": "==2.0.2" }, "pyjwt": { @@ -946,12 +998,14 @@ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.7" }, "pyrsistent": { "hashes": [ "sha256:2e636185d9eb976a18a8a8e96efce62f2905fea90041958d8cc2a189756ebf3e" ], + "markers": "python_version >= '3.5'", "version": "==0.17.3" }, "python-dateutil": { @@ -959,6 +1013,7 @@ "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.8.1" }, "python-dotenv": { @@ -1015,6 +1070,7 @@ "sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2", "sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==3.5.3" }, "requests": { @@ -1022,12 +1078,14 @@ "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.25.1" }, "requests-oauthlib": { "hashes": [ "sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d", - "sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a" + "sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a", + "sha256:fa6c47b933f01060936d87ae9327fead68768b69c6c9ea2109c48be30f2d4dbc" ], "index": "pypi", "version": "==1.3.0" @@ -1045,6 +1103,7 @@ "sha256:44bc6b54fddd45e4bc0619059196679f9e8b79c027f4131bb072e6a22f4d5e28", "sha256:ac79fb25f5476e8e9ed1c53b8a2286d2c3f5dde49eb37dbcee5c7eb6a8415a22" ], + "markers": "python_version >= '3'", "version": "==0.17.4" }, "ruamel.yaml.clib": { @@ -1081,7 +1140,7 @@ "sha256:e9f7d1d8c26a6a12c23421061f9022bb62704e38211fe375c645485f38df34a2", "sha256:f6061a31880c1ed6b6ce341215336e2f3d0c1deccd84957b6fa8ca474b41e89f" ], - "markers": "platform_python_implementation == 'CPython' and python_version < '3.10'", + "markers": "python_version < '3.10' and platform_python_implementation == 'CPython'", "version": "==0.2.2" }, "s3transfer": { @@ -1112,6 +1171,7 @@ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, "sqlparse": { @@ -1119,6 +1179,7 @@ "sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0", "sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8" ], + "markers": "python_version >= '3.5'", "version": "==0.4.1" }, "structlog": { @@ -1174,6 +1235,7 @@ "sha256:7d6f89745680233f1c4db9ddb748df5e88d2a7a37962be174c0fd04c8dba1dc8", "sha256:c16b55f9a67b2419cfdf8846576e2ec9ba94fe6978a83080c352a80db31c93fb" ], + "markers": "python_version >= '3.6'", "version": "==21.2.1" }, "typing-extensions": { @@ -1189,6 +1251,7 @@ "sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f", "sha256:5af8ad10cec94f215e3f48112de2022e1d5a37ed427fbd88652fa908f2ab7cae" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==3.0.1" }, "urllib3": { @@ -1233,6 +1296,7 @@ "sha256:4c9dceab6f76ed92105027c49c823800dd33cacce13bdedc5b914e3514b7fb30", "sha256:7d3b1624a953da82ef63462013bbd271d3eb75751489f9807598e8f340bd637e" ], + "markers": "python_version >= '3.6'", "version": "==5.0.0" }, "watchgod": { @@ -1262,6 +1326,7 @@ "sha256:44b5df8f08c74c3d82d28100fdc81f4536809ce98a17f0757557813275fbb663", "sha256:63509b41d158ae5b7f67eb4ad20fecbb4eee99434e73e140354dc3ff8e09716f" ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.58.0" }, "websockets": { @@ -1348,6 +1413,7 @@ "sha256:f0b059678fd549c66b89bed03efcabb009075bd131c248ecdf087bdb6faba24a", "sha256:fcbb48a93e8699eae920f8d92f7160c03567b421bc17362a9ffbbd706a816f71" ], + "markers": "python_version >= '3.6'", "version": "==1.6.3" }, "zope.interface": { @@ -1404,6 +1470,7 @@ "sha256:f44e517131a98f7a76696a7b21b164bcb85291cee106a23beccce454e1f433a4", "sha256:f7ee479e96f7ee350db1cf24afa5685a5899e2b34992fb99e1f7c1b0b758d263" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==5.4.0" } }, @@ -1420,6 +1487,7 @@ "sha256:4db03ab5fc3340cf619dbc25e42c2cc3755154ce6009469766d7143d1fc2ee4e", "sha256:8a398dfce302c13f14bab13e2b14fe385d32b73f4e4853b9bdfb64598baa1975" ], + "markers": "python_version ~= '3.6'", "version": "==2.5.6" }, "attrs": { @@ -1427,6 +1495,7 @@ "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6", "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==20.3.0" }, "bandit": { @@ -1452,11 +1521,27 @@ "index": "pypi", "version": "==1.0.1" }, + "certifi": { + "hashes": [ + "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c", + "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830" + ], + "version": "==2020.12.5" + }, + "chardet": { + "hashes": [ + "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", + "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==4.0.0" + }, "click": { "hashes": [ "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==7.1.2" }, "colorama": { @@ -1530,14 +1615,23 @@ "sha256:6c4cc71933456991da20917998acbe6cf4fb41eeaab7d6d67fbc05ecd4c865b0", "sha256:96bf5c08b157a666fec41129e6d327235284cca4c81e92109260f353ba138005" ], + "markers": "python_version >= '3.4'", "version": "==4.0.7" }, "gitpython": { "hashes": [ - "sha256:05af150f47a5cca3f4b0af289b73aef8cf3c4fe2385015b06220cbcdee48bb6e", - "sha256:a77824e516d3298b04fb36ec7845e92747df8fcfee9cacc32dd6239f9652f867" + "sha256:3283ae2fba31c913d857e12e5ba5f9a7772bbc064ae2bb09efafa71b0dd4939b", + "sha256:be27633e7509e58391f10207cd32b2a6cf5b908f92d9cd30da2e514e1137af61" ], - "version": "==3.1.15" + "markers": "python_version >= '3.4'", + "version": "==3.1.14" + }, + "idna": { + "hashes": [ + "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", + "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" + ], + "version": "==2.10" }, "iniconfig": { "hashes": [ @@ -1551,6 +1645,7 @@ "sha256:0a943902919f65c5684ac4e0154b1ad4fac6dcaa5d9f3426b732f1c8b5419be6", "sha256:2bb1680aad211e3c9944dbce1d4ba09a989f04e238296c87fe2139faa26d655d" ], + "markers": "python_version >= '3.6' and python_version < '4.0'", "version": "==5.8.0" }, "lazy-object-proxy": { @@ -1578,6 +1673,7 @@ "sha256:ed361bb83436f117f9917d282a456f9e5009ea12fd6de8742d1a4752c3017e93", "sha256:f5144c75445ae3ca2057faac03fda5a902eff196702b0a24daf1d6ce0650514b" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", "version": "==1.6.0" }, "mccabe": { @@ -1614,6 +1710,7 @@ "sha256:42df03e7797b796625b1029c0400279c7c34fd7df24a7d7818a1abb5b38710dd", "sha256:c68c661ac5cc81058ac94247278eeda6d2e6aecb3e227b0387c30d277e7ef8d4" ], + "markers": "python_version >= '2.6'", "version": "==5.6.0" }, "pluggy": { @@ -1621,6 +1718,7 @@ "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.13.1" }, "py": { @@ -1628,6 +1726,7 @@ "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3", "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.10.0" }, "pylint": { @@ -1658,6 +1757,7 @@ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.7" }, "pytest": { @@ -1757,6 +1857,22 @@ ], "version": "==2021.4.4" }, + "requests": { + "hashes": [ + "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", + "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==2.25.1" + }, + "requests-mock": { + "hashes": [ + "sha256:33296f228d8c5df11a7988b741325422480baddfdf5dd9318fd0eb40c3ed8595", + "sha256:5c8ef0254c14a84744be146e9799dc13ebc4f6186058112d9aeed96b131b58e2" + ], + "index": "pypi", + "version": "==1.9.2" + }, "selenium": { "hashes": [ "sha256:2d7131d7bc5a5b99a2d9b04aaf2612c411b03b8ca1b1ee8d3de5845a9be2cb3c", @@ -1770,6 +1886,7 @@ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, "smmap": { @@ -1777,6 +1894,7 @@ "sha256:7e65386bd122d45405ddf795637b7f7d2b532e7e401d46bbe3fb49b9986d5182", "sha256:a9a7479e4c572e2e775c404dcd3080c8dc49f39918c2cf74913d30c4c478e3c2" ], + "markers": "python_version >= '3.5'", "version": "==4.0.0" }, "stevedore": { @@ -1784,6 +1902,7 @@ "sha256:3a5bbd0652bf552748871eaa73a4a8dc2899786bc497a2aa1fcb4dcdb0debeee", "sha256:50d7b78fbaf0d04cd62411188fa7eedcb03eb7f4c4b37005615ceebe582aa82a" ], + "markers": "python_version >= '3.6'", "version": "==3.3.0" }, "toml": { @@ -1791,6 +1910,7 @@ "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.10.2" }, "typed-ast": { diff --git a/authentik/sources/oauth/views/callback.py b/authentik/sources/oauth/views/callback.py index 036652f26..8c82bea5a 100644 --- a/authentik/sources/oauth/views/callback.py +++ b/authentik/sources/oauth/views/callback.py @@ -57,7 +57,7 @@ class OAuthCallback(OAuthClientMixin, View): enroll_info=enroll_info, ) return sfm.get_flow( - token=token, + access_token=token.get("access_token"), ) # pylint: disable=unused-argument @@ -97,9 +97,9 @@ class OAuthSourceFlowManager(SourceFlowManager): connection_type = UserOAuthSourceConnection def update_connection( - self, connection: UserOAuthSourceConnection, token: dict[str, Any] + self, connection: UserOAuthSourceConnection, + access_token: Optional[str] = None, ) -> UserOAuthSourceConnection: """Set the access_token on the connection""" - connection.access_token = token.get("access_token") - connection.save() + connection.access_token = access_token return connection diff --git a/authentik/sources/plex/plex.py b/authentik/sources/plex/plex.py index ba39997ab..dc9c52d16 100644 --- a/authentik/sources/plex/plex.py +++ b/authentik/sources/plex/plex.py @@ -109,5 +109,4 @@ class PlexSourceFlowManager(SourceFlowManager): ) -> PlexSourceConnection: """Set the access_token on the connection""" connection.plex_token = plex_token - connection.save() return connection diff --git a/authentik/sources/plex/tests.py b/authentik/sources/plex/tests.py new file mode 100644 index 000000000..eb2ab30ce --- /dev/null +++ b/authentik/sources/plex/tests.py @@ -0,0 +1,64 @@ +"""plex Source tests""" +from django.test import TestCase +from requests_mock import Mocker + +from authentik.providers.oauth2.generators import generate_client_secret +from authentik.sources.plex.models import PlexSource +from authentik.sources.plex.plex import PlexAuth + +USER_INFO_RESPONSE = { + "id": 1234123419, + "uuid": "qwerqewrqewrqwr", + "username": "username", + "title": "title", + "email": "foo@bar.baz", +} +RESOURCES_RESPONSE = [ + { + "name": "foo", + "clientIdentifier": "allowed", + "provides": "server", + }, + { + "name": "foo", + "clientIdentifier": "denied", + "provides": "server", + }, +] + + +class TestPlexSource(TestCase): + """plex Source tests""" + + def setUp(self): + self.source: PlexSource = PlexSource.objects.create( + name="test", + slug="test", + ) + + def test_get_user_info(self): + """Test get_user_info""" + token = generate_client_secret() + api = PlexAuth(self.source, token) + with Mocker() as mocker: + mocker.get("https://plex.tv/api/v2/user", json=USER_INFO_RESPONSE) + self.assertEqual( + api.get_user_info(), + ( + {"username": "username", "email": "foo@bar.baz", "name": "title"}, + 1234123419, + ), + ) + + def test_check_server_overlap(self): + """Test check_server_overlap""" + token = generate_client_secret() + api = PlexAuth(self.source, token) + with Mocker() as mocker: + mocker.get("https://plex.tv/api/v2/resources", json=RESOURCES_RESPONSE) + self.assertFalse(api.check_server_overlap()) + self.source.allowed_servers = ["allowed"] + self.source.save() + with Mocker() as mocker: + mocker.get("https://plex.tv/api/v2/resources", json=RESOURCES_RESPONSE) + self.assertTrue(api.check_server_overlap()) diff --git a/swagger.yaml b/swagger.yaml index e6db70d15..4e3173804 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -17656,8 +17656,6 @@ definitions: required: - name - slug - - client_id - - allowed_servers type: object properties: pk: @@ -17724,9 +17722,12 @@ definitions: - username_deny client_id: title: Client id + description: Client identifier used to talk to Plex. type: string minLength: 1 allowed_servers: + description: Which servers a user has to be a member of to be granted access. + Empty list allows every server. type: array items: title: Allowed servers diff --git a/tests/e2e/test_source_oauth.py b/tests/e2e/test_source_oauth.py index 5e5d52b7e..a26d9c368 100644 --- a/tests/e2e/test_source_oauth.py +++ b/tests/e2e/test_source_oauth.py @@ -147,11 +147,11 @@ class TestSourceOAuth2(SeleniumTestCase): wait.until( ec.presence_of_element_located( - (By.CSS_SELECTOR, "pf-c-login__main-footer-links-item > button") + (By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button") ) ) identification_stage.find_element( - By.CSS_SELECTOR, "pf-c-login__main-footer-links-item > button" + By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button" ).click() # Now we should be at the IDP, wait for the login field @@ -206,11 +206,11 @@ class TestSourceOAuth2(SeleniumTestCase): wait.until( ec.presence_of_element_located( - (By.CSS_SELECTOR, "pf-c-login__main-footer-links-item > button") + (By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button") ) ) identification_stage.find_element( - By.CSS_SELECTOR, "pf-c-login__main-footer-links-item > button" + By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button" ).click() # Now we should be at the IDP, wait for the login field @@ -245,11 +245,11 @@ class TestSourceOAuth2(SeleniumTestCase): wait.until( ec.presence_of_element_located( - (By.CSS_SELECTOR, "pf-c-login__main-footer-links-item > button") + (By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button") ) ) identification_stage.find_element( - By.CSS_SELECTOR, "pf-c-login__main-footer-links-item > button" + By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button" ).click() # Now we should be at the IDP, wait for the login field @@ -338,17 +338,18 @@ class TestSourceOAuth1(SeleniumTestCase): wait.until( ec.presence_of_element_located( - (By.CSS_SELECTOR, "pf-c-login__main-footer-links-item > button") + (By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button") ) ) identification_stage.find_element( - By.CSS_SELECTOR, "pf-c-login__main-footer-links-item > button" + By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button" ).click() # Now we should be at the IDP, wait for the login field self.wait.until(ec.presence_of_element_located((By.NAME, "username"))) self.driver.find_element(By.NAME, "username").send_keys("example-user") self.driver.find_element(By.NAME, "username").send_keys(Keys.ENTER) + sleep(2) # Wait until we're logged in self.wait.until( diff --git a/tests/e2e/test_source_saml.py b/tests/e2e/test_source_saml.py index 9c5240462..7a409c484 100644 --- a/tests/e2e/test_source_saml.py +++ b/tests/e2e/test_source_saml.py @@ -140,11 +140,11 @@ class TestSourceSAML(SeleniumTestCase): wait.until( ec.presence_of_element_located( - (By.CSS_SELECTOR, "pf-c-login__main-footer-links-item > button") + (By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button") ) ) identification_stage.find_element( - By.CSS_SELECTOR, "pf-c-login__main-footer-links-item > button" + By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button" ).click() # Now we should be at the IDP, wait for the username field @@ -208,11 +208,11 @@ class TestSourceSAML(SeleniumTestCase): wait.until( ec.presence_of_element_located( - (By.CSS_SELECTOR, "pf-c-login__main-footer-links-item > button") + (By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button") ) ) identification_stage.find_element( - By.CSS_SELECTOR, "pf-c-login__main-footer-links-item > button" + By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button" ).click() sleep(1) @@ -289,11 +289,11 @@ class TestSourceSAML(SeleniumTestCase): wait.until( ec.presence_of_element_located( - (By.CSS_SELECTOR, "pf-c-login__main-footer-links-item > button") + (By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button") ) ) identification_stage.find_element( - By.CSS_SELECTOR, "pf-c-login__main-footer-links-item > button" + By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button" ).click() # Now we should be at the IDP, wait for the username field From d330e9ee7f2ec29a6c90e6d9ae35dc5063947401 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Mon, 3 May 2021 22:08:25 +0200 Subject: [PATCH 09/10] web/flows: fix rendering for plex login Signed-off-by: Jens Langhammer --- authentik/sources/oauth/views/callback.py | 3 +- web/.eslintrc.json | 6 ++- web/package-lock.json | 13 +++++++ web/package.json | 1 + web/src/flows/sources/plex/PlexLoginInit.ts | 42 ++++++++++++++++----- web/src/locales/en.po | 4 ++ web/src/locales/pseudo-LOCALE.po | 4 ++ 7 files changed, 61 insertions(+), 12 deletions(-) diff --git a/authentik/sources/oauth/views/callback.py b/authentik/sources/oauth/views/callback.py index 8c82bea5a..fa6d9b735 100644 --- a/authentik/sources/oauth/views/callback.py +++ b/authentik/sources/oauth/views/callback.py @@ -97,7 +97,8 @@ class OAuthSourceFlowManager(SourceFlowManager): connection_type = UserOAuthSourceConnection def update_connection( - self, connection: UserOAuthSourceConnection, + self, + connection: UserOAuthSourceConnection, access_token: Optional[str] = None, ) -> UserOAuthSourceConnection: """Set the access_token on the connection""" diff --git a/web/.eslintrc.json b/web/.eslintrc.json index 39bca2824..82e527d4f 100644 --- a/web/.eslintrc.json +++ b/web/.eslintrc.json @@ -6,7 +6,8 @@ "extends": [ "eslint:recommended", "plugin:@typescript-eslint/recommended", - "plugin:lit/recommended" + "plugin:lit/recommended", + "plugin:custom-elements/recommended" ], "parser": "@typescript-eslint/parser", "parserOptions": { @@ -15,7 +16,8 @@ }, "plugins": [ "@typescript-eslint", - "lit" + "lit", + "custom-elements" ], "rules": { "indent": "off", diff --git a/web/package-lock.json b/web/package-lock.json index 71f47e181..41d9f6731 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -3723,6 +3723,14 @@ "resolved": "https://registry.npmjs.org/eslint-config-google/-/eslint-config-google-0.14.0.tgz", "integrity": "sha512-WsbX4WbjuMvTdeVL6+J3rK1RGhCTqjsFjX7UMSMgZiyxxaNLkoJENbrGExzERFeoTpGw3F3FypTiWAP9ZXzkEw==" }, + "eslint-plugin-custom-elements": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-custom-elements/-/eslint-plugin-custom-elements-0.0.2.tgz", + "integrity": "sha512-lIRBhxh0M/1seyMzSPJwdfdNtlVSPArJ+erF2xqjPsd/6SdCuT43hCQNV2A2te3GqBWhgh/unXSVRO09c1kyPA==", + "requires": { + "eslint-rule-documentation": ">=1.0.0" + } + }, "eslint-plugin-lit": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/eslint-plugin-lit/-/eslint-plugin-lit-1.3.0.tgz", @@ -3733,6 +3741,11 @@ "requireindex": "^1.2.0" } }, + "eslint-rule-documentation": { + "version": "1.0.23", + "resolved": "https://registry.npmjs.org/eslint-rule-documentation/-/eslint-rule-documentation-1.0.23.tgz", + "integrity": "sha512-pWReu3fkohwyvztx/oQWWgld2iad25TfUdi6wvhhaDPIQjHU/pyvlKgXFw1kX31SQK2Nq9MH+vRDWB0ZLy8fYw==" + }, "eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", diff --git a/web/package.json b/web/package.json index 144efa4b1..08062e76e 100644 --- a/web/package.json +++ b/web/package.json @@ -67,6 +67,7 @@ "construct-style-sheets-polyfill": "^2.4.16", "eslint": "^7.25.0", "eslint-config-google": "^0.14.0", + "eslint-plugin-custom-elements": "0.0.2", "eslint-plugin-lit": "^1.3.0", "flowchart.js": "^1.15.0", "lit-element": "^2.5.0", diff --git a/web/src/flows/sources/plex/PlexLoginInit.ts b/web/src/flows/sources/plex/PlexLoginInit.ts index 5a134933a..a968f8293 100644 --- a/web/src/flows/sources/plex/PlexLoginInit.ts +++ b/web/src/flows/sources/plex/PlexLoginInit.ts @@ -1,10 +1,17 @@ +import { t } from "@lingui/macro"; import { Challenge } from "authentik-api"; -import {customElement, property} from "lit-element"; -import {html, TemplateResult} from "lit-html"; -import { PFSize } from "../../../elements/Spinner"; +import PFLogin from "@patternfly/patternfly/components/Login/login.css"; +import PFForm from "@patternfly/patternfly/components/Form/form.css"; +import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; +import PFTitle from "@patternfly/patternfly/components/Title/title.css"; +import PFButton from "@patternfly/patternfly/components/Button/button.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; +import AKGlobal from "../../../authentik.css"; +import { CSSResult, customElement, property } from "lit-element"; +import { html, TemplateResult } from "lit-html"; import { BaseStage } from "../../stages/base"; -import {PlexAPIClient, popupCenterScreen} from "./API"; -import {DEFAULT_CONFIG} from "../../../api/Config"; +import { PlexAPIClient, popupCenterScreen } from "./API"; +import { DEFAULT_CONFIG } from "../../../api/Config"; import { SourcesApi } from "authentik-api"; export interface PlexAuthenticationChallenge extends Challenge { @@ -20,6 +27,10 @@ export class PlexLoginInit extends BaseStage { @property({ attribute: false }) challenge?: PlexAuthenticationChallenge; + static get styles(): CSSResult[] { + return [PFBase, PFLogin, PFForm, PFFormControl, PFButton, PFTitle, AKGlobal]; + } + async firstUpdated(): Promise { const authInfo = await PlexAPIClient.getPin(this.challenge?.client_id || ""); const authWindow = popupCenterScreen(authInfo.authUrl, "plex auth", 550, 700); @@ -36,10 +47,23 @@ export class PlexLoginInit extends BaseStage { }); } - renderLoading(): TemplateResult { - return html`
- -
`; + render(): TemplateResult { + return html` + +
+ +
`; } } diff --git a/web/src/locales/en.po b/web/src/locales/en.po index b958c8b02..6e753cb64 100644 --- a/web/src/locales/en.po +++ b/web/src/locales/en.po @@ -281,6 +281,10 @@ msgstr "Attributes" msgid "Audience" msgstr "Audience" +#: src/flows/sources/plex/PlexLoginInit.ts:56 +msgid "Authenticating with Plex..." +msgstr "Authenticating with Plex..." + #: src/pages/flows/FlowForm.ts:55 msgid "Authentication" msgstr "Authentication" diff --git a/web/src/locales/pseudo-LOCALE.po b/web/src/locales/pseudo-LOCALE.po index 117acd43b..9b2270490 100644 --- a/web/src/locales/pseudo-LOCALE.po +++ b/web/src/locales/pseudo-LOCALE.po @@ -277,6 +277,10 @@ msgstr "" msgid "Audience" msgstr "" +#: src/flows/sources/plex/PlexLoginInit.ts:56 +msgid "Authenticating with Plex..." +msgstr "" + #: src/pages/flows/FlowForm.ts:55 msgid "Authentication" msgstr "" From c012bed379fc84ff38cc43eec2a5d9494b3b1ff9 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Mon, 3 May 2021 22:13:23 +0200 Subject: [PATCH 10/10] web: bump CI pipeline to node 14 Signed-off-by: Jens Langhammer --- web/azure-pipelines.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web/azure-pipelines.yml b/web/azure-pipelines.yml index 4352991cf..4841452d9 100644 --- a/web/azure-pipelines.yml +++ b/web/azure-pipelines.yml @@ -18,7 +18,7 @@ stages: steps: - task: NodeTool@0 inputs: - versionSpec: '12.x' + versionSpec: '14.x' displayName: 'Install Node.js' - task: CmdLine@2 inputs: @@ -37,7 +37,7 @@ stages: steps: - task: NodeTool@0 inputs: - versionSpec: '12.x' + versionSpec: '14.x' displayName: 'Install Node.js' - task: DownloadPipelineArtifact@2 inputs: @@ -59,7 +59,7 @@ stages: steps: - task: NodeTool@0 inputs: - versionSpec: '12.x' + versionSpec: '14.x' displayName: 'Install Node.js' - task: DownloadPipelineArtifact@2 inputs: @@ -83,7 +83,7 @@ stages: steps: - task: NodeTool@0 inputs: - versionSpec: '12.x' + versionSpec: '14.x' displayName: 'Install Node.js' - task: DownloadPipelineArtifact@2 inputs: