From 78aac8f6ba0b5438d498a103db01d0b6bb8b955d Mon Sep 17 00:00:00 2001 From: Roger Joys Date: Sun, 15 Mar 2026 14:46:47 -0700 Subject: [PATCH] Initial commit - FastAPI guest portal with Authentik OIDC, UniFi integration, and branding assets --- .env.example | 30 ++++++ .gitignore | 10 ++ Dockerfile | 14 +++ README.md | 67 ++++++++++++ app/__init__.py | 0 app/auth.py | 51 +++++++++ app/config.py | 38 +++++++ app/main.py | 92 ++++++++++++++++ app/static/jfmt-pdx-logo.svg | 196 +++++++++++++++++++++++++++++++++++ app/static/pup.jpg | Bin 0 -> 22582 bytes app/templates/base.html | 78 ++++++++++++++ app/templates/error.html | 6 ++ app/templates/success.html | 9 ++ app/unifi.py | 52 ++++++++++ docker-compose.yml | 10 ++ requirements.txt | 8 ++ 16 files changed, 661 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/auth.py create mode 100644 app/config.py create mode 100644 app/main.py create mode 100755 app/static/jfmt-pdx-logo.svg create mode 100644 app/static/pup.jpg create mode 100644 app/templates/base.html create mode 100644 app/templates/error.html create mode 100644 app/templates/success.html create mode 100644 app/unifi.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..163aa0c --- /dev/null +++ b/.env.example @@ -0,0 +1,30 @@ +# Authentik OIDC Settings +AUTHENTIK_HOST=https://auth.example.com +AUTHENTIK_CLIENT_ID=your-client-id +AUTHENTIK_CLIENT_SECRET=your-client-secret + +# Portal Settings +PORTAL_SECRET_KEY=your-random-secret-key +PORTAL_BASE_URL=https://portal.example.com + +# Site configurations (JSON) +SITES='[ + { + "id": "jfmt", + "name": "JFMT-PDX", + "unifi_host": "https://192.168.1.1", + "unifi_api_key": "your-api-key", + "unifi_site": "default", + "ssid": "jfmt_guest", + "default_duration_minutes": 1440 + }, + { + "id": "jfhr", + "name": "JFHR", + "unifi_host": "https://192.168.10.1", + "unifi_api_key": "your-api-key", + "unifi_site": "default", + "ssid": "jfhr_guest", + "default_duration_minutes": 1440 + } +]' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..094f639 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +*.egg +.pytest_cache/ +.env +*.log +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bcd6f4f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.12-alpine + +WORKDIR /app + +RUN apk add --no-cache gcc musl-dev libffi-dev + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ ./app/ + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..ead7991 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# UniFi Guest Portal + +A captive portal for UniFi guest WiFi networks using Authentik OIDC authentication. + +## Overview + +This portal replaces the built-in UniFi captive portal with a custom web app that: +- Authenticates guests via Authentik OIDC +- Supports multiple UniFi sites (JFMT, JFHR) +- Authorizes guest MAC addresses via the UniFi API +- Supports per-user session duration overrides via Authentik user attributes + +## Architecture +``` +Guest connects to WiFi + → UniFi redirects to portal (/portal?site=jfmt&mac=xx:xx&...) + → Portal initiates Authentik OIDC login + → Guest authenticates (password + optional MFA) + → Portal calls UniFi API to authorize guest MAC + → Guest redirected to original URL +``` + +## Setup + +### 1. Copy environment file +```bash +cp .env.example .env +``` +Edit `.env` with your Authentik and UniFi credentials. + +### 2. Add static assets +Place the following in `app/static/`: +- `pup.jpg` — the Shiba Inu photo +- `jfmt-pdx-logo.svg` — the JFMT-PDX logo + +### 3. Configure Authentik +Create an OIDC provider in Authentik for the portal: +- Redirect URI: `https://portal.jfmt-pdx.net/callback` +- Note the Client ID and Client Secret for your `.env` + +### 4. Configure UniFi +In UniFi Hotspot Portal: +- Enable **External Portal Server** +- Set URL to `https://portal.jfmt-pdx.net/portal` + +### 5. Run +```bash +docker compose up -d +``` + +## Per-User Session Duration + +By default guests get 24 hours (1440 minutes). To override for a specific user, +set the following attribute on their Authentik user profile: +``` +guest_wifi_duration_minutes: 480 +``` + +## Dependencies + +- [unifi-utils-python](https://pypi.org/project/unifi-utils-python/) — UniFi API client +- [FastAPI](https://fastapi.tiangolo.com/) +- [Authentik](https://goauthentik.io/) + +## License + +Apache License 2.0 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..c754927 --- /dev/null +++ b/app/auth.py @@ -0,0 +1,51 @@ +import httpx +from app.config import AppConfig + +SCOPES = "openid email profile" + + +def get_authorization_url(config: AppConfig, state: str) -> str: + params = { + "client_id": config.authentik_client_id, + "redirect_uri": f"{config.portal_base_url}/callback", + "response_type": "code", + "scope": SCOPES, + "state": state, + } + query = "&".join(f"{k}={v}" for k, v in params.items()) + return f"{config.authentik_host}/application/o/authorize/?{query}" + + +async def exchange_code_for_token(config: AppConfig, code: str) -> dict: + async with httpx.AsyncClient() as client: + response = await client.post( + f"{config.authentik_host}/application/o/token/", + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": f"{config.portal_base_url}/callback", + "client_id": config.authentik_client_id, + "client_secret": config.authentik_client_secret, + }, + ) + response.raise_for_status() + return response.json() + + +async def get_userinfo(config: AppConfig, access_token: str) -> dict: + async with httpx.AsyncClient() as client: + response = await client.get( + f"{config.authentik_host}/application/o/userinfo/", + headers={"Authorization": f"Bearer {access_token}"}, + ) + response.raise_for_status() + return response.json() + + +def get_guest_duration(userinfo: dict, site_default: int) -> int: + """ + Check Authentik user attributes for a custom duration override. + Falls back to site default (1440 minutes = 24 hours). + """ + attributes = userinfo.get("attributes", {}) + return int(attributes.get("guest_wifi_duration_minutes", site_default)) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..e494e18 --- /dev/null +++ b/app/config.py @@ -0,0 +1,38 @@ +import os +import json +from dataclasses import dataclass + + +@dataclass +class SiteConfig: + id: str + name: str + unifi_host: str + unifi_api_key: str + unifi_site: str + ssid: str + default_duration_minutes: int = 1440 + + +@dataclass +class AppConfig: + authentik_host: str + authentik_client_id: str + authentik_client_secret: str + portal_secret_key: str + portal_base_url: str + sites: dict[str, SiteConfig] + + +def load_config() -> AppConfig: + sites_raw = json.loads(os.environ["SITES"]) + sites = {s["id"]: SiteConfig(**s) for s in sites_raw} + + return AppConfig( + authentik_host=os.environ["AUTHENTIK_HOST"], + authentik_client_id=os.environ["AUTHENTIK_CLIENT_ID"], + authentik_client_secret=os.environ["AUTHENTIK_CLIENT_SECRET"], + portal_secret_key=os.environ["PORTAL_SECRET_KEY"], + portal_base_url=os.environ["PORTAL_BASE_URL"], + sites=sites, + ) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..660f2d8 --- /dev/null +++ b/app/main.py @@ -0,0 +1,92 @@ +import logging +import secrets +from urllib.parse import urlencode + +from fastapi import FastAPI, Request, HTTPException +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates + +from app.auth import get_authorization_url, exchange_code_for_token, get_userinfo, get_guest_duration +from app.config import load_config +from app.unifi import authorize_guest + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = FastAPI() +templates = Jinja2Templates(directory="app/templates") +config = load_config() + +# In-memory state store (fine for single-instance deployment) +state_store: dict[str, dict] = {} + + +@app.get("/", response_class=HTMLResponse) +async def index(request: Request): + """Entry point — shown when no site/mac params are present.""" + return templates.TemplateResponse("error.html", { + "request": request, + "message": "No guest session found. Please connect to the guest WiFi first." + }) + + +@app.get("/portal", response_class=HTMLResponse) +async def portal(request: Request, site: str, mac: str, ap: str = "", url: str = ""): + """ + Entry point from UniFi captive portal redirect. + Stores session params and initiates Authentik OIDC login. + """ + if site not in config.sites: + raise HTTPException(status_code=400, detail=f"Unknown site: {site}") + + state = secrets.token_urlsafe(32) + state_store[state] = { + "site": site, + "mac": mac, + "ap": ap, + "url": url, + } + + auth_url = get_authorization_url(config, state) + return RedirectResponse(auth_url) + + +@app.get("/callback") +async def callback(request: Request, code: str, state: str): + """ + Authentik OIDC callback. + Exchanges code for token, gets user info, authorizes guest MAC. + """ + if state not in state_store: + raise HTTPException(status_code=400, detail="Invalid or expired state.") + + session = state_store.pop(state) + site_id = session["site"] + mac = session["mac"] + original_url = session.get("url", "http://detectportal.firefox.com") + + site = config.sites[site_id] + + try: + token = await exchange_code_for_token(config, code) + userinfo = await get_userinfo(config, token["access_token"]) + except Exception as e: + logger.error("OIDC error: %s", str(e)) + raise HTTPException(status_code=500, detail="Authentication failed.") + + duration = get_guest_duration(userinfo, site.default_duration_minutes) + success = authorize_guest(site, mac, duration) + + if not success: + return templates.TemplateResponse("error.html", { + "request": request, + "message": "Failed to authorize your device. Please try again or ask for help." + }) + + return templates.TemplateResponse("success.html", { + "request": request, + "site": site, + "userinfo": userinfo, + "duration_hours": duration // 60, + "original_url": original_url, + }) diff --git a/app/static/jfmt-pdx-logo.svg b/app/static/jfmt-pdx-logo.svg new file mode 100755 index 0000000..6df0549 --- /dev/null +++ b/app/static/jfmt-pdx-logo.svg @@ -0,0 +1,196 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/static/pup.jpg b/app/static/pup.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9b66479d0be069a80d1f9b0030c6c3bd3c6723e6 GIT binary patch literal 22582 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 + + + + + {{ site.name if site is defined else "JFMT-PDX" }} Guest WiFi + + + +
+ Welcome + {% block content %}{% endblock %} + +
+ + diff --git a/app/templates/error.html b/app/templates/error.html new file mode 100644 index 0000000..dcdf027 --- /dev/null +++ b/app/templates/error.html @@ -0,0 +1,6 @@ +{% extends "base.html" %} +{% block content %} +

Something went wrong

+

{{ message }}

+

Please try reconnecting to the guest WiFi or ask for assistance.

+{% endblock %} diff --git a/app/templates/success.html b/app/templates/success.html new file mode 100644 index 0000000..f7d9dac --- /dev/null +++ b/app/templates/success.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% block content %} +

You're connected!

+

Welcome to {{ site.name }} Guest WiFi.

+

Your device has been authorized for {{ duration_hours }} hours.

+{% if original_url %} +Continue Browsing +{% endif %} +{% endblock %} diff --git a/app/unifi.py b/app/unifi.py new file mode 100644 index 0000000..b9fac9b --- /dev/null +++ b/app/unifi.py @@ -0,0 +1,52 @@ +import logging +from unifi_utils import UnifiUtils, UnifiAPI +from app.config import SiteConfig + +logger = logging.getLogger(__name__) + + +def authorize_guest(site: SiteConfig, mac: str, duration_minutes: int) -> bool: + """Authorize a guest MAC address on the UniFi controller.""" + try: + unifi = UnifiUtils( + endpoint=site.unifi_host, + api_key=site.unifi_api_key, + site=site.unifi_site, + ) + response = unifi.make_api_call( + UnifiAPI.ClientAuthorizeGuestPost, + json_body={ + "cmd": "authorize-guest", + "mac": mac, + "minutes": duration_minutes, + }, + ) + logger.info("Authorized guest %s on site %s for %d minutes", mac, site.id, duration_minutes) + logger.debug("UniFi response: %s", response) + return True + except Exception as e: + logger.error("Failed to authorize guest %s on site %s: %s", mac, site.id, str(e)) + return False + + +def unauthorize_guest(site: SiteConfig, mac: str) -> bool: + """Revoke guest access for a MAC address.""" + try: + unifi = UnifiUtils( + endpoint=site.unifi_host, + api_key=site.unifi_api_key, + site=site.unifi_site, + ) + response = unifi.make_api_call( + UnifiAPI.ClientUnauthorizeGuestPost, + json_body={ + "cmd": "unauthorize-guest", + "mac": mac, + }, + ) + logger.info("Unauthorized guest %s on site %s", mac, site.id) + logger.debug("UniFi response: %s", response) + return True + except Exception as e: + logger.error("Failed to unauthorize guest %s on site %s: %s", mac, site.id, str(e)) + return False diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0f26ea7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +services: + portal: + build: . + restart: unless-stopped + ports: + - "8080:8000" + env_file: + - .env + volumes: + - ./app/static:/app/app/static diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0a8fc44 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi>=0.110.0 +uvicorn>=0.29.0 +httpx>=0.27.0 +python-jose[cryptography]>=3.3.0 +python-multipart>=0.0.9 +jinja2>=3.1.3 +pyyaml>=6.0.1 +unifi-utils-python>=1.0.0