-
Notifications
You must be signed in to change notification settings - Fork 0
feat(auth): add modular signup endpoint with language enum and standa… #37
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
695f1fa
6076587
1d2d841
cd60bf6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| from app.api.v1.api import api_router | ||
|
|
||
| __all__ = ["api_router"] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| from fastapi import APIRouter | ||
|
|
||
| from app.api.v1.endpoints.auth import router as auth_router | ||
|
|
||
| api_router = APIRouter() | ||
| api_router.include_router(auth_router) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| from fastapi import APIRouter, Depends, status | ||
| from sqlalchemy.orm import Session | ||
|
|
||
| from app.crud.user import create_user | ||
| from app.db.session import get_db | ||
| from app.schemas.auth import SignupResponse | ||
| from app.schemas.user import UserCreate | ||
|
|
||
| router = APIRouter(prefix="/auth", tags=["auth"]) | ||
| DB_SESSION_DEPENDENCY = Depends(get_db) | ||
|
|
||
|
|
||
| @router.post( | ||
| "/signup", | ||
| response_model=SignupResponse, | ||
| status_code=status.HTTP_201_CREATED, | ||
| ) | ||
| def signup(user_in: UserCreate, db: Session = DB_SESSION_DEPENDENCY) -> SignupResponse: | ||
| user = create_user(db=db, user_in=user_in) | ||
| return SignupResponse.model_validate(user) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| from app.crud.user import create_user, get_user_by_email | ||
|
|
||
| __all__ = ["create_user", "get_user_by_email"] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| from typing import cast | ||
|
|
||
| import bcrypt | ||
| from passlib.context import CryptContext | ||
| from sqlalchemy import select | ||
| from sqlalchemy.orm import Session | ||
|
|
||
| from app.core.exceptions import ConflictException | ||
| from app.models.user import User | ||
| from app.schemas.user import UserCreate | ||
|
|
||
| pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") | ||
|
|
||
|
|
||
| def hash_password(password: str) -> str: | ||
| try: | ||
| return cast(str, pwd_context.hash(password)) | ||
| except ValueError: | ||
| # Passlib's bcrypt backend probing can fail with newer bcrypt builds. | ||
| salt = bcrypt.gensalt() | ||
| return bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8") | ||
|
|
||
|
|
||
| def get_user_by_email(db: Session, email: str) -> User | None: | ||
| statement = select(User).where(User.email == email.lower()) | ||
| return db.execute(statement).scalar_one_or_none() | ||
|
|
||
|
|
||
| def create_user(db: Session, user_in: UserCreate) -> User: | ||
| existing_user = get_user_by_email(db, user_in.email) | ||
| if existing_user: | ||
| raise ConflictException( | ||
| code="EMAIL_ALREADY_REGISTERED", | ||
| message="An account with this email already exists.", | ||
| ) | ||
|
|
||
| db_user = User( | ||
| email=user_in.email.lower(), | ||
| hashed_password=hash_password(user_in.password), | ||
| full_name=user_in.full_name, | ||
| speaking_language=user_in.speaking_language.value, | ||
| listening_language=user_in.listening_language.value, | ||
| is_active=True, | ||
| is_verified=False, | ||
| ) | ||
| db.add(db_user) | ||
| db.commit() | ||
| db.refresh(db_user) | ||
| return db_user | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| from app.db.session import SessionLocal, get_db, get_engine | ||
|
|
||
| __all__ = ["SessionLocal", "get_db", "get_engine"] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| from collections.abc import Generator | ||
| from typing import Final | ||
|
|
||
| from sqlalchemy import create_engine | ||
| from sqlalchemy.engine import Engine | ||
| from sqlalchemy.orm import Session, sessionmaker | ||
|
|
||
| from app.core.config import settings | ||
|
|
||
| DEFAULT_SQLITE_URL: Final[str] = "sqlite:///./fluentmeet.db" | ||
| DATABASE_URL = settings.DATABASE_URL or DEFAULT_SQLITE_URL | ||
|
|
||
| _ENGINE_STATE: dict[str, Engine] = {} | ||
| SessionLocal = sessionmaker(autoflush=False, autocommit=False) | ||
|
|
||
|
|
||
| def get_engine() -> Engine: | ||
| cached_engine = _ENGINE_STATE.get("engine") | ||
| if cached_engine is None: | ||
| try: | ||
| cached_engine = create_engine(DATABASE_URL, pool_pre_ping=True) | ||
| except ModuleNotFoundError as exc: | ||
| # CI/test environments may not install PostgreSQL DBAPI drivers. | ||
| if DATABASE_URL.startswith("postgresql") and exc.name in { | ||
| "psycopg2", | ||
| "psycopg", | ||
| }: | ||
| cached_engine = create_engine(DEFAULT_SQLITE_URL, pool_pre_ping=True) | ||
|
Comment on lines
+22
to
+28
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fail fast outside tests instead of silently switching to SQLite. This fallback is not actually scoped to CI/tests. Any runtime with a PostgreSQL URL and a missing driver will start writing to 🤖 Prompt for AI Agents |
||
| else: | ||
| raise | ||
| SessionLocal.configure(bind=cached_engine) | ||
| _ENGINE_STATE["engine"] = cached_engine | ||
|
Comment on lines
+18
to
+32
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Make engine initialization thread-safe.
🧵 One way to serialize first-use initialization def get_engine() -> Engine:
cached_engine = _ENGINE_STATE.get("engine")
if cached_engine is None:
- try:
- cached_engine = create_engine(DATABASE_URL, pool_pre_ping=True)
- except ModuleNotFoundError as exc:
- # CI/test environments may not install PostgreSQL DBAPI drivers.
- if DATABASE_URL.startswith("postgresql") and exc.name in {
- "psycopg2",
- "psycopg",
- }:
- cached_engine = create_engine(DEFAULT_SQLITE_URL, pool_pre_ping=True)
- else:
- raise
- SessionLocal.configure(bind=cached_engine)
- _ENGINE_STATE["engine"] = cached_engine
+ with _ENGINE_LOCK:
+ cached_engine = _ENGINE_STATE.get("engine")
+ if cached_engine is None:
+ try:
+ cached_engine = create_engine(DATABASE_URL, pool_pre_ping=True)
+ except ModuleNotFoundError as exc:
+ # CI/test environments may not install PostgreSQL DBAPI drivers.
+ if DATABASE_URL.startswith("postgresql") and exc.name in {
+ "psycopg2",
+ "psycopg",
+ }:
+ cached_engine = create_engine(DEFAULT_SQLITE_URL, pool_pre_ping=True)
+ else:
+ raise
+ SessionLocal.configure(bind=cached_engine)
+ _ENGINE_STATE["engine"] = cached_engine
return cached_engineAdd a module lock next to from threading import Lock
_ENGINE_LOCK = Lock()🤖 Prompt for AI Agents |
||
| return cached_engine | ||
|
|
||
|
|
||
| def get_db() -> Generator[Session, None, None]: | ||
| get_engine() | ||
| db = SessionLocal() | ||
| try: | ||
| yield db | ||
| finally: | ||
| db.close() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| from app.schemas.user import UserResponse | ||
|
|
||
|
|
||
| class SignupResponse(UserResponse): | ||
| """Public payload returned by the signup endpoint.""" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: Brints/FluentMeet
Length of output: 1885
Handle unique-email race condition at commit time with proper error handling.
The application-level read-before-write check is vulnerable to a race condition under concurrency. Two concurrent requests can both pass the uniqueness check on line 31 and attempt to insert the same email, causing the second request to fail at the database constraint (unique index on email exists in migrations). Without error handling around
db.commit(), this surfaces as an unhandledIntegrityErrorinstead of your intendedConflictException.Wrap
db.commit()in a try-except block to catchIntegrityErrorand map it toConflictException, with rollback on any exception:Suggested fix
🤖 Prompt for AI Agents