diff --git a/authentik/sources/plex/api.py b/authentik/sources/plex/api.py index 2a923d3837..6b91073388 100644 --- a/authentik/sources/plex/api.py +++ b/authentik/sources/plex/api.py @@ -9,14 +9,16 @@ from rest_framework.permissions import AllowAny from rest_framework.request import Request from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet +from structlog.stdlib import get_logger from authentik.api.decorators import permission_required from authentik.core.api.sources import SourceSerializer from authentik.core.api.utils import PassiveSerializer from authentik.flows.challenge import RedirectChallenge -from authentik.flows.views import to_stage_response from authentik.sources.plex.models import PlexSource -from authentik.sources.plex.plex import PlexAuth +from authentik.sources.plex.plex import PlexAuth, PlexSourceFlowManager + +LOGGER = get_logger() class PlexSourceSerializer(SourceSerializer): @@ -24,7 +26,13 @@ class PlexSourceSerializer(SourceSerializer): class Meta: model = PlexSource - fields = SourceSerializer.Meta.fields + ["client_id", "allowed_servers"] + fields = SourceSerializer.Meta.fields + [ + "client_id", + "allowed_servers", + "allow_friends", + "plex_token", + ] + extra_kwargs = {"plex_token": {"write_only": True}} class PlexTokenRedeemSerializer(PassiveSerializer): @@ -69,7 +77,29 @@ class PlexSourceViewSet(ModelViewSet): if not plex_token: raise Http404 auth_api = PlexAuth(source, plex_token) - if not auth_api.check_server_overlap(): + 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) + 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"], + ) + if not auth_api.check_server_overlap() or not friends_allowed: + LOGGER.warning( + "Denying plex auth because no server overlay and no friends", + user=user_info["username"], + ) raise Http404 - response = auth_api.get_user_url(request) - return to_stage_response(request, response) + sfm = PlexSourceFlowManager( + source=source, + request=request, + identifier=str(identifier), + enroll_info=user_info, + ) + return sfm.get_flow(plex_token=plex_token) diff --git a/authentik/sources/plex/migrations/0001_initial.py b/authentik/sources/plex/migrations/0001_initial.py index b1d99c80d0..8d04cc70a6 100644 --- a/authentik/sources/plex/migrations/0001_initial.py +++ b/authentik/sources/plex/migrations/0001_initial.py @@ -3,6 +3,7 @@ import django.contrib.postgres.fields import django.db.models.deletion from django.db import migrations, models + import authentik.providers.oauth2.generators diff --git a/authentik/sources/plex/migrations/0002_auto_20210505_1717.py b/authentik/sources/plex/migrations/0002_auto_20210505_1717.py new file mode 100644 index 0000000000..43869fd08e --- /dev/null +++ b/authentik/sources/plex/migrations/0002_auto_20210505_1717.py @@ -0,0 +1,42 @@ +# Generated by Django 3.2.1 on 2021-05-05 17:17 + +import django.contrib.postgres.fields +from django.db import migrations, models + +import authentik.providers.oauth2.generators + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_sources_plex", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="plexsource", + name="allow_friends", + field=models.BooleanField( + default=True, + help_text="Allow friends to authenticate, even if you don't share a server.", + ), + ), + migrations.AddField( + model_name="plexsource", + name="plex_token", + field=models.TextField( + default="", help_text="Plex token used to check firends" + ), + ), + migrations.AlterField( + model_name="plexsource", + name="allowed_servers", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.TextField(), + blank=True, + default=list, + help_text="Which servers a user has to be a member of to be granted access. Empty list allows every server.", + size=None, + ), + ), + ] diff --git a/authentik/sources/plex/models.py b/authentik/sources/plex/models.py index 1feed40a9d..ed87502a9a 100644 --- a/authentik/sources/plex/models.py +++ b/authentik/sources/plex/models.py @@ -29,6 +29,7 @@ class PlexSource(Source): allowed_servers = ArrayField( models.TextField(), default=list, + blank=True, help_text=_( ( "Which servers a user has to be a member of to be granted access. " @@ -36,6 +37,13 @@ class PlexSource(Source): ) ), ) + allow_friends = models.BooleanField( + default=True, + help_text=_("Allow friends to authenticate, even if you don't share a server."), + ) + plex_token = models.TextField( + default="", help_text=_("Plex token used to check firends") + ) @property def component(self) -> str: diff --git a/authentik/sources/plex/plex.py b/authentik/sources/plex/plex.py index dc9c52d16c..697d6f9093 100644 --- a/authentik/sources/plex/plex.py +++ b/authentik/sources/plex/plex.py @@ -1,8 +1,7 @@ """Plex Views""" from urllib.parse import urlencode -from django.http.request import HttpRequest -from django.http.response import Http404, HttpResponse +from django.http.response import Http404 from requests import Session from requests.exceptions import RequestException from structlog.stdlib import get_logger @@ -52,6 +51,18 @@ class PlexAuth: response.raise_for_status() return response.json() + def get_friends(self) -> list[dict]: + """Get plex friends""" + qs = { + "X-Plex-Token": self._token, + "X-Plex-Client-Identifier": self._source.client_id, + } + response = self._session.get( + f"https://plex.tv/api/v2/friends?{urlencode(qs)}", + ) + response.raise_for_status() + return response.json() + def get_user_info(self) -> tuple[dict, int]: """Get user info of the plex token""" qs = { @@ -87,17 +98,6 @@ class PlexAuth: return True return False - def get_user_url(self, request: HttpRequest) -> HttpResponse: - """Get a URL to a flow executor for either enrollment or authentication""" - user_info, identifier = self.get_user_info() - sfm = PlexSourceFlowManager( - source=self._source, - request=request, - identifier=str(identifier), - enroll_info=user_info, - ) - return sfm.get_flow(plex_token=self._token) - class PlexSourceFlowManager(SourceFlowManager): """Flow manager for plex sources""" diff --git a/swagger.yaml b/swagger.yaml index 506ea08684..9a04899839 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -18168,6 +18168,15 @@ definitions: title: Allowed servers type: string minLength: 1 + allow_friends: + title: Allow friends + description: Allow friends to authenticate, even if you don't share a server. + type: boolean + plex_token: + title: Plex token + description: Plex token used to check firends + type: string + minLength: 1 PlexTokenRedeem: required: - plex_token diff --git a/web/src/pages/sources/plex/PlexSourceForm.ts b/web/src/pages/sources/plex/PlexSourceForm.ts index 45e582c5c8..69b98d35b2 100644 --- a/web/src/pages/sources/plex/PlexSourceForm.ts +++ b/web/src/pages/sources/plex/PlexSourceForm.ts @@ -20,6 +20,7 @@ export class PlexSourceForm extends Form { slug: value, }).then(source => { this.source = source; + this.plexToken = source.plexToken; }); } @@ -43,6 +44,7 @@ export class PlexSourceForm extends Form { } send = (data: PlexSource): Promise => { + data.plexToken = this.plexToken; if (this.source.slug) { return new SourcesApi(DEFAULT_CONFIG).sourcesPlexUpdate({ slug: this.source.slug, @@ -128,6 +130,14 @@ export class PlexSourceForm extends Form { name="clientId"> + +
+ + +
+