Skip to content

Commit e5f8f51

Browse files
Nayana-R-GowdaNAYANAR0502crivetimihai
authored
Added password policy support under the Edit User section (#1510)
* Added password policy support under the Edit User section Signed-off-by: NAYANAR <[email protected]> * fix: sync password policy validation with backend config settings - Update validate_password_strength to use configurable settings from config.py (password_min_length, password_require_*, etc.) instead of hardcoding requirements - Expand special character set to match EmailAuthService (!@#$%^&*(),.?":{}|<>) instead of limited set (@#$%&*) - Add missing digit/number requirement check - Fix admin_update_user to strip password consistently (was validating stripped password but passing unstripped to service) - Update JavaScript validation to inject settings from backend, conditionally show only enabled requirements in UI - Add numbers requirement to UI when enabled Closes #1510 Signed-off-by: Mihai Criveti <[email protected]> --------- Signed-off-by: NAYANAR <[email protected]> Signed-off-by: Mihai Criveti <[email protected]> Co-authored-by: NAYANAR <[email protected]> Co-authored-by: Mihai Criveti <[email protected]>
1 parent 30f638d commit e5f8f51

File tree

1 file changed

+159
-5
lines changed

1 file changed

+159
-5
lines changed

mcpgateway/admin.py

Lines changed: 159 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,43 @@ def serialize_datetime(obj):
525525
return obj
526526

527527

528+
def validate_password_strength(password: str) -> tuple[bool, str]:
529+
"""Validate password meets strength requirements.
530+
531+
Uses configurable settings from config.py for password policy.
532+
533+
Args:
534+
password: Password to validate
535+
536+
Returns:
537+
tuple: (is_valid, error_message)
538+
"""
539+
min_length = getattr(settings, "password_min_length", 8)
540+
require_uppercase = getattr(settings, "password_require_uppercase", False)
541+
require_lowercase = getattr(settings, "password_require_lowercase", False)
542+
require_numbers = getattr(settings, "password_require_numbers", False)
543+
require_special = getattr(settings, "password_require_special", False)
544+
545+
if len(password) < min_length:
546+
return False, f"Password must be at least {min_length} characters long"
547+
548+
if require_uppercase and not any(c.isupper() for c in password):
549+
return False, "Password must contain at least one uppercase letter (A-Z)"
550+
551+
if require_lowercase and not any(c.islower() for c in password):
552+
return False, "Password must contain at least one lowercase letter (a-z)"
553+
554+
if require_numbers and not any(c.isdigit() for c in password):
555+
return False, "Password must contain at least one number (0-9)"
556+
557+
# Match the special character set used in EmailAuthService
558+
special_chars = '!@#$%^&*(),.?":{}|<>'
559+
if require_special and not any(c in special_chars for c in password):
560+
return False, f"Password must contain at least one special character ({special_chars})"
561+
562+
return True, ""
563+
564+
528565
admin_router = APIRouter(prefix="/admin", tags=["Admin UI"])
529566

530567
####################
@@ -4734,17 +4771,24 @@ async def admin_create_user(
47344771

47354772
form = await request.form()
47364773

4774+
# Validate password strength
4775+
password = str(form.get("password", ""))
4776+
if password:
4777+
is_valid, error_msg = validate_password_strength(password)
4778+
if not is_valid:
4779+
return HTMLResponse(content=f'<div class="text-red-500">Password validation failed: {error_msg}</div>', status_code=400)
4780+
47374781
# First-Party
47384782

47394783
auth_service = EmailAuthService(db)
47404784

47414785
# Create new user
47424786
new_user = await auth_service.create_user(
4743-
email=str(form.get("email", "")), password=str(form.get("password", "")), full_name=str(form.get("full_name", "")), is_admin=form.get("is_admin") == "on", auth_provider="local"
4787+
email=str(form.get("email", "")), password=password, full_name=str(form.get("full_name", "")), is_admin=form.get("is_admin") == "on", auth_provider="local"
47444788
)
47454789

47464790
# If the user was created with the default password, force password change
4747-
if str(form.get("password", "")) == settings.default_user_password.get_secret_value(): # nosec B105
4791+
if password == settings.default_user_password.get_secret_value(): # nosec B105
47484792
new_user.password_change_required = True
47494793
db.commit()
47504794

@@ -4847,7 +4891,7 @@ async def admin_get_user_edit(
48474891
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">New Password (leave empty to keep current)</label>
48484892
<input type="password" name="password" id="password-field"
48494893
class="mt-1 px-1.5 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 text-gray-900 dark:text-white"
4850-
oninput="validatePasswordMatch()">
4894+
oninput="validatePasswordRequirements(); validatePasswordMatch();">
48514895
</div>
48524896
<div>
48534897
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Confirm New Password</label>
@@ -4856,6 +4900,109 @@ async def admin_get_user_edit(
48564900
oninput="validatePasswordMatch()">
48574901
<div id="password-match-message" class="mt-1 text-sm text-red-600 hidden">Passwords do not match</div>
48584902
</div>
4903+
<!-- Password Requirements -->
4904+
<div class="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-md p-4">
4905+
<div class="flex items-start">
4906+
<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">
4907+
<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"/>
4908+
</svg>
4909+
<div class="ml-3 flex-1">
4910+
<h3 class="text-sm font-semibold text-blue-900 dark:text-blue-200">Password Requirements</h3>
4911+
<div class="mt-2 text-sm text-blue-800 dark:text-blue-300 space-y-1">
4912+
<div class="flex items-center" id="req-length">
4913+
<span class="inline-flex items-center justify-center w-4 h-4 bg-gray-400 text-white rounded-full text-xs mr-2">✗</span>
4914+
<span>At least {settings.password_min_length} characters long</span>
4915+
</div>
4916+
{'<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 ''}
4917+
{'<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 ''}
4918+
{'<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 ''}
4919+
{'<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 ''}
4920+
</div>
4921+
</div>
4922+
</div>
4923+
</div>
4924+
4925+
<script>
4926+
// Password policy settings injected from backend
4927+
const passwordPolicy = {{
4928+
minLength: {settings.password_min_length},
4929+
requireUppercase: {'true' if settings.password_require_uppercase else 'false'},
4930+
requireLowercase: {'true' if settings.password_require_lowercase else 'false'},
4931+
requireNumbers: {'true' if settings.password_require_numbers else 'false'},
4932+
requireSpecial: {'true' if settings.password_require_special else 'false'}
4933+
}};
4934+
4935+
function updateRequirementIcon(elementId, isValid) {{
4936+
const req = document.getElementById(elementId);
4937+
if (req) {{
4938+
const icon = req.querySelector('span');
4939+
if (isValid) {{
4940+
icon.className = 'inline-flex items-center justify-center w-4 h-4 bg-green-500 text-white rounded-full text-xs mr-2';
4941+
icon.textContent = '✓';
4942+
}} else {{
4943+
icon.className = 'inline-flex items-center justify-center w-4 h-4 bg-gray-400 text-white rounded-full text-xs mr-2';
4944+
icon.textContent = '✗';
4945+
}}
4946+
}}
4947+
}}
4948+
4949+
function validatePasswordRequirements() {{
4950+
const password = document.getElementById('password-field')?.value || '';
4951+
4952+
// Check length requirement (always required)
4953+
const lengthCheck = password.length >= passwordPolicy.minLength;
4954+
updateRequirementIcon('req-length', lengthCheck);
4955+
4956+
// Check uppercase requirement (if enabled)
4957+
const uppercaseCheck = !passwordPolicy.requireUppercase || /[A-Z]/.test(password);
4958+
updateRequirementIcon('req-uppercase', /[A-Z]/.test(password));
4959+
4960+
// Check lowercase requirement (if enabled)
4961+
const lowercaseCheck = !passwordPolicy.requireLowercase || /[a-z]/.test(password);
4962+
updateRequirementIcon('req-lowercase', /[a-z]/.test(password));
4963+
4964+
// Check numbers requirement (if enabled)
4965+
const numbersCheck = !passwordPolicy.requireNumbers || /[0-9]/.test(password);
4966+
updateRequirementIcon('req-numbers', /[0-9]/.test(password));
4967+
4968+
// Check special character requirement (if enabled) - matches backend set
4969+
const specialCheck = !passwordPolicy.requireSpecial || /[!@#$%^&*(),.?":{{}}|<>]/.test(password);
4970+
updateRequirementIcon('req-special', /[!@#$%^&*(),.?":{{}}|<>]/.test(password));
4971+
4972+
// Enable/disable submit button based on active requirements
4973+
const submitButton = document.querySelector('#user-edit-modal-content button[type="submit"]');
4974+
const allRequirementsMet = lengthCheck && uppercaseCheck && lowercaseCheck && numbersCheck && specialCheck;
4975+
const passwordEmpty = password.length === 0;
4976+
4977+
if (submitButton) {{
4978+
// Allow submission if password is empty (keep current) or if all requirements are met
4979+
if (passwordEmpty || allRequirementsMet) {{
4980+
submitButton.disabled = false;
4981+
submitButton.className = 'px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500';
4982+
}} else {{
4983+
submitButton.disabled = true;
4984+
submitButton.className = 'px-4 py-2 text-sm font-medium text-white bg-gray-400 border border-transparent rounded-md cursor-not-allowed';
4985+
}}
4986+
}}
4987+
}}
4988+
4989+
function validatePasswordMatch() {{
4990+
const password = document.getElementById('password-field')?.value || '';
4991+
const confirmPassword = document.getElementById('confirm-password-field')?.value || '';
4992+
const matchMessage = document.getElementById('password-match-message');
4993+
4994+
if (password && confirmPassword && password !== confirmPassword) {{
4995+
matchMessage?.classList.remove('hidden');
4996+
}} else {{
4997+
matchMessage?.classList.add('hidden');
4998+
}}
4999+
}}
5000+
5001+
// Initialize validation on page load
5002+
document.addEventListener('DOMContentLoaded', function() {{
5003+
validatePasswordRequirements();
5004+
}});
5005+
</script>
48595006
<div class="flex justify-end space-x-3">
48605007
<button type="button" onclick="hideUserEditModal()"
48615008
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700">
@@ -4927,8 +5074,15 @@ async def admin_update_user(
49275074
fn_val = form.get("full_name")
49285075
pw_val = form.get("password")
49295076
full_name = fn_val if isinstance(fn_val, str) else None
4930-
password = pw_val if isinstance(pw_val, str) else None
4931-
await auth_service.update_user(email=decoded_email, full_name=full_name, is_admin=is_admin, password=password if password else None)
5077+
password = pw_val.strip() if isinstance(pw_val, str) and pw_val.strip() else None
5078+
5079+
# Validate password if provided
5080+
if password:
5081+
is_valid, error_msg = validate_password_strength(password)
5082+
if not is_valid:
5083+
return HTMLResponse(content=f'<div class="text-red-500">Password validation failed: {error_msg}</div>', status_code=400)
5084+
5085+
await auth_service.update_user(email=decoded_email, full_name=full_name, is_admin=is_admin, password=password)
49325086

49335087
# Return success message with auto-close and refresh
49345088
success_html = """

0 commit comments

Comments
 (0)