This commit is contained in:
2026-01-08 01:00:26 +10:30
commit 211854ca0a
70 changed files with 5241 additions and 0 deletions
+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