first commit
This commit is contained in:
3
app/__init__.py
Normal file
3
app/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""
|
||||
App package - Main application module
|
||||
"""
|
||||
47
app/core/__init__.py
Normal file
47
app/core/__init__.py
Normal file
@ -0,0 +1,47 @@
|
||||
"""
|
||||
Core Module
|
||||
|
||||
Provides core functionality for the application:
|
||||
- database: Database configuration and session management
|
||||
- security: Password hashing, JWT tokens, and security utilities
|
||||
- logging_config: Structured logging configuration
|
||||
|
||||
Usage:
|
||||
from app.core.database import db
|
||||
from app.core.security import hash_password, verify_password
|
||||
from app.core.logging_config import get_logger
|
||||
"""
|
||||
|
||||
from app.core.database import db, init_db, get_db_session
|
||||
from app.core.security import (
|
||||
hash_password,
|
||||
verify_password,
|
||||
create_jwt_token,
|
||||
decode_jwt_token,
|
||||
create_id_token,
|
||||
generate_secure_token,
|
||||
generate_client_secret,
|
||||
validate_password_strength
|
||||
)
|
||||
from app.core.logging_config import setup_logging, get_logger
|
||||
|
||||
__all__ = [
|
||||
# Database
|
||||
'db',
|
||||
'init_db',
|
||||
'get_db_session',
|
||||
|
||||
# Security
|
||||
'hash_password',
|
||||
'verify_password',
|
||||
'create_jwt_token',
|
||||
'decode_jwt_token',
|
||||
'create_id_token',
|
||||
'generate_secure_token',
|
||||
'generate_client_secret',
|
||||
'validate_password_strength',
|
||||
|
||||
# Logging
|
||||
'setup_logging',
|
||||
'get_logger',
|
||||
]
|
||||
56
app/core/database.py
Normal file
56
app/core/database.py
Normal file
@ -0,0 +1,56 @@
|
||||
"""
|
||||
Database Configuration and Session Management
|
||||
|
||||
Provides database initialization, session management, and base models
|
||||
following the Python Quick Start Guide best practices.
|
||||
"""
|
||||
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from typing import Generator
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
# Database instance
|
||||
db = SQLAlchemy()
|
||||
|
||||
|
||||
def init_db(app) -> None:
|
||||
"""
|
||||
Initialize database with Flask app.
|
||||
|
||||
Args:
|
||||
app: Flask application instance
|
||||
"""
|
||||
db.init_app(app)
|
||||
|
||||
|
||||
def get_db_session() -> Session:
|
||||
"""
|
||||
Get current database session.
|
||||
|
||||
Returns:
|
||||
SQLAlchemy session instance
|
||||
|
||||
Note:
|
||||
This is a Flask-SQLAlchemy session, managed automatically.
|
||||
Use db.session throughout the application.
|
||||
"""
|
||||
return db.session
|
||||
|
||||
|
||||
# For FastAPI-style dependency injection (if migrating to FastAPI later)
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
"""
|
||||
Get database session for dependency injection.
|
||||
|
||||
Yields:
|
||||
Database session
|
||||
|
||||
Usage:
|
||||
def some_function(db: Session = Depends(get_db)):
|
||||
# Use db session
|
||||
"""
|
||||
try:
|
||||
yield db.session
|
||||
finally:
|
||||
# Flask-SQLAlchemy handles cleanup automatically
|
||||
pass
|
||||
255
app/core/logging_config.py
Normal file
255
app/core/logging_config.py
Normal file
@ -0,0 +1,255 @@
|
||||
"""
|
||||
Logging Configuration
|
||||
|
||||
Provides structured logging setup for the application.
|
||||
Based on Python Quick Start Guide best practices.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def setup_logging(
|
||||
log_level: str = "INFO",
|
||||
environment: str = "development"
|
||||
) -> None:
|
||||
"""
|
||||
Configure application logging.
|
||||
|
||||
Args:
|
||||
log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
environment: Environment name (development, production)
|
||||
|
||||
Usage:
|
||||
setup_logging(log_level="INFO", environment="production")
|
||||
"""
|
||||
level = getattr(logging, log_level.upper(), logging.INFO)
|
||||
|
||||
# Clear existing handlers
|
||||
root_logger = logging.getLogger()
|
||||
for handler in root_logger.handlers[:]:
|
||||
root_logger.removeHandler(handler)
|
||||
|
||||
# Console handler
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(level)
|
||||
|
||||
# Format based on environment
|
||||
if environment == "development":
|
||||
# Human-readable format for development
|
||||
formatter = logging.Formatter(
|
||||
fmt='%(asctime)s | %(levelname)-8s | %(name)s:%(funcName)s:%(lineno)d | %(message)s',
|
||||
datefmt='%H:%M:%S'
|
||||
)
|
||||
else:
|
||||
# Structured format for production (easier to parse)
|
||||
formatter = logging.Formatter(
|
||||
fmt='%(asctime)s | %(levelname)s | %(name)s | %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
|
||||
console_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(console_handler)
|
||||
root_logger.setLevel(level)
|
||||
|
||||
# Suppress noisy loggers
|
||||
logging.getLogger('werkzeug').setLevel(logging.WARNING)
|
||||
logging.getLogger('urllib3').setLevel(logging.WARNING)
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""
|
||||
Get a logger instance.
|
||||
|
||||
Args:
|
||||
name: Logger name (typically __name__)
|
||||
|
||||
Returns:
|
||||
Logger instance
|
||||
|
||||
Example:
|
||||
logger = get_logger(__name__)
|
||||
logger.info("User logged in", extra={"user_id": 123})
|
||||
"""
|
||||
return logging.getLogger(name)
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Structured Logging Helpers
|
||||
# ==========================================
|
||||
|
||||
def log_audit_event(
|
||||
logger: logging.Logger,
|
||||
action: str,
|
||||
user_id: Optional[int] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> None:
|
||||
"""
|
||||
Log an audit event with structured data.
|
||||
|
||||
Args:
|
||||
logger: Logger instance
|
||||
action: Action performed (e.g., "login_success", "user_created")
|
||||
user_id: User ID performing action
|
||||
ip_address: IP address of request
|
||||
**kwargs: Additional context data
|
||||
|
||||
Example:
|
||||
log_audit_event(
|
||||
logger,
|
||||
action="login_success",
|
||||
user_id=123,
|
||||
ip_address="192.168.1.1",
|
||||
username="admin"
|
||||
)
|
||||
"""
|
||||
extra_data = {
|
||||
'action': action,
|
||||
'user_id': user_id,
|
||||
'ip_address': ip_address,
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
**kwargs
|
||||
}
|
||||
|
||||
# Filter out None values
|
||||
extra_data = {k: v for k, v in extra_data.items() if v is not None}
|
||||
|
||||
logger.info(f"AUDIT: {action}", extra=extra_data)
|
||||
|
||||
|
||||
def log_security_event(
|
||||
logger: logging.Logger,
|
||||
event_type: str,
|
||||
severity: str = "warning",
|
||||
**kwargs
|
||||
) -> None:
|
||||
"""
|
||||
Log a security-related event.
|
||||
|
||||
Args:
|
||||
logger: Logger instance
|
||||
event_type: Type of security event (e.g., "failed_login", "rate_limit_exceeded")
|
||||
severity: Severity level (debug, info, warning, error, critical)
|
||||
**kwargs: Additional context data
|
||||
|
||||
Example:
|
||||
log_security_event(
|
||||
logger,
|
||||
event_type="failed_login",
|
||||
severity="warning",
|
||||
username="admin",
|
||||
ip_address="192.168.1.1",
|
||||
attempts=3
|
||||
)
|
||||
"""
|
||||
extra_data = {
|
||||
'event_type': event_type,
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
**kwargs
|
||||
}
|
||||
|
||||
log_method = getattr(logger, severity.lower(), logger.warning)
|
||||
log_method(f"SECURITY: {event_type}", extra=extra_data)
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Request Logging Helpers
|
||||
# ==========================================
|
||||
|
||||
def log_request(
|
||||
logger: logging.Logger,
|
||||
method: str,
|
||||
path: str,
|
||||
status_code: int,
|
||||
duration_ms: float,
|
||||
user_id: Optional[int] = None
|
||||
) -> None:
|
||||
"""
|
||||
Log an HTTP request.
|
||||
|
||||
Args:
|
||||
logger: Logger instance
|
||||
method: HTTP method (GET, POST, etc.)
|
||||
path: Request path
|
||||
status_code: HTTP status code
|
||||
duration_ms: Request duration in milliseconds
|
||||
user_id: Authenticated user ID (if any)
|
||||
|
||||
Example:
|
||||
log_request(
|
||||
logger,
|
||||
method="POST",
|
||||
path="/api/users",
|
||||
status_code=201,
|
||||
duration_ms=45.2,
|
||||
user_id=123
|
||||
)
|
||||
"""
|
||||
extra_data = {
|
||||
'method': method,
|
||||
'path': path,
|
||||
'status_code': status_code,
|
||||
'duration_ms': round(duration_ms, 2),
|
||||
'user_id': user_id
|
||||
}
|
||||
|
||||
# Filter out None values
|
||||
extra_data = {k: v for k, v in extra_data.items() if v is not None}
|
||||
|
||||
# Use different log levels based on status code
|
||||
if status_code >= 500:
|
||||
logger.error(f"{method} {path} {status_code}", extra=extra_data)
|
||||
elif status_code >= 400:
|
||||
logger.warning(f"{method} {path} {status_code}", extra=extra_data)
|
||||
else:
|
||||
logger.info(f"{method} {path} {status_code}", extra=extra_data)
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Error Logging Helpers
|
||||
# ==========================================
|
||||
|
||||
def log_exception(
|
||||
logger: logging.Logger,
|
||||
error: Exception,
|
||||
context: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> None:
|
||||
"""
|
||||
Log an exception with context.
|
||||
|
||||
Args:
|
||||
logger: Logger instance
|
||||
error: Exception instance
|
||||
context: Additional context about where error occurred
|
||||
**kwargs: Additional context data
|
||||
|
||||
Example:
|
||||
try:
|
||||
# Some operation
|
||||
except Exception as e:
|
||||
log_exception(
|
||||
logger,
|
||||
error=e,
|
||||
context="Failed to create user",
|
||||
user_id=123
|
||||
)
|
||||
"""
|
||||
extra_data = {
|
||||
'error_type': type(error).__name__,
|
||||
'error_message': str(error),
|
||||
'context': context,
|
||||
**kwargs
|
||||
}
|
||||
|
||||
# Filter out None values
|
||||
extra_data = {k: v for k, v in extra_data.items() if v is not None}
|
||||
|
||||
logger.error(
|
||||
f"Exception: {type(error).__name__}: {str(error)}",
|
||||
extra=extra_data,
|
||||
exc_info=True
|
||||
)
|
||||
237
app/core/security.py
Normal file
237
app/core/security.py
Normal file
@ -0,0 +1,237 @@
|
||||
"""
|
||||
Security Utilities
|
||||
|
||||
Provides password hashing, JWT token management, and other security functions
|
||||
following the Python Quick Start Guide best practices.
|
||||
"""
|
||||
|
||||
import bcrypt
|
||||
import jwt
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Optional, Any
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Password Hashing (bcrypt)
|
||||
# ==========================================
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""
|
||||
Hash a password using bcrypt.
|
||||
|
||||
Args:
|
||||
password: Plain text password
|
||||
|
||||
Returns:
|
||||
Hashed password as string
|
||||
|
||||
Example:
|
||||
hashed = hash_password("mypassword123")
|
||||
"""
|
||||
password_bytes = password.encode('utf-8')
|
||||
salt = bcrypt.gensalt()
|
||||
hashed = bcrypt.hashpw(password_bytes, salt)
|
||||
return hashed.decode('utf-8')
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""
|
||||
Verify a password against a hash.
|
||||
|
||||
Args:
|
||||
plain_password: Plain text password to check
|
||||
hashed_password: Hashed password to compare against
|
||||
|
||||
Returns:
|
||||
True if password matches, False otherwise
|
||||
|
||||
Example:
|
||||
if verify_password("mypassword123", user.password_hash):
|
||||
# Password is correct
|
||||
"""
|
||||
password_bytes = plain_password.encode('utf-8')
|
||||
hash_bytes = hashed_password.encode('utf-8')
|
||||
return bcrypt.checkpw(password_bytes, hash_bytes)
|
||||
|
||||
|
||||
# ==========================================
|
||||
# JWT Token Management
|
||||
# ==========================================
|
||||
|
||||
def create_jwt_token(
|
||||
payload: Dict[str, Any],
|
||||
secret_key: str,
|
||||
algorithm: str = 'HS256',
|
||||
expires_in: int = 3600
|
||||
) -> str:
|
||||
"""
|
||||
Create a JWT token with expiration.
|
||||
|
||||
Args:
|
||||
payload: Token payload data
|
||||
secret_key: Secret key for signing
|
||||
algorithm: JWT algorithm (HS256, RS256, etc.)
|
||||
expires_in: Token lifetime in seconds
|
||||
|
||||
Returns:
|
||||
Encoded JWT token string
|
||||
|
||||
Example:
|
||||
token = create_jwt_token(
|
||||
payload={'user_id': 123, 'role': 'admin'},
|
||||
secret_key=app.config['SECRET_KEY'],
|
||||
expires_in=3600
|
||||
)
|
||||
"""
|
||||
payload = payload.copy()
|
||||
expire = datetime.utcnow() + timedelta(seconds=expires_in)
|
||||
payload.update({'exp': expire, 'iat': datetime.utcnow()})
|
||||
|
||||
return jwt.encode(payload, secret_key, algorithm=algorithm)
|
||||
|
||||
|
||||
def decode_jwt_token(
|
||||
token: str,
|
||||
secret_key: str,
|
||||
algorithm: str = 'HS256'
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Decode and verify a JWT token.
|
||||
|
||||
Args:
|
||||
token: JWT token string
|
||||
secret_key: Secret key for verification
|
||||
algorithm: JWT algorithm used
|
||||
|
||||
Returns:
|
||||
Decoded payload dict, or None if invalid
|
||||
|
||||
Example:
|
||||
payload = decode_jwt_token(token, app.config['SECRET_KEY'])
|
||||
if payload:
|
||||
user_id = payload['user_id']
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(token, secret_key, algorithms=[algorithm])
|
||||
return payload
|
||||
except jwt.ExpiredSignatureError:
|
||||
# Token has expired
|
||||
return None
|
||||
except jwt.InvalidTokenError:
|
||||
# Token is invalid
|
||||
return None
|
||||
|
||||
|
||||
def create_id_token(
|
||||
user_data: Dict[str, Any],
|
||||
client_id: str,
|
||||
issuer: str,
|
||||
private_key: str,
|
||||
algorithm: str = 'RS256',
|
||||
expires_in: int = 3600
|
||||
) -> str:
|
||||
"""
|
||||
Create an OIDC ID Token (JWT).
|
||||
|
||||
Args:
|
||||
user_data: User information (sub, email, name, etc.)
|
||||
client_id: OAuth client ID (aud claim)
|
||||
issuer: OIDC issuer URL (iss claim)
|
||||
private_key: Private key for RS256 signing
|
||||
algorithm: JWT algorithm (should be RS256 for OIDC)
|
||||
expires_in: Token lifetime in seconds
|
||||
|
||||
Returns:
|
||||
Encoded ID token string
|
||||
|
||||
Example:
|
||||
id_token = create_id_token(
|
||||
user_data={'sub': 'user-123', 'email': 'user@example.com'},
|
||||
client_id='my-app',
|
||||
issuer='https://auth.example.com',
|
||||
private_key=app.config['OIDC_JWT_PRIVATE_KEY']
|
||||
)
|
||||
"""
|
||||
now = datetime.utcnow()
|
||||
payload = {
|
||||
'iss': issuer,
|
||||
'sub': user_data.get('sub'),
|
||||
'aud': client_id,
|
||||
'exp': now + timedelta(seconds=expires_in),
|
||||
'iat': now,
|
||||
**user_data # Include all user claims
|
||||
}
|
||||
|
||||
return jwt.encode(payload, private_key, algorithm=algorithm)
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Token Generation
|
||||
# ==========================================
|
||||
|
||||
def generate_secure_token(length: int = 32) -> str:
|
||||
"""
|
||||
Generate a cryptographically secure random token.
|
||||
|
||||
Args:
|
||||
length: Token length in bytes (default 32)
|
||||
|
||||
Returns:
|
||||
URL-safe token string
|
||||
|
||||
Example:
|
||||
auth_code = generate_secure_token(32)
|
||||
access_token = generate_secure_token(64)
|
||||
"""
|
||||
return secrets.token_urlsafe(length)
|
||||
|
||||
|
||||
def generate_client_secret() -> str:
|
||||
"""
|
||||
Generate a secure client secret for OIDC clients.
|
||||
|
||||
Returns:
|
||||
URL-safe client secret string
|
||||
|
||||
Example:
|
||||
client_secret = generate_client_secret()
|
||||
"""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Password Validation
|
||||
# ==========================================
|
||||
|
||||
def validate_password_strength(password: str) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate password strength.
|
||||
|
||||
Args:
|
||||
password: Password to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
|
||||
Rules:
|
||||
- Minimum 8 characters
|
||||
- At least one digit (optional but recommended)
|
||||
|
||||
Example:
|
||||
is_valid, error = validate_password_strength("password123")
|
||||
if not is_valid:
|
||||
raise ValueError(error)
|
||||
"""
|
||||
if len(password) < 8:
|
||||
return False, "Password must be at least 8 characters long"
|
||||
|
||||
# Optional: Check for digit
|
||||
# if not any(char.isdigit() for char in password):
|
||||
# return False, "Password must contain at least one digit"
|
||||
|
||||
# Optional: Check for uppercase
|
||||
# if not any(char.isupper() for char in password):
|
||||
# return False, "Password must contain at least one uppercase letter"
|
||||
|
||||
return True, None
|
||||
11
app/repositories/__init__.py
Normal file
11
app/repositories/__init__.py
Normal file
@ -0,0 +1,11 @@
|
||||
"""
|
||||
Repositories package - Data Access Layer
|
||||
|
||||
Repositories handle ALL database operations. They provide a clean
|
||||
interface for services to work with data without knowing SQL/ORM details.
|
||||
"""
|
||||
from app.repositories.user_repository import UserRepository
|
||||
from app.repositories.client_repository import ClientRepository
|
||||
from app.repositories.token_repository import TokenRepository
|
||||
|
||||
__all__ = ['UserRepository', 'ClientRepository', 'TokenRepository']
|
||||
79
app/repositories/client_repository.py
Normal file
79
app/repositories/client_repository.py
Normal file
@ -0,0 +1,79 @@
|
||||
"""
|
||||
Client Repository - Data Access Layer for Client operations
|
||||
|
||||
Following Python Quick Start Guide:
|
||||
- Repository layer contains ONLY database operations
|
||||
- No business logic (that goes in services)
|
||||
- Simple CRUD operations and queries
|
||||
"""
|
||||
from typing import Optional, List
|
||||
from app.core.database import db
|
||||
from models import Client
|
||||
|
||||
|
||||
class ClientRepository:
|
||||
"""
|
||||
Client repository - handles all Client database operations.
|
||||
|
||||
Responsibilities:
|
||||
- CRUD operations
|
||||
- Database queries
|
||||
- No business logic
|
||||
"""
|
||||
|
||||
def __init__(self, db_session=None):
|
||||
"""Initialize repository with database session."""
|
||||
self.db = db_session or db.session
|
||||
|
||||
def find_by_id(self, client_id_pk: int) -> Optional[Client]:
|
||||
"""Find client by primary key ID."""
|
||||
return Client.query.get(client_id_pk)
|
||||
|
||||
def find_by_client_id(self, client_id: str) -> Optional[Client]:
|
||||
"""Find client by client_id (OIDC identifier)."""
|
||||
return Client.query.filter_by(client_id=client_id).first()
|
||||
|
||||
def find_all(self) -> List[Client]:
|
||||
"""Find all clients."""
|
||||
return Client.query.all()
|
||||
|
||||
def create(self, client: Client) -> Client:
|
||||
"""
|
||||
Create a new client.
|
||||
|
||||
Args:
|
||||
client: Client object to create
|
||||
|
||||
Returns:
|
||||
Created client with ID
|
||||
"""
|
||||
self.db.add(client)
|
||||
self.db.commit()
|
||||
return client
|
||||
|
||||
def update(self, client: Client) -> Client:
|
||||
"""
|
||||
Update an existing client.
|
||||
|
||||
Args:
|
||||
client: Client object with updated fields
|
||||
|
||||
Returns:
|
||||
Updated client
|
||||
"""
|
||||
self.db.commit()
|
||||
return client
|
||||
|
||||
def delete(self, client: Client) -> None:
|
||||
"""
|
||||
Delete a client.
|
||||
|
||||
Args:
|
||||
client: Client object to delete
|
||||
"""
|
||||
self.db.delete(client)
|
||||
self.db.commit()
|
||||
|
||||
def rollback(self) -> None:
|
||||
"""Rollback current transaction."""
|
||||
self.db.rollback()
|
||||
171
app/repositories/token_repository.py
Normal file
171
app/repositories/token_repository.py
Normal file
@ -0,0 +1,171 @@
|
||||
"""
|
||||
Token Repository - Data Access Layer for Token operations
|
||||
|
||||
Following Python Quick Start Guide:
|
||||
- Repository layer contains ONLY database operations
|
||||
- No business logic (that goes in services)
|
||||
- Simple CRUD operations and queries
|
||||
"""
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
from sqlalchemy import func
|
||||
from app.core.database import db
|
||||
from models import AuthorizationCode, AccessToken, User, Client
|
||||
|
||||
|
||||
class TokenRepository:
|
||||
"""
|
||||
Token repository - handles all token database operations.
|
||||
|
||||
Responsibilities:
|
||||
- CRUD operations for authorization codes and access tokens
|
||||
- Database queries
|
||||
- No business logic
|
||||
"""
|
||||
|
||||
def __init__(self, db_session=None):
|
||||
"""Initialize repository with database session."""
|
||||
self.db = db_session or db.session
|
||||
|
||||
# Authorization Code operations
|
||||
def find_auth_code_by_code(self, code: str) -> Optional[AuthorizationCode]:
|
||||
"""Find authorization code by code value."""
|
||||
return AuthorizationCode.query.filter_by(code=code).first()
|
||||
|
||||
def create_auth_code(self, auth_code: AuthorizationCode) -> AuthorizationCode:
|
||||
"""
|
||||
Create a new authorization code.
|
||||
|
||||
Args:
|
||||
auth_code: AuthorizationCode object to create
|
||||
|
||||
Returns:
|
||||
Created authorization code
|
||||
"""
|
||||
self.db.add(auth_code)
|
||||
self.db.commit()
|
||||
return auth_code
|
||||
|
||||
def update_auth_code(self, auth_code: AuthorizationCode) -> AuthorizationCode:
|
||||
"""
|
||||
Update an existing authorization code.
|
||||
|
||||
Args:
|
||||
auth_code: AuthorizationCode object with updated fields
|
||||
|
||||
Returns:
|
||||
Updated authorization code
|
||||
"""
|
||||
self.db.commit()
|
||||
return auth_code
|
||||
|
||||
# Access Token operations
|
||||
def find_access_token_by_token(self, token: str) -> Optional[AccessToken]:
|
||||
"""Find access token by token value."""
|
||||
return AccessToken.query.filter_by(token=token).first()
|
||||
|
||||
def create_access_token(self, access_token: AccessToken) -> AccessToken:
|
||||
"""
|
||||
Create a new access token.
|
||||
|
||||
Args:
|
||||
access_token: AccessToken object to create
|
||||
|
||||
Returns:
|
||||
Created access token
|
||||
"""
|
||||
self.db.add(access_token)
|
||||
self.db.commit()
|
||||
return access_token
|
||||
|
||||
def update_access_token(self, access_token: AccessToken) -> AccessToken:
|
||||
"""
|
||||
Update an existing access token.
|
||||
|
||||
Args:
|
||||
access_token: AccessToken object with updated fields
|
||||
|
||||
Returns:
|
||||
Updated access token
|
||||
"""
|
||||
self.db.commit()
|
||||
return access_token
|
||||
|
||||
def rollback(self) -> None:
|
||||
"""Rollback current transaction."""
|
||||
self.db.rollback()
|
||||
|
||||
# Analytics operations
|
||||
def get_active_tokens_by_client(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all active (non-expired, non-revoked) tokens grouped by client.
|
||||
|
||||
Returns:
|
||||
List of dicts with client_id, client_name, user_id, username, email, created_at
|
||||
"""
|
||||
now = datetime.utcnow()
|
||||
|
||||
query = (
|
||||
self.db.query(
|
||||
AccessToken.client_id,
|
||||
Client.client_name,
|
||||
AccessToken.user_id,
|
||||
User.username,
|
||||
User.email,
|
||||
AccessToken.created_at,
|
||||
AccessToken.expires_at
|
||||
)
|
||||
.join(User, AccessToken.user_id == User.id)
|
||||
.outerjoin(Client, AccessToken.client_id == Client.client_id)
|
||||
.filter(AccessToken.revoked == False)
|
||||
.filter(AccessToken.expires_at > now)
|
||||
.order_by(Client.client_name, User.username)
|
||||
)
|
||||
|
||||
results = []
|
||||
for row in query.all():
|
||||
results.append({
|
||||
'client_id': row.client_id,
|
||||
'client_name': row.client_name or 'Unknown Client',
|
||||
'user_id': row.user_id,
|
||||
'username': row.username,
|
||||
'email': row.email,
|
||||
'created_at': row.created_at,
|
||||
'expires_at': row.expires_at
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
def get_active_sessions_summary(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get summary of active sessions grouped by client.
|
||||
|
||||
Returns:
|
||||
List of dicts with client_id, client_name, active_users_count, total_tokens
|
||||
"""
|
||||
now = datetime.utcnow()
|
||||
|
||||
query = (
|
||||
self.db.query(
|
||||
AccessToken.client_id,
|
||||
Client.client_name,
|
||||
func.count(func.distinct(AccessToken.user_id)).label('active_users'),
|
||||
func.count(AccessToken.id).label('total_tokens')
|
||||
)
|
||||
.outerjoin(Client, AccessToken.client_id == Client.client_id)
|
||||
.filter(AccessToken.revoked == False)
|
||||
.filter(AccessToken.expires_at > now)
|
||||
.group_by(AccessToken.client_id, Client.client_name)
|
||||
.order_by(func.count(func.distinct(AccessToken.user_id)).desc())
|
||||
)
|
||||
|
||||
results = []
|
||||
for row in query.all():
|
||||
results.append({
|
||||
'client_id': row.client_id or 'unknown',
|
||||
'client_name': row.client_name or 'Unknown Client',
|
||||
'active_users_count': row.active_users,
|
||||
'total_tokens': row.total_tokens
|
||||
})
|
||||
|
||||
return results
|
||||
108
app/repositories/user_repository.py
Normal file
108
app/repositories/user_repository.py
Normal file
@ -0,0 +1,108 @@
|
||||
"""
|
||||
User Repository - Data Access Layer for User operations
|
||||
|
||||
Following Python Quick Start Guide:
|
||||
- Repository layer contains ONLY database operations
|
||||
- No business logic (that goes in services)
|
||||
- Simple CRUD operations and queries
|
||||
"""
|
||||
from typing import Optional, List, Dict, Any
|
||||
from app.core.database import db
|
||||
from models import User
|
||||
|
||||
|
||||
class UserRepository:
|
||||
"""
|
||||
User repository - handles all User database operations.
|
||||
|
||||
Responsibilities:
|
||||
- CRUD operations
|
||||
- Database queries
|
||||
- No business logic
|
||||
"""
|
||||
|
||||
def __init__(self, db_session=None):
|
||||
"""Initialize repository with database session."""
|
||||
self.db = db_session or db.session
|
||||
|
||||
def find_by_id(self, user_id: int) -> Optional[User]:
|
||||
"""Find user by ID."""
|
||||
return User.query.get(user_id)
|
||||
|
||||
def find_by_username(self, username: str) -> Optional[User]:
|
||||
"""Find user by username."""
|
||||
return User.query.filter_by(username=username).first()
|
||||
|
||||
def find_by_email(self, email: str) -> Optional[User]:
|
||||
"""Find user by email."""
|
||||
return User.query.filter_by(email=email).first()
|
||||
|
||||
def find_all(self, page: int = 1, per_page: int = 50) -> Any:
|
||||
"""
|
||||
Find all users with pagination.
|
||||
|
||||
Returns:
|
||||
Pagination object with users
|
||||
"""
|
||||
return User.query.order_by(User.id.desc()).paginate(
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
error_out=False
|
||||
)
|
||||
|
||||
def count_all(self) -> int:
|
||||
"""Count total users."""
|
||||
return User.query.count()
|
||||
|
||||
def count_active(self) -> int:
|
||||
"""Count active users."""
|
||||
return User.query.filter_by(is_active=True).count()
|
||||
|
||||
def count_inactive(self) -> int:
|
||||
"""Count inactive users."""
|
||||
return User.query.filter_by(is_active=False).count()
|
||||
|
||||
def count_admins(self) -> int:
|
||||
"""Count admin users."""
|
||||
return User.query.filter_by(is_admin=True).count()
|
||||
|
||||
def create(self, user: User) -> User:
|
||||
"""
|
||||
Create a new user.
|
||||
|
||||
Args:
|
||||
user: User object to create
|
||||
|
||||
Returns:
|
||||
Created user with ID
|
||||
"""
|
||||
self.db.add(user)
|
||||
self.db.commit()
|
||||
return user
|
||||
|
||||
def update(self, user: User) -> User:
|
||||
"""
|
||||
Update an existing user.
|
||||
|
||||
Args:
|
||||
user: User object with updated fields
|
||||
|
||||
Returns:
|
||||
Updated user
|
||||
"""
|
||||
self.db.commit()
|
||||
return user
|
||||
|
||||
def delete(self, user: User) -> None:
|
||||
"""
|
||||
Delete a user.
|
||||
|
||||
Args:
|
||||
user: User object to delete
|
||||
"""
|
||||
self.db.delete(user)
|
||||
self.db.commit()
|
||||
|
||||
def rollback(self) -> None:
|
||||
"""Rollback current transaction."""
|
||||
self.db.rollback()
|
||||
75
app/schemas/__init__.py
Normal file
75
app/schemas/__init__.py
Normal file
@ -0,0 +1,75 @@
|
||||
"""
|
||||
Schemas Module
|
||||
|
||||
Provides Pydantic schemas for request validation and response serialization.
|
||||
|
||||
Usage:
|
||||
from app.schemas.user import UserCreate, UserResponse
|
||||
from app.schemas.client import ClientCreate, ClientResponse
|
||||
from app.schemas.auth import TokenRequest, TokenResponse
|
||||
"""
|
||||
|
||||
from app.schemas.user import (
|
||||
UserBase,
|
||||
UserCreate,
|
||||
UserUpdate,
|
||||
UserLogin,
|
||||
UserResponse,
|
||||
UserListResponse,
|
||||
UserStatistics,
|
||||
PasswordChange
|
||||
)
|
||||
|
||||
from app.schemas.client import (
|
||||
ClientBase,
|
||||
ClientCreate,
|
||||
ClientUpdate,
|
||||
ClientResponse,
|
||||
ClientWithSecret,
|
||||
ClientListResponse
|
||||
)
|
||||
|
||||
from app.schemas.auth import (
|
||||
AuthorizationRequest,
|
||||
AuthorizationResponse,
|
||||
TokenRequest,
|
||||
TokenResponse,
|
||||
UserInfoResponse,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
RegisterRequest,
|
||||
RegisterResponse,
|
||||
OIDCDiscoveryResponse
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# User schemas
|
||||
'UserBase',
|
||||
'UserCreate',
|
||||
'UserUpdate',
|
||||
'UserLogin',
|
||||
'UserResponse',
|
||||
'UserListResponse',
|
||||
'UserStatistics',
|
||||
'PasswordChange',
|
||||
|
||||
# Client schemas
|
||||
'ClientBase',
|
||||
'ClientCreate',
|
||||
'ClientUpdate',
|
||||
'ClientResponse',
|
||||
'ClientWithSecret',
|
||||
'ClientListResponse',
|
||||
|
||||
# Auth schemas
|
||||
'AuthorizationRequest',
|
||||
'AuthorizationResponse',
|
||||
'TokenRequest',
|
||||
'TokenResponse',
|
||||
'UserInfoResponse',
|
||||
'LoginRequest',
|
||||
'LoginResponse',
|
||||
'RegisterRequest',
|
||||
'RegisterResponse',
|
||||
'OIDCDiscoveryResponse',
|
||||
]
|
||||
159
app/schemas/auth.py
Normal file
159
app/schemas/auth.py
Normal file
@ -0,0 +1,159 @@
|
||||
"""
|
||||
Authentication Schemas
|
||||
|
||||
Pydantic schemas for authentication and OIDC-related requests/responses.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
# ==========================================
|
||||
# OIDC Authorization Schemas
|
||||
# ==========================================
|
||||
|
||||
class AuthorizationRequest(BaseModel):
|
||||
"""Schema for OIDC authorization request."""
|
||||
client_id: str = Field(..., description="OAuth client ID")
|
||||
redirect_uri: str = Field(..., description="Callback URL")
|
||||
response_type: str = Field("code", description="Response type (only 'code' supported)")
|
||||
scope: str = Field("openid", description="Requested scopes (space-separated)")
|
||||
state: Optional[str] = Field(None, description="CSRF protection state")
|
||||
|
||||
@field_validator('response_type')
|
||||
@classmethod
|
||||
def validate_response_type(cls, v: str) -> str:
|
||||
"""Validate that response_type is 'code'."""
|
||||
if v != "code":
|
||||
raise ValueError("Only 'code' response_type is supported (Authorization Code Flow)")
|
||||
return v
|
||||
|
||||
@field_validator('scope')
|
||||
@classmethod
|
||||
def validate_scope(cls, v: str) -> str:
|
||||
"""Validate that scope includes 'openid'."""
|
||||
scopes = v.split()
|
||||
if 'openid' not in scopes:
|
||||
raise ValueError("Scope must include 'openid'")
|
||||
return v
|
||||
|
||||
|
||||
class AuthorizationResponse(BaseModel):
|
||||
"""Schema for OIDC authorization response."""
|
||||
code: str = Field(..., description="Authorization code")
|
||||
state: Optional[str] = Field(None, description="State from request")
|
||||
|
||||
|
||||
# ==========================================
|
||||
# OIDC Token Schemas
|
||||
# ==========================================
|
||||
|
||||
class TokenRequest(BaseModel):
|
||||
"""Schema for OIDC token request."""
|
||||
grant_type: str = Field(..., description="Grant type (authorization_code)")
|
||||
code: str = Field(..., description="Authorization code")
|
||||
redirect_uri: str = Field(..., description="Redirect URI (must match authorization request)")
|
||||
client_id: str = Field(..., description="OAuth client ID")
|
||||
client_secret: str = Field(..., description="OAuth client secret")
|
||||
|
||||
@field_validator('grant_type')
|
||||
@classmethod
|
||||
def validate_grant_type(cls, v: str) -> str:
|
||||
"""Validate grant_type."""
|
||||
if v != "authorization_code":
|
||||
raise ValueError("Only 'authorization_code' grant_type is supported")
|
||||
return v
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
"""Schema for OIDC token response."""
|
||||
access_token: str = Field(..., description="Access token")
|
||||
token_type: str = Field("Bearer", description="Token type")
|
||||
expires_in: int = Field(..., description="Token expiration time in seconds")
|
||||
id_token: str = Field(..., description="OpenID Connect ID token")
|
||||
scope: str = Field(..., description="Granted scopes")
|
||||
|
||||
|
||||
# ==========================================
|
||||
# UserInfo Schemas
|
||||
# ==========================================
|
||||
|
||||
class UserInfoResponse(BaseModel):
|
||||
"""Schema for OIDC UserInfo response."""
|
||||
sub: str = Field(..., description="Subject identifier (user ID)")
|
||||
username: str
|
||||
email: str
|
||||
name: str
|
||||
preferred_username: str
|
||||
role: str
|
||||
permissions: List[str]
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Login/Registration Schemas
|
||||
# ==========================================
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""Schema for user login."""
|
||||
username: str = Field(..., description="Username")
|
||||
password: str = Field(..., description="Password")
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
"""Schema for login response."""
|
||||
success: bool
|
||||
message: str
|
||||
user: Optional[dict] = None
|
||||
redirect_url: Optional[str] = None
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
"""Schema for user registration."""
|
||||
username: str = Field(..., min_length=3, max_length=80)
|
||||
email: str = Field(..., description="Email address")
|
||||
name: str = Field(..., min_length=1, max_length=120)
|
||||
password: str = Field(..., min_length=8)
|
||||
password_confirm: str = Field(..., description="Password confirmation")
|
||||
preferred_username: Optional[str] = Field(None, max_length=80)
|
||||
|
||||
@field_validator('password')
|
||||
@classmethod
|
||||
def validate_password(cls, v: str) -> str:
|
||||
"""Validate password strength."""
|
||||
if len(v) < 8:
|
||||
raise ValueError('Password must be at least 8 characters long')
|
||||
return v
|
||||
|
||||
@field_validator('password_confirm')
|
||||
@classmethod
|
||||
def passwords_match(cls, v: str, info) -> str:
|
||||
"""Validate that passwords match."""
|
||||
if 'password' in info.data and v != info.data['password']:
|
||||
raise ValueError('Passwords do not match')
|
||||
return v
|
||||
|
||||
|
||||
class RegisterResponse(BaseModel):
|
||||
"""Schema for registration response."""
|
||||
success: bool
|
||||
message: str
|
||||
user_id: Optional[int] = None
|
||||
|
||||
|
||||
# ==========================================
|
||||
# OIDC Discovery Schemas
|
||||
# ==========================================
|
||||
|
||||
class OIDCDiscoveryResponse(BaseModel):
|
||||
"""Schema for OIDC discovery document."""
|
||||
issuer: str
|
||||
authorization_endpoint: str
|
||||
token_endpoint: str
|
||||
userinfo_endpoint: str
|
||||
jwks_uri: str
|
||||
response_types_supported: List[str]
|
||||
subject_types_supported: List[str]
|
||||
id_token_signing_alg_values_supported: List[str]
|
||||
scopes_supported: List[str]
|
||||
token_endpoint_auth_methods_supported: List[str]
|
||||
claims_supported: List[str]
|
||||
93
app/schemas/client.py
Normal file
93
app/schemas/client.py
Normal file
@ -0,0 +1,93 @@
|
||||
"""
|
||||
Client Schemas
|
||||
|
||||
Pydantic schemas for OIDC client-related requests and responses.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Base Schemas
|
||||
# ==========================================
|
||||
|
||||
class ClientBase(BaseModel):
|
||||
"""Base client schema with common fields."""
|
||||
client_name: str = Field(..., min_length=1, max_length=255, description="Client application name")
|
||||
redirect_uris: List[str] = Field(..., min_items=1, description="List of allowed redirect URIs")
|
||||
allowed_scopes: List[str] = Field(default_factory=lambda: ["openid", "profile", "email"], description="Allowed OAuth scopes")
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Request Schemas (Input)
|
||||
# ==========================================
|
||||
|
||||
class ClientCreate(ClientBase):
|
||||
"""Schema for creating a new OIDC client."""
|
||||
client_id: Optional[str] = Field(None, description="Client ID (auto-generated if not provided)")
|
||||
client_secret: Optional[str] = Field(None, description="Client secret (auto-generated if not provided)")
|
||||
|
||||
@field_validator('redirect_uris')
|
||||
@classmethod
|
||||
def validate_redirect_uris(cls, v: List[str]) -> List[str]:
|
||||
"""Validate redirect URIs."""
|
||||
if not v:
|
||||
raise ValueError('At least one redirect URI is required')
|
||||
|
||||
for uri in v:
|
||||
if not uri.startswith(('http://', 'https://')):
|
||||
raise ValueError(f'Invalid redirect URI: {uri}. Must start with http:// or https://')
|
||||
|
||||
return v
|
||||
|
||||
|
||||
class ClientUpdate(BaseModel):
|
||||
"""Schema for updating an OIDC client."""
|
||||
client_name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
redirect_uris: Optional[List[str]] = None
|
||||
allowed_scopes: Optional[List[str]] = None
|
||||
new_client_secret: Optional[str] = Field(None, description="New client secret (optional)")
|
||||
|
||||
@field_validator('redirect_uris')
|
||||
@classmethod
|
||||
def validate_redirect_uris(cls, v: Optional[List[str]]) -> Optional[List[str]]:
|
||||
"""Validate redirect URIs if provided."""
|
||||
if v is not None:
|
||||
if not v:
|
||||
raise ValueError('At least one redirect URI is required')
|
||||
|
||||
for uri in v:
|
||||
if not uri.startswith(('http://', 'https://')):
|
||||
raise ValueError(f'Invalid redirect URI: {uri}')
|
||||
|
||||
return v
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Response Schemas (Output)
|
||||
# ==========================================
|
||||
|
||||
class ClientResponse(BaseModel):
|
||||
"""Schema for client responses."""
|
||||
id: int
|
||||
client_id: str
|
||||
client_name: str
|
||||
redirect_uris: List[str]
|
||||
allowed_scopes: List[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ClientWithSecret(ClientResponse):
|
||||
"""Schema for client response including secret (only for creation)."""
|
||||
client_secret: str = Field(..., description="Client secret (only shown once)")
|
||||
|
||||
|
||||
class ClientListResponse(BaseModel):
|
||||
"""Schema for client list."""
|
||||
clients: List[ClientResponse]
|
||||
total: int
|
||||
136
app/schemas/user.py
Normal file
136
app/schemas/user.py
Normal file
@ -0,0 +1,136 @@
|
||||
"""
|
||||
User Schemas
|
||||
|
||||
Pydantic schemas for user-related requests and responses.
|
||||
Provides input validation and output serialization.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Base Schemas
|
||||
# ==========================================
|
||||
|
||||
class UserBase(BaseModel):
|
||||
"""Base user schema with common fields."""
|
||||
username: str = Field(..., min_length=3, max_length=80, description="Username (3-80 characters)")
|
||||
email: EmailStr = Field(..., description="Email address")
|
||||
name: str = Field(..., min_length=1, max_length=120, description="Full name")
|
||||
preferred_username: Optional[str] = Field(None, max_length=80, description="Preferred display name")
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Request Schemas (Input)
|
||||
# ==========================================
|
||||
|
||||
class UserCreate(UserBase):
|
||||
"""Schema for creating a new user."""
|
||||
password: str = Field(..., min_length=8, description="Password (minimum 8 characters)")
|
||||
password_confirm: str = Field(..., description="Password confirmation")
|
||||
role: Optional[str] = Field("user", description="User role")
|
||||
is_admin: Optional[bool] = Field(False, description="Admin flag")
|
||||
is_active: Optional[bool] = Field(True, description="Active status")
|
||||
permissions: Optional[List[str]] = Field(default_factory=list, description="List of permissions")
|
||||
|
||||
@field_validator('password')
|
||||
@classmethod
|
||||
def validate_password(cls, v: str) -> str:
|
||||
"""Validate password strength."""
|
||||
if len(v) < 8:
|
||||
raise ValueError('Password must be at least 8 characters long')
|
||||
return v
|
||||
|
||||
@field_validator('password_confirm')
|
||||
@classmethod
|
||||
def passwords_match(cls, v: str, info) -> str:
|
||||
"""Validate that passwords match."""
|
||||
if 'password' in info.data and v != info.data['password']:
|
||||
raise ValueError('Passwords do not match')
|
||||
return v
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
"""Schema for updating a user."""
|
||||
username: Optional[str] = Field(None, min_length=3, max_length=80)
|
||||
email: Optional[EmailStr] = None
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=120)
|
||||
preferred_username: Optional[str] = Field(None, max_length=80)
|
||||
role: Optional[str] = None
|
||||
is_admin: Optional[bool] = None
|
||||
is_active: Optional[bool] = None
|
||||
permissions: Optional[List[str]] = None
|
||||
new_password: Optional[str] = Field(None, min_length=8, description="New password (optional)")
|
||||
|
||||
@field_validator('new_password')
|
||||
@classmethod
|
||||
def validate_password(cls, v: Optional[str]) -> Optional[str]:
|
||||
"""Validate password strength if provided."""
|
||||
if v is not None and len(v) < 8:
|
||||
raise ValueError('Password must be at least 8 characters long')
|
||||
return v
|
||||
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
"""Schema for user login."""
|
||||
username: str = Field(..., description="Username")
|
||||
password: str = Field(..., description="Password")
|
||||
|
||||
|
||||
class PasswordChange(BaseModel):
|
||||
"""Schema for changing user password."""
|
||||
current_password: str = Field(..., description="Current password")
|
||||
new_password: str = Field(..., min_length=8, description="New password")
|
||||
new_password_confirm: str = Field(..., description="New password confirmation")
|
||||
|
||||
@field_validator('new_password')
|
||||
@classmethod
|
||||
def validate_password(cls, v: str) -> str:
|
||||
"""Validate password strength."""
|
||||
if len(v) < 8:
|
||||
raise ValueError('Password must be at least 8 characters long')
|
||||
return v
|
||||
|
||||
@field_validator('new_password_confirm')
|
||||
@classmethod
|
||||
def passwords_match(cls, v: str, info) -> str:
|
||||
"""Validate that passwords match."""
|
||||
if 'new_password' in info.data and v != info.data['new_password']:
|
||||
raise ValueError('Passwords do not match')
|
||||
return v
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Response Schemas (Output)
|
||||
# ==========================================
|
||||
|
||||
class UserResponse(UserBase):
|
||||
"""Schema for user responses."""
|
||||
id: int
|
||||
role: str
|
||||
is_admin: bool
|
||||
is_active: bool
|
||||
permissions: List[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class UserListResponse(BaseModel):
|
||||
"""Schema for paginated user list."""
|
||||
users: List[UserResponse]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
total_pages: int
|
||||
|
||||
|
||||
class UserStatistics(BaseModel):
|
||||
"""Schema for user statistics."""
|
||||
total_users: int
|
||||
active_users: int
|
||||
inactive_users: int
|
||||
admin_users: int
|
||||
13
app/services/__init__.py
Normal file
13
app/services/__init__.py
Normal file
@ -0,0 +1,13 @@
|
||||
"""
|
||||
Services package - Business Logic Layer
|
||||
|
||||
All business logic goes in services. Services orchestrate workflows,
|
||||
enforce business rules, and coordinate between repositories.
|
||||
"""
|
||||
from app.services.auth_service import AuthService
|
||||
from app.services.user_service import UserService
|
||||
from app.services.oidc_service import OIDCService
|
||||
from app.services.client_service import ClientService
|
||||
from app.services.analytics_service import AnalyticsService
|
||||
|
||||
__all__ = ['AuthService', 'UserService', 'OIDCService', 'ClientService', 'AnalyticsService']
|
||||
141
app/services/analytics_service.py
Normal file
141
app/services/analytics_service.py
Normal file
@ -0,0 +1,141 @@
|
||||
"""
|
||||
Analytics Service - Business Logic for Usage Analytics
|
||||
|
||||
Following Python Quick Start Guide:
|
||||
- Service layer contains business logic
|
||||
- Orchestrates repository calls
|
||||
- Returns DTOs/dicts for API layer
|
||||
"""
|
||||
from typing import Dict, List, Any
|
||||
from app.repositories.token_repository import TokenRepository
|
||||
|
||||
|
||||
class AnalyticsService:
|
||||
"""
|
||||
Analytics service - provides usage analytics and statistics.
|
||||
|
||||
Responsibilities:
|
||||
- Get active sessions by client
|
||||
- Get usage summary statistics
|
||||
- Transform data for presentation
|
||||
"""
|
||||
|
||||
def __init__(self, token_repo: TokenRepository = None):
|
||||
"""Initialize service with repository."""
|
||||
self.token_repo = token_repo or TokenRepository()
|
||||
|
||||
def get_active_sessions(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get all active sessions with user and client information.
|
||||
|
||||
Returns:
|
||||
Dict with summary stats and detailed session list
|
||||
"""
|
||||
# Get detailed active tokens
|
||||
active_tokens = self.token_repo.get_active_tokens_by_client()
|
||||
|
||||
# Get summary by client
|
||||
summary = self.token_repo.get_active_sessions_summary()
|
||||
|
||||
# Calculate overall stats
|
||||
total_active_users = len(set(token['user_id'] for token in active_tokens))
|
||||
total_active_tokens = len(active_tokens)
|
||||
total_clients = len(set(token['client_id'] for token in active_tokens if token['client_id']))
|
||||
|
||||
return {
|
||||
'summary': {
|
||||
'total_active_users': total_active_users,
|
||||
'total_active_tokens': total_active_tokens,
|
||||
'total_clients_in_use': total_clients
|
||||
},
|
||||
'by_client': summary,
|
||||
'detailed_sessions': active_tokens
|
||||
}
|
||||
|
||||
def get_client_usage_stats(self, client_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get usage statistics for a specific client.
|
||||
|
||||
Args:
|
||||
client_id: The client ID to get stats for
|
||||
|
||||
Returns:
|
||||
Dict with client usage statistics
|
||||
"""
|
||||
all_sessions = self.get_active_sessions()
|
||||
|
||||
# Filter for specific client
|
||||
client_sessions = [
|
||||
session for session in all_sessions['detailed_sessions']
|
||||
if session['client_id'] == client_id
|
||||
]
|
||||
|
||||
unique_users = len(set(session['user_id'] for session in client_sessions))
|
||||
|
||||
return {
|
||||
'client_id': client_id,
|
||||
'active_users': unique_users,
|
||||
'active_tokens': len(client_sessions),
|
||||
'sessions': client_sessions
|
||||
}
|
||||
|
||||
def get_user_active_clients(self, user_id: int) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all clients that a specific user is currently using.
|
||||
|
||||
Args:
|
||||
user_id: The user ID to get active clients for
|
||||
|
||||
Returns:
|
||||
List of client information dicts
|
||||
"""
|
||||
all_sessions = self.get_active_sessions()
|
||||
|
||||
# Filter for specific user
|
||||
user_sessions = [
|
||||
session for session in all_sessions['detailed_sessions']
|
||||
if session['user_id'] == user_id
|
||||
]
|
||||
|
||||
# Group by client
|
||||
clients = {}
|
||||
for session in user_sessions:
|
||||
client_id = session['client_id']
|
||||
if client_id and client_id not in clients:
|
||||
clients[client_id] = {
|
||||
'client_id': client_id,
|
||||
'client_name': session['client_name'],
|
||||
'last_access': session['created_at'],
|
||||
'expires_at': session['expires_at']
|
||||
}
|
||||
|
||||
return list(clients.values())
|
||||
|
||||
def get_user_analytics(self, user_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Get analytics for a specific user (their own sessions only).
|
||||
|
||||
Args:
|
||||
user_id: The user ID to get analytics for
|
||||
|
||||
Returns:
|
||||
Dict with user's session summary and active clients
|
||||
"""
|
||||
# Get all sessions and filter for this user
|
||||
all_sessions = self.get_active_sessions()
|
||||
|
||||
user_sessions = [
|
||||
session for session in all_sessions['detailed_sessions']
|
||||
if session['user_id'] == user_id
|
||||
]
|
||||
|
||||
# Count unique clients
|
||||
unique_clients = len(set(s['client_id'] for s in user_sessions if s['client_id']))
|
||||
|
||||
return {
|
||||
'summary': {
|
||||
'total_active_sessions': len(user_sessions),
|
||||
'total_clients': unique_clients
|
||||
},
|
||||
'active_sessions': user_sessions
|
||||
}
|
||||
220
app/services/auth_service.py
Normal file
220
app/services/auth_service.py
Normal file
@ -0,0 +1,220 @@
|
||||
"""
|
||||
Authentication Service - Business Logic for User Authentication
|
||||
Handles login, registration, password changes, and admin authentication
|
||||
"""
|
||||
from typing import Optional, Dict, Any
|
||||
from app.core.database import db
|
||||
from models import User, AuditLog
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""
|
||||
Authentication service - contains ALL business logic for authentication.
|
||||
|
||||
Following Python Quick Start Guide:
|
||||
- Service layer contains business rules
|
||||
- No database queries (those go in repository layer - future refactor)
|
||||
- No HTTP/request handling (that stays in endpoints)
|
||||
"""
|
||||
|
||||
def __init__(self, db_session=None):
|
||||
"""Initialize auth service with database session."""
|
||||
self.db = db_session or db.session
|
||||
|
||||
def register_user(
|
||||
self,
|
||||
username: str,
|
||||
email: str,
|
||||
name: str,
|
||||
password: str,
|
||||
password_confirm: str,
|
||||
preferred_username: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Register a new user - complete workflow.
|
||||
|
||||
Business Rules:
|
||||
1. All fields are required
|
||||
2. Passwords must match
|
||||
3. Password must be at least 8 characters
|
||||
4. Username must be unique
|
||||
5. Email must be unique
|
||||
6. User starts as active non-admin
|
||||
|
||||
Args:
|
||||
username: Desired username
|
||||
email: User's email address
|
||||
name: User's full name
|
||||
password: User's password
|
||||
password_confirm: Password confirmation
|
||||
preferred_username: Optional preferred username (defaults to username)
|
||||
|
||||
Returns:
|
||||
Dict with 'success' (bool) and 'message' or 'error'
|
||||
"""
|
||||
# Business Rule 1: Validate all fields are provided
|
||||
if not all([username, email, name, password, password_confirm]):
|
||||
return {'success': False, 'error': 'Alle Felder sind erforderlich'}
|
||||
|
||||
# Business Rule 2: Passwords must match
|
||||
if password != password_confirm:
|
||||
return {'success': False, 'error': 'Passwörter stimmen nicht überein'}
|
||||
|
||||
# Business Rule 3: Password minimum length
|
||||
if len(password) < 8:
|
||||
return {'success': False, 'error': 'Passwort muss mindestens 8 Zeichen lang sein'}
|
||||
|
||||
# Business Rule 4: Check username uniqueness
|
||||
existing_user = User.query.filter_by(username=username).first()
|
||||
if existing_user:
|
||||
return {'success': False, 'error': 'Username bereits vergeben'}
|
||||
|
||||
# Business Rule 5: Check email uniqueness
|
||||
existing_email = User.query.filter_by(email=email).first()
|
||||
if existing_email:
|
||||
return {'success': False, 'error': 'Email bereits registriert'}
|
||||
|
||||
# Create new user (Business Rule 6: Active non-admin by default)
|
||||
user = User(
|
||||
username=username,
|
||||
email=email,
|
||||
name=name,
|
||||
preferred_username=preferred_username or username,
|
||||
is_active=True,
|
||||
is_admin=False
|
||||
)
|
||||
user.set_password(password)
|
||||
|
||||
try:
|
||||
self.db.add(user)
|
||||
self.db.commit()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': 'Registrierung erfolgreich! Du kannst dich jetzt einloggen.',
|
||||
'user_id': user.id
|
||||
}
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
return {'success': False, 'error': f'Registrierung fehlgeschlagen: {str(e)}'}
|
||||
|
||||
def authenticate_user(
|
||||
self,
|
||||
username: str,
|
||||
password: str,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Authenticate a user with username and password.
|
||||
|
||||
Business Rules:
|
||||
1. Username and password are required
|
||||
2. User must exist
|
||||
3. Password must be correct
|
||||
4. User must be active
|
||||
5. Log all authentication attempts (success and failure)
|
||||
|
||||
Args:
|
||||
username: User's username
|
||||
password: User's password
|
||||
ip_address: Client IP address for audit logging
|
||||
user_agent: Client User-Agent for audit logging
|
||||
|
||||
Returns:
|
||||
Dict with 'success' (bool), 'user' (if successful), or 'error'
|
||||
"""
|
||||
# Business Rule 1: Both fields required
|
||||
if not username or not password:
|
||||
return {'success': False, 'error': 'Username und Password sind erforderlich'}
|
||||
|
||||
# Business Rule 2: User must exist
|
||||
user = User.query.filter_by(username=username).first()
|
||||
|
||||
if not user or not user.check_password(password):
|
||||
# Business Rule 5: Log failed login attempt
|
||||
AuditLog.log(
|
||||
action='login_failed',
|
||||
username=username,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
details={'reason': 'invalid_credentials'}
|
||||
)
|
||||
return {'success': False, 'error': 'Ungültige Credentials'}
|
||||
|
||||
# Business Rule 4: User must be active
|
||||
if not user.is_active:
|
||||
# Business Rule 5: Log login attempt on inactive account
|
||||
AuditLog.log(
|
||||
action='login_failed',
|
||||
username=username,
|
||||
user_id=user.id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
details={'reason': 'account_inactive'}
|
||||
)
|
||||
return {'success': False, 'error': 'Account ist deaktiviert'}
|
||||
|
||||
# Business Rule 5: Log successful login
|
||||
AuditLog.log(
|
||||
action='login_success',
|
||||
username=user.username,
|
||||
user_id=user.id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent
|
||||
)
|
||||
|
||||
return {'success': True, 'user': user}
|
||||
|
||||
def change_password(
|
||||
self,
|
||||
username: str,
|
||||
current_password: str,
|
||||
new_password: str,
|
||||
new_password_confirm: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Change user password.
|
||||
|
||||
Business Rules:
|
||||
1. All fields are required
|
||||
2. New passwords must match
|
||||
3. New password must be at least 8 characters
|
||||
4. User must exist and be active
|
||||
5. Current password must be correct
|
||||
|
||||
Args:
|
||||
username: User's username
|
||||
current_password: Current password for verification
|
||||
new_password: New password
|
||||
new_password_confirm: New password confirmation
|
||||
|
||||
Returns:
|
||||
Dict with 'success' (bool) and 'message' or 'error'
|
||||
"""
|
||||
# Business Rule 1: All fields required
|
||||
if not all([username, current_password, new_password, new_password_confirm]):
|
||||
return {'success': False, 'error': 'Alle Felder sind erforderlich'}
|
||||
|
||||
# Business Rule 2: New passwords must match
|
||||
if new_password != new_password_confirm:
|
||||
return {'success': False, 'error': 'Neue Passwörter stimmen nicht überein'}
|
||||
|
||||
# Business Rule 3: Minimum length
|
||||
if len(new_password) < 8:
|
||||
return {'success': False, 'error': 'Neues Passwort muss mindestens 8 Zeichen lang sein'}
|
||||
|
||||
# Business Rule 4 & 5: User exists, is active, and current password is correct
|
||||
user = User.query.filter_by(username=username, is_active=True).first()
|
||||
if not user or not user.check_password(current_password):
|
||||
return {'success': False, 'error': 'Ungültiger Username oder Passwort'}
|
||||
|
||||
# Update password
|
||||
user.set_password(new_password)
|
||||
|
||||
try:
|
||||
self.db.commit()
|
||||
return {'success': True, 'message': 'Passwort erfolgreich geändert!'}
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
return {'success': False, 'error': f'Passwort-Änderung fehlgeschlagen: {str(e)}'}
|
||||
291
app/services/client_service.py
Normal file
291
app/services/client_service.py
Normal file
@ -0,0 +1,291 @@
|
||||
"""
|
||||
Client Service - Business Logic for OIDC Client Management
|
||||
|
||||
Handles client CRUD operations, secret management, and validation.
|
||||
"""
|
||||
from typing import Optional, Dict, Any, List
|
||||
from app.core.database import db
|
||||
from models import Client
|
||||
from app.repositories import ClientRepository
|
||||
import json
|
||||
import secrets
|
||||
|
||||
|
||||
class ClientService:
|
||||
"""
|
||||
Client service - contains ALL business logic for OIDC client management.
|
||||
|
||||
Following Python Quick Start Guide:
|
||||
- Service layer contains business rules
|
||||
- Uses repository layer for database operations
|
||||
- No HTTP/request handling (that stays in endpoints)
|
||||
"""
|
||||
|
||||
def __init__(self, db_session=None):
|
||||
"""Initialize client service with database session."""
|
||||
self.db = db_session or db.session
|
||||
self.client_repo = ClientRepository(db_session)
|
||||
|
||||
def get_all_clients(self) -> List[Client]:
|
||||
"""
|
||||
Get all clients.
|
||||
|
||||
Business Rules:
|
||||
1. Return all clients ordered by ID descending
|
||||
|
||||
Returns:
|
||||
List of Client objects
|
||||
"""
|
||||
return self.client_repo.find_all()
|
||||
|
||||
def get_client_by_id(self, client_id_pk: int) -> Optional[Client]:
|
||||
"""
|
||||
Get client by primary key ID.
|
||||
|
||||
Business Rules:
|
||||
1. Client must exist
|
||||
2. Return None if not found
|
||||
|
||||
Args:
|
||||
client_id_pk: Client primary key ID
|
||||
|
||||
Returns:
|
||||
Client object or None if not found
|
||||
"""
|
||||
return self.client_repo.find_by_id(client_id_pk)
|
||||
|
||||
def create_client(
|
||||
self,
|
||||
client_name: str,
|
||||
redirect_uris_str: str,
|
||||
allowed_scopes_str: str = 'openid, profile, email',
|
||||
client_id: Optional[str] = None,
|
||||
client_secret: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a new OIDC client.
|
||||
|
||||
Business Rules:
|
||||
1. Client name and redirect URIs are required
|
||||
2. Client ID must be unique (auto-generate if not provided)
|
||||
3. Client secret must be secure (auto-generate if not provided)
|
||||
4. Redirect URIs must be valid (one per line)
|
||||
5. Allowed scopes must be valid (comma-separated)
|
||||
|
||||
Args:
|
||||
client_name: Display name for client
|
||||
redirect_uris_str: Newline-separated redirect URIs
|
||||
allowed_scopes_str: Comma-separated allowed scopes
|
||||
client_id: Optional client ID (auto-generated if not provided)
|
||||
client_secret: Optional client secret (auto-generated if not provided)
|
||||
|
||||
Returns:
|
||||
Dict with 'success' (bool), 'client_id' (if successful), or 'error'
|
||||
"""
|
||||
# Business Rule 1: Required fields
|
||||
if not client_name or not redirect_uris_str:
|
||||
return {'success': False, 'error': 'Client Name and Redirect URIs are required'}
|
||||
|
||||
# Business Rule 2: Generate or validate client_id
|
||||
if not client_id:
|
||||
client_id = secrets.token_urlsafe(16)
|
||||
|
||||
# Check uniqueness
|
||||
if self.client_repo.find_by_client_id(client_id):
|
||||
return {'success': False, 'error': 'Client ID already exists'}
|
||||
|
||||
# Business Rule 3: Generate or validate client_secret
|
||||
if not client_secret:
|
||||
client_secret = secrets.token_urlsafe(32)
|
||||
|
||||
# Business Rule 4: Parse redirect URIs (newline-separated)
|
||||
redirect_uris = [uri.strip() for uri in redirect_uris_str.splitlines() if uri.strip()]
|
||||
if not redirect_uris:
|
||||
return {'success': False, 'error': 'At least one redirect URI is required'}
|
||||
|
||||
# Business Rule 5: Parse allowed scopes (comma-separated)
|
||||
allowed_scopes = [scope.strip() for scope in allowed_scopes_str.split(',') if scope.strip()]
|
||||
if not allowed_scopes:
|
||||
return {'success': False, 'error': 'At least one scope is required'}
|
||||
|
||||
# Create client
|
||||
new_client = Client(
|
||||
client_id=client_id,
|
||||
client_name=client_name,
|
||||
redirect_uris=json.dumps(redirect_uris),
|
||||
allowed_scopes=json.dumps(allowed_scopes)
|
||||
)
|
||||
new_client.set_client_secret(client_secret)
|
||||
|
||||
try:
|
||||
self.client_repo.create(new_client)
|
||||
return {
|
||||
'success': True,
|
||||
'client_id': new_client.id,
|
||||
'message': f'Client "{client_name}" created successfully'
|
||||
}
|
||||
except Exception as e:
|
||||
self.client_repo.rollback()
|
||||
return {'success': False, 'error': f'Failed to create client: {str(e)}'}
|
||||
|
||||
def update_client(
|
||||
self,
|
||||
client_id_pk: int,
|
||||
client_name: str,
|
||||
redirect_uris_str: str,
|
||||
allowed_scopes_str: str,
|
||||
new_client_secret: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Update an existing OIDC client.
|
||||
|
||||
Business Rules:
|
||||
1. Client must exist
|
||||
2. Client name and redirect URIs are required
|
||||
3. Update secret only if provided
|
||||
|
||||
Args:
|
||||
client_id_pk: Client primary key ID
|
||||
client_name: New client name
|
||||
redirect_uris_str: Newline-separated redirect URIs
|
||||
allowed_scopes_str: Comma-separated allowed scopes
|
||||
new_client_secret: Optional new client secret
|
||||
|
||||
Returns:
|
||||
Dict with 'success' (bool) and 'message' or 'error'
|
||||
"""
|
||||
# Business Rule 1: Client must exist
|
||||
client = self.client_repo.find_by_id(client_id_pk)
|
||||
if not client:
|
||||
return {'success': False, 'error': 'Client not found'}
|
||||
|
||||
# Business Rule 2: Required fields
|
||||
if not client_name or not redirect_uris_str:
|
||||
return {'success': False, 'error': 'Client Name and Redirect URIs are required'}
|
||||
|
||||
# Parse redirect URIs and scopes
|
||||
redirect_uris = [uri.strip() for uri in redirect_uris_str.splitlines() if uri.strip()]
|
||||
allowed_scopes = [scope.strip() for scope in allowed_scopes_str.split(',') if scope.strip()]
|
||||
|
||||
if not redirect_uris:
|
||||
return {'success': False, 'error': 'At least one redirect URI is required'}
|
||||
|
||||
# Update client fields
|
||||
client.client_name = client_name
|
||||
client.redirect_uris = json.dumps(redirect_uris)
|
||||
client.allowed_scopes = json.dumps(allowed_scopes)
|
||||
|
||||
# Business Rule 3: Update secret if provided
|
||||
if new_client_secret:
|
||||
client.set_client_secret(new_client_secret)
|
||||
|
||||
try:
|
||||
self.client_repo.update(client)
|
||||
return {
|
||||
'success': True,
|
||||
'message': f'Client "{client_name}" updated successfully'
|
||||
}
|
||||
except Exception as e:
|
||||
self.client_repo.rollback()
|
||||
return {'success': False, 'error': f'Failed to update client: {str(e)}'}
|
||||
|
||||
def delete_client(self, client_id_pk: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Delete an OIDC client.
|
||||
|
||||
Business Rules:
|
||||
1. Client must exist
|
||||
2. Permanently remove from database
|
||||
|
||||
Args:
|
||||
client_id_pk: Client primary key ID
|
||||
|
||||
Returns:
|
||||
Dict with 'success' (bool) and 'message' or 'error'
|
||||
"""
|
||||
client = self.client_repo.find_by_id(client_id_pk)
|
||||
if not client:
|
||||
return {'success': False, 'error': 'Client not found'}
|
||||
|
||||
client_name = client.client_name # Save for message
|
||||
|
||||
try:
|
||||
self.client_repo.delete(client)
|
||||
return {'success': True, 'message': f'Client "{client_name}" deleted permanently'}
|
||||
except Exception as e:
|
||||
self.client_repo.rollback()
|
||||
return {'success': False, 'error': f'Failed to delete client: {str(e)}'}
|
||||
|
||||
def regenerate_client_id(self, client_id_pk: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Regenerate client ID for an OIDC client.
|
||||
|
||||
Business Rules:
|
||||
1. Client must exist
|
||||
2. Generate new unique client_id
|
||||
3. Keep all other fields unchanged
|
||||
|
||||
Args:
|
||||
client_id_pk: Client primary key ID
|
||||
|
||||
Returns:
|
||||
Dict with 'success' (bool), 'new_client_id', or 'error'
|
||||
"""
|
||||
client = self.client_repo.find_by_id(client_id_pk)
|
||||
if not client:
|
||||
return {'success': False, 'error': 'Client not found'}
|
||||
|
||||
# Generate new client_id
|
||||
new_client_id = secrets.token_urlsafe(16)
|
||||
|
||||
# Ensure uniqueness (very unlikely collision, but check anyway)
|
||||
while self.client_repo.find_by_client_id(new_client_id):
|
||||
new_client_id = secrets.token_urlsafe(16)
|
||||
|
||||
old_client_id = client.client_id
|
||||
client.client_id = new_client_id
|
||||
|
||||
try:
|
||||
self.client_repo.update(client)
|
||||
return {
|
||||
'success': True,
|
||||
'new_client_id': new_client_id,
|
||||
'message': f'Client ID regenerated from {old_client_id} to {new_client_id}'
|
||||
}
|
||||
except Exception as e:
|
||||
self.client_repo.rollback()
|
||||
return {'success': False, 'error': f'Failed to regenerate client ID: {str(e)}'}
|
||||
|
||||
def rotate_client_secret(self, client_id_pk: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Rotate (regenerate) client secret for an OIDC client.
|
||||
|
||||
Business Rules:
|
||||
1. Client must exist
|
||||
2. Generate new secure secret
|
||||
3. Keep all other fields unchanged
|
||||
|
||||
Args:
|
||||
client_id_pk: Client primary key ID
|
||||
|
||||
Returns:
|
||||
Dict with 'success' (bool), 'new_client_secret', or 'error'
|
||||
"""
|
||||
client = self.client_repo.find_by_id(client_id_pk)
|
||||
if not client:
|
||||
return {'success': False, 'error': 'Client not found'}
|
||||
|
||||
# Generate new client secret
|
||||
new_client_secret = secrets.token_urlsafe(32)
|
||||
client.set_client_secret(new_client_secret)
|
||||
|
||||
try:
|
||||
self.client_repo.update(client)
|
||||
return {
|
||||
'success': True,
|
||||
'new_client_secret': new_client_secret,
|
||||
'message': f'Client secret rotated for {client.client_name}'
|
||||
}
|
||||
except Exception as e:
|
||||
self.client_repo.rollback()
|
||||
return {'success': False, 'error': f'Failed to rotate client secret: {str(e)}'}
|
||||
342
app/services/oidc_service.py
Normal file
342
app/services/oidc_service.py
Normal file
@ -0,0 +1,342 @@
|
||||
"""
|
||||
OIDC Service - Business Logic for OpenID Connect Flow
|
||||
Handles authorization, token exchange, and userinfo
|
||||
"""
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime, timedelta
|
||||
from app.core.database import db
|
||||
from models import User, Client, AuthorizationCode, AccessToken, AuditLog
|
||||
import secrets
|
||||
import jwt
|
||||
from config import get_config
|
||||
import os
|
||||
|
||||
|
||||
class OIDCService:
|
||||
"""
|
||||
OIDC service - contains business logic for OpenID Connect flows.
|
||||
|
||||
Following Python Quick Start Guide:
|
||||
- Service layer contains business rules
|
||||
- Orchestrates token generation and validation
|
||||
"""
|
||||
|
||||
def __init__(self, db_session=None):
|
||||
"""Initialize OIDC service."""
|
||||
self.db = db_session or db.session
|
||||
# Load config
|
||||
env = os.environ.get('FLASK_ENV', 'development')
|
||||
config = get_config(env)()
|
||||
self.config = config
|
||||
|
||||
def validate_authorization_request(
|
||||
self,
|
||||
client_id: str,
|
||||
redirect_uri: str,
|
||||
response_type: str,
|
||||
scope: str = '',
|
||||
state: str = ''
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Validate authorization request parameters.
|
||||
|
||||
Business Rules:
|
||||
1. Client must exist and be valid
|
||||
2. Response type must be 'code'
|
||||
3. Redirect URI must be in client's allowed list
|
||||
|
||||
Args:
|
||||
client_id: OIDC client ID
|
||||
redirect_uri: Redirect URI from request
|
||||
response_type: OAuth response type
|
||||
scope: Requested scopes
|
||||
state: Optional state parameter
|
||||
|
||||
Returns:
|
||||
Dict with 'success' (bool) and 'auth_request' data or 'error'
|
||||
"""
|
||||
# Business Rule 1: Validate client
|
||||
client = Client.query.filter_by(client_id=client_id).first()
|
||||
if not client:
|
||||
return {'success': False, 'error': 'Invalid client_id'}
|
||||
|
||||
# Business Rule 2: Check response type
|
||||
if response_type != 'code':
|
||||
return {'success': False, 'error': "Unsupported response_type. Use 'code'"}
|
||||
|
||||
# Business Rule 3: Validate redirect_uri
|
||||
allowed_uris = client.get_redirect_uris()
|
||||
if not redirect_uri or redirect_uri not in allowed_uris:
|
||||
return {'success': False, 'error': 'Invalid or missing redirect_uri'}
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'auth_request': {
|
||||
'client_id': client_id,
|
||||
'redirect_uri': redirect_uri,
|
||||
'scope': scope,
|
||||
'state': state
|
||||
}
|
||||
}
|
||||
|
||||
def authorize_with_credentials(
|
||||
self,
|
||||
username: str,
|
||||
password: str,
|
||||
client_id: str,
|
||||
redirect_uri: str,
|
||||
scope: str,
|
||||
state: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Authenticate user and create authorization code.
|
||||
|
||||
Business Rules:
|
||||
1. User must exist and be active
|
||||
2. Password must be correct
|
||||
3. Create authorization code for valid user
|
||||
4. Build redirect URL with code
|
||||
|
||||
Args:
|
||||
username: User's username
|
||||
password: User's password
|
||||
client_id: OIDC client ID
|
||||
redirect_uri: Redirect URI
|
||||
scope: Requested scopes
|
||||
state: Optional state parameter
|
||||
|
||||
Returns:
|
||||
Dict with 'success', 'redirect_url' or 'error'
|
||||
"""
|
||||
# Business Rule 1 & 2: Authenticate user
|
||||
user = User.query.filter_by(username=username, is_active=True).first()
|
||||
if not user or not user.check_password(password):
|
||||
return {'success': False, 'error': 'Invalid credentials'}
|
||||
|
||||
# Business Rule 3: Create authorization code
|
||||
result = self.create_authorization_code(
|
||||
client_id=client_id,
|
||||
user_id=user.id,
|
||||
redirect_uri=redirect_uri,
|
||||
scope=scope,
|
||||
state=state
|
||||
)
|
||||
|
||||
if not result['success']:
|
||||
return result
|
||||
|
||||
# Business Rule 4: Build redirect URL
|
||||
separator = '&' if '?' in redirect_uri else '?'
|
||||
redirect_url = f"{redirect_uri}{separator}code={result['code']}"
|
||||
|
||||
if state:
|
||||
redirect_url += f"&state={state}"
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'redirect_url': redirect_url
|
||||
}
|
||||
|
||||
def create_authorization_code(
|
||||
self,
|
||||
client_id: str,
|
||||
user_id: int,
|
||||
redirect_uri: str,
|
||||
scope: str,
|
||||
state: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create an authorization code for OIDC flow.
|
||||
|
||||
Business Rules:
|
||||
1. Client must exist and be valid
|
||||
2. Redirect URI must be in client's allowed list
|
||||
3. User must exist
|
||||
4. Code expires after configured lifetime
|
||||
|
||||
Args:
|
||||
client_id: OIDC client ID
|
||||
user_id: Authenticated user ID
|
||||
redirect_uri: Redirect URI from request
|
||||
scope: Requested scopes
|
||||
state: Optional state parameter
|
||||
|
||||
Returns:
|
||||
Dict with 'success', 'code', 'redirect_uri', 'state'
|
||||
"""
|
||||
# Business Rule 1: Validate client
|
||||
client = Client.query.filter_by(client_id=client_id).first()
|
||||
if not client:
|
||||
return {'success': False, 'error': 'invalid_client'}
|
||||
|
||||
# Business Rule 2: Validate redirect_uri
|
||||
allowed_uris = client.get_redirect_uris()
|
||||
if redirect_uri not in allowed_uris:
|
||||
return {'success': False, 'error': 'invalid_redirect_uri'}
|
||||
|
||||
# Business Rule 3: Validate user
|
||||
user = User.query.get(user_id)
|
||||
if not user:
|
||||
return {'success': False, 'error': 'invalid_user'}
|
||||
|
||||
# Create authorization code
|
||||
code = secrets.token_urlsafe(32)
|
||||
auth_code = AuthorizationCode(
|
||||
code=code,
|
||||
client_id=client_id,
|
||||
user_id=user_id,
|
||||
redirect_uri=redirect_uri,
|
||||
scope=scope,
|
||||
expires_at=datetime.utcnow() + timedelta(seconds=self.config.AUTHORIZATION_CODE_LIFETIME)
|
||||
)
|
||||
|
||||
try:
|
||||
self.db.add(auth_code)
|
||||
self.db.commit()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'code': code,
|
||||
'redirect_uri': redirect_uri,
|
||||
'state': state
|
||||
}
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def exchange_code_for_token(
|
||||
self,
|
||||
grant_type: str,
|
||||
code: str,
|
||||
redirect_uri: str,
|
||||
client_id: str,
|
||||
client_secret: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Exchange authorization code for access token.
|
||||
|
||||
Business Rules:
|
||||
1. Grant type must be 'authorization_code'
|
||||
2. Client must be authenticated
|
||||
3. Code must be valid and not expired
|
||||
4. Redirect URI must match
|
||||
5. Code can only be used once
|
||||
|
||||
Args:
|
||||
grant_type: OAuth grant type
|
||||
code: Authorization code
|
||||
redirect_uri: Redirect URI from initial request
|
||||
client_id: Client ID
|
||||
client_secret: Client secret
|
||||
|
||||
Returns:
|
||||
Dict with token response or error
|
||||
"""
|
||||
# Business Rule 1: Check grant type
|
||||
if grant_type != 'authorization_code':
|
||||
return {'error': 'unsupported_grant_type'}
|
||||
|
||||
# Business Rule 2: Authenticate client
|
||||
client = Client.query.filter_by(client_id=client_id).first()
|
||||
if not client or not client.check_client_secret(client_secret):
|
||||
return {'error': 'invalid_client'}
|
||||
|
||||
# Business Rule 3: Validate code
|
||||
auth_code = AuthorizationCode.query.filter_by(code=code).first()
|
||||
if not auth_code or not auth_code.is_valid():
|
||||
return {'error': 'invalid_grant'}
|
||||
|
||||
# Business Rule 4: Check redirect URI
|
||||
if auth_code.redirect_uri != redirect_uri:
|
||||
return {'error': 'invalid_grant'}
|
||||
|
||||
# Business Rule 5: Mark code as used
|
||||
auth_code.used = True
|
||||
|
||||
# Get user
|
||||
user = User.query.get(auth_code.user_id)
|
||||
if not user:
|
||||
return {'error': 'invalid_grant'}
|
||||
|
||||
# Generate tokens
|
||||
access_token = secrets.token_urlsafe(32)
|
||||
id_token = self._generate_id_token(user, client_id)
|
||||
|
||||
# Store access token
|
||||
token_record = AccessToken(
|
||||
token=access_token,
|
||||
client_id=client_id,
|
||||
user_id=user.id,
|
||||
scope=auth_code.scope,
|
||||
expires_at=datetime.utcnow() + timedelta(seconds=self.config.ACCESS_TOKEN_LIFETIME)
|
||||
)
|
||||
|
||||
try:
|
||||
self.db.add(token_record)
|
||||
self.db.commit()
|
||||
|
||||
return {
|
||||
'access_token': access_token,
|
||||
'token_type': 'Bearer',
|
||||
'expires_in': self.config.ACCESS_TOKEN_LIFETIME,
|
||||
'id_token': id_token,
|
||||
'scope': auth_code.scope
|
||||
}
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
return {'error': str(e)}
|
||||
|
||||
def get_userinfo(self, access_token: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get user information from access token.
|
||||
|
||||
Business Rules:
|
||||
1. Token must be valid
|
||||
2. Token must not be expired or revoked
|
||||
|
||||
Args:
|
||||
access_token: Bearer access token
|
||||
|
||||
Returns:
|
||||
User information dict or error
|
||||
"""
|
||||
# Extract token from Bearer header if needed
|
||||
if access_token.startswith('Bearer '):
|
||||
access_token = access_token[7:]
|
||||
|
||||
# Business Rule 1 & 2: Validate token
|
||||
token = AccessToken.query.filter_by(token=access_token).first()
|
||||
if not token or token.is_expired() or token.revoked:
|
||||
return {'error': 'invalid_token'}
|
||||
|
||||
# Get user info
|
||||
user = token.user
|
||||
return user.to_dict()
|
||||
|
||||
def _generate_id_token(self, user: User, client_id: str) -> str:
|
||||
"""
|
||||
Generate JWT ID token for user.
|
||||
|
||||
Args:
|
||||
user: User object
|
||||
client_id: Client ID
|
||||
|
||||
Returns:
|
||||
Signed JWT ID token
|
||||
"""
|
||||
now = datetime.utcnow()
|
||||
payload = {
|
||||
'iss': self.config.OIDC_ISSUER,
|
||||
'sub': str(user.id),
|
||||
'aud': client_id,
|
||||
'exp': now + timedelta(seconds=self.config.ID_TOKEN_LIFETIME),
|
||||
'iat': now,
|
||||
'name': user.name,
|
||||
'email': user.email,
|
||||
'preferred_username': user.preferred_username,
|
||||
'role': user.role
|
||||
}
|
||||
|
||||
# Sign with private key
|
||||
private_key = self.config.OIDC_JWT_PRIVATE_KEY
|
||||
return jwt.encode(payload, private_key, algorithm='RS256')
|
||||
409
app/services/user_service.py
Normal file
409
app/services/user_service.py
Normal file
@ -0,0 +1,409 @@
|
||||
"""
|
||||
User Service - Business Logic for User Management
|
||||
Handles user CRUD operations, profile management, and user administration
|
||||
"""
|
||||
from typing import Optional, Dict, Any, List
|
||||
from app.core.database import db
|
||||
from models import User, AuditLog
|
||||
from app.repositories import UserRepository
|
||||
import json
|
||||
|
||||
|
||||
class UserService:
|
||||
"""
|
||||
User service - contains ALL business logic for user management.
|
||||
|
||||
Following Python Quick Start Guide:
|
||||
- Service layer contains business rules
|
||||
- Uses repository layer for database operations
|
||||
- No HTTP/request handling (that stays in endpoints)
|
||||
"""
|
||||
|
||||
def __init__(self, db_session=None):
|
||||
"""Initialize user service with database session."""
|
||||
self.db = db_session or db.session
|
||||
self.user_repo = UserRepository(db_session)
|
||||
|
||||
def get_user_by_id(self, user_id: int) -> Optional[User]:
|
||||
"""
|
||||
Get user by ID.
|
||||
|
||||
Business Rules:
|
||||
1. User must exist
|
||||
2. Return None if not found (don't expose deleted users)
|
||||
|
||||
Args:
|
||||
user_id: User's ID
|
||||
|
||||
Returns:
|
||||
User object or None
|
||||
"""
|
||||
user = User.query.get(user_id)
|
||||
return user if user else None
|
||||
|
||||
def get_all_users(self, page: int = 1, per_page: int = 50) -> Dict[str, Any]:
|
||||
"""
|
||||
Get all users with pagination.
|
||||
|
||||
Args:
|
||||
page: Page number (1-indexed)
|
||||
per_page: Items per page
|
||||
|
||||
Returns:
|
||||
Dict with 'users' list and pagination info
|
||||
"""
|
||||
pagination = User.query.order_by(User.id.desc()).paginate(
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
error_out=False
|
||||
)
|
||||
|
||||
return {
|
||||
'users': pagination.items,
|
||||
'total': pagination.total,
|
||||
'page': pagination.page,
|
||||
'per_page': pagination.per_page,
|
||||
'pages': pagination.pages
|
||||
}
|
||||
|
||||
def get_user_statistics(self) -> Dict[str, int]:
|
||||
"""
|
||||
Get user statistics.
|
||||
|
||||
Returns:
|
||||
Dict with counts for total, active, inactive, and admin users
|
||||
"""
|
||||
total_users = User.query.count()
|
||||
active_users = User.query.filter_by(is_active=True).count()
|
||||
inactive_users = total_users - active_users
|
||||
admin_users = User.query.filter_by(is_admin=True).count()
|
||||
|
||||
return {
|
||||
'total_users': total_users,
|
||||
'active_users': active_users,
|
||||
'inactive_users': inactive_users,
|
||||
'admin_users': admin_users
|
||||
}
|
||||
|
||||
def create_user(
|
||||
self,
|
||||
username: str,
|
||||
email: str,
|
||||
name: str,
|
||||
password: str,
|
||||
role: str = 'user',
|
||||
permissions_str: str = '',
|
||||
is_admin: bool = False,
|
||||
is_active: bool = True,
|
||||
admin_id: Optional[int] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a new user (admin operation).
|
||||
|
||||
Business Rules:
|
||||
1. All required fields must be provided
|
||||
2. Username must be unique
|
||||
3. Email must be unique
|
||||
4. Permissions must be valid JSON
|
||||
5. Default to non-admin active user
|
||||
6. Log creation event if admin_id provided
|
||||
|
||||
Args:
|
||||
username: User's username
|
||||
email: User's email
|
||||
name: User's full name
|
||||
password: User's password
|
||||
role: User's role (default: 'user')
|
||||
permissions_str: JSON string of permissions
|
||||
is_admin: Whether user is an admin
|
||||
is_active: Whether user is active
|
||||
admin_id: ID of admin creating this user (for audit log)
|
||||
ip_address: IP address for audit log
|
||||
user_agent: User agent for audit log
|
||||
|
||||
Returns:
|
||||
Dict with 'success' (bool), 'user_id' (if successful), or 'error'
|
||||
"""
|
||||
# Business Rule 1: Required fields
|
||||
if not all([username, email, name, password]):
|
||||
return {'success': False, 'error': 'All fields are required'}
|
||||
|
||||
# Business Rule 2: Username uniqueness
|
||||
if User.query.filter_by(username=username).first():
|
||||
return {'success': False, 'error': 'Username already exists'}
|
||||
|
||||
# Business Rule 3: Email uniqueness
|
||||
if User.query.filter_by(email=email).first():
|
||||
return {'success': False, 'error': 'Email already exists'}
|
||||
|
||||
# Business Rule 4: Parse permissions (comma-separated or JSON)
|
||||
try:
|
||||
if permissions_str:
|
||||
# Try JSON first
|
||||
try:
|
||||
permissions = json.loads(permissions_str)
|
||||
except json.JSONDecodeError:
|
||||
# Fall back to comma-separated
|
||||
permissions = [p.strip() for p in permissions_str.split(',') if p.strip()]
|
||||
else:
|
||||
permissions = []
|
||||
except Exception:
|
||||
return {'success': False, 'error': 'Invalid permissions format'}
|
||||
|
||||
# Create user
|
||||
user = User(
|
||||
username=username,
|
||||
email=email,
|
||||
name=name,
|
||||
preferred_username=username,
|
||||
role=role,
|
||||
permissions=json.dumps(permissions) if permissions else None,
|
||||
is_admin=is_admin,
|
||||
is_active=is_active
|
||||
)
|
||||
user.set_password(password)
|
||||
|
||||
try:
|
||||
self.db.add(user)
|
||||
self.db.commit()
|
||||
|
||||
# Business Rule 6: Log creation if admin_id provided
|
||||
if admin_id:
|
||||
admin_user = User.query.get(admin_id)
|
||||
if admin_user:
|
||||
AuditLog.log(
|
||||
action='user_created',
|
||||
username=admin_user.username,
|
||||
user_id=admin_user.id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
details={
|
||||
'created_user': username,
|
||||
'created_user_id': user.id,
|
||||
'role': role,
|
||||
'is_admin': is_admin
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'user_id': user.id,
|
||||
'message': f'User "{username}" created successfully'
|
||||
}
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
return {'success': False, 'error': f'Failed to create user: {str(e)}'}
|
||||
|
||||
def update_user(
|
||||
self,
|
||||
user_id: int,
|
||||
username: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
name: Optional[str] = None,
|
||||
role: Optional[str] = None,
|
||||
permissions_str: Optional[str] = None,
|
||||
is_admin: Optional[bool] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
new_password: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Update an existing user.
|
||||
|
||||
Business Rules:
|
||||
1. User must exist
|
||||
2. If username changes, new username must be unique
|
||||
3. If email changes, new email must be unique
|
||||
4. Permissions must be valid (JSON or comma-separated) if provided
|
||||
5. Update password if provided
|
||||
|
||||
Args:
|
||||
user_id: ID of user to update
|
||||
username: New username (optional)
|
||||
email: New email (optional)
|
||||
name: New name (optional)
|
||||
role: New role (optional)
|
||||
permissions_str: New permissions (JSON or comma-separated) (optional)
|
||||
is_admin: New admin status (optional)
|
||||
is_active: New active status (optional)
|
||||
new_password: New password (optional)
|
||||
|
||||
Returns:
|
||||
Dict with 'success' (bool) and 'message' or 'error'
|
||||
"""
|
||||
# Business Rule 1: User must exist
|
||||
user = User.query.get(user_id)
|
||||
if not user:
|
||||
return {'success': False, 'error': 'User not found'}
|
||||
|
||||
# Business Rule 2: Username uniqueness (if changing)
|
||||
if username and username != user.username:
|
||||
if User.query.filter_by(username=username).first():
|
||||
return {'success': False, 'error': 'Username already exists'}
|
||||
user.username = username
|
||||
|
||||
# Business Rule 3: Email uniqueness (if changing)
|
||||
if email and email != user.email:
|
||||
if User.query.filter_by(email=email).first():
|
||||
return {'success': False, 'error': 'Email already exists'}
|
||||
user.email = email
|
||||
|
||||
# Update other fields if provided
|
||||
if name:
|
||||
user.name = name
|
||||
if role:
|
||||
user.role = role
|
||||
|
||||
# Business Rule 4: Parse permissions if provided (JSON or comma-separated)
|
||||
if permissions_str is not None:
|
||||
try:
|
||||
if permissions_str:
|
||||
# Try JSON first
|
||||
try:
|
||||
permissions = json.loads(permissions_str)
|
||||
except json.JSONDecodeError:
|
||||
# Fall back to comma-separated
|
||||
permissions = [p.strip() for p in permissions_str.split(',') if p.strip()]
|
||||
user.set_permissions(permissions)
|
||||
else:
|
||||
user.permissions = None
|
||||
except Exception:
|
||||
return {'success': False, 'error': 'Invalid permissions format'}
|
||||
|
||||
if is_admin is not None:
|
||||
user.is_admin = is_admin
|
||||
if is_active is not None:
|
||||
user.is_active = is_active
|
||||
|
||||
# Business Rule 5: Update password if provided
|
||||
if new_password:
|
||||
user.set_password(new_password)
|
||||
|
||||
try:
|
||||
self.db.commit()
|
||||
return {
|
||||
'success': True,
|
||||
'message': f'User "{user.username}" updated successfully'
|
||||
}
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
return {'success': False, 'error': f'Failed to update user: {str(e)}'}
|
||||
|
||||
def deactivate_user(self, user_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Deactivate a user.
|
||||
|
||||
Business Rules:
|
||||
1. User must exist
|
||||
2. Set is_active to False
|
||||
|
||||
Args:
|
||||
user_id: ID of user to deactivate
|
||||
|
||||
Returns:
|
||||
Dict with 'success' (bool) and 'message' or 'error'
|
||||
"""
|
||||
user = User.query.get(user_id)
|
||||
if not user:
|
||||
return {'success': False, 'error': 'User not found'}
|
||||
|
||||
user.is_active = False
|
||||
|
||||
try:
|
||||
self.db.commit()
|
||||
return {'success': True, 'message': f'User "{user.username}" deactivated'}
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
return {'success': False, 'error': f'Failed to deactivate user: {str(e)}'}
|
||||
|
||||
def activate_user(self, user_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Activate a user.
|
||||
|
||||
Business Rules:
|
||||
1. User must exist
|
||||
2. Set is_active to True
|
||||
|
||||
Args:
|
||||
user_id: ID of user to activate
|
||||
|
||||
Returns:
|
||||
Dict with 'success' (bool) and 'message' or 'error'
|
||||
"""
|
||||
user = User.query.get(user_id)
|
||||
if not user:
|
||||
return {'success': False, 'error': 'User not found'}
|
||||
|
||||
user.is_active = True
|
||||
|
||||
try:
|
||||
self.db.commit()
|
||||
return {'success': True, 'message': f'User "{user.username}" activated'}
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
return {'success': False, 'error': f'Failed to activate user: {str(e)}'}
|
||||
|
||||
def delete_user(
|
||||
self,
|
||||
user_id: int,
|
||||
admin_id: Optional[int] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Delete a user.
|
||||
|
||||
Business Rules:
|
||||
1. User must exist
|
||||
2. Cannot delete last admin user
|
||||
3. Permanently remove from database
|
||||
4. Log deletion if admin_id provided
|
||||
|
||||
Args:
|
||||
user_id: ID of user to delete
|
||||
admin_id: ID of admin deleting the user (for audit log)
|
||||
ip_address: IP address for audit log
|
||||
user_agent: User agent for audit log
|
||||
|
||||
Returns:
|
||||
Dict with 'success' (bool) and 'message' or 'error'
|
||||
"""
|
||||
user = User.query.get(user_id)
|
||||
if not user:
|
||||
return {'success': False, 'error': 'User not found'}
|
||||
|
||||
# Business Rule 2: Check if deleting last admin
|
||||
if user.is_admin:
|
||||
admin_count = User.query.filter_by(is_admin=True).count()
|
||||
if admin_count <= 1:
|
||||
return {'success': False, 'error': 'Cannot delete last admin user'}
|
||||
|
||||
# Save info for logging
|
||||
username = user.username
|
||||
deleted_user_id = user.id
|
||||
|
||||
try:
|
||||
self.db.delete(user)
|
||||
self.db.commit()
|
||||
|
||||
# Business Rule 4: Log deletion if admin_id provided
|
||||
if admin_id:
|
||||
admin_user = User.query.get(admin_id)
|
||||
if admin_user:
|
||||
AuditLog.log(
|
||||
action='user_deleted',
|
||||
username=admin_user.username,
|
||||
user_id=admin_user.id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
details={
|
||||
'deleted_user': username,
|
||||
'deleted_user_id': deleted_user_id
|
||||
}
|
||||
)
|
||||
|
||||
return {'success': True, 'message': f'User "{username}" deleted permanently'}
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
return {'success': False, 'error': f'Failed to delete user: {str(e)}'}
|
||||
Reference in New Issue
Block a user