Files
personal-ai-assistant/backend/app/api/v1/auth.py
dolgolyov.alexei 5c651b7988 Phase 9: OAuth & Account Switching — Google + Authentik, multi-account
Backend:
- OAuth service with pluggable provider architecture (Google + Authentik)
- Generic authorize/callback endpoints for any provider
- Authentik OIDC integration (configurable base URL)
- hashed_password made nullable for OAuth-only users
- Migration 009: nullable password column
- /auth/switch endpoint returns full AuthResponse for account switching
- OAuth-only users get clear error on password login attempt
- UserResponse includes oauth_provider + avatar_url

Frontend:
- OAuth buttons on login form (Google + Authentik)
- OAuth callback handler (/auth/callback route)
- Multi-account auth store (accounts array, addAccount, switchTo, removeAccount)
- Account switcher dropdown in header (hover to see other accounts)
- "Add another account" option
- English + Russian translations

Config:
- GOOGLE_CLIENT_ID/SECRET/REDIRECT_URI
- AUTHENTIK_CLIENT_ID/SECRET/BASE_URL/REDIRECT_URI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 15:56:20 +03:00

130 lines
4.1 KiB
Python

from typing import Annotated
from fastapi import APIRouter, Depends, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.database import get_db
from app.models.user import User
from app.schemas.auth import (
AuthResponse,
LoginRequest,
RefreshRequest,
TokenResponse,
UserResponse,
RegisterRequest,
)
from app.core.rate_limit import check_rate_limit
from app.services import auth_service
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=AuthResponse, status_code=status.HTTP_201_CREATED)
async def register(
data: RegisterRequest,
request: Request,
db: Annotated[AsyncSession, Depends(get_db)],
):
await check_rate_limit(request)
from app.services.setting_service import get_setting_value
registration_enabled = await get_setting_value(db, "self_registration_enabled", True)
if not registration_enabled:
from fastapi import HTTPException
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Registration is currently disabled")
return await auth_service.register_user(
db,
data,
ip_address=request.client.host if request.client else None,
device_info=request.headers.get("user-agent"),
)
@router.post("/login", response_model=AuthResponse)
async def login(
data: LoginRequest,
request: Request,
db: Annotated[AsyncSession, Depends(get_db)],
):
await check_rate_limit(request)
return await auth_service.login_user(
db,
email=data.email,
password=data.password,
remember_me=data.remember_me,
ip_address=request.client.host if request.client else None,
device_info=request.headers.get("user-agent"),
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh(
data: RefreshRequest,
db: Annotated[AsyncSession, Depends(get_db)],
):
return await auth_service.refresh_tokens(db, data.refresh_token)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(
data: RefreshRequest,
db: Annotated[AsyncSession, Depends(get_db)],
):
await auth_service.logout_user(db, data.refresh_token)
@router.get("/me", response_model=UserResponse)
async def me(user: Annotated[User, Depends(get_current_user)]):
return UserResponse.model_validate(user)
# --- OAuth ---
@router.get("/oauth/{provider}/authorize")
async def oauth_authorize(provider: str):
from app.services.oauth_service import get_authorize_url
url = await get_authorize_url(provider)
return {"authorize_url": url}
@router.get("/oauth/{provider}/callback")
async def oauth_callback(
provider: str,
code: str,
request: Request,
db: Annotated[AsyncSession, Depends(get_db)],
):
from app.services.oauth_service import handle_callback
result = await handle_callback(
provider, code, db,
ip_address=request.client.host if request.client else None,
device_info=request.headers.get("user-agent"),
)
# Redirect to frontend with tokens
from fastapi.responses import RedirectResponse
redirect_url = f"/auth/callback?access_token={result['access_token']}&refresh_token={result['refresh_token']}"
return RedirectResponse(url=redirect_url)
@router.post("/switch", response_model=AuthResponse)
async def switch_account(
data: RefreshRequest,
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Switch to another account using its refresh token. Returns full AuthResponse."""
tokens = await auth_service.refresh_tokens(db, data.refresh_token)
# Get user from new access token
from app.core.security import decode_access_token
import uuid as uuid_mod
payload = decode_access_token(tokens.access_token)
user_id = uuid_mod.UUID(payload["sub"])
from sqlalchemy import select
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one()
return AuthResponse(
user=UserResponse.model_validate(user),
access_token=tokens.access_token,
refresh_token=tokens.refresh_token,
)