inital
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
+139
@@ -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
|
||||
@@ -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 <repository-url>
|
||||
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
|
||||
+175
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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/<session_id>", 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",
|
||||
)
|
||||
@@ -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/<org_id>", 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/<org_id>", 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/<org_id>", 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/<org_id>/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/<org_id>/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/<org_id>/members/<user_id>", 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/<org_id>/members/<user_id>/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,
|
||||
)
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"<AuditLog action={self.action} user_id={self.user_id}>"
|
||||
|
||||
@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
|
||||
@@ -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"<AuthenticationMethod user_id={self.user_id} type={self.method_type}>"
|
||||
|
||||
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)
|
||||
@@ -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
|
||||
@@ -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"<OIDCClient {self.name} client_id={self.client_id}>"
|
||||
|
||||
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
|
||||
@@ -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"<Organization {self.name}>"
|
||||
|
||||
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
|
||||
)
|
||||
@@ -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"<OrganizationMember user_id={self.user_id} org_id={self.organization_id} role={self.role}>"
|
||||
|
||||
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()
|
||||
@@ -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"<Session user_id={self.user_id} status={self.status}>"
|
||||
|
||||
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)
|
||||
@@ -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"<User {self.email}>"
|
||||
|
||||
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]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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")
|
||||
@@ -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"])
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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'}",
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1,2 @@
|
||||
# Placeholder for database migrations
|
||||
# Migrations will be generated using Flask-Migrate
|
||||
@@ -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"
|
||||
+16
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
-r 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Scripts package."""
|
||||
@@ -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!")
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script to request user organizations with bearer token
|
||||
# Usage: ./request_organizations.sh <bearer_token>
|
||||
|
||||
if [ $# -ne 1 ]; then
|
||||
echo "Usage: $0 <bearer_token>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TOKEN=$1
|
||||
|
||||
curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8888/api/v1/users/me/organizations
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests package."""
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Integration tests package."""
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Unit tests package."""
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Services unit tests package."""
|
||||
@@ -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!",
|
||||
)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user