summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/main.py8
-rw-r--r--app/routes/__init__.py3
-rw-r--r--app/routes/auth/auth_google.py206
-rw-r--r--app/routes/users/user.py23
-rw-r--r--app/schemas/user.py11
-rw-r--r--pyproject.toml2
-rw-r--r--uv.lock50
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",
diff --git a/uv.lock b/uv.lock
index 876cbe9..8d6165c 100644
--- a/uv.lock
+++ b/uv.lock
@@ -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" }