diff --git a/backend/app.py b/backend/app.py
index 0322ffc0a..e4826310f 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -81,16 +81,24 @@
validate_jwt_secret,
is_production_mode
)
+
+
+
+
from password_reset_service import (
FORGOT_PASSWORD_MESSAGE,
request_password_reset,
reset_password_with_token,
)
+
+
+
from email_service import (
build_password_reset_url,
is_email_configured,
send_password_reset_email,
)
+
from collections import defaultdict, deque
from math import ceil
from time import time
@@ -1711,9 +1719,13 @@ def sync_library(validated_data):
# immediately responds with an active session ready to go.
# =========================================================================
@app.route('/api/v1/register', methods=['POST'])
+
+
+
@limiter.limit("5 per 10 seconds")
@validate_schema(RegisterRequest)
def register(validated_data):
+
"""Register a new user and return JWT token."""
try:
diff --git a/backend/middleware.py b/backend/middleware.py
index c6f08d189..784281da5 100644
--- a/backend/middleware.py
+++ b/backend/middleware.py
@@ -15,6 +15,7 @@
except ImportError:
from error_responses import invalid_json_error
from security_parsers import DEFAULT_ALLOWED_CONTENT_TYPES, validate_content_type as _validate_content_type_header
+
logger = logging.getLogger(__name__)
@@ -177,6 +178,8 @@ def decorated_function(*args, **kwargs):
return decorated_function
return decorator
+
+
def csrf_token_required(f):
"""
=============================================================================
@@ -230,4 +233,4 @@ def decorated_function(*args, **kwargs):
"message": "An internal security error occurred."
}), 500
- return decorated_function
+ return decorated_function
\ No newline at end of file
diff --git a/backend/validators.py b/backend/validators.py
index 879c00a7a..26aff7a0c 100644
--- a/backend/validators.py
+++ b/backend/validators.py
@@ -28,6 +28,46 @@ def validate_google_books_id(google_id: str) -> bool:
return bool(GOOGLE_BOOKS_ID_PATTERN.fullmatch(str(google_id).strip()))
+# ─────────────────────────────────────────────────────────────────────────────
+# PASSWORD STRENGTH VALIDATION (Issue #790)
+# ─────────────────────────────────────────────────────────────────────────────
+
+# Individual rule regexes — kept separate so RegisterRequest's field_validator
+# can also iterate over them to produce per-rule error messages.
+_PASSWORD_RULES: List[Dict[str, Any]] = [
+ {"regex": re.compile(r'.{8,}'), "msg": "Minimum 8 characters required"},
+ {"regex": re.compile(r'[A-Z]'), "msg": "At least one uppercase letter (A–Z) required"},
+ {"regex": re.compile(r'[a-z]'), "msg": "At least one lowercase letter (a–z) required"},
+ {"regex": re.compile(r'\d'), "msg": "At least one digit (0–9) required"},
+ {"regex": re.compile(r'[@#$!%&*]'), "msg": "At least one special character (@, #, $, !, %, &, *) required"},
+]
+
+
+def validate_password_strength(password: str) -> tuple[bool, list[str]]:
+ """
+ Check password against all strength rules.
+
+ Returns:
+ (is_valid, errors) — errors is an empty list when is_valid is True.
+
+ Usage (standalone):
+ ok, errors = validate_password_strength("Secure@123")
+ """
+ if not isinstance(password, str):
+ return False, ["Password must be a string"]
+
+ errors = [
+ rule["msg"]
+ for rule in _PASSWORD_RULES
+ if not rule["regex"].search(password)
+ ]
+ return len(errors) == 0, errors
+
+# ─────────────────────────────────────────────────────────────────────────────
+# END PASSWORD STRENGTH VALIDATION
+# ─────────────────────────────────────────────────────────────────────────────
+
+
class ShelfType(str, Enum):
"""Valid shelf types for library items."""
WANT = "want"
@@ -203,7 +243,7 @@ class RegisterRequest(BaseModel):
username: str = Field(..., min_length=3, max_length=50, description="Username (3-50 characters)")
email: EmailStr = Field(..., description="Valid email address")
password: str = Field(..., min_length=8, max_length=100, description="Password (minimum 8 characters)")
-
+
@field_validator('username')
@classmethod
def username_alphanumeric(cls, v: str) -> str:
@@ -213,6 +253,21 @@ def username_alphanumeric(cls, v: str) -> str:
raise ValueError('Username must contain only letters, numbers, and underscores.')
return v
+ @field_validator('password')
+ @classmethod
+ def password_strong_enough(cls, v: str) -> str:
+ """
+ Enforce strong password policy (Issue #790).
+ Runs validate_password_strength() and raises ValueError
+ with all failed rules so the API response is descriptive.
+ """
+ is_valid, errors = validate_password_strength(v)
+ if not is_valid:
+ # Join all failure reasons into one message so Pydantic surfaces them
+ raise ValueError("Password does not meet requirements: " + " | ".join(errors))
+ return v # never sanitize / modify passwords — return as-is
+
+
class LoginRequest(BaseModel):
"""Request schema for POST /api/v1/login endpoint."""
username: str = Field(..., min_length=1, description="Username or email")
diff --git a/frontend/js/authValidator.js b/frontend/js/authValidator.js
new file mode 100644
index 000000000..465a0b2a7
--- /dev/null
+++ b/frontend/js/authValidator.js
@@ -0,0 +1,162 @@
+/**
+ * authValidator.js — Issue #790
+ * Wires up real-time password strength UI for the BiblioDrift auth page.
+ * Depends on passwordValidator.js (imported first in auth.html).
+ */
+
+import { validatePassword, PASSWORD_RULES } from './passwordValidator.js';
+
+// ─── Element references ───────────────────────────────────────────────────────
+const passwordInput = document.getElementById('password');
+const strengthFill = document.getElementById('strength-fill');
+const strengthLabel = document.getElementById('strength-label');
+const submitBtn = document.getElementById('submitBtn');
+const authForm = document.getElementById('authForm');
+
+// Map rule id (from passwordValidator.js) →
id in auth.html
+const RULE_TO_LI = {
+ 'len': 'chk-len',
+ 'up': 'chk-up',
+ 'low': 'chk-low',
+ 'num': 'chk-num',
+ 'spec': 'chk-spec',
+};
+
+const STRENGTH_COLORS = {
+ weak: '#E24B4A',
+ fair: '#EF9F27',
+ good: '#EF9F27',
+ strong: '#1D9E75',
+};
+
+// ─── Show / hide checklist based on form mode ─────────────────────────────────
+function isRegisterMode() {
+ return authForm && authForm.dataset.mode === 'register';
+}
+
+function setChecklistVisible(visible) {
+ const bar = document.querySelector('.password-strength-bar');
+ const lbl = strengthLabel;
+ const ul = document.getElementById('pw-checklist');
+ if (bar) bar.style.display = visible ? '' : 'none';
+ if (lbl) lbl.style.display = visible ? '' : 'none';
+ if (ul) ul.style.display = visible ? '' : 'none';
+}
+
+// ─── Core update function ─────────────────────────────────────────────────────
+function updateStrengthUI() {
+ const val = passwordInput ? passwordInput.value : '';
+
+ // Only show strength UI when in register mode
+ if (!isRegisterMode()) {
+ setChecklistVisible(false);
+ return;
+ }
+
+ setChecklistVisible(true);
+
+ if (!val) {
+ // Reset everything when field is empty
+ if (strengthFill) { strengthFill.style.width = '0%'; strengthFill.style.background = ''; }
+ if (strengthLabel) strengthLabel.textContent = '';
+ Object.values(RULE_TO_LI).forEach(liId => {
+ const li = document.getElementById(liId);
+ if (li) { li.classList.remove('pass', 'fail'); }
+ });
+ if (submitBtn) { submitBtn.disabled = true; submitBtn.style.opacity = '0.45'; submitBtn.style.cursor = 'not-allowed'; }
+ return;
+ }
+
+ const { isValid, results, strength } = validatePassword(val);
+
+ // Update strength bar
+ const pct = (results.filter(r => r.passed).length / PASSWORD_RULES.length) * 100;
+ if (strengthFill) {
+ strengthFill.style.width = `${pct}%`;
+ strengthFill.style.background = STRENGTH_COLORS[strength] || '#E24B4A';
+ }
+ if (strengthLabel) {
+ strengthLabel.textContent = strength
+ ? strength.charAt(0).toUpperCase() + strength.slice(1)
+ : '';
+ strengthLabel.style.color = STRENGTH_COLORS[strength] || '#888';
+ }
+
+ // Update checklist items
+ results.forEach(({ id, passed }) => {
+ const liId = RULE_TO_LI[id];
+ if (!liId) return;
+ const li = document.getElementById(liId);
+ if (!li) return;
+ li.classList.toggle('pass', passed);
+ li.classList.toggle('fail', !passed);
+ });
+
+ // Enable / disable submit button
+ if (submitBtn) {
+ submitBtn.disabled = !isValid;
+ submitBtn.style.opacity = isValid ? '1' : '0.45';
+ submitBtn.style.cursor = isValid ? 'pointer' : 'not-allowed';
+ }
+}
+
+// ─── Listen to password input ─────────────────────────────────────────────────
+if (passwordInput) {
+ passwordInput.addEventListener('input', updateStrengthUI);
+}
+
+// ─── Reset UI when toggling between login ↔ register ────────────────────────
+// auth.html's inline script toggles authForm.dataset.mode — we observe that.
+if (authForm) {
+ const observer = new MutationObserver(() => {
+ updateStrengthUI();
+ // In login mode, always re-enable submit (no strength requirement)
+ if (!isRegisterMode() && submitBtn) {
+ submitBtn.disabled = false;
+ submitBtn.style.opacity = '1';
+ submitBtn.style.cursor = 'pointer';
+ }
+ });
+ observer.observe(authForm, { attributes: true, attributeFilter: ['data-mode'] });
+}
+
+// ─── Block form submission if password is weak (register mode) ───────────────
+if (authForm) {
+ authForm.addEventListener('submit', (e) => {
+ if (!isRegisterMode()) return; // login — no strength check needed
+
+ const val = passwordInput ? passwordInput.value : '';
+ const { isValid, errors } = validatePassword(val);
+
+ if (!isValid) {
+ e.preventDefault();
+ e.stopImmediatePropagation();
+
+ // Shake the checklist to draw attention
+ const ul = document.getElementById('pw-checklist');
+ if (ul) {
+ ul.style.animation = 'none';
+ ul.offsetHeight; // reflow
+ ul.style.animation = 'shake 0.4s ease';
+ }
+
+ // Force-update UI so user sees which rules failed
+ updateStrengthUI();
+ }
+ }, true); // capture phase — runs before handleAuth()
+}
+
+// ─── Simple shake keyframe (injected once) ───────────────────────────────────
+const style = document.createElement('style');
+style.textContent = `
+@keyframes shake {
+ 0%,100% { transform: translateX(0); }
+ 20% { transform: translateX(-6px); }
+ 40% { transform: translateX(6px); }
+ 60% { transform: translateX(-4px); }
+ 80% { transform: translateX(4px); }
+}`;
+document.head.appendChild(style);
+
+// ─── Init: hide checklist on page load (starts in login mode) ────────────────
+setChecklistVisible(false);
\ No newline at end of file
diff --git a/frontend/js/passwordValidator.js b/frontend/js/passwordValidator.js
new file mode 100644
index 000000000..fc40be631
--- /dev/null
+++ b/frontend/js/passwordValidator.js
@@ -0,0 +1,19 @@
+// frontend/js/passwordValidator.js
+
+export const PASSWORD_RULES = [
+ { id: 'len', regex: /.{8,}/, msg: 'At least 8 characters' },
+ { id: 'up', regex: /[A-Z]/, msg: 'One uppercase letter (A–Z)' },
+ { id: 'low', regex: /[a-z]/, msg: 'One lowercase letter (a–z)' },
+ { id: 'num', regex: /[0-9]/, msg: 'One digit (0–9)' },
+ { id: 'spec', regex: /[@#$!%&*]/, msg: 'One special character (@#$!%&*)' },
+];
+
+export function validatePassword(password) {
+ const results = PASSWORD_RULES.map(rule => ({
+ ...rule,
+ passed: rule.regex.test(password),
+ }));
+ const passedCount = results.filter(r => r.passed).length;
+ const strength = ['', 'weak', 'weak', 'fair', 'good', 'strong'][passedCount];
+ return { isValid: passedCount === 5, results, strength };
+}
\ No newline at end of file
diff --git a/frontend/js/security.js b/frontend/js/security.js
index 9c9691e73..e6508ee94 100644
--- a/frontend/js/security.js
+++ b/frontend/js/security.js
@@ -19,6 +19,134 @@ const SAFE_HREF_PROTOCOLS = ['http:', 'https:', 'mailto:'];
const SAFE_HREF_PATTERN = /^(?:(?:https?:|mailto:)[^\s]*|#.*|\/(?!\/)[^\s]*|[^:/\s][^:\s]*)$/i;
let domPurifyHooksRegistered = false;
+// ─────────────────────────────────────────────────────────────────────────────
+// PASSWORD VALIDATION (Issue #790)
+// ─────────────────────────────────────────────────────────────────────────────
+
+/**
+ * Rules that every new password must satisfy.
+ * Each rule has a unique id (used to target the checklist ),
+ * a regex to test against, and the human-readable message shown to the user.
+ */
+const PASSWORD_RULES = [
+ { id: 'pw-len', regex: /.{8,}/, msg: 'At least 8 characters' },
+ { id: 'pw-up', regex: /[A-Z]/, msg: 'One uppercase letter (A–Z)' },
+ { id: 'pw-low', regex: /[a-z]/, msg: 'One lowercase letter (a–z)' },
+ { id: 'pw-num', regex: /[0-9]/, msg: 'One digit (0–9)' },
+ { id: 'pw-spec', regex: /[@#$!%&*]/, msg: 'One special character (@, #, $, !, %, &, *)' },
+];
+
+/**
+ * Validate password strength against PASSWORD_RULES.
+ *
+ * @param {string} password - The password to test
+ * @returns {{ isValid: boolean, errors: string[], strength: string, results: Array }}
+ * - isValid : true only when ALL rules pass
+ * - errors : array of failure messages (empty when valid)
+ * - strength : 'weak' | 'fair' | 'good' | 'strong'
+ * - results : per-rule objects { id, msg, passed } for building UI checklists
+ */
+function validatePassword(password) {
+ if (typeof password !== 'string') {
+ return { isValid: false, errors: ['Password must be a string'], strength: 'weak', results: [] };
+ }
+
+ const results = PASSWORD_RULES.map(rule => ({
+ id: rule.id,
+ msg: rule.msg,
+ passed: rule.regex.test(password),
+ }));
+
+ const passedCount = results.filter(r => r.passed).length;
+ const errors = results.filter(r => !r.passed).map(r => r.msg);
+
+ // Map 1–5 passed rules to a label; 0 rules = no label yet
+ const strengthMap = { 1: 'weak', 2: 'weak', 3: 'fair', 4: 'good', 5: 'strong' };
+ const strength = passedCount === 0 ? '' : (strengthMap[passedCount] || 'weak');
+
+ return {
+ isValid: passedCount === PASSWORD_RULES.length,
+ errors,
+ strength,
+ results,
+ };
+}
+
+/**
+ * Wire up real-time password strength UI.
+ *
+ * Expects this HTML structure inside your registration form:
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * - At least 8 characters
+ * - One uppercase letter (A–Z)
+ * - One lowercase letter (a–z)
+ * - One digit (0–9)
+ * - One special character (@, #, $, !, %, &, *)
+ *
+ *
+ * @param {string} inputId - id of the element
+ * @param {string} fillId - id of the strength bar fill
+ * @param {string} labelId - id of the strength label
+ * @param {string} submitId - id of the submit/register
-
+
+
+
+
+ - At least 8 characters
+ - One uppercase letter
+ - One lowercase letter
+ - One digit (0–9)
+ - One special character
+
+
@@ -1056,18 +1058,8 @@ Ambient Sanctuary
+
+