first commit

This commit is contained in:
2025-11-30 00:07:24 +01:00
commit b5e642aecb
78 changed files with 15162 additions and 0 deletions

13
app/services/__init__.py Normal file
View 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']

View 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
}

View 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)}'}

View 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)}'}

View 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')

View 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)}'}