first commit
This commit is contained in:
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