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

3
app/__init__.py Normal file
View File

@ -0,0 +1,3 @@
"""
App package - Main application module
"""

47
app/core/__init__.py Normal file
View 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
View 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
View 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
View 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

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

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

View 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

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