Files
FabledScribe/src/fabledassistant/services/auth.py
T
bvandeusen b37e15d59a 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>
2026-02-25 20:12:13 -05:00

353 lines
12 KiB
Python

import hashlib
import logging
import secrets
from datetime import datetime, timedelta, timezone
import bcrypt
from sqlalchemy import func, select, update
from fabledassistant.models import async_session
from fabledassistant.models.conversation import Conversation
from fabledassistant.models.note import Note
from fabledassistant.models.invitation import InvitationToken
from fabledassistant.models.password_reset import PasswordResetToken
from fabledassistant.models.setting import Setting
from fabledassistant.models.user import User
logger = logging.getLogger(__name__)
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def verify_password(password: str, password_hash: str) -> bool:
return bcrypt.checkpw(password.encode(), password_hash.encode())
async def get_user_count() -> int:
async with async_session() as session:
return await session.scalar(select(func.count(User.id))) or 0
async def create_user(
username: str,
password: str | None,
email: str | None = None,
oauth_sub: str | None = None,
) -> User:
user_count = await get_user_count()
role = "admin" if user_count == 0 else "user"
async with async_session() as session:
user = User(
username=username,
email=email,
password_hash=hash_password(password) if password is not None else None,
oauth_sub=oauth_sub,
role=role,
)
session.add(user)
await session.commit()
await session.refresh(user)
# First user claims all pre-existing data (migration assigns user_id=1,
# which matches SERIAL first insert; also handles any NULLs)
if role == "admin":
await session.execute(
update(Note)
.where((Note.user_id.is_(None)) | (Note.user_id == user.id))
.values(user_id=user.id)
)
await session.execute(
update(Conversation)
.where(
(Conversation.user_id.is_(None))
| (Conversation.user_id == user.id)
)
.values(user_id=user.id)
)
await session.execute(
update(Setting)
.where(Setting.user_id.is_(None))
.values(user_id=user.id)
)
# Auto-close registration after first user setup
session.add(Setting(user_id=user.id, key="registration_open", value="false"))
await session.commit()
logger.info("First user '%s' created as admin, claimed orphaned data", username)
return user
async def authenticate(username: str, password: str) -> User | None:
async with async_session() as session:
result = await session.execute(
select(User).where(User.username == username)
)
user = result.scalars().first()
if user is None:
return None
if user.password_hash is None:
# OAuth-only user — cannot use password login
return None
if verify_password(password, user.password_hash):
return user
return None
async def get_user_by_id(user_id: int) -> User | None:
async with async_session() as session:
return await session.get(User, user_id)
async def get_user_by_username(username: str) -> User | None:
async with async_session() as session:
result = await session.execute(
select(User).where(User.username == username)
)
return result.scalars().first()
async def change_password(user_id: int, current_password: str, new_password: str) -> bool:
"""Change a user's password. Returns True on success, False if current password is wrong."""
async with async_session() as session:
user = await session.get(User, user_id)
if not user:
return False
if not verify_password(current_password, user.password_hash):
return False
user.password_hash = hash_password(new_password)
await session.commit()
logger.info("Password changed for user %d (%s)", user_id, user.username)
return True
async def is_registration_open() -> bool:
"""Check if new user registration is allowed.
Always open when no users exist (first-user setup).
Otherwise reads the admin's 'registration_open' setting (default closed).
"""
user_count = await get_user_count()
if user_count == 0:
return True
async with async_session() as session:
# Find the admin user's registration_open setting
result = await session.execute(
select(Setting)
.join(User, Setting.user_id == User.id)
.where(User.role == "admin", Setting.key == "registration_open")
)
setting = result.scalar_one_or_none()
return setting.value == "true" if setting else False
async def list_users() -> list[User]:
async with async_session() as session:
result = await session.execute(select(User).order_by(User.created_at))
return list(result.scalars().all())
async def delete_user(user_id: int) -> bool:
async with async_session() as session:
user = await session.get(User, user_id)
if not user:
return False
await session.delete(user)
await session.commit()
logger.info("Deleted user %d (%s)", user_id, user.username)
return True
async def set_registration_open(admin_user_id: int, open: bool) -> None:
from fabledassistant.services.settings import set_setting
await set_setting(admin_user_id, "registration_open", "true" if open else "false")
async def update_user_email(user_id: int, email: str | None) -> None:
async with async_session() as session:
user = await session.get(User, user_id)
if user:
user.email = email
await session.commit()
async def get_user_by_oauth_sub(sub: str) -> User | None:
async with async_session() as session:
result = await session.execute(
select(User).where(User.oauth_sub == sub)
)
return result.scalars().first()
async def link_oauth_sub(user_id: int, sub: str) -> None:
async with async_session() as session:
await session.execute(
update(User).where(User.id == user_id).values(oauth_sub=sub)
)
await session.commit()
async def get_user_by_email(email: str) -> User | None:
async with async_session() as session:
result = await session.execute(
select(User).where(func.lower(User.email) == email.lower())
)
return result.scalars().first()
async def create_password_reset_token(user_id: int) -> str:
"""Generate a password reset token. Returns the raw token (for the email link)."""
raw_token = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
async with async_session() as session:
# Invalidate any existing unused tokens for this user
result = await session.execute(
select(PasswordResetToken).where(
PasswordResetToken.user_id == user_id,
PasswordResetToken.used.is_(False),
)
)
for old_token in result.scalars().all():
old_token.used = True
token = PasswordResetToken(
user_id=user_id,
token_hash=token_hash,
expires_at=expires_at,
)
session.add(token)
await session.commit()
logger.info("Password reset token created for user %d", user_id)
return raw_token
async def reset_password_with_token(raw_token: str, new_password: str) -> int | None:
"""Validate a reset token and update the user's password. Returns user_id on success."""
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
async with async_session() as session:
result = await session.execute(
select(PasswordResetToken).where(PasswordResetToken.token_hash == token_hash)
)
reset_token = result.scalars().first()
if not reset_token:
return None
if reset_token.used:
return None
if reset_token.expires_at < datetime.now(timezone.utc):
return None
user = await session.get(User, reset_token.user_id)
if not user:
return None
user.password_hash = hash_password(new_password)
reset_token.used = True
await session.commit()
logger.info("Password reset via token for user %d (%s)", user.id, user.username)
return user.id
async def create_invitation(email: str, invited_by: int) -> str:
"""Generate an invitation token. Returns the raw token (for the email link)."""
raw_token = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
expires_at = datetime.now(timezone.utc) + timedelta(days=7)
async with async_session() as session:
# Invalidate previous unused invitations for same email
result = await session.execute(
select(InvitationToken).where(
func.lower(InvitationToken.email) == email.lower(),
InvitationToken.used.is_(False),
)
)
for old_token in result.scalars().all():
old_token.used = True
token = InvitationToken(
email=email.lower(),
token_hash=token_hash,
invited_by=invited_by,
expires_at=expires_at,
)
session.add(token)
await session.commit()
logger.info("Invitation created for %s by user %d", email, invited_by)
return raw_token
async def validate_invitation_token(raw_token: str) -> InvitationToken | None:
"""Look up by hash, check not used/expired. Returns the token record with email."""
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
async with async_session() as session:
result = await session.execute(
select(InvitationToken).where(InvitationToken.token_hash == token_hash)
)
invitation = result.scalars().first()
if not invitation:
return None
if invitation.used:
return None
if invitation.expires_at < datetime.now(timezone.utc):
return None
return invitation
async def register_with_invitation(raw_token: str, username: str, password: str) -> User | None:
"""Validate token, create user with the invitation's email, mark token used."""
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
async with async_session() as session:
result = await session.execute(
select(InvitationToken).where(InvitationToken.token_hash == token_hash)
)
invitation = result.scalars().first()
if not invitation or invitation.used or invitation.expires_at < datetime.now(timezone.utc):
return None
invitation.used = True
await session.commit()
# Create user outside the invitation session
user = await create_user(username, password, invitation.email)
logger.info("User '%s' registered via invitation for %s", username, invitation.email)
return user
async def list_pending_invitations() -> list[InvitationToken]:
"""List unused, non-expired invitations."""
async with async_session() as session:
result = await session.execute(
select(InvitationToken).where(
InvitationToken.used.is_(False),
InvitationToken.expires_at > datetime.now(timezone.utc),
).order_by(InvitationToken.created_at.desc())
)
return list(result.scalars().all())
async def revoke_invitation(invitation_id: int) -> bool:
"""Mark an invitation as used (revoked)."""
async with async_session() as session:
invitation = await session.get(InvitationToken, invitation_id)
if not invitation or invitation.used:
return False
invitation.used = True
await session.commit()
logger.info("Invitation %d revoked", invitation_id)
return True