diff --git a/app/services/validation_service.py b/app/services/validation_service.py index cbaaaa0..3f2ccec 100644 --- a/app/services/validation_service.py +++ b/app/services/validation_service.py @@ -23,7 +23,7 @@ def validate_password(password: str, confirm: str) -> str | None: Rules ----- - Password and confirmation must match. - - Minimum length: 8 characters. + - Length: 8-128 characters. - Must contain at least one uppercase letter (A-Z). - Must contain at least one digit (0-9). - Must contain at least one special character (!@#$%^&* etc.). @@ -38,6 +38,10 @@ def validate_password(password: str, confirm: str) -> str | None: return 'Passwords do not match.' if len(password) < 8: return 'Password must be at least 8 characters.' + # Upper bound prevents a pathologically long input from driving up the + # cost of password hashing (which scales with input size) on the server. + if len(password) > 128: + return 'Password must be no more than 128 characters.' if not re.search(r'[A-Z]', password): return 'Password must contain at least one uppercase letter.' if not re.search(r'\d', password): diff --git a/app/static/css/password-strength.css b/app/static/css/password-strength.css new file mode 100644 index 0000000..736b313 --- /dev/null +++ b/app/static/css/password-strength.css @@ -0,0 +1,54 @@ +/* + * Shared password-strength UI: show/hide toggle, strength meter, rule + * checklist, and confirm-match indicator. Used on the register, profile, + * admin create/edit user, and password reset forms. + * + * Relies on the --bg, --border, --border2, --muted, --success, --danger, + * --accent CSS custom properties already defined by each page's theme + * (see base.html for admin/auth pages, or the page's own :root block for + * the standalone auth pages). + */ + +.pw-wrap { position: relative; } +.pw-wrap input { padding-right: 42px; } +.pw-toggle { + position: absolute; right: 10px; top: 50%; transform: translateY(-50%); + background: none; border: none; color: var(--muted); cursor: pointer; + font-size: 16px; padding: 4px; line-height: 1; transition: color .15s; +} +.pw-toggle:hover { color: var(--accent); } + +.pw-strength-wrap { margin-top: 8px; } +.pw-strength-track { + height: 4px; background: var(--border); border-radius: 2px; + overflow: hidden; margin-bottom: 6px; +} +.pw-strength-fill { + height: 100%; border-radius: 2px; width: 0%; + transition: width .3s ease, background .3s ease; +} +.pw-strength-label { + font-size: 11px; font-weight: 600; letter-spacing: .3px; + display: flex; align-items: center; gap: 5px; transition: color .3s; +} + +.pw-rules { + margin-top: 8px; display: grid; grid-template-columns: 1fr 1fr; gap: 3px 12px; + background: var(--bg); border: 1px solid var(--border); border-radius: 8px; + padding: 10px 12px; +} +.pw-rule { display: flex; align-items: center; gap: 5px; font-size: 11.5px; color: var(--muted); transition: color .2s; } +.pw-rule .ri { font-size: 13px; width: 14px; text-align: center; transition: color .2s, transform .15s; } +.pw-rule.met { color: var(--success); } +.pw-rule.met .ri { color: var(--success); transform: scale(1.1); } +.pw-rule.unmet .ri { color: var(--border2); } + +.pw-match { + font-size: 11.5px; margin-top: 6px; display: flex; align-items: center; gap: 5px; + font-weight: 600; letter-spacing: .2px; min-height: 18px; transition: color .2s; +} +.pw-match.ok { color: var(--success); } +.pw-match.fail { color: var(--danger); } + +.pw-input.valid { border-color: var(--success) !important; box-shadow: 0 0 0 3px rgba(5,150,105,.08) !important; } +.pw-input.invalid { border-color: var(--danger) !important; box-shadow: 0 0 0 3px rgba(220,38,38,.08) !important; } diff --git a/app/static/js/password-strength.js b/app/static/js/password-strength.js new file mode 100644 index 0000000..7e75884 --- /dev/null +++ b/app/static/js/password-strength.js @@ -0,0 +1,176 @@ +/** + * Shared password-strength UI: live rule checklist, strength meter, + * confirm-password match indicator, and show/hide toggle. + * + * The RULES below mirror validate_password() in + * app/services/validation_service.py exactly. This is the single + * client-side copy of that policy — every password form (register, + * profile, admin create/edit user, password reset) includes this file + * instead of re-implementing the checks, so the two can never drift + * out of sync with each other. The server remains the source of truth; + * this file only gives the user live feedback before they submit. + */ +(function (global) { + 'use strict'; + + const RULES = { + len : pw => pw.length >= 8 && pw.length <= 128, + upper : pw => /[A-Z]/.test(pw), + digit : pw => /\d/.test(pw), + special: pw => /[!@#$%^&*()\-_=+\[\]{};:'",.<>?/\\|`~]/.test(pw), + }; + + const LEVELS = [ + { label: 'Very Weak', color: '#ef4444', pct: 12 }, + { label: 'Weak', color: '#f97316', pct: 30 }, + { label: 'Fair', color: '#eab308', pct: 52 }, + { label: 'Good', color: '#22c55e', pct: 76 }, + { label: 'Strong', color: '#059669', pct: 100 }, + ]; + + function score(pw) { + let s = 0; + if (RULES.len(pw)) s++; + if (RULES.upper(pw)) s++; + if (RULES.digit(pw)) s++; + if (RULES.special(pw)) s++; + if (pw.length >= 16) s++; // bonus for extra length + return s; + } + + function allRulesMet(pw) { + return RULES.len(pw) && RULES.upper(pw) && RULES.digit(pw) && RULES.special(pw); + } + + function byId(idOrEl) { + return typeof idOrEl === 'string' ? document.getElementById(idOrEl) : idOrEl; + } + + function setRuleEl(el, met) { + if (!el) return; + el.classList.toggle('met', met); + el.classList.toggle('unmet', !met); + const icon = el.querySelector('.ri'); + if (icon) { + icon.innerHTML = met + ? '' + : ''; + } + } + + function toggleVisibility(inputEl, btnEl) { + if (!inputEl || !btnEl) return; + const showing = inputEl.type === 'password'; + inputEl.type = showing ? 'text' : 'password'; + const icon = btnEl.querySelector('i'); + if (icon) icon.className = showing ? 'bi bi-eye-slash' : 'bi bi-eye'; + btnEl.title = showing ? 'Hide password' : 'Show password'; + } + + /** + * Wire up a password field with a live strength bar + rule checklist, + * and optionally a confirm-password field with a match indicator. + * + * opts: + * password - id/element of the password (required) + * confirm - id/element of the confirm-password (optional) + * strengthBar - id/element of the strength bar fill + * strengthLabel - id/element of the strength label text + * strengthWrap - id/element to show/hide with the strength bar + * rulesWrap - id/element of the checklist container to show/hide + * rules - { len, upper, digit, special } -> ids/elements of checklist rows + * matchMsg - id/element for the confirm-match message + * toggles - [{ input, button }, ...] show/hide password buttons + * onChange() - called after every recompute (e.g. to gate a submit button) + */ + function init(opts) { + const pwEl = byId(opts.password); + if (!pwEl) return null; + + const confirmEl = byId(opts.confirm); + const barEl = byId(opts.strengthBar); + const labelEl = byId(opts.strengthLabel); + const strengthWrapEl = byId(opts.strengthWrap); + const rulesWrapEl = byId(opts.rulesWrap); + const matchMsgEl = byId(opts.matchMsg); + const ruleEls = {}; + if (opts.rules) { + Object.keys(opts.rules).forEach(k => { ruleEls[k] = byId(opts.rules[k]); }); + } + + function renderStrength() { + const pw = pwEl.value; + + if (pw.length === 0) { + if (barEl) barEl.style.width = '0%'; + if (labelEl) { labelEl.textContent = ''; labelEl.style.color = ''; } + if (strengthWrapEl) strengthWrapEl.style.display = 'none'; + if (rulesWrapEl) rulesWrapEl.style.display = 'none'; + pwEl.classList.remove('valid', 'invalid'); + } else { + if (strengthWrapEl) strengthWrapEl.style.display = 'block'; + if (rulesWrapEl) rulesWrapEl.style.display = 'grid'; + Object.keys(ruleEls).forEach(k => setRuleEl(ruleEls[k], RULES[k](pw))); + + const s = score(pw); + const lvl = LEVELS[Math.max(0, s - 1)] || LEVELS[0]; + if (barEl) { barEl.style.width = lvl.pct + '%'; barEl.style.background = lvl.color; } + if (labelEl) { + const shieldIcon = s >= 4 ? 'shield-fill' : s >= 2 ? 'shield-half' : 'shield'; + labelEl.innerHTML = ` ${lvl.label}`; + labelEl.style.color = lvl.color; + } + + const met = allRulesMet(pw); + pwEl.classList.toggle('valid', met); + pwEl.classList.toggle('invalid', !met); + } + + renderMatch(); + if (opts.onChange) opts.onChange(); + } + + function renderMatch() { + if (!confirmEl) return; + const pw = pwEl.value; + const pw2 = confirmEl.value; + + if (pw2.length === 0) { + if (matchMsgEl) { matchMsgEl.innerHTML = ''; matchMsgEl.classList.remove('ok', 'fail'); } + confirmEl.classList.remove('valid', 'invalid'); + return; + } + + const ok = pw === pw2; + if (matchMsgEl) { + matchMsgEl.innerHTML = ok + ? ' Passwords match' + : ' Passwords do not match'; + matchMsgEl.classList.toggle('ok', ok); + matchMsgEl.classList.toggle('fail', !ok); + } + confirmEl.classList.toggle('valid', ok); + confirmEl.classList.toggle('invalid', !ok); + } + + pwEl.addEventListener('input', renderStrength); + if (confirmEl) { + confirmEl.addEventListener('input', function () { + renderMatch(); + if (opts.onChange) opts.onChange(); + }); + } + + if (opts.toggles) { + opts.toggles.forEach(({ input, button }) => { + const inputEl = byId(input); + const buttonEl = byId(button); + if (buttonEl) buttonEl.addEventListener('click', () => toggleVisibility(inputEl, buttonEl)); + }); + } + + return { allRulesMet, score, renderStrength, renderMatch }; + } + + global.PasswordStrength = { init, RULES, allRulesMet, score }; +})(window); diff --git a/app/templates/admin/create_user.html b/app/templates/admin/create_user.html index 45f0b95..c656061 100644 --- a/app/templates/admin/create_user.html +++ b/app/templates/admin/create_user.html @@ -3,26 +3,7 @@ {% block page_title %}User Management{% endblock %} {% block head %} - + {% endblock %} {% block content %} @@ -120,36 +101,39 @@