first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 22:25:19 +03:00
commit 361dbef019
25 changed files with 814 additions and 0 deletions

19
.env Normal file
View File

@@ -0,0 +1,19 @@
CLOUD_FLARE_SITE_KEY='0x4AAAAAACHzHKvlEwMamxCM'
CLOUD_FLARE_SECRET='0x4AAAAAACHzHHisTSFzGw15HvwXF3yXRIg'
GITHUB_CLIENT_ID='Ov23liUt9B61O46Mdfm4'
GITHUB_CLIENT_SECRET='c7fc8dcb1b2c8f22120608425d07d5efd995baaf'
GITHUB_SCOPE=['user:email']
GOOGLE_CLIENT_ID='915364976256-691m0s87as2r5vdbqr96f6humblseobt.apps.googleusercontent.com'
GOOGLE_CLIENT_SECRET='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'
GOOGLE_REDIRECT_URL=http://localhost:3000/api/auth/callback/google
JWT_SECRET=ares-fastapi-CT286MautyK9TWfz8SPSIWJTXV83mwLRwriLvSbpcMZuDLxywaHWP9Ju7xRoTS2
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=15
REFRESH_TOKEN_EXPIRE_DAYS=30
DB_URL = "mysql+pymysql://fastapi:gg7678290@10.80.80.70:3306/fastapi"
DATABASE_URL= "mysql+pymysql://fastapi:gg7678290@10.80.80.70:3306/fastapi"

32
.env.example Normal file
View File

@@ -0,0 +1,32 @@
# Copy this to .env and fill values for your environment
# Database (default: SQLite for local development)
# Database (default: MySQL for this project)
DATABASE_URL=mysql+pymysql://fastapi:password@127.0.0.1:3306/fastapi_db
# Alternatively, use a MySQL URL (example):
# DATABASE_URL=mysql+pymysql://user:password@host:3306/dbname
# Some environments use DB_URL (legacy) — set whichever you prefer
DB_URL="mysql+pymysql://fastapi:password@127.0.0.1:3306/fastapi_db"
# JWT / Security
JWT_SECRET=REPLACE_WITH_A_STRONG_RANDOM_SECRET
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=15
REFRESH_TOKEN_EXPIRE_DAYS=30
# OAuth (Google & GitHub)
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URL=http://localhost:8000/auth/oauth/google/callback
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GITHUB_REDIRECT_URL=http://localhost:8000/auth/oauth/github/callback
# Other
SERVER_NAME=localhost:8000
EMAILS_FROM=example@example.com
# Example: enable debug or change as needed
DEBUG=true

21
README.md Normal file
View File

@@ -0,0 +1,21 @@
# FastAPI Account System
Minimal FastAPI project scaffold implementing email/password and OAuth flows with JWTs.
Quick start:
1. Copy `.env.example` to `.env` and fill values (MySQL connection, secrets, OAuth keys).
2. Install dependencies:
```bash
pip install -r requirements.txt
```
3. Create DB and run migrations (or let app create tables on startup for quick runs):
```bash
# For quick local run (creates tables automatically):
uvicorn app.main:app --reload
```
See `.env.example` for required environment variables.

83
account_system.md Normal file
View File

@@ -0,0 +1,83 @@
Aşağıdaki metni aynen VSCode Copilota (veya başka bir kod üreteci/AI asistana) ver — tek istekte tam bir FastAPI proje iskeleti oluşturacak şekilde ayrıntılı, ama gereksiz karmaşıklıktan kaçınan, dosyalar ayrı ayrı olacak ve SQLite (pip ile çalıştırılabilir) kullanacak bir hesap sistemi üretmesini istiyorum. Kod istemiyorum — sadece bu metni Copilot'a yapıştır ve proje oluşturmasını bekle. (Projede kullanılması zorunlu kütüphaneler listemdeki paketlerle uyumlu olsun.)
İstek (kopyala-yapıştır için):
"Generate a complete, minimal and well-structured FastAPI project that implements a user account system supporting:
- email/password registration + login (with Argon2 password hashing),
- social login via Google and GitHub OAuth2 (create handlers for oauth redirect and callback),
- JWT access tokens (short-lived) and refresh tokens (long-lived) using PyJWT,
- a simple user model and CRUD using SQLModel/SQLAlchemy, with SQLite as the default local database (so it works without MySQL installed) but structured so switching to MySQL later only needs DATABASE_URL change and Alembic config update,
- use listed libraries where relevant: fastapi, sqlmodel, SQLAlchemy, alembic, pydantic (v2), python-dotenv, httpx, argon2-cffi, PyJWT, email-validator, uvicorn, and others from my environment list.
- .venv benim virtual environment'im, requirements.txt içinde gerekli paketler olacak, .env dosyası tüm gerekli env değişkenlerini gösterecek.
Requirements and constraints:
- Keep it simple and explicit. Do not put everything in one file — split into clear modules (core/config, db, models, schemas, services, api/routers, utils).
- Provide the following endpoints with expected behavior (implementations should be minimal but complete and runnable):
- POST /auth/register — accept email + password, validate email, hash password, create user, return access + refresh tokens.
- POST /auth/login — email + password, verify, return access + refresh tokens.
- POST /auth/refresh — accept refresh token (in body) and return new access token (and optionally new refresh token).
- GET /auth/oauth/{provider} — provider is "google" or "github", start OAuth flow (redirect to provider auth URL).
- GET /auth/oauth/{provider}/callback — handle provider callback, exchange code for token via httpx, fetch email/profile, create or find local user, return JWT tokens (or redirect with tokens).
- GET /users/me — protected endpoint, returns current user info.
- POST /auth/logout — invalidate refresh token (store refresh tokens in DB).
- Use dependency-injected DB session and FastAPI dependencies for current_user.
- Data models:
- User model (id, email unique, hashed_password nullable for OAuth-only accounts, is_active, created_at).
- RefreshToken model (id, user_id, token (hashed or raw with storage choice), created_at, expires_at).
- Security:
- Hash passwords with argon2-cffi.
- Sign JWTs with SECRET_KEY from environment (.env via python-dotenv / pydantic-settings).
- Respect token expiry values from config (ACCESS_TOKEN_EXPIRE_MINUTES, REFRESH_TOKEN_EXPIRE_DAYS).
- Validate incoming email with email-validator.
- OAuth details:
- Use env vars for GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, and OAUTH_REDIRECT_URL (or provider-specific callbacks).
- Use httpx for server-side HTTP calls to exchange code and fetch user info.
- When creating a user from OAuth, set hashed_password to None and mark provider source.
- Database and migrations:
- Default DB: SQLite file (e.g., sqlite:///./dev.db) so it runs with pip-installed packages only.
- Include alembic config and a basic migration script that creates the user and refresh token tables. Make Alembic configured to read DATABASE_URL from env.
- Project structure (must produce these files/modules — generate code for each):
- app/
- main.py (FastAPI app factory, include routers, startup events)
- core/
- config.py (pydantic settings using pydantic-settings, load .env)
- security.py (hashing functions, token creation/verification)
- oauth.py (provider configs and helper functions)
- db/
- session.py (engine, sessionmaker factory)
- base.py (SQLModel metadata)
- models/
- models.py (SQLModel models: User, RefreshToken)
- schemas/
- schemas.py (pydantic models for requests/responses using pydantic v2 style)
- services/
- auth_service.py (register/login/refresh/oauth logic; create tokens; store refresh tokens)
- user_service.py (basic user CRUD)
- api/
- deps.py (get_db, get_current_user)
- routers/
- auth.py (auth endpoints listed above)
- users.py (users/me)
- alembic/ (alembic env + migration scripts or instruction to autogenerate)
- tests/
- test_auth.py (basic tests covering register/login and protected endpoint — minimal)
- .env.example (show all required env keys and example values)
- requirements.txt (include the exact packages from my provided list that are necessary)
- README.md (how to install, run migrations, run app, env variables, how to register OAuth apps for Google & GitHub and set callback URLs)
Developer notes for code generator (be explicit to the generator):
- Do not implement email sending or complex account verification flows — keep the focus on login flows and JWT.
- The code must be runnable: after pip install -r requirements.txt and creating a .env (from .env.example), a developer should be able to run alembic upgrade head (or a provided script to create tables) and start uvicorn app.main:app --reload and test the endpoints.
- Use simple but clear error handling (HTTPException with appropriate status codes).
- Use typing annotations everywhere and keep functions small and testable.
- For refresh tokens storage you may store a UUID string in DB (no need to encrypt) but code should show where to change to hashed storage.
- Keep OAuth handlers minimal but functional: build auth URL, redirect user, handle callback, fetch profile, extract primary email, create/find user, issue tokens. Use scopes ["openid","email","profile"] for Google and ["user:email"] for GitHub.
- Provide comments in code explaining each module and the main steps of the auth flow.
Output expectation from Copilot:
- Create all files listed above, with working code (no pseudocode). Keep implementations short and readable with comments.
- Provide .env.example and README with run instructions.
- Make sure the app uses SQLite by default and clearly documents how to switch to MySQL.
Language for generated code and docs: English (but variable names and comments can be clear and simple)."
Not: Bu metni VSCode Copilot'a yapıştırdığımda tam bir proje oluştursun; ben yalnızca prompt istedim — kod istemiyorum.

38
alembic.ini Normal file
View File

@@ -0,0 +1,38 @@
[alembic]
script_location = alembic
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers = console
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers = console
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
[alembic:runtime]
; sqlalchemy.url is read from env in alembic/env.py
; keep this file minimal — env.py loads DATABASE_URL from environment

45
alembic/env.py Normal file
View File

@@ -0,0 +1,45 @@
from logging.config import fileConfig
import os
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
from dotenv import load_dotenv
load_dotenv()
config = context.config
fileConfig(config.config_file_name)
# Ensure alembic picks up DATABASE_URL (or DB_URL) from environment
import os
db_url = os.getenv("DATABASE_URL") or os.getenv("DB_URL")
if db_url:
config.set_main_option("sqlalchemy.url", db_url)
target_metadata = None
def run_migrations_offline():
url = os.getenv("DATABASE_URL")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

3
alembic/script.py.mako Normal file
View File

@@ -0,0 +1,3 @@
"""
Migration script template placeholder (Alembic will autogenerate actual scripts).
"""

View File

@@ -0,0 +1,39 @@
"""create users and refresh_tokens tables
Revision ID: 0001_create_users_and_refresh_tokens
Revises:
Create Date: 2026-02-20 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0001'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
'user',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('email', sa.String(length=255), nullable=False, unique=True, index=True),
sa.Column('hashed_password', sa.String(length=512), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1')),
sa.Column('created_at', sa.DateTime(), nullable=False),
)
op.create_table(
'refreshtoken',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('user.id'), nullable=False),
sa.Column('token', sa.String(length=512), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=False),
)
def downgrade() -> None:
op.drop_table('refreshtoken')
op.drop_table('user')

25
app/api/deps.py Normal file
View File

@@ -0,0 +1,25 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlmodel import Session
from app.db.session import get_session
from app.core.security import decode_token
from app.models.models import User
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
def get_db():
yield from get_session()
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User:
try:
payload = decode_token(token)
user_id = int(payload.get("sub"))
except Exception:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
user = db.get(User, user_id)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user

View File

@@ -0,0 +1 @@
from . import auth, users

79
app/api/routers/auth.py Normal file
View File

@@ -0,0 +1,79 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import EmailStr
from sqlmodel import Session
from app.api.deps import get_db
from app.schemas.schemas import UserCreate, Token
from app.services import auth_service
from app.models.models import RefreshToken
router = APIRouter()
@router.post("/register", response_model=Token)
def register(data: UserCreate, db: Session = Depends(get_db)):
try:
user, access, refresh = auth_service.register(db, data.email, data.password)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return {"access_token": access, "refresh_token": refresh}
@router.post("/login", response_model=Token)
def login(data: UserCreate, db: Session = Depends(get_db)):
try:
user, access, refresh = auth_service.login(db, data.email, data.password)
except ValueError:
raise HTTPException(status_code=401, detail="Invalid credentials")
return {"access_token": access, "refresh_token": refresh}
@router.post("/refresh")
def refresh(body: dict, db: Session = Depends(get_db)):
token = body.get("refresh_token")
if not token:
raise HTTPException(status_code=400, detail="refresh_token required")
try:
access = auth_service.refresh_token(db, token)
except ValueError:
raise HTTPException(status_code=401, detail="Invalid refresh token")
return {"access_token": access}
@router.get("/oauth/{provider}")
def oauth_start(provider: str):
if provider == "google":
from app.core.oauth import google_authorize_url
return {"auth_url": google_authorize_url()}
if provider == "github":
from app.core.oauth import github_authorize_url
return {"auth_url": github_authorize_url()}
raise HTTPException(status_code=404, detail="Unknown provider")
@router.get("/oauth/{provider}/callback")
def oauth_callback(provider: str, code: str | None = None, db: Session = Depends(get_db)):
if not code:
raise HTTPException(status_code=400, detail="Missing code")
try:
user, access, refresh = auth_service.handle_oauth_callback(db, provider, code)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return {"access_token": access, "refresh_token": refresh}
@router.post("/logout")
def logout(body: dict, db: Session = Depends(get_db)):
token = body.get("refresh_token")
if not token:
raise HTTPException(status_code=400, detail="refresh_token required")
# Invalidate refresh token: simple delete
statement = db.query(RefreshToken).filter(RefreshToken.token == token)
rt = statement.first()
if rt:
db.delete(rt)
db.commit()
return {"detail": "logged out"}

12
app/api/routers/users.py Normal file
View File

@@ -0,0 +1,12 @@
from fastapi import APIRouter, Depends
from sqlmodel import Session
from app.api.deps import get_current_user, get_db
from app.schemas.schemas import UserRead
router = APIRouter()
@router.get("/me", response_model=UserRead)
def read_me(current_user=Depends(get_current_user)):
return current_user

34
app/core/config.py Normal file
View File

@@ -0,0 +1,34 @@
from __future__ import annotations
from pydantic_settings import BaseSettings
from pydantic import Field
from dotenv import load_dotenv
load_dotenv()
class Settings(BaseSettings):
model_config = {
"env_file": ".env",
"extra": "allow",
}
# Read DATABASE_URL from environment via model_config; avoid using Field(..., env=...)
DATABASE_URL: str = "sqlite:///./dev.db"
JWT_SECRET: str
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
REFRESH_TOKEN_EXPIRE_DAYS: int = 30
GOOGLE_CLIENT_ID: str | None = None
GOOGLE_CLIENT_SECRET: str | None = None
GOOGLE_REDIRECT_URL: str | None = None
GITHUB_CLIENT_ID: str | None = None
GITHUB_CLIENT_SECRET: str | None = None
GITHUB_REDIRECT_URL: str | None = None
SERVER_NAME: str = "localhost:8000"
DEBUG: bool = True
settings = Settings()

27
app/core/oauth.py Normal file
View File

@@ -0,0 +1,27 @@
from urllib.parse import urlencode
from typing import Dict
from app.core.config import settings
def google_authorize_url(state: str = "state") -> str:
params = {
"client_id": settings.GOOGLE_CLIENT_ID,
"redirect_uri": settings.GOOGLE_REDIRECT_URL,
"response_type": "code",
"scope": "openid email profile",
"state": state,
"access_type": "offline",
"prompt": "consent",
}
return "https://accounts.google.com/o/oauth2/v2/auth?" + urlencode(params)
def github_authorize_url(state: str = "state") -> str:
params = {
"client_id": settings.GITHUB_CLIENT_ID,
"redirect_uri": settings.GITHUB_REDIRECT_URL,
"scope": "user:email",
"state": state,
}
return "https://github.com/login/oauth/authorize?" + urlencode(params)

38
app/core/security.py Normal file
View File

@@ -0,0 +1,38 @@
from datetime import datetime, timedelta, timezone
from typing import Tuple
import jwt
from argon2 import PasswordHasher
from app.core.config import settings
ph = PasswordHasher()
def hash_password(password: str) -> str:
return ph.hash(password)
def verify_password(hash: str, password: str) -> bool:
try:
return ph.verify(hash, password)
except Exception:
return False
def create_access_token(sub: str) -> Tuple[str, datetime]:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
payload = {"sub": sub, "exp": expire}
token = jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.ALGORITHM)
return token, expire
def create_refresh_token(sub: str) -> Tuple[str, datetime]:
expire = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
payload = {"sub": sub, "exp": expire}
token = jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.ALGORITHM)
return token, expire
def decode_token(token: str) -> dict:
return jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.ALGORITHM])

3
app/db/base.py Normal file
View File

@@ -0,0 +1,3 @@
from sqlmodel import SQLModel
__all__ = ["SQLModel"]

10
app/db/session.py Normal file
View File

@@ -0,0 +1,10 @@
from sqlmodel import create_engine, Session
from app.core.config import settings
engine = create_engine(settings.DATABASE_URL, echo=False)
def get_session() -> Session:
with Session(engine) as session:
yield session

26
app/main.py Normal file
View File

@@ -0,0 +1,26 @@
from fastapi import FastAPI
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from app.core.config import settings
from app.db.session import engine
from app.db.base import SQLModel
from app.api.routers import auth, users
def create_app() -> FastAPI:
app = FastAPI(title="FastAPI Account System")
app.include_router(auth.router, prefix="/auth", tags=["auth"])
app.include_router(users.router, prefix="/users", tags=["users"])
@asynccontextmanager
async def lifespan(app) -> AsyncGenerator[None, None]:
# For quick local runs create tables automatically. In production use Alembic migrations.
SQLModel.metadata.create_all(engine)
yield
app.router.lifespan_context = lifespan
return app
app = create_app()

20
app/models/models.py Normal file
View File

@@ -0,0 +1,20 @@
from datetime import datetime, timezone
from typing import Optional
from sqlmodel import SQLModel, Field, Column, Integer, String, Boolean, DateTime, ForeignKey
class User(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
email: str = Field(sa_column=Column(String(length=255), unique=True))
hashed_password: Optional[str] = Field(default=None)
is_active: bool = Field(default=True)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class RefreshToken(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
token: str
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
expires_at: datetime

26
app/schemas/schemas.py Normal file
View File

@@ -0,0 +1,26 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from pydantic import EmailStr
from pydantic import BaseModel, ConfigDict
class UserCreate(BaseModel):
email: EmailStr
password: str
class Token(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class UserRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
email: EmailStr
is_active: bool
created_at: datetime

3
app/services/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from . import auth_service, user_service
__all__ = ["auth_service", "user_service"]

View File

@@ -0,0 +1,136 @@
from datetime import datetime, timezone
from typing import Optional, Tuple
import httpx
from sqlmodel import Session, select
from app.core.config import settings
from app.core.security import (
hash_password,
verify_password,
create_access_token,
create_refresh_token,
)
from app.models.models import User, RefreshToken
from app.services.user_service import get_user_by_email, create_user
def register(session: Session, email: str, password: str) -> Tuple[User, str, str]:
existing = get_user_by_email(session, email)
if existing:
raise ValueError("User already exists")
hashed = hash_password(password)
user = create_user(session, email, hashed)
access, _ = create_access_token(str(user.id))
refresh, exp = create_refresh_token(str(user.id))
rt = RefreshToken(user_id=user.id, token=refresh, created_at=datetime.now(timezone.utc), expires_at=exp)
session.add(rt)
session.commit()
return user, access, refresh
def login(session: Session, email: str, password: str) -> Tuple[User, str, str]:
user = get_user_by_email(session, email)
if not user or not user.hashed_password:
raise ValueError("Invalid credentials")
if not verify_password(user.hashed_password, password):
raise ValueError("Invalid credentials")
access, _ = create_access_token(str(user.id))
refresh, exp = create_refresh_token(str(user.id))
rt = RefreshToken(user_id=user.id, token=refresh, created_at=datetime.now(timezone.utc), expires_at=exp)
session.add(rt)
session.commit()
return user, access, refresh
def refresh_token(session: Session, token: str) -> str:
statement = select(RefreshToken).where(RefreshToken.token == token)
rt = session.exec(statement).first()
if not rt:
raise ValueError("Invalid refresh token")
access, _ = create_access_token(str(rt.user_id))
return access
def _create_or_get_user_from_oauth(session: Session, email: str) -> User:
user = get_user_by_email(session, email)
if user:
return user
# OAuth-only user: hashed_password is None
user = create_user(session, email, None)
return user
def handle_oauth_callback(session: Session, provider: str, code: str) -> Tuple[User, str, str]:
if provider == "github":
token_resp = _github_exchange_code(code)
access_token = token_resp.get("access_token")
if not access_token:
raise ValueError("Failed to obtain access token from GitHub")
# fetch emails
headers = {"Authorization": f"token {access_token}", "Accept": "application/vnd.github+json"}
resp = httpx.get("https://api.github.com/user/emails", headers=headers, timeout=10.0)
resp.raise_for_status()
emails = resp.json()
primary = None
for e in emails:
if e.get("primary"):
primary = e.get("email")
break
if not primary and emails:
primary = emails[0].get("email")
if not primary:
raise ValueError("No email found from GitHub")
user = _create_or_get_user_from_oauth(session, primary)
elif provider == "google":
token_resp = _google_exchange_code(code)
access_token = token_resp.get("access_token")
if not access_token:
raise ValueError("Failed to obtain access token from Google")
resp = httpx.get(
"https://openidconnect.googleapis.com/v1/userinfo",
headers={"Authorization": f"Bearer {access_token}"},
timeout=10.0,
)
resp.raise_for_status()
profile = resp.json()
email = profile.get("email")
if not email:
raise ValueError("No email in Google profile")
user = _create_or_get_user_from_oauth(session, email)
else:
raise ValueError("Unsupported provider")
access, _ = create_access_token(str(user.id))
refresh, exp = create_refresh_token(str(user.id))
rt = RefreshToken(user_id=user.id, token=refresh, created_at=datetime.utcnow(), expires_at=exp)
session.add(rt)
session.commit()
return user, access, refresh
def _github_exchange_code(code: str) -> dict:
token_url = "https://github.com/login/oauth/access_token"
data = {
"client_id": settings.GITHUB_CLIENT_ID,
"client_secret": settings.GITHUB_CLIENT_SECRET,
"code": code,
}
resp = httpx.post(token_url, data=data, headers={"Accept": "application/json"}, timeout=10.0)
resp.raise_for_status()
return resp.json()
def _google_exchange_code(code: str) -> dict:
token_url = "https://oauth2.googleapis.com/token"
data = {
"client_id": settings.GOOGLE_CLIENT_ID,
"client_secret": settings.GOOGLE_CLIENT_SECRET,
"code": code,
"grant_type": "authorization_code",
"redirect_uri": settings.GOOGLE_REDIRECT_URL,
}
resp = httpx.post(token_url, data=data, timeout=10.0)
resp.raise_for_status()
return resp.json()

View File

@@ -0,0 +1,17 @@
from typing import Optional
from sqlmodel import Session, select
from app.models.models import User
def get_user_by_email(session: Session, email: str) -> Optional[User]:
statement = select(User).where(User.email == email)
return session.exec(statement).first()
def create_user(session: Session, email: str, hashed_password: Optional[str]) -> User:
user = User(email=email, hashed_password=hashed_password)
session.add(user)
session.commit()
session.refresh(user)
return user

58
requirements.txt Normal file
View File

@@ -0,0 +1,58 @@
alembic==1.18.4
annotated-doc==0.0.4
annotated-types==0.7.0
anyio==4.12.1
argon2-cffi==25.1.0
argon2-cffi-bindings==25.1.0
certifi==2026.1.4
cffi==2.0.0
click==8.3.1
dnspython==2.8.0
email-validator==2.3.0
fastapi==0.129.0
fastapi-cli==0.0.23
fastapi-cloud-cli==0.13.0
fastar==0.8.0
greenlet==3.3.1
h11==0.16.0
httpcore==1.0.9
httptools==0.7.1
httpx==0.28.1
idna==3.11
iniconfig==2.3.0
Jinja2==3.1.6
Mako==1.3.10
markdown-it-py==4.0.0
MarkupSafe==3.0.3
mdurl==0.1.2
packaging==26.0
pluggy==1.6.0
pwdlib==0.3.0
pycparser==3.0
pydantic==2.12.5
pydantic-extra-types==2.11.0
pydantic-settings==2.13.1
pydantic_core==2.41.5
Pygments==2.19.2
PyJWT==2.11.0
PyMySQL==1.1.2
pytest==9.0.2
python-dotenv==1.2.1
python-multipart==0.0.22
PyYAML==6.0.3
rich==14.3.3
rich-toolkit==0.19.4
rignore==0.7.6
sentry-sdk==2.53.0
shellingham==1.5.4
SQLAlchemy==2.0.46
sqlmodel==0.0.34
starlette==0.52.1
typer==0.24.0
typing-inspection==0.4.2
typing_extensions==4.15.0
urllib3==2.6.3
uvicorn==0.41.0
uvloop==0.22.1
watchfiles==1.1.1
websockets==16.0

19
tests/test_auth.py Normal file
View File

@@ -0,0 +1,19 @@
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_register_and_me():
email = "tester@example.com"
password = "password123"
r = client.post("/auth/register", json={"email": email, "password": password})
assert r.status_code == 200
tokens = r.json()
assert "access_token" in tokens
headers = {"Authorization": f"Bearer {tokens['access_token']}"}
r2 = client.get("/users/me", headers=headers)
assert r2.status_code == 200
data = r2.json()
assert data["email"] == email