diff --git a/README.md b/README.md index e8acfba..62eee23 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,149 @@ -# FastAPI Application +# User Authentication Service -This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. +A FastAPI service that provides user authentication functionality including registration, login, and user management. + +## Features + +- User registration and login +- JWT-based authentication +- Password hashing with bcrypt +- User profile management +- Role-based access control (superuser capabilities) +- Email verification flags +- SQLite database with SQLAlchemy and Alembic migrations +- Health check endpoint + +## Requirements + +- Python 3.8+ +- FastAPI +- SQLAlchemy +- Alembic +- Uvicorn +- See `requirements.txt` for complete list + +## Setup + +### 1. Clone the repository + +```bash +git clone +cd userauthenticationservice +``` + +### 2. Set up a virtual environment (optional but recommended) + +```bash +python -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate +``` + +### 3. Install dependencies + +```bash +pip install -r requirements.txt +``` + +### 4. Set environment variables + +Create a `.env` file in the project root with the following content: + +``` +SECRET_KEY=your-secret-key-here +ACCESS_TOKEN_EXPIRE_MINUTES=10080 # 7 days +``` + +### 5. Run migrations to create the database + +```bash +alembic upgrade head +``` + +### 6. Start the application + +```bash +uvicorn main:app --reload +``` + +The API will be available at http://localhost:8000 + +## API Endpoints + +### Authentication + +- `POST /api/v1/auth/register` - Register a new user +- `POST /api/v1/auth/login` - OAuth2 compatible token login (form) +- `POST /api/v1/auth/login/json` - JSON compatible token login + +### Users + +- `GET /api/v1/users/me` - Get current user information (requires authentication) +- `PUT /api/v1/users/me` - Update current user information (requires authentication) +- `GET /api/v1/users` - List all users (requires superuser) +- `GET /api/v1/users/{user_id}` - Get user by ID (requires authentication) +- `PUT /api/v1/users/{user_id}` - Update user by ID (requires superuser) + +### Health Check + +- `GET /health` - Check API health status + +## API Documentation + +Interactive API documentation is available at: + +- Swagger UI: http://localhost:8000/docs +- ReDoc: http://localhost:8000/redoc + +## Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| SECRET_KEY | JWT secret key | "CHANGE_THIS_TO_A_SECURE_SECRET_IN_PRODUCTION" | +| ALGORITHM | JWT algorithm | "HS256" | +| ACCESS_TOKEN_EXPIRE_MINUTES | Token expiration time in minutes | 10080 (7 days) | +| PROJECT_NAME | Project name | "User Authentication Service" | + +## Project Structure + +``` +. +├── alembic.ini +├── app +│ ├── api +│ │ ├── deps.py # Dependency functions +│ │ ├── endpoints +│ │ │ ├── auth.py # Authentication endpoints +│ │ │ └── users.py # User management endpoints +│ │ └── api.py # API router +│ ├── core +│ │ ├── config.py # App configuration +│ │ ├── database.py # Database setup +│ │ ├── errors.py # Error handling +│ │ ├── health.py # Health check endpoint +│ │ └── security.py # Security utilities +│ ├── crud +│ │ └── user.py # User CRUD operations +│ ├── models +│ │ ├── health.py # Health check model +│ │ └── user.py # User database model +│ └── schemas +│ └── user.py # User Pydantic schemas +├── main.py # Application entry point +├── migrations # Alembic migrations +│ ├── env.py +│ ├── script.py.mako +│ └── versions +│ └── initial_migration.py # Initial database schema +└── requirements.txt # Dependencies +``` + +## Security Notes + +For production deployment: + +1. Replace the default SECRET_KEY with a secure random key +2. Consider setting up HTTPS +3. Review the default token expiration time +4. Implement rate limiting for auth endpoints +5. Add more robust logging +6. Consider additional security measures like IP-based blocking \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..09f80bb --- /dev/null +++ b/alembic.ini @@ -0,0 +1,111 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to migrations/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# SQLite database URL +sqlalchemy.url = sqlite:////app/storage/db/db.sqlite + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/api.py b/app/api/api.py new file mode 100644 index 0000000..1b3e5e1 --- /dev/null +++ b/app/api/api.py @@ -0,0 +1,7 @@ +from fastapi import APIRouter + +from app.api.endpoints import auth, users + +api_router = APIRouter(prefix="/api/v1") +api_router.include_router(auth.router, tags=["authentication"]) +api_router.include_router(users.router, tags=["users"]) diff --git a/app/api/deps.py b/app/api/deps.py new file mode 100644 index 0000000..fc8aeb2 --- /dev/null +++ b/app/api/deps.py @@ -0,0 +1,70 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError, jwt +from pydantic import ValidationError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.core.database import get_db +from app.crud import user as crud_user +from app.models.user import User +from app.schemas.user import TokenPayload + +# Create OAuth2 password bearer scheme +oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/auth/login") + + +async def get_current_user( + db: AsyncSession = Depends(get_db), + token: str = Depends(oauth2_scheme), +) -> User: + """ + Validate access token and return current user. + """ + try: + # Decode JWT token + payload = jwt.decode( + token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] + ) + token_data = TokenPayload(**payload) + except (JWTError, ValidationError): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Get user from database + user = await crud_user.get_by_id(db, user_id=token_data.sub) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="User not found" + ) + return user + + +async def get_current_active_user( + current_user: User = Depends(get_current_user), +) -> User: + """ + Get current active user. + """ + if not current_user.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user" + ) + return current_user + + +async def get_current_active_superuser( + current_user: User = Depends(get_current_active_user), +) -> User: + """ + Get current active superuser. + """ + if not current_user.is_superuser: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions", + ) + return current_user diff --git a/app/api/endpoints/__init__.py b/app/api/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/endpoints/auth.py b/app/api/endpoints/auth.py new file mode 100644 index 0000000..291f706 --- /dev/null +++ b/app/api/endpoints/auth.py @@ -0,0 +1,98 @@ +from datetime import timedelta +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.core.database import get_db +from app.core.security import create_access_token +from app.crud import user as crud_user +from app.schemas.user import Token, User, UserCreate, UserLogin + +router = APIRouter() + + +@router.post("/auth/register", response_model=User, status_code=status.HTTP_201_CREATED) +async def register(user_in: UserCreate, db: AsyncSession = Depends(get_db)) -> Any: + """ + Register a new user. + """ + # Check if user with this email already exists + user = await crud_user.get_by_email(db, email=user_in.email) + if user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered", + ) + + # Create new user + user = await crud_user.create(db, obj_in=user_in) + return user + + +@router.post("/auth/login", response_model=Token) +async def login( + form_data: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db) +) -> Any: + """ + OAuth2 compatible token login, get an access token for future requests. + """ + user = await crud_user.authenticate( + db, email=form_data.username, password=form_data.password + ) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user" + ) + + # Update last login timestamp + await crud_user.update_last_login(db, user=user) + + # Create access token + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + return { + "access_token": create_access_token( + user.id, expires_delta=access_token_expires + ), + "token_type": "bearer", + } + + +@router.post("/auth/login/json", response_model=Token) +async def login_json(login_data: UserLogin, db: AsyncSession = Depends(get_db)) -> Any: + """ + JSON compatible login, get an access token for future requests. + """ + user = await crud_user.authenticate( + db, email=login_data.email, password=login_data.password + ) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user" + ) + + # Update last login timestamp + await crud_user.update_last_login(db, user=user) + + # Create access token + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + return { + "access_token": create_access_token( + user.id, expires_delta=access_token_expires + ), + "token_type": "bearer", + } diff --git a/app/api/endpoints/users.py b/app/api/endpoints/users.py new file mode 100644 index 0000000..dd57d11 --- /dev/null +++ b/app/api/endpoints/users.py @@ -0,0 +1,93 @@ +from typing import Any, List + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.crud import user as crud_user +from app.schemas.user import User, UserUpdate +from app.api.deps import get_current_active_user, get_current_active_superuser + +router = APIRouter() + + +@router.get("/users/me", response_model=User) +async def read_user_me( + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Get current user information. + """ + return current_user + + +@router.put("/users/me", response_model=User) +async def update_user_me( + user_in: UserUpdate, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Update own user information. + """ + user = await crud_user.update(db, db_obj=current_user, obj_in=user_in) + return user + + +@router.get("/users", response_model=List[User]) +async def read_users( + skip: int = 0, + limit: int = 100, + current_user: User = Depends(get_current_active_superuser), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Retrieve users. Only available to superusers. + """ + users = await crud_user.get_users(db, skip=skip, limit=limit) + return users + + +@router.get("/users/{user_id}", response_model=User) +async def read_user_by_id( + user_id: str, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Get a specific user by id. + """ + user = await crud_user.get_by_id(db, user_id=user_id) + if user == current_user: + return user + if not current_user.is_superuser: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions to access this resource", + ) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + return user + + +@router.put("/users/{user_id}", response_model=User) +async def update_user( + user_id: str, + user_in: UserUpdate, + current_user: User = Depends(get_current_active_superuser), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Update a user. Only available to superusers. + """ + user = await crud_user.get_by_id(db, user_id=user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + user = await crud_user.update(db, db_obj=user, obj_in=user_in) + return user diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..529a38f --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,47 @@ +from pathlib import Path +from typing import List, Optional, Union + +from pydantic import AnyHttpUrl, EmailStr, validator +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + # Base Settings + PROJECT_NAME: str = "User Authentication Service" + API_V1_STR: str = "/api/v1" + + # Database Settings + DB_DIR: Path = Path("/app") / "storage" / "db" + SQLALCHEMY_DATABASE_URL: str = f"sqlite:///{DB_DIR}/db.sqlite" + + # JWT Settings + SECRET_KEY: str = "CHANGE_THIS_TO_A_SECURE_SECRET_IN_PRODUCTION" + ALGORITHM: str = "HS256" + # 60 minutes * 24 hours * 7 days = 7 days + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 + + # CORS Settings + BACKEND_CORS_ORIGINS: List[Union[str, AnyHttpUrl]] = ["*"] + + @validator("BACKEND_CORS_ORIGINS", pre=True) + def assemble_cors_origins(cls, v: Union[str, List[str]]) -> Union[List[str], str]: + if isinstance(v, str) and not v.startswith("["): + return [i.strip() for i in v.split(",")] + elif isinstance(v, (list, str)): + return v + raise ValueError(v) + + # Email Settings + EMAILS_FROM_EMAIL: Optional[EmailStr] = None + EMAILS_FROM_NAME: Optional[str] = None + + class Config: + case_sensitive = True + env_file = ".env" + + +# Create settings object +settings = Settings() + +# Ensure DB directory exists +settings.DB_DIR.mkdir(parents=True, exist_ok=True) diff --git a/app/core/database.py b/app/core/database.py new file mode 100644 index 0000000..9d73503 --- /dev/null +++ b/app/core/database.py @@ -0,0 +1,54 @@ +from pathlib import Path +from typing import AsyncGenerator + +from sqlalchemy import create_engine +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + + +# Ensure DB directory exists +DB_DIR = Path("/app") / "storage" / "db" +DB_DIR.mkdir(parents=True, exist_ok=True) + +# Create SQLite URL (with URI flag for foreign key support) +SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_DIR}/db.sqlite" + +# Create engine for synchronous operations (like migrations) +engine = create_engine( + SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} +) + +# For async operations, we need to use a different driver +ASYNC_SQLALCHEMY_DATABASE_URL = f"sqlite+aiosqlite:///{DB_DIR}/db.sqlite" +async_engine = create_async_engine( + ASYNC_SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} +) + +# Create session factories +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +AsyncSessionLocal = sessionmaker( + autocommit=False, + autoflush=False, + bind=async_engine, + class_=AsyncSession, +) + +# Create a base class for declarative models +Base = declarative_base() + + +# Dependency function for getting a database session +async def get_db() -> AsyncGenerator[AsyncSession, None]: + """ + Dependency function that yields an async database session. + """ + async with AsyncSessionLocal() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() diff --git a/app/core/errors.py b/app/core/errors.py new file mode 100644 index 0000000..3f1dddc --- /dev/null +++ b/app/core/errors.py @@ -0,0 +1,48 @@ +from typing import Any, Dict, Optional + +from fastapi import HTTPException, status + + +class AuthError(HTTPException): + """Authentication error.""" + + def __init__( + self, + detail: str = "Authentication error", + headers: Optional[Dict[str, Any]] = None, + ): + super().__init__( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=detail, + headers=headers or {"WWW-Authenticate": "Bearer"}, + ) + + +class ForbiddenError(HTTPException): + """Permission denied error.""" + + def __init__(self, detail: str = "Permission denied"): + super().__init__( + status_code=status.HTTP_403_FORBIDDEN, + detail=detail, + ) + + +class NotFoundError(HTTPException): + """Resource not found error.""" + + def __init__(self, detail: str = "Resource not found"): + super().__init__( + status_code=status.HTTP_404_NOT_FOUND, + detail=detail, + ) + + +class BadRequestError(HTTPException): + """Bad request error.""" + + def __init__(self, detail: str = "Bad request"): + super().__init__( + status_code=status.HTTP_400_BAD_REQUEST, + detail=detail, + ) diff --git a/app/core/health.py b/app/core/health.py new file mode 100644 index 0000000..7ac7acb --- /dev/null +++ b/app/core/health.py @@ -0,0 +1,15 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.models.health import HealthCheck + +health_router = APIRouter() + + +@health_router.get("/health", response_model=HealthCheck, tags=["health"]) +async def health(db: AsyncSession = Depends(get_db)): + """ + Health check endpoint to verify API is running properly. + """ + return {"status": "ok", "database": "connected", "api_version": "v1"} diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..c80b2d9 --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,66 @@ +from datetime import datetime, timedelta +from typing import Any, Optional, Union + +from jose import jwt +from passlib.context import CryptContext + +from app.core.config import settings + +# Password hashing context +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +# Token related functions +def create_access_token( + subject: Union[str, Any], expires_delta: Optional[timedelta] = None +) -> str: + """ + Create a JWT access token. + + Args: + subject: The subject to encode in the JWT (typically user ID) + expires_delta: Optional timedelta for token expiration + + Returns: + JWT token as string + """ + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta( + minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES + ) + + to_encode = {"exp": expire, "sub": str(subject)} + encoded_jwt = jwt.encode( + to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM + ) + return encoded_jwt + + +# Password related functions +def verify_password(plain_password: str, hashed_password: str) -> bool: + """ + Verify a plain password against a hashed password. + + Args: + plain_password: The plain text password + hashed_password: The hashed password to compare against + + Returns: + True if the password matches, False otherwise + """ + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + """ + Generate a password hash from a plain password. + + Args: + password: The plain text password to hash + + Returns: + Hashed password string + """ + return pwd_context.hash(password) diff --git a/app/crud/__init__.py b/app/crud/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/crud/user.py b/app/crud/user.py new file mode 100644 index 0000000..9d04153 --- /dev/null +++ b/app/crud/user.py @@ -0,0 +1,113 @@ +from datetime import datetime +from typing import Any, Dict, List, Optional, Union + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.security import get_password_hash, verify_password +from app.models.user import User +from app.schemas.user import UserCreate, UserUpdate + + +async def get_by_id(db: AsyncSession, user_id: str) -> Optional[User]: + """ + Get a user by ID. + """ + result = await db.execute(select(User).filter(User.id == user_id)) + return result.scalars().first() + + +async def get_by_email(db: AsyncSession, email: str) -> Optional[User]: + """ + Get a user by email. + """ + result = await db.execute(select(User).filter(User.email == email)) + return result.scalars().first() + + +async def get_users(db: AsyncSession, skip: int = 0, limit: int = 100) -> List[User]: + """ + Get a list of users with pagination. + """ + result = await db.execute(select(User).offset(skip).limit(limit)) + return result.scalars().all() + + +async def create(db: AsyncSession, *, obj_in: UserCreate) -> User: + """ + Create a new user. + """ + db_obj = User( + email=obj_in.email, + hashed_password=get_password_hash(obj_in.password), + full_name=obj_in.full_name, + is_active=True, + is_superuser=False, + email_verified=False, + ) + db.add(db_obj) + await db.commit() + await db.refresh(db_obj) + return db_obj + + +async def update( + db: AsyncSession, *, db_obj: User, obj_in: Union[UserUpdate, Dict[str, Any]] +) -> User: + """ + Update a user. + """ + if isinstance(obj_in, dict): + update_data = obj_in + else: + update_data = obj_in.dict(exclude_unset=True) + + if "password" in update_data and update_data["password"]: + hashed_password = get_password_hash(update_data["password"]) + del update_data["password"] + update_data["hashed_password"] = hashed_password + + for field in update_data: + if hasattr(db_obj, field): + setattr(db_obj, field, update_data[field]) + + db.add(db_obj) + await db.commit() + await db.refresh(db_obj) + return db_obj + + +async def remove(db: AsyncSession, *, id: str) -> Optional[User]: + """ + Delete a user. + """ + user = await get_by_id(db, id) + if user: + await db.delete(user) + await db.commit() + return user + + +async def authenticate( + db: AsyncSession, *, email: str, password: str +) -> Optional[User]: + """ + Authenticate a user by email and password. + """ + user = await get_by_email(db, email=email) + if not user: + return None + if not verify_password(password, user.hashed_password): + return None + return user + + +async def update_last_login(db: AsyncSession, *, user: User) -> User: + """ + Update the last login timestamp for a user. + """ + user.last_login = datetime.utcnow() + db.add(user) + await db.commit() + await db.refresh(user) + return user diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/health.py b/app/models/health.py new file mode 100644 index 0000000..6b92b24 --- /dev/null +++ b/app/models/health.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel + + +class HealthCheck(BaseModel): + """ + Health check response model. + """ + + status: str + database: str + api_version: str diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..7c36700 --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,36 @@ +import uuid + +from sqlalchemy import Boolean, Column, DateTime, String +from sqlalchemy.sql import func + +from app.core.database import Base + + +class User(Base): + """ + User model for authentication and user management. + """ + + __tablename__ = "users" + + # Primary key + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + + # Auth fields + email = Column(String, unique=True, index=True, nullable=False) + hashed_password = Column(String, nullable=False) + + # User details + full_name = Column(String, nullable=True) + + # Status fields + is_active = Column(Boolean, default=True, nullable=False) + is_superuser = Column(Boolean, default=False, nullable=False) + email_verified = Column(Boolean, default=False, nullable=False) + + # Timestamps + created_at = Column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + last_login = Column(DateTime(timezone=True), nullable=True) diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..280b7bf --- /dev/null +++ b/app/schemas/user.py @@ -0,0 +1,59 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, EmailStr, Field + + +# Shared properties +class UserBase(BaseModel): + email: Optional[EmailStr] = None + is_active: Optional[bool] = True + is_superuser: Optional[bool] = False + full_name: Optional[str] = None + + +# Properties to receive on user creation +class UserCreate(BaseModel): + email: EmailStr + password: str = Field(..., min_length=8) + full_name: Optional[str] = None + + +# Properties to receive on user update +class UserUpdate(UserBase): + password: Optional[str] = Field(None, min_length=8) + + +# Properties to return to client +class User(UserBase): + id: str + email_verified: bool + created_at: datetime + updated_at: Optional[datetime] = None + last_login: Optional[datetime] = None + + class Config: + from_attributes = True + + +# Properties to return to admin users +class UserAdminRead(User): + pass + + +# Properties for user login +class UserLogin(BaseModel): + email: EmailStr + password: str + + +# Token response schema +class Token(BaseModel): + access_token: str + token_type: str = "bearer" + + +# Token payload schema +class TokenPayload(BaseModel): + sub: str + exp: datetime diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/main.py b/main.py new file mode 100644 index 0000000..0ee6fd7 --- /dev/null +++ b/main.py @@ -0,0 +1,48 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.openapi.utils import get_openapi + +from app.api.api import api_router +from app.core.config import settings +from app.core.health import health_router + +app = FastAPI( + title=settings.PROJECT_NAME, + description="User Authentication Service API", + version="0.1.0", + openapi_url="/openapi.json", +) + +# Set up CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include routers +app.include_router(health_router) +app.include_router(api_router) + + +def custom_openapi(): + if app.openapi_schema: + return app.openapi_schema + openapi_schema = get_openapi( + title=app.title, + version=app.version, + description=app.description, + routes=app.routes, + ) + app.openapi_schema = openapi_schema + return app.openapi_schema + + +app.openapi = custom_openapi + +if __name__ == "__main__": + import uvicorn + + uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/migrations/__init__.py b/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..8a36382 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,62 @@ +from logging.config import fileConfig + +from alembic import context +from app.core.database import Base + +# Import all models so they are registered with SQLAlchemy +import app.models.user # noqa +from sqlalchemy import engine_from_config, pool + +# This is the Alembic Config object +config = context.config + +# Interpret the config file for Python logging. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Add your model's MetaData here + +target_metadata = Base.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode.""" + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + # For SQLite, use batch mode for table alterations + render_as_batch=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode.""" + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + is_sqlite = connection.dialect.name == "sqlite" + context.configure( + connection=connection, + target_metadata=target_metadata, + # For SQLite, use batch mode for table alterations + render_as_batch=is_sqlite, + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..1e4564e --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} \ No newline at end of file diff --git a/migrations/versions/initial_migration.py b/migrations/versions/initial_migration.py new file mode 100644 index 0000000..0f485f2 --- /dev/null +++ b/migrations/versions/initial_migration.py @@ -0,0 +1,51 @@ +"""Initial migration + +Revision ID: 001 +Revises: +Create Date: 2023-10-01 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "001" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # Create users table + op.create_table( + "users", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("email", sa.String(), nullable=False), + sa.Column("hashed_password", sa.String(), nullable=False), + sa.Column("full_name", sa.String(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False, default=True), + sa.Column("is_superuser", sa.Boolean(), nullable=False, default=False), + sa.Column("email_verified", sa.Boolean(), nullable=False, default=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_login", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + + # Create indexes + op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True) + + +def downgrade(): + # Drop indexes + op.drop_index(op.f("ix_users_email"), table_name="users") + + # Drop tables + op.drop_table("users") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4b67f64 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +fastapi>=0.103.1 +uvicorn>=0.23.2 +sqlalchemy>=2.0.20 +alembic>=1.12.0 +pydantic>=2.3.0 +pydantic-settings>=2.0.3 +python-jose[cryptography]>=3.3.0 +passlib[bcrypt]>=1.7.4 +python-multipart>=0.0.6 +email-validator>=2.0.0 +ruff>=0.0.290 \ No newline at end of file