diff options
| author | l3wdfut4pwr <l3wdfut4pwr@gmail.com> | 2026-04-29 02:08:02 +0300 |
|---|---|---|
| committer | l3wdfut4pwr <l3wdfut4pwr@gmail.com> | 2026-04-29 02:08:02 +0300 |
| commit | ea58f7b3b514098f365c50facc5fc212459acac0 (patch) | |
| tree | 64924839f51d274cd710b1e88ed0edcad8d7f812 | |
| parent | 5d18f873e9b72bd00d69e42a10c566d44a0c5255 (diff) | |
add google auth
| -rw-r--r-- | app/main.py | 8 | ||||
| -rw-r--r-- | app/routes/__init__.py | 3 | ||||
| -rw-r--r-- | app/routes/auth/auth_google.py | 206 | ||||
| -rw-r--r-- | app/routes/users/user.py | 23 | ||||
| -rw-r--r-- | app/schemas/user.py | 11 | ||||
| -rw-r--r-- | pyproject.toml | 2 | ||||
| -rw-r--r-- | uv.lock | 50 |
7 files changed, 300 insertions, 3 deletions
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", + } diff --git a/pyproject.toml b/pyproject.toml index bf0786a..2ffc365 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,8 @@ dependencies = [ "authlib>=1.6.11", "email-validator>=2.3.0", "fastapi>=0.136.0", + "httpx>=0.28.1", + "itsdangerous>=2.2.0", "loguru>=0.7.3", "pydantic>=2.13.2", "pyjwt>=2.12.1", @@ -85,6 +85,8 @@ dependencies = [ { name = "authlib" }, { name = "email-validator" }, { name = "fastapi" }, + { name = "httpx" }, + { name = "itsdangerous" }, { name = "loguru" }, { name = "pydantic" }, { name = "pyjwt" }, @@ -107,6 +109,8 @@ requires-dist = [ { name = "authlib", specifier = ">=1.6.11" }, { name = "email-validator", specifier = ">=2.3.0" }, { name = "fastapi", specifier = ">=0.136.0" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "itsdangerous", specifier = ">=2.2.0" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "pydantic", specifier = ">=2.13.2" }, { name = "pyjwt", specifier = ">=2.12.1" }, @@ -157,6 +161,15 @@ wheels = [ ] [[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[package]] name = "cffi" version = "2.0.0" source = { registry = "https://pypi.org/simple" } @@ -346,6 +359,34 @@ wheels = [ ] [[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] name = "idna" version = "3.11" source = { registry = "https://pypi.org/simple" } @@ -355,6 +396,15 @@ wheels = [ ] [[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] name = "limits" version = "5.8.0" source = { registry = "https://pypi.org/simple" } |
