From 9398d1e258cbfb6fa887cf9ee59e743275144912 Mon Sep 17 00:00:00 2001 From: James Ward Date: Wed, 3 Apr 2024 18:26:43 -0400 Subject: [PATCH 1/3] feat(admin): support fordwardAuth user headers --- admin/src/portr_admin/apis/security.py | 27 +++++++++++++++++++++++--- admin/src/portr_admin/config.py | 6 ++++-- admin/src/portr_admin/main.py | 14 +++++-------- 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/admin/src/portr_admin/apis/security.py b/admin/src/portr_admin/apis/security.py index c6facf86..eac41caa 100644 --- a/admin/src/portr_admin/apis/security.py +++ b/admin/src/portr_admin/apis/security.py @@ -1,18 +1,39 @@ -from typing import Annotated -from fastapi import Cookie, Depends, Header +from fastapi import Depends, Header, Request from portr_admin.models.auth import Session from portr_admin.models.user import Role, TeamUser, User +from portr_admin.services.user import get_or_create_user, is_user_active from portr_admin.utils.exception import PermissionDenied +from portr_admin.config import settings class NotAuthenticated(Exception): pass +def get_proxy_auth_email(request: Request) -> str | None: + header = settings.remote_user_header + + if header is None: + return None + + return request.headers.get(header) + async def get_current_user( - portr_session: Annotated[str | None, Cookie()] = None, + request: Request ) -> User: + proxy_auth_email = get_proxy_auth_email(request) + + if proxy_auth_email: + proxy_auth_user = await get_or_create_user(proxy_auth_email) + + if not is_user_active(proxy_auth_user): + raise NotAuthenticated + + return proxy_auth_user + + portr_session = request.cookies.get("portr_session") + if portr_session is None: raise NotAuthenticated diff --git a/admin/src/portr_admin/config.py b/admin/src/portr_admin/config.py index 81387aab..4aafdd3b 100644 --- a/admin/src/portr_admin/config.py +++ b/admin/src/portr_admin/config.py @@ -9,8 +9,10 @@ class Settings(BaseSettings): use_vite: bool = Field(default=False, alias="PORTR_ADMIN_USE_VITE") encryption_key: str = Field(alias="PORTR_ADMIN_ENCRYPTION_KEY") - github_client_id: str = Field(alias="PORTR_ADMIN_GITHUB_CLIENT_ID") - github_client_secret: str = Field(alias="PORTR_ADMIN_GITHUB_CLIENT_SECRET") + github_client_id: str = Field(alias="PORTR_ADMIN_GITHUB_CLIENT_ID", default="") + github_client_secret: str = Field(alias="PORTR_ADMIN_GITHUB_CLIENT_SECRET", default="") + + remote_user_header: str = Field(alias="PORTR_ADMIN_REMOTE_USER_HEADER", default="") server_url: str ssh_url: str diff --git a/admin/src/portr_admin/main.py b/admin/src/portr_admin/main.py index 777110a3..81eb4ea8 100644 --- a/admin/src/portr_admin/main.py +++ b/admin/src/portr_admin/main.py @@ -1,5 +1,4 @@ -from typing import Annotated -from fastapi import Cookie, FastAPI, Request +from fastapi import FastAPI, Request from fastapi.responses import JSONResponse, RedirectResponse from portr_admin.apis import api as api_v1 from apscheduler.schedulers.asyncio import AsyncIOScheduler # type: ignore @@ -41,11 +40,10 @@ async def lifespan(app: FastAPI): @app.get("/") async def render_index_template( - request: Request, - portr_session: Annotated[str | None, Cookie()] = None, + request: Request ): try: - user: User = await get_current_user(portr_session) + user: User = await get_current_user(request) except NotAuthenticated: return templates.TemplateResponse( request=request, @@ -67,10 +65,9 @@ async def render_index_template( @app.get("/instance-settings/{rest:path}") async def render_index_template_for_instance_settings_routes( request: Request, - portr_session: Annotated[str | None, Cookie()] = None, ): try: - user: User = await get_current_user(portr_session) + user: User = await get_current_user(request) except NotAuthenticated: next_url = request.url.path + "?" + request.url.query next_url_encoded = urllib.parse.urlencode({"next": next_url}) @@ -98,10 +95,9 @@ async def render_index_template_for_instance_settings_routes( async def render_index_template_for_team_routes( request: Request, team: str, - portr_session: Annotated[str | None, Cookie()] = None, ): try: - user: User = await get_current_user(portr_session) + user: User = await get_current_user(request) except NotAuthenticated: next_url = request.url.path + "?" + request.url.query next_url_encoded = urllib.parse.urlencode({"next": next_url}) From b51d8db46adda7ed7ee65213a606022e8230c4a7 Mon Sep 17 00:00:00 2001 From: James Ward Date: Sat, 6 Apr 2024 10:10:27 -0400 Subject: [PATCH 2/3] test(admin): add tests for forward auth --- admin/src/portr_admin/tests/test_pages.py | 40 +++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/admin/src/portr_admin/tests/test_pages.py b/admin/src/portr_admin/tests/test_pages.py index 055ee6fc..c6dbc9ef 100644 --- a/admin/src/portr_admin/tests/test_pages.py +++ b/admin/src/portr_admin/tests/test_pages.py @@ -1,6 +1,8 @@ +from unittest.mock import patch from portr_admin.tests import TestClient from tortoise.contrib import test from portr_admin.tests.factories import TeamUserFactory, UserFactory +from portr_admin.config import settings class PageTests(test.TestCase): @@ -76,3 +78,41 @@ async def test_root_page_logged_in_with_teams_should_redirect_to_first_team_over resp = self.team_user_auth_client.get("/", follow_redirects=False) assert resp.status_code == 307 assert resp.headers["location"] == f"/{self.team_user.team.slug}/overview" + + +class PageForwardAuthTests(test.TruncationTestCase): + async def asyncSetUp(self) -> None: + await super().asyncSetUp() + self.client = await TestClient.get_client() + self.user = await UserFactory.create() + self.superuser_user = await UserFactory.create(is_superuser=True) + + def test_new_team_page_logged_in_superuser_via_header_should_pass(self): + with patch.object(settings, "remote_user_header", "X-Remote-User"): + resp = self.client.get( + "/instance-settings/team", + follow_redirects=False, + headers={"X-Remote-User": self.superuser_user.email} + ) + assert resp.status_code == 200 + + def test_new_team_page_logged_in_user_via_header_should_pass(self): + with patch.object(settings, "remote_user_header", "X-Remote-User"): + resp = self.client.get( + "/instance-settings/team", + follow_redirects=False, + headers={"X-Remote-User": self.user.email} + ) + assert resp.status_code == 307 + assert resp.headers["location"] == "/" + + def test_new_team_page_logged_in_invalid_via_header_should_error(self): + with patch.object(settings, "remote_user_header", "X-Remote-User"): + resp = self.client.get( + "/instance-settings/team", + follow_redirects=False, + headers={"X-Remote-User": "does-not-exist@example.com"} + ) + + assert resp.status_code == 400 + assert resp.json() == {"message": "User does not exist"} From d826a8dd1a09155195b698c9e0aae0d9e866bbf8 Mon Sep 17 00:00:00 2001 From: James Ward Date: Sat, 6 Apr 2024 12:59:08 -0400 Subject: [PATCH 3/3] docs(admin): add information on how to activate forward auth proxy --- .../docs/server/start-the-tunnel-server.md | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/src/content/docs/server/start-the-tunnel-server.md b/docs/src/content/docs/server/start-the-tunnel-server.md index a37ddc74..810141f1 100644 --- a/docs/src/content/docs/server/start-the-tunnel-server.md +++ b/docs/src/content/docs/server/start-the-tunnel-server.md @@ -37,6 +37,7 @@ POSTGRES_PASSWORD=postgres POSTGRES_DB=postgres PORTR_ADMIN_ENCRYPTION_KEY= +PORTR_ADMIN_REMOTE_USER_HEADER= ``` Generate an encryption key using the following command @@ -54,4 +55,29 @@ If you want to run postgres separately and not as a service, you can exclude the Run `docker compose up` to start the servers. Once the servers are up, go to example.com and login in to the admin. First login will be treated as a superuser. +### Reverse Proxy +#### Configure Auth Proxy Authentication + +You can configure portr's admin interface to trust an HTTP reverse proxy +to handle authentication. Web servers and reverse proxies have many +authentication integrations, and any of those can then be used with portr. + +Even with auth proxy authentication, users are not automatically provisioned. +Except for the superuser - which is provisioned automatically - all users +must be invited to a team to use portr. + +> [!WARNING] +> If you use this feature the portr admin interface **MUST** +> only be accessible via the appropriate auth proxy. +> +> A failure here will allow actors to spoof their identity. + +To activate this feature, configure your reverse proxy to authenticate +and pass the authenticate user's email as a header. This varies from +solution to solution, so consult your reverse proxy's documentation on +how to set it up. + +Once you've confirmed that to be working you may set the environment +variable `PORTR_ADMIN_REMOTE_USER_HEADER` in your `.env` with the +value of the header that will contain the user's email.