Spaces:
Sleeping
Sleeping
Commit ·
7a5b513
1
Parent(s): bf0de9d
feat: Implement System Users module for authentication and user management
Browse files- Added `system_users` package with controllers, models, schemas, and services.
- Created API endpoints for user login, registration, user management, and password change.
- Implemented JWT authentication and user role management.
- Added database management utilities for creating indexes and default roles.
- Updated requirements.txt with necessary dependencies for FastAPI, MongoDB, and JWT handling.
- Created a startup script to set up the environment and run the FastAPI server.
- .env +46 -0
- app/__init__.py +3 -0
- app/auth/__init__.py +3 -0
- app/auth/controllers/__init__.py +3 -0
- app/auth/controllers/router.py +408 -0
- app/cache.py +67 -0
- app/constants/__init__.py +3 -0
- app/constants/collections.py +11 -0
- app/core/__init__.py +3 -0
- app/core/config.py +71 -0
- app/dependencies/__init__.py +3 -0
- app/dependencies/auth.py +141 -0
- app/insightfy_utils-0.1.0-py3-none-any.whl +0 -0
- app/main.py +78 -0
- app/nosql.py +77 -0
- app/system_users/__init__.py +3 -0
- app/system_users/controllers/__init__.py +3 -0
- app/system_users/controllers/router.py +345 -0
- app/system_users/models/__init__.py +3 -0
- app/system_users/models/model.py +125 -0
- app/system_users/schemas/__init__.py +3 -0
- app/system_users/schemas/schema.py +131 -0
- app/system_users/services/__init__.py +3 -0
- app/system_users/services/service.py +432 -0
- app/utils/__init__.py +3 -0
- manage_db.py +123 -0
- requirements.txt +26 -0
- start_server.sh +22 -0
.env
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Auth Microservice Environment Configuration
|
| 2 |
+
|
| 3 |
+
# Application Settings
|
| 4 |
+
APP_NAME=Auth Microservice
|
| 5 |
+
APP_VERSION=1.0.0
|
| 6 |
+
DEBUG=false
|
| 7 |
+
|
| 8 |
+
# MongoDB Configuration
|
| 9 |
+
MONGODB_URI=mongodb://localhost:27017
|
| 10 |
+
MONGODB_DB_NAME=auth_db
|
| 11 |
+
|
| 12 |
+
# Redis Configuration (for caching and session management)
|
| 13 |
+
REDIS_HOST=localhost
|
| 14 |
+
REDIS_PORT=6379
|
| 15 |
+
REDIS_PASSWORD=
|
| 16 |
+
REDIS_DB=0
|
| 17 |
+
|
| 18 |
+
# JWT Configuration
|
| 19 |
+
SECRET_KEY=your-super-secret-jwt-key-change-in-production-please-make-it-very-long-and-random
|
| 20 |
+
ALGORITHM=HS256
|
| 21 |
+
TOKEN_EXPIRATION_HOURS=8
|
| 22 |
+
|
| 23 |
+
# OTP Configuration
|
| 24 |
+
OTP_TTL_SECONDS=600
|
| 25 |
+
OTP_RATE_LIMIT_MAX=10
|
| 26 |
+
OTP_RATE_LIMIT_WINDOW=600
|
| 27 |
+
|
| 28 |
+
# Twilio Configuration (for SMS OTP)
|
| 29 |
+
TWILIO_ACCOUNT_SID=
|
| 30 |
+
TWILIO_AUTH_TOKEN=
|
| 31 |
+
TWILIO_PHONE_NUMBER=
|
| 32 |
+
|
| 33 |
+
# SMTP Configuration (for email notifications)
|
| 34 |
+
SMTP_HOST=
|
| 35 |
+
SMTP_PORT=587
|
| 36 |
+
SMTP_USERNAME=
|
| 37 |
+
SMTP_PASSWORD=
|
| 38 |
+
SMTP_FROM_EMAIL=
|
| 39 |
+
SMTP_USE_TLS=true
|
| 40 |
+
|
| 41 |
+
# Logging Configuration
|
| 42 |
+
LOG_LEVEL=INFO
|
| 43 |
+
|
| 44 |
+
# CORS Settings
|
| 45 |
+
CORS_ORIGINS=["http://localhost:3000","http://localhost:8000","http://localhost:8002"]
|
| 46 |
+
|
app/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Auth Microservice Application Package
|
| 3 |
+
"""
|
app/auth/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Authentication module for Auth microservice
|
| 3 |
+
"""
|
app/auth/controllers/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Authentication controllers module
|
| 3 |
+
"""
|
app/auth/controllers/router.py
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Authentication router for login, logout, and token management endpoints.
|
| 3 |
+
Provides JWT-based authentication with enhanced security features.
|
| 4 |
+
"""
|
| 5 |
+
from datetime import timedelta
|
| 6 |
+
from typing import Optional, List, Dict
|
| 7 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Body, Request
|
| 8 |
+
from pydantic import BaseModel, EmailStr
|
| 9 |
+
import logging
|
| 10 |
+
|
| 11 |
+
from app.system_users.services.service import SystemUserService
|
| 12 |
+
from app.dependencies.auth import get_system_user_service, get_current_user
|
| 13 |
+
from app.system_users.models.model import SystemUserModel
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _get_accessible_widgets(user_role) -> List[Dict]:
|
| 21 |
+
"""Generate accessible widgets based on user role for authentication system."""
|
| 22 |
+
# Base widgets available to all roles - Authentication focused
|
| 23 |
+
base_widgets = [
|
| 24 |
+
{
|
| 25 |
+
"widget_id": "wid_login_count_001",
|
| 26 |
+
"widget_type": "kpi",
|
| 27 |
+
"title": "Login Count",
|
| 28 |
+
"accessible": True
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
"widget_id": "wid_active_users_001",
|
| 32 |
+
"widget_type": "kpi",
|
| 33 |
+
"title": "Active Users",
|
| 34 |
+
"accessible": True
|
| 35 |
+
},
|
| 36 |
+
{
|
| 37 |
+
"widget_id": "wid_failed_logins_001",
|
| 38 |
+
"widget_type": "kpi",
|
| 39 |
+
"title": "Failed Logins (24h)",
|
| 40 |
+
"accessible": True
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
"widget_id": "wid_user_roles_001",
|
| 44 |
+
"widget_type": "chart",
|
| 45 |
+
"title": "User Roles Distribution",
|
| 46 |
+
"accessible": True
|
| 47 |
+
},
|
| 48 |
+
{
|
| 49 |
+
"widget_id": "wid_login_trend_001",
|
| 50 |
+
"widget_type": "chart",
|
| 51 |
+
"title": "Login Trend (7 days)",
|
| 52 |
+
"accessible": True
|
| 53 |
+
}
|
| 54 |
+
]
|
| 55 |
+
|
| 56 |
+
# Advanced widgets for managers and above
|
| 57 |
+
advanced_widgets = [
|
| 58 |
+
{
|
| 59 |
+
"widget_id": "wid_security_events_001",
|
| 60 |
+
"widget_type": "table",
|
| 61 |
+
"title": "Recent Security Events",
|
| 62 |
+
"accessible": True
|
| 63 |
+
},
|
| 64 |
+
{
|
| 65 |
+
"widget_id": "wid_locked_accounts_001",
|
| 66 |
+
"widget_type": "table",
|
| 67 |
+
"title": "Locked Accounts",
|
| 68 |
+
"accessible": True
|
| 69 |
+
},
|
| 70 |
+
{
|
| 71 |
+
"widget_id": "wid_recent_registrations_001",
|
| 72 |
+
"widget_type": "table",
|
| 73 |
+
"title": "Recent User Registrations",
|
| 74 |
+
"accessible": True
|
| 75 |
+
}
|
| 76 |
+
]
|
| 77 |
+
|
| 78 |
+
# Return widgets based on role
|
| 79 |
+
if user_role.value in ["super_admin", "admin"]:
|
| 80 |
+
return base_widgets + advanced_widgets
|
| 81 |
+
elif user_role.value in ["manager"]:
|
| 82 |
+
return base_widgets + advanced_widgets[:2] # Limited advanced widgets
|
| 83 |
+
else:
|
| 84 |
+
return base_widgets # Basic widgets only
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
class LoginRequest(BaseModel):
|
| 88 |
+
"""Login request model."""
|
| 89 |
+
email_or_phone: str # Can be email, phone number, or username
|
| 90 |
+
password: str
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
class LoginResponse(BaseModel):
|
| 94 |
+
"""Login response model."""
|
| 95 |
+
access_token: str
|
| 96 |
+
refresh_token: str
|
| 97 |
+
token_type: str = "bearer"
|
| 98 |
+
expires_in: int = 1800 # 30 minutes
|
| 99 |
+
user: dict
|
| 100 |
+
access_menu: dict
|
| 101 |
+
warnings: Optional[str] = None
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class TokenRefreshRequest(BaseModel):
|
| 105 |
+
"""Token refresh request."""
|
| 106 |
+
refresh_token: str
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
@router.post("/login", response_model=LoginResponse)
|
| 110 |
+
async def login(
|
| 111 |
+
request: Request,
|
| 112 |
+
login_data: LoginRequest,
|
| 113 |
+
user_service: SystemUserService = Depends(get_system_user_service)
|
| 114 |
+
):
|
| 115 |
+
"""
|
| 116 |
+
Authenticate user and return JWT tokens.
|
| 117 |
+
|
| 118 |
+
- **email_or_phone**: User email, phone number, or username
|
| 119 |
+
- **password**: User password
|
| 120 |
+
"""
|
| 121 |
+
try:
|
| 122 |
+
# Get client IP and user agent for security tracking
|
| 123 |
+
client_ip = request.client.host if request.client else None
|
| 124 |
+
user_agent = request.headers.get("User-Agent")
|
| 125 |
+
|
| 126 |
+
# Authenticate user
|
| 127 |
+
user, message = await user_service.authenticate_user(
|
| 128 |
+
login_data.email_or_phone,
|
| 129 |
+
login_data.password,
|
| 130 |
+
ip_address=client_ip,
|
| 131 |
+
user_agent=user_agent
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
if not user:
|
| 135 |
+
raise HTTPException(
|
| 136 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 137 |
+
detail=message,
|
| 138 |
+
headers={"WWW-Authenticate": "Bearer"}
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
# Create tokens
|
| 142 |
+
access_token_expires = timedelta(minutes=30)
|
| 143 |
+
access_token = user_service.create_access_token(
|
| 144 |
+
data={"sub": user.user_id, "username": user.username, "role": user.role.value},
|
| 145 |
+
expires_delta=access_token_expires
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
refresh_token = user_service.create_refresh_token(
|
| 149 |
+
data={"sub": user.user_id, "username": user.username}
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
# Flatten permissions to dot notation
|
| 153 |
+
flattened_permissions = []
|
| 154 |
+
for module, actions in user.permissions.items():
|
| 155 |
+
for action in actions:
|
| 156 |
+
flattened_permissions.append(f"{module}.{action}")
|
| 157 |
+
|
| 158 |
+
# Generate accessible widgets based on user role
|
| 159 |
+
accessible_widgets = _get_accessible_widgets(user.role)
|
| 160 |
+
|
| 161 |
+
# Return user info without sensitive data
|
| 162 |
+
user_info = {
|
| 163 |
+
"user_id": user.user_id,
|
| 164 |
+
"username": user.username,
|
| 165 |
+
"email": user.email,
|
| 166 |
+
"first_name": user.first_name,
|
| 167 |
+
"last_name": user.last_name,
|
| 168 |
+
"role": user.role.value,
|
| 169 |
+
"permissions": user.permissions,
|
| 170 |
+
"status": user.status.value,
|
| 171 |
+
"last_login_at": user.last_login_at,
|
| 172 |
+
"metadata": user.metadata
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
# Access menu structure
|
| 176 |
+
access_menu = {
|
| 177 |
+
"permissions": flattened_permissions,
|
| 178 |
+
"accessible_widgets": accessible_widgets
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
logger.info(f"User logged in successfully: {user.username}")
|
| 182 |
+
|
| 183 |
+
return LoginResponse(
|
| 184 |
+
access_token=access_token,
|
| 185 |
+
refresh_token=refresh_token,
|
| 186 |
+
user=user_info,
|
| 187 |
+
access_menu=access_menu,
|
| 188 |
+
warnings=None
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
except HTTPException:
|
| 192 |
+
raise
|
| 193 |
+
except Exception as e:
|
| 194 |
+
logger.error(f"Login error: {e}")
|
| 195 |
+
raise HTTPException(
|
| 196 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 197 |
+
detail="Authentication failed"
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
class OAuth2LoginRequest(BaseModel):
|
| 202 |
+
"""OAuth2 compatible login request."""
|
| 203 |
+
username: str # Can be email or phone
|
| 204 |
+
password: str
|
| 205 |
+
grant_type: str = "password"
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
@router.post("/login-form")
|
| 209 |
+
async def login_form(
|
| 210 |
+
request: Request,
|
| 211 |
+
form_data: OAuth2LoginRequest,
|
| 212 |
+
user_service: SystemUserService = Depends(get_system_user_service)
|
| 213 |
+
):
|
| 214 |
+
"""
|
| 215 |
+
OAuth2 compatible login endpoint for form-based authentication.
|
| 216 |
+
"""
|
| 217 |
+
try:
|
| 218 |
+
# Get client IP and user agent
|
| 219 |
+
client_ip = request.client.host if request.client else None
|
| 220 |
+
user_agent = request.headers.get("User-Agent")
|
| 221 |
+
|
| 222 |
+
# Authenticate user
|
| 223 |
+
user, message = await user_service.authenticate_user(
|
| 224 |
+
form_data.username, # Can be email or phone
|
| 225 |
+
form_data.password,
|
| 226 |
+
ip_address=client_ip,
|
| 227 |
+
user_agent=user_agent
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
if not user:
|
| 231 |
+
raise HTTPException(
|
| 232 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 233 |
+
detail=message,
|
| 234 |
+
headers={"WWW-Authenticate": "Bearer"}
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
# Create access token
|
| 238 |
+
access_token_expires = timedelta(minutes=30)
|
| 239 |
+
access_token = user_service.create_access_token(
|
| 240 |
+
data={"sub": user.user_id, "username": user.username, "role": user.role.value},
|
| 241 |
+
expires_delta=access_token_expires
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
return {
|
| 245 |
+
"access_token": access_token,
|
| 246 |
+
"token_type": "bearer"
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
except HTTPException:
|
| 250 |
+
raise
|
| 251 |
+
except Exception as e:
|
| 252 |
+
logger.error(f"Form login error: {e}")
|
| 253 |
+
raise HTTPException(
|
| 254 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 255 |
+
detail="Authentication failed"
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
@router.post("/refresh")
|
| 260 |
+
async def refresh_token(
|
| 261 |
+
refresh_data: TokenRefreshRequest,
|
| 262 |
+
user_service: SystemUserService = Depends(get_system_user_service)
|
| 263 |
+
):
|
| 264 |
+
"""
|
| 265 |
+
Refresh access token using refresh token.
|
| 266 |
+
"""
|
| 267 |
+
try:
|
| 268 |
+
# Verify refresh token
|
| 269 |
+
payload = user_service.verify_token(refresh_data.refresh_token, "refresh")
|
| 270 |
+
if payload is None:
|
| 271 |
+
raise HTTPException(
|
| 272 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 273 |
+
detail="Invalid refresh token"
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
user_id = payload.get("sub")
|
| 277 |
+
username = payload.get("username")
|
| 278 |
+
|
| 279 |
+
# Get user to verify they still exist and are active
|
| 280 |
+
user = await user_service.get_user_by_id(user_id)
|
| 281 |
+
if not user or user.status.value != "active":
|
| 282 |
+
raise HTTPException(
|
| 283 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 284 |
+
detail="User not found or inactive"
|
| 285 |
+
)
|
| 286 |
+
|
| 287 |
+
# Create new access token
|
| 288 |
+
access_token_expires = timedelta(minutes=30)
|
| 289 |
+
access_token = user_service.create_access_token(
|
| 290 |
+
data={"sub": user_id, "username": username, "role": user.role.value},
|
| 291 |
+
expires_delta=access_token_expires
|
| 292 |
+
)
|
| 293 |
+
|
| 294 |
+
return {
|
| 295 |
+
"access_token": access_token,
|
| 296 |
+
"token_type": "bearer",
|
| 297 |
+
"expires_in": 1800
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
except HTTPException:
|
| 301 |
+
raise
|
| 302 |
+
except Exception as e:
|
| 303 |
+
logger.error(f"Token refresh error: {e}")
|
| 304 |
+
raise HTTPException(
|
| 305 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 306 |
+
detail="Token refresh failed"
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
@router.get("/me")
|
| 311 |
+
async def get_current_user_info(
|
| 312 |
+
current_user: SystemUserModel = Depends(get_current_user)
|
| 313 |
+
):
|
| 314 |
+
"""
|
| 315 |
+
Get current user information.
|
| 316 |
+
"""
|
| 317 |
+
return {
|
| 318 |
+
"user_id": current_user.user_id,
|
| 319 |
+
"username": current_user.username,
|
| 320 |
+
"email": current_user.email,
|
| 321 |
+
"first_name": current_user.first_name,
|
| 322 |
+
"last_name": current_user.last_name,
|
| 323 |
+
"role": current_user.role.value,
|
| 324 |
+
"permissions": current_user.permissions,
|
| 325 |
+
"status": current_user.status.value,
|
| 326 |
+
"last_login_at": current_user.last_login_at,
|
| 327 |
+
"timezone": current_user.timezone,
|
| 328 |
+
"language": current_user.language,
|
| 329 |
+
"metadata": current_user.metadata
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
@router.post("/logout")
|
| 334 |
+
async def logout(
|
| 335 |
+
current_user: SystemUserModel = Depends(get_current_user)
|
| 336 |
+
):
|
| 337 |
+
"""
|
| 338 |
+
Logout current user.
|
| 339 |
+
Note: In a production environment, you would want to blacklist the token.
|
| 340 |
+
"""
|
| 341 |
+
logger.info(f"User logged out: {current_user.username}")
|
| 342 |
+
return {"message": "Successfully logged out"}
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
@router.post("/test-login")
|
| 346 |
+
async def test_login():
|
| 347 |
+
"""
|
| 348 |
+
Test endpoint to verify authentication system is working.
|
| 349 |
+
Returns sample login credentials.
|
| 350 |
+
"""
|
| 351 |
+
return {
|
| 352 |
+
"message": "Authentication system is ready",
|
| 353 |
+
"test_credentials": [
|
| 354 |
+
{
|
| 355 |
+
"type": "Super Admin",
|
| 356 |
+
"email": "superadmin@cuatrobeauty.com",
|
| 357 |
+
"password": "SuperAdmin@123",
|
| 358 |
+
"description": "Full system access"
|
| 359 |
+
},
|
| 360 |
+
{
|
| 361 |
+
"type": "Company Admin",
|
| 362 |
+
"email": "admin@cuatrobeauty.com",
|
| 363 |
+
"password": "CompanyAdmin@123",
|
| 364 |
+
"description": "Company-wide management"
|
| 365 |
+
},
|
| 366 |
+
{
|
| 367 |
+
"type": "Manager",
|
| 368 |
+
"email": "manager@cuatrobeauty.com",
|
| 369 |
+
"password": "Manager@123",
|
| 370 |
+
"description": "Team management"
|
| 371 |
+
}
|
| 372 |
+
]
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
@router.get("/access-roles")
|
| 377 |
+
async def get_access_roles(
|
| 378 |
+
user_service: SystemUserService = Depends(get_system_user_service)
|
| 379 |
+
):
|
| 380 |
+
"""
|
| 381 |
+
Get available access roles and their permissions structure.
|
| 382 |
+
|
| 383 |
+
Returns the complete role hierarchy with grouped permissions.
|
| 384 |
+
"""
|
| 385 |
+
try:
|
| 386 |
+
# Get roles from database
|
| 387 |
+
roles = await user_service.get_all_roles()
|
| 388 |
+
|
| 389 |
+
return {
|
| 390 |
+
"message": "Access roles with grouped permissions structure",
|
| 391 |
+
"total_roles": len(roles),
|
| 392 |
+
"roles": [
|
| 393 |
+
{
|
| 394 |
+
"role_id": role.get("role_id"),
|
| 395 |
+
"role_name": role.get("role_name"),
|
| 396 |
+
"description": role.get("description"),
|
| 397 |
+
"permissions": role.get("permissions", {}),
|
| 398 |
+
"is_active": role.get("is_active", True)
|
| 399 |
+
}
|
| 400 |
+
for role in roles
|
| 401 |
+
]
|
| 402 |
+
}
|
| 403 |
+
except Exception as e:
|
| 404 |
+
logger.error(f"Error fetching access roles: {e}")
|
| 405 |
+
return {
|
| 406 |
+
"message": "Error fetching access roles",
|
| 407 |
+
"error": str(e)
|
| 408 |
+
}
|
app/cache.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Cache module for Auth microservice - Redis connection and caching utilities
|
| 3 |
+
"""
|
| 4 |
+
import redis
|
| 5 |
+
from typing import Optional, Any
|
| 6 |
+
import json
|
| 7 |
+
import logging
|
| 8 |
+
from app.core.config import settings
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class CacheService:
|
| 14 |
+
"""Redis cache service for authentication system"""
|
| 15 |
+
|
| 16 |
+
def __init__(self):
|
| 17 |
+
self._redis_client: Optional[redis.Redis] = None
|
| 18 |
+
|
| 19 |
+
def get_client(self) -> redis.Redis:
|
| 20 |
+
"""Get Redis client instance"""
|
| 21 |
+
if self._redis_client is None:
|
| 22 |
+
self._redis_client = redis.Redis(
|
| 23 |
+
host=settings.REDIS_HOST,
|
| 24 |
+
port=settings.REDIS_PORT,
|
| 25 |
+
password=settings.REDIS_PASSWORD,
|
| 26 |
+
db=settings.REDIS_DB,
|
| 27 |
+
decode_responses=True
|
| 28 |
+
)
|
| 29 |
+
return self._redis_client
|
| 30 |
+
|
| 31 |
+
async def set(self, key: str, value: Any, ttl: int = 300) -> bool:
|
| 32 |
+
"""Set a value in cache with TTL"""
|
| 33 |
+
try:
|
| 34 |
+
client = self.get_client()
|
| 35 |
+
serialized_value = json.dumps(value) if not isinstance(value, str) else value
|
| 36 |
+
return client.setex(key, ttl, serialized_value)
|
| 37 |
+
except Exception as e:
|
| 38 |
+
logger.error(f"Cache set error: {e}")
|
| 39 |
+
return False
|
| 40 |
+
|
| 41 |
+
async def get(self, key: str) -> Optional[Any]:
|
| 42 |
+
"""Get a value from cache"""
|
| 43 |
+
try:
|
| 44 |
+
client = self.get_client()
|
| 45 |
+
value = client.get(key)
|
| 46 |
+
if value:
|
| 47 |
+
try:
|
| 48 |
+
return json.loads(value)
|
| 49 |
+
except json.JSONDecodeError:
|
| 50 |
+
return value
|
| 51 |
+
return None
|
| 52 |
+
except Exception as e:
|
| 53 |
+
logger.error(f"Cache get error: {e}")
|
| 54 |
+
return None
|
| 55 |
+
|
| 56 |
+
async def delete(self, key: str) -> bool:
|
| 57 |
+
"""Delete a key from cache"""
|
| 58 |
+
try:
|
| 59 |
+
client = self.get_client()
|
| 60 |
+
return client.delete(key) > 0
|
| 61 |
+
except Exception as e:
|
| 62 |
+
logger.error(f"Cache delete error: {e}")
|
| 63 |
+
return False
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
# Global cache instance
|
| 67 |
+
cache_service = CacheService()
|
app/constants/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Constants module for Auth microservice
|
| 3 |
+
"""
|
app/constants/collections.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MongoDB collection names for Auth microservice.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
# Collection names
|
| 6 |
+
AUTH_SYSTEM_USERS_COLLECTION = "auth_system_users"
|
| 7 |
+
AUTH_ACCESS_ROLES_COLLECTION = "auth_access_roles"
|
| 8 |
+
AUTH_AUTH_LOGS_COLLECTION = "auth_auth_logs"
|
| 9 |
+
AUTH_OTP_COLLECTION = "auth_otp"
|
| 10 |
+
AUTH_PASSWORD_RESET_COLLECTION = "auth_password_reset"
|
| 11 |
+
AUTH_SESSIONS_COLLECTION = "auth_sessions"
|
app/core/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Core configuration module
|
| 3 |
+
"""
|
app/core/config.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Configuration settings for Auth microservice.
|
| 3 |
+
Loads environment variables and provides application settings.
|
| 4 |
+
"""
|
| 5 |
+
import os
|
| 6 |
+
from typing import Optional, List
|
| 7 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class Settings(BaseSettings):
|
| 11 |
+
"""Application settings loaded from environment variables"""
|
| 12 |
+
|
| 13 |
+
# Application
|
| 14 |
+
APP_NAME: str = "Auth Microservice"
|
| 15 |
+
APP_VERSION: str = "1.0.0"
|
| 16 |
+
DEBUG: bool = False
|
| 17 |
+
|
| 18 |
+
# MongoDB Configuration
|
| 19 |
+
MONGODB_URI: str = os.getenv("MONGODB_URI", "mongodb://localhost:27017")
|
| 20 |
+
MONGODB_DB_NAME: str = os.getenv("MONGODB_DB_NAME", "auth_db")
|
| 21 |
+
|
| 22 |
+
# Redis Configuration
|
| 23 |
+
REDIS_HOST: str = os.getenv("REDIS_HOST", "localhost")
|
| 24 |
+
REDIS_PORT: int = int(os.getenv("REDIS_PORT", "6379"))
|
| 25 |
+
REDIS_PASSWORD: Optional[str] = os.getenv("REDIS_PASSWORD")
|
| 26 |
+
REDIS_DB: int = int(os.getenv("REDIS_DB", "0"))
|
| 27 |
+
|
| 28 |
+
# JWT Configuration
|
| 29 |
+
SECRET_KEY: str = os.getenv("SECRET_KEY", "your-secret-key-change-in-production")
|
| 30 |
+
ALGORITHM: str = os.getenv("ALGORITHM", "HS256")
|
| 31 |
+
TOKEN_EXPIRATION_HOURS: int = int(os.getenv("TOKEN_EXPIRATION_HOURS", "8"))
|
| 32 |
+
|
| 33 |
+
# OTP Configuration
|
| 34 |
+
OTP_TTL_SECONDS: int = int(os.getenv("OTP_TTL_SECONDS", "600"))
|
| 35 |
+
OTP_RATE_LIMIT_MAX: int = int(os.getenv("OTP_RATE_LIMIT_MAX", "10"))
|
| 36 |
+
OTP_RATE_LIMIT_WINDOW: int = int(os.getenv("OTP_RATE_LIMIT_WINDOW", "600"))
|
| 37 |
+
|
| 38 |
+
# Twilio Configuration
|
| 39 |
+
TWILIO_ACCOUNT_SID: Optional[str] = os.getenv("TWILIO_ACCOUNT_SID")
|
| 40 |
+
TWILIO_AUTH_TOKEN: Optional[str] = os.getenv("TWILIO_AUTH_TOKEN")
|
| 41 |
+
TWILIO_PHONE_NUMBER: Optional[str] = os.getenv("TWILIO_PHONE_NUMBER")
|
| 42 |
+
|
| 43 |
+
# SMTP Configuration
|
| 44 |
+
SMTP_HOST: Optional[str] = os.getenv("SMTP_HOST")
|
| 45 |
+
SMTP_PORT: int = int(os.getenv("SMTP_PORT", "587"))
|
| 46 |
+
SMTP_USERNAME: Optional[str] = os.getenv("SMTP_USERNAME")
|
| 47 |
+
SMTP_PASSWORD: Optional[str] = os.getenv("SMTP_PASSWORD")
|
| 48 |
+
SMTP_FROM_EMAIL: Optional[str] = os.getenv("SMTP_FROM_EMAIL")
|
| 49 |
+
SMTP_USE_TLS: bool = os.getenv("SMTP_USE_TLS", "true").lower() == "true"
|
| 50 |
+
|
| 51 |
+
# Logging
|
| 52 |
+
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
|
| 53 |
+
|
| 54 |
+
# CORS
|
| 55 |
+
CORS_ORIGINS: List[str] = [
|
| 56 |
+
"http://localhost:3000",
|
| 57 |
+
"http://localhost:8000",
|
| 58 |
+
"http://localhost:8002",
|
| 59 |
+
]
|
| 60 |
+
|
| 61 |
+
# Pydantic v2 config
|
| 62 |
+
model_config = SettingsConfigDict(
|
| 63 |
+
env_file=".env",
|
| 64 |
+
env_file_encoding="utf-8",
|
| 65 |
+
case_sensitive=True,
|
| 66 |
+
extra="allow", # allows extra environment variables without error
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# Global settings instance
|
| 71 |
+
settings = Settings()
|
app/dependencies/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Dependencies module
|
| 3 |
+
"""
|
app/dependencies/auth.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Authentication dependencies for FastAPI.
|
| 3 |
+
"""
|
| 4 |
+
from typing import Optional
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from fastapi import Depends, HTTPException, status
|
| 7 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 8 |
+
from app.system_users.models.model import SystemUserModel, UserRole
|
| 9 |
+
from app.system_users.services.service import SystemUserService
|
| 10 |
+
from app.nosql import get_database
|
| 11 |
+
|
| 12 |
+
security = HTTPBearer()
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def get_system_user_service() -> SystemUserService:
|
| 16 |
+
"""
|
| 17 |
+
Dependency to get SystemUserService instance.
|
| 18 |
+
|
| 19 |
+
Returns:
|
| 20 |
+
SystemUserService: Service instance with database connection
|
| 21 |
+
"""
|
| 22 |
+
# get_database() returns AsyncIOMotorDatabase directly, no await needed
|
| 23 |
+
db = get_database()
|
| 24 |
+
return SystemUserService(db)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
async def get_current_user(
|
| 28 |
+
credentials: HTTPAuthorizationCredentials = Depends(security),
|
| 29 |
+
user_service: SystemUserService = Depends(get_system_user_service)
|
| 30 |
+
) -> SystemUserModel:
|
| 31 |
+
"""Get current authenticated user from JWT token."""
|
| 32 |
+
|
| 33 |
+
credentials_exception = HTTPException(
|
| 34 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 35 |
+
detail="Could not validate credentials",
|
| 36 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
try:
|
| 40 |
+
# Verify token
|
| 41 |
+
payload = user_service.verify_token(credentials.credentials, "access")
|
| 42 |
+
if payload is None:
|
| 43 |
+
raise credentials_exception
|
| 44 |
+
|
| 45 |
+
user_id: str = payload.get("sub")
|
| 46 |
+
if user_id is None:
|
| 47 |
+
raise credentials_exception
|
| 48 |
+
|
| 49 |
+
except Exception:
|
| 50 |
+
raise credentials_exception
|
| 51 |
+
|
| 52 |
+
# Get user from database
|
| 53 |
+
user = await user_service.get_user_by_id(user_id)
|
| 54 |
+
if user is None:
|
| 55 |
+
raise credentials_exception
|
| 56 |
+
|
| 57 |
+
# Check if user is active
|
| 58 |
+
if user.status.value != "active":
|
| 59 |
+
raise HTTPException(
|
| 60 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 61 |
+
detail="User account is not active"
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
return user
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
async def get_current_active_user(
|
| 68 |
+
current_user: SystemUserModel = Depends(get_current_user)
|
| 69 |
+
) -> SystemUserModel:
|
| 70 |
+
"""Get current active user (alias for get_current_user for clarity)."""
|
| 71 |
+
return current_user
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
async def require_admin_role(
|
| 75 |
+
current_user: SystemUserModel = Depends(get_current_user)
|
| 76 |
+
) -> SystemUserModel:
|
| 77 |
+
"""Require admin or super_admin role."""
|
| 78 |
+
if current_user.role not in [UserRole.ADMIN, UserRole.SUPER_ADMIN]:
|
| 79 |
+
raise HTTPException(
|
| 80 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 81 |
+
detail="Admin privileges required"
|
| 82 |
+
)
|
| 83 |
+
return current_user
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
async def require_super_admin_role(
|
| 87 |
+
current_user: SystemUserModel = Depends(get_current_user)
|
| 88 |
+
) -> SystemUserModel:
|
| 89 |
+
"""Require super_admin role."""
|
| 90 |
+
if current_user.role != UserRole.SUPER_ADMIN:
|
| 91 |
+
raise HTTPException(
|
| 92 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 93 |
+
detail="Super admin privileges required"
|
| 94 |
+
)
|
| 95 |
+
return current_user
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def require_permission(permission: str):
|
| 99 |
+
"""Dependency factory to require specific permission."""
|
| 100 |
+
async def permission_checker(
|
| 101 |
+
current_user: SystemUserModel = Depends(get_current_user)
|
| 102 |
+
) -> SystemUserModel:
|
| 103 |
+
if (permission not in current_user.permissions and
|
| 104 |
+
current_user.role not in [UserRole.ADMIN, UserRole.SUPER_ADMIN]):
|
| 105 |
+
raise HTTPException(
|
| 106 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 107 |
+
detail=f"Permission '{permission}' required"
|
| 108 |
+
)
|
| 109 |
+
return current_user
|
| 110 |
+
|
| 111 |
+
return permission_checker
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
async def get_optional_user(
|
| 115 |
+
credentials: Optional[HTTPAuthorizationCredentials] = Depends(HTTPBearer(auto_error=False)),
|
| 116 |
+
user_service: SystemUserService = Depends(get_system_user_service)
|
| 117 |
+
) -> Optional[SystemUserModel]:
|
| 118 |
+
"""Get current user if token is provided, otherwise return None."""
|
| 119 |
+
|
| 120 |
+
if credentials is None:
|
| 121 |
+
return None
|
| 122 |
+
|
| 123 |
+
try:
|
| 124 |
+
# Verify token
|
| 125 |
+
payload = user_service.verify_token(credentials.credentials, "access")
|
| 126 |
+
if payload is None:
|
| 127 |
+
return None
|
| 128 |
+
|
| 129 |
+
user_id: str = payload.get("sub")
|
| 130 |
+
if user_id is None:
|
| 131 |
+
return None
|
| 132 |
+
|
| 133 |
+
# Get user from database
|
| 134 |
+
user = await user_service.get_user_by_id(user_id)
|
| 135 |
+
if user is None or user.status.value != "active":
|
| 136 |
+
return None
|
| 137 |
+
|
| 138 |
+
return user
|
| 139 |
+
|
| 140 |
+
except Exception:
|
| 141 |
+
return None
|
app/insightfy_utils-0.1.0-py3-none-any.whl
ADDED
|
Binary file (32.2 kB). View file
|
|
|
app/main.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Main FastAPI application for AUTH Microservice.
|
| 3 |
+
"""
|
| 4 |
+
import logging
|
| 5 |
+
from contextlib import asynccontextmanager
|
| 6 |
+
from fastapi import FastAPI
|
| 7 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 8 |
+
from app.core.config import settings
|
| 9 |
+
|
| 10 |
+
from app.nosql import connect_to_mongo, close_mongo_connection
|
| 11 |
+
from app.system_users.controllers.router import router as system_user_router
|
| 12 |
+
from app.auth.controllers.router import router as auth_router
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
logging.basicConfig(level=logging.INFO)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@asynccontextmanager
|
| 19 |
+
async def lifespan(app: FastAPI):
|
| 20 |
+
"""Manage application lifespan events"""
|
| 21 |
+
# Startup
|
| 22 |
+
logger.info("Starting AUTH Microservice")
|
| 23 |
+
await connect_to_mongo()
|
| 24 |
+
logger.info("AUTH Microservice started successfully")
|
| 25 |
+
|
| 26 |
+
yield
|
| 27 |
+
|
| 28 |
+
# Shutdown
|
| 29 |
+
logger.info("Shutting down AUTH Microservice")
|
| 30 |
+
await close_mongo_connection()
|
| 31 |
+
logger.info("AUTH Microservice shut down successfully")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# Create FastAPI app
|
| 35 |
+
app = FastAPI(
|
| 36 |
+
title="AUTH Microservice",
|
| 37 |
+
description="Authentication & Authorization System - User Management, Login, JWT Tokens & Security",
|
| 38 |
+
version="1.0.0",
|
| 39 |
+
docs_url="/docs",
|
| 40 |
+
redoc_url="/redoc",
|
| 41 |
+
lifespan=lifespan
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
# CORS middleware
|
| 45 |
+
app.add_middleware(
|
| 46 |
+
CORSMiddleware,
|
| 47 |
+
allow_origins=settings.CORS_ORIGINS,
|
| 48 |
+
allow_credentials=True,
|
| 49 |
+
allow_methods=["*"],
|
| 50 |
+
allow_headers=["*"],
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# Health check endpoint
|
| 55 |
+
@app.get("/health", tags=["health"])
|
| 56 |
+
async def health_check():
|
| 57 |
+
"""Health check endpoint"""
|
| 58 |
+
return {
|
| 59 |
+
"status": "healthy",
|
| 60 |
+
"service": "auth-microservice",
|
| 61 |
+
"version": "1.0.0"
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# Include routers
|
| 66 |
+
app.include_router(auth_router) # Authentication endpoints (login, logout, token refresh)
|
| 67 |
+
app.include_router(system_user_router) # User management endpoints (CRUD operations)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
if __name__ == "__main__":
|
| 71 |
+
import uvicorn
|
| 72 |
+
uvicorn.run(
|
| 73 |
+
"app.main:app",
|
| 74 |
+
host="0.0.0.0",
|
| 75 |
+
port=8002,
|
| 76 |
+
reload=True,
|
| 77 |
+
log_level="info"
|
| 78 |
+
)
|
app/nosql.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MongoDB connection and database instance.
|
| 3 |
+
Provides a singleton database connection for the application.
|
| 4 |
+
"""
|
| 5 |
+
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
|
| 6 |
+
import logging
|
| 7 |
+
from app.core.config import settings
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class DatabaseConnection:
|
| 13 |
+
"""Singleton class to manage MongoDB connection"""
|
| 14 |
+
_client: AsyncIOMotorClient = None
|
| 15 |
+
_db: AsyncIOMotorDatabase = None
|
| 16 |
+
|
| 17 |
+
@classmethod
|
| 18 |
+
def get_database(cls) -> AsyncIOMotorDatabase:
|
| 19 |
+
"""
|
| 20 |
+
Get the database instance.
|
| 21 |
+
|
| 22 |
+
Returns:
|
| 23 |
+
MongoDB database instance
|
| 24 |
+
|
| 25 |
+
Raises:
|
| 26 |
+
RuntimeError if database is not connected
|
| 27 |
+
"""
|
| 28 |
+
if cls._db is None:
|
| 29 |
+
raise RuntimeError("Database not connected. Call connect_to_mongo() first.")
|
| 30 |
+
return cls._db
|
| 31 |
+
|
| 32 |
+
@classmethod
|
| 33 |
+
async def connect(cls):
|
| 34 |
+
"""
|
| 35 |
+
Establish connection to MongoDB.
|
| 36 |
+
Called during application startup.
|
| 37 |
+
"""
|
| 38 |
+
try:
|
| 39 |
+
logger.info(f"Connecting to MongoDB: {settings.MONGODB_URI}")
|
| 40 |
+
|
| 41 |
+
cls._client = AsyncIOMotorClient(settings.MONGODB_URI)
|
| 42 |
+
cls._db = cls._client[settings.MONGODB_DB_NAME]
|
| 43 |
+
|
| 44 |
+
# Test the connection
|
| 45 |
+
await cls._client.admin.command('ping')
|
| 46 |
+
|
| 47 |
+
logger.info(f"Successfully connected to MongoDB database: {settings.MONGODB_DB_NAME}")
|
| 48 |
+
except Exception as e:
|
| 49 |
+
logger.error(f"Failed to connect to MongoDB: {e}")
|
| 50 |
+
raise
|
| 51 |
+
|
| 52 |
+
@classmethod
|
| 53 |
+
async def close(cls):
|
| 54 |
+
"""
|
| 55 |
+
Close MongoDB connection.
|
| 56 |
+
Called during application shutdown.
|
| 57 |
+
"""
|
| 58 |
+
if cls._client:
|
| 59 |
+
logger.info("Closing MongoDB connection")
|
| 60 |
+
cls._client.close()
|
| 61 |
+
logger.info("MongoDB connection closed")
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# Public API
|
| 65 |
+
async def connect_to_mongo():
|
| 66 |
+
"""Establish connection to MongoDB"""
|
| 67 |
+
await DatabaseConnection.connect()
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
async def close_mongo_connection():
|
| 71 |
+
"""Close MongoDB connection"""
|
| 72 |
+
await DatabaseConnection.close()
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def get_database() -> AsyncIOMotorDatabase:
|
| 76 |
+
"""Get the database instance"""
|
| 77 |
+
return DatabaseConnection.get_database()
|
app/system_users/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
System Users module for authentication and user management
|
| 3 |
+
"""
|
app/system_users/controllers/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
System Users controllers
|
| 3 |
+
"""
|
app/system_users/controllers/router.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
System User router for authentication and user management endpoints.
|
| 3 |
+
"""
|
| 4 |
+
from datetime import timedelta
|
| 5 |
+
from typing import List, Optional
|
| 6 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
| 7 |
+
from fastapi.security import HTTPAuthorizationCredentials
|
| 8 |
+
import logging
|
| 9 |
+
|
| 10 |
+
from app.system_users.services.service import SystemUserService, ACCESS_TOKEN_EXPIRE_MINUTES
|
| 11 |
+
from app.system_users.schemas.schema import (
|
| 12 |
+
LoginRequest,
|
| 13 |
+
LoginResponse,
|
| 14 |
+
CreateUserRequest,
|
| 15 |
+
UpdateUserRequest,
|
| 16 |
+
ChangePasswordRequest,
|
| 17 |
+
UserInfoResponse,
|
| 18 |
+
UserListResponse,
|
| 19 |
+
StandardResponse
|
| 20 |
+
)
|
| 21 |
+
from app.system_users.models.model import UserStatus, SystemUserModel
|
| 22 |
+
from app.dependencies.auth import (
|
| 23 |
+
get_system_user_service,
|
| 24 |
+
get_current_user,
|
| 25 |
+
require_admin_role,
|
| 26 |
+
require_super_admin_role
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
logger = logging.getLogger(__name__)
|
| 30 |
+
|
| 31 |
+
router = APIRouter(
|
| 32 |
+
prefix="/auth",
|
| 33 |
+
tags=["Authentication & User Management"]
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@router.post("/login", response_model=LoginResponse)
|
| 38 |
+
async def login(
|
| 39 |
+
request: Request,
|
| 40 |
+
login_data: LoginRequest,
|
| 41 |
+
user_service: SystemUserService = Depends(get_system_user_service)
|
| 42 |
+
):
|
| 43 |
+
"""
|
| 44 |
+
Authenticate user and return access token.
|
| 45 |
+
"""
|
| 46 |
+
try:
|
| 47 |
+
# Get client IP and user agent
|
| 48 |
+
client_ip = request.client.host if request.client else None
|
| 49 |
+
user_agent = request.headers.get("User-Agent")
|
| 50 |
+
|
| 51 |
+
# Authenticate user
|
| 52 |
+
user, message = await user_service.authenticate_user(
|
| 53 |
+
email_or_phone=login_data.email_or_phone,
|
| 54 |
+
password=login_data.password,
|
| 55 |
+
ip_address=client_ip,
|
| 56 |
+
user_agent=user_agent
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
if not user:
|
| 60 |
+
raise HTTPException(
|
| 61 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 62 |
+
detail=message,
|
| 63 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
# Create access token
|
| 67 |
+
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 68 |
+
if login_data.remember_me:
|
| 69 |
+
access_token_expires = timedelta(hours=24) # Longer expiry for remember me
|
| 70 |
+
|
| 71 |
+
access_token = user_service.create_access_token(
|
| 72 |
+
data={"sub": user.user_id, "username": user.username, "role": user.role.value},
|
| 73 |
+
expires_delta=access_token_expires
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
# Convert user to response model
|
| 77 |
+
user_info = user_service.convert_to_user_info_response(user)
|
| 78 |
+
|
| 79 |
+
logger.info(f"User logged in successfully: {user.username}")
|
| 80 |
+
|
| 81 |
+
return LoginResponse(
|
| 82 |
+
access_token=access_token,
|
| 83 |
+
token_type="bearer",
|
| 84 |
+
expires_in=int(access_token_expires.total_seconds()),
|
| 85 |
+
user_info=user_info
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
except HTTPException:
|
| 89 |
+
raise
|
| 90 |
+
except Exception as e:
|
| 91 |
+
logger.error(f"Login error: {e}")
|
| 92 |
+
raise HTTPException(
|
| 93 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 94 |
+
detail="Login failed"
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
@router.get("/me", response_model=UserInfoResponse)
|
| 99 |
+
async def get_current_user_info(
|
| 100 |
+
current_user: SystemUserModel = Depends(get_current_user),
|
| 101 |
+
user_service: SystemUserService = Depends(get_system_user_service)
|
| 102 |
+
):
|
| 103 |
+
"""
|
| 104 |
+
Get current user information.
|
| 105 |
+
"""
|
| 106 |
+
return user_service.convert_to_user_info_response(current_user)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
@router.post("/users", response_model=UserInfoResponse)
|
| 110 |
+
async def create_user(
|
| 111 |
+
user_data: CreateUserRequest,
|
| 112 |
+
current_user: SystemUserModel = Depends(require_admin_role),
|
| 113 |
+
user_service: SystemUserService = Depends(get_system_user_service)
|
| 114 |
+
):
|
| 115 |
+
"""
|
| 116 |
+
Create a new user account. Requires admin privileges.
|
| 117 |
+
"""
|
| 118 |
+
try:
|
| 119 |
+
new_user = await user_service.create_user(user_data, current_user.user_id)
|
| 120 |
+
return user_service.convert_to_user_info_response(new_user)
|
| 121 |
+
|
| 122 |
+
except HTTPException:
|
| 123 |
+
raise
|
| 124 |
+
except Exception as e:
|
| 125 |
+
logger.error(f"Error creating user: {e}")
|
| 126 |
+
raise HTTPException(
|
| 127 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 128 |
+
detail="Failed to create user"
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
@router.get("/users", response_model=UserListResponse)
|
| 133 |
+
async def list_users(
|
| 134 |
+
page: int = 1,
|
| 135 |
+
page_size: int = 20,
|
| 136 |
+
status_filter: Optional[UserStatus] = None,
|
| 137 |
+
current_user: SystemUserModel = Depends(require_admin_role),
|
| 138 |
+
user_service: SystemUserService = Depends(get_system_user_service)
|
| 139 |
+
):
|
| 140 |
+
"""
|
| 141 |
+
List users with pagination. Requires admin privileges.
|
| 142 |
+
"""
|
| 143 |
+
try:
|
| 144 |
+
if page_size > 100:
|
| 145 |
+
page_size = 100 # Limit maximum page size
|
| 146 |
+
|
| 147 |
+
users, total_count = await user_service.list_users(page, page_size, status_filter)
|
| 148 |
+
|
| 149 |
+
user_responses = [
|
| 150 |
+
user_service.convert_to_user_info_response(user) for user in users
|
| 151 |
+
]
|
| 152 |
+
|
| 153 |
+
return UserListResponse(
|
| 154 |
+
users=user_responses,
|
| 155 |
+
total_count=total_count,
|
| 156 |
+
page=page,
|
| 157 |
+
page_size=page_size
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
except Exception as e:
|
| 161 |
+
logger.error(f"Error listing users: {e}")
|
| 162 |
+
raise HTTPException(
|
| 163 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 164 |
+
detail="Failed to retrieve users"
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
@router.get("/users/{user_id}", response_model=UserInfoResponse)
|
| 169 |
+
async def get_user_by_id(
|
| 170 |
+
user_id: str,
|
| 171 |
+
current_user: SystemUserModel = Depends(require_admin_role),
|
| 172 |
+
user_service: SystemUserService = Depends(get_system_user_service)
|
| 173 |
+
):
|
| 174 |
+
"""
|
| 175 |
+
Get user by ID. Requires admin privileges.
|
| 176 |
+
"""
|
| 177 |
+
user = await user_service.get_user_by_id(user_id)
|
| 178 |
+
if not user:
|
| 179 |
+
raise HTTPException(
|
| 180 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 181 |
+
detail="User not found"
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
return user_service.convert_to_user_info_response(user)
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
@router.put("/users/{user_id}", response_model=UserInfoResponse)
|
| 188 |
+
async def update_user(
|
| 189 |
+
user_id: str,
|
| 190 |
+
update_data: UpdateUserRequest,
|
| 191 |
+
current_user: SystemUserModel = Depends(require_admin_role),
|
| 192 |
+
user_service: SystemUserService = Depends(get_system_user_service)
|
| 193 |
+
):
|
| 194 |
+
"""
|
| 195 |
+
Update user information. Requires admin privileges.
|
| 196 |
+
"""
|
| 197 |
+
try:
|
| 198 |
+
updated_user = await user_service.update_user(user_id, update_data, current_user.user_id)
|
| 199 |
+
if not updated_user:
|
| 200 |
+
raise HTTPException(
|
| 201 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 202 |
+
detail="User not found"
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
return user_service.convert_to_user_info_response(updated_user)
|
| 206 |
+
|
| 207 |
+
except HTTPException:
|
| 208 |
+
raise
|
| 209 |
+
except Exception as e:
|
| 210 |
+
logger.error(f"Error updating user {user_id}: {e}")
|
| 211 |
+
raise HTTPException(
|
| 212 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 213 |
+
detail="Failed to update user"
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
@router.put("/change-password", response_model=StandardResponse)
|
| 218 |
+
async def change_password(
|
| 219 |
+
password_data: ChangePasswordRequest,
|
| 220 |
+
current_user: SystemUserModel = Depends(get_current_user),
|
| 221 |
+
user_service: SystemUserService = Depends(get_system_user_service)
|
| 222 |
+
):
|
| 223 |
+
"""
|
| 224 |
+
Change current user's password.
|
| 225 |
+
"""
|
| 226 |
+
try:
|
| 227 |
+
success = await user_service.change_password(
|
| 228 |
+
user_id=current_user.user_id,
|
| 229 |
+
current_password=password_data.current_password,
|
| 230 |
+
new_password=password_data.new_password
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
if not success:
|
| 234 |
+
raise HTTPException(
|
| 235 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 236 |
+
detail="Current password is incorrect"
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
return StandardResponse(
|
| 240 |
+
success=True,
|
| 241 |
+
message="Password changed successfully"
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
except HTTPException:
|
| 245 |
+
raise
|
| 246 |
+
except Exception as e:
|
| 247 |
+
logger.error(f"Error changing password for user {current_user.user_id}: {e}")
|
| 248 |
+
raise HTTPException(
|
| 249 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 250 |
+
detail="Failed to change password"
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
@router.delete("/users/{user_id}", response_model=StandardResponse)
|
| 255 |
+
async def deactivate_user(
|
| 256 |
+
user_id: str,
|
| 257 |
+
current_user: SystemUserModel = Depends(require_admin_role),
|
| 258 |
+
user_service: SystemUserService = Depends(get_system_user_service)
|
| 259 |
+
):
|
| 260 |
+
"""
|
| 261 |
+
Deactivate user account. Requires admin privileges.
|
| 262 |
+
"""
|
| 263 |
+
try:
|
| 264 |
+
# Prevent self-deactivation
|
| 265 |
+
if user_id == current_user.user_id:
|
| 266 |
+
raise HTTPException(
|
| 267 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 268 |
+
detail="Cannot deactivate your own account"
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
success = await user_service.deactivate_user(user_id, current_user.user_id)
|
| 272 |
+
if not success:
|
| 273 |
+
raise HTTPException(
|
| 274 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 275 |
+
detail="User not found"
|
| 276 |
+
)
|
| 277 |
+
|
| 278 |
+
return StandardResponse(
|
| 279 |
+
success=True,
|
| 280 |
+
message="User deactivated successfully"
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
except HTTPException:
|
| 284 |
+
raise
|
| 285 |
+
except Exception as e:
|
| 286 |
+
logger.error(f"Error deactivating user {user_id}: {e}")
|
| 287 |
+
raise HTTPException(
|
| 288 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 289 |
+
detail="Failed to deactivate user"
|
| 290 |
+
)
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
@router.post("/logout", response_model=StandardResponse)
|
| 294 |
+
async def logout(
|
| 295 |
+
current_user: SystemUserModel = Depends(get_current_user)
|
| 296 |
+
):
|
| 297 |
+
"""
|
| 298 |
+
Logout current user.
|
| 299 |
+
Note: Since we're using stateless JWT tokens, actual logout would require
|
| 300 |
+
token blacklisting on the client side or implementing a token blacklist on server.
|
| 301 |
+
"""
|
| 302 |
+
logger.info(f"User logged out: {current_user.username}")
|
| 303 |
+
|
| 304 |
+
return StandardResponse(
|
| 305 |
+
success=True,
|
| 306 |
+
message="Logged out successfully"
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
# Create default super admin endpoint (for initial setup)
|
| 311 |
+
@router.post("/setup/super-admin", response_model=UserInfoResponse)
|
| 312 |
+
async def create_super_admin(
|
| 313 |
+
user_data: CreateUserRequest,
|
| 314 |
+
user_service: SystemUserService = Depends(get_system_user_service)
|
| 315 |
+
):
|
| 316 |
+
"""
|
| 317 |
+
Create the first super admin user. Only works if no users exist in the system.
|
| 318 |
+
"""
|
| 319 |
+
try:
|
| 320 |
+
# Check if any users exist
|
| 321 |
+
users, total_count = await user_service.list_users(page=1, page_size=1)
|
| 322 |
+
if total_count > 0:
|
| 323 |
+
raise HTTPException(
|
| 324 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 325 |
+
detail="Super admin already exists or users are present in system"
|
| 326 |
+
)
|
| 327 |
+
|
| 328 |
+
# Force super admin role
|
| 329 |
+
user_data.role = "super_admin"
|
| 330 |
+
|
| 331 |
+
# Create super admin
|
| 332 |
+
super_admin = await user_service.create_user(user_data, "system")
|
| 333 |
+
|
| 334 |
+
logger.info(f"Super admin created: {super_admin.username}")
|
| 335 |
+
|
| 336 |
+
return user_service.convert_to_user_info_response(super_admin)
|
| 337 |
+
|
| 338 |
+
except HTTPException:
|
| 339 |
+
raise
|
| 340 |
+
except Exception as e:
|
| 341 |
+
logger.error(f"Error creating super admin: {e}")
|
| 342 |
+
raise HTTPException(
|
| 343 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 344 |
+
detail="Failed to create super admin"
|
| 345 |
+
)
|
app/system_users/models/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
System Users models
|
| 3 |
+
"""
|
app/system_users/models/model.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
System User model for authentication and authorization.
|
| 3 |
+
Represents a system user with login credentials and permissions.
|
| 4 |
+
"""
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from typing import Optional, Dict, Any, List
|
| 7 |
+
from pydantic import BaseModel, Field, EmailStr
|
| 8 |
+
from enum import Enum
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class UserStatus(str, Enum):
|
| 12 |
+
"""User account status options."""
|
| 13 |
+
ACTIVE = "active"
|
| 14 |
+
INACTIVE = "inactive"
|
| 15 |
+
SUSPENDED = "suspended"
|
| 16 |
+
LOCKED = "locked"
|
| 17 |
+
PENDING_ACTIVATION = "pending_activation"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class UserRole(str, Enum):
|
| 21 |
+
"""System user roles."""
|
| 22 |
+
SUPER_ADMIN = "super_admin"
|
| 23 |
+
ADMIN = "admin"
|
| 24 |
+
MANAGER = "manager"
|
| 25 |
+
USER = "user"
|
| 26 |
+
READ_ONLY = "read_only"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class LoginAttemptModel(BaseModel):
|
| 30 |
+
"""Login attempt tracking."""
|
| 31 |
+
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
| 32 |
+
ip_address: Optional[str] = Field(None, description="IP address of login attempt")
|
| 33 |
+
user_agent: Optional[str] = Field(None, description="User agent string")
|
| 34 |
+
success: bool = Field(..., description="Whether login was successful")
|
| 35 |
+
failure_reason: Optional[str] = Field(None, description="Reason for failure if unsuccessful")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class SecuritySettingsModel(BaseModel):
|
| 39 |
+
"""User security settings."""
|
| 40 |
+
require_password_change: bool = Field(default=False, description="Force password change on next login")
|
| 41 |
+
password_expires_at: Optional[datetime] = Field(None, description="Password expiry date")
|
| 42 |
+
failed_login_attempts: int = Field(default=0, description="Count of consecutive failed login attempts")
|
| 43 |
+
last_failed_login: Optional[datetime] = Field(None, description="Timestamp of last failed login")
|
| 44 |
+
account_locked_until: Optional[datetime] = Field(None, description="Account lock expiry time")
|
| 45 |
+
last_password_change: Optional[datetime] = Field(None, description="Last password change timestamp")
|
| 46 |
+
login_attempts: List[LoginAttemptModel] = Field(default_factory=list, description="Recent login attempts (last 10)")
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class SystemUserModel(BaseModel):
|
| 50 |
+
"""
|
| 51 |
+
System User data model for authentication and authorization.
|
| 52 |
+
Represents the complete user document in MongoDB.
|
| 53 |
+
"""
|
| 54 |
+
user_id: str = Field(..., description="Unique user identifier (UUID/ULID)")
|
| 55 |
+
username: str = Field(..., description="Unique username (lowercase alphanumeric)")
|
| 56 |
+
email: EmailStr = Field(..., description="User email address")
|
| 57 |
+
|
| 58 |
+
# Authentication
|
| 59 |
+
password_hash: str = Field(..., description="Bcrypt hashed password")
|
| 60 |
+
|
| 61 |
+
# Personal information
|
| 62 |
+
first_name: str = Field(..., description="User first name")
|
| 63 |
+
last_name: Optional[str] = Field(None, description="User last name")
|
| 64 |
+
phone: Optional[str] = Field(None, description="User phone number (E.164 format)")
|
| 65 |
+
|
| 66 |
+
# Authorization
|
| 67 |
+
role: UserRole = Field(default=UserRole.USER, description="Primary user role")
|
| 68 |
+
permissions: Dict[str, List[str]] = Field(default_factory=dict, description="Grouped permissions by module")
|
| 69 |
+
|
| 70 |
+
# Status and security
|
| 71 |
+
status: UserStatus = Field(default=UserStatus.PENDING_ACTIVATION, description="Account status")
|
| 72 |
+
security_settings: SecuritySettingsModel = Field(
|
| 73 |
+
default_factory=SecuritySettingsModel,
|
| 74 |
+
description="Security and login settings"
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
# Session management
|
| 78 |
+
last_login_at: Optional[datetime] = Field(None, description="Last successful login timestamp")
|
| 79 |
+
last_login_ip: Optional[str] = Field(None, description="IP address of last login")
|
| 80 |
+
current_session_token: Optional[str] = Field(None, description="Current JWT token hash for session management")
|
| 81 |
+
|
| 82 |
+
# Profile information
|
| 83 |
+
profile_picture_url: Optional[str] = Field(None, description="URL to profile picture")
|
| 84 |
+
timezone: str = Field(default="UTC", description="User timezone")
|
| 85 |
+
language: str = Field(default="en", description="Preferred language code")
|
| 86 |
+
|
| 87 |
+
# Audit fields
|
| 88 |
+
created_by: str = Field(..., description="User ID who created this user account")
|
| 89 |
+
created_at: datetime = Field(default_factory=datetime.utcnow, description="Account creation timestamp")
|
| 90 |
+
updated_at: Optional[datetime] = Field(None, description="Last update timestamp")
|
| 91 |
+
updated_by: Optional[str] = Field(None, description="User ID who last updated this record")
|
| 92 |
+
|
| 93 |
+
# Additional data
|
| 94 |
+
metadata: Optional[Dict[str, Any]] = Field(None, description="Additional metadata")
|
| 95 |
+
|
| 96 |
+
class Config:
|
| 97 |
+
json_schema_extra = {
|
| 98 |
+
"example": {
|
| 99 |
+
"user_id": "usr_01HZQX5K3N2P8R6T4V9W",
|
| 100 |
+
"username": "john.doe",
|
| 101 |
+
"email": "john.doe@company.com",
|
| 102 |
+
"password_hash": "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LeVMstdMT6jmDQrji",
|
| 103 |
+
"first_name": "John",
|
| 104 |
+
"last_name": "Doe",
|
| 105 |
+
"phone": "+919876543210",
|
| 106 |
+
"role": "admin",
|
| 107 |
+
"permissions": {
|
| 108 |
+
"customers": ["view", "create", "update"],
|
| 109 |
+
"orders": ["view", "create", "update"],
|
| 110 |
+
"settings": ["view", "update"]
|
| 111 |
+
},
|
| 112 |
+
"status": "active",
|
| 113 |
+
"security_settings": {
|
| 114 |
+
"require_password_change": False,
|
| 115 |
+
"failed_login_attempts": 0,
|
| 116 |
+
"login_attempts": []
|
| 117 |
+
},
|
| 118 |
+
"last_login_at": "2024-11-30T10:30:00Z",
|
| 119 |
+
"last_login_ip": "192.168.1.100",
|
| 120 |
+
"timezone": "Asia/Kolkata",
|
| 121 |
+
"language": "en",
|
| 122 |
+
"created_by": "system",
|
| 123 |
+
"created_at": "2024-01-15T08:00:00Z"
|
| 124 |
+
}
|
| 125 |
+
}
|
app/system_users/schemas/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
System Users schemas
|
| 3 |
+
"""
|
app/system_users/schemas/schema.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
System User schemas for request/response models.
|
| 3 |
+
"""
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from typing import Optional, List, Dict
|
| 6 |
+
from pydantic import BaseModel, Field, EmailStr, validator
|
| 7 |
+
from app.system_users.models.model import UserStatus, UserRole
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class LoginRequest(BaseModel):
|
| 11 |
+
"""Login request schema."""
|
| 12 |
+
email_or_phone: str = Field(..., description="Email address or phone number", min_length=3, max_length=100)
|
| 13 |
+
password: str = Field(..., description="User password", min_length=6, max_length=100)
|
| 14 |
+
remember_me: bool = Field(default=False, description="Keep session active for longer period")
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class LoginResponse(BaseModel):
|
| 18 |
+
"""Login response schema."""
|
| 19 |
+
access_token: str = Field(..., description="JWT access token")
|
| 20 |
+
token_type: str = Field(default="bearer", description="Token type")
|
| 21 |
+
expires_in: int = Field(..., description="Token expiry time in seconds")
|
| 22 |
+
user_info: "UserInfoResponse" = Field(..., description="Basic user information")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class UserInfoResponse(BaseModel):
|
| 26 |
+
"""User information response schema."""
|
| 27 |
+
user_id: str = Field(..., description="Unique user identifier")
|
| 28 |
+
username: str = Field(..., description="Username")
|
| 29 |
+
email: str = Field(..., description="Email address")
|
| 30 |
+
first_name: str = Field(..., description="First name")
|
| 31 |
+
last_name: Optional[str] = Field(None, description="Last name")
|
| 32 |
+
role: UserRole = Field(..., description="User role")
|
| 33 |
+
permissions: Dict[str, List[str]] = Field(default_factory=dict, description="User permissions")
|
| 34 |
+
status: UserStatus = Field(..., description="Account status")
|
| 35 |
+
last_login_at: Optional[datetime] = Field(None, description="Last login timestamp")
|
| 36 |
+
profile_picture_url: Optional[str] = Field(None, description="Profile picture URL")
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class CreateUserRequest(BaseModel):
|
| 40 |
+
"""Create user request schema."""
|
| 41 |
+
username: str = Field(..., description="Unique username", min_length=3, max_length=30)
|
| 42 |
+
email: EmailStr = Field(..., description="Email address")
|
| 43 |
+
password: str = Field(..., description="Password", min_length=8, max_length=100)
|
| 44 |
+
first_name: str = Field(..., description="First name", min_length=1, max_length=50)
|
| 45 |
+
last_name: Optional[str] = Field(None, description="Last name", max_length=50)
|
| 46 |
+
phone: Optional[str] = Field(None, description="Phone number")
|
| 47 |
+
role: UserRole = Field(default=UserRole.USER, description="User role")
|
| 48 |
+
permissions: Dict[str, List[str]] = Field(default_factory=dict, description="Additional permissions")
|
| 49 |
+
|
| 50 |
+
@validator('username')
|
| 51 |
+
def validate_username(cls, v):
|
| 52 |
+
if not v.replace('_', '').replace('.', '').isalnum():
|
| 53 |
+
raise ValueError('Username can only contain alphanumeric characters, underscores, and periods')
|
| 54 |
+
return v.lower()
|
| 55 |
+
|
| 56 |
+
@validator('password')
|
| 57 |
+
def validate_password(cls, v):
|
| 58 |
+
if len(v) < 8:
|
| 59 |
+
raise ValueError('Password must be at least 8 characters long')
|
| 60 |
+
if not any(c.isupper() for c in v):
|
| 61 |
+
raise ValueError('Password must contain at least one uppercase letter')
|
| 62 |
+
if not any(c.islower() for c in v):
|
| 63 |
+
raise ValueError('Password must contain at least one lowercase letter')
|
| 64 |
+
if not any(c.isdigit() for c in v):
|
| 65 |
+
raise ValueError('Password must contain at least one digit')
|
| 66 |
+
return v
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class UpdateUserRequest(BaseModel):
|
| 70 |
+
"""Update user request schema."""
|
| 71 |
+
first_name: Optional[str] = Field(None, description="First name", min_length=1, max_length=50)
|
| 72 |
+
last_name: Optional[str] = Field(None, description="Last name", max_length=50)
|
| 73 |
+
phone: Optional[str] = Field(None, description="Phone number")
|
| 74 |
+
role: Optional[UserRole] = Field(None, description="User role")
|
| 75 |
+
permissions: Optional[Dict[str, List[str]]] = Field(None, description="User permissions")
|
| 76 |
+
status: Optional[UserStatus] = Field(None, description="Account status")
|
| 77 |
+
timezone: Optional[str] = Field(None, description="User timezone")
|
| 78 |
+
language: Optional[str] = Field(None, description="Preferred language")
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class ChangePasswordRequest(BaseModel):
|
| 82 |
+
"""Change password request schema."""
|
| 83 |
+
current_password: str = Field(..., description="Current password")
|
| 84 |
+
new_password: str = Field(..., description="New password", min_length=8, max_length=100)
|
| 85 |
+
|
| 86 |
+
@validator('new_password')
|
| 87 |
+
def validate_new_password(cls, v):
|
| 88 |
+
if len(v) < 8:
|
| 89 |
+
raise ValueError('Password must be at least 8 characters long')
|
| 90 |
+
if not any(c.isupper() for c in v):
|
| 91 |
+
raise ValueError('Password must contain at least one uppercase letter')
|
| 92 |
+
if not any(c.islower() for c in v):
|
| 93 |
+
raise ValueError('Password must contain at least one lowercase letter')
|
| 94 |
+
if not any(c.isdigit() for c in v):
|
| 95 |
+
raise ValueError('Password must contain at least one digit')
|
| 96 |
+
return v
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
class ResetPasswordRequest(BaseModel):
|
| 100 |
+
"""Reset password request schema."""
|
| 101 |
+
email: EmailStr = Field(..., description="Email address")
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class UserListResponse(BaseModel):
|
| 105 |
+
"""User list response schema."""
|
| 106 |
+
users: List[UserInfoResponse] = Field(..., description="List of users")
|
| 107 |
+
total_count: int = Field(..., description="Total number of users")
|
| 108 |
+
page: int = Field(..., description="Current page number")
|
| 109 |
+
page_size: int = Field(..., description="Number of items per page")
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
class TokenRefreshRequest(BaseModel):
|
| 113 |
+
"""Token refresh request schema."""
|
| 114 |
+
refresh_token: str = Field(..., description="Refresh token")
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
class LogoutRequest(BaseModel):
|
| 118 |
+
"""Logout request schema."""
|
| 119 |
+
logout_from_all_devices: bool = Field(default=False, description="Logout from all devices")
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
# Response models
|
| 123 |
+
class StandardResponse(BaseModel):
|
| 124 |
+
"""Standard API response."""
|
| 125 |
+
success: bool = Field(..., description="Operation success status")
|
| 126 |
+
message: str = Field(..., description="Response message")
|
| 127 |
+
data: Optional[dict] = Field(None, description="Response data")
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# Update forward references
|
| 131 |
+
LoginResponse.model_rebuild()
|
app/system_users/services/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
System Users services
|
| 3 |
+
"""
|
app/system_users/services/service.py
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
System User service for authentication and user management.
|
| 3 |
+
"""
|
| 4 |
+
import secrets
|
| 5 |
+
from datetime import datetime, timedelta
|
| 6 |
+
from typing import Optional, List, Dict, Any, Tuple
|
| 7 |
+
from motor.motor_asyncio import AsyncIOMotorDatabase
|
| 8 |
+
from passlib.context import CryptContext
|
| 9 |
+
from jose import JWTError, jwt
|
| 10 |
+
from fastapi import HTTPException, status
|
| 11 |
+
import logging
|
| 12 |
+
|
| 13 |
+
from app.system_users.models.model import (
|
| 14 |
+
SystemUserModel,
|
| 15 |
+
UserStatus,
|
| 16 |
+
UserRole,
|
| 17 |
+
LoginAttemptModel,
|
| 18 |
+
SecuritySettingsModel
|
| 19 |
+
)
|
| 20 |
+
from app.system_users.schemas.schema import (
|
| 21 |
+
CreateUserRequest,
|
| 22 |
+
UpdateUserRequest,
|
| 23 |
+
ChangePasswordRequest,
|
| 24 |
+
UserInfoResponse
|
| 25 |
+
)
|
| 26 |
+
from app.constants.collections import AUTH_SYSTEM_USERS_COLLECTION, AUTH_ACCESS_ROLES_COLLECTION
|
| 27 |
+
from app.core.config import settings
|
| 28 |
+
|
| 29 |
+
logger = logging.getLogger(__name__)
|
| 30 |
+
|
| 31 |
+
# Password hashing context
|
| 32 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 33 |
+
|
| 34 |
+
# JWT settings
|
| 35 |
+
ALGORITHM = "HS256"
|
| 36 |
+
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
| 37 |
+
REFRESH_TOKEN_EXPIRE_DAYS = 7
|
| 38 |
+
MAX_FAILED_LOGIN_ATTEMPTS = 5
|
| 39 |
+
ACCOUNT_LOCK_DURATION_MINUTES = 15
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class SystemUserService:
|
| 43 |
+
"""Service class for system user operations."""
|
| 44 |
+
|
| 45 |
+
def __init__(self, db: AsyncIOMotorDatabase):
|
| 46 |
+
self.db = db
|
| 47 |
+
self.collection = db[AUTH_SYSTEM_USERS_COLLECTION]
|
| 48 |
+
|
| 49 |
+
@staticmethod
|
| 50 |
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
| 51 |
+
"""Verify a password against its hash."""
|
| 52 |
+
try:
|
| 53 |
+
return pwd_context.verify(plain_password, hashed_password)
|
| 54 |
+
except Exception as e:
|
| 55 |
+
logger.error(f"Error verifying password: {e}")
|
| 56 |
+
return False
|
| 57 |
+
|
| 58 |
+
@staticmethod
|
| 59 |
+
def get_password_hash(password: str) -> str:
|
| 60 |
+
"""Generate password hash."""
|
| 61 |
+
return pwd_context.hash(password)
|
| 62 |
+
|
| 63 |
+
@staticmethod
|
| 64 |
+
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
| 65 |
+
"""Create JWT access token."""
|
| 66 |
+
to_encode = data.copy()
|
| 67 |
+
if expires_delta:
|
| 68 |
+
expire = datetime.utcnow() + expires_delta
|
| 69 |
+
else:
|
| 70 |
+
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 71 |
+
|
| 72 |
+
to_encode.update({"exp": expire, "type": "access"})
|
| 73 |
+
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
| 74 |
+
return encoded_jwt
|
| 75 |
+
|
| 76 |
+
@staticmethod
|
| 77 |
+
def create_refresh_token(data: dict) -> str:
|
| 78 |
+
"""Create JWT refresh token."""
|
| 79 |
+
to_encode = data.copy()
|
| 80 |
+
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
| 81 |
+
to_encode.update({"exp": expire, "type": "refresh"})
|
| 82 |
+
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
| 83 |
+
return encoded_jwt
|
| 84 |
+
|
| 85 |
+
@staticmethod
|
| 86 |
+
def verify_token(token: str, token_type: str = "access") -> Optional[Dict[str, Any]]:
|
| 87 |
+
"""Verify JWT token and return payload."""
|
| 88 |
+
try:
|
| 89 |
+
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
| 90 |
+
if payload.get("type") != token_type:
|
| 91 |
+
return None
|
| 92 |
+
return payload
|
| 93 |
+
except JWTError as e:
|
| 94 |
+
logger.warning(f"Token verification failed: {e}")
|
| 95 |
+
return None
|
| 96 |
+
|
| 97 |
+
async def get_user_by_id(self, user_id: str) -> Optional[SystemUserModel]:
|
| 98 |
+
"""Get user by user_id."""
|
| 99 |
+
try:
|
| 100 |
+
user_doc = await self.collection.find_one({"user_id": user_id})
|
| 101 |
+
if user_doc:
|
| 102 |
+
return SystemUserModel(**user_doc)
|
| 103 |
+
return None
|
| 104 |
+
except Exception as e:
|
| 105 |
+
logger.error(f"Error getting user by ID {user_id}: {e}")
|
| 106 |
+
return None
|
| 107 |
+
|
| 108 |
+
async def get_user_by_username(self, username: str) -> Optional[SystemUserModel]:
|
| 109 |
+
"""Get user by username."""
|
| 110 |
+
try:
|
| 111 |
+
user_doc = await self.collection.find_one({"username": username.lower()})
|
| 112 |
+
if user_doc:
|
| 113 |
+
return SystemUserModel(**user_doc)
|
| 114 |
+
return None
|
| 115 |
+
except Exception as e:
|
| 116 |
+
logger.error(f"Error getting user by username {username}: {e}")
|
| 117 |
+
return None
|
| 118 |
+
|
| 119 |
+
async def get_user_by_email(self, email: str) -> Optional[SystemUserModel]:
|
| 120 |
+
"""Get user by email."""
|
| 121 |
+
try:
|
| 122 |
+
user_doc = await self.collection.find_one({"email": email.lower()})
|
| 123 |
+
if user_doc:
|
| 124 |
+
return SystemUserModel(**user_doc)
|
| 125 |
+
return None
|
| 126 |
+
except Exception as e:
|
| 127 |
+
logger.error(f"Error getting user by email {email}: {e}")
|
| 128 |
+
return None
|
| 129 |
+
|
| 130 |
+
async def get_user_by_phone(self, phone: str) -> Optional[SystemUserModel]:
|
| 131 |
+
"""Get user by phone number."""
|
| 132 |
+
try:
|
| 133 |
+
user_doc = await self.collection.find_one({"phone": phone})
|
| 134 |
+
if user_doc:
|
| 135 |
+
return SystemUserModel(**user_doc)
|
| 136 |
+
return None
|
| 137 |
+
except Exception as e:
|
| 138 |
+
logger.error(f"Error getting user by phone {phone}: {e}")
|
| 139 |
+
return None
|
| 140 |
+
|
| 141 |
+
async def create_user(self, user_data: CreateUserRequest, created_by: str) -> SystemUserModel:
|
| 142 |
+
"""Create a new user."""
|
| 143 |
+
try:
|
| 144 |
+
# Check if username or email already exists
|
| 145 |
+
existing_user = await self.get_user_by_username(user_data.username)
|
| 146 |
+
if existing_user:
|
| 147 |
+
raise HTTPException(
|
| 148 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 149 |
+
detail="Username already exists"
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
existing_email = await self.get_user_by_email(user_data.email)
|
| 153 |
+
if existing_email:
|
| 154 |
+
raise HTTPException(
|
| 155 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 156 |
+
detail="Email already exists"
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
# Generate user ID
|
| 160 |
+
user_id = f"usr_{secrets.token_urlsafe(16)}"
|
| 161 |
+
|
| 162 |
+
# Hash password
|
| 163 |
+
password_hash = self.get_password_hash(user_data.password)
|
| 164 |
+
|
| 165 |
+
# Create user model
|
| 166 |
+
user_model = SystemUserModel(
|
| 167 |
+
user_id=user_id,
|
| 168 |
+
username=user_data.username.lower(),
|
| 169 |
+
email=user_data.email.lower(),
|
| 170 |
+
password_hash=password_hash,
|
| 171 |
+
first_name=user_data.first_name,
|
| 172 |
+
last_name=user_data.last_name,
|
| 173 |
+
phone=user_data.phone,
|
| 174 |
+
role=user_data.role,
|
| 175 |
+
permissions=user_data.permissions,
|
| 176 |
+
status=UserStatus.ACTIVE, # Set as active by default
|
| 177 |
+
created_by=created_by,
|
| 178 |
+
created_at=datetime.utcnow()
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
# Insert to database
|
| 182 |
+
await self.collection.insert_one(user_model.model_dump())
|
| 183 |
+
|
| 184 |
+
logger.info(f"User created successfully: {user_id}")
|
| 185 |
+
return user_model
|
| 186 |
+
|
| 187 |
+
except HTTPException:
|
| 188 |
+
raise
|
| 189 |
+
except Exception as e:
|
| 190 |
+
logger.error(f"Error creating user: {e}")
|
| 191 |
+
raise HTTPException(
|
| 192 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 193 |
+
detail="Failed to create user"
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
async def authenticate_user(self, email_or_phone: str, password: str, ip_address: Optional[str] = None, user_agent: Optional[str] = None) -> Tuple[Optional[SystemUserModel], str]:
|
| 197 |
+
"""Authenticate user with email/phone/username and password."""
|
| 198 |
+
try:
|
| 199 |
+
# Get user by email, phone, or username
|
| 200 |
+
user = await self.get_user_by_email(email_or_phone)
|
| 201 |
+
if not user:
|
| 202 |
+
user = await self.get_user_by_phone(email_or_phone)
|
| 203 |
+
if not user:
|
| 204 |
+
user = await self.get_user_by_username(email_or_phone)
|
| 205 |
+
|
| 206 |
+
# Record failed attempt if user not found
|
| 207 |
+
if not user:
|
| 208 |
+
logger.warning(f"Login attempt with non-existent email/phone/username: {email_or_phone}")
|
| 209 |
+
return None, "Invalid email, phone number, or username"
|
| 210 |
+
|
| 211 |
+
# Check if account is locked
|
| 212 |
+
if (user.security_settings.account_locked_until and
|
| 213 |
+
user.security_settings.account_locked_until > datetime.utcnow()):
|
| 214 |
+
return None, f"Account is locked until {user.security_settings.account_locked_until}"
|
| 215 |
+
|
| 216 |
+
# Check account status
|
| 217 |
+
if user.status not in [UserStatus.ACTIVE]:
|
| 218 |
+
return None, f"Account is {user.status.value}"
|
| 219 |
+
|
| 220 |
+
# Verify password
|
| 221 |
+
if not self.verify_password(password, user.password_hash):
|
| 222 |
+
await self._record_failed_login(user, ip_address, user_agent)
|
| 223 |
+
return None, "Invalid username or password"
|
| 224 |
+
|
| 225 |
+
# Password correct - reset failed attempts and record successful login
|
| 226 |
+
await self._record_successful_login(user, ip_address, user_agent)
|
| 227 |
+
|
| 228 |
+
return user, "Authentication successful"
|
| 229 |
+
|
| 230 |
+
except Exception as e:
|
| 231 |
+
logger.error(f"Error during authentication: {e}")
|
| 232 |
+
return None, "Authentication failed"
|
| 233 |
+
|
| 234 |
+
async def _record_failed_login(self, user: SystemUserModel, ip_address: Optional[str], user_agent: Optional[str]):
|
| 235 |
+
"""Record failed login attempt and update security settings."""
|
| 236 |
+
try:
|
| 237 |
+
failed_attempts = user.security_settings.failed_login_attempts + 1
|
| 238 |
+
now = datetime.utcnow()
|
| 239 |
+
|
| 240 |
+
# Add login attempt to history
|
| 241 |
+
login_attempt = LoginAttemptModel(
|
| 242 |
+
timestamp=now,
|
| 243 |
+
ip_address=ip_address,
|
| 244 |
+
user_agent=user_agent,
|
| 245 |
+
success=False,
|
| 246 |
+
failure_reason="Invalid password"
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
# Keep only last 10 attempts
|
| 250 |
+
attempts_history = user.security_settings.login_attempts[-9:] + [login_attempt]
|
| 251 |
+
|
| 252 |
+
update_data = {
|
| 253 |
+
"security_settings.failed_login_attempts": failed_attempts,
|
| 254 |
+
"security_settings.last_failed_login": now,
|
| 255 |
+
"security_settings.login_attempts": [attempt.model_dump() for attempt in attempts_history],
|
| 256 |
+
"updated_at": now
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
# Lock account if too many failed attempts
|
| 260 |
+
if failed_attempts >= MAX_FAILED_LOGIN_ATTEMPTS:
|
| 261 |
+
lock_until = now + timedelta(minutes=ACCOUNT_LOCK_DURATION_MINUTES)
|
| 262 |
+
update_data["security_settings.account_locked_until"] = lock_until
|
| 263 |
+
logger.warning(f"Account locked due to failed login attempts: {user.user_id}")
|
| 264 |
+
|
| 265 |
+
await self.collection.update_one(
|
| 266 |
+
{"user_id": user.user_id},
|
| 267 |
+
{"$set": update_data}
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
except Exception as e:
|
| 271 |
+
logger.error(f"Error recording failed login: {e}")
|
| 272 |
+
|
| 273 |
+
async def _record_successful_login(self, user: SystemUserModel, ip_address: Optional[str], user_agent: Optional[str]):
|
| 274 |
+
"""Record successful login and reset security counters."""
|
| 275 |
+
try:
|
| 276 |
+
now = datetime.utcnow()
|
| 277 |
+
|
| 278 |
+
# Add login attempt to history
|
| 279 |
+
login_attempt = LoginAttemptModel(
|
| 280 |
+
timestamp=now,
|
| 281 |
+
ip_address=ip_address,
|
| 282 |
+
user_agent=user_agent,
|
| 283 |
+
success=True
|
| 284 |
+
)
|
| 285 |
+
|
| 286 |
+
# Keep only last 10 attempts
|
| 287 |
+
attempts_history = user.security_settings.login_attempts[-9:] + [login_attempt]
|
| 288 |
+
|
| 289 |
+
await self.collection.update_one(
|
| 290 |
+
{"user_id": user.user_id},
|
| 291 |
+
{"$set": {
|
| 292 |
+
"last_login_at": now,
|
| 293 |
+
"last_login_ip": ip_address,
|
| 294 |
+
"security_settings.failed_login_attempts": 0,
|
| 295 |
+
"security_settings.last_failed_login": None,
|
| 296 |
+
"security_settings.account_locked_until": None,
|
| 297 |
+
"security_settings.login_attempts": [attempt.model_dump() for attempt in attempts_history],
|
| 298 |
+
"updated_at": now
|
| 299 |
+
}}
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
except Exception as e:
|
| 303 |
+
logger.error(f"Error recording successful login: {e}")
|
| 304 |
+
|
| 305 |
+
async def update_user(self, user_id: str, update_data: UpdateUserRequest, updated_by: str) -> Optional[SystemUserModel]:
|
| 306 |
+
"""Update user information."""
|
| 307 |
+
try:
|
| 308 |
+
user = await self.get_user_by_id(user_id)
|
| 309 |
+
if not user:
|
| 310 |
+
return None
|
| 311 |
+
|
| 312 |
+
update_dict = {}
|
| 313 |
+
for field, value in update_data.dict(exclude_unset=True).items():
|
| 314 |
+
if value is not None:
|
| 315 |
+
update_dict[field] = value
|
| 316 |
+
|
| 317 |
+
if update_dict:
|
| 318 |
+
update_dict["updated_at"] = datetime.utcnow()
|
| 319 |
+
update_dict["updated_by"] = updated_by
|
| 320 |
+
|
| 321 |
+
await self.collection.update_one(
|
| 322 |
+
{"user_id": user_id},
|
| 323 |
+
{"$set": update_dict}
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
return await self.get_user_by_id(user_id)
|
| 327 |
+
|
| 328 |
+
except Exception as e:
|
| 329 |
+
logger.error(f"Error updating user {user_id}: {e}")
|
| 330 |
+
return None
|
| 331 |
+
|
| 332 |
+
async def change_password(self, user_id: str, current_password: str, new_password: str) -> bool:
|
| 333 |
+
"""Change user password."""
|
| 334 |
+
try:
|
| 335 |
+
user = await self.get_user_by_id(user_id)
|
| 336 |
+
if not user:
|
| 337 |
+
return False
|
| 338 |
+
|
| 339 |
+
# Verify current password
|
| 340 |
+
if not self.verify_password(current_password, user.password_hash):
|
| 341 |
+
return False
|
| 342 |
+
|
| 343 |
+
# Hash new password
|
| 344 |
+
new_password_hash = self.get_password_hash(new_password)
|
| 345 |
+
|
| 346 |
+
# Update password
|
| 347 |
+
await self.collection.update_one(
|
| 348 |
+
{"user_id": user_id},
|
| 349 |
+
{"$set": {
|
| 350 |
+
"password_hash": new_password_hash,
|
| 351 |
+
"security_settings.last_password_change": datetime.utcnow(),
|
| 352 |
+
"security_settings.require_password_change": False,
|
| 353 |
+
"updated_at": datetime.utcnow()
|
| 354 |
+
}}
|
| 355 |
+
)
|
| 356 |
+
|
| 357 |
+
logger.info(f"Password changed for user: {user_id}")
|
| 358 |
+
return True
|
| 359 |
+
|
| 360 |
+
except Exception as e:
|
| 361 |
+
logger.error(f"Error changing password for user {user_id}: {e}")
|
| 362 |
+
return False
|
| 363 |
+
|
| 364 |
+
async def list_users(self, page: int = 1, page_size: int = 20, status_filter: Optional[UserStatus] = None) -> Tuple[List[SystemUserModel], int]:
|
| 365 |
+
"""List users with pagination."""
|
| 366 |
+
try:
|
| 367 |
+
skip = (page - 1) * page_size
|
| 368 |
+
|
| 369 |
+
# Build query filter
|
| 370 |
+
query_filter = {}
|
| 371 |
+
if status_filter:
|
| 372 |
+
query_filter["status"] = status_filter.value
|
| 373 |
+
|
| 374 |
+
# Get total count
|
| 375 |
+
total_count = await self.collection.count_documents(query_filter)
|
| 376 |
+
|
| 377 |
+
# Get users - don't exclude password_hash to avoid validation errors
|
| 378 |
+
cursor = self.collection.find(query_filter).skip(skip).limit(page_size).sort("created_at", -1)
|
| 379 |
+
users = []
|
| 380 |
+
async for user_doc in cursor:
|
| 381 |
+
users.append(SystemUserModel(**user_doc))
|
| 382 |
+
return users, total_count
|
| 383 |
+
|
| 384 |
+
except Exception as e:
|
| 385 |
+
logger.error(f"Error listing users: {e}")
|
| 386 |
+
return [], 0
|
| 387 |
+
|
| 388 |
+
async def deactivate_user(self, user_id: str, deactivated_by: str) -> bool:
|
| 389 |
+
"""Deactivate user account."""
|
| 390 |
+
try:
|
| 391 |
+
result = await self.collection.update_one(
|
| 392 |
+
{"user_id": user_id},
|
| 393 |
+
{"$set": {
|
| 394 |
+
"status": UserStatus.INACTIVE.value,
|
| 395 |
+
"updated_at": datetime.utcnow(),
|
| 396 |
+
"updated_by": deactivated_by
|
| 397 |
+
}}
|
| 398 |
+
)
|
| 399 |
+
|
| 400 |
+
if result.modified_count > 0:
|
| 401 |
+
logger.info(f"User deactivated: {user_id}")
|
| 402 |
+
return True
|
| 403 |
+
return False
|
| 404 |
+
|
| 405 |
+
except Exception as e:
|
| 406 |
+
logger.error(f"Error deactivating user {user_id}: {e}")
|
| 407 |
+
return False
|
| 408 |
+
|
| 409 |
+
def convert_to_user_info_response(self, user: SystemUserModel) -> UserInfoResponse:
|
| 410 |
+
"""Convert SystemUserModel to UserInfoResponse."""
|
| 411 |
+
return UserInfoResponse(
|
| 412 |
+
user_id=user.user_id,
|
| 413 |
+
username=user.username,
|
| 414 |
+
email=user.email,
|
| 415 |
+
first_name=user.first_name,
|
| 416 |
+
last_name=user.last_name,
|
| 417 |
+
role=user.role,
|
| 418 |
+
permissions=user.permissions,
|
| 419 |
+
status=user.status,
|
| 420 |
+
last_login_at=user.last_login_at,
|
| 421 |
+
profile_picture_url=user.profile_picture_url
|
| 422 |
+
)
|
| 423 |
+
|
| 424 |
+
async def get_all_roles(self) -> List[dict]:
|
| 425 |
+
"""Get all access roles from database."""
|
| 426 |
+
try:
|
| 427 |
+
cursor = self.db[AUTH_ACCESS_ROLES_COLLECTION].find({})
|
| 428 |
+
roles = await cursor.to_list(length=None)
|
| 429 |
+
return roles
|
| 430 |
+
except Exception as e:
|
| 431 |
+
logger.error(f"Error fetching access roles: {e}")
|
| 432 |
+
return []
|
app/utils/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Utilities module for Auth microservice
|
| 3 |
+
"""
|
manage_db.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Database management utilities for Auth microservice
|
| 3 |
+
"""
|
| 4 |
+
import asyncio
|
| 5 |
+
import logging
|
| 6 |
+
from motor.motor_asyncio import AsyncIOMotorClient
|
| 7 |
+
from app.core.config import settings
|
| 8 |
+
from app.constants.collections import (
|
| 9 |
+
AUTH_SYSTEM_USERS_COLLECTION,
|
| 10 |
+
AUTH_ACCESS_ROLES_COLLECTION
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
async def create_indexes():
|
| 17 |
+
"""Create database indexes for better performance"""
|
| 18 |
+
try:
|
| 19 |
+
client = AsyncIOMotorClient(settings.MONGODB_URI)
|
| 20 |
+
db = client[settings.MONGODB_DB_NAME]
|
| 21 |
+
|
| 22 |
+
# System Users indexes
|
| 23 |
+
users_collection = db[AUTH_SYSTEM_USERS_COLLECTION]
|
| 24 |
+
|
| 25 |
+
# Create indexes for users collection
|
| 26 |
+
await users_collection.create_index("user_id", unique=True)
|
| 27 |
+
await users_collection.create_index("username", unique=True)
|
| 28 |
+
await users_collection.create_index("email", unique=True)
|
| 29 |
+
await users_collection.create_index("phone")
|
| 30 |
+
await users_collection.create_index("status")
|
| 31 |
+
await users_collection.create_index("created_at")
|
| 32 |
+
|
| 33 |
+
# Access Roles indexes
|
| 34 |
+
roles_collection = db[AUTH_ACCESS_ROLES_COLLECTION]
|
| 35 |
+
await roles_collection.create_index("role_id", unique=True)
|
| 36 |
+
await roles_collection.create_index("role_name", unique=True)
|
| 37 |
+
|
| 38 |
+
logger.info("Database indexes created successfully")
|
| 39 |
+
|
| 40 |
+
except Exception as e:
|
| 41 |
+
logger.error(f"Error creating indexes: {e}")
|
| 42 |
+
finally:
|
| 43 |
+
client.close()
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
async def create_default_roles():
|
| 47 |
+
"""Create default system roles if they don't exist"""
|
| 48 |
+
try:
|
| 49 |
+
client = AsyncIOMotorClient(settings.MONGODB_URI)
|
| 50 |
+
db = client[settings.MONGODB_DB_NAME]
|
| 51 |
+
roles_collection = db[AUTH_ACCESS_ROLES_COLLECTION]
|
| 52 |
+
|
| 53 |
+
default_roles = [
|
| 54 |
+
{
|
| 55 |
+
"role_id": "role_super_admin",
|
| 56 |
+
"role_name": "super_admin",
|
| 57 |
+
"description": "Super Administrator with full system access",
|
| 58 |
+
"permissions": {
|
| 59 |
+
"users": ["view", "create", "update", "delete"],
|
| 60 |
+
"roles": ["view", "create", "update", "delete"],
|
| 61 |
+
"settings": ["view", "update"],
|
| 62 |
+
"auth": ["view", "manage"],
|
| 63 |
+
"system": ["view", "manage"]
|
| 64 |
+
},
|
| 65 |
+
"is_active": True
|
| 66 |
+
},
|
| 67 |
+
{
|
| 68 |
+
"role_id": "role_admin",
|
| 69 |
+
"role_name": "admin",
|
| 70 |
+
"description": "Administrator with limited system access",
|
| 71 |
+
"permissions": {
|
| 72 |
+
"users": ["view", "create", "update"],
|
| 73 |
+
"roles": ["view"],
|
| 74 |
+
"settings": ["view", "update"],
|
| 75 |
+
"auth": ["view"]
|
| 76 |
+
},
|
| 77 |
+
"is_active": True
|
| 78 |
+
},
|
| 79 |
+
{
|
| 80 |
+
"role_id": "role_manager",
|
| 81 |
+
"role_name": "manager",
|
| 82 |
+
"description": "Manager with team management capabilities",
|
| 83 |
+
"permissions": {
|
| 84 |
+
"users": ["view", "update"],
|
| 85 |
+
"auth": ["view"]
|
| 86 |
+
},
|
| 87 |
+
"is_active": True
|
| 88 |
+
},
|
| 89 |
+
{
|
| 90 |
+
"role_id": "role_user",
|
| 91 |
+
"role_name": "user",
|
| 92 |
+
"description": "Standard user with basic access",
|
| 93 |
+
"permissions": {
|
| 94 |
+
"auth": ["view"]
|
| 95 |
+
},
|
| 96 |
+
"is_active": True
|
| 97 |
+
}
|
| 98 |
+
]
|
| 99 |
+
|
| 100 |
+
for role in default_roles:
|
| 101 |
+
existing = await roles_collection.find_one({"role_name": role["role_name"]})
|
| 102 |
+
if not existing:
|
| 103 |
+
await roles_collection.insert_one(role)
|
| 104 |
+
logger.info(f"Created default role: {role['role_name']}")
|
| 105 |
+
|
| 106 |
+
logger.info("Default roles setup completed")
|
| 107 |
+
|
| 108 |
+
except Exception as e:
|
| 109 |
+
logger.error(f"Error creating default roles: {e}")
|
| 110 |
+
finally:
|
| 111 |
+
client.close()
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
async def init_database():
|
| 115 |
+
"""Initialize database with indexes and default data"""
|
| 116 |
+
logger.info("Initializing database...")
|
| 117 |
+
await create_indexes()
|
| 118 |
+
await create_default_roles()
|
| 119 |
+
logger.info("Database initialization completed")
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
if __name__ == "__main__":
|
| 123 |
+
asyncio.run(init_database())
|
requirements.txt
CHANGED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.104.1
|
| 2 |
+
uvicorn[standard]==0.24.0
|
| 3 |
+
python-multipart==0.0.6
|
| 4 |
+
|
| 5 |
+
motor==3.3.2
|
| 6 |
+
pymongo==4.6.0
|
| 7 |
+
email-validator==2.3.0
|
| 8 |
+
redis==5.0.1
|
| 9 |
+
|
| 10 |
+
python-jose[cryptography]==3.3.0
|
| 11 |
+
passlib[bcrypt]==1.7.4
|
| 12 |
+
bcrypt==4.1.3
|
| 13 |
+
pydantic>=2.12.5,<3.0.0
|
| 14 |
+
pydantic-settings>=2.0.0
|
| 15 |
+
|
| 16 |
+
pytest==7.4.3
|
| 17 |
+
pytest-asyncio==0.21.1
|
| 18 |
+
httpx==0.25.2
|
| 19 |
+
hypothesis==6.92.1
|
| 20 |
+
|
| 21 |
+
python-dotenv==1.0.0
|
| 22 |
+
|
| 23 |
+
twilio==8.10.3
|
| 24 |
+
aiosmtplib==3.0.1
|
| 25 |
+
|
| 26 |
+
python-json-logger==2.0.7
|
start_server.sh
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
|
| 3 |
+
# Start Auth Microservice
|
| 4 |
+
|
| 5 |
+
echo "Starting Auth Microservice..."
|
| 6 |
+
|
| 7 |
+
# Check if virtual environment exists
|
| 8 |
+
if [ ! -d "venv" ]; then
|
| 9 |
+
echo "Creating virtual environment..."
|
| 10 |
+
python3 -m venv venv
|
| 11 |
+
fi
|
| 12 |
+
|
| 13 |
+
# Activate virtual environment
|
| 14 |
+
source venv/bin/activate
|
| 15 |
+
|
| 16 |
+
# Install dependencies
|
| 17 |
+
echo "Installing dependencies..."
|
| 18 |
+
pip install -r requirements.txt
|
| 19 |
+
|
| 20 |
+
# Run the application
|
| 21 |
+
echo "Starting FastAPI server..."
|
| 22 |
+
uvicorn app.main:app --host 0.0.0.0 --port 8002 --reload
|