Add Authentik OAuth/OIDC SSO, email change, and setup docs
Phase 18 changes: OAuth/OIDC SSO (Authorization Code + PKCE): - alembic/versions/0015_add_oauth_fields.py: add oauth_sub UNIQUE column, drop NOT NULL on password_hash - src/fabledassistant/services/oauth.py: OIDC discovery (cached), build_auth_url, exchange_code, get_userinfo, find_or_create_oauth_user (sub→email auto-link→create) - src/fabledassistant/routes/auth.py: GET /api/auth/oauth/login and GET /api/auth/oauth/callback; LOCAL_AUTH_ENABLED guards on login/register; /api/auth/status now returns oauth_enabled + local_auth_enabled - src/fabledassistant/config.py: OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_SCOPES, LOCAL_AUTH_ENABLED, oidc_enabled() classmethod - src/fabledassistant/models/user.py: password_hash nullable, oauth_sub field, has_password bool in to_dict() - src/fabledassistant/services/auth.py: create_user accepts password=None + oauth_sub kwarg; authenticate returns None for OAuth-only users; add get_user_by_oauth_sub, link_oauth_sub, update_user_email - frontend: AuthStatus + User types updated; auth store exposes oauthEnabled + localAuthEnabled; LoginView shows SSO button / hides password form accordingly Email change: - PUT /api/auth/email: requires password confirmation for local-auth users, skips check for OAuth-only users; enforces email uniqueness - SettingsView.vue: new Email Address section pre-filled with current email, updates authStore.user in-place on success Docs: - docs/oauth-setup.md: step-by-step Authentik provider setup, example docker-compose env vars, account linking explanation, per-provider issuer URL table Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
import secrets
|
||||
|
||||
from quart import Blueprint, g, jsonify, request, session
|
||||
from quart import Blueprint, g, jsonify, redirect, request, session
|
||||
|
||||
from fabledassistant.auth import login_required, get_current_user_id
|
||||
from fabledassistant.config import Config
|
||||
@@ -17,7 +21,9 @@ from fabledassistant.services.auth import (
|
||||
is_registration_open,
|
||||
register_with_invitation,
|
||||
reset_password_with_token,
|
||||
update_user_email,
|
||||
validate_invitation_token,
|
||||
verify_password,
|
||||
)
|
||||
from fabledassistant.services.logging import log_audit
|
||||
from fabledassistant.services.notifications import (
|
||||
@@ -26,6 +32,12 @@ from fabledassistant.services.notifications import (
|
||||
send_password_reset_success_email,
|
||||
)
|
||||
from fabledassistant.services.email import get_base_url, is_smtp_configured
|
||||
from fabledassistant.services.oauth import (
|
||||
build_auth_url,
|
||||
exchange_code,
|
||||
get_userinfo,
|
||||
find_or_create_oauth_user,
|
||||
)
|
||||
|
||||
auth_bp = Blueprint("auth", __name__, url_prefix="/api/auth")
|
||||
|
||||
@@ -41,6 +53,8 @@ def _client_ip() -> str:
|
||||
|
||||
@auth_bp.route("/register", methods=["POST"])
|
||||
async def register():
|
||||
if not Config.LOCAL_AUTH_ENABLED:
|
||||
return jsonify({"error": "Local authentication is disabled. Please use SSO."}), 403
|
||||
if await is_rate_limited(f"register:{_client_ip()}", max_requests=5, window_seconds=300):
|
||||
return jsonify({"error": "Too many registration attempts. Try again later."}), 429
|
||||
if not await is_registration_open():
|
||||
@@ -68,6 +82,8 @@ async def register():
|
||||
|
||||
@auth_bp.route("/login", methods=["POST"])
|
||||
async def login():
|
||||
if not Config.LOCAL_AUTH_ENABLED:
|
||||
return jsonify({"error": "Local authentication is disabled. Please use SSO."}), 403
|
||||
if await is_rate_limited(f"login:{_client_ip()}", max_requests=10, window_seconds=60):
|
||||
return jsonify({"error": "Too many login attempts. Try again later."}), 429
|
||||
data = await request.get_json()
|
||||
@@ -146,6 +162,37 @@ async def update_password():
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@auth_bp.route("/email", methods=["PUT"])
|
||||
@login_required
|
||||
async def update_email():
|
||||
data = await request.get_json()
|
||||
new_email = (data.get("email") or "").strip().lower() or None
|
||||
password = data.get("password") or ""
|
||||
|
||||
uid = get_current_user_id()
|
||||
user = await get_user_by_id(uid)
|
||||
if not user:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
# Require password confirmation only for local-auth users
|
||||
if user.password_hash is not None:
|
||||
if not password:
|
||||
return jsonify({"error": "Password is required to change your email"}), 400
|
||||
if not verify_password(password, user.password_hash):
|
||||
return jsonify({"error": "Incorrect password"}), 403
|
||||
|
||||
# Check the new email isn't taken by a different account
|
||||
if new_email:
|
||||
existing = await get_user_by_email(new_email)
|
||||
if existing and existing.id != uid:
|
||||
return jsonify({"error": "Email already in use by another account"}), 409
|
||||
|
||||
await update_user_email(uid, new_email)
|
||||
await log_audit("email_change", user_id=uid, username=user.username, ip_address=_client_ip())
|
||||
updated = await get_user_by_id(uid)
|
||||
return jsonify(updated.to_dict())
|
||||
|
||||
|
||||
@auth_bp.route("/forgot-password", methods=["POST"])
|
||||
async def forgot_password():
|
||||
if await is_rate_limited(f"forgot:{_client_ip()}", max_requests=5, window_seconds=300):
|
||||
@@ -245,4 +292,59 @@ async def register_with_invite():
|
||||
async def status():
|
||||
count = await get_user_count()
|
||||
reg_open = await is_registration_open()
|
||||
return jsonify({"has_users": count > 0, "registration_open": reg_open})
|
||||
return jsonify({
|
||||
"has_users": count > 0,
|
||||
"registration_open": reg_open,
|
||||
"oauth_enabled": Config.oidc_enabled(),
|
||||
"local_auth_enabled": Config.LOCAL_AUTH_ENABLED,
|
||||
})
|
||||
|
||||
|
||||
@auth_bp.route("/oauth/login", methods=["GET"])
|
||||
async def oauth_login():
|
||||
if not Config.oidc_enabled():
|
||||
return jsonify({"error": "SSO is not configured"}), 404
|
||||
|
||||
state = secrets.token_hex(32)
|
||||
code_verifier = secrets.token_urlsafe(64)
|
||||
|
||||
digest = hashlib.sha256(code_verifier.encode()).digest()
|
||||
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
|
||||
|
||||
session["oauth_state"] = state
|
||||
session["oauth_code_verifier"] = code_verifier
|
||||
|
||||
url = await build_auth_url(state, code_challenge)
|
||||
return redirect(url)
|
||||
|
||||
|
||||
@auth_bp.route("/oauth/callback", methods=["GET"])
|
||||
async def oauth_callback():
|
||||
error = request.args.get("error")
|
||||
code = request.args.get("code")
|
||||
state = request.args.get("state")
|
||||
|
||||
stored_state = session.pop("oauth_state", None)
|
||||
code_verifier = session.pop("oauth_code_verifier", None)
|
||||
|
||||
if error or not code or state != stored_state:
|
||||
return redirect("/login?error=oauth")
|
||||
|
||||
redirect_uri = Config.BASE_URL.rstrip("/") + "/api/auth/oauth/callback"
|
||||
try:
|
||||
token_response = await exchange_code(code, redirect_uri, code_verifier)
|
||||
claims = await get_userinfo(token_response["access_token"])
|
||||
except Exception:
|
||||
return redirect("/login?error=oauth")
|
||||
|
||||
sub = claims.get("sub", "")
|
||||
email = claims.get("email", "")
|
||||
preferred_username = claims.get("preferred_username", "")
|
||||
|
||||
if not sub:
|
||||
return redirect("/login?error=oauth")
|
||||
|
||||
user = await find_or_create_oauth_user(sub, email, preferred_username)
|
||||
session["user_id"] = user.id
|
||||
await log_audit("oauth_login", user_id=user.id, username=user.username, ip_address=_client_ip())
|
||||
return redirect("/")
|
||||
|
||||
Reference in New Issue
Block a user