Add user authentication to Todo application
- Create User model and schema - Implement password hashing with bcrypt - Add JWT token-based authentication - Create user and auth endpoints - Update todo endpoints with user authentication - Add alembic migration for user model - Update README with new features
This commit is contained in:
parent
b399d38a23
commit
69f6a404bd
43
README.md
43
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:
|
||||
|
@ -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"]
|
||||
__all__ = ["todos_router", "auth_router", "users_router"]
|
94
app/api/auth.py
Normal file
94
app/api/auth.py
Normal file
@ -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
|
@ -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
|
91
app/api/users.py
Normal file
91
app/api/users.py
Normal file
@ -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
|
1
app/core/__init__.py
Normal file
1
app/core/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Init file for core package
|
85
app/core/deps.py
Normal file
85
app/core/deps.py
Normal file
@ -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
|
53
app/core/security.py
Normal file
53
app/core/security.py
Normal file
@ -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)
|
@ -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"]
|
||||
__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"
|
||||
]
|
@ -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
|
||||
|
||||
|
101
app/crud/user.py
Normal file
101
app/crud/user.py
Normal file
@ -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
|
@ -1,3 +1,4 @@
|
||||
from app.models.todo import Todo
|
||||
from app.models.user import User
|
||||
|
||||
__all__ = ["Todo"]
|
||||
__all__ = ["Todo", "User"]
|
@ -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
|
||||
|
||||
@ -13,3 +14,7 @@ class Todo(Base):
|
||||
completed = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
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")
|
20
app/models/user.py
Normal file
20
app/models/user.py
Normal file
@ -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")
|
@ -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"]
|
||||
__all__ = [
|
||||
"Todo", "TodoBase", "TodoCreate", "TodoUpdate",
|
||||
"User", "UserBase", "UserCreate", "UserUpdate", "UserInDB", "Token", "TokenData"
|
||||
]
|
@ -22,6 +22,7 @@ class TodoUpdate(BaseModel):
|
||||
|
||||
class Todo(TodoBase):
|
||||
id: int
|
||||
owner_id: int
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
46
app/schemas/user.py
Normal file
46
app/schemas/user.py
Normal file
@ -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
|
12
main.py
12
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",
|
||||
}
|
58
migrations/versions/user_auth_migration.py
Normal file
58
migrations/versions/user_auth_migration.py
Normal file
@ -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')
|
@ -5,3 +5,6 @@ pydantic==2.4.2
|
||||
alembic==1.12.0
|
||||
python-multipart==0.0.6
|
||||
ruff==0.1.1
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
pydantic-settings==2.0.3
|
Loading…
x
Reference in New Issue
Block a user