commit 211854ca0aafdb0e7bebea26652d560d2bfbc605 Author: Cory Hawkvelt Date: Thu Jan 8 01:00:26 2026 +1030 inital diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c0bce3d --- /dev/null +++ b/.env.example @@ -0,0 +1,40 @@ +# Flask Configuration +FLASK_APP=wsgi.py +FLASK_ENV=development +SECRET_KEY=your-secret-key-here-change-in-production + +# Database +DATABASE_URL=postgresql://user:password@localhost:5432/authy2_dev +SQLALCHEMY_ECHO=False + +# Security +BCRYPT_LOG_ROUNDS=12 +SESSION_COOKIE_SECURE=False +SESSION_COOKIE_HTTPONLY=True +SESSION_COOKIE_SAMESITE=Lax +MAX_SESSION_DURATION=86400 + +# CORS +CORS_ORIGINS=http://localhost:3000,http://localhost:5173 + +# JWT (if using JWT instead of sessions) +JWT_SECRET_KEY=your-jwt-secret-key-here +JWT_ACCESS_TOKEN_EXPIRES=3600 +JWT_REFRESH_TOKEN_EXPIRES=2592000 + +# Redis (for session storage) +REDIS_URL=redis://localhost:6379/0 + +# OIDC +OIDC_ISSUER_URL=http://localhost:5000 + +# Logging +LOG_LEVEL=INFO +LOG_TO_STDOUT=True + +# Rate Limiting +RATELIMIT_ENABLED=True +RATELIMIT_STORAGE_URL=redis://localhost:6379/1 + +# Testing +TESTING=False diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..c5ecb7e --- /dev/null +++ b/.flake8 @@ -0,0 +1,19 @@ +[flake8] +max-line-length = 100 +exclude = + .git, + __pycache__, + .venv, + venv, + env, + migrations, + build, + dist, + *.egg-info +ignore = + E203, # whitespace before ':' + E501, # line too long (handled by black) + W503, # line break before binary operator + W504 # line break after binary operator +per-file-ignores = + __init__.py:F401 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a145224 --- /dev/null +++ b/.gitignore @@ -0,0 +1,139 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +Pipfile.lock + +# PEP 582 +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Project specific +migrations/versions/*.py +!migrations/versions/.gitkeep +*.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..182af5e --- /dev/null +++ b/README.md @@ -0,0 +1,284 @@ +# Authy2 Backend - Authentication & Authorization API + +Production-ready Flask/SQLAlchemy API for authentication and authorization services. + +## Features + +- ๐Ÿ” **Multi-method Authentication**: Password, OAuth (Google, GitHub, Microsoft), SAML, OIDC +- ๐Ÿ‘ฅ **Multi-tenancy**: Organization-based access control with roles +- ๐Ÿ”‘ **Session Management**: Secure session handling with Redis +- ๐Ÿ“ **Audit Logging**: Comprehensive activity tracking +- ๐Ÿ›ก๏ธ **Security**: Bcrypt password hashing, CORS, security headers, rate limiting +- ๐Ÿ“Š **API Response Envelope**: Consistent response format across all endpoints +- โœ… **Validation**: Marshmallow schemas for request/response validation +- ๐Ÿงช **Testing**: Comprehensive unit and integration tests +- ๐Ÿ“š **Documentation**: OpenAPI/Swagger compatible + +## Tech Stack + +- **Framework**: Flask 3.0 +- **Database**: PostgreSQL with SQLAlchemy ORM +- **Caching/Sessions**: Redis +- **Validation**: Marshmallow +- **Testing**: Pytest +- **Security**: Flask-Bcrypt, Flask-CORS +- **Migration**: Flask-Migrate (Alembic) + +## Quick Start + +### Prerequisites + +- Python 3.11+ +- PostgreSQL 14+ +- Redis 6+ + +### Installation + +1. **Clone the repository**: +```bash +git clone +cd authy2/backend +``` + +2. **Create virtual environment**: +```bash +python -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate +``` + +3. **Install dependencies**: +```bash +pip install -r requirements/development.txt +``` + +4. **Set up environment variables**: +```bash +cp .env.example .env +# Edit .env with your configuration +``` + +5. **Initialize database**: +```bash +python scripts/init_db.py +``` + +6. **Seed sample data** (optional): +```bash +python scripts/seed_data.py +``` + +7. **Run the application**: +```bash +flask run +# Or using the WSGI file +python wsgi.py +``` + +The API will be available at `http://localhost:5000` + +## Project Structure + +``` +backend/ +โ”œโ”€โ”€ app/ +โ”‚ โ”œโ”€โ”€ __init__.py # Application factory +โ”‚ โ”œโ”€โ”€ api/ # API endpoints +โ”‚ โ”‚ โ”œโ”€โ”€ __init__.py +โ”‚ โ”‚ โ””โ”€โ”€ v1/ +โ”‚ โ”‚ โ”œโ”€โ”€ auth.py # Authentication endpoints +โ”‚ โ”‚ โ”œโ”€โ”€ users.py # User endpoints +โ”‚ โ”‚ โ””โ”€โ”€ organizations.py +โ”‚ โ”œโ”€โ”€ exceptions/ # Custom exceptions +โ”‚ โ”œโ”€โ”€ middleware/ # Middleware components +โ”‚ โ”œโ”€โ”€ models/ # Database models +โ”‚ โ”œโ”€โ”€ schemas/ # Marshmallow schemas +โ”‚ โ”œโ”€โ”€ services/ # Business logic layer +โ”‚ โ””โ”€โ”€ utils/ # Utilities +โ”œโ”€โ”€ config/ # Configuration files +โ”œโ”€โ”€ docs/ # Documentation +โ”œโ”€โ”€ migrations/ # Database migrations +โ”œโ”€โ”€ scripts/ # Utility scripts +โ”œโ”€โ”€ tests/ # Test suite +โ”‚ โ”œโ”€โ”€ integration/ +โ”‚ โ””โ”€โ”€ unit/ +โ”œโ”€โ”€ requirements/ # Dependencies +โ”œโ”€โ”€ .env.example # Environment variables template +โ”œโ”€โ”€ pytest.ini # Pytest configuration +โ”œโ”€โ”€ pyproject.toml # Project metadata +โ””โ”€โ”€ wsgi.py # WSGI entry point +``` + +## API Endpoints + +### Authentication +- `POST /api/v1/auth/register` - Register new user +- `POST /api/v1/auth/login` - Login +- `POST /api/v1/auth/logout` - Logout +- `GET /api/v1/auth/me` - Get current user +- `GET /api/v1/auth/sessions` - Get user sessions +- `DELETE /api/v1/auth/sessions/:id` - Revoke session + +### Users +- `GET /api/v1/users/me` - Get current user profile +- `PATCH /api/v1/users/me` - Update profile +- `DELETE /api/v1/users/me` - Delete account +- `POST /api/v1/users/me/password` - Change password +- `GET /api/v1/users/me/organizations` - Get user organizations + +### Organizations +- `POST /api/v1/organizations` - Create organization +- `GET /api/v1/organizations/:id` - Get organization +- `PATCH /api/v1/organizations/:id` - Update organization +- `DELETE /api/v1/organizations/:id` - Delete organization +- `GET /api/v1/organizations/:id/members` - Get members +- `POST /api/v1/organizations/:id/members` - Add member +- `DELETE /api/v1/organizations/:id/members/:userId` - Remove member +- `PATCH /api/v1/organizations/:id/members/:userId/role` - Update role + +### Health +- `GET /api/health` - Health check + +## API Response Format + +All API responses follow the standardized envelope format: + +```json +{ + "version": "1.0", + "success": true, + "code": 200, + "message": "Success message", + "request_id": "uuid-v4", + "data": {}, + "meta": {} +} +``` + +Error responses: + +```json +{ + "version": "1.0", + "success": false, + "code": 400, + "message": "Error message", + "request_id": "uuid-v4", + "error": { + "type": "VALIDATION_ERROR", + "details": {} + } +} +``` + +## Testing + +Run all tests: +```bash +pytest +``` + +Run with coverage: +```bash +pytest --cov=app --cov-report=html +``` + +Run specific test types: +```bash +pytest -m unit # Unit tests only +pytest -m integration # Integration tests only +``` + +## Database Migrations + +Create a new migration: +```bash +flask db migrate -m "Description of changes" +``` + +Apply migrations: +```bash +flask db upgrade +``` + +Rollback: +```bash +flask db downgrade +``` + +## Development + +### Code Quality + +Run linter: +```bash +flake8 app/ tests/ +``` + +Format code: +```bash +black app/ tests/ +isort app/ tests/ +``` + +### Environment Configuration + +- **Development**: `FLASK_ENV=development` +- **Testing**: `FLASK_ENV=testing` +- **Production**: `FLASK_ENV=production` + +## Production Deployment + +### Using Gunicorn + +```bash +pip install -r requirements/production.txt +gunicorn -w 4 -b 0.0.0.0:8000 wsgi:app +``` + +### Docker (example) + +```dockerfile +FROM python:3.11-slim +WORKDIR /app +COPY requirements/production.txt . +RUN pip install -r production.txt +COPY . . +CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8000", "wsgi:app"] +``` + +### Environment Variables + +Required production environment variables: +- `SECRET_KEY` - Flask secret key (must be random) +- `DATABASE_URL` - PostgreSQL connection string +- `REDIS_URL` - Redis connection string +- `FLASK_ENV=production` + +## Security Considerations + +- All passwords hashed with Bcrypt (12+ rounds in production) +- CORS configured for allowed origins +- Security headers enabled (CSP, HSTS, etc.) +- Rate limiting on sensitive endpoints +- SQL injection protection via SQLAlchemy ORM +- Session management with secure cookies +- Request ID tracking for audit trails + +## License + +MIT + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Add tests +5. Run test suite +6. Submit a pull request + +## Support + +For issues and questions: +- GitHub Issues: [repository-url]/issues +- Documentation: See `docs/` directory diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..1c77f93 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,175 @@ +"""Application factory.""" +import os +import logging +from flask import Flask +from config import get_config +from app.extensions import db, migrate, bcrypt, cors, ma, limiter, session +from app.middleware import RequestIDMiddleware, SecurityHeadersMiddleware, setup_cors +from app.exceptions.base import BaseAPIException +from app.utils.response import api_response +import redis + + +def create_app(config_name=None): + """ + Create and configure the Flask application. + + Args: + config_name: Configuration name (development, testing, production) + + Returns: + Flask application instance + """ + app = Flask(__name__) + + # Load configuration + config = get_config(config_name) + app.config.from_object(config) + + # Initialize extensions + initialize_extensions(app) + + # Setup middleware + setup_middleware(app) + + # Register blueprints + register_blueprints(app) + + # Register error handlers + register_error_handlers(app) + + # Setup logging + setup_logging(app) + + return app + + +def initialize_extensions(app): + """Initialize Flask extensions.""" + # Database + db.init_app(app) + migrate.init_app(app, db) + + # Security + bcrypt.init_app(app) + + # CORS + cors.init_app( + app, + origins=app.config.get("CORS_ORIGINS", []), + supports_credentials=app.config.get("CORS_SUPPORTS_CREDENTIALS", True), + ) + + # Marshmallow + ma.init_app(app) + + # Rate limiting + if app.config.get("RATELIMIT_ENABLED"): + limiter.init_app(app) + + # Redis for sessions + try: + redis_url = app.config.get("REDIS_URL") + if redis_url: + redis_client = redis.from_url(redis_url) + app.config["SESSION_REDIS"] = redis_client + except Exception as e: + app.logger.warning(f"Redis connection failed: {e}") + + # Flask-Session + session.init_app(app) + + +def setup_middleware(app): + """Setup application middleware.""" + RequestIDMiddleware(app) + SecurityHeadersMiddleware(app) + setup_cors(app, cors) + + +def register_blueprints(app): + """Register application blueprints.""" + from app.api import register_api_blueprints + + register_api_blueprints(app) + + +def register_error_handlers(app): + """Register error handlers.""" + + @app.errorhandler(BaseAPIException) + def handle_api_exception(error): + """Handle custom API exceptions.""" + return api_response( + success=False, + message=error.message, + status=error.status_code, + error_type=error.error_type, + error_details=error.error_details, + ) + + @app.errorhandler(404) + def handle_not_found(error): + """Handle 404 errors.""" + return api_response( + success=False, + message="Resource not found", + status=404, + error_type="NOT_FOUND", + ) + + @app.errorhandler(405) + def handle_method_not_allowed(error): + """Handle 405 errors.""" + return api_response( + success=False, + message="Method not allowed", + status=405, + error_type="METHOD_NOT_ALLOWED", + ) + + @app.errorhandler(500) + def handle_internal_error(error): + """Handle 500 errors.""" + app.logger.error(f"Internal server error: {error}") + return api_response( + success=False, + message="Internal server error", + status=500, + error_type="INTERNAL_ERROR", + ) + + @app.errorhandler(Exception) + def handle_unexpected_error(error): + """Handle unexpected errors.""" + app.logger.error(f"Unexpected error: {error}", exc_info=True) + return api_response( + success=False, + message="An unexpected error occurred", + status=500, + error_type="INTERNAL_ERROR", + ) + + +def setup_logging(app): + """Setup application logging.""" + log_level = getattr(logging, app.config.get("LOG_LEVEL", "INFO")) + + # Create formatter + formatter = logging.Formatter( + "[%(asctime)s] %(levelname)s in %(module)s: %(message)s" + ) + + # Configure root logger + if app.config.get("LOG_TO_STDOUT"): + stream_handler = logging.StreamHandler() + stream_handler.setFormatter(formatter) + stream_handler.setLevel(log_level) + app.logger.addHandler(stream_handler) + + app.logger.setLevel(log_level) + + # Reduce SQLAlchemy logging noise + logging.getLogger('sqlalchemy').setLevel(logging.WARNING) + + app.logger.info("Application startup") diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..d275d62 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1,24 @@ +"""API package.""" +from flask import Blueprint +from app.utils.response import api_response + +# Create main API blueprint +api_bp = Blueprint("api", __name__) + + +@api_bp.route("/health", methods=["GET"]) +def health_check(): + """Health check endpoint.""" + return api_response( + data={"status": "healthy", "service": "authy2-backend"}, + message="Service is running", + ) + + +def register_api_blueprints(app): + """Register all API blueprints.""" + from app.api.v1 import api_v1_bp + + # Register versioned API blueprints + app.register_blueprint(api_bp, url_prefix="/api") + app.register_blueprint(api_v1_bp, url_prefix="/api/v1") diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000..638a613 --- /dev/null +++ b/app/api/v1/__init__.py @@ -0,0 +1,8 @@ +"""API v1 blueprint.""" +from flask import Blueprint + +# Create v1 API blueprint +api_v1_bp = Blueprint("api_v1", __name__) + +# Import route modules to register them +from app.api.v1 import auth, users, organizations diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py new file mode 100644 index 0000000..bab831f --- /dev/null +++ b/app/api/v1/auth.py @@ -0,0 +1,212 @@ +"""Authentication endpoints.""" +from flask import request, session, g +from marshmallow import ValidationError +from app.api.v1 import api_v1_bp +from app.utils.response import api_response +from app.schemas.auth_schema import RegisterSchema, LoginSchema +from app.services.auth_service import AuthService +from app.services.user_service import UserService +from app.utils.decorators import login_required +from app.utils.constants import AuditAction + + +@api_v1_bp.route("/auth/register", methods=["POST"]) +def register(): + """ + Register a new user. + + Request body: + email: User email + password: User password + password_confirm: Password confirmation + full_name: Optional full name + + Returns: + 201: User created successfully + 400: Validation error + 409: Email already exists + """ + try: + # Validate request data + schema = RegisterSchema() + data = schema.load(request.json) + + # Register user + user = AuthService.register_user( + email=data["email"], + password=data["password"], + full_name=data.get("full_name"), + ) + + # Create session + user_session = AuthService.create_session(user) + + return api_response( + data={ + "user": user.to_dict(), + "token": user_session.token, + "expires_at": user_session.expires_at.isoformat() + "Z" if user_session.expires_at.isoformat()[-1] != "Z" else user_session.expires_at.isoformat(), + }, + message="Registration successful", + status=201, + ) + + except ValidationError as e: + return api_response( + success=False, + message="Validation failed", + status=400, + error_type="VALIDATION_ERROR", + error_details=e.messages, + ) + + +@api_v1_bp.route("/auth/login", methods=["POST"]) +def login(): + """ + Login user. + + Request body: + email: User email + password: User password + remember_me: Optional boolean for extended session + + Returns: + 200: Login successful + 400: Validation error + 401: Invalid credentials + """ + try: + # Validate request data + schema = LoginSchema() + data = schema.load(request.json) + + # Authenticate user + user = AuthService.authenticate( + email=data["email"], + password=data["password"], + ) + + # Create session + duration = 2592000 if data.get("remember_me") else 86400 # 30 days vs 1 day + user_session = AuthService.create_session(user, duration_seconds=duration) + + return api_response( + data={ + "user": user.to_dict(), + "token": user_session.token, + "expires_at": user_session.expires_at.isoformat() + "Z" if user_session.expires_at.isoformat()[-1] != "Z" else user_session.expires_at.isoformat(), + }, + message="Login successful", + ) + + except ValidationError as e: + return api_response( + success=False, + message="Validation failed", + status=400, + error_type="VALIDATION_ERROR", + error_details=e.messages, + ) + + +@api_v1_bp.route("/auth/logout", methods=["POST"]) +@login_required +def logout(): + """ + Logout current user. + + Returns: + 200: Logout successful + 401: Not authenticated + """ + # Revoke current session (g.current_session is set by login_required decorator) + if g.current_session: + AuthService.revoke_session(g.current_session.id, reason="User logout") + + return api_response( + message="Logout successful", + ) + + +@api_v1_bp.route("/auth/me", methods=["GET"]) +@login_required +def get_current_user(): + """ + Get current authenticated user. + + Returns: + 200: User data + 401: Not authenticated + """ + user = g.current_user + + return api_response( + data={ + "user": user.to_dict(), + "organizations": [ + {"id": org.id, "name": org.name, "slug": org.slug} + for org in user.get_organizations() + ], + }, + message="User retrieved successfully", + ) + + +@api_v1_bp.route("/auth/sessions", methods=["GET"]) +@login_required +def get_user_sessions(): + """ + Get all active sessions for current user. + + Returns: + 200: List of active sessions + 401: Not authenticated + """ + from app.services.session_service import SessionService + + sessions = SessionService.get_user_sessions(g.current_user.id, active_only=True) + + return api_response( + data={ + "sessions": [session.to_dict() for session in sessions], + "count": len(sessions), + }, + message="Sessions retrieved successfully", + ) + + +@api_v1_bp.route("/auth/sessions/", methods=["DELETE"]) +@login_required +def revoke_session(session_id): + """ + Revoke a specific session. + + Args: + session_id: ID of session to revoke + + Returns: + 200: Session revoked + 401: Not authenticated + 404: Session not found + """ + from app.models.session import Session + + # Ensure session belongs to current user + user_session = Session.query.filter_by( + id=session_id, user_id=g.current_user.id, deleted_at=None + ).first() + + if not user_session: + return api_response( + success=False, + message="Session not found", + status=404, + error_type="NOT_FOUND", + ) + + AuthService.revoke_session(session_id, reason="Revoked by user") + + return api_response( + message="Session revoked successfully", + ) diff --git a/app/api/v1/organizations.py b/app/api/v1/organizations.py new file mode 100644 index 0000000..28f8e3d --- /dev/null +++ b/app/api/v1/organizations.py @@ -0,0 +1,372 @@ +"""Organization endpoints.""" +from flask import g, request +from marshmallow import ValidationError +from app.api.v1 import api_v1_bp +from app.utils.response import api_response +from app.utils.decorators import login_required, require_admin, require_owner +from app.schemas.organization_schema import ( + OrganizationCreateSchema, + OrganizationUpdateSchema, + InviteMemberSchema, + UpdateMemberRoleSchema, +) +from app.services.organization_service import OrganizationService +from app.services.user_service import UserService +from app.utils.constants import OrganizationRole + + +@api_v1_bp.route("/organizations", methods=["POST"]) +@login_required +def create_organization(): + """ + Create a new organization. + + Request body: + name: Organization name + slug: Organization slug (unique) + description: Optional description + logo_url: Optional logo URL + + Returns: + 201: Organization created successfully + 400: Validation error + 401: Not authenticated + 409: Slug already exists + """ + try: + # Validate request data + schema = OrganizationCreateSchema() + data = schema.load(request.json) + + # Create organization + org = OrganizationService.create_organization( + name=data["name"], + slug=data["slug"], + owner_user_id=g.current_user.id, + description=data.get("description"), + logo_url=data.get("logo_url"), + ) + + return api_response( + data={"organization": org.to_dict()}, + message="Organization created successfully", + status=201, + ) + + except ValidationError as e: + return api_response( + success=False, + message="Validation failed", + status=400, + error_type="VALIDATION_ERROR", + error_details=e.messages, + ) + + +@api_v1_bp.route("/organizations/", methods=["GET"]) +@login_required +def get_organization(org_id): + """ + Get organization by ID. + + Args: + org_id: Organization ID + + Returns: + 200: Organization data + 401: Not authenticated + 403: Not a member + 404: Organization not found + """ + org = OrganizationService.get_organization_by_id(org_id) + + # Check if user is a member + if not org.is_member(g.current_user.id): + return api_response( + success=False, + message="You are not a member of this organization", + status=403, + error_type="AUTHORIZATION_ERROR", + ) + + return api_response( + data={ + "organization": org.to_dict(), + "member_count": org.get_member_count(), + }, + message="Organization retrieved successfully", + ) + + +@api_v1_bp.route("/organizations/", methods=["PATCH"]) +@login_required +@require_admin +def update_organization(org_id): + """ + Update organization. + + Args: + org_id: Organization ID + + Request body: + name: Optional organization name + description: Optional description + logo_url: Optional logo URL + + Returns: + 200: Organization updated successfully + 400: Validation error + 401: Not authenticated + 403: Not an admin + 404: Organization not found + """ + try: + # Validate request data + schema = OrganizationUpdateSchema() + data = schema.load(request.json) + + org = OrganizationService.get_organization_by_id(org_id) + + # Update organization + org = OrganizationService.update_organization( + org=org, + user_id=g.current_user.id, + **data + ) + + return api_response( + data={"organization": org.to_dict()}, + message="Organization updated successfully", + ) + + except ValidationError as e: + return api_response( + success=False, + message="Validation failed", + status=400, + error_type="VALIDATION_ERROR", + error_details=e.messages, + ) + + +@api_v1_bp.route("/organizations/", methods=["DELETE"]) +@login_required +@require_owner +def delete_organization(org_id): + """ + Delete organization (soft delete). + + Args: + org_id: Organization ID + + Returns: + 200: Organization deleted successfully + 401: Not authenticated + 403: Not the owner + 404: Organization not found + """ + org = OrganizationService.get_organization_by_id(org_id) + + OrganizationService.delete_organization( + org=org, + user_id=g.current_user.id, + soft=True, + ) + + return api_response( + message="Organization deleted successfully", + ) + + +@api_v1_bp.route("/organizations//members", methods=["GET"]) +@login_required +def get_organization_members(org_id): + """ + Get all members of an organization. + + Args: + org_id: Organization ID + + Returns: + 200: List of members + 401: Not authenticated + 403: Not a member + 404: Organization not found + """ + org = OrganizationService.get_organization_by_id(org_id) + + # Check if user is a member + if not org.is_member(g.current_user.id): + return api_response( + success=False, + message="You are not a member of this organization", + status=403, + error_type="AUTHORIZATION_ERROR", + ) + + members_data = [] + for member in org.members: + if member.deleted_at is None: + member_dict = member.to_dict() + member_dict["user"] = member.user.to_dict() + members_data.append(member_dict) + + return api_response( + data={ + "members": members_data, + "count": len(members_data), + }, + message="Members retrieved successfully", + ) + + +@api_v1_bp.route("/organizations//members", methods=["POST"]) +@login_required +@require_admin +def add_organization_member(org_id): + """ + Add a member to the organization. + + Args: + org_id: Organization ID + + Request body: + email: User email to invite + role: Member role (owner, admin, member, guest) + + Returns: + 201: Member added successfully + 400: Validation error + 401: Not authenticated + 403: Not an admin + 404: Organization or user not found + 409: User already a member + """ + try: + # Validate request data + schema = InviteMemberSchema() + data = schema.load(request.json) + + org = OrganizationService.get_organization_by_id(org_id) + + # Find user by email + user = UserService.get_user_by_email(data["email"]) + if not user: + return api_response( + success=False, + message="User not found", + status=404, + error_type="NOT_FOUND", + ) + + # Add member + role = OrganizationRole(data["role"]) + member = OrganizationService.add_member( + org=org, + user_id=user.id, + role=role, + inviter_id=g.current_user.id, + ) + + member_dict = member.to_dict() + member_dict["user"] = user.to_dict() + + return api_response( + data={"member": member_dict}, + message="Member added successfully", + status=201, + ) + + except ValidationError as e: + return api_response( + success=False, + message="Validation failed", + status=400, + error_type="VALIDATION_ERROR", + error_details=e.messages, + ) + + +@api_v1_bp.route("/organizations//members/", methods=["DELETE"]) +@login_required +@require_admin +def remove_organization_member(org_id, user_id): + """ + Remove a member from the organization. + + Args: + org_id: Organization ID + user_id: User ID to remove + + Returns: + 200: Member removed successfully + 401: Not authenticated + 403: Not an admin + 404: Organization or member not found + """ + org = OrganizationService.get_organization_by_id(org_id) + + OrganizationService.remove_member( + org=org, + user_id=user_id, + remover_id=g.current_user.id, + ) + + return api_response( + message="Member removed successfully", + ) + + +@api_v1_bp.route("/organizations//members//role", methods=["PATCH"]) +@login_required +@require_admin +def update_member_role(org_id, user_id): + """ + Update a member's role. + + Args: + org_id: Organization ID + user_id: User ID + + Request body: + role: New role (owner, admin, member, guest) + + Returns: + 200: Role updated successfully + 400: Validation error + 401: Not authenticated + 403: Not an admin + 404: Organization or member not found + """ + try: + # Validate request data + schema = UpdateMemberRoleSchema() + data = schema.load(request.json) + + org = OrganizationService.get_organization_by_id(org_id) + + # Update role + new_role = OrganizationRole(data["role"]) + member = OrganizationService.update_member_role( + org=org, + user_id=user_id, + new_role=new_role, + updater_id=g.current_user.id, + ) + + member_dict = member.to_dict() + member_dict["user"] = member.user.to_dict() + + return api_response( + data={"member": member_dict}, + message="Member role updated successfully", + ) + + except ValidationError as e: + return api_response( + success=False, + message="Validation failed", + status=400, + error_type="VALIDATION_ERROR", + error_details=e.messages, + ) diff --git a/app/api/v1/users.py b/app/api/v1/users.py new file mode 100644 index 0000000..8c14229 --- /dev/null +++ b/app/api/v1/users.py @@ -0,0 +1,155 @@ +"""User endpoints.""" +from flask import g, request +from marshmallow import ValidationError +from app.api.v1 import api_v1_bp +from app.utils.response import api_response +from app.utils.decorators import login_required +from app.schemas.user_schema import UserUpdateSchema, ChangePasswordSchema +from app.services.user_service import UserService +from app.services.auth_service import AuthService + + +@api_v1_bp.route("/users/me", methods=["GET"]) +@login_required +def get_me(): + """ + Get current user profile. + + Returns: + 200: User profile data + 401: Not authenticated + """ + user = g.current_user + + return api_response( + data={"user": user.to_dict()}, + message="User profile retrieved successfully", + ) + + +@api_v1_bp.route("/users/me", methods=["PATCH"]) +@login_required +def update_me(): + """ + Update current user profile. + + Request body: + full_name: Optional full name + avatar_url: Optional avatar URL + + Returns: + 200: User updated successfully + 400: Validation error + 401: Not authenticated + """ + try: + # Validate request data + schema = UserUpdateSchema() + data = schema.load(request.json) + + # Update user + user = UserService.update_user(g.current_user, **data) + + return api_response( + data={"user": user.to_dict()}, + message="Profile updated successfully", + ) + + except ValidationError as e: + return api_response( + success=False, + message="Validation failed", + status=400, + error_type="VALIDATION_ERROR", + error_details=e.messages, + ) + + +@api_v1_bp.route("/users/me", methods=["DELETE"]) +@login_required +def delete_me(): + """ + Delete current user account (soft delete). + + Returns: + 200: Account deleted successfully + 401: Not authenticated + """ + UserService.delete_user(g.current_user, soft=True) + + return api_response( + message="Account deleted successfully", + ) + + +@api_v1_bp.route("/users/me/password", methods=["POST"]) +@login_required +def change_password(): + """ + Change current user password. + + Request body: + current_password: Current password + new_password: New password + new_password_confirm: New password confirmation + + Returns: + 200: Password changed successfully + 400: Validation error + 401: Not authenticated or invalid current password + """ + try: + # Validate request data + schema = ChangePasswordSchema() + data = schema.load(request.json) + + # Verify passwords match + if data["new_password"] != data["new_password_confirm"]: + return api_response( + success=False, + message="New passwords do not match", + status=400, + error_type="VALIDATION_ERROR", + error_details={"new_password_confirm": ["Passwords do not match"]}, + ) + + # Change password + AuthService.change_password( + user=g.current_user, + current_password=data["current_password"], + new_password=data["new_password"], + ) + + return api_response( + message="Password changed successfully", + ) + + except ValidationError as e: + return api_response( + success=False, + message="Validation failed", + status=400, + error_type="VALIDATION_ERROR", + error_details=e.messages, + ) + + +@api_v1_bp.route("/users/me/organizations", methods=["GET"]) +@login_required +def get_my_organizations(): + """ + Get all organizations current user is a member of. + + Returns: + 200: List of organizations + 401: Not authenticated + """ + organizations = UserService.get_user_organizations(g.current_user) + + return api_response( + data={ + "organizations": [org.to_dict() for org in organizations], + "count": len(organizations), + }, + message="Organizations retrieved successfully", + ) diff --git a/app/exceptions/__init__.py b/app/exceptions/__init__.py new file mode 100644 index 0000000..0fa0398 --- /dev/null +++ b/app/exceptions/__init__.py @@ -0,0 +1,40 @@ +"""Exceptions package.""" +from app.exceptions.base import BaseAPIException +from app.exceptions.auth_exceptions import ( + UnauthorizedError, + ForbiddenError, + InvalidCredentialsError, + AccountSuspendedError, + AccountInactiveError, + SessionExpiredError, + InvalidTokenError, +) +from app.exceptions.validation_exceptions import ( + ValidationError, + NotFoundError, + ConflictError, + BadRequestError, + RateLimitExceededError, + EmailAlreadyExistsError, + OrganizationNotFoundError, + UserNotFoundError, +) + +__all__ = [ + "BaseAPIException", + "UnauthorizedError", + "ForbiddenError", + "InvalidCredentialsError", + "AccountSuspendedError", + "AccountInactiveError", + "SessionExpiredError", + "InvalidTokenError", + "ValidationError", + "NotFoundError", + "ConflictError", + "BadRequestError", + "RateLimitExceededError", + "EmailAlreadyExistsError", + "OrganizationNotFoundError", + "UserNotFoundError", +] diff --git a/app/exceptions/auth_exceptions.py b/app/exceptions/auth_exceptions.py new file mode 100644 index 0000000..bdf6d25 --- /dev/null +++ b/app/exceptions/auth_exceptions.py @@ -0,0 +1,58 @@ +"""Authentication and authorization exceptions.""" +from app.exceptions.base import BaseAPIException + + +class UnauthorizedError(BaseAPIException): + """Raised when authentication is required but not provided.""" + + status_code = 401 + error_type = "AUTHENTICATION_ERROR" + message = "Authentication required" + + +class ForbiddenError(BaseAPIException): + """Raised when user lacks permissions for the requested action.""" + + status_code = 403 + error_type = "AUTHORIZATION_ERROR" + message = "You don't have permission to perform this action" + + +class InvalidCredentialsError(BaseAPIException): + """Raised when login credentials are invalid.""" + + status_code = 401 + error_type = "AUTHENTICATION_ERROR" + message = "Invalid email or password" + + +class AccountSuspendedError(BaseAPIException): + """Raised when user account is suspended.""" + + status_code = 403 + error_type = "AUTHORIZATION_ERROR" + message = "Your account has been suspended" + + +class AccountInactiveError(BaseAPIException): + """Raised when user account is inactive.""" + + status_code = 403 + error_type = "AUTHORIZATION_ERROR" + message = "Your account is inactive" + + +class SessionExpiredError(BaseAPIException): + """Raised when user session has expired.""" + + status_code = 401 + error_type = "AUTHENTICATION_ERROR" + message = "Your session has expired. Please log in again" + + +class InvalidTokenError(BaseAPIException): + """Raised when authentication token is invalid.""" + + status_code = 401 + error_type = "AUTHENTICATION_ERROR" + message = "Invalid authentication token" diff --git a/app/exceptions/base.py b/app/exceptions/base.py new file mode 100644 index 0000000..3f1f42b --- /dev/null +++ b/app/exceptions/base.py @@ -0,0 +1,31 @@ +"""Base exception classes.""" + + +class BaseAPIException(Exception): + """Base exception for all API errors.""" + + status_code = 500 + error_type = "INTERNAL_ERROR" + message = "An unexpected error occurred" + + def __init__(self, message=None, error_details=None): + """ + Initialize exception. + + Args: + message: Custom error message + error_details: Additional error details dictionary + """ + super().__init__() + if message: + self.message = message + self.error_details = error_details or {} + + def to_dict(self): + """Convert exception to dictionary for API response.""" + return { + "error_type": self.error_type, + "message": self.message, + "details": self.error_details, + "status_code": self.status_code, + } diff --git a/app/exceptions/validation_exceptions.py b/app/exceptions/validation_exceptions.py new file mode 100644 index 0000000..b5e9bf9 --- /dev/null +++ b/app/exceptions/validation_exceptions.py @@ -0,0 +1,60 @@ +"""Validation and resource exceptions.""" +from app.exceptions.base import BaseAPIException + + +class ValidationError(BaseAPIException): + """Raised when request data validation fails.""" + + status_code = 400 + error_type = "VALIDATION_ERROR" + message = "Validation failed" + + +class NotFoundError(BaseAPIException): + """Raised when a requested resource is not found.""" + + status_code = 404 + error_type = "NOT_FOUND" + message = "Resource not found" + + +class ConflictError(BaseAPIException): + """Raised when a resource conflict occurs.""" + + status_code = 409 + error_type = "CONFLICT" + message = "Resource conflict" + + +class BadRequestError(BaseAPIException): + """Raised when the request is malformed or invalid.""" + + status_code = 400 + error_type = "BAD_REQUEST" + message = "Bad request" + + +class RateLimitExceededError(BaseAPIException): + """Raised when rate limit is exceeded.""" + + status_code = 429 + error_type = "RATE_LIMIT_EXCEEDED" + message = "Too many requests. Please try again later" + + +class EmailAlreadyExistsError(ConflictError): + """Raised when attempting to register with an existing email.""" + + message = "Email address already registered" + + +class OrganizationNotFoundError(NotFoundError): + """Raised when organization is not found.""" + + message = "Organization not found" + + +class UserNotFoundError(NotFoundError): + """Raised when user is not found.""" + + message = "User not found" diff --git a/app/extensions.py b/app/extensions.py new file mode 100644 index 0000000..9113e1c --- /dev/null +++ b/app/extensions.py @@ -0,0 +1,28 @@ +"""Flask extensions initialization.""" +from flask_sqlalchemy import SQLAlchemy +from flask_migrate import Migrate +from flask_bcrypt import Bcrypt +from flask_cors import CORS +from flask_marshmallow import Marshmallow +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address +from flask_session import Session + +# Initialize extensions +db = SQLAlchemy() +migrate = Migrate() +bcrypt = Bcrypt() +cors = CORS( + supports_credentials=True, + resources={r"/api/*": {"origins": "*"}}, # Apply CORS to all API routes + allow_headers=["Content-Type", "Authorization", "X-Request-ID"], + methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + expose_headers=["X-Request-ID"], +) +ma = Marshmallow() +limiter = Limiter( + key_func=get_remote_address, + default_limits=["100 per hour"], + storage_uri="memory://", # Will be overridden by config +) +session = Session() diff --git a/app/middleware/__init__.py b/app/middleware/__init__.py new file mode 100644 index 0000000..790ad8d --- /dev/null +++ b/app/middleware/__init__.py @@ -0,0 +1,6 @@ +"""Middleware package.""" +from app.middleware.request_id import RequestIDMiddleware +from app.middleware.security_headers import SecurityHeadersMiddleware +from app.middleware.cors import setup_cors + +__all__ = ["RequestIDMiddleware", "SecurityHeadersMiddleware", "setup_cors"] diff --git a/app/middleware/cors.py b/app/middleware/cors.py new file mode 100644 index 0000000..b59e642 --- /dev/null +++ b/app/middleware/cors.py @@ -0,0 +1,29 @@ +"""CORS middleware configuration.""" +from flask import request + + +def setup_cors(app, cors): + """ + Configure CORS for the application. + + Args: + app: Flask application instance + cors: Flask-CORS instance + """ + # CORS is already initialized in extensions.py + # This function provides additional configuration if needed + + @app.after_request + def after_request_cors(response): + """Add additional CORS headers if needed.""" + origin = request.headers.get("Origin") + cors_origins = app.config.get("CORS_ORIGINS", []) + + # Allow all origins in development if CORS_ORIGINS is "*" + if cors_origins == "*" or origin in cors_origins: + response.headers["Access-Control-Allow-Origin"] = origin if cors_origins != "*" else "*" + response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS" + response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization, X-Request-ID" + response.headers["Access-Control-Max-Age"] = "3600" + + return response diff --git a/app/middleware/request_id.py b/app/middleware/request_id.py new file mode 100644 index 0000000..5ddc095 --- /dev/null +++ b/app/middleware/request_id.py @@ -0,0 +1,38 @@ +"""Request ID middleware for request tracing.""" +import uuid +from flask import g, request + + +class RequestIDMiddleware: + """Middleware to add unique request ID to each request.""" + + def __init__(self, app=None): + """Initialize middleware.""" + self.app = app + if app is not None: + self.init_app(app) + + def init_app(self, app): + """Initialize with Flask app.""" + app.before_request(self.before_request) + app.after_request(self.after_request) + + @staticmethod + def before_request(): + """Generate or extract request ID before request processing.""" + # Check if request already has an ID from client + request_id = request.headers.get("X-Request-ID") + + # Generate new ID if not provided + if not request_id: + request_id = str(uuid.uuid4()) + + # Store in Flask g object for access throughout request + g.request_id = request_id + + @staticmethod + def after_request(response): + """Add request ID to response headers.""" + if hasattr(g, "request_id"): + response.headers["X-Request-ID"] = g.request_id + return response diff --git a/app/middleware/security_headers.py b/app/middleware/security_headers.py new file mode 100644 index 0000000..d6e5bb0 --- /dev/null +++ b/app/middleware/security_headers.py @@ -0,0 +1,54 @@ +"""Security headers middleware.""" +from flask import request + + +class SecurityHeadersMiddleware: + """Middleware to add security headers to responses.""" + + def __init__(self, app=None): + """Initialize middleware.""" + self.app = app + if app is not None: + self.init_app(app) + + def init_app(self, app): + """Initialize with Flask app.""" + app.after_request(self.add_security_headers) + + @staticmethod + def add_security_headers(response): + """Add security headers to response.""" + # Prevent MIME type sniffing + response.headers["X-Content-Type-Options"] = "nosniff" + + # Enable XSS protection + response.headers["X-XSS-Protection"] = "1; mode=block" + + # Prevent clickjacking + response.headers["X-Frame-Options"] = "DENY" + + # Strict Transport Security (HSTS) + if request.is_secure: + response.headers["Strict-Transport-Security"] = ( + "max-age=31536000; includeSubDomains" + ) + + # Content Security Policy + response.headers["Content-Security-Policy"] = ( + "default-src 'self'; " + "script-src 'self' 'unsafe-inline'; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data: https:; " + "font-src 'self' data:; " + "connect-src 'self'" + ) + + # Referrer Policy + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + + # Permissions Policy + response.headers["Permissions-Policy"] = ( + "geolocation=(), microphone=(), camera=()" + ) + + return response diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..c7bdf26 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,20 @@ +"""Models package.""" +from app.models.base import BaseModel +from app.models.user import User +from app.models.organization import Organization +from app.models.organization_member import OrganizationMember +from app.models.authentication_method import AuthenticationMethod +from app.models.session import Session +from app.models.audit_log import AuditLog +from app.models.oidc_client import OIDCClient + +__all__ = [ + "BaseModel", + "User", + "Organization", + "OrganizationMember", + "AuthenticationMethod", + "Session", + "AuditLog", + "OIDCClient", +] diff --git a/app/models/audit_log.py b/app/models/audit_log.py new file mode 100644 index 0000000..7dd764e --- /dev/null +++ b/app/models/audit_log.py @@ -0,0 +1,62 @@ +"""Audit log model.""" +from app.extensions import db +from app.models.base import BaseModel +from app.utils.constants import AuditAction + + +class AuditLog(BaseModel): + """Audit log model for tracking user and system actions.""" + + __tablename__ = "audit_logs" + + user_id = db.Column(db.String(36), db.ForeignKey("users.id"), nullable=True, index=True) + action = db.Column(db.Enum(AuditAction), nullable=False, index=True) + + # Context + resource_type = db.Column(db.String(50), nullable=True, index=True) + resource_id = db.Column(db.String(36), nullable=True, index=True) + organization_id = db.Column(db.String(36), nullable=True, index=True) + + # Request details + ip_address = db.Column(db.String(45), nullable=True) + user_agent = db.Column(db.Text, nullable=True) + request_id = db.Column(db.String(36), nullable=True, index=True) + + # Additional data + extra_data = db.Column(db.JSON, nullable=True) + description = db.Column(db.Text, nullable=True) + + # Success/failure + success = db.Column(db.Boolean, default=True, nullable=False) + error_message = db.Column(db.Text, nullable=True) + + # Relationships + user = db.relationship("User", back_populates="audit_logs") + + # Indexes for common queries + __table_args__ = ( + db.Index("idx_audit_user_action", "user_id", "action"), + db.Index("idx_audit_resource", "resource_type", "resource_id"), + db.Index("idx_audit_org", "organization_id", "created_at"), + ) + + def __repr__(self): + """String representation of AuditLog.""" + return f"" + + @classmethod + def log(cls, action, user_id=None, **kwargs): + """ + Create an audit log entry. + + Args: + action: AuditAction enum value + user_id: ID of the user performing the action + **kwargs: Additional audit log fields + + Returns: + AuditLog instance + """ + log_entry = cls(action=action, user_id=user_id, **kwargs) + log_entry.save() + return log_entry diff --git a/app/models/authentication_method.py b/app/models/authentication_method.py new file mode 100644 index 0000000..9bd7794 --- /dev/null +++ b/app/models/authentication_method.py @@ -0,0 +1,59 @@ +"""Authentication method model.""" +from app.extensions import db +from app.models.base import BaseModel +from app.utils.constants import AuthMethodType + + +class AuthenticationMethod(BaseModel): + """Authentication method model storing user authentication credentials.""" + + __tablename__ = "authentication_methods" + + user_id = db.Column(db.String(36), db.ForeignKey("users.id"), nullable=False, index=True) + method_type = db.Column(db.Enum(AuthMethodType), nullable=False, index=True) + + # For password authentication + password_hash = db.Column(db.String(255), nullable=True) + + # For OAuth/OIDC providers + provider_user_id = db.Column(db.String(255), nullable=True) + provider_data = db.Column(db.JSON, nullable=True) + + # Metadata + is_primary = db.Column(db.Boolean, default=False, nullable=False) + verified = db.Column(db.Boolean, default=False, nullable=False) + last_used_at = db.Column(db.DateTime, nullable=True) + + # Relationships + user = db.relationship("User", back_populates="authentication_methods") + + # Ensure unique provider combinations + __table_args__ = ( + db.Index("idx_user_method", "user_id", "method_type"), + db.UniqueConstraint( + "user_id", "method_type", "provider_user_id", name="uix_user_method_provider" + ), + ) + + def __repr__(self): + """String representation of AuthenticationMethod.""" + return f"" + + def is_password(self): + """Check if this is a password authentication method.""" + return self.method_type == AuthMethodType.PASSWORD + + def is_oauth(self): + """Check if this is an OAuth authentication method.""" + return self.method_type in [ + AuthMethodType.GOOGLE, + AuthMethodType.GITHUB, + AuthMethodType.MICROSOFT, + ] + + def to_dict(self, exclude=None): + """Convert to dictionary, excluding sensitive fields.""" + exclude = exclude or [] + # Always exclude password hash + exclude.append("password_hash") + return super().to_dict(exclude=exclude) diff --git a/app/models/base.py b/app/models/base.py new file mode 100644 index 0000000..594fd74 --- /dev/null +++ b/app/models/base.py @@ -0,0 +1,73 @@ +"""Base model with common fields and functionality.""" +import uuid +from datetime import datetime +from app.extensions import db + + +class BaseModel(db.Model): + """Base model class with common fields.""" + + __abstract__ = True + + id = db.Column( + db.String(36), + primary_key=True, + default=lambda: str(uuid.uuid4()), + unique=True, + nullable=False, + ) + created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + updated_at = db.Column( + db.DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow + ) + deleted_at = db.Column(db.DateTime, nullable=True) + + def save(self): + """Save the model instance to database.""" + db.session.add(self) + db.session.commit() + return self + + def delete(self, soft=True): + """ + Delete the model instance. + + Args: + soft: If True, performs soft delete. If False, hard delete. + """ + if soft: + self.deleted_at = datetime.utcnow() + db.session.commit() + else: + db.session.delete(self) + db.session.commit() + + def update(self, **kwargs): + """Update model fields.""" + for key, value in kwargs.items(): + if hasattr(self, key): + setattr(self, key, value) + self.updated_at = datetime.utcnow() + db.session.commit() + return self + + def to_dict(self, exclude=None): + """ + Convert model to dictionary. + + Args: + exclude: List of fields to exclude from output + + Returns: + Dictionary representation of the model + """ + exclude = exclude or [] + result = {} + for column in self.__table__.columns: + if column.name not in exclude: + value = getattr(self, column.name) + if isinstance(value, datetime): + result[column.name] = value.isoformat() + else: + result[column.name] = value + return result diff --git a/app/models/oidc_client.py b/app/models/oidc_client.py new file mode 100644 index 0000000..c9d8467 --- /dev/null +++ b/app/models/oidc_client.py @@ -0,0 +1,69 @@ +"""OIDC Client model.""" +from app.extensions import db +from app.models.base import BaseModel +from app.utils.constants import OIDCGrantType, OIDCResponseType + + +class OIDCClient(BaseModel): + """OIDC client model for OAuth2/OIDC integrations.""" + + __tablename__ = "oidc_clients" + + organization_id = db.Column( + db.String(36), db.ForeignKey("organizations.id"), nullable=False, index=True + ) + name = db.Column(db.String(255), nullable=False) + client_id = db.Column(db.String(255), unique=True, nullable=False, index=True) + client_secret_hash = db.Column(db.String(255), nullable=False) + + # OAuth/OIDC configuration + redirect_uris = db.Column(db.JSON, nullable=False) # List of allowed redirect URIs + grant_types = db.Column(db.JSON, nullable=False) # List of allowed grant types + response_types = db.Column(db.JSON, nullable=False) # List of allowed response types + scopes = db.Column(db.JSON, nullable=False) # List of allowed scopes + + # Client metadata + logo_uri = db.Column(db.String(512), nullable=True) + client_uri = db.Column(db.String(512), nullable=True) + policy_uri = db.Column(db.String(512), nullable=True) + tos_uri = db.Column(db.String(512), nullable=True) + + # Settings + is_active = db.Column(db.Boolean, default=True, nullable=False) + is_confidential = db.Column(db.Boolean, default=True, nullable=False) + require_pkce = db.Column(db.Boolean, default=True, nullable=False) + + # Token lifetimes (in seconds) + access_token_lifetime = db.Column(db.Integer, default=3600, nullable=False) + refresh_token_lifetime = db.Column(db.Integer, default=2592000, nullable=False) + id_token_lifetime = db.Column(db.Integer, default=3600, nullable=False) + + # Relationships + organization = db.relationship("Organization", back_populates="oidc_clients") + + def __repr__(self): + """String representation of OIDCClient.""" + return f"" + + def to_dict(self, exclude=None): + """Convert to dictionary, excluding sensitive fields.""" + exclude = exclude or [] + # Always exclude client secret + exclude.append("client_secret_hash") + return super().to_dict(exclude=exclude) + + def has_grant_type(self, grant_type): + """Check if client supports a specific grant type.""" + return grant_type in self.grant_types + + def has_response_type(self, response_type): + """Check if client supports a specific response type.""" + return response_type in self.response_types + + def is_redirect_uri_allowed(self, redirect_uri): + """Check if a redirect URI is allowed for this client.""" + return redirect_uri in self.redirect_uris + + def has_scope(self, scope): + """Check if client is allowed to request a specific scope.""" + return scope in self.scopes diff --git a/app/models/organization.py b/app/models/organization.py new file mode 100644 index 0000000..cbb4787 --- /dev/null +++ b/app/models/organization.py @@ -0,0 +1,54 @@ +"""Organization model.""" +from app.extensions import db +from app.models.base import BaseModel + + +class Organization(BaseModel): + """Organization model representing a tenant/workspace.""" + + __tablename__ = "organizations" + + name = db.Column(db.String(255), nullable=False) + slug = db.Column(db.String(255), unique=True, nullable=False, index=True) + description = db.Column(db.Text, nullable=True) + logo_url = db.Column(db.String(512), nullable=True) + is_active = db.Column(db.Boolean, default=True, nullable=False) + + # Settings (stored as JSON) + settings = db.Column(db.JSON, nullable=True, default=dict) + + # Relationships + members = db.relationship( + "OrganizationMember", back_populates="organization", cascade="all, delete-orphan" + ) + oidc_clients = db.relationship( + "OIDCClient", back_populates="organization", cascade="all, delete-orphan" + ) + + def __repr__(self): + """String representation of Organization.""" + return f"" + + def get_member_count(self): + """Get the count of active members in the organization.""" + return len([m for m in self.members if m.deleted_at is None]) + + def get_owner(self): + """Get the owner of the organization.""" + from app.utils.constants import OrganizationRole + + for member in self.members: + if member.role == OrganizationRole.OWNER and member.deleted_at is None: + return member.user + return None + + def is_member(self, user_id): + """Check if a user is a member of the organization.""" + from app.models.organization_member import OrganizationMember + + return ( + OrganizationMember.query.filter_by( + user_id=user_id, organization_id=self.id, deleted_at=None + ).first() + is not None + ) diff --git a/app/models/organization_member.py b/app/models/organization_member.py new file mode 100644 index 0000000..0e5371c --- /dev/null +++ b/app/models/organization_member.py @@ -0,0 +1,51 @@ +"""Organization member model.""" +from app.extensions import db +from app.models.base import BaseModel +from app.utils.constants import OrganizationRole + + +class OrganizationMember(BaseModel): + """Organization member model representing user membership in an organization.""" + + __tablename__ = "organization_members" + + user_id = db.Column(db.String(36), db.ForeignKey("users.id"), nullable=False, index=True) + organization_id = db.Column( + db.String(36), db.ForeignKey("organizations.id"), nullable=False, index=True + ) + role = db.Column( + db.Enum(OrganizationRole), default=OrganizationRole.MEMBER, nullable=False + ) + invited_by_id = db.Column(db.String(36), db.ForeignKey("users.id"), nullable=True) + invited_at = db.Column(db.DateTime, nullable=True) + joined_at = db.Column(db.DateTime, nullable=True) + + # Relationships + user = db.relationship("User", foreign_keys=[user_id], back_populates="organization_memberships") + organization = db.relationship("Organization", back_populates="members") + invited_by = db.relationship("User", foreign_keys=[invited_by_id]) + + # Unique constraint to prevent duplicate memberships + __table_args__ = ( + db.UniqueConstraint("user_id", "organization_id", name="uix_user_org"), + ) + + def __repr__(self): + """String representation of OrganizationMember.""" + return f"" + + def is_owner(self): + """Check if member is an owner.""" + return self.role == OrganizationRole.OWNER + + def is_admin(self): + """Check if member is an admin or owner.""" + return self.role in [OrganizationRole.OWNER, OrganizationRole.ADMIN] + + def can_manage_members(self): + """Check if member can manage other members.""" + return self.is_admin() + + def can_delete_organization(self): + """Check if member can delete the organization.""" + return self.is_owner() diff --git a/app/models/session.py b/app/models/session.py new file mode 100644 index 0000000..b166d63 --- /dev/null +++ b/app/models/session.py @@ -0,0 +1,77 @@ +"""Session model.""" +from datetime import datetime, timedelta +from app.extensions import db +from app.models.base import BaseModel +from app.utils.constants import SessionStatus + + +class Session(BaseModel): + """Session model for tracking user sessions.""" + + __tablename__ = "sessions" + + user_id = db.Column(db.String(36), db.ForeignKey("users.id"), nullable=False, index=True) + token = db.Column(db.String(255), unique=True, nullable=False, index=True) + status = db.Column(db.Enum(SessionStatus), default=SessionStatus.ACTIVE, nullable=False) + + # Session metadata + ip_address = db.Column(db.String(45), nullable=True) + user_agent = db.Column(db.Text, nullable=True) + device_info = db.Column(db.JSON, nullable=True) + + # Timing + expires_at = db.Column(db.DateTime, nullable=False) + last_activity_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + revoked_at = db.Column(db.DateTime, nullable=True) + revoked_reason = db.Column(db.String(255), nullable=True) + + # Relationships + user = db.relationship("User", back_populates="sessions") + + def __repr__(self): + """String representation of Session.""" + return f"" + + def is_active(self): + """Check if session is currently active.""" + now = datetime.utcnow() + return ( + self.status == SessionStatus.ACTIVE + and self.expires_at > now + and self.deleted_at is None + ) + + def is_expired(self): + """Check if session has expired.""" + return datetime.utcnow() > self.expires_at + + def refresh(self, duration_seconds=86400): + """ + Refresh session expiration. + + Args: + duration_seconds: New session duration in seconds + """ + self.expires_at = datetime.utcnow() + timedelta(seconds=duration_seconds) + self.last_activity_at = datetime.utcnow() + db.session.commit() + + def revoke(self, reason=None): + """ + Revoke the session. + + Args: + reason: Optional reason for revocation + """ + self.status = SessionStatus.REVOKED + self.revoked_at = datetime.utcnow() + if reason: + self.revoked_reason = reason + db.session.commit() + + def to_dict(self, exclude=None): + """Convert to dictionary, excluding sensitive fields.""" + exclude = exclude or [] + # Exclude token from dict + exclude.append("token") + return super().to_dict(exclude=exclude) diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..154c456 --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,61 @@ +"""User model.""" +from app.extensions import db +from app.models.base import BaseModel +from app.utils.constants import UserStatus + + +class User(BaseModel): + """User model representing a user account.""" + + __tablename__ = "users" + + email = db.Column(db.String(255), unique=True, nullable=False, index=True) + email_verified = db.Column(db.Boolean, default=False, nullable=False) + full_name = db.Column(db.String(255), nullable=True) + avatar_url = db.Column(db.String(512), nullable=True) + status = db.Column( + db.Enum(UserStatus), default=UserStatus.ACTIVE, nullable=False, index=True + ) + last_login_at = db.Column(db.DateTime, nullable=True) + last_login_ip = db.Column(db.String(45), nullable=True) + + # Relationships + authentication_methods = db.relationship( + "AuthenticationMethod", back_populates="user", cascade="all, delete-orphan" + ) + sessions = db.relationship("Session", back_populates="user", cascade="all, delete-orphan") + organization_memberships = db.relationship( + "OrganizationMember", + back_populates="user", + cascade="all, delete-orphan", + foreign_keys="OrganizationMember.user_id", + ) + audit_logs = db.relationship("AuditLog", back_populates="user", cascade="all, delete-orphan") + + def __repr__(self): + """String representation of User.""" + return f"" + + def to_dict(self, exclude=None): + """Convert user to dictionary, excluding sensitive fields by default.""" + exclude = exclude or [] + # Always exclude password-related fields + default_exclude = [] + all_exclude = list(set(default_exclude + exclude)) + return super().to_dict(exclude=all_exclude) + + def has_password_auth(self): + """Check if user has password authentication enabled.""" + from app.models.authentication_method import AuthenticationMethod + from app.utils.constants import AuthMethodType + + return ( + AuthenticationMethod.query.filter_by( + user_id=self.id, method_type=AuthMethodType.PASSWORD, deleted_at=None + ).first() + is not None + ) + + def get_organizations(self): + """Get all organizations the user is a member of.""" + return [membership.organization for membership in self.organization_memberships] diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..8d0b1d0 --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1,34 @@ +"""Schemas package.""" +from app.schemas.user_schema import UserSchema, UserUpdateSchema, ChangePasswordSchema +from app.schemas.auth_schema import ( + RegisterSchema, + LoginSchema, + RefreshTokenSchema, + ForgotPasswordSchema, + ResetPasswordSchema, +) +from app.schemas.organization_schema import ( + OrganizationSchema, + OrganizationCreateSchema, + OrganizationUpdateSchema, + OrganizationMemberSchema, + InviteMemberSchema, + UpdateMemberRoleSchema, +) + +__all__ = [ + "UserSchema", + "UserUpdateSchema", + "ChangePasswordSchema", + "RegisterSchema", + "LoginSchema", + "RefreshTokenSchema", + "ForgotPasswordSchema", + "ResetPasswordSchema", + "OrganizationSchema", + "OrganizationCreateSchema", + "OrganizationUpdateSchema", + "OrganizationMemberSchema", + "InviteMemberSchema", + "UpdateMemberRoleSchema", +] diff --git a/app/schemas/auth_schema.py b/app/schemas/auth_schema.py new file mode 100644 index 0000000..0f9bda5 --- /dev/null +++ b/app/schemas/auth_schema.py @@ -0,0 +1,57 @@ +"""Authentication schemas for validation.""" +from marshmallow import Schema, fields, validate, validates_schema, ValidationError + + +class RegisterSchema(Schema): + """Schema for user registration.""" + + email = fields.Email(required=True) + password = fields.Str( + required=True, + validate=validate.Length(min=8, max=128), + ) + password_confirm = fields.Str(required=True) + full_name = fields.Str(allow_none=True, validate=validate.Length(max=255)) + + @validates_schema + def validate_passwords_match(self, data, **kwargs): + """Validate that passwords match.""" + if data.get("password") != data.get("password_confirm"): + raise ValidationError("Passwords do not match", field_name="password_confirm") + + +class LoginSchema(Schema): + """Schema for user login.""" + + email = fields.Email(required=True) + password = fields.Str(required=True, validate=validate.Length(min=1)) + remember_me = fields.Bool(missing=False) + + +class RefreshTokenSchema(Schema): + """Schema for token refresh.""" + + refresh_token = fields.Str(required=True) + + +class ForgotPasswordSchema(Schema): + """Schema for forgot password request.""" + + email = fields.Email(required=True) + + +class ResetPasswordSchema(Schema): + """Schema for password reset.""" + + token = fields.Str(required=True) + password = fields.Str( + required=True, + validate=validate.Length(min=8, max=128), + ) + password_confirm = fields.Str(required=True) + + @validates_schema + def validate_passwords_match(self, data, **kwargs): + """Validate that passwords match.""" + if data.get("password") != data.get("password_confirm"): + raise ValidationError("Passwords do not match", field_name="password_confirm") diff --git a/app/schemas/organization_schema.py b/app/schemas/organization_schema.py new file mode 100644 index 0000000..28e6c20 --- /dev/null +++ b/app/schemas/organization_schema.py @@ -0,0 +1,62 @@ +"""Organization schemas for validation.""" +from marshmallow import Schema, fields, validate + + +class OrganizationSchema(Schema): + """Schema for Organization model.""" + + id = fields.Str(dump_only=True) + name = fields.Str(required=True, validate=validate.Length(min=1, max=255)) + slug = fields.Str(required=True, validate=validate.Length(min=1, max=255)) + description = fields.Str(allow_none=True) + logo_url = fields.Url(allow_none=True, validate=validate.Length(max=512)) + is_active = fields.Bool(dump_only=True) + created_at = fields.DateTime(dump_only=True) + updated_at = fields.DateTime(dump_only=True) + + +class OrganizationCreateSchema(Schema): + """Schema for creating an organization.""" + + name = fields.Str(required=True, validate=validate.Length(min=1, max=255)) + slug = fields.Str(required=True, validate=validate.Length(min=1, max=255)) + description = fields.Str(allow_none=True) + logo_url = fields.Url(allow_none=True, validate=validate.Length(max=512)) + + +class OrganizationUpdateSchema(Schema): + """Schema for updating an organization.""" + + name = fields.Str(validate=validate.Length(min=1, max=255)) + description = fields.Str(allow_none=True) + logo_url = fields.Url(allow_none=True, validate=validate.Length(max=512)) + + +class OrganizationMemberSchema(Schema): + """Schema for Organization Member.""" + + id = fields.Str(dump_only=True) + user_id = fields.Str(dump_only=True) + organization_id = fields.Str(dump_only=True) + role = fields.Str(dump_only=True) + joined_at = fields.DateTime(dump_only=True) + created_at = fields.DateTime(dump_only=True) + + +class InviteMemberSchema(Schema): + """Schema for inviting a member to an organization.""" + + email = fields.Email(required=True) + role = fields.Str( + required=True, + validate=validate.OneOf(["owner", "admin", "member", "guest"]) + ) + + +class UpdateMemberRoleSchema(Schema): + """Schema for updating a member's role.""" + + role = fields.Str( + required=True, + validate=validate.OneOf(["owner", "admin", "member", "guest"]) + ) diff --git a/app/schemas/user_schema.py b/app/schemas/user_schema.py new file mode 100644 index 0000000..8906fb3 --- /dev/null +++ b/app/schemas/user_schema.py @@ -0,0 +1,47 @@ +"""User schemas for validation and serialization.""" +from marshmallow import Schema, fields, validate, validates, ValidationError +from app.utils.constants import UserStatus + + +class UserSchema(Schema): + """Schema for User model.""" + + id = fields.Str(dump_only=True) + email = fields.Email(required=True) + email_verified = fields.Bool(dump_only=True) + full_name = fields.Str(allow_none=True, validate=validate.Length(max=255)) + avatar_url = fields.Url(allow_none=True, validate=validate.Length(max=512)) + status = fields.Str(dump_only=True) + last_login_at = fields.DateTime(dump_only=True) + created_at = fields.DateTime(dump_only=True) + updated_at = fields.DateTime(dump_only=True) + + +class UserUpdateSchema(Schema): + """Schema for updating user profile.""" + + full_name = fields.Str(allow_none=True, validate=validate.Length(max=255)) + avatar_url = fields.Url(allow_none=True, validate=validate.Length(max=512)) + + +class ChangePasswordSchema(Schema): + """Schema for changing password.""" + + current_password = fields.Str(required=True, validate=validate.Length(min=1)) + new_password = fields.Str( + required=True, + validate=validate.Length(min=8, max=128), + ) + new_password_confirm = fields.Str(required=True) + + @validates("new_password") + def validate_password_strength(self, value): + """Validate password strength.""" + if len(value) < 8: + raise ValidationError("Password must be at least 8 characters long") + if not any(char.isdigit() for char in value): + raise ValidationError("Password must contain at least one digit") + if not any(char.isupper() for char in value): + raise ValidationError("Password must contain at least one uppercase letter") + if not any(char.islower() for char in value): + raise ValidationError("Password must contain at least one lowercase letter") diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..aa74efa --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1,14 @@ +"""Services package.""" +from app.services.auth_service import AuthService +from app.services.user_service import UserService +from app.services.organization_service import OrganizationService +from app.services.session_service import SessionService +from app.services.audit_service import AuditService + +__all__ = [ + "AuthService", + "UserService", + "OrganizationService", + "SessionService", + "AuditService", +] diff --git a/app/services/audit_service.py b/app/services/audit_service.py new file mode 100644 index 0000000..64b776d --- /dev/null +++ b/app/services/audit_service.py @@ -0,0 +1,107 @@ +"""Audit service.""" +from flask import request, g +from app.models.audit_log import AuditLog +from app.utils.constants import AuditAction + + +class AuditService: + """Service for audit logging.""" + + @staticmethod + def log_action( + action, + user_id=None, + organization_id=None, + resource_type=None, + resource_id=None, + metadata=None, + description=None, + success=True, + error_message=None, + ): + """ + Create an audit log entry. + + Args: + action: AuditAction enum value + user_id: ID of user performing the action + organization_id: ID of related organization + resource_type: Type of resource being acted upon + resource_id: ID of resource being acted upon + metadata: Additional metadata dictionary + description: Human-readable description + success: Whether the action succeeded + error_message: Error message if action failed + + Returns: + AuditLog instance + """ + # Get request details if available + ip_address = None + user_agent = None + request_id = None + + try: + if request: + ip_address = request.remote_addr + user_agent = request.headers.get("User-Agent") + request_id = g.get("request_id") + except RuntimeError: + # No request context + pass + + log_entry = AuditLog( + action=action, + user_id=user_id, + organization_id=organization_id, + resource_type=resource_type, + resource_id=resource_id, + ip_address=ip_address, + user_agent=user_agent, + request_id=request_id, + metadata=metadata, + description=description, + success=success, + error_message=error_message, + ) + log_entry.save() + + return log_entry + + @staticmethod + def get_user_activity(user_id, limit=50): + """ + Get recent activity for a user. + + Args: + user_id: User ID + limit: Maximum number of records to return + + Returns: + List of AuditLog instances + """ + return ( + AuditLog.query.filter_by(user_id=user_id) + .order_by(AuditLog.created_at.desc()) + .limit(limit) + .all() + ) + + @staticmethod + def get_organization_activity(organization_id, limit=50): + """ + Get recent activity for an organization. + + Args: + organization_id: Organization ID + limit: Maximum number of records to return + + Returns: + List of AuditLog instances + """ + return ( + AuditLog.query.filter_by(organization_id=organization_id) + .order_by(AuditLog.created_at.desc()) + .limit(limit) + .all() + ) diff --git a/app/services/auth_service.py b/app/services/auth_service.py new file mode 100644 index 0000000..aa20c05 --- /dev/null +++ b/app/services/auth_service.py @@ -0,0 +1,215 @@ +"""Authentication service.""" +from datetime import datetime, timedelta +from flask import request, g +from app.extensions import db, bcrypt +from app.models.user import User +from app.models.authentication_method import AuthenticationMethod +from app.models.session import Session +from app.utils.constants import AuthMethodType, SessionStatus, UserStatus, AuditAction +from app.exceptions.auth_exceptions import InvalidCredentialsError, AccountSuspendedError, AccountInactiveError +from app.exceptions.validation_exceptions import EmailAlreadyExistsError +from app.services.audit_service import AuditService +import secrets + + +class AuthService: + """Service for authentication operations.""" + + @staticmethod + def register_user(email, password, full_name=None): + """ + Register a new user with email/password. + + Args: + email: User email address + password: Plain text password + full_name: Optional full name + + Returns: + User instance + + Raises: + EmailAlreadyExistsError: If email is already registered + """ + # Check if email already exists + existing_user = User.query.filter_by(email=email.lower()).first() + if existing_user and existing_user.deleted_at is None: + raise EmailAlreadyExistsError() + + # Create user + user = User( + email=email.lower(), + full_name=full_name, + status=UserStatus.ACTIVE, + ) + user.save() + + # Create password authentication method + password_hash = bcrypt.generate_password_hash(password).decode("utf-8") + auth_method = AuthenticationMethod( + user_id=user.id, + method_type=AuthMethodType.PASSWORD, + password_hash=password_hash, + is_primary=True, + verified=True, + ) + auth_method.save() + + # Log the registration + AuditService.log_action( + action=AuditAction.USER_REGISTER, + user_id=user.id, + resource_type="user", + resource_id=user.id, + description=f"User registered with email: {email}", + ) + + return user + + @staticmethod + def authenticate(email, password): + """ + Authenticate user with email/password. + + Args: + email: User email + password: Plain text password + + Returns: + User instance if authentication succeeds + + Raises: + InvalidCredentialsError: If credentials are invalid + AccountSuspendedError: If account is suspended + AccountInactiveError: If account is inactive + """ + # Find user + user = User.query.filter_by(email=email.lower(), deleted_at=None).first() + if not user: + raise InvalidCredentialsError() + + # Check account status + if user.status == UserStatus.SUSPENDED: + raise AccountSuspendedError() + if user.status == UserStatus.INACTIVE: + raise AccountInactiveError() + + # Find password auth method + auth_method = AuthenticationMethod.query.filter_by( + user_id=user.id, + method_type=AuthMethodType.PASSWORD, + deleted_at=None, + ).first() + + if not auth_method or not auth_method.password_hash: + raise InvalidCredentialsError() + + # Verify password + if not bcrypt.check_password_hash(auth_method.password_hash, password): + raise InvalidCredentialsError() + + # Update last login + user.last_login_at = datetime.utcnow() + user.last_login_ip = request.remote_addr + auth_method.last_used_at = datetime.utcnow() + db.session.commit() + + return user + + @staticmethod + def create_session(user, duration_seconds=86400): + """ + Create a new session for the user. + + Args: + user: User instance + duration_seconds: Session duration in seconds + + Returns: + Session instance + """ + # Generate session token + token = secrets.token_urlsafe(32) + + # Create session + session = Session( + user_id=user.id, + token=token, + status=SessionStatus.ACTIVE, + ip_address=request.remote_addr, + user_agent=request.headers.get("User-Agent"), + expires_at=datetime.utcnow() + timedelta(seconds=duration_seconds), + last_activity_at=datetime.utcnow(), + ) + session.save() + + # Log session creation + AuditService.log_action( + action=AuditAction.SESSION_CREATE, + user_id=user.id, + resource_type="session", + resource_id=session.id, + description="User session created", + ) + + return session + + @staticmethod + def change_password(user, current_password, new_password): + """ + Change user password. + + Args: + user: User instance + current_password: Current password + new_password: New password + + Raises: + InvalidCredentialsError: If current password is incorrect + """ + # Find password auth method + auth_method = AuthenticationMethod.query.filter_by( + user_id=user.id, + method_type=AuthMethodType.PASSWORD, + deleted_at=None, + ).first() + + if not auth_method or not auth_method.password_hash: + raise InvalidCredentialsError("No password authentication method found") + + # Verify current password + if not bcrypt.check_password_hash(auth_method.password_hash, current_password): + raise InvalidCredentialsError("Current password is incorrect") + + # Update password + auth_method.password_hash = bcrypt.generate_password_hash(new_password).decode("utf-8") + db.session.commit() + + # Log password change + AuditService.log_action( + action=AuditAction.PASSWORD_CHANGE, + user_id=user.id, + description="User changed password", + ) + + @staticmethod + def revoke_session(session_id, reason=None): + """ + Revoke a session. + + Args: + session_id: Session ID to revoke + reason: Optional revocation reason + """ + session = Session.query.get(session_id) + if session: + session.revoke(reason=reason) + + # Log session revocation + AuditService.log_action( + action=AuditAction.SESSION_REVOKE, + user_id=session.user_id, + resource_type="session", + resource_id=session.id, + description=f"Session revoked: {reason or 'User logout'}", + ) diff --git a/app/services/organization_service.py b/app/services/organization_service.py new file mode 100644 index 0000000..719ebcc --- /dev/null +++ b/app/services/organization_service.py @@ -0,0 +1,280 @@ +"""Organization service.""" +from datetime import datetime +from app.extensions import db +from app.models.organization import Organization +from app.models.organization_member import OrganizationMember +from app.exceptions.validation_exceptions import OrganizationNotFoundError, ConflictError +from app.utils.constants import OrganizationRole, AuditAction +from app.services.audit_service import AuditService + + +class OrganizationService: + """Service for organization operations.""" + + @staticmethod + def create_organization(name, slug, owner_user_id, description=None, logo_url=None): + """ + Create a new organization. + + Args: + name: Organization name + slug: Unique organization slug + owner_user_id: ID of the user who will be the owner + description: Optional description + logo_url: Optional logo URL + + Returns: + Organization instance + + Raises: + ConflictError: If slug already exists + """ + # Check if slug already exists + existing = Organization.query.filter_by(slug=slug, deleted_at=None).first() + if existing: + raise ConflictError("Organization slug already exists") + + # Create organization + org = Organization( + name=name, + slug=slug, + description=description, + logo_url=logo_url, + is_active=True, + ) + org.save() + + # Add owner as member + member = OrganizationMember( + user_id=owner_user_id, + organization_id=org.id, + role=OrganizationRole.OWNER, + joined_at=datetime.utcnow(), + ) + member.save() + + # Log organization creation + AuditService.log_action( + action=AuditAction.ORG_CREATE, + user_id=owner_user_id, + organization_id=org.id, + resource_type="organization", + resource_id=org.id, + description=f"Organization created: {name}", + ) + + return org + + @staticmethod + def get_organization_by_id(org_id): + """ + Get organization by ID. + + Args: + org_id: Organization ID + + Returns: + Organization instance + + Raises: + OrganizationNotFoundError: If organization not found + """ + org = Organization.query.filter_by(id=org_id, deleted_at=None).first() + if not org: + raise OrganizationNotFoundError() + return org + + @staticmethod + def get_organization_by_slug(slug): + """ + Get organization by slug. + + Args: + slug: Organization slug + + Returns: + Organization instance or None + """ + return Organization.query.filter_by(slug=slug, deleted_at=None).first() + + @staticmethod + def update_organization(org, user_id, **kwargs): + """ + Update organization. + + Args: + org: Organization instance + user_id: ID of user performing the update + **kwargs: Fields to update + + Returns: + Updated Organization instance + """ + allowed_fields = ["name", "description", "logo_url"] + update_data = {k: v for k, v in kwargs.items() if k in allowed_fields} + + if update_data: + org.update(**update_data) + + # Log organization update + AuditService.log_action( + action=AuditAction.ORG_UPDATE, + user_id=user_id, + organization_id=org.id, + resource_type="organization", + resource_id=org.id, + metadata=update_data, + description="Organization updated", + ) + + return org + + @staticmethod + def delete_organization(org, user_id, soft=True): + """ + Delete organization. + + Args: + org: Organization instance + user_id: ID of user performing the delete + soft: If True, performs soft delete + + Returns: + Deleted Organization instance + """ + org.delete(soft=soft) + + # Log organization deletion + AuditService.log_action( + action=AuditAction.ORG_DELETE, + user_id=user_id, + organization_id=org.id, + resource_type="organization", + resource_id=org.id, + description=f"Organization {'soft' if soft else 'hard'} deleted", + ) + + return org + + @staticmethod + def add_member(org, user_id, role, inviter_id): + """ + Add a member to the organization. + + Args: + org: Organization instance + user_id: ID of user to add + role: OrganizationRole + inviter_id: ID of user performing the invitation + + Returns: + OrganizationMember instance + + Raises: + ConflictError: If user is already a member + """ + # Check if already a member + existing = OrganizationMember.query.filter_by( + user_id=user_id, + organization_id=org.id, + deleted_at=None, + ).first() + + if existing: + raise ConflictError("User is already a member of this organization") + + # Create membership + member = OrganizationMember( + user_id=user_id, + organization_id=org.id, + role=role, + invited_by_id=inviter_id, + invited_at=datetime.utcnow(), + joined_at=datetime.utcnow(), + ) + member.save() + + # Log member addition + AuditService.log_action( + action=AuditAction.ORG_MEMBER_ADD, + user_id=inviter_id, + organization_id=org.id, + resource_type="organization_member", + resource_id=member.id, + metadata={"added_user_id": user_id, "role": role.value}, + description=f"Member added to organization with role: {role.value}", + ) + + return member + + @staticmethod + def remove_member(org, user_id, remover_id): + """ + Remove a member from the organization. + + Args: + org: Organization instance + user_id: ID of user to remove + remover_id: ID of user performing the removal + """ + member = OrganizationMember.query.filter_by( + user_id=user_id, + organization_id=org.id, + deleted_at=None, + ).first() + + if member: + member.delete(soft=True) + + # Log member removal + AuditService.log_action( + action=AuditAction.ORG_MEMBER_REMOVE, + user_id=remover_id, + organization_id=org.id, + resource_type="organization_member", + resource_id=member.id, + metadata={"removed_user_id": user_id}, + description="Member removed from organization", + ) + + @staticmethod + def update_member_role(org, user_id, new_role, updater_id): + """ + Update a member's role in the organization. + + Args: + org: Organization instance + user_id: ID of user whose role to update + new_role: New OrganizationRole + updater_id: ID of user performing the update + + Returns: + Updated OrganizationMember instance + """ + member = OrganizationMember.query.filter_by( + user_id=user_id, + organization_id=org.id, + deleted_at=None, + ).first() + + if member: + old_role = member.role + member.role = new_role + db.session.commit() + + # Log role change + AuditService.log_action( + action=AuditAction.ORG_MEMBER_ROLE_CHANGE, + user_id=updater_id, + organization_id=org.id, + resource_type="organization_member", + resource_id=member.id, + metadata={ + "target_user_id": user_id, + "old_role": old_role.value, + "new_role": new_role.value, + }, + description=f"Member role changed from {old_role.value} to {new_role.value}", + ) + + return member diff --git a/app/services/session_service.py b/app/services/session_service.py new file mode 100644 index 0000000..66be1b8 --- /dev/null +++ b/app/services/session_service.py @@ -0,0 +1,76 @@ +"""Session service.""" +from datetime import datetime +from app.models.session import Session +from app.utils.constants import SessionStatus + + +class SessionService: + """Service for session operations.""" + + @staticmethod + def get_active_session_by_token(token): + """Get active session by token. + + Args: + token: The session token string + + Returns: + Session object if found and active, None otherwise + """ + from app.models.session import Session + from app.utils.constants import SessionStatus + return Session.query.filter_by( + token=token, + status=SessionStatus.ACTIVE, + deleted_at=None + ).first() + + @staticmethod + def get_user_sessions(user_id, active_only=True): + """ + Get all sessions for a user. + + Args: + user_id: User ID + active_only: If True, only return active sessions + + Returns: + List of Session instances + """ + query = Session.query.filter_by(user_id=user_id, deleted_at=None) + + if active_only: + query = query.filter_by(status=SessionStatus.ACTIVE).filter( + Session.expires_at > datetime.utcnow() + ) + + return query.all() + + @staticmethod + def revoke_user_sessions(user_id, reason="User logged out from all devices"): + """ + Revoke all active sessions for a user. + + Args: + user_id: User ID + reason: Reason for revocation + """ + sessions = SessionService.get_user_sessions(user_id, active_only=True) + + for session in sessions: + session.revoke(reason=reason) + + @staticmethod + def cleanup_expired_sessions(): + """Clean up expired sessions.""" + expired_sessions = Session.query.filter( + Session.status == SessionStatus.ACTIVE, + Session.expires_at < datetime.utcnow(), + Session.deleted_at.is_(None), + ).all() + + for session in expired_sessions: + session.status = SessionStatus.EXPIRED + session.save() + + return len(expired_sessions) diff --git a/app/services/user_service.py b/app/services/user_service.py new file mode 100644 index 0000000..8474cd1 --- /dev/null +++ b/app/services/user_service.py @@ -0,0 +1,110 @@ +"""User service.""" +from app.extensions import db +from app.models.user import User +from app.exceptions.validation_exceptions import UserNotFoundError +from app.utils.constants import AuditAction +from app.services.audit_service import AuditService + + +class UserService: + """Service for user operations.""" + + @staticmethod + def get_user_by_id(user_id): + """ + Get user by ID. + + Args: + user_id: User ID + + Returns: + User instance + + Raises: + UserNotFoundError: If user not found + """ + user = User.query.filter_by(id=user_id, deleted_at=None).first() + if not user: + raise UserNotFoundError() + return user + + @staticmethod + def get_user_by_email(email): + """ + Get user by email. + + Args: + email: User email + + Returns: + User instance or None + """ + return User.query.filter_by(email=email.lower(), deleted_at=None).first() + + @staticmethod + def update_user(user, **kwargs): + """ + Update user profile. + + Args: + user: User instance + **kwargs: Fields to update + + Returns: + Updated User instance + """ + allowed_fields = ["full_name", "avatar_url"] + update_data = {k: v for k, v in kwargs.items() if k in allowed_fields} + + if update_data: + user.update(**update_data) + + # Log user update + AuditService.log_action( + action=AuditAction.USER_UPDATE, + user_id=user.id, + resource_type="user", + resource_id=user.id, + metadata=update_data, + description="User profile updated", + ) + + return user + + @staticmethod + def delete_user(user, soft=True): + """ + Delete user account. + + Args: + user: User instance + soft: If True, performs soft delete + + Returns: + Deleted User instance + """ + user.delete(soft=soft) + + # Log user deletion + AuditService.log_action( + action=AuditAction.USER_DELETE, + user_id=user.id, + resource_type="user", + resource_id=user.id, + description=f"User account {'soft' if soft else 'hard'} deleted", + ) + + return user + + @staticmethod + def get_user_organizations(user): + """ + Get all organizations the user is a member of. + + Args: + user: User instance + + Returns: + List of organizations + """ + return user.get_organizations() diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..27121a9 --- /dev/null +++ b/app/utils/__init__.py @@ -0,0 +1,25 @@ +"""Utilities package.""" +from app.utils.response import api_response +from app.utils.constants import ( + UserStatus, + OrganizationRole, + AuthMethodType, + SessionStatus, + AuditAction, + ErrorType, +) +from app.utils.decorators import login_required, require_role, require_owner, require_admin + +__all__ = [ + "api_response", + "UserStatus", + "OrganizationRole", + "AuthMethodType", + "SessionStatus", + "AuditAction", + "ErrorType", + "login_required", + "require_role", + "require_owner", + "require_admin", +] diff --git a/app/utils/constants.py b/app/utils/constants.py new file mode 100644 index 0000000..2425d7e --- /dev/null +++ b/app/utils/constants.py @@ -0,0 +1,99 @@ +"""Application constants and enums.""" +from enum import Enum + + +class UserStatus(str, Enum): + """User account status.""" + + ACTIVE = "active" + INACTIVE = "inactive" + SUSPENDED = "suspended" + PENDING = "pending" + + +class OrganizationRole(str, Enum): + """Organization member roles.""" + + OWNER = "owner" + ADMIN = "admin" + MEMBER = "member" + GUEST = "guest" + + +class AuthMethodType(str, Enum): + """Authentication method types.""" + + PASSWORD = "password" + GOOGLE = "google" + GITHUB = "github" + MICROSOFT = "microsoft" + SAML = "saml" + OIDC = "oidc" + + +class SessionStatus(str, Enum): + """Session status.""" + + ACTIVE = "active" + EXPIRED = "expired" + REVOKED = "revoked" + + +class AuditAction(str, Enum): + """Audit log action types.""" + + # User actions + USER_LOGIN = "user.login" + USER_LOGOUT = "user.logout" + USER_REGISTER = "user.register" + USER_UPDATE = "user.update" + USER_DELETE = "user.delete" + PASSWORD_CHANGE = "user.password_change" + PASSWORD_RESET = "user.password_reset" + + # Organization actions + ORG_CREATE = "org.create" + ORG_UPDATE = "org.update" + ORG_DELETE = "org.delete" + ORG_MEMBER_ADD = "org.member.add" + ORG_MEMBER_REMOVE = "org.member.remove" + ORG_MEMBER_ROLE_CHANGE = "org.member.role_change" + + # Session actions + SESSION_CREATE = "session.create" + SESSION_REVOKE = "session.revoke" + + # Auth method actions + AUTH_METHOD_ADD = "auth.method.add" + AUTH_METHOD_REMOVE = "auth.method.remove" + + +class OIDCGrantType(str, Enum): + """OIDC grant types.""" + + AUTHORIZATION_CODE = "authorization_code" + IMPLICIT = "implicit" + REFRESH_TOKEN = "refresh_token" + CLIENT_CREDENTIALS = "client_credentials" + + +class OIDCResponseType(str, Enum): + """OIDC response types.""" + + CODE = "code" + TOKEN = "token" + ID_TOKEN = "id_token" + + +# Error type constants +class ErrorType: + """Error type constants for API responses.""" + + VALIDATION_ERROR = "VALIDATION_ERROR" + AUTHENTICATION_ERROR = "AUTHENTICATION_ERROR" + AUTHORIZATION_ERROR = "AUTHORIZATION_ERROR" + NOT_FOUND = "NOT_FOUND" + CONFLICT = "CONFLICT" + RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED" + INTERNAL_ERROR = "INTERNAL_ERROR" + BAD_REQUEST = "BAD_REQUEST" diff --git a/app/utils/decorators.py b/app/utils/decorators.py new file mode 100644 index 0000000..f175d92 --- /dev/null +++ b/app/utils/decorators.py @@ -0,0 +1,129 @@ +"""Custom decorators for authentication and authorization.""" +from functools import wraps +from flask import request, g +from app.utils.response import api_response +from app.utils.constants import OrganizationRole + + +def login_required(f): + """Decorator to require Bearer token authentication. + + Extracts token from Authorization: Bearer {token} header, + validates the session, and sets g.current_user and g.current_session. + """ + from app.services.session_service import SessionService + + @wraps(f) + def decorated_function(*args, **kwargs): + # Extract token from Authorization header + auth_header = request.headers.get('Authorization') + + if not auth_header: + return api_response( + success=False, + message="Authorization header is required", + status=401, + error_type="AUTH_REQUIRED" + ) + + # Expect format: "Bearer {token}" + parts = auth_header.split() + if len(parts) != 2 or parts[0].lower() != 'bearer': + return api_response( + success=False, + message="Invalid authorization format. Use: Bearer {token}", + status=401, + error_type="INVALID_AUTH_FORMAT" + ) + + token = parts[1] + + # Get active session by token + session = SessionService.get_active_session_by_token(token) + + if not session: + return api_response( + success=False, + message="Invalid or expired session", + status=401, + error_type="INVALID_TOKEN" + ) + + # Validate session is active + if not session.is_active(): + return api_response( + success=False, + message="Session is no longer active", + status=401, + error_type="SESSION_INACTIVE" + ) + + # Update last_activity_at timestamp + from datetime import datetime, timezone + session.last_activity_at = datetime.now(timezone.utc) + from app import db + db.session.commit() + + # Set context variables + g.current_user = session.user + g.current_session = session + + return f(*args, **kwargs) + + return decorated_function + + +def require_role(*allowed_roles): + """ + Decorator to require specific organization roles. + + Args: + *allowed_roles: Variable number of OrganizationRole values + + Raises: + ForbiddenError: If user doesn't have required role + """ + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + # Ensure user is authenticated first + if not hasattr(g, "current_user"): + raise UnauthorizedError("Authentication required") + + # Get organization_id from kwargs or URL parameters + org_id = kwargs.get("org_id") or kwargs.get("organization_id") + if not org_id: + raise ForbiddenError("Organization context required") + + # Check user's role in the organization + from app.models.organization_member import OrganizationMember + + membership = OrganizationMember.query.filter_by( + user_id=g.current_user.id, + organization_id=org_id, + ).first() + + if not membership: + raise ForbiddenError("Not a member of this organization") + + if membership.role not in allowed_roles: + raise ForbiddenError( + f"Requires one of the following roles: {', '.join(allowed_roles)}" + ) + + g.current_membership = membership + return f(*args, **kwargs) + + return decorated_function + + return decorator + + +def require_owner(f): + """Decorator to require organization owner role.""" + return require_role(OrganizationRole.OWNER)(f) + + +def require_admin(f): + """Decorator to require organization admin or owner role.""" + return require_role(OrganizationRole.OWNER, OrganizationRole.ADMIN)(f) diff --git a/app/utils/response.py b/app/utils/response.py new file mode 100644 index 0000000..60fcb6c --- /dev/null +++ b/app/utils/response.py @@ -0,0 +1,54 @@ +"""API response utilities.""" +from flask import jsonify, g + + +# Version for the response envelope +ENVELOPE_VERSION = "1.0" + + +def api_response( + *, + data=None, + success=True, + message="", + status=200, + error_type=None, + error_details=None, + meta=None +): + """ + Create a standardized API response. + + Args: + data: Response data (only included if success=True) + success: Whether the request was successful + message: Human-readable message + status: HTTP status code + error_type: Type of error (only if success=False) + error_details: Additional error details (only if success=False) + meta: Additional metadata (pagination, etc.) + + Returns: + Tuple of (response, status_code) + """ + payload = { + "version": ENVELOPE_VERSION, + "success": success, + "code": status, + "message": message, + "request_id": g.get("request_id", "unknown"), + } + + if meta: + payload["meta"] = meta + + if success: + if data is not None: + payload["data"] = data + else: + payload["error"] = { + "type": error_type or "UNKNOWN", + "details": error_details or {} + } + + return jsonify(payload), status diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..ab07fc0 --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,21 @@ +"""Configuration package.""" +import os +from config.base import BaseConfig +from config.development import DevelopmentConfig +from config.testing import TestingConfig +from config.production import ProductionConfig + + +config_by_name = { + "development": DevelopmentConfig, + "testing": TestingConfig, + "production": ProductionConfig, + "default": DevelopmentConfig, +} + + +def get_config(config_name=None): + """Get configuration object based on environment.""" + if config_name is None: + config_name = os.getenv("FLASK_ENV", "development") + return config_by_name.get(config_name, DevelopmentConfig) diff --git a/config/base.py b/config/base.py new file mode 100644 index 0000000..9f32ed2 --- /dev/null +++ b/config/base.py @@ -0,0 +1,72 @@ +"""Base configuration for all environments.""" +import os +from datetime import timedelta + + +class BaseConfig: + """Base configuration class with common settings.""" + + # Application + SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key-change-in-production") + DEBUG = False + TESTING = False + + # Database + SQLALCHEMY_DATABASE_URI = os.getenv( + "DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/authy2" + ) + SQLALCHEMY_TRACK_MODIFICATIONS = False + SQLALCHEMY_ECHO = os.getenv("SQLALCHEMY_ECHO", "False").lower() == "true" + SQLALCHEMY_ENGINE_OPTIONS = { + "pool_pre_ping": True, + "pool_recycle": 300, + } + + # Security + BCRYPT_LOG_ROUNDS = int(os.getenv("BCRYPT_LOG_ROUNDS", "12")) + # Session configuration - deprecated, migrating to Bearer token authentication + # SESSION_COOKIE_SECURE = os.getenv("SESSION_COOKIE_SECURE", "True").lower() == "true" + # SESSION_COOKIE_HTTPONLY = True + # SESSION_COOKIE_SAMESITE = os.getenv("SESSION_COOKIE_SAMESITE", "Lax") + # PERMANENT_SESSION_LIFETIME = timedelta( + # seconds=int(os.getenv("MAX_SESSION_DURATION", "86400")) + # ) + + # CORS + CORS_ORIGINS = os.getenv("CORS_ORIGINS", "http://localhost:3000").split(",") + CORS_SUPPORTS_CREDENTIALS = True + + # JWT (if using JWT) + JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", SECRET_KEY) + JWT_ACCESS_TOKEN_EXPIRES = timedelta( + seconds=int(os.getenv("JWT_ACCESS_TOKEN_EXPIRES", "3600")) + ) + JWT_REFRESH_TOKEN_EXPIRES = timedelta( + seconds=int(os.getenv("JWT_REFRESH_TOKEN_EXPIRES", "2592000")) + ) + + # Redis + REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0") + # Flask session configuration - deprecated, migrating to Bearer token authentication + # SESSION_TYPE = "redis" + # SESSION_REDIS = None # Will be set at app initialization + + # Rate Limiting + RATELIMIT_ENABLED = os.getenv("RATELIMIT_ENABLED", "True").lower() == "true" + RATELIMIT_STORAGE_URL = os.getenv("RATELIMIT_STORAGE_URL", "redis://localhost:6379/1") + RATELIMIT_DEFAULT = "100/hour" + + # Logging + LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO") + LOG_TO_STDOUT = os.getenv("LOG_TO_STDOUT", "False").lower() == "true" + + # OIDC + OIDC_ISSUER_URL = os.getenv("OIDC_ISSUER_URL", "http://localhost:5000") + + # API Versioning + API_VERSION = "1.0.0" + ENVELOPE_VERSION = "1.0" + + # Pagination + DEFAULT_PAGE_SIZE = 20 + MAX_PAGE_SIZE = 100 diff --git a/config/development.py b/config/development.py new file mode 100644 index 0000000..74e87e7 --- /dev/null +++ b/config/development.py @@ -0,0 +1,17 @@ +"""Development environment configuration.""" +from config.base import BaseConfig + + +class DevelopmentConfig(BaseConfig): + """Development configuration.""" + + DEBUG = True + SQLALCHEMY_ECHO = True + SESSION_COOKIE_SECURE = False + + # More verbose logging in development + LOG_LEVEL = "DEBUG" + LOG_TO_STDOUT = True + + # Reduced bcrypt rounds for faster dev cycles + BCRYPT_LOG_ROUNDS = 4 diff --git a/config/production.py b/config/production.py new file mode 100644 index 0000000..280084c --- /dev/null +++ b/config/production.py @@ -0,0 +1,29 @@ +"""Production environment configuration.""" +import os +from config.base import BaseConfig + + +class ProductionConfig(BaseConfig): + """Production configuration.""" + + DEBUG = False + TESTING = False + + # Enforce environment variables in production + SECRET_KEY = os.environ["SECRET_KEY"] + SQLALCHEMY_DATABASE_URI = os.environ["DATABASE_URL"] + + # Strict security settings + SESSION_COOKIE_SECURE = True + SESSION_COOKIE_HTTPONLY = True + SESSION_COOKIE_SAMESITE = "Strict" + + # Production logging + LOG_LEVEL = "WARNING" + LOG_TO_STDOUT = True + + # Strong password hashing + BCRYPT_LOG_ROUNDS = 13 + + # Disable SQL echo in production + SQLALCHEMY_ECHO = False diff --git a/config/testing.py b/config/testing.py new file mode 100644 index 0000000..6ee817f --- /dev/null +++ b/config/testing.py @@ -0,0 +1,25 @@ +"""Testing environment configuration.""" +from config.base import BaseConfig + + +class TestingConfig(BaseConfig): + """Testing configuration.""" + + TESTING = True + DEBUG = True + + # Use in-memory SQLite for testing + SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:" + SQLALCHEMY_ECHO = False + + # Disable CSRF for testing + WTF_CSRF_ENABLED = False + + # Fast password hashing for tests + BCRYPT_LOG_ROUNDS = 4 + + # Disable rate limiting in tests + RATELIMIT_ENABLED = False + + # Use different Redis DB for testing + REDIS_URL = "redis://localhost:6379/15" diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..85bbfe6 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,400 @@ +# Authy2 Backend - Architecture Documentation + +## Overview + +This document describes the architecture and design decisions for the Authy2 backend authentication and authorization API. + +## Architecture Pattern + +The application follows a **layered architecture pattern** with clear separation of concerns: + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ API Layer (Routes) โ”‚ Flask blueprints, request validation +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ Service Layer (Business) โ”‚ Business logic, orchestration +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ Data Layer (Models) โ”‚ ORM models, database access +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ Database (PostgreSQL) โ”‚ Data persistence +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +### Key Principles + +1. **Separation of Concerns**: Each layer has distinct responsibilities +2. **Dependency Injection**: Extensions initialized separately +3. **Factory Pattern**: Application factory for flexible configuration +4. **Repository Pattern**: Service layer abstracts data access +5. **Single Responsibility**: Each module has one reason to change + +## Component Structure + +### 1. Application Factory (`app/__init__.py`) + +Implements the factory pattern for creating Flask applications with different configurations. + +**Responsibilities**: +- Initialize Flask app +- Load configuration +- Initialize extensions +- Register blueprints +- Setup middleware +- Register error handlers + +### 2. Models Layer (`app/models/`) + +SQLAlchemy ORM models representing the database schema. + +**Key Models**: +- `User`: User accounts +- `Organization`: Multi-tenant organizations +- `OrganizationMember`: User-organization membership +- `AuthenticationMethod`: Multi-method authentication +- `Session`: User session management +- `AuditLog`: Activity tracking +- `OIDCClient`: OAuth2/OIDC clients + +**Base Model Features**: +- UUID primary keys +- Timestamps (created_at, updated_at) +- Soft delete (deleted_at) +- Common methods (save, delete, update, to_dict) + +### 3. Service Layer (`app/services/`) + +Contains business logic and orchestrates data access. + +**Services**: +- `AuthService`: Authentication operations +- `UserService`: User management +- `OrganizationService`: Organization management +- `SessionService`: Session management +- `AuditService`: Audit logging + +**Benefits**: +- Keeps controllers thin +- Testable business logic +- Reusable across endpoints +- Transaction management + +### 4. API Layer (`app/api/`) + +Flask blueprints defining HTTP endpoints. + +**Structure**: +- Versioned blueprints (`v1/`, future `v2/`) +- RESTful design +- Request validation with Marshmallow +- Response formatting + +**Endpoint Groups**: +- `auth.py`: Authentication endpoints +- `users.py`: User profile endpoints +- `organizations.py`: Organization CRUD + +### 5. Schemas (`app/schemas/`) + +Marshmallow schemas for validation and serialization. + +**Types**: +- Input validation schemas +- Output serialization schemas +- Nested schemas for relationships + +### 6. Middleware (`app/middleware/`) + +Request/response middleware components. + +**Components**: +- `RequestIDMiddleware`: Request tracing +- `SecurityHeadersMiddleware`: Security headers +- `CORS`: Cross-origin resource sharing + +### 7. Exceptions (`app/exceptions/`) + +Custom exception hierarchy for API errors. + +**Hierarchy**: +``` +BaseAPIException +โ”œโ”€โ”€ UnauthorizedError (401) +โ”œโ”€โ”€ ForbiddenError (403) +โ”œโ”€โ”€ ValidationError (400) +โ”œโ”€โ”€ NotFoundError (404) +โ””โ”€โ”€ ConflictError (409) +``` + +### 8. Utilities (`app/utils/`) + +Shared utilities and helpers. + +**Components**: +- `response.py`: Standardized API responses +- `constants.py`: Enums and constants +- `decorators.py`: Authentication decorators + +## Data Models + +### User Model + +```python +User +โ”œโ”€โ”€ id: UUID (PK) +โ”œโ”€โ”€ email: String (unique) +โ”œโ”€โ”€ email_verified: Boolean +โ”œโ”€โ”€ full_name: String +โ”œโ”€โ”€ status: Enum (active, inactive, suspended) +โ”œโ”€โ”€ last_login_at: DateTime +โ””โ”€โ”€ relationships: + โ”œโ”€โ”€ authentication_methods + โ”œโ”€โ”€ sessions + โ”œโ”€โ”€ organization_memberships + โ””โ”€โ”€ audit_logs +``` + +### Organization Model + +```python +Organization +โ”œโ”€โ”€ id: UUID (PK) +โ”œโ”€โ”€ name: String +โ”œโ”€โ”€ slug: String (unique) +โ”œโ”€โ”€ description: Text +โ”œโ”€โ”€ is_active: Boolean +โ”œโ”€โ”€ settings: JSON +โ””โ”€โ”€ relationships: + โ”œโ”€โ”€ members (OrganizationMember) + โ””โ”€โ”€ oidc_clients +``` + +### Authentication Method Model + +```python +AuthenticationMethod +โ”œโ”€โ”€ id: UUID (PK) +โ”œโ”€โ”€ user_id: UUID (FK) +โ”œโ”€โ”€ method_type: Enum (password, google, github, oidc) +โ”œโ”€โ”€ password_hash: String (for password auth) +โ”œโ”€โ”€ provider_user_id: String (for OAuth) +โ”œโ”€โ”€ provider_data: JSON +โ””โ”€โ”€ is_primary: Boolean +``` + +## Security Architecture + +### Authentication Flow + +1. User submits credentials +2. `AuthService.authenticate()` validates credentials +3. Session created with secure token +4. Token stored in Redis +5. Session ID returned to client +6. Subsequent requests authenticated via session + +### Authorization Flow + +1. Request includes session token +2. `@login_required` decorator validates session +3. User loaded into `g.current_user` +4. `@require_role` checks organization permissions +5. Request proceeds or returns 403 + +### Password Security + +- Bcrypt hashing (12+ rounds in production) +- Configurable rounds per environment +- No plain-text passwords stored +- Password strength validation + +### Session Security + +- Secure session tokens (32-byte random) +- Configurable expiration +- Session revocation support +- IP and user agent tracking + +## API Response Format + +All responses follow a standardized envelope: + +```json +{ + "version": "1.0", + "success": boolean, + "code": number, + "message": string, + "request_id": string, + "data": object | null, + "error": { + "type": string, + "details": object + } | null, + "meta": object | null +} +``` + +**Benefits**: +- Consistent client parsing +- Request tracing +- Error handling +- Pagination metadata + +## Database Design + +### Multi-Tenancy + +Organizations provide multi-tenancy: +- Each org is isolated +- Users can belong to multiple orgs +- Role-based access per org + +### Audit Trail + +Comprehensive logging: +- All mutations logged +- User context captured +- IP and user agent tracked +- Queryable history + +### Soft Deletes + +All models support soft delete: +- `deleted_at` timestamp +- Allows recovery +- Maintains referential integrity +- Audit trail preserved + +## Configuration Management + +### Environment-based Config + +``` +config/ +โ”œโ”€โ”€ base.py # Common settings +โ”œโ”€โ”€ development.py # Dev overrides +โ”œโ”€โ”€ testing.py # Test config +โ””โ”€โ”€ production.py # Production settings +``` + +### Configuration Hierarchy + +1. Base configuration +2. Environment-specific overrides +3. Environment variables (highest priority) + +## Testing Strategy + +### Unit Tests + +- Test individual functions/methods +- Mock external dependencies +- Fast execution +- High coverage target (>80%) + +### Integration Tests + +- Test API endpoints end-to-end +- Use test database +- Verify request/response flow +- Authentication flow testing + +### Test Fixtures + +- Reusable test data +- Database setup/teardown +- Authenticated clients +- Sample users/organizations + +## Deployment Architecture + +### Recommended Setup + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Load โ”‚ +โ”‚ Balancer โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ”Œโ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ” + โ”‚ โ”‚ +โ”Œโ”€โ”€โ–ผโ”€โ”€โ” โ”Œโ”€โ”€โ–ผโ”€โ”€โ” +โ”‚ Web โ”‚ โ”‚ Web โ”‚ Gunicorn workers +โ”‚ App โ”‚ โ”‚ App โ”‚ +โ””โ”€โ”€โ”ฌโ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”ฌโ”€โ”€โ”€โ”˜ + โ”‚ โ”‚ + โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ”Œโ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ” + โ”‚ Redis โ”‚ Session storage + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ”Œโ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ PostgreSQL โ”‚ Data persistence + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +### Scaling Considerations + +- Stateless application (sessions in Redis) +- Horizontal scaling via load balancer +- Database connection pooling +- Redis for distributed sessions +- Celery for background tasks (future) + +## Error Handling + +### Exception Hierarchy + +All exceptions inherit from `BaseAPIException`: +- Consistent error responses +- HTTP status codes +- Error type categorization +- Detailed error information + +### Global Error Handlers + +- Catch all exceptions +- Log errors appropriately +- Return standardized responses +- Never expose internals + +## Logging & Monitoring + +### Audit Logging + +- User actions tracked +- Organization changes logged +- Authentication events +- Queryable audit trail + +### Application Logging + +- Structured logging +- Request/response logging +- Error logging +- Performance metrics + +## Future Enhancements + +1. **OAuth Provider**: Implement full OAuth2/OIDC provider +2. **MFA**: Multi-factor authentication +3. **Email Service**: Email verification and notifications +4. **Webhooks**: Event-driven notifications +5. **API Keys**: Service account authentication +6. **Rate Limiting**: Per-user/org rate limits +7. **Background Jobs**: Celery integration +8. **Monitoring**: Prometheus/Grafana metrics + +## Best Practices + +1. **Always use service layer** for business logic +2. **Validate all inputs** with Marshmallow schemas +3. **Use decorators** for authentication/authorization +4. **Log important events** to audit log +5. **Follow RESTful conventions** for endpoints +6. **Write tests** for all new features +7. **Use transactions** for multi-step operations +8. **Never return sensitive data** without filtering +9. **Keep controllers thin** - logic goes in services +10. **Version the API** for backward compatibility diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..a9258fb --- /dev/null +++ b/manage.py @@ -0,0 +1,17 @@ +"""Management script for Flask application.""" +import os +from flask.cli import FlaskGroup +from app import create_app +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +# Create application +app = create_app(os.getenv("FLASK_ENV", "development")) + +# Create Flask CLI group +cli = FlaskGroup(create_app=lambda: app) + +if __name__ == "__main__": + cli() diff --git a/migrations/.gitkeep b/migrations/.gitkeep new file mode 100644 index 0000000..9555759 --- /dev/null +++ b/migrations/.gitkeep @@ -0,0 +1,2 @@ +# Placeholder for database migrations +# Migrations will be generated using Flask-Migrate diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5f03b2b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,79 @@ +[project] +name = "authy2-backend" +version = "1.0.0" +description = "Authentication and Authorization API Service" +readme = "README.md" +requires-python = ">=3.11" +license = {text = "MIT"} +authors = [ + {name = "Your Name", email = "your.email@example.com"} +] + +[project.urls] +Homepage = "https://github.com/yourusername/authy2-backend" +Repository = "https://github.com/yourusername/authy2-backend" + +[tool.black] +line-length = 100 +target-version = ['py311'] +include = '\.pyi?$' +extend-exclude = ''' +/( + # directories + \.eggs + | \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | build + | dist + | migrations +)/ +''' + +[tool.isort] +profile = "black" +line_length = 100 +multi_line_output = 3 +include_trailing_comma = true +force_grid_wrap = 0 +use_parentheses = true +ensure_newline_before_comments = true +skip_glob = ["*/migrations/*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "-v", + "--strict-markers", + "--cov=app", + "--cov-report=term-missing", + "--cov-report=html", + "--cov-branch" +] +markers = [ + "unit: Unit tests", + "integration: Integration tests", + "slow: Slow running tests" +] + +[tool.coverage.run] +source = ["app"] +omit = [ + "*/tests/*", + "*/migrations/*", + "*/__init__.py" +] + +[tool.coverage.report] +precision = 2 +show_missing = true +skip_covered = false + +[build-system] +requires = ["setuptools>=68.0"] +build-backend = "setuptools.build_meta" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..209f92d --- /dev/null +++ b/pytest.ini @@ -0,0 +1,16 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = + -v + --strict-markers + --cov=app + --cov-report=term-missing + --cov-report=html + --cov-branch +markers = + unit: Unit tests + integration: Integration tests + slow: Slow running tests diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5603c37 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +-r requirements/base.txt diff --git a/requirements/base.txt b/requirements/base.txt new file mode 100644 index 0000000..7d8559b --- /dev/null +++ b/requirements/base.txt @@ -0,0 +1,40 @@ +# Core Flask +Flask==3.0.0 +Werkzeug==3.0.1 + +# Database +SQLAlchemy==2.0.23 +Flask-SQLAlchemy==3.1.1 +Flask-Migrate==4.0.5 +psycopg2-binary==2.9.9 + +# Validation & Serialization +marshmallow==3.20.1 +Flask-Marshmallow==0.15.0 +marshmallow-sqlalchemy==0.29.0 + +# Security +bcrypt==4.1.2 +Flask-Bcrypt==1.0.1 + +# CORS +Flask-CORS==4.0.0 + +# Environment variables +python-dotenv==1.0.0 + +# UUID +shortuuid==1.0.11 + +# Date/Time +python-dateutil==2.8.2 + +# Redis (for sessions) +redis==5.0.1 +Flask-Session==0.5.0 + +# Rate limiting +Flask-Limiter==3.5.0 + +# Logging +python-json-logger==2.0.7 diff --git a/requirements/development.txt b/requirements/development.txt new file mode 100644 index 0000000..bccb4ec --- /dev/null +++ b/requirements/development.txt @@ -0,0 +1,22 @@ +-r base.txt + +# Testing +pytest==7.4.3 +pytest-cov==4.1.0 +pytest-flask==1.3.0 +factory-boy==3.3.0 +faker==20.1.0 + +# Code quality +flake8==6.1.0 +black==23.12.1 +isort==5.13.2 +pylint==3.0.3 + +# Development tools +ipython==8.18.1 +ipdb==0.13.13 +watchdog==3.0.0 + +# Documentation +sphinx==7.2.6 diff --git a/requirements/production.txt b/requirements/production.txt new file mode 100644 index 0000000..d50532a --- /dev/null +++ b/requirements/production.txt @@ -0,0 +1,10 @@ +-r base.txt + +# Production WSGI server +gunicorn==21.2.0 + +# Monitoring & logging +sentry-sdk[flask]==1.39.1 + +# Performance +gevent==23.9.1 diff --git a/requirements/testing.txt b/requirements/testing.txt new file mode 100644 index 0000000..9b25abf --- /dev/null +++ b/requirements/testing.txt @@ -0,0 +1,9 @@ +-r base.txt + +# Testing +pytest==7.4.3 +pytest-cov==4.1.0 +pytest-flask==1.3.0 +factory-boy==3.3.0 +faker==20.1.0 +coverage==7.3.4 diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..862f852 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""Scripts package.""" diff --git a/scripts/init_db.py b/scripts/init_db.py new file mode 100644 index 0000000..1ed6c11 --- /dev/null +++ b/scripts/init_db.py @@ -0,0 +1,21 @@ +"""Initialize database script.""" +from app import create_app +from app.extensions import db +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +# Create application +app = create_app() + +with app.app_context(): + # Drop all tables + print("Dropping all tables...") + db.drop_all() + + # Create all tables + print("Creating all tables...") + db.create_all() + + print("Database initialized successfully!") diff --git a/scripts/request_organizations.sh b/scripts/request_organizations.sh new file mode 100755 index 0000000..2202794 --- /dev/null +++ b/scripts/request_organizations.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +# Script to request user organizations with bearer token +# Usage: ./request_organizations.sh + +if [ $# -ne 1 ]; then + echo "Usage: $0 " + exit 1 +fi + +TOKEN=$1 + +curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8888/api/v1/users/me/organizations \ No newline at end of file diff --git a/scripts/seed_data.py b/scripts/seed_data.py new file mode 100644 index 0000000..17a80da --- /dev/null +++ b/scripts/seed_data.py @@ -0,0 +1,436 @@ +"""Seed database with comprehensive test data. + +This script creates: +- 3 organizations (Acme Corp, Tech Startup, Data Systems Inc) +- 2 admin users +- 8 regular users +- Proper organization memberships with different roles +""" +import sys + +from app import create_app +from app.extensions import db +from app.models.user import User +from app.models.organization import Organization +from app.models.organization_member import OrganizationMember +from app.models.authentication_method import AuthenticationMethod +from app.services.auth_service import AuthService +from app.services.organization_service import OrganizationService +from app.utils.constants import OrganizationRole, UserStatus, AuthMethodType +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +# Create application +app = create_app() + + +def user_exists(email): + """Check if a user with the given email exists.""" + return User.query.filter_by(email=email.lower(), deleted_at=None).first() is not None + + +def organization_exists(slug): + """Check if an organization with the given slug exists.""" + return Organization.query.filter_by(slug=slug, deleted_at=None).first() is not None + + +def create_or_get_user(email, password, full_name): + """Create a user if they don't exist, or return existing user.""" + existing_user = User.query.filter_by(email=email.lower(), deleted_at=None).first() + if existing_user: + print(f" โ†’ User {email} already exists, skipping") + return existing_user + + try: + user = AuthService.register_user( + email=email, + password=password, + full_name=full_name, + ) + print(f" โ†’ Created user: {email}") + return user + except Exception as e: + # If email already exists (soft deleted), try to find it + existing = User.query.filter_by(email=email.lower()).first() + if existing: + print(f" โ†’ User {email} exists (soft deleted), skipping") + return existing + raise e + + +def create_or_get_organization(name, slug, owner_user_id, description=None): + """Create an organization if it doesn't exist, or return existing org.""" + existing_org = Organization.query.filter_by(slug=slug, deleted_at=None).first() + if existing_org: + print(f" โ†’ Organization {name} already exists, skipping") + return existing_org + + existing = Organization.query.filter_by(slug=slug).first() + if existing: + print(f" โ†’ Organization {slug} exists (soft deleted), skipping") + return existing + + try: + org = OrganizationService.create_organization( + name=name, + slug=slug, + owner_user_id=owner_user_id, + description=description, + ) + print(f" โ†’ Created organization: {name}") + return org + except Exception as e: + print(f" โ†’ Error creating organization {name}: {e}") + raise e + + +def add_org_member(org, user_id, role, inviter_id): + """Add a user to an organization if not already a member.""" + existing = OrganizationMember.query.filter_by( + user_id=user_id, + organization_id=org.id, + deleted_at=None, + ).first() + + if existing: + print(f" โ†’ User {user_id} is already a member of {org.name}, skipping") + return existing + + try: + member = OrganizationService.add_member( + org=org, + user_id=user_id, + role=role, + inviter_id=inviter_id, + ) + print(f" โ†’ Added user to {org.name} as {role.value}") + return member + except Exception as e: + # ConflictError means already a member + if "already a member" in str(e).lower(): + print(f" โ†’ User {user_id} is already a member of {org.name}, skipping") + return + raise e + + +def seed_data(): + """Seed the database with test data.""" + print("=" * 60) + print("Authy2 Database Seed Script") + print("=" * 60) + + with app.app_context(): + # Define test data + # Organizations + organizations = [ + { + "name": "Acme Corporation", + "slug": "acme-corp", + "description": "Leading provider of innovative enterprise solutions", + }, + { + "name": "Tech Startup Inc", + "slug": "tech-startup", + "description": "Disrupting the industry with cutting-edge technology", + }, + { + "name": "Data Systems Inc", + "slug": "data-systems", + "description": "Enterprise data management and analytics", + }, + ] + + # Admin users (global admins across organizations) + admin_users = [ + { + "email": "admin@acme-corp.com", + "password": "AdminPass123!", + "full_name": "Alice Administrator", + }, + { + "email": "superadmin@acme-corp.com", + "password": "SuperAdmin123!", + "full_name": "Sarah SuperAdmin", + }, + ] + + # Regular users for Acme Corp + acme_users = [ + { + "email": "bob@acme-corp.com", + "password": "UserPass123!", + "full_name": "Bob Builder", + }, + { + "email": "carol@acme-corp.com", + "password": "UserPass123!", + "full_name": "Carol Developer", + }, + { + "email": "david@acme-corp.com", + "password": "UserPass123!", + "full_name": "David Designer", + }, + { + "email": "eve@acme-corp.com", + "password": "UserPass123!", + "full_name": "Eve Engineer", + }, + ] + + # Regular users for Tech Startup + tech_startup_users = [ + { + "email": "frank@tech-startup.com", + "password": "UserPass123!", + "full_name": "Frank Founder", + }, + { + "email": "grace@tech-startup.com", + "password": "UserPass123!", + "full_name": "Grace Growth", + }, + { + "email": "henry@tech-startup.com", + "password": "UserPass123!", + "full_name": "Henry Hacker", + }, + ] + + # Regular users for Data Systems + data_systems_users = [ + { + "email": "iris@data-systems.com", + "password": "UserPass123!", + "full_name": "Iris Analyst", + }, + { + "email": "jack@data-systems.com", + "password": "UserPass123!", + "full_name": "Jack Data", + }, + ] + + # Cross-organization users (users in multiple orgs) + cross_org_users = [ + { + "email": "charlie@cross-org.com", + "password": "UserPass123!", + "full_name": "Charlie Consultant", + }, + ] + + # ========================================================================= + # Step 1: Create Users First (needed for organization owners) + # ========================================================================= + print("\n[Step 1] Creating Admin Users...") + admin_objects = {} + + for admin_data in admin_users: + user = create_or_get_user( + email=admin_data["email"], + password=admin_data["password"], + full_name=admin_data["full_name"], + ) + admin_objects[admin_data["email"]] = user + + print(f"\n Created {len(admin_objects)} admin users") + + # ========================================================================= + # Step 2: Create Regular Users + # ========================================================================= + print("\n[Step 2] Creating Regular Users...") + all_users = {} + + # Acme Corp users + print("\n Acme Corporation Users:") + for user_data in acme_users: + user = create_or_get_user( + email=user_data["email"], + password=user_data["password"], + full_name=user_data["full_name"], + ) + all_users[user_data["email"]] = user + + # Tech Startup users + print("\n Tech Startup Users:") + for user_data in tech_startup_users: + user = create_or_get_user( + email=user_data["email"], + password=user_data["password"], + full_name=user_data["full_name"], + ) + all_users[user_data["email"]] = user + + # Data Systems users + print("\n Data Systems Users:") + for user_data in data_systems_users: + user = create_or_get_user( + email=user_data["email"], + password=user_data["password"], + full_name=user_data["full_name"], + ) + all_users[user_data["email"]] = user + + # Cross-organization user + print("\n Cross-Organization User:") + for user_data in cross_org_users: + user = create_or_get_user( + email=user_data["email"], + password=user_data["password"], + full_name=user_data["full_name"], + ) + all_users[user_data["email"]] = user + + print(f"\n Created {len(all_users)} regular users") + + # ========================================================================= + # Step 3: Create Organizations (with valid owner_user_id) + # ========================================================================= + print("\n[Step 3] Creating Organizations...") + org_objects = {} + + # Map organizations to their owners + org_owner_map = { + "acme-corp": "admin@acme-corp.com", + "tech-startup": "superadmin@acme-corp.com", + "data-systems": "admin@acme-corp.com", + } + + for org_data in organizations: + owner_email = org_owner_map.get(org_data["slug"]) + owner_user = admin_objects.get(owner_email) if owner_email else None + owner_user_id = owner_user.id if owner_user else None + + org = create_or_get_organization( + name=org_data["name"], + slug=org_data["slug"], + owner_user_id=owner_user_id, + description=org_data["description"], + ) + org_objects[org_data["slug"]] = org + + print(f"\n Created {len(org_objects)} organizations") + + # ========================================================================= + # Step 4: Add Users to Organizations + # ========================================================================= + print("\n[Step 4] Adding Users to Organizations...") + + # Get organization and user references + acme_org = org_objects.get("acme-corp") + tech_org = org_objects.get("tech-startup") + data_org = org_objects.get("data-systems") + acme_admin = admin_objects.get("admin@acme-corp.com") + sarah = admin_objects.get("superadmin@acme-corp.com") + alice = admin_objects.get("admin@acme-corp.com") + + # Add Acme Corp users + print("\n Adding to Acme Corporation:") + for user_email in ["bob@acme-corp.com", "carol@acme-corp.com"]: + user = all_users.get(user_email) + if user and acme_admin and acme_org: + add_org_member(acme_org, user.id, OrganizationRole.MEMBER, acme_admin.id) + + # Make Carol an admin + carol = all_users.get("carol@acme-corp.com") + if carol and acme_admin and acme_org: + try: + OrganizationService.update_member_role( + acme_org, carol.id, OrganizationRole.ADMIN, acme_admin.id + ) + print(f" โ†’ Promoted Carol to ADMIN in Acme Corp") + except Exception: + pass # May already be admin + + # Add Tech Startup users + print("\n Adding to Tech Startup:") + for user_email in ["frank@tech-startup.com", "grace@tech-startup.com"]: + user = all_users.get(user_email) + if user and sarah and tech_org: + add_org_member(tech_org, user.id, OrganizationRole.MEMBER, sarah.id) + + # Make Frank an admin + frank = all_users.get("frank@tech-startup.com") + if frank and sarah and tech_org: + try: + OrganizationService.update_member_role( + tech_org, frank.id, OrganizationRole.ADMIN, sarah.id + ) + print(f" โ†’ Promoted Frank to ADMIN in Tech Startup") + except Exception: + pass + + # Add Data Systems users + print("\n Adding to Data Systems:") + if data_org: + # Alice is owner of Data Systems too + if alice: + add_org_member(data_org, alice.id, OrganizationRole.OWNER, alice.id) + + for user_email in ["iris@data-systems.com", "jack@data-systems.com"]: + user = all_users.get(user_email) + if user and alice: + add_org_member(data_org, user.id, OrganizationRole.MEMBER, alice.id) + + # Add cross-organization user to multiple orgs + print("\n Adding Cross-Organization User:") + charlie = all_users.get("charlie@cross-org.com") + if charlie: + # Add Charlie to Acme Corp as guest + if acme_admin and acme_org: + add_org_member(acme_org, charlie.id, OrganizationRole.GUEST, acme_admin.id) + + # Add Charlie to Tech Startup as member + if sarah and tech_org: + add_org_member(tech_org, charlie.id, OrganizationRole.MEMBER, sarah.id) + + # ========================================================================= + # Summary + # ========================================================================= + print("\n" + "=" * 60) + print("Seed Complete!") + print("=" * 60) + + print("\n๐Ÿ“Š Summary:") + print(f" Organizations: {len(org_objects)}") + print(f" Admin Users: {len(admin_objects)}") + print(f" Regular Users: {len(all_users)}") + + print("\n๐Ÿ” Test Credentials:") + print("\n Admin Accounts:") + for email, password in [ + ("admin@acme-corp.com", "AdminPass123!"), + ("superadmin@acme-corp.com", "SuperAdmin123!"), + ]: + print(f" {email} / {password}") + + print("\n Regular User Accounts (password: UserPass123!):") + for email in list(all_users.keys())[:5]: + print(f" {email}") + if len(all_users) > 5: + print(f" ... and {len(all_users) - 5} more") + + print("\n๐Ÿข Organizations:") + for slug, org in org_objects.items(): + member_count = org.get_member_count() + owner = org.get_owner() + owner_email = owner.email if owner else "None" + print(f" {org.name} (slug: {slug})") + print(f" Members: {member_count}, Owner: {owner_email}") + + print("\n" + "=" * 60) + + +if __name__ == "__main__": + try: + seed_data() + print("\nโœ… Database seeded successfully!") + sys.exit(0) + except Exception as e: + print(f"\nโŒ Error seeding database: {e}") + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..46816dd --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests package.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..1e590d0 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,99 @@ +"""Pytest configuration and fixtures.""" +import pytest +from app import create_app +from app.extensions import db as _db +from app.models import User, Organization, OrganizationMember +from app.services.auth_service import AuthService +from app.utils.constants import OrganizationRole + + +@pytest.fixture(scope="session") +def app(): + """Create application for testing.""" + app = create_app("testing") + return app + + +@pytest.fixture(scope="function") +def db(app): + """Create database for testing.""" + with app.app_context(): + _db.create_all() + yield _db + _db.session.remove() + _db.drop_all() + + +@pytest.fixture(scope="function") +def client(app, db): + """Create test client.""" + return app.test_client() + + +@pytest.fixture(scope="function") +def test_user(db): + """Create a test user.""" + email = "test@example.com" + password = "TestPassword123!" + full_name = "Test User" + + user = AuthService.register_user( + email=email, + password=password, + full_name=full_name, + ) + + # Store password for testing + user._test_password = password + + return user + + +@pytest.fixture(scope="function") +def test_organization(db, test_user): + """Create a test organization.""" + from app.services.organization_service import OrganizationService + + org = OrganizationService.create_organization( + name="Test Organization", + slug="test-org", + owner_user_id=test_user.id, + description="A test organization", + ) + + return org + + +@pytest.fixture(scope="function") +def authenticated_client(client, test_user): + """Create authenticated test client.""" + # Login + response = client.post( + "/api/v1/auth/login", + json={ + "email": test_user.email, + "password": test_user._test_password, + }, + ) + + assert response.status_code == 200 + + return client + + +@pytest.fixture(scope="function") +def second_test_user(db): + """Create a second test user.""" + email = "second@example.com" + password = "TestPassword123!" + full_name = "Second User" + + user = AuthService.register_user( + email=email, + password=password, + full_name=full_name, + ) + + user._test_password = password + + return user diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..c66cd71 --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1 @@ +"""Integration tests package.""" diff --git a/tests/integration/test_auth_flow.py b/tests/integration/test_auth_flow.py new file mode 100644 index 0000000..cb801a5 --- /dev/null +++ b/tests/integration/test_auth_flow.py @@ -0,0 +1,107 @@ +"""Integration tests for authentication flow.""" +import pytest +import json + + +@pytest.mark.integration +class TestAuthFlow: + """Integration tests for authentication endpoints.""" + + def test_register_login_logout_flow(self, client, db): + """Test complete registration, login, and logout flow.""" + # Register + register_data = { + "email": "integration@example.com", + "password": "TestPassword123!", + "password_confirm": "TestPassword123!", + "full_name": "Integration Test", + } + + response = client.post( + "/api/v1/auth/register", + data=json.dumps(register_data), + content_type="application/json", + ) + + assert response.status_code == 201 + data = response.get_json() + assert data["success"] is True + assert "user" in data["data"] + assert data["data"]["user"]["email"] == "integration@example.com" + + # Logout + response = client.post("/api/v1/auth/logout") + assert response.status_code == 200 + + # Login + login_data = { + "email": "integration@example.com", + "password": "TestPassword123!", + } + + response = client.post( + "/api/v1/auth/login", + data=json.dumps(login_data), + content_type="application/json", + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["success"] is True + assert "user" in data["data"] + + # Logout again + response = client.post("/api/v1/auth/logout") + assert response.status_code == 200 + + def test_get_current_user_authenticated(self, authenticated_client): + """Test getting current user when authenticated.""" + response = authenticated_client.get("/api/v1/auth/me") + + assert response.status_code == 200 + data = response.get_json() + assert data["success"] is True + assert "user" in data["data"] + + def test_get_current_user_unauthenticated(self, client): + """Test getting current user when not authenticated.""" + response = client.get("/api/v1/auth/me") + + assert response.status_code == 401 + data = response.get_json() + assert data["success"] is False + + def test_invalid_credentials(self, client, test_user): + """Test login with invalid credentials.""" + login_data = { + "email": test_user.email, + "password": "WrongPassword123!", + } + + response = client.post( + "/api/v1/auth/login", + data=json.dumps(login_data), + content_type="application/json", + ) + + assert response.status_code == 401 + data = response.get_json() + assert data["success"] is False + + def test_duplicate_registration(self, client, test_user): + """Test registering with existing email.""" + register_data = { + "email": test_user.email, + "password": "TestPassword123!", + "password_confirm": "TestPassword123!", + } + + response = client.post( + "/api/v1/auth/register", + data=json.dumps(register_data), + content_type="application/json", + ) + + assert response.status_code == 409 + data = response.get_json() + assert data["success"] is False diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..ea3f8b9 --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ +"""Unit tests package.""" diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py new file mode 100644 index 0000000..9848964 --- /dev/null +++ b/tests/unit/test_models.py @@ -0,0 +1,76 @@ +"""Unit tests for models.""" +import pytest +from datetime import datetime +from app.models import User, Organization +from app.utils.constants import UserStatus + + +@pytest.mark.unit +class TestUserModel: + """Tests for User model.""" + + def test_create_user(self, db): + """Test creating a user.""" + user = User( + email="test@example.com", + full_name="Test User", + status=UserStatus.ACTIVE, + ) + user.save() + + assert user.id is not None + assert user.email == "test@example.com" + assert user.full_name == "Test User" + assert user.status == UserStatus.ACTIVE + assert user.created_at is not None + assert user.deleted_at is None + + def test_user_to_dict(self, test_user): + """Test user to_dict method.""" + user_dict = test_user.to_dict() + + assert "id" in user_dict + assert "email" in user_dict + assert user_dict["email"] == test_user.email + assert "created_at" in user_dict + + def test_user_soft_delete(self, test_user): + """Test soft deleting a user.""" + test_user.delete(soft=True) + + assert test_user.deleted_at is not None + assert isinstance(test_user.deleted_at, datetime) + + +@pytest.mark.unit +class TestOrganizationModel: + """Tests for Organization model.""" + + def test_create_organization(self, db): + """Test creating an organization.""" + org = Organization( + name="Test Org", + slug="test-org", + description="Test organization", + ) + org.save() + + assert org.id is not None + assert org.name == "Test Org" + assert org.slug == "test-org" + assert org.is_active is True + assert org.created_at is not None + + def test_organization_to_dict(self, test_organization): + """Test organization to_dict method.""" + org_dict = test_organization.to_dict() + + assert "id" in org_dict + assert "name" in org_dict + assert org_dict["name"] == test_organization.name + assert "slug" in org_dict + + def test_get_member_count(self, test_organization): + """Test getting member count.""" + count = test_organization.get_member_count() + assert count == 1 # Only the owner diff --git a/tests/unit/test_services/__init__.py b/tests/unit/test_services/__init__.py new file mode 100644 index 0000000..4c0ee2c --- /dev/null +++ b/tests/unit/test_services/__init__.py @@ -0,0 +1 @@ +"""Services unit tests package.""" diff --git a/tests/unit/test_services/test_auth_service.py b/tests/unit/test_services/test_auth_service.py new file mode 100644 index 0000000..13ca360 --- /dev/null +++ b/tests/unit/test_services/test_auth_service.py @@ -0,0 +1,102 @@ +"""Unit tests for AuthService.""" +import pytest +from app.services.auth_service import AuthService +from app.exceptions.auth_exceptions import InvalidCredentialsError +from app.exceptions.validation_exceptions import EmailAlreadyExistsError +from app.utils.constants import UserStatus, AuthMethodType + + +@pytest.mark.unit +class TestAuthService: + """Tests for AuthService.""" + + def test_register_user(self, db): + """Test user registration.""" + email = "newuser@example.com" + password = "SecurePassword123!" + full_name = "New User" + + user = AuthService.register_user( + email=email, + password=password, + full_name=full_name, + ) + + assert user.id is not None + assert user.email == email.lower() + assert user.full_name == full_name + assert user.status == UserStatus.ACTIVE + assert user.has_password_auth() + + def test_register_duplicate_email(self, db, test_user): + """Test registering with duplicate email.""" + with pytest.raises(EmailAlreadyExistsError): + AuthService.register_user( + email=test_user.email, + password="SomePassword123!", + ) + + def test_authenticate_success(self, db, test_user): + """Test successful authentication.""" + user = AuthService.authenticate( + email=test_user.email, + password=test_user._test_password, + ) + + assert user.id == test_user.id + assert user.last_login_at is not None + + def test_authenticate_wrong_password(self, db, test_user): + """Test authentication with wrong password.""" + with pytest.raises(InvalidCredentialsError): + AuthService.authenticate( + email=test_user.email, + password="WrongPassword123!", + ) + + def test_authenticate_nonexistent_user(self, db): + """Test authentication with non-existent email.""" + with pytest.raises(InvalidCredentialsError): + AuthService.authenticate( + email="nonexistent@example.com", + password="SomePassword123!", + ) + + def test_create_session(self, app, db, test_user): + """Test creating a session.""" + with app.test_request_context(): + session = AuthService.create_session(test_user) + + assert session.id is not None + assert session.user_id == test_user.id + assert session.token is not None + assert session.is_active() + + def test_change_password(self, app, db, test_user): + """Test changing password.""" + with app.test_request_context(): + new_password = "NewPassword456!" + + AuthService.change_password( + user=test_user, + current_password=test_user._test_password, + new_password=new_password, + ) + + # Verify can login with new password + user = AuthService.authenticate( + email=test_user.email, + password=new_password, + ) + + assert user.id == test_user.id + + def test_change_password_wrong_current(self, app, db, test_user): + """Test changing password with wrong current password.""" + with app.test_request_context(): + with pytest.raises(InvalidCredentialsError): + AuthService.change_password( + user=test_user, + current_password="WrongPassword123!", + new_password="NewPassword456!", + ) diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..2e8da86 --- /dev/null +++ b/wsgi.py @@ -0,0 +1,15 @@ +"""WSGI entry point for the application.""" +from dotenv import load_dotenv, find_dotenv + +# Load environment variables from .env file FIRST, before any imports +# This must be done before importing app to ensure config has access to env vars +load_dotenv(find_dotenv()) + +import os +from app import create_app + +# Create application instance +app = create_app(os.getenv("FLASK_ENV", "development")) + +if __name__ == "__main__": + app.run()