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>
This commit is contained in:
366
auth_models.py
Normal file
366
auth_models.py
Normal file
@@ -0,0 +1,366 @@
|
||||
"""Authentication models for Talk2Me application"""
|
||||
import uuid
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any, List
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_bcrypt import Bcrypt
|
||||
from sqlalchemy import Index, text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB, ENUM
|
||||
from sqlalchemy.ext.hybrid import hybrid_property
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from database import db
|
||||
|
||||
bcrypt = Bcrypt()
|
||||
|
||||
|
||||
class User(db.Model):
|
||||
"""User account model with authentication and authorization"""
|
||||
__tablename__ = 'users'
|
||||
|
||||
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
|
||||
username = db.Column(db.String(100), unique=True, nullable=False, index=True)
|
||||
password_hash = db.Column(db.String(255), nullable=False)
|
||||
|
||||
# User profile
|
||||
full_name = db.Column(db.String(255), nullable=True)
|
||||
avatar_url = db.Column(db.String(500), nullable=True)
|
||||
|
||||
# API Key - unique per user
|
||||
api_key = db.Column(db.String(64), unique=True, nullable=False, index=True)
|
||||
api_key_created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
# Account status
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
is_verified = db.Column(db.Boolean, default=False, nullable=False)
|
||||
is_suspended = db.Column(db.Boolean, default=False, nullable=False)
|
||||
suspension_reason = db.Column(db.Text, nullable=True)
|
||||
suspended_at = db.Column(db.DateTime, nullable=True)
|
||||
suspended_until = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Role and permissions
|
||||
role = db.Column(db.String(20), nullable=False, default='user') # admin, user
|
||||
permissions = db.Column(JSONB, default=[], nullable=False) # Additional granular permissions
|
||||
|
||||
# Usage limits (per user)
|
||||
rate_limit_per_minute = db.Column(db.Integer, default=30, nullable=False)
|
||||
rate_limit_per_hour = db.Column(db.Integer, default=500, nullable=False)
|
||||
rate_limit_per_day = db.Column(db.Integer, default=5000, nullable=False)
|
||||
|
||||
# Usage tracking
|
||||
total_requests = db.Column(db.Integer, default=0, nullable=False)
|
||||
total_translations = db.Column(db.Integer, default=0, nullable=False)
|
||||
total_transcriptions = db.Column(db.Integer, default=0, nullable=False)
|
||||
total_tts_requests = db.Column(db.Integer, default=0, nullable=False)
|
||||
|
||||
# Timestamps
|
||||
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)
|
||||
last_login_at = db.Column(db.DateTime, nullable=True)
|
||||
last_active_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Security
|
||||
password_changed_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
failed_login_attempts = db.Column(db.Integer, default=0, nullable=False)
|
||||
locked_until = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Settings
|
||||
settings = db.Column(JSONB, default={}, nullable=False)
|
||||
|
||||
# Relationships
|
||||
login_history = relationship('LoginHistory', back_populates='user', cascade='all, delete-orphan')
|
||||
sessions = relationship('UserSession', back_populates='user', cascade='all, delete-orphan')
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_users_email_active', 'email', 'is_active'),
|
||||
Index('idx_users_role_active', 'role', 'is_active'),
|
||||
Index('idx_users_created_at', 'created_at'),
|
||||
)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super(User, self).__init__(**kwargs)
|
||||
if not self.api_key:
|
||||
self.api_key = self.generate_api_key()
|
||||
|
||||
@staticmethod
|
||||
def generate_api_key() -> str:
|
||||
"""Generate a secure API key"""
|
||||
return f"tk_{secrets.token_urlsafe(32)}"
|
||||
|
||||
def regenerate_api_key(self) -> str:
|
||||
"""Regenerate user's API key"""
|
||||
self.api_key = self.generate_api_key()
|
||||
self.api_key_created_at = datetime.utcnow()
|
||||
return self.api_key
|
||||
|
||||
def set_password(self, password: str) -> None:
|
||||
"""Hash and set user password"""
|
||||
self.password_hash = bcrypt.generate_password_hash(password).decode('utf-8')
|
||||
self.password_changed_at = datetime.utcnow()
|
||||
|
||||
def check_password(self, password: str) -> bool:
|
||||
"""Check if provided password matches hash"""
|
||||
return bcrypt.check_password_hash(self.password_hash, password)
|
||||
|
||||
@hybrid_property
|
||||
def is_admin(self) -> bool:
|
||||
"""Check if user has admin role"""
|
||||
return self.role == 'admin'
|
||||
|
||||
@hybrid_property
|
||||
def is_locked(self) -> bool:
|
||||
"""Check if account is locked due to failed login attempts"""
|
||||
if self.locked_until is None:
|
||||
return False
|
||||
return datetime.utcnow() < self.locked_until
|
||||
|
||||
@hybrid_property
|
||||
def is_suspended_now(self) -> bool:
|
||||
"""Check if account is currently suspended"""
|
||||
if not self.is_suspended:
|
||||
return False
|
||||
if self.suspended_until is None:
|
||||
return True # Indefinite suspension
|
||||
return datetime.utcnow() < self.suspended_until
|
||||
|
||||
def can_login(self) -> tuple[bool, Optional[str]]:
|
||||
"""Check if user can login"""
|
||||
if not self.is_active:
|
||||
return False, "Account is deactivated"
|
||||
if self.is_locked:
|
||||
return False, "Account is locked due to failed login attempts"
|
||||
if self.is_suspended_now:
|
||||
return False, f"Account is suspended: {self.suspension_reason or 'Policy violation'}"
|
||||
return True, None
|
||||
|
||||
def record_login_attempt(self, success: bool) -> None:
|
||||
"""Record login attempt and handle lockout"""
|
||||
if success:
|
||||
self.failed_login_attempts = 0
|
||||
self.locked_until = None
|
||||
self.last_login_at = datetime.utcnow()
|
||||
else:
|
||||
self.failed_login_attempts += 1
|
||||
# Lock account after 5 failed attempts
|
||||
if self.failed_login_attempts >= 5:
|
||||
self.locked_until = datetime.utcnow() + timedelta(minutes=30)
|
||||
|
||||
def has_permission(self, permission: str) -> bool:
|
||||
"""Check if user has specific permission"""
|
||||
if self.is_admin:
|
||||
return True # Admins have all permissions
|
||||
return permission in (self.permissions or [])
|
||||
|
||||
def add_permission(self, permission: str) -> None:
|
||||
"""Add permission to user"""
|
||||
if self.permissions is None:
|
||||
self.permissions = []
|
||||
if permission not in self.permissions:
|
||||
self.permissions = self.permissions + [permission]
|
||||
|
||||
def remove_permission(self, permission: str) -> None:
|
||||
"""Remove permission from user"""
|
||||
if self.permissions and permission in self.permissions:
|
||||
self.permissions = [p for p in self.permissions if p != permission]
|
||||
|
||||
def suspend(self, reason: str, until: Optional[datetime] = None) -> None:
|
||||
"""Suspend user account"""
|
||||
self.is_suspended = True
|
||||
self.suspension_reason = reason
|
||||
self.suspended_at = datetime.utcnow()
|
||||
self.suspended_until = until
|
||||
|
||||
def unsuspend(self) -> None:
|
||||
"""Unsuspend user account"""
|
||||
self.is_suspended = False
|
||||
self.suspension_reason = None
|
||||
self.suspended_at = None
|
||||
self.suspended_until = None
|
||||
|
||||
def to_dict(self, include_sensitive: bool = False) -> Dict[str, Any]:
|
||||
"""Convert user to dictionary"""
|
||||
data = {
|
||||
'id': str(self.id),
|
||||
'email': self.email,
|
||||
'username': self.username,
|
||||
'full_name': self.full_name,
|
||||
'avatar_url': self.avatar_url,
|
||||
'role': self.role,
|
||||
'is_active': self.is_active,
|
||||
'is_verified': self.is_verified,
|
||||
'is_suspended': self.is_suspended_now,
|
||||
'created_at': self.created_at.isoformat(),
|
||||
'last_login_at': self.last_login_at.isoformat() if self.last_login_at else None,
|
||||
'last_active_at': self.last_active_at.isoformat() if self.last_active_at else None,
|
||||
'total_requests': self.total_requests,
|
||||
'total_translations': self.total_translations,
|
||||
'total_transcriptions': self.total_transcriptions,
|
||||
'total_tts_requests': self.total_tts_requests,
|
||||
'settings': self.settings or {}
|
||||
}
|
||||
|
||||
if include_sensitive:
|
||||
data.update({
|
||||
'api_key': self.api_key,
|
||||
'api_key_created_at': self.api_key_created_at.isoformat(),
|
||||
'permissions': self.permissions or [],
|
||||
'rate_limit_per_minute': self.rate_limit_per_minute,
|
||||
'rate_limit_per_hour': self.rate_limit_per_hour,
|
||||
'rate_limit_per_day': self.rate_limit_per_day,
|
||||
'suspension_reason': self.suspension_reason,
|
||||
'suspended_until': self.suspended_until.isoformat() if self.suspended_until else None,
|
||||
'failed_login_attempts': self.failed_login_attempts,
|
||||
'is_locked': self.is_locked
|
||||
})
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class LoginHistory(db.Model):
|
||||
"""Track user login history for security auditing"""
|
||||
__tablename__ = 'login_history'
|
||||
|
||||
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id = db.Column(UUID(as_uuid=True), db.ForeignKey('users.id'), nullable=False, index=True)
|
||||
|
||||
# Login details
|
||||
login_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
logout_at = db.Column(db.DateTime, nullable=True)
|
||||
login_method = db.Column(db.String(20), nullable=False) # password, api_key, jwt
|
||||
success = db.Column(db.Boolean, nullable=False)
|
||||
failure_reason = db.Column(db.String(255), nullable=True)
|
||||
|
||||
# Session info
|
||||
session_id = db.Column(db.String(255), nullable=True, index=True)
|
||||
jwt_jti = db.Column(db.String(255), nullable=True, index=True) # JWT ID for revocation
|
||||
|
||||
# Client info
|
||||
ip_address = db.Column(db.String(45), nullable=False)
|
||||
user_agent = db.Column(db.String(500), nullable=True)
|
||||
device_info = db.Column(JSONB, nullable=True) # Parsed user agent info
|
||||
|
||||
# Location info (if available)
|
||||
country = db.Column(db.String(2), nullable=True)
|
||||
city = db.Column(db.String(100), nullable=True)
|
||||
|
||||
# Security flags
|
||||
is_suspicious = db.Column(db.Boolean, default=False, nullable=False)
|
||||
security_notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Relationship
|
||||
user = relationship('User', back_populates='login_history')
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_login_history_user_time', 'user_id', 'login_at'),
|
||||
Index('idx_login_history_session', 'session_id'),
|
||||
Index('idx_login_history_ip', 'ip_address'),
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert login history to dictionary"""
|
||||
return {
|
||||
'id': str(self.id),
|
||||
'user_id': str(self.user_id),
|
||||
'login_at': self.login_at.isoformat(),
|
||||
'logout_at': self.logout_at.isoformat() if self.logout_at else None,
|
||||
'login_method': self.login_method,
|
||||
'success': self.success,
|
||||
'failure_reason': self.failure_reason,
|
||||
'session_id': self.session_id,
|
||||
'ip_address': self.ip_address,
|
||||
'user_agent': self.user_agent,
|
||||
'device_info': self.device_info,
|
||||
'country': self.country,
|
||||
'city': self.city,
|
||||
'is_suspicious': self.is_suspicious,
|
||||
'security_notes': self.security_notes
|
||||
}
|
||||
|
||||
|
||||
class UserSession(db.Model):
|
||||
"""Active user sessions for session management"""
|
||||
__tablename__ = 'user_sessions'
|
||||
|
||||
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
session_id = db.Column(db.String(255), unique=True, nullable=False, index=True)
|
||||
user_id = db.Column(UUID(as_uuid=True), db.ForeignKey('users.id'), nullable=False, index=True)
|
||||
|
||||
# JWT tokens
|
||||
access_token_jti = db.Column(db.String(255), nullable=True, index=True)
|
||||
refresh_token_jti = db.Column(db.String(255), nullable=True, index=True)
|
||||
|
||||
# Session info
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
last_active_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
expires_at = db.Column(db.DateTime, nullable=False)
|
||||
|
||||
# Client info
|
||||
ip_address = db.Column(db.String(45), nullable=False)
|
||||
user_agent = db.Column(db.String(500), nullable=True)
|
||||
|
||||
# Session data
|
||||
data = db.Column(JSONB, default={}, nullable=False)
|
||||
|
||||
# Relationship
|
||||
user = relationship('User', back_populates='sessions')
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_user_sessions_user_active', 'user_id', 'expires_at'),
|
||||
Index('idx_user_sessions_token', 'access_token_jti'),
|
||||
)
|
||||
|
||||
@hybrid_property
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if session is expired"""
|
||||
return datetime.utcnow() > self.expires_at
|
||||
|
||||
def refresh(self, duration_hours: int = 24) -> None:
|
||||
"""Refresh session expiration"""
|
||||
self.last_active_at = datetime.utcnow()
|
||||
self.expires_at = datetime.utcnow() + timedelta(hours=duration_hours)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert session to dictionary"""
|
||||
return {
|
||||
'id': str(self.id),
|
||||
'session_id': self.session_id,
|
||||
'user_id': str(self.user_id),
|
||||
'created_at': self.created_at.isoformat(),
|
||||
'last_active_at': self.last_active_at.isoformat(),
|
||||
'expires_at': self.expires_at.isoformat(),
|
||||
'is_expired': self.is_expired,
|
||||
'ip_address': self.ip_address,
|
||||
'user_agent': self.user_agent,
|
||||
'data': self.data or {}
|
||||
}
|
||||
|
||||
|
||||
class RevokedToken(db.Model):
|
||||
"""Store revoked JWT tokens for blacklisting"""
|
||||
__tablename__ = 'revoked_tokens'
|
||||
|
||||
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
jti = db.Column(db.String(255), unique=True, nullable=False, index=True)
|
||||
token_type = db.Column(db.String(20), nullable=False) # access, refresh
|
||||
user_id = db.Column(UUID(as_uuid=True), nullable=True, index=True)
|
||||
revoked_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
expires_at = db.Column(db.DateTime, nullable=False) # When token would have expired
|
||||
reason = db.Column(db.String(255), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_revoked_tokens_expires', 'expires_at'),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def is_token_revoked(cls, jti: str) -> bool:
|
||||
"""Check if a token JTI is revoked"""
|
||||
return cls.query.filter_by(jti=jti).first() is not None
|
||||
|
||||
@classmethod
|
||||
def cleanup_expired(cls) -> int:
|
||||
"""Remove revoked tokens that have expired anyway"""
|
||||
deleted = cls.query.filter(cls.expires_at < datetime.utcnow()).delete()
|
||||
db.session.commit()
|
||||
return deleted
|
||||
Reference in New Issue
Block a user