Skip to content

Commit 02bc252

Browse files
allow the default password even if it doesn't meet the normal password policy requirements (#1524)
* feat: allow default password to bypass policy validation during bootstrap - Add skip_password_validation parameter to create_user() method in EmailAuthService to allow bypassing password policy during bootstrap - Change bootstrap_db.py to use create_platform_admin() which skips password validation for the initial admin user - Update default password policy to require uppercase, lowercase, and special characters (password_require_uppercase, password_require_lowercase, password_require_special now default to true) - Display password requirements dynamically in admin UI based on enabled policies - Fix special character regex to include additional valid characters - Add validation that old_password must be provided when changing password - Update doctests and unit tests to use passwords that meet new defaults Closes #1524 Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * Rebase and lint Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> --------- Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> Co-authored-by: Mihai Criveti <crivetimihai@gmail.com>
1 parent 4a4c857 commit 02bc252

File tree

6 files changed

+113
-56
lines changed

6 files changed

+113
-56
lines changed

mcpgateway/admin.py

Lines changed: 85 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3757,7 +3757,7 @@ async def admin_get_team_edit(
37573757
if not team:
37583758
return HTMLResponse(content='<div class="text-red-500">Team not found</div>', status_code=404)
37593759

3760-
edit_form = f"""
3760+
edit_form = rf"""
37613761
<div class="space-y-4">
37623762
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Edit Team</h3>
37633763
<form method="post" action="{root_path}/admin/teams/{team_id}/update" hx-post="{root_path}/admin/teams/{team_id}/update" hx-target="#team-edit-modal-content" class="space-y-4">
@@ -4868,6 +4868,63 @@ async def admin_get_user_edit(
48684868
if not user_obj:
48694869
return HTMLResponse(content='<div class="text-red-500">User not found</div>', status_code=404)
48704870

4871+
# Build Password Requirements HTML separately to avoid backslash issues inside f-strings
4872+
if settings.password_require_uppercase or settings.password_require_lowercase or settings.password_require_numbers or settings.password_require_special:
4873+
pr_lines = []
4874+
pr_lines.append(
4875+
f""" <!-- Password Requirements -->
4876+
<div class="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-md p-4">
4877+
<div class="flex items-start">
4878+
<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">
4879+
<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"/>
4880+
</svg>
4881+
<div class="ml-3 flex-1">
4882+
<h3 class="text-sm font-semibold text-blue-900 dark:text-blue-200">Password Requirements</h3>
4883+
<div class="mt-2 text-sm text-blue-800 dark:text-blue-300 space-y-1">
4884+
<div class="flex items-center" id="req-length">
4885+
<span class="inline-flex items-center justify-center w-4 h-4 bg-gray-400 text-white rounded-full text-xs mr-2">✗</span>
4886+
<span>At least {settings.password_min_length} characters long</span>
4887+
</div>
4888+
"""
4889+
)
4890+
if settings.password_require_uppercase:
4891+
pr_lines.append(
4892+
"""
4893+
<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>
4894+
"""
4895+
)
4896+
if settings.password_require_lowercase:
4897+
pr_lines.append(
4898+
"""
4899+
<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>
4900+
"""
4901+
)
4902+
if settings.password_require_numbers:
4903+
pr_lines.append(
4904+
"""
4905+
<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>
4906+
"""
4907+
)
4908+
if settings.password_require_special:
4909+
pr_lines.append(
4910+
"""
4911+
<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>
4912+
"""
4913+
)
4914+
pr_lines.append(
4915+
"""
4916+
</div>
4917+
</div>
4918+
</div>
4919+
</div>
4920+
"""
4921+
)
4922+
password_requirements_html = "".join(pr_lines)
4923+
else:
4924+
# Intentionally an empty string for HTML insertion when no requirements apply.
4925+
# This is not a password value; suppress Bandit false positive B105.
4926+
password_requirements_html = "" # nosec B105
4927+
48714928
# Create edit form HTML
48724929
edit_form = f"""
48734930
<div class="space-y-4">
@@ -4902,27 +4959,7 @@ async def admin_get_user_edit(
49024959
oninput="validatePasswordMatch()">
49034960
<div id="password-match-message" class="mt-1 text-sm text-red-600 hidden">Passwords do not match</div>
49044961
</div>
4905-
<!-- Password Requirements -->
4906-
<div class="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-md p-4">
4907-
<div class="flex items-start">
4908-
<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">
4909-
<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"/>
4910-
</svg>
4911-
<div class="ml-3 flex-1">
4912-
<h3 class="text-sm font-semibold text-blue-900 dark:text-blue-200">Password Requirements</h3>
4913-
<div class="mt-2 text-sm text-blue-800 dark:text-blue-300 space-y-1">
4914-
<div class="flex items-center" id="req-length">
4915-
<span class="inline-flex items-center justify-center w-4 h-4 bg-gray-400 text-white rounded-full text-xs mr-2">✗</span>
4916-
<span>At least {settings.password_min_length} characters long</span>
4917-
</div>
4918-
{'<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 ''}
4919-
{'<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 ''}
4920-
{'<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 ''}
4921-
{'<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 ''}
4922-
</div>
4923-
</div>
4924-
</div>
4925-
</div>
4962+
{password_requirements_html}
49264963

49274964
<script>
49284965
// Password policy settings injected from backend
@@ -4934,6 +4971,8 @@ async def admin_get_user_edit(
49344971
requireSpecial: {'true' if settings.password_require_special else 'false'}
49354972
}};
49364973

4974+
// (No debug output) passwordPolicy available in JS for logic below
4975+
49374976
function updateRequirementIcon(elementId, isValid) {{
49384977
const req = document.getElementById(elementId);
49394978
if (req) {{
@@ -4957,19 +4996,19 @@ async def admin_get_user_edit(
49574996

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

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

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

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

49745013
// Enable/disable submit button based on active requirements
49755014
const submitButton = document.querySelector('#user-edit-modal-content button[type="submit"]');
@@ -5000,9 +5039,25 @@ async def admin_get_user_edit(
50005039
}}
50015040
}}
50025041

5003-
// Initialize validation on page load
5004-
document.addEventListener('DOMContentLoaded', function() {{
5005-
validatePasswordRequirements();
5042+
// Initialize validation when the form is present (supports HTMX-injected content)
5043+
(function initPasswordValidation() {{
5044+
if (document.getElementById('password-field')) {{
5045+
validatePasswordRequirements();
5046+
validatePasswordMatch();
5047+
}}
5048+
}})();
5049+
5050+
// Re-run validation after HTMX swaps content into the DOM (modal loaded via HTMX)
5051+
document.addEventListener('htmx:afterSwap', function(event) {{
5052+
try {{
5053+
const target = event.detail && event.detail.target ? event.detail.target : null;
5054+
if (target && (target.querySelector('#password-field') || target.id === 'user-edit-modal-content')) {{
5055+
validatePasswordRequirements();
5056+
validatePasswordMatch();
5057+
}}
5058+
}} catch (e) {{
5059+
// Ignore errors from HTMX event handling
5060+
}}
50065061
}});
50075062
</script>
50085063
<div class="flex justify-end space-x-3">

mcpgateway/bootstrap_db.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,11 +76,10 @@ async def bootstrap_admin_user() -> None:
7676

7777
# Create admin user
7878
logger.info(f"Creating platform admin user: {settings.platform_admin_email}")
79-
admin_user = await auth_service.create_user(
79+
admin_user = await auth_service.create_platform_admin(
8080
email=settings.platform_admin_email,
8181
password=settings.platform_admin_password.get_secret_value(),
8282
full_name=settings.platform_admin_full_name,
83-
is_admin=True,
8483
)
8584

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

265264
if "gateways" not in insp.get_table_names():
266265
logger.info("Empty DB detected - creating baseline schema")
267-
268266
# Apply MariaDB compatibility fixes if needed
269267
if settings.database_url.startswith(("mariadb", "mysql")):
270268
# pylint: disable=import-outside-toplevel

mcpgateway/config.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -301,10 +301,10 @@ class Settings(BaseSettings):
301301

302302
# Password Policy Configuration
303303
password_min_length: int = Field(default=8, description="Minimum password length")
304-
password_require_uppercase: bool = Field(default=False, description="Require uppercase letters in passwords")
305-
password_require_lowercase: bool = Field(default=False, description="Require lowercase letters in passwords")
304+
password_require_uppercase: bool = Field(default=True, description="Require uppercase letters in passwords")
305+
password_require_lowercase: bool = Field(default=True, description="Require lowercase letters in passwords")
306306
password_require_numbers: bool = Field(default=False, description="Require numbers in passwords")
307-
password_require_special: bool = Field(default=False, description="Require special characters in passwords")
307+
password_require_special: bool = Field(default=True, description="Require special characters in passwords")
308308

309309
# Account Security Configuration
310310
max_failed_login_attempts: int = Field(default=5, description="Maximum failed login attempts before account lockout")

mcpgateway/services/email_auth_service.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -200,13 +200,13 @@ def validate_password(self, password: str) -> bool:
200200
201201
Examples:
202202
>>> service = EmailAuthService(None)
203-
>>> service.validate_password("password123")
203+
>>> service.validate_password("Password123!") # Meets all requirements
204204
True
205205
>>> service.validate_password("ValidPassword123!")
206206
True
207-
>>> service.validate_password("shortpass") # 8+ chars to meet default min_length
207+
>>> service.validate_password("Shortpass!") # 8+ chars with requirements
208208
True
209-
>>> service.validate_password("verylongpasswordthatmeetsminimumrequirements")
209+
>>> service.validate_password("VeryLongPasswordThatMeetsMinimumRequirements!")
210210
True
211211
>>> try:
212212
... service.validate_password("")
@@ -273,7 +273,7 @@ async def get_user_by_email(self, email: str) -> Optional[EmailUser]:
273273
logger.error(f"Error getting user by email {email}: {e}")
274274
return None
275275

276-
async def create_user(self, email: str, password: str, full_name: Optional[str] = None, is_admin: bool = False, auth_provider: str = "local") -> EmailUser:
276+
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:
277277
"""Create a new user with email authentication.
278278
279279
Args:
@@ -282,6 +282,7 @@ async def create_user(self, email: str, password: str, full_name: Optional[str]
282282
full_name: Optional full name for display
283283
is_admin: Whether user has admin privileges
284284
auth_provider: Authentication provider ('local', 'github', etc.)
285+
skip_password_validation: Skip password policy validation (for bootstrap)
285286
286287
Returns:
287288
EmailUser: The created user object
@@ -305,7 +306,8 @@ async def create_user(self, email: str, password: str, full_name: Optional[str]
305306

306307
# Validate inputs
307308
self.validate_email(email)
308-
self.validate_password(password)
309+
if not skip_password_validation:
310+
self.validate_password(password)
309311

310312
# Check if user already exists
311313
existing_user = await self.get_user_by_email(email)
@@ -462,6 +464,10 @@ async def change_password(self, email: str, old_password: Optional[str], new_pas
462464
# )
463465
# success # Returns: True
464466
"""
467+
# Validate old password is provided
468+
if old_password is None:
469+
raise AuthenticationError("Current password is required")
470+
465471
# First authenticate with old password
466472
user = await self.authenticate_user(email, old_password, ip_address, user_agent)
467473
if not user:
@@ -539,8 +545,8 @@ async def create_platform_admin(self, email: str, password: str, full_name: Opti
539545
logger.info(f"Updated platform admin user: {email}")
540546
return existing_admin
541547

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

545551
logger.info(f"Created platform admin user: {email}")
546552
return admin_user

tests/unit/mcpgateway/services/test_email_auth_basic.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,9 @@ def test_validate_email_too_long(self, service):
8989
def test_validate_password_basic_success(self, service):
9090
"""Test basic password validation success."""
9191
# Should not raise any exception with default settings
92-
service.validate_password("password123")
93-
service.validate_password("simple123") # 8+ chars
94-
service.validate_password("verylongpasswordstring")
92+
service.validate_password("Password123!")
93+
service.validate_password("Simple123!") # 8+ chars with requirements
94+
service.validate_password("VerylongPasswordString!")
9595

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

478478
with pytest.raises(UserExistsError, match="already exists"):
479-
await service.create_user(email="test@example.com", password="Password123")
479+
await service.create_user(email="test@example.com", password="Password123!")
480480

481481
@pytest.mark.asyncio
482482
async def test_create_user_database_integrity_error(self, service, mock_db, mock_password_service):
@@ -668,7 +668,7 @@ async def test_change_password_same_as_old(self, service, mock_db, mock_user, mo
668668
mock_password_service.verify_password.return_value = True
669669

670670
with pytest.raises(PasswordValidationError, match="must be different"):
671-
await service.change_password(email="test@example.com", old_password="password123", new_password="password123")
671+
await service.change_password(email="test@example.com", old_password="Password123!", new_password="Password123!")
672672

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

0 commit comments

Comments
 (0)