Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(admin): support forward-auth remote user headers #25

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions admin/src/portr_admin/apis/security.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down
6 changes: 4 additions & 2 deletions admin/src/portr_admin/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 5 additions & 9 deletions admin/src/portr_admin/main.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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})
Expand Down Expand Up @@ -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})
Expand Down
40 changes: 40 additions & 0 deletions admin/src/portr_admin/tests/test_pages.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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": "[email protected]"}
)

assert resp.status_code == 400
assert resp.json() == {"message": "User does not exist"}
26 changes: 26 additions & 0 deletions docs/src/content/docs/server/start-the-tunnel-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Loading