commit 78aac8f6ba0b5438d498a103db01d0b6bb8b955d Author: Roger Joys Date: Sun Mar 15 14:46:47 2026 -0700 Initial commit - FastAPI guest portal with Authentik OIDC, UniFi integration, and branding assets 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 0000000..9b66479 Binary files /dev/null and b/app/static/pup.jpg differ diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..ea3bba6 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,78 @@ + + + + + + {{ 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