From 5c651b798851ed6e1fe7dcd8dd82f874c67052e9 Mon Sep 17 00:00:00 2001 From: "dolgolyov.alexei" Date: Thu, 19 Mar 2026 15:56:20 +0300 Subject: [PATCH] =?UTF-8?q?Phase=209:=20OAuth=20&=20Account=20Switching=20?= =?UTF-8?q?=E2=80=94=20Google=20+=20Authentik,=20multi-account?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- GeneralPlan.md | 8 +- .../versions/009_oauth_nullable_password.py | 24 +++ backend/app/api/v1/auth.py | 50 ++++++ backend/app/config.py | 9 ++ backend/app/models/user.py | 2 +- backend/app/schemas/auth.py | 2 + backend/app/services/auth_service.py | 4 +- backend/app/services/oauth_service.py | 152 ++++++++++++++++++ backend/pyproject.toml | 2 + frontend/public/locales/en/translation.json | 7 +- frontend/public/locales/ru/translation.json | 7 +- frontend/src/api/auth.ts | 14 ++ frontend/src/components/auth/login-form.tsx | 23 ++- .../src/components/auth/oauth-callback.tsx | 37 +++++ frontend/src/components/layout/header.tsx | 35 +++- frontend/src/routes.tsx | 5 + frontend/src/stores/auth-store.ts | 62 ++++++- plans/phase-9-oauth.md | 26 +-- 18 files changed, 436 insertions(+), 33 deletions(-) create mode 100644 backend/alembic/versions/009_oauth_nullable_password.py create mode 100644 backend/app/services/oauth_service.py create mode 100644 frontend/src/components/auth/oauth-callback.tsx diff --git a/GeneralPlan.md b/GeneralPlan.md index 5cd8ba9..76a9c12 100644 --- a/GeneralPlan.md +++ b/GeneralPlan.md @@ -252,10 +252,10 @@ Daily scheduled job (APScheduler, 8 AM) reviews each user's memory + recent docs - Summary: Admin-managed Jinja2 PDF templates in DB with locale support (en/ru), template selector for users/AI, live preview editor, basic + medical seed templates ### Phase 9: OAuth & Account Switching -- **Status**: NOT STARTED -- [ ] Subplan created (`plans/phase-9-oauth.md`) -- [ ] Phase completed -- Summary: OAuth (Google, GitHub), account switching UI, multiple stored sessions +- **Status**: COMPLETED +- [x] Subplan created (`plans/phase-9-oauth.md`) +- [x] Phase completed +- Summary: OAuth (Google + Authentik), account switching UI, multi-account store ### Phase 10: Per-User Rate Limits - **Status**: COMPLETED diff --git a/backend/alembic/versions/009_oauth_nullable_password.py b/backend/alembic/versions/009_oauth_nullable_password.py new file mode 100644 index 0000000..02f359c --- /dev/null +++ b/backend/alembic/versions/009_oauth_nullable_password.py @@ -0,0 +1,24 @@ +"""Make hashed_password nullable for OAuth users + +Revision ID: 009 +Revises: 008 +Create Date: 2026-03-19 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "009" +down_revision: Union[str, None] = "008" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.alter_column("users", "hashed_password", nullable=True) + + +def downgrade() -> None: + op.alter_column("users", "hashed_password", nullable=False) diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index 81b0637..645bff8 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -77,3 +77,53 @@ async def logout( @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, + ) diff --git a/backend/app/config.py b/backend/app/config.py index 539a262..3ba5f0e 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -23,6 +23,15 @@ class Settings(BaseSettings): RATE_LIMIT_REQUESTS: int = 20 RATE_LIMIT_WINDOW_SECONDS: int = 60 + GOOGLE_CLIENT_ID: str = "" + GOOGLE_CLIENT_SECRET: str = "" + GOOGLE_REDIRECT_URI: str = "http://localhost/api/v1/auth/oauth/google/callback" + + AUTHENTIK_CLIENT_ID: str = "" + AUTHENTIK_CLIENT_SECRET: str = "" + AUTHENTIK_BASE_URL: str = "" # e.g. https://auth.example.com + AUTHENTIK_REDIRECT_URI: str = "http://localhost/api/v1/auth/oauth/authentik/callback" + FIRST_ADMIN_EMAIL: str = "admin@example.com" FIRST_ADMIN_USERNAME: str = "admin" FIRST_ADMIN_PASSWORD: str = "changeme_admin_password" diff --git a/backend/app/models/user.py b/backend/app/models/user.py index fed0d3f..7ebea09 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -11,7 +11,7 @@ class User(Base): email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False) username: Mapped[str] = mapped_column(String(100), unique=True, index=True, nullable=False) - hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) + hashed_password: Mapped[str | None] = mapped_column(String(255), nullable=True) full_name: Mapped[str | None] = mapped_column(String(255), nullable=True) role: Mapped[str] = mapped_column(String(20), nullable=False, default="user") is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index f1aa1aa..886ffca 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -29,6 +29,8 @@ class UserResponse(BaseModel): role: str is_active: bool max_chats: int + oauth_provider: str | None = None + avatar_url: str | None = None created_at: datetime model_config = {"from_attributes": True} diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index 4992546..4ed0f4b 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -94,10 +94,10 @@ async def login_user( result = await db.execute(select(User).where(User.email == email)) user = result.scalar_one_or_none() - if not user or not verify_password(password, user.hashed_password): + if not user or not user.hashed_password or not verify_password(password, user.hashed_password): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid email or password", + detail="Invalid email or password" if not user or user.hashed_password else "This account uses OAuth login. Please sign in with your provider.", ) if not user.is_active: diff --git a/backend/app/services/oauth_service.py b/backend/app/services/oauth_service.py new file mode 100644 index 0000000..77cf872 --- /dev/null +++ b/backend/app/services/oauth_service.py @@ -0,0 +1,152 @@ +from datetime import datetime, timedelta, timezone + +from authlib.integrations.httpx_client import AsyncOAuth2Client +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.core.security import create_access_token, generate_refresh_token, hash_refresh_token +from app.models.session import Session +from app.models.user import User + +# --- Provider configs --- + +PROVIDERS = { + "google": { + "authorize_url": "https://accounts.google.com/o/oauth2/v2/auth", + "token_url": "https://oauth2.googleapis.com/token", + "userinfo_url": "https://www.googleapis.com/oauth2/v3/userinfo", + "scope": "openid email profile", + }, +} + + +def _get_authentik_config(): + base = settings.AUTHENTIK_BASE_URL.rstrip("/") + return { + "authorize_url": f"{base}/application/o/authorize/", + "token_url": f"{base}/application/o/token/", + "userinfo_url": f"{base}/application/o/userinfo/", + "scope": "openid email profile", + } + + +def _get_provider_config(provider: str) -> dict: + if provider == "google": + return PROVIDERS["google"] + elif provider == "authentik": + if not settings.AUTHENTIK_BASE_URL: + raise HTTPException(status_code=400, detail="Authentik not configured") + return _get_authentik_config() + raise HTTPException(status_code=400, detail=f"Unsupported OAuth provider: {provider}") + + +def _get_client_credentials(provider: str) -> tuple[str, str, str]: + if provider == "google": + return settings.GOOGLE_CLIENT_ID, settings.GOOGLE_CLIENT_SECRET, settings.GOOGLE_REDIRECT_URI + elif provider == "authentik": + return settings.AUTHENTIK_CLIENT_ID, settings.AUTHENTIK_CLIENT_SECRET, settings.AUTHENTIK_REDIRECT_URI + raise HTTPException(status_code=400, detail=f"Unsupported provider: {provider}") + + +def _get_client(provider: str) -> AsyncOAuth2Client: + config = _get_provider_config(provider) + client_id, client_secret, redirect_uri = _get_client_credentials(provider) + return AsyncOAuth2Client( + client_id=client_id, + client_secret=client_secret, + redirect_uri=redirect_uri, + scope=config["scope"], + ) + + +async def get_authorize_url(provider: str) -> str: + config = _get_provider_config(provider) + client = _get_client(provider) + url, _ = client.create_authorization_url(config["authorize_url"]) + return url + + +async def handle_callback( + provider: str, code: str, db: AsyncSession, + ip_address: str | None = None, device_info: str | None = None, +) -> dict: + """Exchange code, get user info, create/link user, return auth tokens.""" + config = _get_provider_config(provider) + client = _get_client(provider) + + try: + await client.fetch_token(config["token_url"], code=code) + except Exception: + raise HTTPException(status_code=400, detail="Failed to exchange OAuth code") + + resp = await client.get(config["userinfo_url"]) + if resp.status_code != 200: + raise HTTPException(status_code=400, detail=f"Failed to get user info from {provider}") + + userinfo = resp.json() + email = userinfo.get("email") + name = userinfo.get("name") or userinfo.get("preferred_username") + picture = userinfo.get("picture") + provider_id = userinfo.get("sub") + + if not email: + raise HTTPException(status_code=400, detail=f"{provider} account has no email") + + # Find or create user + result = await db.execute( + select(User).where(User.oauth_provider == provider, User.oauth_provider_id == provider_id) + ) + user = result.scalar_one_or_none() + + if not user: + result = await db.execute(select(User).where(User.email == email)) + user = result.scalar_one_or_none() + + if user: + user.oauth_provider = provider + user.oauth_provider_id = provider_id + if picture: + user.avatar_url = picture + else: + username = email.split("@")[0] + base = username + counter = 1 + while True: + result = await db.execute(select(User).where(User.username == username)) + if not result.scalar_one_or_none(): + break + username = f"{base}{counter}" + counter += 1 + + user = User( + email=email, + username=username, + hashed_password=None, + full_name=name, + oauth_provider=provider, + oauth_provider_id=provider_id, + avatar_url=picture, + ) + db.add(user) + await db.flush() + + if not user.is_active: + raise HTTPException(status_code=403, detail="Account is deactivated") + + access_token = create_access_token(user.id, user.role) + refresh_token = generate_refresh_token() + expires_at = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS) + + session = Session( + user_id=user.id, + refresh_token_hash=hash_refresh_token(refresh_token), + device_info=device_info, + ip_address=ip_address, + expires_at=expires_at, + ) + db.add(session) + await db.flush() + + return {"user": user, "access_token": access_token, "refresh_token": refresh_token} diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 9d626ac..af18557 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -22,6 +22,8 @@ dependencies = [ "weasyprint>=62.0", "jinja2>=3.1.0", "python-json-logger>=2.0.0", + "authlib>=1.3.0", + "itsdangerous>=2.2.0", ] [project.optional-dependencies] diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index 15113fd..fa92437 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -22,7 +22,12 @@ "passwordMinLength": "Password must be at least 8 characters", "usernameFormat": "Username must be 3-50 characters (letters, numbers, _ or -)", "required": "This field is required" - } + }, + "orDivider": "or continue with", + "oauthGoogle": "Sign in with Google", + "oauthAuthentik": "Sign in with Authentik", + "addAccount": "Add another account", + "switchAccount": "Switch account" }, "layout": { "dashboard": "Dashboard", diff --git a/frontend/public/locales/ru/translation.json b/frontend/public/locales/ru/translation.json index f8a62ec..0b32dcc 100644 --- a/frontend/public/locales/ru/translation.json +++ b/frontend/public/locales/ru/translation.json @@ -22,7 +22,12 @@ "passwordMinLength": "Пароль должен содержать минимум 8 символов", "usernameFormat": "Имя пользователя: 3-50 символов (буквы, цифры, _ или -)", "required": "Это поле обязательно" - } + }, + "orDivider": "или войти через", + "oauthGoogle": "Войти через Google", + "oauthAuthentik": "Войти через Authentik", + "addAccount": "Добавить аккаунт", + "switchAccount": "Сменить аккаунт" }, "layout": { "dashboard": "Главная", diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 6668f0f..7c4535a 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -8,6 +8,8 @@ export interface UserResponse { role: string; is_active: boolean; max_chats: number; + oauth_provider: string | null; + avatar_url: string | null; created_at: string; } @@ -59,6 +61,18 @@ export async function refresh(refreshToken: string): Promise { return data; } +export async function getOAuthUrl(provider: string): Promise { + const { data } = await api.get<{ authorize_url: string }>(`/auth/oauth/${provider}/authorize`); + return data.authorize_url; +} + +export async function switchAccount(refreshToken: string): Promise { + const { data } = await api.post("/auth/switch", { + refresh_token: refreshToken, + }); + return data; +} + export async function logout(refreshToken: string): Promise { await api.post("/auth/logout", { refresh_token: refreshToken }); } diff --git a/frontend/src/components/auth/login-form.tsx b/frontend/src/components/auth/login-form.tsx index e868968..50359fc 100644 --- a/frontend/src/components/auth/login-form.tsx +++ b/frontend/src/components/auth/login-form.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Link, useNavigate } from "react-router-dom"; import { useAuthStore } from "@/stores/auth-store"; -import { login } from "@/api/auth"; +import { login, getOAuthUrl } from "@/api/auth"; export function LoginForm() { const { t } = useTranslation(); @@ -92,6 +92,27 @@ export function LoginForm() { {loading ? t("common.loading") : t("auth.login")} +
+
+
{t("auth.orDivider")}
+
+ + + + +

{t("auth.noAccount")}{" "} diff --git a/frontend/src/components/auth/oauth-callback.tsx b/frontend/src/components/auth/oauth-callback.tsx new file mode 100644 index 0000000..0469027 --- /dev/null +++ b/frontend/src/components/auth/oauth-callback.tsx @@ -0,0 +1,37 @@ +import { useEffect } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { useAuthStore } from "@/stores/auth-store"; +import { getMe } from "@/api/auth"; + +export function OAuthCallback() { + const navigate = useNavigate(); + const [params] = useSearchParams(); + const setAuth = useAuthStore((s) => s.setAuth); + + useEffect(() => { + const accessToken = params.get("access_token"); + const refreshToken = params.get("refresh_token"); + + if (accessToken && refreshToken) { + // Temporarily set tokens to make the API call work + useAuthStore.getState().setTokens(accessToken, refreshToken); + + getMe() + .then((user) => { + setAuth(user, accessToken, refreshToken); + navigate("/"); + }) + .catch(() => { + navigate("/login"); + }); + } else { + navigate("/login"); + } + }, [params, setAuth, navigate]); + + return ( +

+

Signing in...

+
+ ); +} diff --git a/frontend/src/components/layout/header.tsx b/frontend/src/components/layout/header.tsx index f42ee7d..8c21c73 100644 --- a/frontend/src/components/layout/header.tsx +++ b/frontend/src/components/layout/header.tsx @@ -1,6 +1,6 @@ import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; -import { Menu, Sun, Moon, LogOut, User } from "lucide-react"; +import { Menu, Sun, Moon, LogOut, User, ChevronDown } from "lucide-react"; import { useAuthStore } from "@/stores/auth-store"; import { useUIStore } from "@/stores/ui-store"; import { LanguageToggle } from "@/components/shared/language-toggle"; @@ -11,7 +11,7 @@ export function Header() { const { t } = useTranslation(); const navigate = useNavigate(); const user = useAuthStore((s) => s.user); - const { refreshToken, clearAuth } = useAuthStore(); + const { refreshToken, clearAuth, accounts, switchTo } = useAuthStore(); const { theme, setTheme, toggleSidebar } = useUIStore(); const handleLogout = async () => { @@ -52,13 +52,42 @@ export function Header() { {theme === "dark" ? : } -
+
{user?.full_name || user?.username} + {accounts.length > 1 && ( + + )} + + {accounts.length > 1 && ( +
+ {accounts.filter(a => a.user.id !== user?.id).map((account) => ( + + ))} + +
+ )} +