sources/plex: allow users to connect their plex account without login flow
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
		| @ -5,7 +5,7 @@ from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_sche | |||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.exceptions import PermissionDenied | from rest_framework.exceptions import PermissionDenied | ||||||
| from rest_framework.fields import CharField | 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.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import ValidationError | 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.core.api.utils import PassiveSerializer | ||||||
| from authentik.flows.challenge import RedirectChallenge | from authentik.flows.challenge import RedirectChallenge | ||||||
| from authentik.flows.views.executor import to_stage_response | 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 | from authentik.sources.plex.plex import PlexAuth, PlexSourceFlowManager | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
| @ -98,21 +98,11 @@ class PlexSourceViewSet(UsedByMixin, ModelViewSet): | |||||||
|         user_info, identifier = auth_api.get_user_info() |         user_info, identifier = auth_api.get_user_info() | ||||||
|         # Check friendship first, then check server overlay |         # Check friendship first, then check server overlay | ||||||
|         friends_allowed = False |         friends_allowed = False | ||||||
|         owner_id = None |  | ||||||
|         if source.allow_friends: |         if source.allow_friends: | ||||||
|             owner_api = PlexAuth(source, source.plex_token) |             owner_api = PlexAuth(source, source.plex_token) | ||||||
|             owner_id = owner_api.get_user_info |             friends_allowed = owner_api.check_friends_overlap(identifier) | ||||||
|             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"], |  | ||||||
|                     ) |  | ||||||
|         servers_allowed = auth_api.check_server_overlap() |         servers_allowed = auth_api.check_server_overlap() | ||||||
|         owner_allowed = owner_id == identifier |         if any([friends_allowed, servers_allowed]): | ||||||
|         if any([friends_allowed, servers_allowed, owner_allowed]): |  | ||||||
|             sfm = PlexSourceFlowManager( |             sfm = PlexSourceFlowManager( | ||||||
|                 source=source, |                 source=source, | ||||||
|                 request=request, |                 request=request, | ||||||
| @ -125,3 +115,57 @@ class PlexSourceViewSet(UsedByMixin, ModelViewSet): | |||||||
|             user=user_info["username"], |             user=user_info["username"], | ||||||
|         ) |         ) | ||||||
|         raise PermissionDenied("Access denied.") |         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.") | ||||||
|  | |||||||
| @ -83,6 +83,7 @@ class PlexSource(Source): | |||||||
|             data={ |             data={ | ||||||
|                 "title": f"Plex {self.name}", |                 "title": f"Plex {self.name}", | ||||||
|                 "component": "ak-user-settings-source-plex", |                 "component": "ak-user-settings-source-plex", | ||||||
|  |                 "configure_url": self.client_id, | ||||||
|             } |             } | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  | |||||||
| @ -36,7 +36,7 @@ class PlexAuth: | |||||||
|         return { |         return { | ||||||
|             "X-Plex-Product": "authentik", |             "X-Plex-Product": "authentik", | ||||||
|             "X-Plex-Version": __version__, |             "X-Plex-Version": __version__, | ||||||
|             "X-Plex-Device-Vendor": "BeryJu.org", |             "X-Plex-Device-Vendor": "goauthentik.io", | ||||||
|         } |         } | ||||||
|  |  | ||||||
|     def get_resources(self) -> list[dict]: |     def get_resources(self) -> list[dict]: | ||||||
| @ -96,6 +96,21 @@ class PlexAuth: | |||||||
|                     return True |                     return True | ||||||
|         return False |         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): | class PlexSourceFlowManager(SourceFlowManager): | ||||||
|     """Flow manager for plex sources""" |     """Flow manager for plex sources""" | ||||||
|  | |||||||
							
								
								
									
										41
									
								
								schema.yml
									
									
									
									
									
								
							
							
						
						
									
										41
									
								
								schema.yml
									
									
									
									
									
								
							| @ -11884,21 +11884,6 @@ paths: | |||||||
|           $ref: '#/components/schemas/ValidationError' |           $ref: '#/components/schemas/ValidationError' | ||||||
|         '403': |         '403': | ||||||
|           $ref: '#/components/schemas/GenericError' |           $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/: |   /sources/all/: | ||||||
|     get: |     get: | ||||||
|       operationId: sources_all_list |       operationId: sources_all_list | ||||||
| @ -12979,6 +12964,32 @@ paths: | |||||||
|           description: Token not found |           description: Token not found | ||||||
|         '403': |         '403': | ||||||
|           description: Access denied |           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/: |   /sources/saml/: | ||||||
|     get: |     get: | ||||||
|       operationId: sources_saml_list |       operationId: sources_saml_list | ||||||
|  | |||||||
| @ -1,4 +1,4 @@ | |||||||
| import { VERSION } from "../../../constants"; | import { VERSION } from "../constants"; | ||||||
| 
 | 
 | ||||||
| export interface PlexPinResponse { | export interface PlexPinResponse { | ||||||
|     // Only has the fields we care about
 |     // 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}`, { |         const pinResponse = await fetch(`https://plex.tv/api/v2/pins/${id}`, { | ||||||
|             headers: headers, |             headers: headers, | ||||||
|         }); |         }); | ||||||
|  |         if (pinResponse.status > 200) { | ||||||
|  |             throw new Error("Invalid response code") | ||||||
|  |         } | ||||||
|         const pin: PlexPinResponse = await pinResponse.json(); |         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<string> { |     static async pinPoll(clientIdentifier: string, id: number): Promise<string> { | ||||||
| @ -19,10 +19,10 @@ import { | |||||||
| import { SourcesApi } from "@goauthentik/api"; | import { SourcesApi } from "@goauthentik/api"; | ||||||
|  |  | ||||||
| import { DEFAULT_CONFIG } from "../../../api/Config"; | import { DEFAULT_CONFIG } from "../../../api/Config"; | ||||||
|  | import { PlexAPIClient, popupCenterScreen } from "../../../api/Plex"; | ||||||
| import { MessageLevel } from "../../../elements/messages/Message"; | import { MessageLevel } from "../../../elements/messages/Message"; | ||||||
| import { showMessage } from "../../../elements/messages/MessageContainer"; | import { showMessage } from "../../../elements/messages/MessageContainer"; | ||||||
| import { BaseStage } from "../../stages/base"; | import { BaseStage } from "../../stages/base"; | ||||||
| import { PlexAPIClient, popupCenterScreen } from "./API"; |  | ||||||
|  |  | ||||||
| @customElement("ak-flow-sources-plex") | @customElement("ak-flow-sources-plex") | ||||||
| export class PlexLoginInit extends BaseStage< | export class PlexLoginInit extends BaseStage< | ||||||
|  | |||||||
| @ -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." | 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/SourceSettingsOAuth.ts | ||||||
|  | #: src/user/user-settings/sources/SourceSettingsPlex.ts | ||||||
| msgid "Connect" | msgid "Connect" | ||||||
| msgstr "Connect" | msgstr "Connect" | ||||||
|  |  | ||||||
|  | |||||||
| @ -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." | 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/SourceSettingsOAuth.ts | ||||||
|  | #: src/user/user-settings/sources/SourceSettingsPlex.ts | ||||||
| msgid "Connect" | msgid "Connect" | ||||||
| msgstr "Connecter" | msgstr "Connecter" | ||||||
|  |  | ||||||
|  | |||||||
| @ -925,6 +925,7 @@ msgid "Configure what data should be used as unique User Identifier. For most ca | |||||||
| msgstr "" | msgstr "" | ||||||
|  |  | ||||||
| #: src/user/user-settings/sources/SourceSettingsOAuth.ts | #: src/user/user-settings/sources/SourceSettingsOAuth.ts | ||||||
|  | #: src/user/user-settings/sources/SourceSettingsPlex.ts | ||||||
| msgid "Connect" | msgid "Connect" | ||||||
| msgstr "" | msgstr "" | ||||||
|  |  | ||||||
|  | |||||||
| @ -14,10 +14,10 @@ import { | |||||||
| } from "@goauthentik/api"; | } from "@goauthentik/api"; | ||||||
|  |  | ||||||
| import { DEFAULT_CONFIG } from "../../../api/Config"; | import { DEFAULT_CONFIG } from "../../../api/Config"; | ||||||
|  | import { PlexAPIClient, PlexResource, popupCenterScreen } from "../../../api/Plex"; | ||||||
| import "../../../elements/forms/FormGroup"; | import "../../../elements/forms/FormGroup"; | ||||||
| import "../../../elements/forms/HorizontalFormElement"; | import "../../../elements/forms/HorizontalFormElement"; | ||||||
| import { ModelForm } from "../../../elements/forms/ModelForm"; | import { ModelForm } from "../../../elements/forms/ModelForm"; | ||||||
| import { PlexAPIClient, PlexResource, popupCenterScreen } from "../../../flows/sources/plex/API"; |  | ||||||
| import { first, randomString } from "../../../utils"; | import { first, randomString } from "../../../utils"; | ||||||
|  |  | ||||||
| @customElement("ak-source-plex-form") | @customElement("ak-source-plex-form") | ||||||
|  | |||||||
| @ -47,6 +47,7 @@ export class UserSourceSettingsPage extends LitElement { | |||||||
|                 return html`<ak-user-settings-source-plex |                 return html`<ak-user-settings-source-plex | ||||||
|                     objectId=${source.objectUid} |                     objectId=${source.objectUid} | ||||||
|                     title=${source.title} |                     title=${source.title} | ||||||
|  |                     .configureUrl=${source.configureUrl} | ||||||
|                 > |                 > | ||||||
|                 </ak-user-settings-source-plex>`; |                 </ak-user-settings-source-plex>`; | ||||||
|             default: |             default: | ||||||
|  | |||||||
| @ -7,6 +7,8 @@ import { until } from "lit/directives/until"; | |||||||
| import { SourcesApi } from "@goauthentik/api"; | import { SourcesApi } from "@goauthentik/api"; | ||||||
|  |  | ||||||
| import { DEFAULT_CONFIG } from "../../../api/Config"; | import { DEFAULT_CONFIG } from "../../../api/Config"; | ||||||
|  | import { PlexAPIClient, popupCenterScreen } from "../../../api/Plex"; | ||||||
|  | import { EVENT_REFRESH } from "../../../constants"; | ||||||
| import { BaseUserSettings } from "../BaseUserSettings"; | import { BaseUserSettings } from "../BaseUserSettings"; | ||||||
|  |  | ||||||
| @customElement("ak-user-settings-source-plex") | @customElement("ak-user-settings-source-plex") | ||||||
| @ -21,6 +23,26 @@ export class SourceSettingsPlex extends BaseUserSettings { | |||||||
|         </div>`; |         </div>`; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     async doPlex(): Promise<void> { | ||||||
|  |         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 { |     renderInner(): TemplateResult { | ||||||
|         return html`${until( |         return html`${until( | ||||||
|             new SourcesApi(DEFAULT_CONFIG) |             new SourcesApi(DEFAULT_CONFIG) | ||||||
| @ -43,7 +65,10 @@ export class SourceSettingsPlex extends BaseUserSettings { | |||||||
|                                 ${t`Disconnect`} |                                 ${t`Disconnect`} | ||||||
|                             </button>`; |                             </button>`; | ||||||
|                     } |                     } | ||||||
|                     return html`<p>${t`Not connected.`}</p>`; |                     return html`<p>${t`Not connected.`}</p> | ||||||
|  |                         <button @click=${this.doPlex} class="pf-c-button pf-m-primary"> | ||||||
|  |                             ${t`Connect`} | ||||||
|  |                         </button>`; | ||||||
|                 }), |                 }), | ||||||
|         )}`; |         )}`; | ||||||
|     } |     } | ||||||
|  | |||||||
		Reference in New Issue
	
	Block a user
	 Jens Langhammer
					Jens Langhammer