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:
Jens Langhammer 2021-11-03 21:08:57 +01:00
parent 08eff4cc5d
commit 5374352411
12 changed files with 139 additions and 35 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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>`;
}), }),
)}`; )}`;
} }