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:
parent
08eff4cc5d
commit
5374352411
|
@ -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.")
|
||||
|
|
|
@ -83,6 +83,7 @@ class PlexSource(Source):
|
|||
data={
|
||||
"title": f"Plex {self.name}",
|
||||
"component": "ak-user-settings-source-plex",
|
||||
"configure_url": self.client_id,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
@ -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"""
|
||||
|
|
41
schema.yml
41
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
|
||||
|
|
|
@ -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<string> {
|
|
@ -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<
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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 ""
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -47,6 +47,7 @@ export class UserSourceSettingsPage extends LitElement {
|
|||
return html`<ak-user-settings-source-plex
|
||||
objectId=${source.objectUid}
|
||||
title=${source.title}
|
||||
.configureUrl=${source.configureUrl}
|
||||
>
|
||||
</ak-user-settings-source-plex>`;
|
||||
default:
|
||||
|
|
|
@ -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 {
|
|||
</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 {
|
||||
return html`${until(
|
||||
new SourcesApi(DEFAULT_CONFIG)
|
||||
|
@ -43,7 +65,10 @@ export class SourceSettingsPlex extends BaseUserSettings {
|
|||
${t`Disconnect`}
|
||||
</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 a new issue