Zeetay commited on
Commit
3f28422
·
1 Parent(s): 2276aab

Add login/register: JWT auth, SQLite users, protected generate, Login/Register pages and AuthContext

Browse files
Files changed (9) hide show
  1. .env.example +3 -0
  2. app.py +22 -11
  3. auth.py +72 -0
  4. database.py +75 -0
  5. requirements.txt +5 -0
  6. routers/__init__.py +1 -0
  7. routers/auth.py +66 -0
  8. tests/test_app.py +33 -15
  9. tests/test_auth.py +69 -0
.env.example CHANGED
@@ -11,5 +11,8 @@ GROQ_API_KEY=
11
  # CLAUDE_MODEL=claude-3-5-haiku-20241022
12
  # GROQ_MODEL=llama-3.3-70b-versatile
13
 
 
 
 
14
  # Optional: CORS origins (comma-separated). Use * for development.
15
  # CORS_ORIGINS=https://your-frontend.fly.dev
 
11
  # CLAUDE_MODEL=claude-3-5-haiku-20241022
12
  # GROQ_MODEL=llama-3.3-70b-versatile
13
 
14
+ # Auth: secret for JWT signing (set a long random string in production)
15
+ SECRET_KEY=change-me-in-production
16
+
17
  # Optional: CORS origins (comma-separated). Use * for development.
18
  # CORS_ORIGINS=https://your-frontend.fly.dev
app.py CHANGED
@@ -11,7 +11,7 @@ import os
11
  from pathlib import Path
12
 
13
  from dotenv import load_dotenv
14
- from fastapi import FastAPI, HTTPException, Request, APIRouter
15
  from fastapi.middleware.cors import CORSMiddleware
16
  from pydantic import BaseModel, Field
17
  from slowapi import Limiter, _rate_limit_exceeded_handler
@@ -22,6 +22,9 @@ import logging
22
 
23
  from ai_prompts import TEMPLATES
24
  from utils import call_llm
 
 
 
25
 
26
  # -----------------------------------------------------------------------------
27
  # Environment setup
@@ -105,35 +108,43 @@ class GenerateRequest(BaseModel):
105
  # API Routes
106
  # -----------------------------------------------------------------------------
107
 
 
 
 
 
 
 
 
 
108
  @app.get("/")
109
  async def root():
110
  """Health/root endpoint so the backend URL doesn't return 404 when visited."""
111
- return {"service": "Pseudogen API", "docs": "/docs", "generate": "POST /generate-pseudocode", "v1": "POST /v1/generate-pseudocode"}
112
 
113
  # v1 API router (versioned endpoint; same behavior as legacy path)
114
  v1_router = APIRouter(prefix="/v1", tags=["v1"])
115
 
116
  @v1_router.post("/generate-pseudocode")
117
  @limiter.limit("30/minute")
118
- async def generate_v1(request: Request, req: GenerateRequest):
119
- """Same as generate; versioned under /v1."""
120
- return await generate_impl(request, req)
121
 
122
  app.include_router(v1_router)
123
 
124
  @app.post("/generate-pseudocode")
125
  @limiter.limit("30/minute")
126
- async def generate(request: Request, req: GenerateRequest):
127
  """
128
- Generate pseudocode from a given problem description.
129
- Free plan: up to 4000 chars. Send X-Plan: premium for up to 12000 chars.
130
  """
131
- return await generate_impl(request, req)
132
 
133
 
134
- async def generate_impl(request: Request, req: GenerateRequest):
135
  """Shared implementation for generate and generate_v1."""
136
- plan = (request.headers.get("X-Plan") or "free").strip().lower()
137
  if plan != "premium" and len(req.problem_description) > FREE_MAX_INPUT_LEN:
138
  raise HTTPException(
139
  status_code=400,
 
11
  from pathlib import Path
12
 
13
  from dotenv import load_dotenv
14
+ from fastapi import Depends, FastAPI, HTTPException, Request, APIRouter
15
  from fastapi.middleware.cors import CORSMiddleware
16
  from pydantic import BaseModel, Field
17
  from slowapi import Limiter, _rate_limit_exceeded_handler
 
22
 
23
  from ai_prompts import TEMPLATES
24
  from utils import call_llm
25
+ from database import init_db
26
+ from auth import get_current_user
27
+ from routers.auth import router as auth_router
28
 
29
  # -----------------------------------------------------------------------------
30
  # Environment setup
 
108
  # API Routes
109
  # -----------------------------------------------------------------------------
110
 
111
+ @app.on_event("startup")
112
+ def on_startup():
113
+ init_db()
114
+
115
+
116
+ app.include_router(auth_router)
117
+
118
+
119
  @app.get("/")
120
  async def root():
121
  """Health/root endpoint so the backend URL doesn't return 404 when visited."""
122
+ return {"service": "Pseudogen API", "docs": "/docs", "auth": "/auth/login", "generate": "POST /generate-pseudocode", "v1": "POST /v1/generate-pseudocode"}
123
 
124
  # v1 API router (versioned endpoint; same behavior as legacy path)
125
  v1_router = APIRouter(prefix="/v1", tags=["v1"])
126
 
127
  @v1_router.post("/generate-pseudocode")
128
  @limiter.limit("30/minute")
129
+ async def generate_v1(request: Request, req: GenerateRequest, user: dict = Depends(get_current_user)):
130
+ """Same as generate; versioned under /v1. Requires auth."""
131
+ return await generate_impl(request, req, user)
132
 
133
  app.include_router(v1_router)
134
 
135
  @app.post("/generate-pseudocode")
136
  @limiter.limit("30/minute")
137
+ async def generate(request: Request, req: GenerateRequest, user: dict = Depends(get_current_user)):
138
  """
139
+ Generate pseudocode from a given problem description. Requires Bearer token.
140
+ Plan (free/premium) is taken from the logged-in user.
141
  """
142
+ return await generate_impl(request, req, user)
143
 
144
 
145
+ async def generate_impl(request: Request, req: GenerateRequest, user: dict):
146
  """Shared implementation for generate and generate_v1."""
147
+ plan = (user.get("plan") or "free").strip().lower()
148
  if plan != "premium" and len(req.problem_description) > FREE_MAX_INPUT_LEN:
149
  raise HTTPException(
150
  status_code=400,
auth.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # backend/auth.py
2
+ """
3
+ JWT and password hashing for Pseudogen. get_current_user dependency for protected routes.
4
+ """
5
+ import os
6
+ from datetime import datetime, timedelta, timezone
7
+
8
+ import jwt
9
+ from fastapi import Depends, HTTPException, status
10
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
11
+ from passlib.context import CryptContext
12
+ from dotenv import load_dotenv
13
+ from pathlib import Path
14
+
15
+ from database import get_user_by_email, get_user_by_id
16
+
17
+ load_dotenv(dotenv_path=Path(__file__).resolve().parent / ".env")
18
+
19
+ SECRET_KEY = os.getenv("SECRET_KEY", "change-me-in-production-use-env")
20
+ ALGORITHM = "HS256"
21
+ ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
22
+
23
+ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
24
+ security = HTTPBearer(auto_error=False)
25
+
26
+
27
+ def hash_password(password: str) -> str:
28
+ return pwd_context.hash(password)
29
+
30
+
31
+ def verify_password(plain: str, hashed: str) -> bool:
32
+ return pwd_context.verify(plain, hashed)
33
+
34
+
35
+ def create_access_token(data: dict) -> str:
36
+ to_encode = data.copy()
37
+ expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
38
+ to_encode["exp"] = expire
39
+ return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
40
+
41
+
42
+ def decode_token(token: str) -> dict | None:
43
+ try:
44
+ return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
45
+ except jwt.PyJWTError:
46
+ return None
47
+
48
+
49
+ async def get_current_user(
50
+ credentials: HTTPAuthorizationCredentials | None = Depends(security),
51
+ ) -> dict:
52
+ """Dependency: require valid Bearer token and return user dict (id, email, plan)."""
53
+ if credentials is None:
54
+ raise HTTPException(
55
+ status_code=status.HTTP_401_UNAUTHORIZED,
56
+ detail="Not authenticated",
57
+ headers={"WWW-Authenticate": "Bearer"},
58
+ )
59
+ payload = decode_token(credentials.credentials)
60
+ if payload is None:
61
+ raise HTTPException(
62
+ status_code=status.HTTP_401_UNAUTHORIZED,
63
+ detail="Invalid or expired token",
64
+ headers={"WWW-Authenticate": "Bearer"},
65
+ )
66
+ user_id = payload.get("sub")
67
+ if not user_id:
68
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
69
+ user = get_user_by_id(int(user_id))
70
+ if user is None:
71
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
72
+ return user
database.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # backend/database.py
2
+ """
3
+ SQLite database and user CRUD for Pseudogen auth.
4
+ """
5
+ import sqlite3
6
+ from pathlib import Path
7
+
8
+ _BACKEND_DIR = Path(__file__).resolve().parent
9
+ DB_PATH = _BACKEND_DIR / "pseudogen.db"
10
+
11
+
12
+ def get_connection():
13
+ conn = sqlite3.connect(DB_PATH)
14
+ conn.row_factory = sqlite3.Row
15
+ return conn
16
+
17
+
18
+ def init_db():
19
+ """Create users table if it does not exist."""
20
+ conn = get_connection()
21
+ try:
22
+ conn.execute("""
23
+ CREATE TABLE IF NOT EXISTS users (
24
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
25
+ email TEXT UNIQUE NOT NULL,
26
+ hashed_password TEXT NOT NULL,
27
+ plan TEXT NOT NULL DEFAULT 'free',
28
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
29
+ )
30
+ """)
31
+ conn.commit()
32
+ finally:
33
+ conn.close()
34
+
35
+
36
+ def get_user_by_email(email: str) -> dict | None:
37
+ conn = get_connection()
38
+ try:
39
+ row = conn.execute(
40
+ "SELECT id, email, hashed_password, plan, created_at FROM users WHERE email = ?",
41
+ (email.strip().lower(),),
42
+ ).fetchone()
43
+ if row is None:
44
+ return None
45
+ return dict(row)
46
+ finally:
47
+ conn.close()
48
+
49
+
50
+ def get_user_by_id(user_id: int) -> dict | None:
51
+ conn = get_connection()
52
+ try:
53
+ row = conn.execute(
54
+ "SELECT id, email, plan, created_at FROM users WHERE id = ?",
55
+ (user_id,),
56
+ ).fetchone()
57
+ if row is None:
58
+ return None
59
+ return dict(row)
60
+ finally:
61
+ conn.close()
62
+
63
+
64
+ def create_user(email: str, hashed_password: str, plan: str = "free") -> dict:
65
+ conn = get_connection()
66
+ try:
67
+ cursor = conn.execute(
68
+ "INSERT INTO users (email, hashed_password, plan) VALUES (?, ?, ?)",
69
+ (email.strip().lower(), hashed_password, plan),
70
+ )
71
+ conn.commit()
72
+ user_id = cursor.lastrowid
73
+ return {"id": user_id, "email": email.strip().lower(), "plan": plan}
74
+ finally:
75
+ conn.close()
requirements.txt CHANGED
@@ -13,6 +13,11 @@ PyYAML==6.0.3
13
  # Rate limiting
14
  slowapi==0.1.9
15
 
 
 
 
 
 
16
  # Testing
17
  pytest==8.3.4
18
  pytest-asyncio==0.24.0
 
13
  # Rate limiting
14
  slowapi==0.1.9
15
 
16
+ # Auth
17
+ passlib[bcrypt]==1.7.4
18
+ PyJWT==2.10.1
19
+ email-validator==2.2.0
20
+
21
  # Testing
22
  pytest==8.3.4
23
  pytest-asyncio==0.24.0
routers/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # backend/routers
routers/auth.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # backend/routers/auth.py
2
+ from fastapi import APIRouter, Depends, HTTPException, status
3
+ from pydantic import BaseModel, EmailStr
4
+
5
+ from database import init_db, get_user_by_email, create_user
6
+ from auth import hash_password, verify_password, create_access_token, get_current_user
7
+
8
+ router = APIRouter(prefix="/auth", tags=["auth"])
9
+
10
+
11
+ class RegisterRequest(BaseModel):
12
+ email: EmailStr
13
+ password: str
14
+
15
+
16
+ class LoginRequest(BaseModel):
17
+ email: EmailStr
18
+ password: str
19
+
20
+
21
+ class TokenResponse(BaseModel):
22
+ access_token: str
23
+ token_type: str = "bearer"
24
+
25
+
26
+ class UserResponse(BaseModel):
27
+ id: int
28
+ email: str
29
+ plan: str
30
+
31
+
32
+ @router.post("/register", response_model=TokenResponse)
33
+ def register(req: RegisterRequest):
34
+ if len(req.password) < 8:
35
+ raise HTTPException(
36
+ status_code=status.HTTP_400_BAD_REQUEST,
37
+ detail="Password must be at least 8 characters",
38
+ )
39
+ if get_user_by_email(req.email):
40
+ raise HTTPException(
41
+ status_code=status.HTTP_400_BAD_REQUEST,
42
+ detail="Email already registered",
43
+ )
44
+ init_db()
45
+ hashed = hash_password(req.password)
46
+ user = create_user(req.email, hashed)
47
+ token = create_access_token({"sub": str(user["id"])})
48
+ return TokenResponse(access_token=token)
49
+
50
+
51
+ @router.post("/login", response_model=TokenResponse)
52
+ def login(req: LoginRequest):
53
+ init_db()
54
+ user = get_user_by_email(req.email)
55
+ if not user or not verify_password(req.password, user["hashed_password"]):
56
+ raise HTTPException(
57
+ status_code=status.HTTP_401_UNAUTHORIZED,
58
+ detail="Invalid email or password",
59
+ )
60
+ token = create_access_token({"sub": str(user["id"])})
61
+ return TokenResponse(access_token=token)
62
+
63
+
64
+ @router.get("/me", response_model=UserResponse)
65
+ def me(user: dict = Depends(get_current_user)):
66
+ return UserResponse(id=user["id"], email=user["email"], plan=user["plan"])
tests/test_app.py CHANGED
@@ -1,14 +1,29 @@
1
  """
2
  Unit tests for Pseudogen FastAPI app.
3
  Tests GenerateRequest validation, style/detail handling, and generate endpoint with mocked LLM.
 
4
  """
5
  import pytest
6
  from fastapi.testclient import TestClient
7
  from unittest.mock import patch
8
 
9
- # Import app after setting up mocks so we can patch utils.call_llm
10
  from app import app, GenerateRequest
 
11
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  client = TestClient(app)
13
 
14
 
@@ -132,17 +147,20 @@ def test_free_plan_rejects_input_over_4000_chars():
132
 
133
 
134
  def test_premium_plan_accepts_input_up_to_12000_chars():
135
- """With X-Plan: premium, input up to 12000 chars is accepted."""
136
- with patch("app.call_llm") as mock_llm:
137
- mock_llm.return_value = "BEGIN\nEND"
138
- r = client.post(
139
- "/generate-pseudocode",
140
- headers={"X-Plan": "premium"},
141
- json={
142
- "problem_description": "y" * 10000,
143
- "style": "Academic",
144
- "detail": "Concise",
145
- },
146
- )
147
- assert r.status_code == 200
148
- assert r.json().get("markdown") == "BEGIN\nEND"
 
 
 
 
1
  """
2
  Unit tests for Pseudogen FastAPI app.
3
  Tests GenerateRequest validation, style/detail handling, and generate endpoint with mocked LLM.
4
+ Auth is overridden so generate endpoints receive a fake user.
5
  """
6
  import pytest
7
  from fastapi.testclient import TestClient
8
  from unittest.mock import patch
9
 
 
10
  from app import app, GenerateRequest
11
+ from auth import get_current_user
12
 
13
+ # Override auth so generate endpoints see a fake user (plan from user, not header)
14
+ FAKE_USER_FREE = {"id": 1, "email": "test@test.com", "plan": "free"}
15
+ FAKE_USER_PREMIUM = {"id": 2, "email": "premium@test.com", "plan": "premium"}
16
+
17
+
18
+ async def _fake_user_free():
19
+ return FAKE_USER_FREE
20
+
21
+
22
+ async def _fake_user_premium():
23
+ return FAKE_USER_PREMIUM
24
+
25
+
26
+ app.dependency_overrides[get_current_user] = _fake_user_free
27
  client = TestClient(app)
28
 
29
 
 
147
 
148
 
149
  def test_premium_plan_accepts_input_up_to_12000_chars():
150
+ """With user plan premium, input up to 12000 chars is accepted."""
151
+ app.dependency_overrides[get_current_user] = _fake_user_premium
152
+ try:
153
+ with patch("app.call_llm") as mock_llm:
154
+ mock_llm.return_value = "BEGIN\nEND"
155
+ r = client.post(
156
+ "/generate-pseudocode",
157
+ json={
158
+ "problem_description": "y" * 10000,
159
+ "style": "Academic",
160
+ "detail": "Concise",
161
+ },
162
+ )
163
+ assert r.status_code == 200
164
+ assert r.json().get("markdown") == "BEGIN\nEND"
165
+ finally:
166
+ app.dependency_overrides[get_current_user] = _fake_user_free
tests/test_auth.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for auth endpoints: register, login, /me.
3
+ Uses real SQLite DB (backend/pseudogen.db). Register uses a unique email per run to avoid conflicts.
4
+ """
5
+ import time
6
+ import pytest
7
+ from fastapi.testclient import TestClient
8
+
9
+ from app import app
10
+
11
+ client = TestClient(app)
12
+
13
+
14
+ def _unique_email():
15
+ return f"test-{int(time.time() * 1000)}@example.com"
16
+
17
+
18
+ def test_register_returns_token():
19
+ email = _unique_email()
20
+ r = client.post("/auth/register", json={"email": email, "password": "password123"})
21
+ assert r.status_code == 200
22
+ data = r.json()
23
+ assert "access_token" in data
24
+ assert data.get("token_type") == "bearer"
25
+
26
+
27
+ def test_register_rejects_short_password():
28
+ r = client.post("/auth/register", json={"email": "a@b.com", "password": "short"})
29
+ assert r.status_code == 400
30
+ assert "8" in r.json().get("detail", "")
31
+
32
+
33
+ def test_register_rejects_duplicate_email():
34
+ email = _unique_email()
35
+ client.post("/auth/register", json={"email": email, "password": "password123"})
36
+ r = client.post("/auth/register", json={"email": email, "password": "password123"})
37
+ assert r.status_code == 400
38
+ assert "already" in r.json().get("detail", "").lower()
39
+
40
+
41
+ def test_login_returns_token():
42
+ email = _unique_email()
43
+ client.post("/auth/register", json={"email": email, "password": "password123"})
44
+ r = client.post("/auth/login", json={"email": email, "password": "password123"})
45
+ assert r.status_code == 200
46
+ assert "access_token" in r.json()
47
+
48
+
49
+ def test_login_rejects_wrong_password():
50
+ email = _unique_email()
51
+ client.post("/auth/register", json={"email": email, "password": "password123"})
52
+ r = client.post("/auth/login", json={"email": email, "password": "wrong"})
53
+ assert r.status_code == 401
54
+
55
+
56
+ def test_me_returns_user_with_valid_token():
57
+ email = _unique_email()
58
+ reg = client.post("/auth/register", json={"email": email, "password": "password123"})
59
+ token = reg.json()["access_token"]
60
+ r = client.get("/auth/me", headers={"Authorization": f"Bearer {token}"})
61
+ assert r.status_code == 200
62
+ data = r.json()
63
+ assert data["email"] == email
64
+ assert data["plan"] == "free"
65
+
66
+
67
+ def test_me_rejects_invalid_token():
68
+ r = client.get("/auth/me", headers={"Authorization": "Bearer invalid"})
69
+ assert r.status_code == 401