first commit
This commit is contained in:
19
.env
Normal file
19
.env
Normal 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
32
.env.example
Normal 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
21
README.md
Normal 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
83
account_system.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
Aşağıdaki metni aynen VSCode Copilot’a (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
38
alembic.ini
Normal 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
45
alembic/env.py
Normal 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
3
alembic/script.py.mako
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
Migration script template placeholder (Alembic will autogenerate actual scripts).
|
||||||
|
"""
|
||||||
39
alembic/versions/0001_create_users_and_refresh_tokens.py
Normal file
39
alembic/versions/0001_create_users_and_refresh_tokens.py
Normal 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
25
app/api/deps.py
Normal 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
|
||||||
1
app/api/routers/__init__.py
Normal file
1
app/api/routers/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from . import auth, users
|
||||||
79
app/api/routers/auth.py
Normal file
79
app/api/routers/auth.py
Normal 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
12
app/api/routers/users.py
Normal 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
34
app/core/config.py
Normal 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
27
app/core/oauth.py
Normal 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
38
app/core/security.py
Normal 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
3
app/db/base.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
|
__all__ = ["SQLModel"]
|
||||||
10
app/db/session.py
Normal file
10
app/db/session.py
Normal 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
26
app/main.py
Normal 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
20
app/models/models.py
Normal 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
26
app/schemas/schemas.py
Normal 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
3
app/services/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from . import auth_service, user_service
|
||||||
|
|
||||||
|
__all__ = ["auth_service", "user_service"]
|
||||||
136
app/services/auth_service.py
Normal file
136
app/services/auth_service.py
Normal 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()
|
||||||
|
|
||||||
17
app/services/user_service.py
Normal file
17
app/services/user_service.py
Normal 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
58
requirements.txt
Normal 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
19
tests/test_auth.py
Normal 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
|
||||||
Reference in New Issue
Block a user