feat: implement auth API (register, login, JWT)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1,4 @@
|
|||||||
"""API module."""
|
"""API module."""
|
||||||
|
from app.api import auth, users, images, history
|
||||||
|
|
||||||
|
__all__ = ["auth", "users", "images", "history"]
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.schemas.auth import UserRegister, UserLogin, Token, TokenRefresh
|
||||||
|
from app.schemas.user import UserResponse
|
||||||
|
from app.services.auth import AuthService
|
||||||
|
from app.core.security import create_access_token
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
def register(data: UserRegister, db: Session = Depends(get_db)):
|
||||||
|
service = AuthService(db)
|
||||||
|
try:
|
||||||
|
user = service.register(data.username, data.email, data.password)
|
||||||
|
return user
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
@router.post("/login", response_model=Token)
|
||||||
|
def login(data: UserLogin, db: Session = Depends(get_db)):
|
||||||
|
service = AuthService(db)
|
||||||
|
user = service.authenticate(data.username, data.password)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Incorrect username or password"
|
||||||
|
)
|
||||||
|
|
||||||
|
access_token = create_access_token(data={"sub": str(user.id)})
|
||||||
|
# For simplicity, using same token as refresh - in production, use separate refresh token
|
||||||
|
return Token(access_token=access_token, refresh_token=access_token)
|
||||||
|
|
||||||
|
@router.post("/refresh", response_model=Token)
|
||||||
|
def refresh(data: TokenRefresh, db: Session = Depends(get_db)):
|
||||||
|
# Simplified: in production, validate refresh token properly
|
||||||
|
from app.core.security import decode_access_token
|
||||||
|
payload = decode_access_token(data.refresh_token)
|
||||||
|
if not payload:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||||
|
|
||||||
|
access_token = create_access_token(data={"sub": payload.get("sub")})
|
||||||
|
return Token(access_token=access_token, refresh_token=data.refresh_token)
|
||||||
@@ -1 +1,39 @@
|
|||||||
"""API dependencies placeholder."""
|
from typing import Generator
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.core.security import decode_access_token
|
||||||
|
from app.services.auth import AuthService
|
||||||
|
|
||||||
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
def get_current_user(
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
token = credentials.credentials
|
||||||
|
payload = decode_access_token(token)
|
||||||
|
if not payload:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid or expired token"
|
||||||
|
)
|
||||||
|
|
||||||
|
user_id_str = payload.get("sub")
|
||||||
|
if not user_id_str:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
try:
|
||||||
|
user_id = UUID(user_id_str)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid user ID")
|
||||||
|
|
||||||
|
service = AuthService(db)
|
||||||
|
user = service.get_user_by_id(user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||||
|
|
||||||
|
return user
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
@@ -1 +1,18 @@
|
|||||||
"""Auth schemas placeholder."""
|
from pydantic import BaseModel, EmailStr
|
||||||
|
|
||||||
|
class UserRegister(BaseModel):
|
||||||
|
username: str
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
|
||||||
|
class UserLogin(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
class Token(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
|
||||||
|
class TokenRefresh(BaseModel):
|
||||||
|
refresh_token: str
|
||||||
@@ -1 +1,21 @@
|
|||||||
"""User schemas placeholder."""
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
class UserBase(BaseModel):
|
||||||
|
username: str
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
class UserCreate(UserBase):
|
||||||
|
password: str
|
||||||
|
|
||||||
|
class UserResponse(UserBase):
|
||||||
|
id: UUID
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class UserUpdate(BaseModel):
|
||||||
|
email: Optional[EmailStr] = None
|
||||||
@@ -1 +1,36 @@
|
|||||||
"""Auth service placeholder."""
|
from typing import Optional
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from app.models.user import User
|
||||||
|
from app.core.security import hash_password, verify_password
|
||||||
|
|
||||||
|
class AuthService:
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def register(self, username: str, email: str, password: str) -> User:
|
||||||
|
# Check if user exists
|
||||||
|
existing = self.db.query(User).filter(
|
||||||
|
(User.username == username) | (User.email == email)
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
raise ValueError("Username or email already registered")
|
||||||
|
|
||||||
|
hashed_password = hash_password(password)
|
||||||
|
user = User(username=username, email=email, hashed_password=hashed_password)
|
||||||
|
self.db.add(user)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
def authenticate(self, username: str, password: str) -> Optional[User]:
|
||||||
|
user = self.db.query(User).filter(User.username == username).first()
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
if not verify_password(password, user.hashed_password):
|
||||||
|
return None
|
||||||
|
return user
|
||||||
|
|
||||||
|
def get_user_by_id(self, user_id: UUID) -> Optional[User]:
|
||||||
|
return self.db.query(User).filter(User.id == user_id).first()
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import pytest
|
||||||
|
import os
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from app.database import Base, get_db
|
||||||
|
from app.main import app
|
||||||
|
from app.models.user import User
|
||||||
|
from app.core.security import hash_password
|
||||||
|
|
||||||
|
# Use file-based SQLite for testing to avoid in-memory connection issues
|
||||||
|
TEST_DB_PATH = "/tmp/test_auth.db"
|
||||||
|
if os.path.exists(TEST_DB_PATH):
|
||||||
|
os.remove(TEST_DB_PATH)
|
||||||
|
|
||||||
|
engine = create_engine(f"sqlite:///{TEST_DB_PATH}", connect_args={"check_same_thread": False})
|
||||||
|
TestSessionLocal = sessionmaker(bind=engine)
|
||||||
|
|
||||||
|
def override_get_db():
|
||||||
|
db = TestSessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
# Create tables once at module level
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
def test_register_success():
|
||||||
|
response = client.post("/api/auth/register", json={
|
||||||
|
"username": "newuser",
|
||||||
|
"email": "new@example.com",
|
||||||
|
"password": "password123"
|
||||||
|
})
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["username"] == "newuser"
|
||||||
|
assert "id" in data
|
||||||
|
|
||||||
|
def test_register_duplicate_username():
|
||||||
|
db = TestSessionLocal()
|
||||||
|
user = User(username="existing", email="existing@example.com", hashed_password=hash_password("pass"))
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
response = client.post("/api/auth/register", json={
|
||||||
|
"username": "existing",
|
||||||
|
"email": "new@example.com",
|
||||||
|
"password": "password123"
|
||||||
|
})
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_login_success():
|
||||||
|
db = TestSessionLocal()
|
||||||
|
user = User(username="loginuser", email="login@example.com", hashed_password=hash_password("correctpassword"))
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
response = client.post("/api/auth/login", json={
|
||||||
|
"username": "loginuser",
|
||||||
|
"password": "correctpassword"
|
||||||
|
})
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "access_token" in data
|
||||||
|
assert "token_type" in data
|
||||||
|
|
||||||
|
def test_login_wrong_password():
|
||||||
|
db = TestSessionLocal()
|
||||||
|
user = User(username="wrongpass", email="wrongpass@example.com", hashed_password=hash_password("correctpassword"))
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
response = client.post("/api/auth/login", json={
|
||||||
|
"username": "wrongpass",
|
||||||
|
"password": "wrongpassword"
|
||||||
|
})
|
||||||
|
assert response.status_code == 401
|
||||||
Reference in New Issue
Block a user