From ea58f7b3b514098f365c50facc5fc212459acac0 Mon Sep 17 00:00:00 2001 From: l3wdfut4pwr Date: Wed, 29 Apr 2026 02:08:02 +0300 Subject: add google auth --- app/main.py | 8 ++ app/routes/__init__.py | 3 +- app/routes/auth/auth_google.py | 206 +++++++++++++++++++++++++++++++++++++++++ app/routes/users/user.py | 23 ++++- app/schemas/user.py | 11 ++- 5 files changed, 248 insertions(+), 3 deletions(-) create mode 100644 app/routes/auth/auth_google.py (limited to 'app') diff --git a/app/main.py b/app/main.py index 49e7a1f..2aeead8 100644 --- a/app/main.py +++ b/app/main.py @@ -1,3 +1,4 @@ +import os import time from contextlib import asynccontextmanager from pathlib import Path @@ -5,6 +6,7 @@ from pathlib import Path from dotenv import load_dotenv from fastapi import FastAPI from sqlalchemy import text +from starlette.middleware.sessions import SessionMiddleware from app.routes import router as api_router from app.utils.cors import setup_cors @@ -61,5 +63,11 @@ async def lifespan(app: FastAPI): app = FastAPI(lifespan=lifespan) +app.add_middleware( + SessionMiddleware, + secret_key=os.getenv("SESSION_SECRET_KEY", "dev-secret"), + same_site="lax", + https_only=False, +) setup_cors(app) app.include_router(api_router, prefix="/api") diff --git a/app/routes/__init__.py b/app/routes/__init__.py index 65d9c36..b0a55c4 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -1,6 +1,7 @@ from fastapi import APIRouter from app.routes.auth.auth import router as auth_router +from app.routes.auth.auth_google import router as google_router from app.routes.auth.logout import router as logout_router from app.routes.auth.register import router as register_router from app.routes.users.changeusername import router as changeusername_router @@ -12,7 +13,7 @@ router = APIRouter() router.include_router(changeusername_router) router.include_router(me_router) router.include_router(user_router) - +router.include_router(google_router) router.include_router(auth_router, prefix="/auth") router.include_router(register_router, prefix="/auth") router.include_router(logout_router, prefix="/auth") diff --git a/app/routes/auth/auth_google.py b/app/routes/auth/auth_google.py new file mode 100644 index 0000000..3e25738 --- /dev/null +++ b/app/routes/auth/auth_google.py @@ -0,0 +1,206 @@ +import os +import random +import string +from pathlib import Path +from typing import Any + +from authlib.integrations.starlette_client import OAuth +from dotenv import load_dotenv +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import RedirectResponse +from sqlalchemy import insert, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.jwt import create_access_token, create_refresh_token +from app.models.integrations import UserIntegration +from app.models.profile import Profile +from app.models.user import User +from app.utils.db import get_async_session +from app.utils.logger_cfg import logger + +env_path = Path(__file__).resolve().parents[3] / ".env" +load_dotenv(env_path) + +GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID") +GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET") +FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:3000") + +if not GOOGLE_CLIENT_ID or not GOOGLE_CLIENT_SECRET: + raise RuntimeError("Google OAuth env not configured") + +router = APIRouter(prefix="/auth/google", tags=["auth"]) + +oauth = OAuth() +oauth.register( + name="google", + client_id=GOOGLE_CLIENT_ID, + client_secret=GOOGLE_CLIENT_SECRET, + server_metadata_url="https://accounts.google.com/.well-known/openid-configuration", + client_kwargs={"scope": "openid email profile"}, +) + + +@router.get("/login") +async def google_login(request: Request): + redirect_uri = request.url_for("google_callback") + logger.debug("Google redirect_uri: {}", redirect_uri) + + return await oauth.google.authorize_redirect(request, redirect_uri) + + +def generate_username(length: int = 8) -> str: + chars = string.ascii_letters + string.digits + return "".join(random.choices(chars, k=length)) + + +async def is_username_taken(session: AsyncSession, username: str) -> bool: + result = await session.execute( + select(User.id).where(User.username == username) + ) + return result.scalar_one_or_none() is not None + + +async def generate_unique_username( + session: AsyncSession, + max_attempts: int = 10, +) -> str: + for _ in range(max_attempts): + username = generate_username() + + if not await is_username_taken(session, username): + return username + + raise RuntimeError("Failed to generate unique username") + + +async def ensure_user_state(session: AsyncSession, user_id: int): + + profile = await session.execute( + select(Profile).where(Profile.user_id == user_id) + ) + profile = profile.scalar_one_or_none() + + if not profile: + session.add(Profile(user_id=user_id)) + logger.debug("Created missing Profile for user={}", user_id) + + integrations = await session.execute( + select(UserIntegration).where(UserIntegration.user_id == user_id) + ) + integrations = integrations.scalar_one_or_none() + + if not integrations: + session.add(UserIntegration(user_id=user_id)) + logger.debug("Created missing Integrations for user={}", user_id) + + await session.commit() + + +async def create_user_with_relations( + session: AsyncSession, + *, + email: str, + username: str, + google_id: str, +) -> User: + + result = await session.execute( + insert(User) + .values( + email=email, + username=username, + google_id=google_id, + password=None, + ) + .returning(User.id) + ) + + user_id = result.scalar_one() + + session.add(Profile(user_id=user_id)) + session.add(UserIntegration(user_id=user_id)) + + await session.commit() + + result = await session.execute(select(User).where(User.id == user_id)) + + return result.scalar_one() + + +@router.get("/callback", name="google_callback") +async def google_callback( + request: Request, + session: AsyncSession = Depends(get_async_session), +): + try: + token: dict[str, Any] = await oauth.google.authorize_access_token( + request + ) + logger.debug("OAuth token received: {}", token.keys()) + except Exception as e: + logger.exception("Google OAuth failed: {}", str(e)) + raise HTTPException(status_code=400, detail="OAuth failed") + + try: + user_info = token.get("userinfo") or await oauth.google.parse_id_token( + request, token + ) + except Exception as e: + logger.exception("Failed to parse user info: {}", str(e)) + raise HTTPException(status_code=400, detail="Invalid token") + + email: str | None = user_info.get("email") + google_id: str | None = user_info.get("sub") + + if not email or not google_id: + raise HTTPException(status_code=400, detail="Invalid Google response") + + result = await session.execute( + select(User).where(User.google_id == google_id) + ) + user = result.scalar_one_or_none() + + if not user: + logger.info("Creating new Google user: {}", email) + + username = await generate_unique_username(session) + + user = await create_user_with_relations( + session, + email=email, + username=username, + google_id=google_id, + ) + else: + logger.debug("User exists: {}", user.id) + + await ensure_user_state(session, user.id) + + access_token = create_access_token( + {"sub": str(user.id), "token_version": user.token_version} + ) + refresh_token = create_refresh_token( + {"sub": str(user.id), "token_version": user.token_version} + ) + + response = RedirectResponse(url=f"{FRONTEND_URL}/oauth-success") + + response.set_cookie( + key="access_token", + value=access_token, + httponly=True, + secure=False, + samesite="lax", + max_age=60 * 15, + ) + + response.set_cookie( + key="refresh_token", + value=refresh_token, + httponly=True, + secure=False, + samesite="lax", + max_age=60 * 60 * 24 * 7, + ) + + return response diff --git a/app/routes/users/user.py b/app/routes/users/user.py index ed0e898..034233e 100644 --- a/app/routes/users/user.py +++ b/app/routes/users/user.py @@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.auth.dependencies import get_current_user from app.models.user import User from app.schemas.profile import DescriptionUpdate -from app.schemas.user import ChangeEmail, ChangePassword, UserRead +from app.schemas.user import ChangeEmail, ChangePassword, SetPassword, UserRead from app.utils.db import get_async_session from app.utils.hash_cfg import hash_password, verify_password @@ -92,3 +92,24 @@ async def change_password( "success": True, "message": "Password updated successfully", } + + +@router.post("/password/set") +async def set_password( + data: SetPassword, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_async_session), +): + if user.password: + raise HTTPException(status_code=400, detail="Password already set") + + if data.new_password != data.repeat_password: + raise HTTPException(status_code=400, detail="Passwords do not match") + + user.password = hash_password(data.new_password) + + session.add(user) + await session.commit() + await session.refresh(user) + + return {"success": True} diff --git a/app/schemas/user.py b/app/schemas/user.py index 9f67ff1..bee48aa 100644 --- a/app/schemas/user.py +++ b/app/schemas/user.py @@ -1,6 +1,6 @@ from typing import Optional -from pydantic import BaseModel, EmailStr, Field +from pydantic import BaseModel, EmailStr, Field, field_validator from app.schemas.profile import ProfileRead @@ -49,3 +49,12 @@ class ChangePassword(BaseModel): current_password: str = Field(..., min_length=8) new_password: str = Field(..., min_length=8) repeat_password: str = Field(..., min_length=8) + + +class SetPassword(BaseModel): + new_password: str = Field(..., min_length=8) + repeat_password: str = Field(..., min_length=8) + + model_config = { + "extra": "forbid", + } -- cgit v1.3-3-g829e