Skip to content
Open
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
112 changes: 84 additions & 28 deletions mcpgateway/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -4868,6 +4868,64 @@ async def admin_get_user_edit(
if not user_obj:
return HTMLResponse(content='<div class="text-red-500">User not found</div>', status_code=404)

# Build Password Requirements HTML separately to avoid backslash issues inside f-strings
if settings.password_require_uppercase or settings.password_require_lowercase or settings.password_require_numbers or settings.password_require_special:
pr_lines = []
pr_lines.append(
"""
<!-- Password Requirements -->
<div class="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-md p-4">
<div class="flex items-start">
<svg class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
</svg>
<div class="ml-3 flex-1">
<h3 class="text-sm font-semibold text-blue-900 dark:text-blue-200">Password Requirements</h3>
<div class="mt-2 text-sm text-blue-800 dark:text-blue-300 space-y-1">
<div class="flex items-center" id="req-length">
<span class="inline-flex items-center justify-center w-4 h-4 bg-gray-400 text-white rounded-full text-xs mr-2">✗</span>
<span>At least {settings.password_min_length} characters long</span>
</div>
"""
)
if settings.password_require_uppercase:
pr_lines.append(
"""
<div class="flex items-center" id="req-uppercase"><span class="inline-flex items-center justify-center w-4 h-4 bg-gray-400 text-white rounded-full text-xs mr-2">✗</span><span>Contains uppercase letters (A-Z)</span></div>
"""
)
if settings.password_require_lowercase:
pr_lines.append(
"""
<div class="flex items-center" id="req-lowercase"><span class="inline-flex items-center justify-center w-4 h-4 bg-gray-400 text-white rounded-full text-xs mr-2">✗</span><span>Contains lowercase letters (a-z)</span></div>
"""
)
if settings.password_require_numbers:
pr_lines.append(
"""
<div class="flex items-center" id="req-numbers"><span class="inline-flex items-center justify-center w-4 h-4 bg-gray-400 text-white rounded-full text-xs mr-2">✗</span><span>Contains numbers (0-9)</span></div>
"""
)
if settings.password_require_special:
pr_lines.append(
"""
<div class="flex items-center" id="req-special"><span class="inline-flex items-center justify-center w-4 h-4 bg-gray-400 text-white rounded-full text-xs mr-2">✗</span><span>Contains special characters (!@#$%^&amp;*(),.?&quot;:{{}}|&lt;&gt;)</span></div>
"""
)
pr_lines.append(
"""
</div>
</div>
</div>
</div>
"""
)
password_requirements_html = "".join(pr_lines)
else:
# Intentionally an empty string for HTML insertion when no requirements apply.
# This is not a password value; suppress Bandit false positive B105.
password_requirements_html = "" # nosec B105

# Create edit form HTML
edit_form = f"""
<div class="space-y-4">
Expand Down Expand Up @@ -4902,27 +4960,7 @@ async def admin_get_user_edit(
oninput="validatePasswordMatch()">
<div id="password-match-message" class="mt-1 text-sm text-red-600 hidden">Passwords do not match</div>
</div>
<!-- Password Requirements -->
<div class="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-md p-4">
<div class="flex items-start">
<svg class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
</svg>
<div class="ml-3 flex-1">
<h3 class="text-sm font-semibold text-blue-900 dark:text-blue-200">Password Requirements</h3>
<div class="mt-2 text-sm text-blue-800 dark:text-blue-300 space-y-1">
<div class="flex items-center" id="req-length">
<span class="inline-flex items-center justify-center w-4 h-4 bg-gray-400 text-white rounded-full text-xs mr-2">✗</span>
<span>At least {settings.password_min_length} characters long</span>
</div>
{'<div class="flex items-center" id="req-uppercase"><span class="inline-flex items-center justify-center w-4 h-4 bg-gray-400 text-white rounded-full text-xs mr-2">✗</span><span>Contains uppercase letters (A-Z)</span></div>' if settings.password_require_uppercase else ''}
{'<div class="flex items-center" id="req-lowercase"><span class="inline-flex items-center justify-center w-4 h-4 bg-gray-400 text-white rounded-full text-xs mr-2">✗</span><span>Contains lowercase letters (a-z)</span></div>' if settings.password_require_lowercase else ''}
{'<div class="flex items-center" id="req-numbers"><span class="inline-flex items-center justify-center w-4 h-4 bg-gray-400 text-white rounded-full text-xs mr-2">✗</span><span>Contains numbers (0-9)</span></div>' if settings.password_require_numbers else ''}
{'<div class="flex items-center" id="req-special"><span class="inline-flex items-center justify-center w-4 h-4 bg-gray-400 text-white rounded-full text-xs mr-2">✗</span><span>Contains special characters (!@#$%^&amp;*(),.?&quot;:{{}}|&lt;&gt;)</span></div>' if settings.password_require_special else ''}
</div>
</div>
</div>
</div>
{password_requirements_html}

<script>
// Password policy settings injected from backend
Expand All @@ -4934,6 +4972,8 @@ async def admin_get_user_edit(
requireSpecial: {'true' if settings.password_require_special else 'false'}
}};

// (No debug output) passwordPolicy available in JS for logic below

function updateRequirementIcon(elementId, isValid) {{
const req = document.getElementById(elementId);
if (req) {{
Expand All @@ -4957,19 +4997,19 @@ async def admin_get_user_edit(

// Check uppercase requirement (if enabled)
const uppercaseCheck = !passwordPolicy.requireUppercase || /[A-Z]/.test(password);
updateRequirementIcon('req-uppercase', /[A-Z]/.test(password));
updateRequirementIcon('req-uppercase', uppercaseCheck);

// Check lowercase requirement (if enabled)
const lowercaseCheck = !passwordPolicy.requireLowercase || /[a-z]/.test(password);
updateRequirementIcon('req-lowercase', /[a-z]/.test(password));
updateRequirementIcon('req-lowercase', lowercaseCheck);

// Check numbers requirement (if enabled)
const numbersCheck = !passwordPolicy.requireNumbers || /[0-9]/.test(password);
updateRequirementIcon('req-numbers', /[0-9]/.test(password));
updateRequirementIcon('req-numbers', numbersCheck);

// Check special character requirement (if enabled) - matches backend set
const specialCheck = !passwordPolicy.requireSpecial || /[!@#$%^&*(),.?":{{}}|<>]/.test(password);
updateRequirementIcon('req-special', /[!@#$%^&*(),.?":{{}}|<>]/.test(password));
updateRequirementIcon('req-special', specialCheck);

// Enable/disable submit button based on active requirements
const submitButton = document.querySelector('#user-edit-modal-content button[type="submit"]');
Expand Down Expand Up @@ -5000,9 +5040,25 @@ async def admin_get_user_edit(
}}
}}

// Initialize validation on page load
document.addEventListener('DOMContentLoaded', function() {{
validatePasswordRequirements();
// Initialize validation when the form is present (supports HTMX-injected content)
(function initPasswordValidation() {{
if (document.getElementById('password-field')) {{
validatePasswordRequirements();
validatePasswordMatch();
}}
}})();

// Re-run validation after HTMX swaps content into the DOM (modal loaded via HTMX)
document.addEventListener('htmx:afterSwap', function(event) {{
try {{
const target = event.detail && event.detail.target ? event.detail.target : null;
if (target && (target.querySelector('#password-field') || target.id === 'user-edit-modal-content')) {{
validatePasswordRequirements();
validatePasswordMatch();
}}
}} catch (e) {{
// Ignore errors from HTMX event handling
}}
}});
</script>
<div class="flex justify-end space-x-3">
Expand Down
4 changes: 1 addition & 3 deletions mcpgateway/bootstrap_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,10 @@ async def bootstrap_admin_user() -> None:

# Create admin user
logger.info(f"Creating platform admin user: {settings.platform_admin_email}")
admin_user = await auth_service.create_user(
admin_user = await auth_service.create_platform_admin(
email=settings.platform_admin_email,
password=settings.platform_admin_password.get_secret_value(),
full_name=settings.platform_admin_full_name,
is_admin=True,
)

# Mark admin user as email verified and require password change on first login
Expand Down Expand Up @@ -264,7 +263,6 @@ async def main() -> None:

if "gateways" not in insp.get_table_names():
logger.info("Empty DB detected - creating baseline schema")

# Apply MariaDB compatibility fixes if needed
if settings.database_url.startswith(("mariadb", "mysql")):
# pylint: disable=import-outside-toplevel
Expand Down
6 changes: 3 additions & 3 deletions mcpgateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,10 +301,10 @@ class Settings(BaseSettings):

# Password Policy Configuration
password_min_length: int = Field(default=8, description="Minimum password length")
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_uppercase: bool = Field(default=True, description="Require uppercase letters in passwords")
password_require_lowercase: bool = Field(default=True, description="Require lowercase letters in passwords")
password_require_numbers: bool = Field(default=False, description="Require numbers in passwords")
password_require_special: bool = Field(default=False, description="Require special characters in passwords")
password_require_special: bool = Field(default=True, description="Require special characters in passwords")

# Account Security Configuration
max_failed_login_attempts: int = Field(default=5, description="Maximum failed login attempts before account lockout")
Expand Down
20 changes: 13 additions & 7 deletions mcpgateway/services/email_auth_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,13 +200,13 @@ def validate_password(self, password: str) -> bool:

Examples:
>>> service = EmailAuthService(None)
>>> service.validate_password("password123")
>>> service.validate_password("Password123!") # Meets all requirements
True
>>> service.validate_password("ValidPassword123!")
True
>>> service.validate_password("shortpass") # 8+ chars to meet default min_length
>>> service.validate_password("Shortpass!") # 8+ chars with requirements
True
>>> service.validate_password("verylongpasswordthatmeetsminimumrequirements")
>>> service.validate_password("VeryLongPasswordThatMeetsMinimumRequirements!")
True
>>> try:
... service.validate_password("")
Expand Down Expand Up @@ -273,7 +273,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 +282,7 @@ 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: Skip password policy validation (for bootstrap)

Returns:
EmailUser: The created user object
Expand All @@ -305,7 +306,8 @@ async def create_user(self, email: str, password: str, full_name: Optional[str]

# Validate inputs
self.validate_email(email)
self.validate_password(password)
if not skip_password_validation:
self.validate_password(password)

# Check if user already exists
existing_user = await self.get_user_by_email(email)
Expand Down Expand Up @@ -462,6 +464,10 @@ async def change_password(self, email: str, old_password: Optional[str], new_pas
# )
# success # Returns: True
"""
# Validate old password is provided
if old_password is None:
raise AuthenticationError("Current password is required")

# First authenticate with old password
user = await self.authenticate_user(email, old_password, ip_address, user_agent)
if not user:
Expand Down Expand Up @@ -539,8 +545,8 @@ async def create_platform_admin(self, email: str, password: str, full_name: Opti
logger.info(f"Updated platform admin user: {email}")
return existing_admin

# Create new admin user
admin_user = await self.create_user(email=email, password=password, full_name=full_name, is_admin=True, auth_provider="local")
# Create new admin user - skip password validation during bootstrap
admin_user = await self.create_user(email=email, password=password, full_name=full_name, is_admin=True, auth_provider="local", skip_password_validation=True)

logger.info(f"Created platform admin user: {email}")
return admin_user
Expand Down
10 changes: 5 additions & 5 deletions tests/unit/mcpgateway/services/test_email_auth_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,9 @@ def test_validate_email_too_long(self, service):
def test_validate_password_basic_success(self, service):
"""Test basic password validation success."""
# Should not raise any exception with default settings
service.validate_password("password123")
service.validate_password("simple123") # 8+ chars
service.validate_password("verylongpasswordstring")
service.validate_password("Password123!")
service.validate_password("Simple123!") # 8+ chars with requirements
service.validate_password("VerylongPasswordString!")

def test_validate_password_empty(self, service):
"""Test password validation with empty password."""
Expand Down Expand Up @@ -476,7 +476,7 @@ async def test_create_user_already_exists(self, service, mock_db, mock_user):
mock_db.execute.return_value.scalar_one_or_none.return_value = mock_user

with pytest.raises(UserExistsError, match="already exists"):
await service.create_user(email="[email protected]", password="Password123")
await service.create_user(email="[email protected]", password="Password123!")

@pytest.mark.asyncio
async def test_create_user_database_integrity_error(self, service, mock_db, mock_password_service):
Expand Down Expand Up @@ -668,7 +668,7 @@ async def test_change_password_same_as_old(self, service, mock_db, mock_user, mo
mock_password_service.verify_password.return_value = True

with pytest.raises(PasswordValidationError, match="must be different"):
await service.change_password(email="[email protected]", old_password="password123", new_password="password123")
await service.change_password(email="[email protected]", old_password="Password123!", new_password="Password123!")

@pytest.mark.skip(reason="Complex mock interaction with finally block - core functionality covered by other tests")
@pytest.mark.asyncio
Expand Down
Loading
Loading