diff --git a/README.md b/README.md index 48d66f0..0730e2b 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,22 @@ When sharing sensitive records with doctors, employers, financial institutions, - **Global File Inventory**: Platform-wide vault explorer with SHA-256 hash inspection, encryption status verification, and physical disk file cleanup. - **Emergency Pass Governance**: System-wide pass monitor with instant emergency revocation capabilities. +### ⚙️ 7. Account Settings & Profile Management +- **Unified Settings Dashboard**: Accessible at `/settings/` for standard users and administrators with `Cache-Control: no-store` and strict CSRF protection. +- **Profile Customization**: Optional display name (up to 50 characters, control character sanitized) and profile avatar. +- **Validated Avatar Pipeline**: Validated with Pillow MIME headers (JPEG, PNG, WebP only; max 2 MB; 10M pixel decompression bomb guard; animated/multi-frame files rejected), square-cropped, resized to 256x256, and re-encoded to WebP with EXIF/metadata stripped. Served strictly through an authenticated Django route with `X-Content-Type-Options: nosniff` and `Cache-Control: private` (no public `/media/` routing). +- **Username Change Policy**: Usernames can be changed at most once every 30 days, enforced under atomic database row locks (`select_for_update()`). Validated against reserved system identifiers and '@' symbol exclusion to prevent login ambiguity. +- **Two-Step Email Change**: Email addresses are never modified without verification. Changes require current password authorization, store pending state isolated from the user record, dispatch a 6-digit OTP to the new address, and send security notifications to the old address if verified. Addresses are atomically re-checked upon confirmation. +- **Dual-Path Password Change**: + - *Standard*: Requires current password re-authentication, enforces `AUTH_PASSWORD_VALIDATORS`, rejects previous passwords, updates session auth hashes to maintain current session while invalidating all other sessions, and sends notification to verified emails. + - *Forgot Password*: Authorized users with verified emails can receive a 6-digit email OTP (purpose `change_password`), unlocking a 10-minute session window to reset their password without the old credential. +- **Database-Backed Action Throttling**: 5 consecutive failed current-password attempts lock the action for 15 minutes, doubling exponentially per subsequent failure up to 24 hours. +- **Account Event Audit**: Records security milestones (`username_changed`, `email_change_requested`, `email_changed`, `password_changed`, `password_otp_sent`, `avatar_updated`, `avatar_removed`, `reauth_failed`) with sanitized client IP and user agent metadata. +- **Transparent Security Disclosures**: + - *No Two-Factor Authentication (2FA/TOTP)*: Account login and settings do not currently implement time-based one-time password (TOTP) or hardware security key 2FA. + - *Avatars Stored Unencrypted*: Unlike vault documents which use AES-256-GCM AEAD encryption at rest, avatar images are stored as standard WebP image files on disk under `MEDIA_ROOT/avatars/`. + - *Email OTPs via SMTP*: Verification and reset codes are delivered as 6-digit numerical OTPs via standard transactional SMTP email. + --- ## 🔐 Cryptographic Architecture Flow diff --git a/SECURITY.md b/SECURITY.md index 7c6366e..1e721e6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -74,6 +74,18 @@ We actively support and release security updates for the following versions of P - Access events are recorded in an append-only relational audit table (`ShareAccessLog`), capturing client IP, user agent, event type, and outcome timestamp for owner visibility. - Sensitive values (passcode characters, authorization tokens, full email addresses) are sanitized and masked before log persistence. +### 9. Account Settings Security Model +- **Username Modification Policy**: Usernames may be changed at most once every 30 days. Changes are checked and committed under atomic database row locks (`select_for_update()`) on `UserProfile` to prevent race conditions. Usernames cannot contain '@' symbols, eliminating authentication ambiguity with email addresses. +- **Two-Step Email Verification**: Modifying an account email requires re-authenticating with the current password. The new email is stored in a pending state and verified via a 6-digit OTP code dispatched to the new address. Unverified old addresses receive no codes, while verified old addresses receive security notifications on request and completion. Uniqueness is re-verified atomically upon code confirmation. +- **Two-Path Password Change & DB Throttling**: + - *Standard*: Requires current password, enforces Django password validators, rejects current password reuse, rotates session auth hashes (invalidating all other device sessions), and notifies the verified email. + - *Forgot Password*: Accessible only when a verified email is on file; dispatches a purpose-scoped 6-digit OTP granting a 10-minute session authorization window to set a new password without current credential entry. + - *Brute-Force Throttling*: Failed current-password attempts are tracked in the database (`ActionThrottle`): 5 failures trigger a 15-minute action lockout, doubling exponentially per subsequent failure up to 24 hours. +- **Transparent Security Disclosures**: + - **No 2FA**: The application does not currently support multi-factor authentication (MFA) or time-based one-time password (TOTP) app authenticators. + - **Avatars Stored Unencrypted**: User avatars are stored as standard unencrypted WebP files under `MEDIA_ROOT/avatars/`. They are never exposed via public `/media/` routes and are served strictly through an authenticated view enforcing `X-Content-Type-Options: nosniff` and `Cache-Control: private`. + - **Email OTP via SMTP**: Verification codes are delivered as 6-digit numerical codes through transactional SMTP email. + --- ## 🚨 Reporting a Vulnerability diff --git a/accounts/forms.py b/accounts/forms.py index 1d467c5..a0e2675 100644 --- a/accounts/forms.py +++ b/accounts/forms.py @@ -365,3 +365,112 @@ def clean_code(self): if not raw.isdigit() or len(raw) != 6: raise ValidationError("Please enter a valid 6-digit numerical code.") return raw + + +class PasswordChangeNormalForm(forms.Form): + """Form to change password with current password authentication.""" + + current_password = forms.CharField( + required=True, + widget=forms.PasswordInput( + attrs={ + "class": "form-input", + "placeholder": "Enter your current password", + "autocomplete": "current-password", + "id": "settings-current-password", + } + ), + ) + new_password = forms.CharField( + required=True, + widget=forms.PasswordInput( + attrs={ + "class": "form-input", + "placeholder": "Enter new secure password", + "autocomplete": "new-password", + "id": "settings-new-password", + } + ), + ) + confirm_password = forms.CharField( + required=True, + widget=forms.PasswordInput( + attrs={ + "class": "form-input", + "placeholder": "Confirm new password", + "autocomplete": "new-password", + "id": "settings-confirm-password", + } + ), + ) + + def clean(self): + cleaned_data = super().clean() + new_pwd = cleaned_data.get("new_password") + confirm_pwd = cleaned_data.get("confirm_password") + if new_pwd and confirm_pwd and new_pwd != confirm_pwd: + self.add_error("confirm_password", "The new passwords do not match.") + return cleaned_data + + +class PasswordResetOTPConfirmForm(forms.Form): + """Form to submit 6-digit OTP code for forgotten password reset authorization.""" + + code = forms.CharField( + max_length=6, + min_length=6, + required=True, + widget=forms.TextInput( + attrs={ + "class": "form-input font-mono-data", + "placeholder": "123456", + "id": "password-otp-combined-input", + "autocomplete": "one-time-code", + "inputmode": "numeric", + "pattern": "[0-9]{6}", + "maxlength": "6", + "style": "letter-spacing: 0.25em; text-align: center; font-size: 1.15rem; max-width: 200px;", + } + ), + ) + + def clean_code(self): + raw = self.cleaned_data.get("code", "").strip() + if not raw.isdigit() or len(raw) != 6: + raise ValidationError("Please enter a valid 6-digit numerical code.") + return raw + + +class SetNewPasswordAfterOTPForm(forms.Form): + """Form to set new password after email OTP session authorization.""" + + new_password = forms.CharField( + required=True, + widget=forms.PasswordInput( + attrs={ + "class": "form-input", + "placeholder": "Enter new secure password", + "autocomplete": "new-password", + "id": "settings-otp-new-password", + } + ), + ) + confirm_password = forms.CharField( + required=True, + widget=forms.PasswordInput( + attrs={ + "class": "form-input", + "placeholder": "Confirm new password", + "autocomplete": "new-password", + "id": "settings-otp-confirm-password", + } + ), + ) + + def clean(self): + cleaned_data = super().clean() + new_pwd = cleaned_data.get("new_password") + confirm_pwd = cleaned_data.get("confirm_password") + if new_pwd and confirm_pwd and new_pwd != confirm_pwd: + self.add_error("confirm_password", "The new passwords do not match.") + return cleaned_data diff --git a/accounts/views.py b/accounts/views.py index b575cec..24e5d7a 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,5 +1,5 @@ import secrets -from datetime import timedelta +from datetime import datetime, timedelta from pathlib import Path from django.conf import settings @@ -26,6 +26,9 @@ UsernameChangeForm, EmailChangeRequestForm, EmailChangeConfirmForm, + PasswordChangeNormalForm, + PasswordResetOTPConfirmForm, + SetNewPasswordAfterOTPForm, ) from .models import ( UserProfile, @@ -50,6 +53,15 @@ confirm_email_change, cancel_email_change, ) +from .services.password_service import ( + change_password_normal, + request_password_reset_otp, + verify_password_reset_otp, + change_password_via_otp_session, + get_password_change_throttle, + PASSWORD_RESET_SESSION_KEY, + PASSWORD_RESET_WINDOW_MINUTES, +) from .services.otp_service import issue_otp from .services.email_service import send_templated_email from documents.models import Document @@ -616,6 +628,96 @@ def account_settings_view(request): messages.info(request, "Pending email change request cancelled.") return redirect(reverse("account_settings") + "#email") + elif action == "change_password_normal": + pwd_normal_form = PasswordChangeNormalForm(request.POST) + if pwd_normal_form.is_valid(): + try: + change_password_normal( + user=user, + current_password=pwd_normal_form.cleaned_data["current_password"], + new_password=pwd_normal_form.cleaned_data["new_password"], + confirm_password=pwd_normal_form.cleaned_data["confirm_password"], + request=request, + ) + messages.success(request, "Your master password has been successfully updated.") + return redirect(reverse("account_settings") + "#password") + except ValidationError as exc: + messages.error(request, exc.message if hasattr(exc, "message") else str(exc)) + return redirect(reverse("account_settings") + "#password") + else: + err_msg = ( + pwd_normal_form.errors.get("current_password") + or pwd_normal_form.errors.get("new_password") + or pwd_normal_form.errors.get("confirm_password") + ) + if err_msg: + messages.error(request, err_msg[0]) + else: + messages.error(request, "Please check the password form for errors.") + return redirect(reverse("account_settings") + "#password") + + elif action == "request_password_otp": + try: + success, msg = request_password_reset_otp(user, request=request) + if success: + request.session["pwd_reset_otp_requested"] = True + messages.success(request, msg) + else: + messages.error(request, msg) + except ValidationError as exc: + messages.error(request, exc.message if hasattr(exc, "message") else str(exc)) + return redirect(reverse("account_settings") + "#password") + + elif action == "verify_password_otp": + pwd_otp_form = PasswordResetOTPConfirmForm(request.POST) + if pwd_otp_form.is_valid(): + success, msg = verify_password_reset_otp( + user=user, + submitted_code=pwd_otp_form.cleaned_data["code"], + request=request, + ) + if success: + request.session.pop("pwd_reset_otp_requested", None) + messages.success(request, msg) + else: + messages.error(request, msg) + else: + err = pwd_otp_form.errors.get("code") + messages.error(request, err[0] if err else "Please enter a valid 6-digit code.") + return redirect(reverse("account_settings") + "#password") + + elif action == "set_password_after_otp": + pwd_set_form = SetNewPasswordAfterOTPForm(request.POST) + if pwd_set_form.is_valid(): + try: + change_password_via_otp_session( + user=user, + new_password=pwd_set_form.cleaned_data["new_password"], + confirm_password=pwd_set_form.cleaned_data["confirm_password"], + request=request, + ) + messages.success(request, "Your master password has been successfully updated.") + except ValidationError as exc: + messages.error(request, exc.message if hasattr(exc, "message") else str(exc)) + else: + err_msg = pwd_set_form.errors.get("new_password") or pwd_set_form.errors.get("confirm_password") + if err_msg: + messages.error(request, err_msg[0]) + else: + messages.error(request, "Please check the password form for errors.") + return redirect(reverse("account_settings") + "#password") + + elif action == "cancel_password_reset": + request.session.pop(PASSWORD_RESET_SESSION_KEY, None) + request.session.pop("pwd_reset_otp_requested", None) + EmailVerificationCode.objects.filter( + user=user, + purpose=EmailVerificationCode.PURPOSE_CHANGE_PASSWORD, + is_used=False, + ).update(is_used=True) + messages.info(request, "Password reset cancelled.") + return redirect(reverse("account_settings") + "#password") + # GET request form instances and context profile_form = ProfileForm(initial={"display_name": profile.display_name}) avatar_form = AvatarUploadForm() @@ -640,6 +742,56 @@ def account_settings_view(request): if elapsed < 60: resend_cooldown_seconds = max(0, int(60 - elapsed)) + # Password throttle status + is_pwd_locked, pwd_lockout_minutes, pwd_failures = get_password_change_throttle(user) + + # Check 10-minute session authorization earned via OTP verification + is_pwd_reset_authorized = False + auth_str = request.session.get(PASSWORD_RESET_SESSION_KEY) + if auth_str: + try: + auth_time = datetime.fromisoformat(auth_str) + if timezone.is_naive(auth_time): + auth_time = timezone.make_aware(auth_time) + if timezone.now() <= auth_time + timedelta(minutes=PASSWORD_RESET_WINDOW_MINUTES): + is_pwd_reset_authorized = True + else: + request.session.pop(PASSWORD_RESET_SESSION_KEY, None) + except (ValueError, TypeError): + request.session.pop(PASSWORD_RESET_SESSION_KEY, None) + + # Check if an OTP code is awaiting confirmation for change_password + pwd_otp_pending = False + if not is_pwd_reset_authorized: + active_pwd_code = EmailVerificationCode.objects.filter( + user=user, + purpose=EmailVerificationCode.PURPOSE_CHANGE_PASSWORD, + is_used=False, + expires_at__gt=timezone.now(), + ).first() + if (active_pwd_code and not active_pwd_code.is_locked) or request.session.get("pwd_reset_otp_requested"): + pwd_otp_pending = True + + pwd_resend_cooldown = 0 + if pwd_otp_pending: + latest_pwd_code = ( + EmailVerificationCode.objects.filter( + user=user, + purpose=EmailVerificationCode.PURPOSE_CHANGE_PASSWORD, + ) + .order_by("-created_at") + .first() + ) + if latest_pwd_code: + elapsed = (timezone.now() - latest_pwd_code.created_at).total_seconds() + if elapsed < 60: + pwd_resend_cooldown = max(0, int(60 - elapsed)) + + can_use_forgot_pwd = bool(user.email and profile.is_email_verified) + password_normal_form = PasswordChangeNormalForm() + password_otp_form = PasswordResetOTPConfirmForm() + password_set_new_form = SetNewPasswordAfterOTPForm() + context = { "profile": profile, "profile_form": profile_form, @@ -654,6 +806,16 @@ def account_settings_view(request): "has_avatar": bool(profile.avatar_path), "avatar_url": reverse("avatar_serve_self") if profile.avatar_path else None, } + context["is_auth_locked"] = is_pwd_locked + context["lockout_minutes"] = pwd_lockout_minutes + context["throttle_failures"] = pwd_failures + context["is_reset_authorized"] = is_pwd_reset_authorized + context["reset_otp_pending"] = pwd_otp_pending + context["reset_resend_wait"] = pwd_resend_cooldown + context["can_use_recovery"] = can_use_forgot_pwd + context["password_normal_form"] = password_normal_form + context["password_otp_form"] = password_otp_form + context["password_set_new_form"] = password_set_new_form response = render(request, "accounts/settings.html", context) response["Cache-Control"] = "no-store, no-cache, must-revalidate, private" diff --git a/static/css/settings.css b/static/css/settings.css index 9940d4d..735714c 100644 --- a/static/css/settings.css +++ b/static/css/settings.css @@ -416,6 +416,88 @@ border-color: #94a3b8; } +/* Password Input & Strength Meter */ +.settings-password-field-wrapper { + position: relative; + display: flex; + align-items: center; + width: 100%; +} + +.settings-password-field-wrapper .form-input { + padding-right: 2.75rem; +} + +.settings-password-toggle { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 6px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + transition: color 150ms ease, background-color 150ms ease; +} + +.settings-password-toggle:hover { + color: var(--text-main); + background-color: var(--bg-subtle); +} + +.settings-password-toggle:focus-visible { + outline: 2px solid var(--brand-blue); + outline-offset: 1px; +} + +.settings-strength-meter { + height: 4px; + border-radius: 2px; + background-color: var(--border-light); + margin-top: 6px; + overflow: hidden; + width: 100%; +} + +.settings-strength-bar { + height: 100%; + width: 0%; + border-radius: 2px; + transition: width 200ms ease, background-color 200ms ease; +} + +.settings-strength-bar.weak { + width: 25%; + background-color: #ef4444; +} + +.settings-strength-bar.fair { + width: 50%; + background-color: #f59e0b; +} + +.settings-strength-bar.good { + width: 75%; + background-color: #3b82f6; +} + +.settings-strength-bar.strong { + width: 100%; + background-color: #10b981; +} + +.settings-strength-hint { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 4px; + display: block; +} + /* Mobile Responsiveness */ @media (max-width: 768px) { .settings-layout { diff --git a/static/js/settings.js b/static/js/settings.js index 4916f37..c4e11d6 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -1,8 +1,3 @@ -/** - * Passli Account Settings Vanilla ES Module - * Progressive enhancement for avatar preview, tab routing, and responsive interactions. - */ - document.documentElement.classList.add('js-ready'); document.addEventListener('DOMContentLoaded', () => { @@ -10,6 +5,8 @@ document.addEventListener('DOMContentLoaded', () => { initAvatarUploader(); initOtpInputs(); initResendTimer(); + initPasswordToggles(); + initPasswordStrength(); }); function initNavigationTabs() { @@ -116,98 +113,175 @@ function initAvatarUploader() { } function initOtpInputs() { - const container = document.getElementById('email-otp-boxes-container'); - const fallbackInput = document.getElementById('email-otp-combined-input'); - if (!container || !fallbackInput) return; - - const boxes = Array.from(container.querySelectorAll('.settings-otp-box')); - if (boxes.length !== 6) return; - - function syncToFallback() { - const fullVal = boxes.map(b => b.value.trim()).join(''); - fallbackInput.value = fullVal; - boxes.forEach(b => { - b.classList.toggle('filled', b.value.length > 0); - }); - } + function bindOtpGroup(containerId, fallbackInputId) { + const container = document.getElementById(containerId); + const fallbackInput = document.getElementById(fallbackInputId); + if (!container || !fallbackInput) return; - boxes.forEach((box, index) => { - box.addEventListener('input', () => { - const val = box.value.replace(/[^0-9]/g, ''); - box.value = val ? val[0] : ''; - syncToFallback(); - if (box.value && index < 5) { - boxes[index + 1].focus(); - boxes[index + 1].select(); - } - }); + const boxes = Array.from(container.querySelectorAll('.settings-otp-box')); + if (boxes.length !== 6) return; + + function syncToFallback() { + const fullVal = boxes.map(b => b.value.trim()).join(''); + fallbackInput.value = fullVal; + boxes.forEach(b => { + b.classList.toggle('filled', b.value.length > 0); + }); + } + + boxes.forEach((box, index) => { + box.addEventListener('input', () => { + const val = box.value.replace(/[^0-9]/g, ''); + box.value = val ? val[0] : ''; + syncToFallback(); + if (box.value && index < 5) { + boxes[index + 1].focus(); + boxes[index + 1].select(); + } + }); - box.addEventListener('keydown', (e) => { - if (e.key === 'Backspace') { - if (!box.value && index > 0) { + box.addEventListener('keydown', (e) => { + if (e.key === 'Backspace') { + if (!box.value && index > 0) { + boxes[index - 1].focus(); + boxes[index - 1].value = ''; + syncToFallback(); + e.preventDefault(); + } else if (box.value) { + box.value = ''; + syncToFallback(); + e.preventDefault(); + } + } else if (e.key === 'ArrowLeft' && index > 0) { boxes[index - 1].focus(); - boxes[index - 1].value = ''; - syncToFallback(); + boxes[index - 1].select(); e.preventDefault(); - } else if (box.value) { - box.value = ''; - syncToFallback(); + } else if (e.key === 'ArrowRight' && index < 5) { + boxes[index + 1].focus(); + boxes[index + 1].select(); e.preventDefault(); } - } else if (e.key === 'ArrowLeft' && index > 0) { - boxes[index - 1].focus(); - boxes[index - 1].select(); - e.preventDefault(); - } else if (e.key === 'ArrowRight' && index < 5) { - boxes[index + 1].focus(); - boxes[index + 1].select(); + }); + + box.addEventListener('paste', (e) => { e.preventDefault(); - } - }); + const pasteData = (e.clipboardData || window.clipboardData).getData('text'); + const cleanDigits = pasteData.replace(/[^0-9]/g, '').slice(0, 6); + if (!cleanDigits) return; - box.addEventListener('paste', (e) => { - e.preventDefault(); - const pasteData = (e.clipboardData || window.clipboardData).getData('text'); - const cleanDigits = pasteData.replace(/[^0-9]/g, '').slice(0, 6); - if (!cleanDigits) return; + cleanDigits.split('').forEach((digit, i) => { + if (boxes[i]) { + boxes[i].value = digit; + } + }); + syncToFallback(); + const focusIndex = Math.min(cleanDigits.length, 5); + boxes[focusIndex].focus(); + }); - cleanDigits.split('').forEach((digit, i) => { - if (boxes[i]) { - boxes[i].value = digit; - } + box.addEventListener('focus', () => { + box.select(); }); - syncToFallback(); - const focusIndex = Math.min(cleanDigits.length, 5); - boxes[focusIndex].focus(); }); + } - box.addEventListener('focus', () => { - box.select(); + bindOtpGroup('email-otp-boxes-container', 'email-otp-combined-input'); + bindOtpGroup('password-otp-boxes-container', 'password-otp-combined-input'); +} + +function initResendTimer() { + function bindTimer(buttonId, textId) { + const resendBtn = document.getElementById(buttonId); + const timerText = document.getElementById(textId); + if (!resendBtn || !timerText) return; + + let cooldownSeconds = parseInt(resendBtn.getAttribute('data-cooldown') || '0', 10); + if (isNaN(cooldownSeconds) || cooldownSeconds <= 0) return; + + resendBtn.disabled = true; + timerText.textContent = `Resend in ${cooldownSeconds}s`; + + const timer = setInterval(() => { + cooldownSeconds -= 1; + if (cooldownSeconds <= 0) { + clearInterval(timer); + resendBtn.disabled = false; + timerText.textContent = 'Resend Code'; + resendBtn.removeAttribute('data-cooldown'); + } else { + timerText.textContent = `Resend in ${cooldownSeconds}s`; + } + }, 1000); + } + + bindTimer('btn-resend-email-otp', 'resend-email-timer-text'); + bindTimer('btn-resend-password-otp', 'resend-pwd-timer-text'); +} + +function initPasswordToggles() { + const toggleButtons = document.querySelectorAll('.settings-password-toggle'); + toggleButtons.forEach(btn => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + const wrapper = btn.closest('.settings-password-field-wrapper'); + if (!wrapper) return; + const input = wrapper.querySelector('input'); + const icon = btn.querySelector('.material-symbols-outlined'); + if (!input) return; + + if (input.type === 'password') { + input.type = 'text'; + if (icon) icon.textContent = 'visibility_off'; + btn.setAttribute('aria-label', 'Hide password'); + } else { + input.type = 'password'; + if (icon) icon.textContent = 'visibility'; + btn.setAttribute('aria-label', 'Show password'); + } }); }); } -function initResendTimer() { - const resendBtn = document.getElementById('btn-resend-email-otp'); - const timerText = document.getElementById('resend-email-timer-text'); - if (!resendBtn || !timerText) return; - - let cooldownSeconds = parseInt(resendBtn.getAttribute('data-cooldown') || '0', 10); - if (isNaN(cooldownSeconds) || cooldownSeconds <= 0) return; - - resendBtn.disabled = true; - timerText.textContent = `Resend in ${cooldownSeconds}s`; - - const timer = setInterval(() => { - cooldownSeconds -= 1; - if (cooldownSeconds <= 0) { - clearInterval(timer); - resendBtn.disabled = false; - timerText.textContent = 'Resend Code'; - resendBtn.removeAttribute('data-cooldown'); - } else { - timerText.textContent = `Resend in ${cooldownSeconds}s`; - } - }, 1000); +function initPasswordStrength() { + function setupStrengthMeter(inputId, barId, hintId) { + const input = document.getElementById(inputId); + const bar = document.getElementById(barId); + const hint = document.getElementById(hintId); + if (!input || !bar || !hint) return; + + input.addEventListener('input', () => { + const val = input.value; + if (!val) { + bar.className = 'settings-strength-bar'; + hint.textContent = 'Strength hint: Enter password (hint only; server validators enforce policy)'; + return; + } + + let score = 0; + if (val.length >= 8) score += 1; + if (val.length >= 12) score += 1; + if (/[a-z]/.test(val) && /[A-Z]/.test(val)) score += 1; + if (/[0-9]/.test(val) && /[^a-zA-Z0-9]/.test(val)) score += 1; + + bar.className = 'settings-strength-bar'; + if (score <= 1) { + bar.classList.add('weak'); + hint.textContent = 'Strength hint: Weak (hint only; server validators enforce policy)'; + } else if (score === 2) { + bar.classList.add('fair'); + hint.textContent = 'Strength hint: Fair (hint only; server validators enforce policy)'; + } else if (score === 3) { + bar.classList.add('good'); + hint.textContent = 'Strength hint: Good (hint only; server validators enforce policy)'; + } else { + bar.classList.add('strong'); + hint.textContent = 'Strength hint: Strong (hint only; server validators enforce policy)'; + } + }); + } + + setupStrengthMeter('settings-new-password', 'strength-bar-normal', 'strength-hint-normal'); + setupStrengthMeter('settings-otp-new-password', 'strength-bar-otp', 'strength-hint-otp'); } + diff --git a/templates/accounts/settings.html b/templates/accounts/settings.html index f115d24..6101c6c 100644 --- a/templates/accounts/settings.html +++ b/templates/accounts/settings.html @@ -347,14 +347,217 @@
- For maximum protection, choose a passphrase of at least 10 characters combining uppercase, lowercase, numbers, and symbols. + For maximum protection, choose a passphrase combining uppercase, lowercase, numbers, and symbols. Changing your password invalidates all other active sessions.
- -+ You are authorized to define a new master password without entering your current password. This authorization expires in 10 minutes. +
++ We can send a 6-digit verification code to your verified email ({{ user.email }}) to verify your identity. +
+