summaryrefslogtreecommitdiff
path: root/app/routes
diff options
context:
space:
mode:
Diffstat (limited to 'app/routes')
-rw-r--r--app/routes/__init__.py3
-rw-r--r--app/routes/auth/auth_google.py206
-rw-r--r--app/routes/users/user.py23
3 files changed, 230 insertions, 2 deletions
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}