Clawith/backend/app/api/auth.py

1120 lines
41 KiB
Python

"""Authentication API routes."""
import uuid
from datetime import datetime, timezone
import uuid
from typing import Any
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, status
from loguru import logger
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import create_access_token, get_authenticated_user, get_current_user, hash_password, verify_password
from app.database import get_db
from app.models.user import Identity, User
from app.schemas.schemas import (
ForgotPasswordRequest,
ResetPasswordRequest,
IdentityBindRequest,
IdentityUnbindRequest,
OAuthAuthorizeResponse,
OAuthCallbackRequest,
TokenResponse,
UserLogin,
UserOut,
UserRegister,
UserUpdate,
VerifyEmailRequest,
ResendVerificationRequest,
NeedsVerificationResponse,
RegisterInitRequest,
RegisterInitResponse,
RegisterCompleteRequest,
RegisterCompleteResponse,
SSORegisterRequest,
TenantChoice,
MultiTenantResponse,
IdentityOut,
TenantSwitchRequest,
TenantSwitchResponse,
)
from sqlalchemy.orm import selectinload
router = APIRouter(prefix="/auth", tags=["auth"])
@router.get("/registration-config")
async def get_registration_config(db: AsyncSession = Depends(get_db)):
"""Public endpoint — returns registration requirements (no auth needed)."""
from app.models.system_settings import SystemSetting
result = await db.execute(select(SystemSetting).where(SystemSetting.key == "invitation_code_enabled"))
setting = result.scalar_one_or_none()
enabled = setting.value.get("enabled", False) if setting else False
return {"invitation_code_required": enabled}
@router.get("/check-duplicate")
async def check_duplicate(
email: str | None = Query(None, description="Email to check"),
username: str | None = Query(None, description="Username to check"),
db: AsyncSession = Depends(get_db),
):
"""Check if email or username already exists."""
from app.models.user import Identity, User
result = {"email_exists": False, "username_exists": False, "conflicts": []}
if email:
# Check Identity email
existing = await db.execute(
select(Identity).where(Identity.email == email)
)
if existing.scalar_one_or_none():
result["email_exists"] = True
result["conflicts"].append({"type": "email", "scope": "global", "message": "Email already registered"})
if username:
existing = await db.execute(select(Identity).where(Identity.username == username))
if existing.scalar_one_or_none():
result["username_exists"] = True
result["conflicts"].append({"type": "username", "scope": "global", "message": "Username already taken"})
result["has_conflict"] = result["email_exists"] or result["username_exists"]
return result
async def _send_verification_email_task(
user: User,
background_tasks: BackgroundTasks,
settings: Any,
db: AsyncSession,
) -> None:
"""Helper to create verification token and add email task to background tasks."""
# Check if email is configured — either via DB (platform settings UI) or env vars.
# We must check the DB config too, since most users configure SMTP via the UI.
from app.services.system_email_service import resolve_email_config_async
email_config = await resolve_email_config_async(db)
if not email_config:
logger.debug("No email config found (env or DB), skipping verification email")
return
from app.services.email_verification_service import email_verification_service
try:
# Get identity for this user
res = await db.execute(select(Identity).where(Identity.id == user.identity_id))
identity = res.scalar_one_or_none()
if not identity:
logger.warning(f"No identity found for user {user.id} ({user.email}). Cannot send verification.")
return
raw_code, expires_at = await email_verification_service.create_email_verification_token(identity.id, identity.email)
expiry_minutes = int((expires_at - datetime.now(timezone.utc)).total_seconds() // 60)
background_tasks.add_task(
email_verification_service.send_verification_email,
identity.email,
user.display_name or identity.username or "User",
raw_code,
expiry_minutes,
)
except Exception as exc:
logger.error(f"Failed to create verification token for {user.email}: {exc}")
logger.warning(f"Failed to send verification email for {user.email}: {exc}")
@router.post("/register", response_model=Any, status_code=status.HTTP_201_CREATED)
async def register(
data: UserRegister,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
):
"""Legacy registration endpoint - kept for backward compatibility.
For new implementations, use:
- /register/init - Step 1: Initialize registration
- /register/sso - SSO registration
- /verify-email - Step 3: Verify email
"""
from app.config import get_settings
settings = get_settings()
# Handle SSO registration if provider info provided
if data.provider and data.provider_code:
return await _handle_sso_register(data, db)
# Regular username/password registration - delegate to new flow
return await _handle_normal_register(data, background_tasks, db, settings)
@router.post("/register/init", response_model=RegisterInitResponse, status_code=status.HTTP_201_CREATED)
async def register_init(
data: RegisterInitRequest,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
):
"""Step 1: Initialize registration with account credentials.
Creates/finds a global Identity and a tenant-scoped User.
"""
from app.config import get_settings
settings = get_settings()
from app.services.registration_service import registration_service
from app.models.user import Identity, User
logger.info(f"[REGISTER_INIT] Starting registration for email={data.email}")
# Check if this is the first user (platform admin setup)
from sqlalchemy import func
ident_count_result = await db.execute(select(func.count()).select_from(Identity))
is_first_user = ident_count_result.scalar() == 0
# Find or Create Identity
identity = await registration_service.find_or_create_identity(
db,
email=data.email,
username=data.username,
password=data.password,
is_platform_admin=is_first_user
)
# Defense-in-depth: verify the returned identity actually belongs to the
# submitted email. Under normal circumstances this should never trigger
# (find_or_create_identity no longer uses username as a lookup key), but
# this guard protects against future regressions.
if identity.email and identity.email != data.email:
logger.warning(
"[REGISTER_INIT] Identity email mismatch: submitted=%s returned=%s — rejecting",
data.email,
identity.email,
)
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Username already taken. Please choose a different username.",
)
# If identity existed, verify password
if identity.password_hash and not verify_password(data.password, identity.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Email already registered. Incorrect password."
)
# For first user: auto-create/get default tenant
tenant_uuid = None
if is_first_user:
from app.models.tenant import Tenant
default = await db.execute(select(Tenant).where(Tenant.slug == "default"))
tenant = default.scalar_one_or_none()
if not tenant:
tenant = Tenant(name="Default", slug="default", im_provider="web_only")
db.add(tenant)
await db.flush()
tenant_uuid = tenant.id
# Create User (tenant-scoped)
# Check if user already exists in this tenant (if tenant_uuid is set)
if tenant_uuid:
existing_user_res = await db.execute(
select(User).where(User.identity_id == identity.id, User.tenant_id == tenant_uuid)
)
user = existing_user_res.scalar_one_or_none()
else:
# Check for a "tenant-less" user (pending company setup)
existing_user_res = await db.execute(
select(User).where(User.identity_id == identity.id, User.tenant_id == None)
)
user = existing_user_res.scalar_one_or_none()
if not user:
user = await registration_service.create_user_with_identity(
db,
identity=identity,
display_name=data.display_name or data.username,
role="platform_admin" if is_first_user else "member",
tenant_id=tenant_uuid,
)
# Set initial status
user.is_active = is_first_user # Active immediately if first user
user.email_verified = identity.email_verified
await db.flush()
# Generate token
token = create_access_token(str(user.id), user.role)
# Send verification email if not verified
if not identity.email_verified:
await _send_verification_email_task(user, background_tasks, settings, db)
return RegisterInitResponse(
user_id=user.id,
email=identity.email,
access_token=token,
user=UserOut.model_validate(user),
message="Registration initiated. Please verify your email." if not identity.email_verified else "Registration successful.",
needs_company_setup=user.tenant_id is None,
target_tenant_id=data.target_tenant_id,
)
@router.post("/register/sso", response_model=TokenResponse)
async def register_sso(
data: SSORegisterRequest,
db: AsyncSession = Depends(get_db),
):
"""SSO registration - completely separate from normal registration flow.
This endpoint handles OAuth-based registration/login via external providers.
"""
from app.services.auth_registry import auth_provider_registry
from app.services.registration_service import registration_service
logger.info(f"[REGISTER_SSO] Starting SSO registration: provider={data.provider}")
# Get provider
auth_provider = await auth_provider_registry.get_provider(db, data.provider)
if not auth_provider:
raise HTTPException(status_code=400, detail=f"Provider '{data.provider}' not supported")
# Perform SSO registration
user, is_new, error = await registration_service.register_with_sso(
db, data.provider, data.code, auth_provider
)
if error:
raise HTTPException(status_code=400, detail=error)
# If no tenant, check for email domain match
if not user.tenant_id and user.email:
tenant, _ = await registration_service.get_tenant_for_registration(
db, email=user.email, invitation_code=data.invitation_code
)
if tenant:
user.tenant_id = tenant.id
await db.flush()
# Generate token
token = create_access_token(str(user.id), user.role)
logger.info(f"[REGISTER_SSO] SSO successful: user_id={user.id}, is_new={is_new}")
return TokenResponse(
access_token=token,
user=UserOut.model_validate(user),
needs_company_setup=user.tenant_id is None,
)
async def _handle_normal_register(data: UserRegister, background_tasks: BackgroundTasks, db: AsyncSession, settings):
"""Legacy normal registration handler."""
logger.info(f"[REGISTER_LEGACY] email={data.email}")
from app.services.registration_service import registration_service
from sqlalchemy import func
# Check if first user
user_count_result = await db.execute(select(func.count()).select_from(User))
is_first_user = user_count_result.scalar() == 0
# Resolve tenant
tenant_uuid = None
if is_first_user:
from app.models.tenant import Tenant
default = await db.execute(select(Tenant).where(Tenant.slug == "default"))
tenant = default.scalar_one_or_none()
if not tenant:
tenant = Tenant(name="Default", slug="default", im_provider="web_only")
db.add(tenant)
await db.flush()
tenant_uuid = tenant.id
role = "platform_admin"
else:
tenant, _ = await registration_service.get_tenant_for_registration(
db, email=data.email, invitation_code=data.invitation_code
)
if tenant:
tenant_uuid = tenant.id
role = "member"
# 1. Check for existing Identity/Tenant-User
from app.services.registration_service import registration_service
# Check if this email is already registered globally
identity_query = select(Identity).where(Identity.email == data.email)
ident_res = await db.execute(identity_query)
identity = ident_res.scalar_one_or_none()
if identity:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Email already registered, please login directly."
)
# 2. Uniqueness Check (Already handled by Identity lookup above, but let's be explicit for Phone if needed)
# conflicts = await registration_service.check_duplicate_identity(db, email=data.email)
# ...
# 3. Resolve or create Identity
# If it's the first user, we auto-verify (trusted admin)
identity = await registration_service.find_or_create_identity(
db,
email=data.email,
username=data.username,
password=data.password,
is_platform_admin=is_first_user
)
# Defense-in-depth: verify the returned identity actually belongs to the
# submitted email. Should be unreachable after the username-lookup fix, but
# acts as a safety net against future regressions.
if identity.email and identity.email != data.email:
logger.warning(
"[REGISTER_LEGACY] Identity email mismatch: submitted=%s returned=%s — rejecting",
data.email,
identity.email,
)
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Username already taken. Please choose a different username.",
)
if is_first_user:
identity.email_verified = True
identity.is_active = True
await db.flush()
# 4. Create Tenant User (Handles OrgMember binding and Participant creation)
user = await registration_service.create_user_with_identity(
db,
identity=identity,
display_name=data.display_name or data.username,
role=role,
tenant_id=tenant_uuid,
registration_source="web"
)
# Seed default agents for first user
if is_first_user:
await db.commit()
try:
from app.services.agent_seeder import seed_default_agents
await seed_default_agents()
except Exception as e:
logger.warning(f"Failed to seed default agents: {e}")
# Send verification email
await _send_verification_email_task(user, background_tasks, settings, db)
return RegisterInitResponse(
user_id=user.id,
email=user.email,
access_token=create_access_token(str(user.id), user.role),
user=UserOut.model_validate(user),
message="Registration successful. Please verify your email.",
needs_company_setup=user.tenant_id is None,
)
async def _handle_sso_register(data: UserRegister, db: AsyncSession):
"""Legacy SSO registration handler - delegates to new SSO endpoint logic."""
# Redirect to new SSO flow
sso_data = SSORegisterRequest(
provider=data.provider,
code=data.provider_code,
invitation_code=data.invitation_code
)
return await register_sso(sso_data, db)
@router.post("/login", response_model=Any)
async def login(data: UserLogin, background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db)):
"""Login with email/phone/username and password. Supports multi-tenant selection."""
from app.models.tenant import Tenant
from app.models.user import Identity, User
# 1. Query Identity
query = select(Identity).where(
(Identity.email == data.login_identifier) |
(Identity.phone == data.login_identifier) |
(Identity.username == data.login_identifier)
)
result = await db.execute(query)
identity = result.scalar_one_or_none()
if not identity or not identity.password_hash or not verify_password(data.password, identity.password_hash):
logger.warning(f"[LOGIN] Invalid credentials for {data.login_identifier} identity_id={identity.id if identity else 'None'}")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
# 2. Check Global Activity & Verification
if not identity.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Your account has been disabled.")
if not identity.email_verified:
from app.config import get_settings
# Find any user record (just for the task)
user_res = await db.execute(select(User).where(User.identity_id == identity.id).limit(1))
user = user_res.scalar_one_or_none()
# Trigger email delivery in background
if user:
await _send_verification_email_task(user, background_tasks, get_settings(), db)
# Consistent with identity-first flow: Return 403 Forbidden with verification intent
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"needs_verification": True,
"email": identity.email,
"message": "Please verify your email to continue."
}
)
# 3. Find all User records (tenants)
result = await db.execute(select(User).where(User.identity_id == identity.id).options(selectinload(User.identity)))
valid_users = list(result.scalars().all())
if not valid_users:
# User has an identity but no tenant records? Should they create one?
# Create a "tenant-less" user if needed, or redirect to company setup
# For now, if no users, they need company setup.
# But wait, register_init should have created one.
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No organization associated with this account.")
# 4. Handle Tenant Selection
if not data.tenant_id:
# If multiple tenants, return choice
if len(valid_users) > 1:
tenant_ids = [u.tenant_id for u in valid_users if u.tenant_id]
tenants_map = {}
if tenant_ids:
tenants_result = await db.execute(
select(Tenant).where(Tenant.id.in_(tenant_ids))
)
tenants_map = {str(t.id): t for t in tenants_result.scalars().all()}
tenant_choices = []
for u in valid_users:
tenant = tenants_map.get(str(u.tenant_id)) if u.tenant_id else None
tenant_choices.append(TenantChoice(
tenant_id=u.tenant_id,
tenant_name=tenant.name if tenant else "Create or Join Organization",
tenant_slug=tenant.slug if tenant else "",
))
return MultiTenantResponse(
requires_tenant_selection=True,
login_identifier=data.login_identifier,
tenants=tenant_choices,
)
# Only one tenant
user = valid_users[0]
else:
# Specific tenant requested (Dedicated Link flow)
# Search for the user record in that tenant
user = next((u for u in valid_users if u.tenant_id == data.tenant_id), None)
# Cross-tenant access check
if not user:
# Even platform admins must have a valid record in the targeted tenant
# when logging in via a dedicated tenant URL / tenant_id.
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="This account does not belong to the selected organization.",
)
if user.tenant_id:
t_result = await db.execute(select(Tenant).where(Tenant.id == user.tenant_id))
tenant = t_result.scalar_one_or_none()
if tenant and not tenant.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Your organization has been disabled.",
)
# 6. Generate Token
token = create_access_token(str(user.id), user.role)
return TokenResponse(
access_token=token,
user=UserOut.model_validate(user),
identity=IdentityOut.model_validate(identity),
needs_company_setup=user.tenant_id is None,
)
@router.get("/email-hint")
async def get_email_hint(username: str, db: AsyncSession = Depends(get_db)):
"""Return a hinted email address for a given username."""
from app.models.user import Identity
result = await db.execute(select(Identity).where(Identity.username == username))
identity = result.scalar_one_or_none()
if not identity or not identity.email:
raise HTTPException(status_code=404, detail="Account not found.")
email = identity.email
parts = email.split("@")
if len(parts) == 2:
name, domain = parts
# Obfuscate name
if len(name) <= 2:
obs_name = name[0] + "***"
else:
obs_name = name[:2] + "***" + name[-1]
# Obfuscate domain
domain_parts = domain.split(".")
if len(domain_parts) >= 2:
d_name = domain_parts[0]
d_ext = ".".join(domain_parts[1:])
if len(d_name) <= 2:
obs_domain = d_name[0] + "***." + d_ext
else:
obs_domain = d_name[0] + "***" + d_name[-1] + "." + d_ext
hint = f"{obs_name}@{obs_domain}"
else:
hint = f"{obs_name}@{domain}"
else:
hint = email[:3] + "***"
return {"hint": hint}
@router.post("/forgot-password")
async def forgot_password(
data: ForgotPasswordRequest,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
):
"""Request a password reset link for a global Identity."""
from app.services.system_email_service import resolve_email_config_async
email_config = await resolve_email_config_async(db)
if not email_config:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Password reset is currently unavailable (no mail server configured)."
)
generic_response = {
"ok": True,
"message": "If an account with that email exists, a password reset email has been sent.",
}
# Find Identity by email
identity_query = select(Identity).where(Identity.email == data.email)
identity_result = await db.execute(identity_query)
identity = identity_result.scalar_one_or_none()
if not identity or not identity.is_active:
return generic_response
try:
from app.services.password_reset_service import build_password_reset_url, create_password_reset_token
from app.services.system_email_service import (
send_password_reset_email,
)
raw_token, expires_at = await create_password_reset_token(identity.id)
reset_url = await build_password_reset_url(db, raw_token)
expiry_minutes = int((expires_at - datetime.now(timezone.utc)).total_seconds() // 60)
background_tasks.add_task(
send_password_reset_email,
identity.email,
identity.username or "User",
reset_url,
expiry_minutes,
)
except Exception as exc:
logger.warning(f"Failed to process password reset email for {data.email}: {exc}")
return generic_response
@router.post("/reset-password")
async def reset_password(data: ResetPasswordRequest, db: AsyncSession = Depends(get_db)):
"""Reset a password using a valid single-use token."""
from app.services.password_reset_service import consume_password_reset_token
token_data = await consume_password_reset_token(data.token)
if not token_data:
raise HTTPException(status_code=400, detail="Invalid or expired reset token")
identity_id = token_data["identity_id"]
result = await db.execute(select(Identity).where(Identity.id == identity_id))
identity = result.scalar_one_or_none()
if not identity or not identity.is_active:
raise HTTPException(status_code=400, detail="Invalid or expired reset token")
new_hash = hash_password(data.new_password)
identity.password_hash = new_hash
await db.flush()
await db.commit()
return {"ok": True}
@router.get("/me", response_model=UserOut)
async def get_me(current_user: User = Depends(get_authenticated_user)):
"""Get current user profile."""
return UserOut.model_validate(current_user)
@router.patch("/me", response_model=UserOut)
async def update_me(
data: UserUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Update current user profile."""
update_data = data.model_dump(exclude_unset=True)
# Validate username uniqueness if changing
if "username" in update_data and update_data["username"] != current_user.username:
existing = await db.execute(
select(User)
.join(Identity, User.identity_id == Identity.id)
.where(Identity.username == update_data["username"])
)
if existing.scalars().first():
raise HTTPException(status_code=409, detail="Username already taken")
# Validate email uniqueness within tenant if changing
if "email" in update_data and update_data["email"] != current_user.email:
existing = await db.execute(
select(User)
.join(Identity, User.identity_id == Identity.id)
.where(
Identity.email == update_data["email"],
User.tenant_id == current_user.tenant_id,
User.id != current_user.id,
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Email already registered")
# Validate mobile uniqueness within tenant if changing
if "primary_mobile" in update_data and update_data["primary_mobile"] != current_user.primary_mobile:
existing = await db.execute(
select(User)
.join(Identity, User.identity_id == Identity.id)
.where(
Identity.phone == update_data["primary_mobile"],
User.tenant_id == current_user.tenant_id,
User.id != current_user.id,
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Mobile already registered")
for field, value in update_data.items():
setattr(current_user, field, value)
await db.flush()
# Sync email/phone to OrgMember if changed
if "email" in update_data or "primary_mobile" in update_data:
from app.services.registration_service import registration_service
await registration_service.sync_org_member_contact_from_user(
db,
current_user,
sync_email="email" in update_data,
sync_phone="primary_mobile" in update_data,
)
return UserOut.model_validate(current_user)
@router.get("/my-tenants", response_model=list[TenantChoice])
async def get_my_tenants(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get all tenants associated with the current user's identity."""
from app.models.tenant import Tenant
# 1. Get all user records for this identity
result = await db.execute(
select(User).where(User.identity_id == current_user.identity_id)
)
users = result.scalars().all()
# 2. Extract tenant IDs
tenant_ids = [u.tenant_id for u in users if u.tenant_id]
if not tenant_ids:
return []
# 3. Get tenant details
result = await db.execute(
select(Tenant).where(Tenant.id.in_(tenant_ids))
)
tenants = result.scalars().all()
return [
TenantChoice(
tenant_id=t.id,
tenant_name=t.name,
tenant_slug=t.slug
) for t in tenants
]
@router.post("/switch-tenant", response_model=TenantSwitchResponse)
async def switch_tenant(
data: TenantSwitchRequest,
request: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Switch to a different tenant and return a new token and redirect URL."""
from app.models.tenant import Tenant
from app.models.system_settings import SystemSetting
# 1. Verify membership
result = await db.execute(
select(User).where(
User.identity_id == current_user.identity_id,
User.tenant_id == data.tenant_id
)
)
target_user = result.scalar_one_or_none()
if not target_user:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You do not have access to this organization."
)
# 2. Get tenant details
result = await db.execute(select(Tenant).where(Tenant.id == data.tenant_id))
tenant = result.scalar_one_or_none()
if not tenant or not tenant.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="This organization is currently unavailable."
)
# 3. Generate new token
token = create_access_token(str(target_user.id), target_user.role)
# 4. Determine redirect URL
# Determine redirect URL (Priority: sso_domain > ENV > Request > Fallback)
from app.services.platform_service import platform_service
redirect_url = await platform_service.get_tenant_sso_base_url(db, tenant, request)
# Include token in redirect URL for cross-domain switching if needed
if redirect_url:
separator = "&" if "?" in redirect_url else "?"
redirect_url = f"{redirect_url}{separator}token={token}"
return TenantSwitchResponse(
access_token=token,
redirect_url=redirect_url,
message="Switching organization..."
)
@router.put("/me/password")
async def change_password(
data: dict,
current_user: User = Depends(get_authenticated_user),
db: AsyncSession = Depends(get_db),
):
"""Change current user's password. Updates the global identity password."""
old_password = data.get("old_password", "")
new_password = data.get("new_password", "")
if not old_password or not new_password:
raise HTTPException(status_code=400, detail="Both old_password and new_password are required")
if len(new_password) < 6:
raise HTTPException(status_code=400, detail="New password must be at least 6 characters")
# Access identity through current_user (TenantUser)
res = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.identity)))
user = res.scalar_one()
identity = user.identity
if not identity or not identity.password_hash or not verify_password(old_password, identity.password_hash):
raise HTTPException(status_code=400, detail="Current password is incorrect")
new_hash = hash_password(new_password)
identity.password_hash = new_hash
await db.flush()
await db.commit()
return {"ok": True}
# ─── SSO/OAuth Endpoints ─────────────────────────────────────────────
@router.get("/providers")
async def list_providers(
db: AsyncSession = Depends(get_db),
tenant_id: uuid.UUID | None = Query(None, description="Optional tenant ID"),
):
"""List all available identity providers."""
from app.services.auth_registry import auth_provider_registry
providers = await auth_provider_registry.list_providers(db, str(tenant_id) if tenant_id else None)
return [{"id": str(p.id), "provider_type": p.provider_type, "name": p.name, "is_active": p.is_active} for p in providers]
@router.get("/{provider}/authorize", response_model=OAuthAuthorizeResponse)
async def authorize(
provider: str,
redirect_uri: str = Query(..., description="OAuth callback URI"),
state: str = Query("", description="CSRF state parameter"),
db: AsyncSession = Depends(get_db),
):
"""Start OAuth authorization flow for a provider."""
from app.services.auth_registry import auth_provider_registry
from app.services.sso_service import sso_service
# Get provider
auth_provider = await auth_provider_registry.get_provider(db, provider)
if not auth_provider:
raise HTTPException(status_code=404, detail=f"Provider '{provider}' not supported")
# Generate authorization URL
try:
auth_url = await auth_provider.get_authorization_url(redirect_uri, state)
except NotImplementedError as e:
raise HTTPException(status_code=501, detail=str(e))
except Exception as e:
logger.error(f"Failed to generate authorization URL for {provider}: {e}")
raise HTTPException(status_code=500, detail="Failed to generate authorization URL")
return OAuthAuthorizeResponse(authorization_url=auth_url)
@router.post("/{provider}/callback", response_model=TokenResponse)
async def oauth_callback(
provider: str,
data: OAuthCallbackRequest,
db: AsyncSession = Depends(get_db),
):
"""Handle OAuth callback and login/register user."""
from app.services.auth_registry import auth_provider_registry
# Get provider
auth_provider = await auth_provider_registry.get_provider(db, provider)
if not auth_provider:
raise HTTPException(status_code=404, detail=f"Provider '{provider}' not supported")
try:
# Exchange code for token
token_data = await auth_provider.exchange_code_for_token(data.code)
access_token = token_data.get("access_token")
if not access_token:
raise HTTPException(status_code=400, detail="Failed to get access token from provider")
# Get user info
user_info = await auth_provider.get_user_info(access_token)
# Find or create user
user, is_new = await auth_provider.find_or_create_user(db, user_info)
if not user:
raise HTTPException(status_code=500, detail="Failed to create user")
if not user.is_active:
raise HTTPException(status_code=403, detail="Account is disabled")
except HTTPException:
raise
except Exception as e:
logger.error(f"OAuth callback failed for {provider}: {e}")
raise HTTPException(status_code=500, detail="OAuth authentication failed")
# Generate JWT token
jwt_token = create_access_token(str(user.id), user.role)
return TokenResponse(
access_token=jwt_token,
user=UserOut.model_validate(user),
needs_company_setup=user.tenant_id is None,
)
@router.post("/{provider}/bind", response_model=UserOut)
async def bind_identity(
provider: str,
data: IdentityBindRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Bind an external identity to the current user."""
from app.services.auth_registry import auth_provider_registry
from app.services.sso_service import sso_service
# Get provider
auth_provider = await auth_provider_registry.get_provider(db, provider)
if not auth_provider:
raise HTTPException(status_code=404, detail=f"Provider '{provider}' not supported")
try:
# Exchange code for token
token_data = await auth_provider.exchange_code_for_token(data.code)
access_token = token_data.get("access_token")
if not access_token:
raise HTTPException(status_code=400, detail="Failed to get access token from provider")
# Get user info
user_info = await auth_provider.get_user_info(access_token)
# Check if identity is already linked to another user
existing_user = await sso_service.check_duplicate_identity(db, provider, user_info.provider_user_id)
if existing_user and existing_user.id != current_user.id:
raise HTTPException(
status_code=409,
detail="This identity is already linked to another account",
)
# Link identity to current user
await sso_service.link_identity(
db,
str(current_user.id),
provider,
user_info.provider_user_id,
user_info.raw_data,
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Identity bind failed for {provider}: {e}")
raise HTTPException(status_code=500, detail="Failed to bind identity")
return UserOut.model_validate(current_user)
@router.post("/{provider}/unbind", response_model=UserOut)
async def unbind_identity(
provider: str,
data: IdentityUnbindRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Unlink an external identity from the current user."""
from app.services.sso_service import sso_service
# Unlink identity
success = await sso_service.unlink_identity(db, str(current_user.id), provider)
if not success:
raise HTTPException(status_code=404, detail=f"No linked identity found for provider '{provider}'")
return UserOut.model_validate(current_user)
# ─── Email Verification Endpoints ──────────────────────────────────────
@router.post("/verify-email")
async def verify_email(data: VerifyEmailRequest, db: AsyncSession = Depends(get_db)):
"""Verify email address using a token from the verification email.
On success, returns user info and access token to allow immediate login.
"""
from app.services.email_verification_service import email_verification_service
token_data = await email_verification_service.consume_email_verification_token(data.token)
if not token_data:
raise HTTPException(status_code=400, detail="Invalid or expired verification token")
identity_id = token_data.get("identity_id")
if not identity_id:
raise HTTPException(status_code=400, detail="Token does not contain identity information")
# 1. Update Identity
identity_result = await db.execute(select(Identity).where(Identity.id == identity_id))
identity = identity_result.scalar_one_or_none()
if not identity:
raise HTTPException(status_code=400, detail="Identity not found")
identity.email_verified = True
identity.is_active = True
# 2. Activate all linked User accounts
# email_verified is a proxy to Identity, so only update physical is_active column
from sqlalchemy import update
await db.execute(
update(User)
.where(User.identity_id == identity.id)
.values(is_active=True)
)
await db.flush()
await db.commit()
# Refresh after commit to avoid MissingGreenlet during Pydantic validation
await db.refresh(identity)
# 3. Find a representative user for the token (for immediate login)
user_result = await db.execute(
select(User)
.where(User.identity_id == identity.id)
.order_by(User.created_at.desc())
.limit(1)
)
user = user_result.scalar_one_or_none()
# 4. Generate token and return full response for Auto Login (TokenResponse)
effective_id = str(user.id) if user else str(identity.id)
effective_role = user.role if user else "user"
token = create_access_token(effective_id, effective_role)
return TokenResponse(
access_token=token,
user=UserOut.model_validate(user) if user else None,
identity=IdentityOut.model_validate(identity),
needs_company_setup=user.tenant_id is None if user else True,
)
@router.post("/resend-verification")
async def resend_verification(
data: ResendVerificationRequest,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
):
"""Resend email verification link."""
from app.services.system_email_service import resolve_email_config_async
# Always return success to prevent email enumeration
generic_response = {
"ok": True,
"message": "If an account with that email exists, a verification email has been sent.",
}
# Check if email is configured (DB-only, no env fallback)
email_config = await resolve_email_config_async(db)
if not email_config:
return generic_response
# Find Identity by email
id_result = await db.execute(select(Identity).where(Identity.email == data.email))
identity = id_result.scalar_one_or_none()
# Don't reveal if user exists or already verified
if not identity or identity.email_verified:
return generic_response
# Pick a representative user context (e.g. latest one)
u_result = await db.execute(
select(User).where(User.identity_id == identity.id).order_by(User.created_at.desc()).limit(1)
)
user = u_result.scalar_one_or_none()
if user:
await _send_verification_email_task(user, background_tasks, settings, db)
return generic_response