Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion mcpgateway/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
15 changes: 15 additions & 0 deletions mcpgateway/bootstrap_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions mcpgateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
23 changes: 21 additions & 2 deletions mcpgateway/services/email_auth_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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)
Expand Down
63 changes: 47 additions & 16 deletions mcpgateway/templates/change-password-required.html
Original file line number Diff line number Diff line change
Expand Up @@ -222,22 +222,36 @@ <h3 class="text-sm font-semibold text-blue-700 dark:text-blue-300 mb-2">
<i class="fas fa-info-circle mr-2"></i>Password Requirements
</h3>
<ul class="text-xs text-blue-600 dark:text-blue-400 space-y-1">
{% if password_require_min_length %}
<li id="req-length" class="flex items-center">
<i class="fas fa-circle text-green-500 mr-2"></i>
At least 8 characters long
At least {{ password_min_length | default(8) }} characters long
</li>
{% endif %}
{% if password_require_uppercase %}
<li id="req-uppercase" class="flex items-center">
<i class="fas fa-circle text-green-500 mr-2"></i>
Contains uppercase letters (A-Z)
</li>
{% endif %}
{% if password_require_lowercase %}
<li id="req-lowercase" class="flex items-center">
<i class="fas fa-circle text-green-500 mr-2"></i>
Contains lowercase letters (a-z)
</li>
{% endif %}
{% if password_require_numbers %}
<li id="req-numbers" class="flex items-center">
<i class="fas fa-circle text-green-500 mr-2"></i>
Contains numbers (0-9)
</li>
{% endif %}
{% if password_require_special %}
<li id="req-special" class="flex items-center">
<i class="fas fa-circle text-green-500 mr-2"></i>
Contains special characters (!@#$%&*)
</li>
{% endif %}
</ul>
</div>

Expand Down Expand Up @@ -404,6 +418,16 @@ <h4 class="text-sm font-semibold text-white mb-2">
// 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();
Expand Down Expand Up @@ -468,14 +492,17 @@ <h4 class="text-sm font-semibold text-white mb-2">

// 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
Expand All @@ -496,14 +523,15 @@ <h4 class="text-sm font-semibold text-white mb-2">
// 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');
}
Expand All @@ -524,10 +552,13 @@ <h4 class="text-sm font-semibold text-white mb-2">

// 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
Expand Down
29 changes: 27 additions & 2 deletions tests/unit/mcpgateway/services/test_email_auth_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

# =========================================================================
Expand Down Expand Up @@ -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
Expand Down
Loading