Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:

Expand Down
5 changes: 4 additions & 1 deletion backend/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -177,6 +178,8 @@ def decorated_function(*args, **kwargs):
return decorated_function

return decorator


def csrf_token_required(f):
"""
=============================================================================
Expand Down Expand Up @@ -230,4 +233,4 @@ def decorated_function(*args, **kwargs):
"message": "An internal security error occurred."
}), 500

return decorated_function
return decorated_function
57 changes: 56 additions & 1 deletion backend/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand All @@ -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")
Expand Down
162 changes: 162 additions & 0 deletions frontend/js/authValidator.js
Original file line number Diff line number Diff line change
@@ -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) β†’ <li> 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);
19 changes: 19 additions & 0 deletions frontend/js/passwordValidator.js
Original file line number Diff line number Diff line change
@@ -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 };
}
Loading
Loading