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.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 a new issue