From 5374352411b8e529db802315f695027e64ca762c Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Wed, 3 Nov 2021 21:08:57 +0100 Subject: [PATCH] sources/plex: allow users to connect their plex account without login flow Signed-off-by: Jens Langhammer --- authentik/sources/plex/api/source.py | 72 +++++++++++++++---- authentik/sources/plex/models.py | 1 + authentik/sources/plex/plex.py | 17 ++++- schema.yml | 41 +++++++---- .../sources/plex/API.ts => api/Plex.ts} | 8 ++- web/src/flows/sources/plex/PlexLoginInit.ts | 2 +- web/src/locales/en.po | 1 + web/src/locales/fr_FR.po | 1 + web/src/locales/pseudo-LOCALE.po | 1 + web/src/pages/sources/plex/PlexSourceForm.ts | 2 +- .../user-settings/sources/SourceSettings.ts | 1 + .../sources/SourceSettingsPlex.ts | 27 ++++++- 12 files changed, 139 insertions(+), 35 deletions(-) rename web/src/{flows/sources/plex/API.ts => api/Plex.ts} (93%) diff --git a/authentik/sources/plex/api/source.py b/authentik/sources/plex/api/source.py index e83863423..4e14e51d6 100644 --- a/authentik/sources/plex/api/source.py +++ b/authentik/sources/plex/api/source.py @@ -5,7 +5,7 @@ from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_sche from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied from rest_framework.fields import CharField -from rest_framework.permissions import AllowAny +from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response from rest_framework.serializers import ValidationError @@ -18,7 +18,7 @@ from authentik.core.api.used_by import UsedByMixin from authentik.core.api.utils import PassiveSerializer from authentik.flows.challenge import RedirectChallenge from authentik.flows.views.executor import to_stage_response -from authentik.sources.plex.models import PlexSource +from authentik.sources.plex.models import PlexSource, PlexSourceConnection from authentik.sources.plex.plex import PlexAuth, PlexSourceFlowManager LOGGER = get_logger() @@ -98,21 +98,11 @@ class PlexSourceViewSet(UsedByMixin, ModelViewSet): user_info, identifier = auth_api.get_user_info() # Check friendship first, then check server overlay friends_allowed = False - owner_id = None if source.allow_friends: owner_api = PlexAuth(source, source.plex_token) - owner_id = owner_api.get_user_info - owner_friends = owner_api.get_friends() - for friend in owner_friends: - if int(friend.get("id", "0")) == int(identifier): - friends_allowed = True - LOGGER.info( - "allowing user for plex because of friend", - user=user_info["username"], - ) + friends_allowed = owner_api.check_friends_overlap(identifier) servers_allowed = auth_api.check_server_overlap() - owner_allowed = owner_id == identifier - if any([friends_allowed, servers_allowed, owner_allowed]): + if any([friends_allowed, servers_allowed]): sfm = PlexSourceFlowManager( source=source, request=request, @@ -125,3 +115,57 @@ class PlexSourceViewSet(UsedByMixin, ModelViewSet): user=user_info["username"], ) raise PermissionDenied("Access denied.") + + @extend_schema( + request=PlexTokenRedeemSerializer(), + responses={ + 204: OpenApiResponse(), + 400: OpenApiResponse(description="Token not found"), + 403: OpenApiResponse(description="Access denied"), + }, + parameters=[ + OpenApiParameter( + name="slug", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.STR, + ) + ], + ) + @action( + methods=["POST"], + detail=False, + pagination_class=None, + filter_backends=[], + permission_classes=[IsAuthenticated], + ) + def redeem_token_authenticated(self, request: Request) -> Response: + """Redeem a plex token for an authenticated user, creating a connection""" + 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 ValidationError("No plex token given") + auth_api = PlexAuth(source, plex_token) + user_info, identifier = auth_api.get_user_info() + # Check friendship first, then check server overlay + friends_allowed = False + if source.allow_friends: + owner_api = PlexAuth(source, source.plex_token) + friends_allowed = owner_api.check_friends_overlap(identifier) + servers_allowed = auth_api.check_server_overlap() + if any([friends_allowed, servers_allowed]): + PlexSourceConnection.objects.create( + plex_token=plex_token, + user=request.user, + identifier=identifier, + source=source, + ) + return Response(status=204) + LOGGER.warning( + "Denying plex connection because no server overlay and no friends and not owner", + user=user_info["username"], + friends_allowed=friends_allowed, + servers_allowed=servers_allowed, + ) + raise PermissionDenied("Access denied.") diff --git a/authentik/sources/plex/models.py b/authentik/sources/plex/models.py index 6a1d91583..6ccb0293a 100644 --- a/authentik/sources/plex/models.py +++ b/authentik/sources/plex/models.py @@ -83,6 +83,7 @@ class PlexSource(Source): data={ "title": f"Plex {self.name}", "component": "ak-user-settings-source-plex", + "configure_url": self.client_id, } ) diff --git a/authentik/sources/plex/plex.py b/authentik/sources/plex/plex.py index f1f91e395..29b7c622a 100644 --- a/authentik/sources/plex/plex.py +++ b/authentik/sources/plex/plex.py @@ -36,7 +36,7 @@ class PlexAuth: return { "X-Plex-Product": "authentik", "X-Plex-Version": __version__, - "X-Plex-Device-Vendor": "BeryJu.org", + "X-Plex-Device-Vendor": "goauthentik.io", } def get_resources(self) -> list[dict]: @@ -96,6 +96,21 @@ class PlexAuth: return True return False + def check_friends_overlap(self, user_ident: int) -> bool: + """Check if the user is a friend of the owner, or the owner themselves""" + friends_allowed = False + _, owner_id = self.get_user_info() + owner_friends = self.get_friends() + for friend in owner_friends: + if int(friend.get("id", "0")) == user_ident: + friends_allowed = True + LOGGER.info( + "allowing user for plex because of friend", + user=user_ident, + ) + owner_allowed = owner_id == user_ident + return any([friends_allowed, owner_allowed]) + class PlexSourceFlowManager(SourceFlowManager): """Flow manager for plex sources""" diff --git a/schema.yml b/schema.yml index 6aa838fd3..e856e624c 100644 --- a/schema.yml +++ b/schema.yml @@ -11884,21 +11884,6 @@ paths: $ref: '#/components/schemas/ValidationError' '403': $ref: '#/components/schemas/GenericError' - /sentry/: - post: - operationId: sentry_create - description: Sentry tunnel, to prevent ad blockers from blocking sentry - tags: - - sentry - security: - - {} - responses: - '200': - description: No response body - '400': - $ref: '#/components/schemas/ValidationError' - '403': - $ref: '#/components/schemas/GenericError' /sources/all/: get: operationId: sources_all_list @@ -12979,6 +12964,32 @@ paths: description: Token not found '403': description: Access denied + /sources/plex/redeem_token_authenticated/: + post: + operationId: sources_plex_redeem_token_authenticated_create + description: Redeem a plex token for an authenticated user, creating a connection + parameters: + - in: query + name: slug + schema: + type: string + tags: + - sources + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PlexTokenRedeemRequest' + required: true + security: + - authentik: [] + responses: + '204': + description: No response body + '400': + description: Token not found + '403': + description: Access denied /sources/saml/: get: operationId: sources_saml_list diff --git a/web/src/flows/sources/plex/API.ts b/web/src/api/Plex.ts similarity index 93% rename from web/src/flows/sources/plex/API.ts rename to web/src/api/Plex.ts index 1fdc20160..d619d24e6 100644 --- a/web/src/flows/sources/plex/API.ts +++ b/web/src/api/Plex.ts @@ -1,4 +1,4 @@ -import { VERSION } from "../../../constants"; +import { VERSION } from "../constants"; export interface PlexPinResponse { // Only has the fields we care about @@ -72,8 +72,12 @@ export class PlexAPIClient { const pinResponse = await fetch(`https://plex.tv/api/v2/pins/${id}`, { headers: headers, }); + if (pinResponse.status > 200) { + throw new Error("Invalid response code") + } const pin: PlexPinResponse = await pinResponse.json(); - return pin.authToken || ""; + console.debug(`authentik/plex: polling Pin`); + return pin.authToken; } static async pinPoll(clientIdentifier: string, id: number): Promise { diff --git a/web/src/flows/sources/plex/PlexLoginInit.ts b/web/src/flows/sources/plex/PlexLoginInit.ts index 2144cc8c9..c4ad53f5d 100644 --- a/web/src/flows/sources/plex/PlexLoginInit.ts +++ b/web/src/flows/sources/plex/PlexLoginInit.ts @@ -19,10 +19,10 @@ import { import { SourcesApi } from "@goauthentik/api"; import { DEFAULT_CONFIG } from "../../../api/Config"; +import { PlexAPIClient, popupCenterScreen } from "../../../api/Plex"; import { MessageLevel } from "../../../elements/messages/Message"; import { showMessage } from "../../../elements/messages/MessageContainer"; import { BaseStage } from "../../stages/base"; -import { PlexAPIClient, popupCenterScreen } from "./API"; @customElement("ak-flow-sources-plex") export class PlexLoginInit extends BaseStage< diff --git a/web/src/locales/en.po b/web/src/locales/en.po index ec5155927..916db56d7 100644 --- a/web/src/locales/en.po +++ b/web/src/locales/en.po @@ -931,6 +931,7 @@ msgid "Configure what data should be used as unique User Identifier. For most ca msgstr "Configure what data should be used as unique User Identifier. For most cases, the default should be fine." #: src/user/user-settings/sources/SourceSettingsOAuth.ts +#: src/user/user-settings/sources/SourceSettingsPlex.ts msgid "Connect" msgstr "Connect" diff --git a/web/src/locales/fr_FR.po b/web/src/locales/fr_FR.po index 2f25fa47c..e19a0a461 100644 --- a/web/src/locales/fr_FR.po +++ b/web/src/locales/fr_FR.po @@ -929,6 +929,7 @@ msgid "Configure what data should be used as unique User Identifier. For most ca msgstr "Configure quelle donnée utiliser pour l'identifiant unique utilisateur. La valeur par défaut devrait être correcte dans la plupart des cas." #: src/user/user-settings/sources/SourceSettingsOAuth.ts +#: src/user/user-settings/sources/SourceSettingsPlex.ts msgid "Connect" msgstr "Connecter" diff --git a/web/src/locales/pseudo-LOCALE.po b/web/src/locales/pseudo-LOCALE.po index 44eb77cc3..97d8204fc 100644 --- a/web/src/locales/pseudo-LOCALE.po +++ b/web/src/locales/pseudo-LOCALE.po @@ -925,6 +925,7 @@ msgid "Configure what data should be used as unique User Identifier. For most ca msgstr "" #: src/user/user-settings/sources/SourceSettingsOAuth.ts +#: src/user/user-settings/sources/SourceSettingsPlex.ts msgid "Connect" msgstr "" diff --git a/web/src/pages/sources/plex/PlexSourceForm.ts b/web/src/pages/sources/plex/PlexSourceForm.ts index 2f23294df..08f7eaea0 100644 --- a/web/src/pages/sources/plex/PlexSourceForm.ts +++ b/web/src/pages/sources/plex/PlexSourceForm.ts @@ -14,10 +14,10 @@ import { } from "@goauthentik/api"; import { DEFAULT_CONFIG } from "../../../api/Config"; +import { PlexAPIClient, PlexResource, popupCenterScreen } from "../../../api/Plex"; import "../../../elements/forms/FormGroup"; import "../../../elements/forms/HorizontalFormElement"; import { ModelForm } from "../../../elements/forms/ModelForm"; -import { PlexAPIClient, PlexResource, popupCenterScreen } from "../../../flows/sources/plex/API"; import { first, randomString } from "../../../utils"; @customElement("ak-source-plex-form") diff --git a/web/src/user/user-settings/sources/SourceSettings.ts b/web/src/user/user-settings/sources/SourceSettings.ts index 4a7a20c87..9929376a9 100644 --- a/web/src/user/user-settings/sources/SourceSettings.ts +++ b/web/src/user/user-settings/sources/SourceSettings.ts @@ -47,6 +47,7 @@ export class UserSourceSettingsPage extends LitElement { return html` `; default: diff --git a/web/src/user/user-settings/sources/SourceSettingsPlex.ts b/web/src/user/user-settings/sources/SourceSettingsPlex.ts index 292d2ad47..d740d2154 100644 --- a/web/src/user/user-settings/sources/SourceSettingsPlex.ts +++ b/web/src/user/user-settings/sources/SourceSettingsPlex.ts @@ -7,6 +7,8 @@ import { until } from "lit/directives/until"; import { SourcesApi } from "@goauthentik/api"; import { DEFAULT_CONFIG } from "../../../api/Config"; +import { PlexAPIClient, popupCenterScreen } from "../../../api/Plex"; +import { EVENT_REFRESH } from "../../../constants"; import { BaseUserSettings } from "../BaseUserSettings"; @customElement("ak-user-settings-source-plex") @@ -21,6 +23,26 @@ export class SourceSettingsPlex extends BaseUserSettings { `; } + async doPlex(): Promise { + const authInfo = await PlexAPIClient.getPin(this.configureUrl || ""); + const authWindow = popupCenterScreen(authInfo.authUrl, "plex auth", 550, 700); + PlexAPIClient.pinPoll(this.configureUrl || "", authInfo.pin.id).then((token) => { + authWindow?.close(); + new SourcesApi(DEFAULT_CONFIG).sourcesPlexRedeemTokenAuthenticatedCreate({ + plexTokenRedeemRequest: { + plexToken: token, + }, + slug: this.objectId, + }); + }); + this.dispatchEvent( + new CustomEvent(EVENT_REFRESH, { + bubbles: true, + composed: true, + }), + ); + } + renderInner(): TemplateResult { return html`${until( new SourcesApi(DEFAULT_CONFIG) @@ -43,7 +65,10 @@ export class SourceSettingsPlex extends BaseUserSettings { ${t`Disconnect`} `; } - return html`

${t`Not connected.`}

`; + return html`

${t`Not connected.`}

+ `; }), )}`; }