talk2me/migrations/add_user_authentication.py
Adolfo Delorenzo fa951c3141 Add comprehensive database integration, authentication, and admin dashboard
This commit introduces major enhancements to Talk2Me:

## Database Integration
- PostgreSQL support with SQLAlchemy ORM
- Redis integration for caching and real-time analytics
- Automated database initialization scripts
- Migration support infrastructure

## User Authentication System
- JWT-based API authentication
- Session-based web authentication
- API key authentication for programmatic access
- User roles and permissions (admin/user)
- Login history and session tracking
- Rate limiting per user with customizable limits

## Admin Dashboard
- Real-time analytics and monitoring
- User management interface (create, edit, delete users)
- System health monitoring
- Request/error tracking
- Language pair usage statistics
- Performance metrics visualization

## Key Features
- Dual authentication support (token + user accounts)
- Graceful fallback for missing services
- Non-blocking analytics middleware
- Comprehensive error handling
- Session management with security features

## Bug Fixes
- Fixed rate limiting bypass for admin routes
- Added missing email validation method
- Improved error handling for missing database tables
- Fixed session-based authentication for API endpoints

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-03 18:21:56 -06:00

216 lines
10 KiB
Python

"""Add user authentication tables and update existing models
This migration:
1. Creates user authentication tables (users, login_history, user_sessions, revoked_tokens)
2. Updates translation and transcription tables to link to users
3. Adds proper foreign key constraints and indexes
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
import uuid
# revision identifiers
revision = 'add_user_authentication'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# Create users table
op.create_table('users',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False, default=uuid.uuid4),
sa.Column('email', sa.String(255), nullable=False),
sa.Column('username', sa.String(100), nullable=False),
sa.Column('password_hash', sa.String(255), nullable=False),
sa.Column('full_name', sa.String(255), nullable=True),
sa.Column('avatar_url', sa.String(500), nullable=True),
sa.Column('api_key', sa.String(64), nullable=False),
sa.Column('api_key_created_at', sa.DateTime(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False, default=True),
sa.Column('is_verified', sa.Boolean(), nullable=False, default=False),
sa.Column('is_suspended', sa.Boolean(), nullable=False, default=False),
sa.Column('suspension_reason', sa.Text(), nullable=True),
sa.Column('suspended_at', sa.DateTime(), nullable=True),
sa.Column('suspended_until', sa.DateTime(), nullable=True),
sa.Column('role', sa.String(20), nullable=False, default='user'),
sa.Column('permissions', postgresql.JSONB(astext_type=sa.Text()), nullable=False, default=[]),
sa.Column('rate_limit_per_minute', sa.Integer(), nullable=False, default=30),
sa.Column('rate_limit_per_hour', sa.Integer(), nullable=False, default=500),
sa.Column('rate_limit_per_day', sa.Integer(), nullable=False, default=5000),
sa.Column('total_requests', sa.Integer(), nullable=False, default=0),
sa.Column('total_translations', sa.Integer(), nullable=False, default=0),
sa.Column('total_transcriptions', sa.Integer(), nullable=False, default=0),
sa.Column('total_tts_requests', sa.Integer(), nullable=False, default=0),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('last_login_at', sa.DateTime(), nullable=True),
sa.Column('last_active_at', sa.DateTime(), nullable=True),
sa.Column('password_changed_at', sa.DateTime(), nullable=False),
sa.Column('failed_login_attempts', sa.Integer(), nullable=False, default=0),
sa.Column('locked_until', sa.DateTime(), nullable=True),
sa.Column('settings', postgresql.JSONB(astext_type=sa.Text()), nullable=False, default={}),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email'),
sa.UniqueConstraint('username'),
sa.UniqueConstraint('api_key')
)
# Create indexes on users table
op.create_index('idx_users_email', 'users', ['email'])
op.create_index('idx_users_username', 'users', ['username'])
op.create_index('idx_users_api_key', 'users', ['api_key'])
op.create_index('idx_users_email_active', 'users', ['email', 'is_active'])
op.create_index('idx_users_role_active', 'users', ['role', 'is_active'])
op.create_index('idx_users_created_at', 'users', ['created_at'])
# Create login_history table
op.create_table('login_history',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False, default=uuid.uuid4),
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('login_at', sa.DateTime(), nullable=False),
sa.Column('logout_at', sa.DateTime(), nullable=True),
sa.Column('login_method', sa.String(20), nullable=False),
sa.Column('success', sa.Boolean(), nullable=False),
sa.Column('failure_reason', sa.String(255), nullable=True),
sa.Column('session_id', sa.String(255), nullable=True),
sa.Column('jwt_jti', sa.String(255), nullable=True),
sa.Column('ip_address', sa.String(45), nullable=False),
sa.Column('user_agent', sa.String(500), nullable=True),
sa.Column('device_info', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('country', sa.String(2), nullable=True),
sa.Column('city', sa.String(100), nullable=True),
sa.Column('is_suspicious', sa.Boolean(), nullable=False, default=False),
sa.Column('security_notes', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
# Create indexes on login_history
op.create_index('idx_login_history_user_id', 'login_history', ['user_id'])
op.create_index('idx_login_history_user_time', 'login_history', ['user_id', 'login_at'])
op.create_index('idx_login_history_session', 'login_history', ['session_id'])
op.create_index('idx_login_history_jwt_jti', 'login_history', ['jwt_jti'])
op.create_index('idx_login_history_ip', 'login_history', ['ip_address'])
# Create user_sessions table
op.create_table('user_sessions',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False, default=uuid.uuid4),
sa.Column('session_id', sa.String(255), nullable=False),
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('access_token_jti', sa.String(255), nullable=True),
sa.Column('refresh_token_jti', sa.String(255), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('last_active_at', sa.DateTime(), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('ip_address', sa.String(45), nullable=False),
sa.Column('user_agent', sa.String(500), nullable=True),
sa.Column('data', postgresql.JSONB(astext_type=sa.Text()), nullable=False, default={}),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('session_id')
)
# Create indexes on user_sessions
op.create_index('idx_user_sessions_session_id', 'user_sessions', ['session_id'])
op.create_index('idx_user_sessions_user_id', 'user_sessions', ['user_id'])
op.create_index('idx_user_sessions_user_active', 'user_sessions', ['user_id', 'expires_at'])
op.create_index('idx_user_sessions_token', 'user_sessions', ['access_token_jti'])
# Create revoked_tokens table
op.create_table('revoked_tokens',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False, default=uuid.uuid4),
sa.Column('jti', sa.String(255), nullable=False),
sa.Column('token_type', sa.String(20), nullable=False),
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('revoked_at', sa.DateTime(), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('reason', sa.String(255), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('jti')
)
# Create indexes on revoked_tokens
op.create_index('idx_revoked_tokens_jti', 'revoked_tokens', ['jti'])
op.create_index('idx_revoked_tokens_user_id', 'revoked_tokens', ['user_id'])
op.create_index('idx_revoked_tokens_expires', 'revoked_tokens', ['expires_at'])
# Update translations table to add user_id with proper foreign key
# First, check if user_id column exists
try:
op.add_column('translations', sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=True))
op.create_foreign_key('fk_translations_user_id', 'translations', 'users', ['user_id'], ['id'], ondelete='SET NULL')
op.create_index('idx_translations_user_id', 'translations', ['user_id'])
except:
pass # Column might already exist
# Update transcriptions table to add user_id with proper foreign key
try:
op.add_column('transcriptions', sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=True))
op.create_foreign_key('fk_transcriptions_user_id', 'transcriptions', 'users', ['user_id'], ['id'], ondelete='SET NULL')
op.create_index('idx_transcriptions_user_id', 'transcriptions', ['user_id'])
except:
pass # Column might already exist
# Update user_preferences table to add proper foreign key if not exists
try:
op.create_foreign_key('fk_user_preferences_user_id', 'user_preferences', 'users', ['user_id'], ['id'], ondelete='CASCADE')
except:
pass # Foreign key might already exist
# Update api_keys table to add proper foreign key if not exists
try:
op.add_column('api_keys', sa.Column('user_id_new', postgresql.UUID(as_uuid=True), nullable=True))
op.create_foreign_key('fk_api_keys_user_id', 'api_keys', 'users', ['user_id_new'], ['id'], ondelete='CASCADE')
except:
pass # Column/FK might already exist
# Create function for updating updated_at timestamp
op.execute("""
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
""")
# Create trigger for users table
op.execute("""
CREATE TRIGGER update_users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
""")
def downgrade():
# Drop triggers
op.execute("DROP TRIGGER IF EXISTS update_users_updated_at ON users")
op.execute("DROP FUNCTION IF EXISTS update_updated_at_column()")
# Drop foreign keys
try:
op.drop_constraint('fk_translations_user_id', 'translations', type_='foreignkey')
op.drop_constraint('fk_transcriptions_user_id', 'transcriptions', type_='foreignkey')
op.drop_constraint('fk_user_preferences_user_id', 'user_preferences', type_='foreignkey')
op.drop_constraint('fk_api_keys_user_id', 'api_keys', type_='foreignkey')
except:
pass
# Drop columns
try:
op.drop_column('translations', 'user_id')
op.drop_column('transcriptions', 'user_id')
op.drop_column('api_keys', 'user_id_new')
except:
pass
# Drop tables
op.drop_table('revoked_tokens')
op.drop_table('user_sessions')
op.drop_table('login_history')
op.drop_table('users')