Skip to content

Commit 31eea05

Browse files
PttCodingManclaude
andcommitted
feat: OIDC SSO (Google/GitHub/generic) and LDAP login with invitation-only mode.
Adds two login paths alongside local passwords: - OIDC/OAuth via a provider registry, shipping Google, GitHub, and a generic OIDC profile (Keycloak/Authentik/Okta). Access is gated by email-domain, email, or IdP-group allowlists, with `OIDC_ALLOW_SIGNUP` defaulting to invitation-only. Admins pre-provision users through a new `POST /api/users/invite` endpoint; SSO then links by verified email. - LDAP/AD as a fallback in `POST /api/auth/login`, with optional group sync into local `groups` (rows marked `ldap_dn`) and admin-group mapping. Takeover guard refuses to bind when a real local password exists under the same username. Schema additions land as migrations v6 (`auth_identities` table keyed by `(provider, subject)`) and v7 (`groups.ldap_dn` with partial unique index). SSO-only accounts use `password_hash='!'` so bcrypt can never match. LDAP group-search errors are distinguished from clean empty results so transient LDAP flakes can't silently demote admins. SessionMiddleware cookies auto-harden to HTTPS when `PUBLIC_BASE_URL` is https, with a startup warning when SSO is enabled over plaintext. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f78884b commit 31eea05

17 files changed

Lines changed: 2224 additions & 12 deletions

File tree

.env.example

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,5 +33,61 @@ AI_RATE_LIMIT_PER_HOUR=20
3333
# Groq: AI_BASE_URL=https://api.groq.com/openai/v1 AI_MODEL=llama-3.3-70b-versatile
3434
# DeepSeek: AI_BASE_URL=https://api.deepseek.com AI_MODEL=deepseek-chat
3535

36+
# ── OIDC / OAuth SSO (optional) ──
37+
# Public URL the browser can reach — used to build OIDC redirect_uri.
38+
# Dev with Vite proxy: http://localhost:5173 Prod: https://wiki.example.com
39+
PUBLIC_BASE_URL=http://localhost:8000
40+
41+
OIDC_ENABLED=false
42+
# Comma-separated. Only providers listed here AND with a client_id+secret below are offered.
43+
OIDC_PROVIDERS=google,github,generic
44+
45+
# Who can sign in (any rule that is set must pass):
46+
# invitation-only (default): admin pre-creates user via Admin → Invite (SSO).
47+
# Set OIDC_ALLOW_SIGNUP=true to auto-create on first login (still gated by rules below).
48+
OIDC_ALLOW_SIGNUP=false
49+
OIDC_ALLOWED_EMAILS=
50+
OIDC_ALLOWED_EMAIL_DOMAINS=
51+
OIDC_REQUIRED_GROUPS=
52+
OIDC_DEFAULT_ROLE=editor
53+
54+
# Google
55+
# Redirect URI to register in Google Cloud Console:
56+
# {PUBLIC_BASE_URL}/api/auth/oauth/google/callback
57+
OIDC_GOOGLE_CLIENT_ID=
58+
OIDC_GOOGLE_CLIENT_SECRET=
59+
OIDC_GOOGLE_DISCOVERY=https://accounts.google.com/.well-known/openid-configuration
60+
61+
# GitHub — OAuth2 (not OIDC). The app needs `user:email` scope to read the
62+
# primary email when it's set to private in GitHub settings.
63+
# Redirect URI: {PUBLIC_BASE_URL}/api/auth/oauth/github/callback
64+
OIDC_GITHUB_CLIENT_ID=
65+
OIDC_GITHUB_CLIENT_SECRET=
66+
67+
# Generic OIDC — Keycloak, Authentik, Okta, Auth0, any compliant provider.
68+
# Redirect URI: {PUBLIC_BASE_URL}/api/auth/oauth/generic/callback
69+
OIDC_GENERIC_NAME=Company SSO
70+
OIDC_GENERIC_CLIENT_ID=
71+
OIDC_GENERIC_CLIENT_SECRET=
72+
OIDC_GENERIC_DISCOVERY=
73+
74+
# ── LDAP / Active Directory (optional) ──
75+
LDAP_ENABLED=false
76+
# Must be ldaps:// — plain ldap:// is rejected to avoid sending passwords in clear.
77+
LDAP_SERVER=ldaps://ldap.example.com
78+
LDAP_TLS_VERIFY=true
79+
LDAP_BIND_DN=cn=svc-wiki,ou=services,dc=example,dc=com
80+
LDAP_BIND_PASSWORD=
81+
LDAP_USER_BASE=ou=people,dc=example,dc=com
82+
LDAP_USER_FILTER=(&(objectClass=person)(uid={username}))
83+
LDAP_ATTR_EMAIL=mail
84+
LDAP_ATTR_DISPLAY_NAME=displayName
85+
LDAP_DEFAULT_ROLE=editor
86+
# Group sync (optional)
87+
LDAP_SYNC_GROUPS=false
88+
LDAP_GROUP_BASE=ou=groups,dc=example,dc=com
89+
LDAP_GROUP_FILTER=(&(objectClass=groupOfNames)(member={user_dn}))
90+
LDAP_ADMIN_GROUPS=wiki-admins
91+
3692
# ── Webhook (Phase 7, 可選) ──
3793
WEBHOOK_URLS=

backend/app/config.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,56 @@ class Settings(BaseSettings):
1919
# in production, e.g. "https://wiki.example.com".
2020
ALLOWED_ORIGINS: str = ""
2121

22+
# Public base URL the browser can reach. Used to build OIDC redirect_uri
23+
# and SSO-error redirects. In dev leave as localhost:8000; in prod set to
24+
# e.g. "https://wiki.example.com".
25+
PUBLIC_BASE_URL: str = "http://localhost:8000"
26+
27+
# ── OIDC / OAuth SSO (optional) ──
28+
OIDC_ENABLED: bool = False
29+
OIDC_PROVIDERS: str = "" # comma-separated: google,github,generic
30+
31+
# Access-control layers. Any rule that is set and does not match → 403.
32+
OIDC_ALLOW_SIGNUP: bool = False # if False, only pre-provisioned users
33+
OIDC_ALLOWED_EMAILS: str = "" # comma-separated individual whitelist
34+
OIDC_ALLOWED_EMAIL_DOMAINS: str = "" # comma-separated domain whitelist
35+
OIDC_REQUIRED_GROUPS: str = "" # IdP must return these in groups claim
36+
OIDC_DEFAULT_ROLE: str = "editor" # role for newly signed-up users
37+
38+
# Google
39+
OIDC_GOOGLE_CLIENT_ID: str = ""
40+
OIDC_GOOGLE_CLIENT_SECRET: str = ""
41+
OIDC_GOOGLE_DISCOVERY: str = (
42+
"https://accounts.google.com/.well-known/openid-configuration"
43+
)
44+
45+
# GitHub (non-OIDC OAuth2; no discovery URL)
46+
OIDC_GITHUB_CLIENT_ID: str = ""
47+
OIDC_GITHUB_CLIENT_SECRET: str = ""
48+
49+
# Generic OIDC (Keycloak / Authentik / Okta / self-hosted IdP)
50+
OIDC_GENERIC_NAME: str = "Company SSO"
51+
OIDC_GENERIC_CLIENT_ID: str = ""
52+
OIDC_GENERIC_CLIENT_SECRET: str = ""
53+
OIDC_GENERIC_DISCOVERY: str = ""
54+
55+
# ── LDAP / Active Directory (optional) ──
56+
LDAP_ENABLED: bool = False
57+
LDAP_SERVER: str = "" # must be ldaps:// unless TLS disabled
58+
LDAP_TLS_VERIFY: bool = True
59+
LDAP_BIND_DN: str = ""
60+
LDAP_BIND_PASSWORD: str = ""
61+
LDAP_USER_BASE: str = ""
62+
LDAP_USER_FILTER: str = "(&(objectClass=person)(uid={username}))"
63+
LDAP_ATTR_EMAIL: str = "mail"
64+
LDAP_ATTR_DISPLAY_NAME: str = "displayName"
65+
LDAP_DEFAULT_ROLE: str = "editor"
66+
67+
LDAP_SYNC_GROUPS: bool = False
68+
LDAP_GROUP_BASE: str = ""
69+
LDAP_GROUP_FILTER: str = "(&(objectClass=groupOfNames)(member={user_dn}))"
70+
LDAP_ADMIN_GROUPS: str = "" # CNs that grant role=admin
71+
2272
# ── AI chat (optional, OpenAI-compatible) ──
2373
# Default targets Gemini's OpenAI-compatible endpoint, but any provider
2474
# that speaks the same wire format works (OpenAI, Ollama, Groq, DeepSeek…).

backend/app/database.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,23 @@
3636
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
3737
);
3838
39+
-- SSO binding per user. A user may have zero or more identities alongside
40+
-- a local password (or instead of one, in which case users.password_hash
41+
-- is set to the sentinel '!' to disable local login while keeping the
42+
-- column NOT NULL).
43+
CREATE TABLE IF NOT EXISTS auth_identities (
44+
id INTEGER PRIMARY KEY AUTOINCREMENT,
45+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
46+
provider TEXT NOT NULL,
47+
subject TEXT NOT NULL,
48+
email TEXT,
49+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
50+
last_login_at TIMESTAMP,
51+
UNIQUE (provider, subject)
52+
);
53+
54+
CREATE INDEX IF NOT EXISTS idx_auth_identities_user ON auth_identities(user_id);
55+
3956
CREATE TABLE IF NOT EXISTS pages (
4057
id INTEGER PRIMARY KEY AUTOINCREMENT,
4158
slug TEXT UNIQUE NOT NULL,
@@ -187,6 +204,7 @@
187204
id INTEGER PRIMARY KEY AUTOINCREMENT,
188205
name TEXT UNIQUE NOT NULL,
189206
description TEXT DEFAULT '',
207+
ldap_dn TEXT UNIQUE, -- non-NULL marks this group as LDAP-sourced
190208
created_by INTEGER REFERENCES users(id),
191209
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
192210
);

backend/app/main.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@
44
from fastapi import FastAPI, Request
55
from fastapi.middleware.cors import CORSMiddleware
66
from fastapi.responses import JSONResponse
7+
from starlette.middleware.sessions import SessionMiddleware
78

89
from app import __version__
910
from app.config import settings
1011
from app.database import init_db, close_db, seed_welcome_page, get_db
1112
from app.auth import ensure_admin_exists
12-
from app.routers import auth_router, pages, media, templates, search, tags, activity, bookmarks, versions, diagrams, users, comments, backup, export, trash, notifications, watch, public, dashboard, acl, groups, ai
13+
from app.routers import auth_router, oauth_router, pages, media, templates, search, tags, activity, bookmarks, versions, diagrams, users, comments, backup, export, trash, notifications, watch, public, dashboard, acl, groups, ai
1314

1415
logger = logging.getLogger("justwiki")
1516

@@ -42,6 +43,18 @@ def _check_security():
4243
"Change it in .env before deploying to production.",
4344
settings.ADMIN_PASS,
4445
)
46+
# Either SSO path issues the session cookie (OIDC state/nonce/PKCE) over
47+
# the wire. Without TLS marking, a passive attacker on the path can
48+
# replay the short-lived state cookie. We can't force HTTPS for the
49+
# operator, but we can refuse to stay quiet about the risk.
50+
if (settings.OIDC_ENABLED or settings.LDAP_ENABLED) and not settings.COOKIE_SECURE:
51+
if not settings.PUBLIC_BASE_URL.startswith("https://"):
52+
logger.warning(
53+
"OIDC/LDAP is enabled but COOKIE_SECURE=false and PUBLIC_BASE_URL "
54+
"is not https. Session cookies (OAuth state/PKCE) will travel "
55+
"in plaintext. Serve the app over HTTPS and set "
56+
"COOKIE_SECURE=true before exposing it."
57+
)
4558

4659

4760
@asynccontextmanager
@@ -126,7 +139,25 @@ def _origin_of(url: str | None) -> str | None:
126139
allow_headers=["*"],
127140
)
128141

142+
# authlib stashes OAuth state/nonce/PKCE in the signed session cookie between
143+
# /login and /callback. Registered before routes so the OAuth router can read it.
144+
#
145+
# https_only follows COOKIE_SECURE, but also auto-enables when PUBLIC_BASE_URL
146+
# is https:// — operators running behind TLS commonly forget to flip
147+
# COOKIE_SECURE, and we don't want to leak an OAuth state cookie over
148+
# plaintext just because of a config oversight.
149+
_session_https_only = settings.COOKIE_SECURE or settings.PUBLIC_BASE_URL.startswith("https://")
150+
app.add_middleware(
151+
SessionMiddleware,
152+
secret_key=settings.SECRET_KEY,
153+
session_cookie="justwiki_oauth",
154+
same_site="lax",
155+
https_only=_session_https_only,
156+
max_age=600,
157+
)
158+
129159
app.include_router(auth_router.router)
160+
app.include_router(oauth_router.router)
130161
app.include_router(pages.router)
131162
app.include_router(media.router)
132163
app.include_router(templates.router)

backend/app/migrations.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,59 @@ async def _m005_page_is_public(db: aiosqlite.Connection) -> None:
7171
)
7272

7373

74+
async def _m006_auth_identities(db: aiosqlite.Connection) -> None:
75+
"""Create auth_identities table to record OIDC / LDAP bindings.
76+
77+
Fresh DBs already have it from SCHEMA_SQL; this migration handles upgrades
78+
where the CREATE TABLE would otherwise never run. The index is declared
79+
inside SCHEMA_SQL for fresh DBs but only gets created here for upgrades —
80+
without it, ON DELETE CASCADE becomes a full-table scan per user delete.
81+
"""
82+
await db.execute(
83+
"""
84+
CREATE TABLE IF NOT EXISTS auth_identities (
85+
id INTEGER PRIMARY KEY AUTOINCREMENT,
86+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
87+
provider TEXT NOT NULL,
88+
subject TEXT NOT NULL,
89+
email TEXT,
90+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
91+
last_login_at TIMESTAMP,
92+
UNIQUE (provider, subject)
93+
)
94+
"""
95+
)
96+
await db.execute(
97+
"CREATE INDEX IF NOT EXISTS idx_auth_identities_user ON auth_identities(user_id)"
98+
)
99+
100+
101+
async def _m007_groups_ldap_dn(db: aiosqlite.Connection) -> None:
102+
"""Add `ldap_dn` to `groups` so LDAP-mirrored groups can be reconciled.
103+
104+
A group with ldap_dn IS NOT NULL is considered fully managed by the LDAP
105+
sync loop — user additions/removals during sync only touch those rows;
106+
manually-created groups (ldap_dn IS NULL) are never pruned.
107+
"""
108+
if not await _column_exists(db, "groups", "ldap_dn"):
109+
await db.execute("ALTER TABLE groups ADD COLUMN ldap_dn TEXT")
110+
# SQLite can't add UNIQUE via ALTER. A partial unique index is the
111+
# idiomatic workaround and matches the semantics we want: only non-NULL
112+
# DNs must be unique (multiple manual groups with NULL dn are fine).
113+
await db.execute(
114+
"CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_ldap_dn "
115+
"ON groups(ldap_dn) WHERE ldap_dn IS NOT NULL"
116+
)
117+
118+
74119
MIGRATIONS: list[Migration] = [
75120
(1, "user_profile_columns", _m001_user_profile_columns),
76121
(2, "user_soft_delete", _m002_user_soft_delete),
77122
(3, "page_version_counter", _m003_page_version_counter),
78123
(4, "page_soft_delete", _m004_page_soft_delete),
79124
(5, "page_is_public", _m005_page_is_public),
125+
(6, "auth_identities", _m006_auth_identities),
126+
(7, "groups_ldap_dn", _m007_groups_ldap_dn),
80127
]
81128

82129

@@ -91,6 +138,15 @@ async def _m005_page_is_public(db: aiosqlite.Connection) -> None:
91138
"CREATE INDEX IF NOT EXISTS idx_users_deleted ON users(deleted_at)",
92139
"CREATE INDEX IF NOT EXISTS idx_pages_deleted ON pages(deleted_at)",
93140
"CREATE INDEX IF NOT EXISTS idx_pages_public ON pages(slug) WHERE is_public = 1",
141+
# SQLite can't express "column-level UNIQUE only when non-NULL" in a
142+
# plain CREATE TABLE; the partial unique index is the standard workaround
143+
# and matches what groups.ldap_dn needs.
144+
"CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_ldap_dn "
145+
"ON groups(ldap_dn) WHERE ldap_dn IS NOT NULL",
146+
# ON DELETE CASCADE on auth_identities.user_id would be a full scan
147+
# without this. Declared in SCHEMA_SQL but that's skipped on upgrades
148+
# where the backfill marks m006 as already applied.
149+
"CREATE INDEX IF NOT EXISTS idx_auth_identities_user ON auth_identities(user_id)",
94150
)
95151

96152

@@ -147,6 +203,13 @@ async def _detect_preexisting(db: aiosqlite.Connection) -> set[int]:
147203
applied.add(4)
148204
if await _column_exists(db, "pages", "is_public"):
149205
applied.add(5)
206+
rows = await db.execute_fetchall(
207+
"SELECT name FROM sqlite_master WHERE type='table' AND name='auth_identities'"
208+
)
209+
if rows:
210+
applied.add(6)
211+
if await _column_exists(db, "groups", "ldap_dn"):
212+
applied.add(7)
150213
return applied
151214

152215

backend/app/routers/auth_router.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,37 @@ async def login(body: LoginRequest, request: Request, response: Response):
3939
"SELECT id, username, password_hash, role, display_name, email FROM users WHERE username = ? AND deleted_at IS NULL",
4040
(body.username,),
4141
)
42-
if not rows or not verify_password(body.password, rows[0]["password_hash"]):
42+
43+
# 1. Local password path. Shell accounts (password_hash='!') always fail
44+
# this check so bcrypt can't coincidentally accept an empty / short
45+
# password; they must sign in via SSO or LDAP instead.
46+
user = None
47+
if rows and rows[0]["password_hash"] != "!" and verify_password(body.password, rows[0]["password_hash"]):
48+
user = dict(rows[0])
49+
50+
# 2. LDAP fallback. The service is imported lazily so sites without LDAP
51+
# never pay the `ldap3` import cost on every login attempt.
52+
if user is None and settings.LDAP_ENABLED:
53+
from app.services import ldap_auth
54+
try:
55+
lu = await ldap_auth.authenticate(body.username, body.password)
56+
except ldap_auth.LdapError as e:
57+
# Configuration/connectivity problem — log but still return 401
58+
# rather than 500 so a misconfigured LDAP doesn't reveal to the
59+
# world that LDAP is enabled.
60+
import logging
61+
logging.getLogger(__name__).error("LDAP login error: %s", e)
62+
lu = None
63+
if lu is not None:
64+
try:
65+
user = await ldap_auth.provision_ldap_user(db, lu)
66+
except ldap_auth.LdapError as e:
67+
# Takeover guard tripped (username collision with a local user).
68+
raise HTTPException(status_code=403, detail=str(e))
69+
70+
if user is None:
4371
raise HTTPException(status_code=401, detail="Invalid credentials")
4472

45-
user = dict(rows[0])
4673
token = create_token(user["id"], user["username"], user["role"])
4774
response.set_cookie(
4875
key="token",

0 commit comments

Comments
 (0)