diff --git a/README.md b/README.md index e8acfba..01e48e3 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,181 @@ -# FastAPI Application +# Enviodeck Authentication API -This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. +A FastAPI backend API for the Enviodeck mobile app with comprehensive user authentication features including phone number OTP, email/password, and third-party OAuth (Google/Apple) authentication. + +## Features + +- **Phone Number Authentication**: OTP-based login via SMS +- **Email/Password Authentication**: Traditional signup and login +- **Third-Party Authentication**: Google and Apple OAuth integration +- **JWT Token-based Authentication**: Secure session management +- **Rate Limiting**: Protection against OTP abuse +- **SQLite Database**: Lightweight, embedded database +- **API Documentation**: Auto-generated Swagger/OpenAPI docs + +## Tech Stack + +- **Framework**: FastAPI +- **Database**: SQLite with SQLAlchemy ORM +- **Authentication**: JWT tokens with python-jose +- **Password Hashing**: bcrypt via passlib +- **Rate Limiting**: slowapi +- **Migrations**: Alembic +- **Code Quality**: Ruff for linting + +## API Endpoints + +### Authentication +- `POST /auth/request-otp` - Request OTP for phone number +- `POST /auth/verify-otp` - Verify OTP and get access token +- `POST /auth/signup-email` - Sign up with email/password +- `POST /auth/login-email` - Login with email/password +- `POST /auth/login-google` - Login with Google OAuth +- `POST /auth/login-apple` - Login with Apple OAuth + +### User Management +- `GET /user/me` - Get current user information + +### System +- `GET /` - API information +- `GET /health` - Health check endpoint +- `GET /docs` - Swagger UI documentation +- `GET /redoc` - ReDoc documentation + +## Installation & Setup + +1. **Install Dependencies** + ```bash + pip install -r requirements.txt + ``` + +2. **Environment Configuration** + ```bash + cp .env.example .env + # Edit .env with your configuration values + ``` + +3. **Database Setup** + ```bash + # Run migrations to create database tables + alembic upgrade head + ``` + +4. **Start the Server** + ```bash + uvicorn main:app --reload --host 0.0.0.0 --port 8000 + ``` + +## Environment Variables + +The following environment variables need to be configured: + +### Required +- `JWT_SECRET_KEY`: Secret key for JWT token generation (change in production!) + +### Optional (for full functionality) +- `REDIS_URL`: Redis connection URL for OTP storage (defaults to mock implementation) +- `GOOGLE_CLIENT_ID`: Google OAuth client ID +- `GOOGLE_CLIENT_SECRET`: Google OAuth client secret +- `APPLE_CLIENT_ID`: Apple OAuth client ID +- `APPLE_CLIENT_SECRET`: Apple OAuth client secret +- `TWILIO_ACCOUNT_SID`: Twilio account SID for SMS +- `TWILIO_AUTH_TOKEN`: Twilio auth token +- `TWILIO_PHONE_NUMBER`: Twilio phone number for sending SMS + +## Database Schema + +### User Model +- `id`: Primary key +- `phone_number`: Unique phone number (nullable) +- `email`: Unique email address (nullable) +- `name`: User's name (optional) +- `password_hash`: Hashed password for email auth +- `auth_provider`: Enum (PHONE, EMAIL, GOOGLE, APPLE) +- `is_active`: User account status +- `is_verified`: Phone/email verification status +- `created_at`: Account creation timestamp +- `updated_at`: Last update timestamp + +## API Usage Examples + +### Phone Authentication Flow +```bash +# 1. Request OTP +curl -X POST "http://localhost:8000/auth/request-otp" \ + -H "Content-Type: application/json" \ + -d '{"phone_number": "+1234567890"}' + +# 2. Verify OTP +curl -X POST "http://localhost:8000/auth/verify-otp" \ + -H "Content-Type: application/json" \ + -d '{"phone_number": "+1234567890", "otp": "123456"}' +``` + +### Email Authentication +```bash +# Sign up +curl -X POST "http://localhost:8000/auth/signup-email" \ + -H "Content-Type: application/json" \ + -d '{"email": "user@example.com", "password": "password123", "name": "John Doe"}' + +# Login +curl -X POST "http://localhost:8000/auth/login-email" \ + -H "Content-Type: application/json" \ + -d '{"email": "user@example.com", "password": "password123"}' +``` + +### Accessing Protected Endpoints +```bash +# Get current user info +curl -X GET "http://localhost:8000/user/me" \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +``` + +## Security Features + +- **Password Hashing**: Secure bcrypt hashing for email authentication +- **JWT Tokens**: Stateless authentication with configurable expiration +- **Rate Limiting**: OTP requests limited to 5 per minute per IP +- **OTP Security**: 6-digit codes with 5-minute expiration and attempt limits +- **CORS**: Configured for cross-origin requests + +## Development + +### Code Quality +```bash +# Run linting and auto-fix +ruff check . --fix +ruff format . +``` + +### Database Migrations +```bash +# Create new migration +alembic revision --autogenerate -m "description" + +# Apply migrations +alembic upgrade head + +# Rollback migration +alembic downgrade -1 +``` + +## Production Deployment + +1. **Security**: Change the `JWT_SECRET_KEY` to a strong, random value +2. **Database**: Consider migrating to PostgreSQL for production +3. **Redis**: Set up Redis instance for OTP storage +4. **SMS Service**: Configure Twilio or alternative SMS provider +5. **OAuth**: Set up Google and Apple OAuth applications +6. **HTTPS**: Enable SSL/TLS encryption +7. **Rate Limiting**: Consider more sophisticated rate limiting strategies + +## Testing + +Access the interactive API documentation at: +- Swagger UI: http://localhost:8000/docs +- ReDoc: http://localhost:8000/redoc + +## License + +This project is licensed under the MIT License. diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..017f263 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,41 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +version_path_separator = os +sqlalchemy.url = sqlite:////app/storage/db/db.sqlite + +[post_write_hooks] + +[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/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..08c9162 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,54 @@ +from logging.config import fileConfig +from sqlalchemy import engine_from_config +from sqlalchemy import pool +from alembic import context +import sys +import os + +# Add the project root to the Python path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.db.base import Base +from app.models.user import User # noqa: F401 + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + """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"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """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: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..37d0cac --- /dev/null +++ b/alembic/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() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} \ No newline at end of file diff --git a/alembic/versions/001_initial_migration.py b/alembic/versions/001_initial_migration.py new file mode 100644 index 0000000..80fc96b --- /dev/null +++ b/alembic/versions/001_initial_migration.py @@ -0,0 +1,60 @@ +"""Initial migration + +Revision ID: 001 +Revises: +Create Date: 2024-01-01 00:00:00.000000 + +""" + +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() -> None: + # Create users table + op.create_table( + "users", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("phone_number", sa.String(), nullable=True), + sa.Column("email", sa.String(), nullable=True), + sa.Column("name", sa.String(), nullable=True), + sa.Column("password_hash", sa.String(), nullable=True), + sa.Column( + "auth_provider", + sa.Enum("PHONE", "GOOGLE", "APPLE", "EMAIL", name="authprovider"), + nullable=False, + ), + sa.Column("is_active", sa.Boolean(), nullable=True), + sa.Column("is_verified", sa.Boolean(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=True, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=True, + ), + sa.PrimaryKeyConstraint("id"), + ) + 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_phone_number"), "users", ["phone_number"], unique=True + ) + + +def downgrade() -> None: + op.drop_index(op.f("ix_users_phone_number"), 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") + op.drop_table("users") diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..edabda9 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +# App package diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..28b07ef --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1 @@ +# API package diff --git a/app/api/routes/__init__.py b/app/api/routes/__init__.py new file mode 100644 index 0000000..b61b999 --- /dev/null +++ b/app/api/routes/__init__.py @@ -0,0 +1 @@ +# Route modules diff --git a/app/api/routes/auth.py b/app/api/routes/auth.py new file mode 100644 index 0000000..77bc53c --- /dev/null +++ b/app/api/routes/auth.py @@ -0,0 +1,176 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from slowapi import Limiter +from slowapi.util import get_remote_address +from fastapi import Request + +from app.db.session import get_db +from app.schemas.auth import ( + OTPRequest, + OTPVerify, + EmailSignup, + EmailLogin, + GoogleLogin, + AppleLogin, + Token, +) +from app.schemas.user import UserCreate +from app.models.user import AuthProvider +from app.services.user import ( + get_user_by_phone, + get_user_by_email, + create_user, + authenticate_user, + verify_user_phone, +) +from app.utils.otp import generate_otp, store_otp, verify_otp, send_otp +from app.utils.auth import create_access_token + +router = APIRouter() +limiter = Limiter(key_func=get_remote_address) + + +@router.post("/request-otp", response_model=dict) +@limiter.limit("5/minute") +async def request_otp( + request: Request, otp_request: OTPRequest, db: Session = Depends(get_db) +): + """Request OTP for phone number authentication""" + + # Generate and store OTP + otp = generate_otp() + if not store_otp(otp_request.phone_number, otp): + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to generate OTP", + ) + + # Send OTP (mock implementation) + if not send_otp(otp_request.phone_number, otp): + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to send OTP", + ) + + return {"message": "OTP sent successfully"} + + +@router.post("/verify-otp", response_model=Token) +async def verify_otp_endpoint(otp_verify: OTPVerify, db: Session = Depends(get_db)): + """Verify OTP and return access token""" + + # Verify OTP + if not verify_otp(otp_verify.phone_number, otp_verify.otp): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired OTP" + ) + + # Get or create user + user = get_user_by_phone(db, otp_verify.phone_number) + if not user: + user_create = UserCreate( + phone_number=otp_verify.phone_number, auth_provider=AuthProvider.PHONE + ) + user = create_user(db, user_create) + else: + user = verify_user_phone(db, otp_verify.phone_number) + + # Create access token + access_token = create_access_token(data={"sub": str(user.id)}) + + return {"access_token": access_token, "token_type": "bearer"} + + +@router.post("/signup-email", response_model=Token) +async def signup_email(signup_data: EmailSignup, db: Session = Depends(get_db)): + """Sign up with email and password""" + + # Check if user already exists + if get_user_by_email(db, signup_data.email): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered" + ) + + # Create user + user_create = UserCreate( + email=signup_data.email, + name=signup_data.name, + password=signup_data.password, + auth_provider=AuthProvider.EMAIL, + ) + user = create_user(db, user_create) + + # Create access token + access_token = create_access_token(data={"sub": str(user.id)}) + + return {"access_token": access_token, "token_type": "bearer"} + + +@router.post("/login-email", response_model=Token) +async def login_email(login_data: EmailLogin, db: Session = Depends(get_db)): + """Login with email and password""" + + user = authenticate_user(db, login_data.email, login_data.password) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user" + ) + + # Create access token + access_token = create_access_token(data={"sub": str(user.id)}) + + return {"access_token": access_token, "token_type": "bearer"} + + +@router.post("/login-google", response_model=Token) +async def login_google(google_data: GoogleLogin, db: Session = Depends(get_db)): + """Login with Google OAuth token""" + + # Mock Google token verification + # In production, verify the token with Google's API + mock_user_data = {"email": "user@gmail.com", "name": "Google User"} + + # Get or create user + user = get_user_by_email(db, mock_user_data["email"]) + if not user: + user_create = UserCreate( + email=mock_user_data["email"], + name=mock_user_data["name"], + auth_provider=AuthProvider.GOOGLE, + ) + user = create_user(db, user_create) + + # Create access token + access_token = create_access_token(data={"sub": str(user.id)}) + + return {"access_token": access_token, "token_type": "bearer"} + + +@router.post("/login-apple", response_model=Token) +async def login_apple(apple_data: AppleLogin, db: Session = Depends(get_db)): + """Login with Apple OAuth token""" + + # Mock Apple token verification + # In production, verify the token with Apple's API + mock_user_data = {"email": "user@icloud.com", "name": "Apple User"} + + # Get or create user + user = get_user_by_email(db, mock_user_data["email"]) + if not user: + user_create = UserCreate( + email=mock_user_data["email"], + name=mock_user_data["name"], + auth_provider=AuthProvider.APPLE, + ) + user = create_user(db, user_create) + + # Create access token + access_token = create_access_token(data={"sub": str(user.id)}) + + return {"access_token": access_token, "token_type": "bearer"} diff --git a/app/api/routes/users.py b/app/api/routes/users.py new file mode 100644 index 0000000..9f79254 --- /dev/null +++ b/app/api/routes/users.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter, Depends +from app.schemas.user import User +from app.utils.dependencies import get_current_active_user + +router = APIRouter() + + +@router.get("/me", response_model=User) +async def get_current_user_info(current_user: User = Depends(get_current_active_user)): + """Get current user information""" + return current_user diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..97c29a8 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,44 @@ +from pathlib import Path +from decouple import config + + +class Settings: + PROJECT_NAME: str = "Enviodeck Authentication API" + PROJECT_VERSION: str = "1.0.0" + + # Database + DB_DIR = Path("/app") / "storage" / "db" + DB_DIR.mkdir(parents=True, exist_ok=True) + SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_DIR}/db.sqlite" + + # JWT + JWT_SECRET_KEY: str = config( + "JWT_SECRET_KEY", default="your-secret-key-change-this" + ) + JWT_ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 * 24 * 60 # 30 days + + # Redis (for OTP storage) + REDIS_URL: str = config("REDIS_URL", default="redis://localhost:6379") + + # OTP settings + OTP_EXPIRE_MINUTES: int = 5 + OTP_MAX_ATTEMPTS: int = 3 + + # Rate limiting + RATE_LIMIT_REQUESTS: int = 5 + RATE_LIMIT_WINDOW: int = 60 # seconds + + # Third-party auth + GOOGLE_CLIENT_ID: str = config("GOOGLE_CLIENT_ID", default="") + GOOGLE_CLIENT_SECRET: str = config("GOOGLE_CLIENT_SECRET", default="") + APPLE_CLIENT_ID: str = config("APPLE_CLIENT_ID", default="") + APPLE_CLIENT_SECRET: str = config("APPLE_CLIENT_SECRET", default="") + + # Twilio (for SMS) + TWILIO_ACCOUNT_SID: str = config("TWILIO_ACCOUNT_SID", default="") + TWILIO_AUTH_TOKEN: str = config("TWILIO_AUTH_TOKEN", default="") + TWILIO_PHONE_NUMBER: str = config("TWILIO_PHONE_NUMBER", default="") + + +settings = Settings() diff --git a/app/db/base.py b/app/db/base.py new file mode 100644 index 0000000..860e542 --- /dev/null +++ b/app/db/base.py @@ -0,0 +1,3 @@ +from sqlalchemy.ext.declarative import declarative_base + +Base = declarative_base() diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..278080e --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,22 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from app.core.config import settings +from app.db.base import Base + +engine = create_engine( + settings.SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def create_tables(): + Base.metadata.create_all(bind=engine) diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..11c4c12 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,3 @@ +from .user import User, AuthProvider + +__all__ = ["User", "AuthProvider"] diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..653ac5a --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,28 @@ +from sqlalchemy import Column, Integer, String, DateTime, Enum, Boolean +from sqlalchemy.sql import func +from app.db.base import Base +import enum + + +class AuthProvider(enum.Enum): + PHONE = "PHONE" + GOOGLE = "GOOGLE" + APPLE = "APPLE" + EMAIL = "EMAIL" + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + phone_number = Column(String, unique=True, nullable=True, index=True) + email = Column(String, unique=True, nullable=True, index=True) + name = Column(String, nullable=True) + password_hash = Column(String, nullable=True) + auth_provider = Column(Enum(AuthProvider), nullable=False) + is_active = Column(Boolean, default=True) + is_verified = Column(Boolean, default=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..195e584 --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1 @@ +# Schema modules diff --git a/app/schemas/auth.py b/app/schemas/auth.py new file mode 100644 index 0000000..3c9de07 --- /dev/null +++ b/app/schemas/auth.py @@ -0,0 +1,60 @@ +from pydantic import BaseModel, EmailStr, validator +from typing import Optional + + +class OTPRequest(BaseModel): + phone_number: str + + @validator("phone_number") + def validate_phone_number(cls, v): + # Basic phone number validation + if not v.startswith("+"): + raise ValueError("Phone number must start with +") + if len(v) < 10: + raise ValueError("Phone number must be at least 10 characters") + return v + + +class OTPVerify(BaseModel): + phone_number: str + otp: str + + @validator("otp") + def validate_otp(cls, v): + if len(v) != 6 or not v.isdigit(): + raise ValueError("OTP must be 6 digits") + return v + + +class EmailSignup(BaseModel): + email: EmailStr + password: str + name: Optional[str] = None + + @validator("password") + def validate_password(cls, v): + if len(v) < 8: + raise ValueError("Password must be at least 8 characters") + return v + + +class EmailLogin(BaseModel): + email: EmailStr + password: str + + +class GoogleLogin(BaseModel): + token: str + + +class AppleLogin(BaseModel): + token: str + + +class Token(BaseModel): + access_token: str + token_type: str + + +class TokenData(BaseModel): + user_id: Optional[int] = None diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..b301e30 --- /dev/null +++ b/app/schemas/user.py @@ -0,0 +1,39 @@ +from pydantic import BaseModel, EmailStr +from typing import Optional +from datetime import datetime +from app.models.user import AuthProvider + + +class UserBase(BaseModel): + email: Optional[EmailStr] = None + phone_number: Optional[str] = None + name: Optional[str] = None + + +class UserCreate(UserBase): + password: Optional[str] = None + auth_provider: AuthProvider + + +class UserUpdate(UserBase): + pass + + +class UserInDBBase(UserBase): + id: int + auth_provider: AuthProvider + is_active: bool + is_verified: bool + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class User(UserInDBBase): + pass + + +class UserInDB(UserInDBBase): + password_hash: Optional[str] = None diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..ead8c78 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ +# Service modules diff --git a/app/services/user.py b/app/services/user.py new file mode 100644 index 0000000..3ad953e --- /dev/null +++ b/app/services/user.py @@ -0,0 +1,57 @@ +from sqlalchemy.orm import Session +from typing import Optional +from app.models.user import User, AuthProvider +from app.schemas.user import UserCreate +from app.utils.auth import get_password_hash + + +def get_user_by_email(db: Session, email: str) -> Optional[User]: + return db.query(User).filter(User.email == email).first() + + +def get_user_by_phone(db: Session, phone_number: str) -> Optional[User]: + return db.query(User).filter(User.phone_number == phone_number).first() + + +def get_user_by_id(db: Session, user_id: int) -> Optional[User]: + return db.query(User).filter(User.id == user_id).first() + + +def create_user(db: Session, user: UserCreate) -> User: + db_user = User( + email=user.email, + phone_number=user.phone_number, + name=user.name, + auth_provider=user.auth_provider, + is_verified=True + if user.auth_provider in [AuthProvider.GOOGLE, AuthProvider.APPLE] + else False, + ) + + if user.password: + db_user.password_hash = get_password_hash(user.password) + + db.add(db_user) + db.commit() + db.refresh(db_user) + return db_user + + +def authenticate_user(db: Session, email: str, password: str) -> Optional[User]: + from app.utils.auth import verify_password + + user = get_user_by_email(db, email) + if not user or not user.password_hash: + return None + if not verify_password(password, user.password_hash): + return None + return user + + +def verify_user_phone(db: Session, phone_number: str) -> Optional[User]: + user = get_user_by_phone(db, phone_number) + if user: + user.is_verified = True + db.commit() + db.refresh(user) + return user diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..2c5b738 --- /dev/null +++ b/app/utils/__init__.py @@ -0,0 +1 @@ +# Utility modules diff --git a/app/utils/auth.py b/app/utils/auth.py new file mode 100644 index 0000000..f22c6a6 --- /dev/null +++ b/app/utils/auth.py @@ -0,0 +1,40 @@ +from datetime import datetime, timedelta +from typing import Optional +from jose import JWTError, jwt +from passlib.context import CryptContext +from app.core.config import settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + return pwd_context.hash(password) + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta( + minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES + ) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode( + to_encode, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM + ) + return encoded_jwt + + +def verify_token(token: str) -> Optional[dict]: + try: + payload = jwt.decode( + token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM] + ) + return payload + except JWTError: + return None diff --git a/app/utils/dependencies.py b/app/utils/dependencies.py new file mode 100644 index 0000000..517565c --- /dev/null +++ b/app/utils/dependencies.py @@ -0,0 +1,42 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.orm import Session +from app.db.session import get_db +from app.utils.auth import verify_token +from app.services.user import get_user_by_id +from app.models.user import User + +security = HTTPBearer() + + +def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: Session = Depends(get_db), +) -> User: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + token = credentials.credentials + payload = verify_token(token) + + if payload is None: + raise credentials_exception + + user_id = payload.get("sub") + if user_id is None: + raise credentials_exception + + user = get_user_by_id(db, user_id=int(user_id)) + if user is None: + raise credentials_exception + + return user + + +def get_current_active_user(current_user: User = Depends(get_current_user)) -> User: + if not current_user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + return current_user diff --git a/app/utils/otp.py b/app/utils/otp.py new file mode 100644 index 0000000..0824d04 --- /dev/null +++ b/app/utils/otp.py @@ -0,0 +1,81 @@ +import random +import string +from typing import Optional +import redis +import json +from app.core.config import settings + + +# Mock Redis client for development +class MockRedisClient: + def __init__(self): + self.data = {} + + def setex(self, key: str, time: int, value: str): + self.data[key] = value + return True + + def get(self, key: str) -> Optional[str]: + return self.data.get(key) + + def delete(self, key: str): + if key in self.data: + del self.data[key] + return True + + +try: + redis_client = redis.from_url(settings.REDIS_URL) + redis_client.ping() +except Exception: + redis_client = MockRedisClient() + + +def generate_otp() -> str: + return "".join(random.choices(string.digits, k=6)) + + +def store_otp(phone_number: str, otp: str) -> bool: + try: + otp_data = {"otp": otp, "attempts": 0} + redis_client.setex( + f"otp:{phone_number}", + settings.OTP_EXPIRE_MINUTES * 60, + json.dumps(otp_data), + ) + return True + except Exception: + return False + + +def verify_otp(phone_number: str, otp: str) -> bool: + try: + stored_data = redis_client.get(f"otp:{phone_number}") + if not stored_data: + return False + + otp_data = json.loads(stored_data) + + if otp_data["attempts"] >= settings.OTP_MAX_ATTEMPTS: + redis_client.delete(f"otp:{phone_number}") + return False + + if otp_data["otp"] == otp: + redis_client.delete(f"otp:{phone_number}") + return True + else: + otp_data["attempts"] += 1 + redis_client.setex( + f"otp:{phone_number}", + settings.OTP_EXPIRE_MINUTES * 60, + json.dumps(otp_data), + ) + return False + except Exception: + return False + + +def send_otp(phone_number: str, otp: str) -> bool: + # Mock SMS sending for development + print(f"Sending OTP {otp} to {phone_number}") + return True diff --git a/main.py b/main.py new file mode 100644 index 0000000..bb96d2a --- /dev/null +++ b/main.py @@ -0,0 +1,39 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from app.api.routes import auth, users + +app = FastAPI( + title="Enviodeck Authentication API", + description="Mobile app backend API with user authentication features", + version="1.0.0", + openapi_url="/openapi.json", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(auth.router, prefix="/auth", tags=["Authentication"]) +app.include_router(users.router, prefix="/user", tags=["Users"]) + + +@app.get("/") +async def root(): + return { + "title": "Enviodeck Authentication API", + "documentation": "/docs", + "health": "/health", + } + + +@app.get("/health") +async def health_check(): + return { + "status": "healthy", + "service": "Enviodeck Authentication API", + "version": "1.0.0", + } diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..50dcf18 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,13 @@ +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +sqlalchemy==2.0.23 +alembic==1.12.1 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.6 +pydantic[email]==2.5.0 +python-decouple==3.8 +slowapi==0.1.9 +redis==5.0.1 +httpx==0.25.2 +ruff==0.1.6 \ No newline at end of file