diff --git a/README.md b/README.md index b66b9be..0172462 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,15 @@ -# Simple Todo Application API +# Simple Todo Application API with Authentication -This is a REST API for a simple todo application built with FastAPI and SQLite. +This is a REST API for a todo application built with FastAPI and SQLite, featuring user authentication and authorization. ## Features +- User registration and authentication with JWT tokens +- Secure password hashing with bcrypt +- User-specific todo items - Create, read, update, and delete todo items +- User profile management +- Role-based access control - Health endpoint for application monitoring - API documentation via Swagger UI and ReDoc - Database migrations using Alembic @@ -15,6 +20,7 @@ This is a REST API for a simple todo application built with FastAPI and SQLite. ``` ├── app/ │ ├── api/ # API endpoints +│ ├── core/ # Core functionality, security, dependencies │ ├── crud/ # Database CRUD operations │ ├── db/ # Database connection and utilities │ ├── models/ # SQLAlchemy models @@ -51,14 +57,41 @@ The API will be available at http://localhost:8000 ## API Endpoints -- `GET /`: Root endpoint with API information -- `GET /health`: Health check endpoint -- `GET /todos`: Get all todo items +### Authentication + +- `POST /auth/register`: Register a new user +- `POST /auth/login`: Login and get access token +- `POST /auth/refresh`: Refresh access token +- `GET /auth/me`: Get current user information + +### Users + +- `GET /users/`: Get all users (requires authentication) +- `GET /users/{id}`: Get a specific user by ID (requires authentication) +- `PATCH /users/{id}`: Update a user (requires authentication and ownership) +- `DELETE /users/{id}`: Delete a user (requires authentication and ownership) + +### Todo Items + +- `GET /todos`: Get all todo items for the current user - `POST /todos`: Create a new todo item - `GET /todos/{id}`: Get a specific todo item - `PATCH /todos/{id}`: Update a todo item - `DELETE /todos/{id}`: Delete a todo item +**Note:** All todo operations require authentication and only access to the user's own todos is allowed. + +### Other + +- `GET /`: Root endpoint with API information +- `GET /health`: Health check endpoint + +## Authentication Flow + +1. Register a new user: `POST /auth/register` +2. Login to get a JWT token: `POST /auth/login` +3. Use the token in the Authorization header for all subsequent requests: `Authorization: Bearer {token}` + ## Database Migrations Run migrations with: diff --git a/app/api/__init__.py b/app/api/__init__.py index 7538a8f..f3942f2 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -1,3 +1,5 @@ from app.api.todos import router as todos_router +from app.api.auth import router as auth_router +from app.api.users import router as users_router -__all__ = ["todos_router"] \ No newline at end of file +__all__ = ["todos_router", "auth_router", "users_router"] \ No newline at end of file diff --git a/app/api/auth.py b/app/api/auth.py new file mode 100644 index 0000000..449907e --- /dev/null +++ b/app/api/auth.py @@ -0,0 +1,94 @@ +from datetime import timedelta +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session + +from app.core.deps import get_current_user +from app.core.security import ( + ACCESS_TOKEN_EXPIRE_MINUTES, + create_access_token, +) +from app.crud.user import authenticate_user, create_user, get_user_by_email, get_user_by_username +from app.db.database import get_db +from app.models.user import User +from app.schemas.user import Token, User as UserSchema, UserCreate + +router = APIRouter( + prefix="/auth", + tags=["authentication"], +) + + +@router.post("/login", response_model=Token) +def login_for_access_token( + db: Session = Depends(get_db), form_data: OAuth2PasswordRequestForm = Depends() +) -> Any: + """ + OAuth2 compatible token login, get an access token for future requests + """ + user = authenticate_user(db, form_data.username, form_data.password) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + subject=user.id, expires_delta=access_token_expires + ) + + return {"access_token": access_token, "token_type": "bearer"} + + +@router.post("/refresh", response_model=Token) +def refresh_token( + db: Session = Depends(get_db), current_user: User = Depends(get_current_user) +) -> Any: + """ + Refresh access token + """ + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + subject=current_user.id, expires_delta=access_token_expires + ) + + return {"access_token": access_token, "token_type": "bearer"} + + +@router.post("/register", response_model=UserSchema, status_code=status.HTTP_201_CREATED) +def register_user(*, db: Session = Depends(get_db), user_in: UserCreate) -> Any: + """ + Register a new user + """ + # Check if user with this email already exists + user = get_user_by_email(db, email=user_in.email) + if user: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="A user with this email already exists", + ) + + # Check if user with this username already exists + user = get_user_by_username(db, username=user_in.username) + if user: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="A user with this username already exists", + ) + + # Create new user + user = create_user(db, user_in) + + return user + + +@router.get("/me", response_model=UserSchema) +def read_users_me(current_user: User = Depends(get_current_user)) -> Any: + """ + Get current user information + """ + return current_user \ No newline at end of file diff --git a/app/api/todos.py b/app/api/todos.py index 67b34d2..54596af 100644 --- a/app/api/todos.py +++ b/app/api/todos.py @@ -3,8 +3,10 @@ from typing import List from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session +from app.core.deps import get_current_active_user from app.crud import todo as todo_crud from app.db.database import get_db +from app.models.user import User from app.schemas.todo import Todo, TodoCreate, TodoUpdate router = APIRouter( @@ -15,50 +17,72 @@ router = APIRouter( @router.post("/", response_model=Todo, status_code=status.HTTP_201_CREATED) -def create_todo(todo: TodoCreate, db: Session = Depends(get_db)): +def create_todo( + todo: TodoCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): """ - Create a new todo item. + Create a new todo item for the current user. """ - return todo_crud.create_todo(db=db, todo=todo) + return todo_crud.create_todo(db=db, todo=todo, owner_id=current_user.id) @router.get("/", response_model=List[Todo]) -def read_todos(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): +def read_todos( + skip: int = 0, + limit: int = 100, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): """ - Retrieve all todo items with pagination. + Retrieve all todo items for the current user with pagination. """ - todos = todo_crud.get_todos(db, skip=skip, limit=limit) + todos = todo_crud.get_todos_by_owner(db, owner_id=current_user.id, skip=skip, limit=limit) return todos @router.get("/{todo_id}", response_model=Todo) -def read_todo(todo_id: int, db: Session = Depends(get_db)): +def read_todo( + todo_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): """ - Retrieve a specific todo item by ID. + Retrieve a specific todo item by ID for the current user. """ - db_todo = todo_crud.get_todo(db, todo_id=todo_id) + db_todo = todo_crud.get_todo_by_owner(db, todo_id=todo_id, owner_id=current_user.id) if db_todo is None: raise HTTPException(status_code=404, detail="Todo not found") return db_todo @router.patch("/{todo_id}", response_model=Todo) -def update_todo(todo_id: int, todo: TodoUpdate, db: Session = Depends(get_db)): +def update_todo( + todo_id: int, + todo: TodoUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): """ - Update a todo item. + Update a todo item for the current user. """ - db_todo = todo_crud.update_todo(db, todo_id=todo_id, todo=todo) + db_todo = todo_crud.update_todo(db, todo_id=todo_id, todo=todo, owner_id=current_user.id) if db_todo is None: raise HTTPException(status_code=404, detail="Todo not found") return db_todo @router.delete("/{todo_id}", status_code=status.HTTP_204_NO_CONTENT) -def delete_todo(todo_id: int, db: Session = Depends(get_db)): +def delete_todo( + todo_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): """ - Delete a todo item. + Delete a todo item for the current user. """ - success = todo_crud.delete_todo(db, todo_id=todo_id) + success = todo_crud.delete_todo(db, todo_id=todo_id, owner_id=current_user.id) if not success: raise HTTPException(status_code=404, detail="Todo not found") return None \ No newline at end of file diff --git a/app/api/users.py b/app/api/users.py new file mode 100644 index 0000000..0ad8b84 --- /dev/null +++ b/app/api/users.py @@ -0,0 +1,91 @@ +from typing import Any, List + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.core.deps import get_current_active_user +from app.crud.user import get_user, get_users, update_user, delete_user +from app.db.database import get_db +from app.models.user import User +from app.schemas.user import User as UserSchema, UserUpdate + +router = APIRouter( + prefix="/users", + tags=["users"], + dependencies=[Depends(get_current_active_user)], + responses={404: {"description": "User not found"}}, +) + + +@router.get("/", response_model=List[UserSchema]) +def read_users( + db: Session = Depends(get_db), + skip: int = 0, + limit: int = 100, + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Retrieve users. + """ + users = get_users(db, skip=skip, limit=limit) + return users + + +@router.get("/{user_id}", response_model=UserSchema) +def read_user( + user_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Get a specific user by id. + """ + user = get_user(db, user_id=user_id) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + return user + + +@router.patch("/{user_id}", response_model=UserSchema) +def update_user_endpoint( + user_id: int, + user_in: UserUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Update a user. + """ + # Only allow users to update their own information + if current_user.id != user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions to update this user" + ) + + user = update_user(db, user_id=user_id, user=user_in) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + return user + + +@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_user_endpoint( + user_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Delete a user. + """ + # Only allow users to delete their own account + if current_user.id != user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions to delete this user" + ) + + success = delete_user(db, user_id=user_id) + if not success: + raise HTTPException(status_code=404, detail="User not found") + return None \ No newline at end of file diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..7f2afa8 --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1 @@ +# Init file for core package \ No newline at end of file diff --git a/app/core/deps.py b/app/core/deps.py new file mode 100644 index 0000000..cd34a5f --- /dev/null +++ b/app/core/deps.py @@ -0,0 +1,85 @@ +from typing import Optional + +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError, jwt +from sqlalchemy.orm import Session + +from app.core.security import ALGORITHM, SECRET_KEY +from app.crud.user import get_user +from app.db.database import get_db +from app.models.user import User +from app.schemas.user import TokenData + +# OAuth2 configuration +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login") + + +def get_current_user( + db: Session = Depends(get_db), token: str = Depends(oauth2_scheme) +) -> User: + """ + Validates the access token and returns the current user + """ + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + user_id: str = payload.get("sub") + + if user_id is None: + raise credentials_exception + + token_data = TokenData(user_id=int(user_id)) + except JWTError: + raise credentials_exception + + user = get_user(db, user_id=token_data.user_id) + + if user is None: + raise credentials_exception + + return user + + +def get_current_active_user(current_user: User = Depends(get_current_user)) -> User: + """ + Checks if the current user is active + """ + if not current_user.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user" + ) + return current_user + + +def get_optional_current_user( + db: Session = Depends(get_db), token: Optional[str] = Depends(oauth2_scheme) +) -> Optional[User]: + """ + Similar to get_current_user but doesn't raise an exception for missing token + """ + if token is None: + return None + + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + user_id: str = payload.get("sub") + + if user_id is None: + return None + + token_data = TokenData(user_id=int(user_id)) + except JWTError: + return None + + user = get_user(db, user_id=token_data.user_id) + + if user is None or not user.is_active: + return None + + return user \ No newline at end of file diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..8c56606 --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,53 @@ +from datetime import datetime, timedelta +from typing import Any, Optional, Union + +from jose import jwt +from passlib.context import CryptContext + +# Password hashing context +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +# Secret key and algorithm for JWT +SECRET_KEY = "a_very_secret_key_that_should_be_in_env_vars" # In production, use env vars +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 30 + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """ + Verify if the provided password matches the stored hashed password + """ + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + """ + Hash a password for storing + """ + return pwd_context.hash(password) + + +def create_access_token( + subject: Union[str, Any], expires_delta: Optional[timedelta] = None +) -> str: + """ + Create a JWT access token + """ + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode = {"exp": expire, "sub": str(subject)} + + return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + + +def create_refresh_token(subject: Union[str, Any]) -> str: + """ + Create a JWT refresh token with longer expiry + """ + expire = datetime.utcnow() + timedelta(days=7) # Refresh tokens are valid for 7 days + to_encode = {"exp": expire, "sub": str(subject), "type": "refresh"} + + return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) \ No newline at end of file diff --git a/app/crud/__init__.py b/app/crud/__init__.py index 87bdd56..025346a 100644 --- a/app/crud/__init__.py +++ b/app/crud/__init__.py @@ -1,3 +1,11 @@ from app.crud.todo import create_todo, delete_todo, get_todo, get_todos, update_todo +from app.crud.user import ( + authenticate_user, create_user, delete_user, get_user, get_user_by_email, + get_user_by_username, get_users, update_user +) -__all__ = ["create_todo", "delete_todo", "get_todo", "get_todos", "update_todo"] \ No newline at end of file +__all__ = [ + "create_todo", "delete_todo", "get_todo", "get_todos", "update_todo", + "authenticate_user", "create_user", "delete_user", "get_user", "get_user_by_email", + "get_user_by_username", "get_users", "update_user" +] \ No newline at end of file diff --git a/app/crud/todo.py b/app/crud/todo.py index 22582b3..e9c4f1f 100644 --- a/app/crud/todo.py +++ b/app/crud/todo.py @@ -10,15 +10,24 @@ def get_todo(db: Session, todo_id: int) -> Optional[Todo]: return db.query(Todo).filter(Todo.id == todo_id).first() +def get_todo_by_owner(db: Session, todo_id: int, owner_id: int) -> Optional[Todo]: + return db.query(Todo).filter(Todo.id == todo_id, Todo.owner_id == owner_id).first() + + def get_todos(db: Session, skip: int = 0, limit: int = 100) -> List[Todo]: return db.query(Todo).offset(skip).limit(limit).all() -def create_todo(db: Session, todo: TodoCreate) -> Todo: +def get_todos_by_owner(db: Session, owner_id: int, skip: int = 0, limit: int = 100) -> List[Todo]: + return db.query(Todo).filter(Todo.owner_id == owner_id).offset(skip).limit(limit).all() + + +def create_todo(db: Session, todo: TodoCreate, owner_id: int) -> Todo: db_todo = Todo( title=todo.title, description=todo.description, completed=todo.completed, + owner_id=owner_id, ) db.add(db_todo) db.commit() @@ -26,8 +35,8 @@ def create_todo(db: Session, todo: TodoCreate) -> Todo: return db_todo -def update_todo(db: Session, todo_id: int, todo: TodoUpdate) -> Optional[Todo]: - db_todo = get_todo(db, todo_id) +def update_todo(db: Session, todo_id: int, todo: TodoUpdate, owner_id: int) -> Optional[Todo]: + db_todo = get_todo_by_owner(db, todo_id, owner_id) if db_todo is None: return None @@ -40,8 +49,8 @@ def update_todo(db: Session, todo_id: int, todo: TodoUpdate) -> Optional[Todo]: return db_todo -def delete_todo(db: Session, todo_id: int) -> bool: - db_todo = get_todo(db, todo_id) +def delete_todo(db: Session, todo_id: int, owner_id: int) -> bool: + db_todo = get_todo_by_owner(db, todo_id, owner_id) if db_todo is None: return False diff --git a/app/crud/user.py b/app/crud/user.py new file mode 100644 index 0000000..9f5f7a6 --- /dev/null +++ b/app/crud/user.py @@ -0,0 +1,101 @@ +from typing import List, Optional + +from sqlalchemy.orm import Session + +from app.core.security import get_password_hash, verify_password +from app.models.user import User +from app.schemas.user import UserCreate, UserUpdate + + +def get_user(db: Session, user_id: int) -> Optional[User]: + """ + Get a user by ID + """ + return db.query(User).filter(User.id == user_id).first() + + +def get_user_by_email(db: Session, email: str) -> Optional[User]: + """ + Get a user by email + """ + return db.query(User).filter(User.email == email).first() + + +def get_user_by_username(db: Session, username: str) -> Optional[User]: + """ + Get a user by username + """ + return db.query(User).filter(User.username == username).first() + + +def get_users(db: Session, skip: int = 0, limit: int = 100) -> List[User]: + """ + Get a list of users with pagination + """ + return db.query(User).offset(skip).limit(limit).all() + + +def create_user(db: Session, user: UserCreate) -> User: + """ + Create a new user + """ + hashed_password = get_password_hash(user.password) + db_user = User( + email=user.email, + username=user.username, + hashed_password=hashed_password, + is_active=user.is_active, + ) + db.add(db_user) + db.commit() + db.refresh(db_user) + return db_user + + +def update_user( + db: Session, user_id: int, user: UserUpdate +) -> Optional[User]: + """ + Update user information + """ + db_user = get_user(db, user_id) + if not db_user: + return None + + update_data = user.model_dump(exclude_unset=True) + + # Hash the password if it's being updated + if "password" in update_data: + update_data["hashed_password"] = get_password_hash(update_data.pop("password")) + + for key, value in update_data.items(): + setattr(db_user, key, value) + + db.commit() + db.refresh(db_user) + return db_user + + +def delete_user(db: Session, user_id: int) -> bool: + """ + Delete a user + """ + db_user = get_user(db, user_id) + if not db_user: + return False + + db.delete(db_user) + db.commit() + return True + + +def authenticate_user(db: Session, username: str, password: str) -> Optional[User]: + """ + Authenticate a user by username and password + """ + user = get_user_by_username(db, username) + if not user: + return None + if not verify_password(password, user.hashed_password): + return None + return user \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py index 4dec223..35f4662 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,3 +1,4 @@ from app.models.todo import Todo +from app.models.user import User -__all__ = ["Todo"] \ No newline at end of file +__all__ = ["Todo", "User"] \ No newline at end of file diff --git a/app/models/todo.py b/app/models/todo.py index 24dc07c..7274722 100644 --- a/app/models/todo.py +++ b/app/models/todo.py @@ -1,5 +1,6 @@ -from sqlalchemy import Boolean, Column, Integer, String, DateTime +from sqlalchemy import Boolean, Column, Integer, String, DateTime, ForeignKey from sqlalchemy.sql import func +from sqlalchemy.orm import relationship from app.db.database import Base @@ -12,4 +13,8 @@ class Todo(Base): description = Column(String) completed = Column(Boolean, default=False) created_at = Column(DateTime(timezone=True), server_default=func.now()) - updated_at = Column(DateTime(timezone=True), onupdate=func.now()) \ No newline at end of file + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # New fields for user association + owner_id = Column(Integer, ForeignKey("users.id")) + owner = relationship("User", back_populates="todos") \ No newline at end of file diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..a0abac9 --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,20 @@ +from sqlalchemy import Boolean, Column, Integer, String, DateTime +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship + +from app.db.database import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + email = Column(String, unique=True, index=True) + username = Column(String, unique=True, index=True) + hashed_password = Column(String) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Relationship with Todo items + todos = relationship("Todo", back_populates="owner", cascade="all, delete-orphan") \ No newline at end of file diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py index 7308074..49ca72c 100644 --- a/app/schemas/__init__.py +++ b/app/schemas/__init__.py @@ -1,3 +1,7 @@ from app.schemas.todo import Todo, TodoBase, TodoCreate, TodoUpdate +from app.schemas.user import User, UserBase, UserCreate, UserUpdate, UserInDB, Token, TokenData -__all__ = ["Todo", "TodoBase", "TodoCreate", "TodoUpdate"] \ No newline at end of file +__all__ = [ + "Todo", "TodoBase", "TodoCreate", "TodoUpdate", + "User", "UserBase", "UserCreate", "UserUpdate", "UserInDB", "Token", "TokenData" +] \ No newline at end of file diff --git a/app/schemas/todo.py b/app/schemas/todo.py index da47309..05fbba1 100644 --- a/app/schemas/todo.py +++ b/app/schemas/todo.py @@ -22,6 +22,7 @@ class TodoUpdate(BaseModel): class Todo(TodoBase): id: int + owner_id: int created_at: datetime updated_at: Optional[datetime] = None diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..abe8f36 --- /dev/null +++ b/app/schemas/user.py @@ -0,0 +1,46 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, EmailStr, Field + + +class UserBase(BaseModel): + email: EmailStr + username: str + is_active: Optional[bool] = True + + +class UserCreate(UserBase): + password: str = Field(..., min_length=6) + + +class UserUpdate(BaseModel): + email: Optional[EmailStr] = None + username: Optional[str] = None + password: Optional[str] = Field(None, min_length=6) + is_active: Optional[bool] = None + + +class User(UserBase): + id: int + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + orm_mode = True + from_attributes = True + + +class UserInDB(User): + hashed_password: str + + +class Token(BaseModel): + access_token: str + token_type: str + + +class TokenData(BaseModel): + username: Optional[str] = None + email: Optional[str] = None + user_id: Optional[int] = None \ No newline at end of file diff --git a/main.py b/main.py index c63e4b4..de4f5e9 100644 --- a/main.py +++ b/main.py @@ -1,16 +1,16 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from app.api import todos_router +from app.api import auth_router, todos_router, users_router from app.db.database import Base, engine # Create tables in database Base.metadata.create_all(bind=engine) app = FastAPI( - title="Simple Todo Application", - description="A simple todo application API built with FastAPI", - version="0.1.0", + title="Simple Todo Application with Authentication", + description="A todo application API built with FastAPI and user authentication", + version="0.2.0", ) # Setup CORS @@ -23,6 +23,8 @@ app.add_middleware( ) # Include routers +app.include_router(auth_router) +app.include_router(users_router) app.include_router(todos_router) # Health endpoint @@ -40,7 +42,7 @@ def read_root(): Root endpoint with links to API documentation """ return { - "message": "Welcome to the Simple Todo Application API", + "message": "Welcome to the Simple Todo Application API with User Authentication", "documentation": "/docs", "alternative_doc": "/redoc", } \ No newline at end of file diff --git a/migrations/versions/user_auth_migration.py b/migrations/versions/user_auth_migration.py new file mode 100644 index 0000000..1d192d0 --- /dev/null +++ b/migrations/versions/user_auth_migration.py @@ -0,0 +1,58 @@ +"""User authentication and Todo-User relationship + +Revision ID: 2 +Depends on: 1 +Create Date: 2023-10-28 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '2' +down_revision = '1' +branch_labels = None +depends_on = None + + +def upgrade(): + # Create users table + op.create_table( + 'users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('email', sa.String(), nullable=False), + sa.Column('username', sa.String(), nullable=False), + sa.Column('hashed_password', sa.String(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=True, default=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)')), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + + # Create indexes on users table + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False) + op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True) + + # Add owner_id column to todos table + op.add_column('todos', sa.Column('owner_id', sa.Integer(), nullable=True)) + + # Create foreign key constraint + op.create_foreign_key('fk_todos_owner_id_users', 'todos', 'users', ['owner_id'], ['id']) + + +def downgrade(): + # Drop foreign key constraint + op.drop_constraint('fk_todos_owner_id_users', 'todos', type_='foreignkey') + + # Drop owner_id column from todos table + op.drop_column('todos', 'owner_id') + + # Drop indexes on users table + op.drop_index(op.f('ix_users_username'), table_name='users') + op.drop_index(op.f('ix_users_id'), table_name='users') + op.drop_index(op.f('ix_users_email'), table_name='users') + + # Drop users table + op.drop_table('users') \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index a93d39e..b65791a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,7 @@ sqlalchemy==2.0.22 pydantic==2.4.2 alembic==1.12.0 python-multipart==0.0.6 -ruff==0.1.1 \ No newline at end of file +ruff==0.1.1 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +pydantic-settings==2.0.3 \ No newline at end of file