diff --git a/decnet_web/src/components/Config/tabs/UsersTab.tsx b/decnet_web/src/components/Config/tabs/UsersTab.tsx index c67b6c99..a517b7f8 100644 --- a/decnet_web/src/components/Config/tabs/UsersTab.tsx +++ b/decnet_web/src/components/Config/tabs/UsersTab.tsx @@ -54,8 +54,8 @@ export const UsersTab: React.FC = ({ }; const handleResetPassword = async (uuid: string) => { - if (!resetPassword.trim() || resetPassword.length < 8) { - alert('Password must be at least 8 characters'); + if (!resetPassword.trim() || resetPassword.length < 12) { + alert('Password must be at least 12 characters'); return; } const r = await onResetUserPassword(uuid, resetPassword); @@ -193,7 +193,7 @@ export const UsersTab: React.FC = ({ value={newPassword} onChange={(e) => setNewPassword(e.target.value)} required - minLength={8} + minLength={12} maxLength={72} /> diff --git a/decnet_web/src/components/Login.css b/decnet_web/src/components/Login.css index 66fa8a4b..4e03e527 100644 --- a/decnet_web/src/components/Login.css +++ b/decnet_web/src/components/Login.css @@ -95,3 +95,83 @@ html[data-theme="light"] .login-form input { opacity: 0.4; letter-spacing: 1px; } + +/* Password strength checklist — shown during must_change_password flow */ +.pw-checklist { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px 10px; + border: 1px solid var(--border-color); + background-color: rgba(0, 0, 0, 0.25); +} + +.pw-check { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.65rem; + letter-spacing: 0.5px; + opacity: 0.55; + transition: color 0.15s, opacity 0.15s; +} + +.pw-check.passed { + color: var(--matrix, #00ff41); + opacity: 0.9; +} + +.pw-check.failed { + color: var(--alert, #ff4141); + opacity: 0.75; +} + +.pw-check-icon { + font-size: 0.6rem; + width: 10px; + flex-shrink: 0; +} + +/* Class sub-hints shown beneath the char-classes check */ +.pw-class-hints { + display: flex; + gap: 8px; + padding-left: 16px; + flex-wrap: wrap; +} + +.pw-class-hint { + font-size: 0.6rem; + opacity: 0.4; + letter-spacing: 0.3px; +} + +.pw-class-hint.active { + color: var(--matrix, #00ff41); + opacity: 0.8; +} + +/* Light mode: the neon-on-dark box reads as muddy grey, so go fully white + with black text. Pass/fail stays legible via the ✓/✗ icon colour only. */ +html[data-theme="light"] .pw-checklist { + background-color: #ffffff; + border-color: rgba(0, 0, 0, 0.18); +} + +html[data-theme="light"] .pw-check, +html[data-theme="light"] .pw-check.passed, +html[data-theme="light"] .pw-check.failed, +html[data-theme="light"] .pw-class-hint, +html[data-theme="light"] .pw-class-hint.active { + color: var(--ink, #1a1a1a); + opacity: 0.85; +} + +html[data-theme="light"] .pw-check.passed .pw-check-icon { + color: #0a7d2c; +} + +html[data-theme="light"] .pw-check.failed .pw-check-icon { + color: #c62828; + opacity: 1; +} diff --git a/decnet_web/src/components/Login.tsx b/decnet_web/src/components/Login.tsx index b89bd397..f0c3705c 100644 --- a/decnet_web/src/components/Login.tsx +++ b/decnet_web/src/components/Login.tsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import React, { useState } from 'react'; import api from '../utils/api'; +import { validateNewPassword } from '../utils/passwordPolicy'; import './Login.css'; import { Activity } from '../icons'; @@ -47,6 +48,12 @@ const Login: React.FC = ({ onLogin }) => { setError('Passwords do not match'); return; } + // Client-side policy guard — API is the enforcement boundary; this prevents + // the opaque 400 "Schema structural violation" from reaching the user. + if (!validateNewPassword(newPassword).ok) { + setError('Password does not meet the requirements below'); + return; + } setLoading(true); setError(''); @@ -113,32 +120,57 @@ const Login: React.FC = ({ onLogin }) => {

MANDATORY SECURITY UPDATE

Please establish a new access key

- +
- setNewPassword(e.target.value)} - required - minLength={8} + setNewPassword(e.target.value)} + required + minLength={12} />
- setConfirmPassword(e.target.value)} - required - minLength={8} + setConfirmPassword(e.target.value)} + required + minLength={12} />
+ {(() => { + const { checks, classes } = validateNewPassword(newPassword); + return ( +
+ {checks.map((chk) => ( +
+ {chk.passed ? '✓' : '✗'} + {chk.label} + {chk.id === 'char-classes' && ( + + a–z + A–Z + 0–9 + #!@… + + )} +
+ ))} +
+ ); + })()} + {error &&
{error}
} - diff --git a/decnet_web/src/utils/passwordPolicy.test.ts b/decnet_web/src/utils/passwordPolicy.test.ts new file mode 100644 index 00000000..16d5097a --- /dev/null +++ b/decnet_web/src/utils/passwordPolicy.test.ts @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import { describe, it, expect } from 'vitest'; +import { validateNewPassword, MIN_LENGTH, MAX_BYTES, MIN_CLASSES } from './passwordPolicy'; + +describe('validateNewPassword', () => { + it('exports the expected constants', () => { + expect(MIN_LENGTH).toBe(12); + expect(MAX_BYTES).toBe(72); + expect(MIN_CLASSES).toBe(3); + }); + + it('empty string — length and class checks fail, ok=false', () => { + const r = validateNewPassword(''); + expect(r.ok).toBe(false); + expect(r.checks.find((c) => c.id === 'min-length')!.passed).toBe(false); + expect(r.checks.find((c) => c.id === 'char-classes')!.passed).toBe(false); + // max-bytes trivially passes (0 <= 72), but ok is still false + expect(r.checks.find((c) => c.id === 'max-bytes')!.passed).toBe(true); + expect(r.classes).toEqual({ lower: false, upper: false, digit: false, special: false }); + }); + + it('11 characters — fails length check', () => { + const r = validateNewPassword('aB1!aB1!aB1'); // 11 chars, 3 classes + const lengthCheck = r.checks.find((c) => c.id === 'min-length')!; + expect(lengthCheck.passed).toBe(false); + expect(r.ok).toBe(false); + }); + + it('exactly 12 characters — passes length check', () => { + const r = validateNewPassword('aB1!aB1!aB1!'); // 12 chars + const lengthCheck = r.checks.find((c) => c.id === 'min-length')!; + expect(lengthCheck.passed).toBe(true); + }); + + it('multibyte: <=72 chars but >72 bytes — fails byte check', () => { + // Each '€' is 3 bytes in UTF-8. 25 × '€' = 75 bytes but only 25 chars. + // Pad with ASCII so length >=12 and classes >=3 to isolate the byte check. + const emoji = '€'.repeat(20) + 'aB1!'; // 24 chars, 20×3+4 = 64 bytes → passes + const r1 = validateNewPassword(emoji); + const byteCheck1 = r1.checks.find((c) => c.id === 'max-bytes')!; + expect(byteCheck1.passed).toBe(true); + + // 25 × '€' (75 bytes) + 'aB1!' → 79 bytes → fails + const tooBig = '€'.repeat(25) + 'aB1!'; // 29 chars, 79 bytes + const r2 = validateNewPassword(tooBig); + const byteCheck2 = r2.checks.find((c) => c.id === 'max-bytes')!; + expect(byteCheck2.passed).toBe(false); + expect(r2.ok).toBe(false); + }); + + it('1 class (lowercase only) — class check fails', () => { + const r = validateNewPassword('abcdefghijkl'); // 12 chars, 1 class + const cls = r.checks.find((c) => c.id === 'char-classes')!; + expect(cls.passed).toBe(false); + expect(r.classes.lower).toBe(true); + expect(r.classes.upper).toBe(false); + }); + + it('2 classes (lower + upper) — class check fails', () => { + const r = validateNewPassword('AbcdefGhijkl'); // 12 chars, 2 classes + const cls = r.checks.find((c) => c.id === 'char-classes')!; + expect(cls.passed).toBe(false); + expect(r.classes.lower).toBe(true); + expect(r.classes.upper).toBe(true); + expect(r.classes.digit).toBe(false); + }); + + it('3 classes (lower + upper + digit) — class check passes', () => { + const r = validateNewPassword('AbcdefGhij12'); // 12 chars, 3 classes + const cls = r.checks.find((c) => c.id === 'char-classes')!; + expect(cls.passed).toBe(true); + }); + + it('4 classes — class check passes', () => { + const r = validateNewPassword('AbcdefGh1!23'); // 12 chars, all 4 classes + const cls = r.checks.find((c) => c.id === 'char-classes')!; + expect(cls.passed).toBe(true); + expect(r.classes).toEqual({ lower: true, upper: true, digit: true, special: true }); + }); + + it('fully valid strong password — ok=true', () => { + const r = validateNewPassword('Tr0ub4dor&3xY'); // long, all classes + expect(r.ok).toBe(true); + r.checks.forEach((c) => expect(c.passed).toBe(true)); + }); +}); diff --git a/decnet_web/src/utils/passwordPolicy.ts b/decnet_web/src/utils/passwordPolicy.ts new file mode 100644 index 00000000..858459d6 --- /dev/null +++ b/decnet_web/src/utils/passwordPolicy.ts @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// Client-side password policy mirror. +// NOTE: This is advisory UX only — the API remains the enforcement boundary. +// Keep constants in sync with decnet/web/db/models/auth.py ChangePasswordRequest. + +export const MIN_LENGTH = 12; +export const MAX_BYTES = 72; +export const MIN_CLASSES = 3; + +export interface PasswordCheck { + id: string; + label: string; + passed: boolean; +} + +export interface PasswordResult { + checks: PasswordCheck[]; + /** True when every check passes. */ + ok: boolean; + /** Individual character-class flags exposed for richer UI hints. */ + classes: { + lower: boolean; + upper: boolean; + digit: boolean; + special: boolean; + }; +} + +export function validateNewPassword(pw: string): PasswordResult { + const lower = /[a-z]/.test(pw); + const upper = /[A-Z]/.test(pw); + const digit = /[0-9]/.test(pw); + const special = /[^a-zA-Z0-9]/.test(pw); + const classCount = [lower, upper, digit, special].filter(Boolean).length; + + const byteLen = new TextEncoder().encode(pw).length; + + const checks: PasswordCheck[] = [ + { + id: 'min-length', + label: `At least ${MIN_LENGTH} characters`, + passed: pw.length >= MIN_LENGTH, + }, + { + id: 'max-bytes', + label: `${MAX_BYTES} bytes or fewer (bcrypt limit)`, + passed: byteLen <= MAX_BYTES, + }, + { + id: 'char-classes', + label: `At least ${MIN_CLASSES} of 4 character classes (lowercase, uppercase, digit, special)`, + passed: classCount >= MIN_CLASSES, + }, + ]; + + return { + checks, + ok: checks.every((c) => c.passed), + classes: { lower, upper, digit, special }, + }; +}