diff --git a/backend/.env.example b/backend/.env.example index 360647b..63f2a02 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -7,7 +7,15 @@ API_V1_PREFIX="/api/v1" # Note: NeonDB (PostgreSQL) connection string DATABASE_URL="postgresql://user:password@localhost/dbname" + # Security SECRET_KEY="your-secret-key-change-in-production" ALGORITHM="HS256" ACCESS_TOKEN_EXPIRE_MINUTES=3000 + +#AUTH +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_REDIRECT_URI=http://localhost:8000/users/google/callback + +FRONTEND_URL=http://localhost:5173 \ No newline at end of file diff --git a/backend/app/config.py b/backend/app/config.py index 4e8ac27..f70e23d 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -30,6 +30,14 @@ class Settings(BaseSettings): STORAGE_PROVIDER: str = "supabase" BACKGROUND_WORKER: str = "taskiq" + # Goole Oauth + GOOGLE_CLIENT_ID: str = "" + GOOGLE_CLIENT_SECRET: str = "" + GOOGLE_REDIRECT_URI: str = "" + + # Frontend + FRONTEND_URL: str = "" + class Config: env_file = ".env" case_sensitive = True diff --git a/backend/app/main.py b/backend/app/main.py index 67f053e..a4946ec 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,8 +1,10 @@ from collections.abc import AsyncGenerator from contextlib import asynccontextmanager +from authlib.integrations.starlette_client import OAuth from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from starlette.middleware.sessions import SessionMiddleware from app.config import settings from app.exceptions.auth import register_auth_exception_handlers @@ -49,6 +51,12 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: allow_methods=["*"], allow_headers=["*"], ) + +app.add_middleware( + SessionMiddleware, + secret_key=settings.SECRET_KEY, +) + register_auth_exception_handlers(app) register_common_exception_handlers(app) register_sql_alchemy_exception_handlers(app) diff --git a/backend/app/routers/user.py b/backend/app/routers/user.py index e8abf8f..97766df 100644 --- a/backend/app/routers/user.py +++ b/backend/app/routers/user.py @@ -1,7 +1,11 @@ -from fastapi import APIRouter, Depends, status +from typing import cast + +from fastapi import APIRouter, Depends, Request, status +from fastapi.responses import RedirectResponse from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.config import settings from app.database import get_db from app.exceptions.auth import UserNotFoundError from app.logger import get_logger @@ -15,6 +19,7 @@ ) from app.utils.authorization import get_current_user, verify_ownership from app.utils.bcrypt_hasher import BcryptHasher +from app.utils.google_oauth import oauth from app.utils.jwt_auth import JwtAuth logger = get_logger(__name__) @@ -55,6 +60,14 @@ async def login(credentials: UserLogin, db: AsyncSession = Depends(get_db)) -> T return TokenResponse(token=token, user=UserResponse.model_validate(user)) +@router.get("/me", response_model=UserResponse) +async def get_me( + current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) +) -> UserResponse: + await db.refresh(current_user, attribute_names=["profile"]) + return UserResponse.model_validate(current_user) + + @router.get("/{user_id}", response_model=UserResponse) async def get_user( user_id: int, @@ -141,3 +154,43 @@ async def delete_user( await db.delete(user) await db.commit() logger.info("User deleted successfully: %d", user_id) + + +# Google Oauth login +@router.get("/google/login") +async def google_login(request: Request) -> RedirectResponse: + return cast( + RedirectResponse, + await oauth.google.authorize_redirect( + request, redirect_uri="http://localhost:8000/users/google/callback" + ), + ) + + +@router.get("/google/callback") +async def google_callback( + request: Request, + db: AsyncSession = Depends(get_db), +) -> RedirectResponse: + token = await oauth.google.authorize_access_token(request) + user_info = token.get("userinfo") + email = user_info["email"] + username = user_info.get("name", email.split("@")[0]) + result = await db.execute(select(User).where(User.email == email)) + user = result.scalar_one_or_none() + if not user: + user = User( + username=username, + email=email, + password_hash="none", + ) + db.add(user) + await db.flush() + profile = UserProfile(user_id=user.id) + db.add(profile) + await db.commit() + await db.refresh(user) + auth = JwtAuth(db) + access_token = await auth.generate_token(user) + + return RedirectResponse(url=f"{settings.FRONTEND_URL}?token={access_token}") diff --git a/backend/app/utils/google_oauth.py b/backend/app/utils/google_oauth.py new file mode 100644 index 0000000..6284bda --- /dev/null +++ b/backend/app/utils/google_oauth.py @@ -0,0 +1,15 @@ +from authlib.integrations.starlette_client import OAuth + +from app.config import settings + +oauth = OAuth() + +oauth.register( + name="google", + client_id=settings.GOOGLE_CLIENT_ID, + client_secret=settings.GOOGLE_CLIENT_SECRET, + server_metadata_url="https://accounts.google.com/.well-known/openid-configuration", + client_kwargs={ + "scope": "openid email profile", + }, +) diff --git a/backend/app/utils/jwt_auth.py b/backend/app/utils/jwt_auth.py index 4766c67..d9e3b76 100644 --- a/backend/app/utils/jwt_auth.py +++ b/backend/app/utils/jwt_auth.py @@ -110,14 +110,15 @@ async def generate_token(self, user: User) -> str: return token async def authenticate(self, username: str, password: str) -> User: - result = await self.db_session.execute(select(User).where(User.username == username)) - user = result.scalar_one_or_none() if not user: raise InvalidUserCredentialsError() + if not user.password_hash: + raise InvalidUserCredentialsError() + if not self.hasher.verify(password, user.password_hash): raise InvalidUserCredentialsError() diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 6f94e71..f31ccec 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -23,10 +23,14 @@ dependencies = [ "taskiq-redis>=0.5.0", "python-multipart>=0.0.27", "supabase>=2.30.0", + "authlib>=1.7.2", + "httpx>=0.28.1", + "itsdangerous>=2.2.0", ] [dependency-groups] dev = [ "mypy>=1.20.1", "ruff>=0.15.10", + "pytest>=8.0.0", ] diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..d313e76 --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,15 @@ +from fastapi.testclient import TestClient + +from app.main import app + +client = TestClient(app) + + +def test_app_runs() -> None: + response = client.get("/google/login") + assert response.status_code != 500 + + +def test_google_callback_exists() -> None: + response = client.get("/google/callback") + assert response.status_code != 500 diff --git a/backend/uv.lock b/backend/uv.lock index a0aed5f..7b77023 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -212,6 +212,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] +[[package]] +name = "authlib" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "joserfc" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, +] + [[package]] name = "backend" version = "0.1.0" @@ -220,9 +233,12 @@ dependencies = [ { name = "aiosqlite" }, { name = "alembic" }, { name = "asyncpg" }, + { name = "authlib" }, { name = "bcrypt" }, { name = "fastapi" }, { name = "greenlet" }, + { name = "httpx" }, + { name = "itsdangerous" }, { name = "langchain-litellm" }, { name = "pydantic", extra = ["email"] }, { name = "pydantic-settings" }, @@ -240,6 +256,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "mypy" }, + { name = "pytest" }, { name = "ruff" }, ] @@ -248,9 +265,12 @@ requires-dist = [ { name = "aiosqlite", specifier = ">=0.22.1" }, { name = "alembic", specifier = ">=1.18.4" }, { name = "asyncpg", specifier = ">=0.31.0" }, + { name = "authlib", specifier = ">=1.7.2" }, { name = "bcrypt", specifier = ">=4.0.0" }, { name = "fastapi", specifier = ">=0.115.0" }, { name = "greenlet", specifier = ">=3.4.0" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "itsdangerous", specifier = ">=2.2.0" }, { name = "langchain-litellm", specifier = ">=0.6.4" }, { name = "pydantic", extras = ["email"], specifier = ">=2.13.0" }, { name = "pydantic-settings", specifier = ">=2.13.1" }, @@ -268,6 +288,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "mypy", specifier = ">=1.20.1" }, + { name = "pytest", specifier = ">=8.0.0" }, { name = "ruff", specifier = ">=0.15.10" }, ] @@ -988,6 +1009,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[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 = "jinja2" version = "3.1.6" @@ -1072,6 +1111,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/e9/1f9ada30cef7b05e74bb06f52127e7a724976c225f46adb65c37b1dadfb6/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67f00d94b281174144d6532a04b66a12cb866cbdc47c3af3bfe2973677f9861a", size = 349613, upload-time = "2026-04-10T14:28:40.066Z" }, ] +[[package]] +name = "joserfc" +version = "1.6.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/dc/5f768c2e391e9afabe5d18e3221346deb5fb6338565f1ccc9e7c6d7befdd/joserfc-1.6.5.tar.gz", hash = "sha256:1482a7db78fb4602e44ed89e51b599d052e091288c7c532c5b694e20149dec48", size = 231881, upload-time = "2026-05-06T04:58:13.408Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/3b/ad1cb22e75c963b1f07c8a2329bf47227ce7e4361df5eb2fb101b2ce33ef/joserfc-1.6.5-py3-none-any.whl", hash = "sha256:e9878a0f8243fe7b95e11fdda81374ca9f7a689e302751579d3dfdeec559675e", size = 70464, upload-time = "2026-05-06T04:58:11.668Z" }, +] + [[package]] name = "jsonpatch" version = "1.33" @@ -1689,6 +1740,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "postgrest" version = "2.30.0" @@ -2048,6 +2108,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/e0/39afe4bddbed6276c54e35e310aa345fbeb00f8890e96e7f48cdc2be9c66/pyroaring-1.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99c42fe1449acfbf130da65e66b4d5b2726aba4497be359bae7672e38a15fc62", size = 234615, upload-time = "2026-04-24T21:29:08.751Z" }, ] +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0"