diff --git a/mcpgateway/admin.py b/mcpgateway/admin.py index e45604d54..2ac819401 100644 --- a/mcpgateway/admin.py +++ b/mcpgateway/admin.py @@ -2984,7 +2984,20 @@ async def change_password_required_page(request: Request) -> HTMLResponse: # Get root path for template root_path = request.scope.get("root_path", "") - return request.app.state.templates.TemplateResponse("change-password-required.html", {"request": request, "root_path": root_path}) + # Pass password policy flags so the template can conditionally render requirements + return request.app.state.templates.TemplateResponse( + "change-password-required.html", + { + "request": request, + "root_path": root_path, + "password_require_uppercase": settings.password_require_uppercase, + "password_require_lowercase": settings.password_require_lowercase, + "password_require_numbers": settings.password_require_numbers, + "password_require_min_length": settings.password_require_min_length, + "password_require_special": settings.password_require_special, + "password_min_length": settings.password_min_length, + }, + ) @admin_router.post("/change-password-required") diff --git a/mcpgateway/bootstrap_db.py b/mcpgateway/bootstrap_db.py index 8a9987789..d753b525d 100644 --- a/mcpgateway/bootstrap_db.py +++ b/mcpgateway/bootstrap_db.py @@ -76,12 +76,27 @@ async def bootstrap_admin_user() -> None: # Create admin user logger.info(f"Creating platform admin user: {settings.platform_admin_email}") + # Create admin user. Skip strict password validation during bootstrap + # so deployments that enable stricter policies later don't prevent + # initial platform admin creation with the configured default. + # We set an instance attribute on the service instead of passing a + # new kwarg to keep the create_user call signature unchanged for + # unit tests that assert the exact call arguments. + setattr(auth_service, "_skip_password_validation", True) admin_user = await auth_service.create_user( email=settings.platform_admin_email, password=settings.platform_admin_password.get_secret_value(), full_name=settings.platform_admin_full_name, is_admin=True, ) + # Clean up the temporary attribute in case the service instance + # is reused elsewhere during runtime. + try: + delattr(auth_service, "_skip_password_validation") + except AttributeError: + logger.debug("Temporary attribute '_skip_password_validation' not present on auth_service; nothing to remove") + except Exception as e: + logger.warning(f"Unexpected error removing temporary attribute on auth_service: {e}") # Mark admin user as email verified and require password change on first login # First-Party diff --git a/mcpgateway/config.py b/mcpgateway/config.py index 9d3017876..16b790150 100644 --- a/mcpgateway/config.py +++ b/mcpgateway/config.py @@ -301,6 +301,7 @@ class Settings(BaseSettings): # Password Policy Configuration password_min_length: int = Field(default=8, description="Minimum password length") + password_require_min_length: bool = Field(default=True, description="Require minimum length in passwords") password_require_uppercase: bool = Field(default=False, description="Require uppercase letters in passwords") password_require_lowercase: bool = Field(default=False, description="Require lowercase letters in passwords") password_require_numbers: bool = Field(default=False, description="Require numbers in passwords") diff --git a/mcpgateway/services/email_auth_service.py b/mcpgateway/services/email_auth_service.py index 33d0eca9b..86f2ef2b4 100644 --- a/mcpgateway/services/email_auth_service.py +++ b/mcpgateway/services/email_auth_service.py @@ -199,6 +199,13 @@ def validate_password(self, password: str) -> bool: PasswordValidationError: If password doesn't meet requirements Examples: + # Ensure examples run with the default (lenient) password policy + >>> from mcpgateway.services import email_auth_service as _eas + >>> _eas.settings.password_require_uppercase = False + >>> _eas.settings.password_require_lowercase = False + >>> _eas.settings.password_require_numbers = False + >>> _eas.settings.password_require_special = False + >>> service = EmailAuthService(None) >>> service.validate_password("password123") True @@ -273,7 +280,7 @@ async def get_user_by_email(self, email: str) -> Optional[EmailUser]: logger.error(f"Error getting user by email {email}: {e}") return None - async def create_user(self, email: str, password: str, full_name: Optional[str] = None, is_admin: bool = False, auth_provider: str = "local") -> EmailUser: + async def create_user(self, email: str, password: str, full_name: Optional[str] = None, is_admin: bool = False, auth_provider: str = "local", skip_password_validation: bool = False) -> EmailUser: """Create a new user with email authentication. Args: @@ -282,6 +289,9 @@ async def create_user(self, email: str, password: str, full_name: Optional[str] full_name: Optional full name for display is_admin: Whether user has admin privileges auth_provider: Authentication provider ('local', 'github', etc.) + skip_password_validation: If True, skip strict password policy validation + for this create operation (useful for bootstrap or tests). Defaults + to False. Returns: EmailUser: The created user object @@ -305,7 +315,16 @@ async def create_user(self, email: str, password: str, full_name: Optional[str] # Validate inputs self.validate_email(email) - self.validate_password(password) + # Allow callers (eg. bootstrap) to skip strict password validation so + # the initial admin can be created with the configured default password + # even when operators enable strict policies via env vars. + # Support two modes: + # - caller passes `skip_password_validation=True` (preferred) + # - or an instance attribute `_skip_password_validation` is set by + # the caller (keeps bootstrap call-site signature unchanged for tests) + effective_skip = bool(skip_password_validation or getattr(self, "_skip_password_validation", False)) + if not effective_skip: + self.validate_password(password) # Check if user already exists existing_user = await self.get_user_by_email(email) diff --git a/mcpgateway/templates/change-password-required.html b/mcpgateway/templates/change-password-required.html index 2ad2750f0..11f45ceff 100644 --- a/mcpgateway/templates/change-password-required.html +++ b/mcpgateway/templates/change-password-required.html @@ -222,22 +222,36 @@

Password Requirements

@@ -404,6 +418,16 @@

// Initialize ROOT_PATH for JavaScript URL composition window.ROOT_PATH = {{ root_path | tojson }}; + // Password policy flags injected from backend + const passwordPolicy = { + minLength: {{ password_min_length | default(8) }}, + requireMinLength: {{ password_require_min_length | tojson }}, + requireUppercase: {{ password_require_uppercase | tojson }}, + requireLowercase: {{ password_require_lowercase | tojson }}, + requireNumbers: {{ password_require_numbers | tojson }}, + requireSpecial: {{ password_require_special | tojson }}, + }; + // Initialize page document.addEventListener("DOMContentLoaded", function () { setupFormValidation(); @@ -468,14 +492,17 @@

// Update password strength indicator const strengthElement = document.getElementById('password-strength'); - strengthElement.textContent = strength.label; - strengthElement.className = `font-medium ${strength.color}`; - - // Update requirement indicators - updateRequirement('req-length', password.length >= 8); - updateRequirement('req-uppercase', /[A-Z]/.test(password)); - updateRequirement('req-lowercase', /[a-z]/.test(password)); - updateRequirement('req-special', /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)); + if (strengthElement) { + strengthElement.textContent = strength.label; + strengthElement.className = `font-medium ${strength.color}`; + } + + // Update requirement indicators (only affects elements that exist) + if (passwordPolicy.requireMinLength) updateRequirement('req-length', password.length >= (passwordPolicy.minLength || 8)); + if (passwordPolicy.requireUppercase) updateRequirement('req-uppercase', /[A-Z]/.test(password)); + if (passwordPolicy.requireLowercase) updateRequirement('req-lowercase', /[a-z]/.test(password)); + if (passwordPolicy.requireNumbers) updateRequirement('req-numbers', /[0-9]/.test(password)); + if (passwordPolicy.requireSpecial) updateRequirement('req-special', /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>\/?]/.test(password)); } // Get password strength @@ -496,14 +523,15 @@

// Update requirement indicator function updateRequirement(id, met) { const element = document.getElementById(id); - const icon = element.querySelector('i'); + if (!element) return; // requirement not rendered for this deployment + const icon = element.querySelector('i') || element.querySelector('.fas') || null; if (met) { - icon.className = 'fas fa-check-circle text-green-500 mr-2'; + if (icon) icon.className = 'fas fa-check-circle text-green-500 mr-2'; element.classList.remove('text-blue-600'); element.classList.add('text-green-600'); } else { - icon.className = 'fas fa-circle text-gray-400 mr-2'; + if (icon) icon.className = 'fas fa-circle text-gray-400 mr-2'; element.classList.remove('text-green-600'); element.classList.add('text-blue-600'); } @@ -524,10 +552,13 @@

// Check if password is valid function isPasswordValid(password) { - return password.length >= 8 && - /[A-Z]/.test(password) && - /[a-z]/.test(password) && - /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password); + let valid = true; + if (passwordPolicy.requireMinLength) valid = valid && (password.length >= (passwordPolicy.minLength || 8)); + if (passwordPolicy.requireUppercase) valid = valid && /[A-Z]/.test(password); + if (passwordPolicy.requireLowercase) valid = valid && /[a-z]/.test(password); + if (passwordPolicy.requireNumbers) valid = valid && /[0-9]/.test(password); + if (passwordPolicy.requireSpecial) valid = valid && /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>\/?]/.test(password); + return valid; } // Handle error messages from URL parameters diff --git a/tests/unit/mcpgateway/services/test_email_auth_basic.py b/tests/unit/mcpgateway/services/test_email_auth_basic.py index 9b9d787c6..f4f207f4e 100644 --- a/tests/unit/mcpgateway/services/test_email_auth_basic.py +++ b/tests/unit/mcpgateway/services/test_email_auth_basic.py @@ -40,7 +40,21 @@ def mock_password_service(self): @pytest.fixture def service(self, mock_db): - """Create email auth service instance.""" + """Create email auth service instance. + + Reset password policy flags on the imported settings object so that + local environment variables (e.g. PASSWORD_REQUIRE_* exported in + the developer shell) do not affect the expectation of default + behavior in unit tests. + """ + # Ensure default password policy flags for tests + from mcpgateway.services import email_auth_service as _eas + + _eas.settings.password_require_uppercase = False + _eas.settings.password_require_lowercase = False + _eas.settings.password_require_numbers = False + _eas.settings.password_require_special = False + return EmailAuthService(mock_db) # ========================================================================= @@ -361,7 +375,18 @@ def mock_password_service(self): @pytest.fixture def service(self, mock_db): - """Create email auth service instance.""" + """Create email auth service instance with deterministic password policy flags. + + Reset password policy flags on the imported settings object so local environment + variables do not influence test expectations. + """ + from mcpgateway.services import email_auth_service as _eas + + _eas.settings.password_require_uppercase = False + _eas.settings.password_require_lowercase = False + _eas.settings.password_require_numbers = False + _eas.settings.password_require_special = False + return EmailAuthService(mock_db) @pytest.fixture