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.`}

+
+
+
+
`; + } + +}