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>
130 lines
4.1 KiB
Python
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,
|
|
)
|