diff --git a/README.md b/README.md index c042c30..eac2eda 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,9 @@ Proof of concept backup dashboard, intended to display backup jobs from multiple ``` collectors ──POST /api/v1/ingest (API key)──▶ ┌──────────────┐ │ FastAPI + │ -browser ──nginx (basic auth, TLS)──▶ /api/* ─▶│ SQLite │ - └─ static files: public/ └──────────────┘ +browser ──nginx (TLS)──▶ /api/* ─────────────▶│ SQLite │ + │ └─ static files: public/ └──────────────┘ + └─ auth_request ─▶ oauth2-proxy ─▶ Microsoft Entra ID (Microsoft 365 sign-in) ``` - `public/`: static frontend (no build step). Add `?mock=1` to the URL to use built-in sample data. @@ -72,12 +73,58 @@ The OpenAPI schema is at `/api/v1/openapi.json`. ## Authentication -- **Dashboard users:** nginx basic auth over HTTPS (`deploy/nginx.conf`). Every user can see every tenant. - *Planned:* Microsoft 365 sign-in through [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) - with the Entra ID provider and nginx `auth_request`. That's an nginx-only change. The frontend - already tells the user to reload when a session expires (HTTP 401). +- **Dashboard users:** Microsoft 365 sign-in. nginx checks every request with + [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/), which signs users in with Microsoft + Entra ID. Who may sign in is controlled in Entra (users or a group assigned to the app). Every + signed-in user can see every tenant. Sessions last 12 hours; signing in again is usually silent. + The dashboard shows who's signed in; **Sign out** also signs out of Microsoft in that browser, + otherwise the next visit would sign straight back in. - **Collectors:** per-collector bearer keys, checked by the app. Only a SHA-256 hash is stored. - To revoke one, run `python manage.py revoke-key NAME`. + To revoke one, run `python manage.py revoke-key NAME`. `/api/v1/ingest` is the only path that + doesn't need Microsoft sign-in. + +### Microsoft 365 sign-in setup + +**In the Microsoft Entra admin center** (entra.microsoft.com), as an admin of your tenant: + +1. *App registrations → New registration*. Name: `Backup Dashboard`. Supported account types: + **Accounts in this organizational directory only** (single tenant). Redirect URI: platform + **Web**, `https:///oauth2/callback`. Register. +2. On its *Overview*, note the **Application (client) ID** and **Directory (tenant) ID**. +3. *Certificates & secrets → New client secret*. Copy the secret's **Value** now (it's only shown + once). Note its expiry date: sign-in stops working when it expires, so set a reminder to + create a new one before then. +4. *Enterprise applications → Backup Dashboard → Properties*: set **Assignment required** to + **Yes**. Then *Users and groups → Add user/group* and add the people (or a group) who should + see the dashboard. Everyone else in the tenant is refused by Microsoft before reaching it. + +No API permissions beyond the default `User.Read` are needed. + +**On the dashboard server:** + +1. Install oauth2-proxy 7.8 or newer from its + [releases](https://github.com/oauth2-proxy/oauth2-proxy/releases) as `/usr/local/bin/oauth2-proxy`, + and create its user: `sudo useradd --system --no-create-home oauth2-proxy`. +2. Install the config and the client secret, readable only by root and oauth2-proxy: + ```bash + sudo install -d -m 750 -g oauth2-proxy /etc/oauth2-proxy + sudo install -m 640 -g oauth2-proxy deploy/oauth2-proxy.cfg /etc/oauth2-proxy/backup-dashboard.cfg + sudo sh -c 'umask 027; printf %s "" > /etc/oauth2-proxy/client-secret' && sudo chgrp oauth2-proxy /etc/oauth2-proxy/client-secret + ``` + In `/etc/oauth2-proxy/backup-dashboard.cfg`, fill in ``, ``, + ``, and ``, generated with: + `openssl rand -base64 32 | tr -- '+/' '-_'` +3. Install `deploy/oauth2-proxy.service` into `/etc/systemd/system/`, then + `sudo systemctl daemon-reload && sudo systemctl enable --now oauth2-proxy`. + Check it with `journalctl -u oauth2-proxy -n 20`: it should say "OAuthProxy configured for + Microsoft Entra ID". +4. Install the new `deploy/nginx.conf` (keeping your server name and certificate paths), then + `sudo nginx -t && sudo systemctl reload nginx`. Basic auth and its htpasswd file are no longer + used. + +**Troubleshooting:** sign-in problems show in `journalctl -u oauth2-proxy`. Microsoft's own +errors (wrong redirect URI, user not assigned, expired secret) appear on its sign-in page with an +`AADSTS…` code. ## Development @@ -96,7 +143,7 @@ BACKUP_DASH_SERVE_STATIC=1 uvicorn --factory app.main:create_app --reload 1. Copy the repo to `/opt/backup-dashboard`, create a venv at `/opt/backup-dashboard/venv`, and run `pip install -r backend/requirements.txt`. 2. Copy `deploy/backup-dashboard.env` to `/etc/backup-dashboard/backup-dashboard.env`. Both the service and `manage.py` read their settings from it. 3. Create a `backupdash` system user, then install `deploy/backup-dashboard-api.service` into `/etc/systemd/system/` and enable it. The database is created on first start. -4. Copy `public/` to `/var/www/backup-dashboard/public`, install `deploy/nginx.conf`, get a certificate with certbot, and create users with `htpasswd -B`. +4. Copy `public/` to `/var/www/backup-dashboard/public`, install `deploy/nginx.conf`, get a certificate with certbot, and set up Microsoft 365 sign-in (see [Microsoft 365 sign-in setup](#microsoft-365-sign-in-setup)). 5. Run `manage.py` as the service user. It finds the database through the env file: ```bash cd /opt/backup-dashboard/backend diff --git a/backend/app/auth.py b/backend/app/auth.py index 8c669c9..45a53a1 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -1,7 +1,7 @@ """Collector (machine) authentication. -Dashboard *users* are authenticated by nginx in front of the app (basic auth today, -oauth2-proxy + Microsoft Entra ID later), so nothing here deals with people. +Dashboard *users* are authenticated by nginx in front of the app (Microsoft 365 sign-in via +oauth2-proxy), so nothing here deals with people. """ import hashlib diff --git a/backend/app/main.py b/backend/app/main.py index 7c39580..b9c72da 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -25,6 +25,16 @@ def health(conn: sqlite3.Connection = Depends(db.get_db)): return {"status": "ok"} +@router.get("/me") +def me(request: Request): + # Set by nginx from oauth2-proxy's sign-in check (nginx overwrites anything the browser sends). + # Display only: access control happens in nginx. Empty in dev or without Microsoft sign-in. + return { + "email": request.headers.get("x-forwarded-email") or None, + "name": request.headers.get("x-forwarded-preferred-username") or request.headers.get("x-forwarded-user") or None, + } + + @router.get("/jobs") def list_jobs(conn: sqlite3.Connection = Depends(db.get_db)): return {"generated_at": to_db(utcnow()), "jobs": repo.list_jobs(conn)} diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 30fe42a..5bbd6ca 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -122,3 +122,9 @@ def test_tenant_name_is_case_insensitive(client): body["tenant"] = "ACME CORP" client.post("/api/v1/ingest", json=body) assert len(client.get("/api/v1/jobs").json()["jobs"]) == 1 + + +def test_me_reports_signed_in_user_from_proxy_headers(client): + assert client.get("/api/v1/me").json() == {"email": None, "name": None} + r = client.get("/api/v1/me", headers={"X-Forwarded-Email": "kyle@example.com", "X-Forwarded-User": "abc-123"}) + assert r.json() == {"email": "kyle@example.com", "name": "abc-123"} diff --git a/deploy/nginx.conf b/deploy/nginx.conf index 685b247..f58cf0f 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -1,7 +1,7 @@ server { listen 80; server_name backups.example.com; - # Basic auth sends the password with every request — never serve this over plain HTTP + # Session cookies and API keys must never cross plain HTTP return 301 https://$host$request_uri; } @@ -23,26 +23,72 @@ server { add_header Referrer-Policy "no-referrer" always; add_header Strict-Transport-Security "max-age=31536000" always; - # Dashboard users. Create with: htpasswd -B -c /etc/nginx/backup-dashboard.htpasswd - # To move to Microsoft 365 sign-in later, replace these two lines with an auth_request to - # oauth2-proxy (provider=entra-id); the app itself doesn't need to change. - auth_basic "Backup Monitor"; - auth_basic_user_file /etc/nginx/backup-dashboard.htpasswd; + # ---------- Microsoft 365 sign-in ---------- + # oauth2-proxy (deploy/oauth2-proxy.cfg) runs on 127.0.0.1:4180 and does the sign-in with + # Entra ID. Every request below asks it "is this browser signed in?" via auth_request. + + # Its own pages: /oauth2/start (go to Microsoft), /oauth2/callback, /oauth2/sign_out + location /oauth2/ { + proxy_pass http://127.0.0.1:4180; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + # Where to return after signing in: the page that was asked for + proxy_set_header X-Auth-Request-Redirect $request_uri; + # Entra ID session cookies are large + proxy_buffer_size 16k; + proxy_buffers 8 16k; + } + + # The sign-in check itself, only reachable as an auth_request subrequest + location = /oauth2/auth { + internal; + proxy_pass http://127.0.0.1:4180; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Uri $request_uri; + proxy_set_header Content-Length ""; + proxy_pass_request_body off; + proxy_buffer_size 16k; + proxy_buffers 8 16k; + } + + # ---------- Dashboard ---------- location / { + auth_request /oauth2/auth; + # Not signed in: straight to Microsoft's sign-in, then back to this page + error_page 401 = /oauth2/start; + # Make browsers check for a newer page/script on every load (a cheap 304 when unchanged), + # so an update shows up at once. (expires, not add_header: an add_header here would drop + # the security headers above.) + expires -1; try_files $uri $uri/ /index.html; } location /api/ { + auth_request /oauth2/auth; + # No redirect here: API calls from the page get a plain 401, and the page asks the user to + # reload, which goes through sign-in above. + + # Who's signed in, for the dashboard's "signed in as" (proxy_set_header replaces any value + # the browser sent, so these can't be spoofed) + auth_request_set $auth_email $upstream_http_x_auth_request_email; + auth_request_set $auth_user $upstream_http_x_auth_request_user; + auth_request_set $auth_name $upstream_http_x_auth_request_preferred_username; + proxy_set_header X-Forwarded-Email $auth_email; + proxy_set_header X-Forwarded-User $auth_user; + proxy_set_header X-Forwarded-Preferred-Username $auth_name; + proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } - # Collectors authenticate with their own API key (Authorization: Bearer …), checked by the app + # Collectors authenticate with their own API key (Authorization: Bearer …), checked by the app, + # so no Microsoft sign-in here location = /api/v1/ingest { - auth_basic off; client_max_body_size 256k; proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; diff --git a/deploy/oauth2-proxy.cfg b/deploy/oauth2-proxy.cfg new file mode 100644 index 0000000..69effe3 --- /dev/null +++ b/deploy/oauth2-proxy.cfg @@ -0,0 +1,46 @@ +# oauth2-proxy config for Backup Dashboard's Microsoft 365 (Entra ID) sign-in. +# Install as /etc/oauth2-proxy/backup-dashboard.cfg, readable only by root and the oauth2-proxy +# user (it holds the cookie secret). Needs oauth2-proxy 7.8 or newer for the entra-id provider. +# Replace the four <...> values; see the README's "Microsoft 365 sign-in" section. + +## Where it listens: only nginx talks to it +http_address = "127.0.0.1:4180" +reverse_proxy = true +# Only nginx (on this machine) may supply X-Forwarded-* headers +trusted_proxy_ips = ["127.0.0.1/32", "::1/128"] + +## Entra ID app registration (single tenant) +provider = "entra-id" +oidc_issuer_url = "https://login.microsoftonline.com//v2.0" +client_id = "" +# The client secret's *value*, in a file of its own, root:oauth2-proxy, mode 640 +client_secret_file = "/etc/oauth2-proxy/client-secret" +redirect_url = "https:///oauth2/callback" + +## Who may sign in +# Anyone in the tenant that Entra lets through. Limit it in Entra itself: Enterprise +# applications -> this app -> Properties -> "Assignment required" = Yes, then assign users or a +# group. Entra then refuses everyone else before they ever reach the dashboard. +email_domains = ["*"] +# The user's sign-in name (UPN), which every work account has. The "email" claim is only present +# when the account has a mailbox address. +oidc_email_claim = "preferred_username" + +## Session cookie +# Generate with: openssl rand -base64 32 | tr -- '+/' '-_' +cookie_secret = "" +cookie_name = "_backup_dashboard" +cookie_secure = true +cookie_samesite = "lax" +# Sign in again (usually silently, via the Microsoft session) after this long +cookie_expire = "12h" + +## Behaviour with nginx's auth_request +# Auth-only: nginx serves the dashboard; oauth2-proxy just answers "signed in or not" +upstreams = ["static://202"] +# Put the user in X-Auth-Request-* headers on /oauth2/auth responses, for nginx to pass on +set_xauthrequest = true +# Go straight to Microsoft instead of showing a "Sign in with..." page first +skip_provider_button = true +# Allow "Sign out" to continue to Microsoft's sign-out page +whitelist_domains = ["login.microsoftonline.com"] diff --git a/deploy/oauth2-proxy.service b/deploy/oauth2-proxy.service new file mode 100644 index 0000000..136a2d6 --- /dev/null +++ b/deploy/oauth2-proxy.service @@ -0,0 +1,18 @@ +[Unit] +Description=oauth2-proxy (Microsoft 365 sign-in for Backup Dashboard) +After=network-online.target +Wants=network-online.target + +[Service] +User=oauth2-proxy +Group=oauth2-proxy +ExecStart=/usr/local/bin/oauth2-proxy --config=/etc/oauth2-proxy/backup-dashboard.cfg +Restart=on-failure + +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/public/css/styles.css b/public/css/styles.css index 46619d7..4f033a8 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -71,6 +71,12 @@ body { } .topbar__title { margin: 0; font-size: 1.25rem; } .topbar__meta { display: flex; align-items: center; gap: 0.75rem; font-size: 0.875rem; } +/* Only when shown: display:flex on the bare class would override the hidden attribute */ +.topbar__user:not([hidden]) { + display: flex; align-items: center; gap: 0.75rem; + padding-left: 0.75rem; border-left: 1px solid var(--border); +} +a.btn { text-decoration: none; } .container { max-width: 1400px; diff --git a/public/index.html b/public/index.html index 1ffdef3..1788c74 100644 --- a/public/index.html +++ b/public/index.html @@ -14,6 +14,11 @@
Loading… + +
diff --git a/public/js/api.js b/public/js/api.js index 6ba1f27..cc3e3a4 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -49,6 +49,10 @@ const httpApi = { getRun(id) { return request(`/runs/${encodeURIComponent(id)}`); }, + /** GET /me → { email, name } of the Microsoft 365 user, both null without sign-in */ + getMe() { + return request('/me'); + }, }; // Mock is loaded lazily so it never ships in the production request path. diff --git a/public/js/app.js b/public/js/app.js index 3efae9c..90fca61 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -56,6 +56,9 @@ const dom = { empty: $('#empty-state'), search: $('#search'), refreshBtn: $('#refresh-btn'), + signedIn: $('#signed-in'), + signedInEmail: $('#signed-in-email'), + signOut: $('#sign-out'), lastUpdated: $('#last-updated'), errorBanner: $('#error-banner'), dialog: $('#detail-dialog'), @@ -524,5 +527,20 @@ dom.search.addEventListener('input', () => { dom.refreshBtn.addEventListener('click', () => refresh().then(schedulePoll)); +// ---------- Signed-in user ---------- +async function showSignedInUser() { + try { + const me = await api.getMe(); + const who = me?.email || me?.name; + if (!who) return; // no Microsoft 365 sign-in in front of the dashboard (dev, mock) + dom.signedInEmail.textContent = who; + dom.signOut.href = CONFIG.signOutUrl; + dom.signedIn.hidden = false; + } catch (err) { + console.error(err); // not worth a banner: the jobs request will surface real problems + } +} + // ---------- Boot ---------- +showSignedInUser(); refresh().then(schedulePoll); \ No newline at end of file diff --git a/public/js/config.js b/public/js/config.js index 9a5f73a..888c379 100644 --- a/public/js/config.js +++ b/public/js/config.js @@ -12,4 +12,8 @@ export const CONFIG = Object.freeze({ historyDays: 30, // ?mock=1 → use in-browser sample data, no backend needed useMock: params.has('mock'), + // oauth2-proxy clears the dashboard session, then hands over to Microsoft's sign-out page. + // Signing out of the dashboard alone would be undone at once: the browser's Microsoft session + // would sign it straight back in. + signOutUrl: '/oauth2/sign_out?rd=' + encodeURIComponent('https://login.microsoftonline.com/common/oauth2/v2.0/logout'), }); \ No newline at end of file diff --git a/public/js/mock.js b/public/js/mock.js index 77c53c6..6a63c37 100644 --- a/public/js/mock.js +++ b/public/js/mock.js @@ -126,4 +126,7 @@ export const mockApi = { } throw new Error('Run not found'); }, + async getMe() { + return { email: null, name: null }; // no sign-in in mock mode + }, };