Add JWT-based user authentication to backend

- Create User model with bcrypt password hashing
- Add auth routes: register, login, refresh, me
- Implement JWT access and refresh tokens
- Add get_current_user dependency for protected routes
- Update Task model with user_id foreign key for data isolation
- Update TaskService to filter tasks by authenticated user
- Add auth configuration (secret key, token expiry)
This commit is contained in:
m3mo 2026-02-02 22:57:38 +01:00
parent 5cd79e096d
commit 911f192c38
13 changed files with 315 additions and 7 deletions

View File

@ -0,0 +1,5 @@
from .models import User
from .routes import router as auth_router
from .dependencies import get_current_user
__all__ = ["User", "auth_router", "get_current_user"]

View File

@ -0,0 +1,46 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from ..db import get_db
from .models import User
from .utils import decode_token
security = HTTPBearer()
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db),
) -> User:
token = credentials.credentials
payload = decode_token(token)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
if payload.get("type") != "access":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token type",
)
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload",
)
user = db.query(User).filter(User.id == user_id).first()
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
)
return user

View File

@ -0,0 +1,18 @@
import uuid
from datetime import datetime
from sqlalchemy import Column, String, DateTime
from sqlalchemy.orm import relationship
from ..db import Base
class User(Base):
__tablename__ = "users"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
email = Column(String, unique=True, nullable=False, index=True)
password_hash = Column(String, nullable=False)
name = Column(String, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
tasks = relationship("Task", back_populates="owner", cascade="all, delete-orphan")

View File

@ -0,0 +1,67 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ..db import get_db
from .schemas import UserCreate, UserLogin, UserResponse, TokenResponse, TokenRefresh
from .services import AuthService
from .dependencies import get_current_user
from .models import User
router = APIRouter(prefix="/auth", tags=["authentication"])
def get_auth_service(db: Session = Depends(get_db)) -> AuthService:
return AuthService(db)
@router.post("/register", response_model=UserResponse, status_code=201)
def register(
user_data: UserCreate,
service: AuthService = Depends(get_auth_service),
):
existing_user = service.get_user_by_email(user_data.email)
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
)
user = service.create_user(user_data)
return user
@router.post("/login", response_model=TokenResponse)
def login(
credentials: UserLogin,
service: AuthService = Depends(get_auth_service),
):
user = service.authenticate_user(credentials.email, credentials.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
return service.create_tokens(user)
@router.post("/refresh", response_model=TokenResponse)
def refresh(
token_data: TokenRefresh,
service: AuthService = Depends(get_auth_service),
):
tokens = service.refresh_tokens(token_data.refresh_token)
if tokens is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired refresh token",
)
return tokens
@router.get("/me", response_model=UserResponse)
def get_current_user_info(
current_user: User = Depends(get_current_user),
):
return current_user

View File

@ -0,0 +1,39 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field, EmailStr
class UserBase(BaseModel):
email: EmailStr
name: str = Field(..., min_length=1, max_length=100)
class UserCreate(UserBase):
password: str = Field(..., min_length=8, max_length=100)
class UserLogin(BaseModel):
email: EmailStr
password: str
class UserResponse(UserBase):
model_config = ConfigDict(from_attributes=True)
id: str
created_at: datetime
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class TokenRefresh(BaseModel):
refresh_token: str
class TokenData(BaseModel):
user_id: Optional[str] = None
email: Optional[str] = None

View File

@ -0,0 +1,58 @@
from typing import Optional
from sqlalchemy.orm import Session
from .models import User
from .schemas import UserCreate
from .utils import get_password_hash, verify_password, create_access_token, create_refresh_token, decode_token
class AuthService:
def __init__(self, db: Session):
self.db = db
def get_user_by_email(self, email: str) -> Optional[User]:
return self.db.query(User).filter(User.email == email).first()
def get_user_by_id(self, user_id: str) -> Optional[User]:
return self.db.query(User).filter(User.id == user_id).first()
def create_user(self, user_data: UserCreate) -> User:
password_hash = get_password_hash(user_data.password)
user = User(
email=user_data.email,
name=user_data.name,
password_hash=password_hash,
)
self.db.add(user)
self.db.commit()
self.db.refresh(user)
return user
def authenticate_user(self, email: str, password: str) -> Optional[User]:
user = self.get_user_by_email(email)
if not user:
return None
if not verify_password(password, user.password_hash):
return None
return user
def create_tokens(self, user: User) -> dict:
access_token = create_access_token(data={"sub": user.id, "email": user.email})
refresh_token = create_refresh_token(data={"sub": user.id, "email": user.email})
return {
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "bearer",
}
def refresh_tokens(self, refresh_token: str) -> Optional[dict]:
payload = decode_token(refresh_token)
if payload is None or payload.get("type") != "refresh":
return None
user_id = payload.get("sub")
user = self.get_user_by_id(user_id)
if user is None:
return None
return self.create_tokens(user)

45
backend/app/auth/utils.py Normal file
View File

@ -0,0 +1,45 @@
from datetime import datetime, timedelta
from typing import Optional
import bcrypt
from jose import JWTError, jwt
from ..config import get_settings
settings = get_settings()
def verify_password(plain_password: str, hashed_password: str) -> bool:
return bcrypt.checkpw(
plain_password.encode('utf-8'),
hashed_password.encode('utf-8')
)
def get_password_hash(password: str) -> str:
return bcrypt.hashpw(
password.encode('utf-8'),
bcrypt.gensalt()
).decode('utf-8')
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=settings.access_token_expire_minutes))
to_encode.update({"exp": expire, "type": "access"})
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
def create_refresh_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(days=settings.refresh_token_expire_days)
to_encode.update({"exp": expire, "type": "refresh"})
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
def decode_token(token: str) -> Optional[dict]:
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
return payload
except JWTError:
return None

View File

@ -6,6 +6,11 @@ class Settings(BaseSettings):
database_url: str = "sqlite:///./tasks.db"
debug: bool = False
secret_key: str = "your-secret-key-change-in-production-min-32-chars"
algorithm: str = "HS256"
access_token_expire_minutes: int = 30
refresh_token_expire_days: int = 7
class Config:
env_file = ".env"

View File

@ -3,7 +3,10 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .db import Base, engine
from .models import Task # noqa: F401 - needed for table creation
from .auth.models import User # noqa: F401 - needed for table creation
from .routes import router
from .auth.routes import router as auth_router
from .config import get_settings
settings = get_settings()
@ -30,6 +33,7 @@ app.add_middleware(
allow_headers=["*"],
)
app.include_router(auth_router)
app.include_router(router)

View File

@ -1,6 +1,7 @@
import uuid
from datetime import datetime
from sqlalchemy import Column, String, Boolean, DateTime, Index
from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from .db import Base
@ -17,3 +18,6 @@ class Task(Base):
is_done = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user_id = Column(String, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
owner = relationship("User", back_populates="tasks")

View File

@ -11,12 +11,17 @@ from .schemas import (
HealthResponse,
)
from .services import TaskService
from .auth.dependencies import get_current_user
from .auth.models import User
router = APIRouter()
def get_task_service(db: Session = Depends(get_db)) -> TaskService:
return TaskService(db)
def get_task_service(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> TaskService:
return TaskService(db, current_user.id)
@router.get("/health", response_model=HealthResponse)

View File

@ -6,13 +6,17 @@ from .schemas import TaskCreate, TaskUpdate
class TaskService:
def __init__(self, db: Session):
def __init__(self, db: Session, user_id: str):
self.db = db
self.user_id = user_id
def get_tasks_by_date(
self, date: str, status: Optional[str] = None
) -> list[Task]:
query = self.db.query(Task).filter(Task.date == date)
query = self.db.query(Task).filter(
Task.date == date,
Task.user_id == self.user_id
)
if status == "active":
query = query.filter(Task.is_done == False)
@ -22,7 +26,10 @@ class TaskService:
return query.order_by(Task.created_at.desc()).all()
def get_task_by_id(self, task_id: str) -> Optional[Task]:
return self.db.query(Task).filter(Task.id == task_id).first()
return self.db.query(Task).filter(
Task.id == task_id,
Task.user_id == self.user_id
).first()
def create_task(self, task_data: TaskCreate) -> Task:
task = Task(
@ -31,6 +38,7 @@ class TaskService:
date=task_data.date,
time=task_data.time,
priority=task_data.priority.value,
user_id=self.user_id,
)
self.db.add(task)
self.db.commit()

View File

@ -1,8 +1,12 @@
fastapi==0.115.0
uvicorn[standard]==0.32.0
sqlalchemy==2.0.36
pydantic==2.10.0
pydantic[email]==2.10.0
pydantic-settings==2.6.0
python-dotenv==1.0.1
pytest==8.3.0
httpx==0.28.0
python-jose[cryptography]==3.3.0
bcrypt==4.2.0
python-multipart==0.0.6
psycopg[binary]==3.2.3