This commit is contained in:
2026-01-08 01:00:26 +10:30
commit 211854ca0a
70 changed files with 5241 additions and 0 deletions
+40
View File
@@ -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
+19
View File
@@ -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
View File
@@ -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
+284
View File
@@ -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
View File
@@ -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")
+24
View File
@@ -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")
+8
View File
@@ -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
+212
View File
@@ -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",
)
+372
View File
@@ -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,
)
+155
View File
@@ -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",
)
+40
View File
@@ -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",
]
+58
View File
@@ -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"
+31
View File
@@ -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,
}
+60
View File
@@ -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"
+28
View File
@@ -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()
+6
View File
@@ -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"]
+29
View File
@@ -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
+38
View File
@@ -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
+54
View File
@@ -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
+20
View File
@@ -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",
]
+62
View File
@@ -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
+59
View File
@@ -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)
+73
View File
@@ -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
+69
View File
@@ -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
+54
View File
@@ -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
)
+51
View File
@@ -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()
+77
View File
@@ -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)
+61
View File
@@ -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]
+34
View File
@@ -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",
]
+57
View File
@@ -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")
+62
View File
@@ -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"])
)
+47
View File
@@ -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")
+14
View File
@@ -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",
]
+107
View File
@@ -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()
)
+215
View File
@@ -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'}",
)
+280
View File
@@ -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
+76
View File
@@ -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)
+110
View File
@@ -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()
+25
View File
@@ -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",
]
+99
View File
@@ -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"
+129
View File
@@ -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)
+54
View File
@@ -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
+21
View File
@@ -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)
+72
View File
@@ -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
+17
View File
@@ -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
+29
View File
@@ -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
+25
View File
@@ -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"
+400
View File
@@ -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
+17
View File
@@ -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()
+2
View File
@@ -0,0 +1,2 @@
# Placeholder for database migrations
# Migrations will be generated using Flask-Migrate
+79
View File
@@ -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
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
-r requirements/base.txt
+40
View File
@@ -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
+22
View File
@@ -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
+10
View File
@@ -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
+9
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Scripts package."""
+21
View File
@@ -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!")
+13
View File
@@ -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
+436
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""Tests package."""
+99
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Integration tests package."""
+107
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Unit tests package."""
+76
View File
@@ -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
+1
View File
@@ -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!",
)
+15
View File
@@ -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()