Skip to content
Closed
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
80 changes: 79 additions & 1 deletion backend/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@
DEFAULT_ALLOWED_CONTENT_TYPES,
validate_content_type as _validate_content_type_header,
)
from .validators import validate_password_strength
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
from validators import validate_password_strength

logger = logging.getLogger(__name__)

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

return decorator


# ─────────────────────────────────────────────────────────────────────────────
# PASSWORD STRENGTH ENFORCEMENT (Issue #790)
# ─────────────────────────────────────────────────────────────────────────────

def require_strong_password(f):
"""
Decorator that enforces strong password policy on any endpoint that
receives a 'password' field in its JSON body (e.g. /api/v1/register).

This acts as a second layer of defence — RegisterRequest in validators.py
already runs the same check via Pydantic. Having it here means the rule
is enforced even if the route is called without the Pydantic schema, and
it returns a consistent, descriptive 400 response before any business
logic runs.

Usage:
@app.route('/api/v1/register', methods=['POST'])
@require_strong_password
def register():
...

Response on failure (HTTP 400):
{
"success": false,
"error": "Password does not meet security requirements",
"requirements": [
"Minimum 8 characters required",
"At least one uppercase letter (A–Z) required"
]
}
"""
@wraps(f)
def decorated_function(*args, **kwargs):
# Only check POST / PUT / PATCH — skip GET, HEAD, DELETE
if request.method not in ('POST', 'PUT', 'PATCH'):
return f(*args, **kwargs)

data = request.get_json(silent=True) or {}
password = data.get('password', '')

if not password:
logger.warning(
f"[require_strong_password] Missing password field "
f"on {request.method} {request.path} from {request.remote_addr}"
)
return jsonify({
'success': False,
'error': 'Password is required',
'requirements': []
}), 400

is_valid, errors = validate_password_strength(password)

if not is_valid:
logger.warning(
f"[require_strong_password] Weak password rejected "
f"on {request.path} from {request.remote_addr} — "
f"failed rules: {errors}"
)
return jsonify({
'success': False,
'error': 'Password does not meet security requirements',
'requirements': errors
}), 400

return f(*args, **kwargs)

return decorated_function

# ─────────────────────────────────────────────────────────────────────────────
# END PASSWORD STRENGTH ENFORCEMENT
# ─────────────────────────────────────────────────────────────────────────────


def csrf_token_required(f):
"""
=============================================================================
Expand Down Expand Up @@ -230,4 +308,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
192 changes: 192 additions & 0 deletions frontend/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -7112,6 +7112,198 @@ body.night-mode .tooltip-author {
border-color: var(--text-main);
}

/* Password Strength Styles */
.password-strength-bar {
height: 5px;
background: #e0e0e0;
border-radius: 3px;
margin: 6px 0 4px;
overflow: hidden;
}
.strength-fill {
height: 100%;
width: 0%;
border-radius: 3px;
transition: width 0.3s ease, background 0.3s ease;
}
.strength-label { font-size: 12px; margin: 0 0 8px; min-height: 16px; }
.pw-checklist { list-style: none; padding: 0; margin: 0 0 12px; font-size: 13px; }
.pw-checklist li { padding: 2px 0; color: #888; transition: color 0.2s; }
.pw-checklist li.pass { color: #1D9E75; }
.pw-checklist li.fail { color: #E24B4A; }
.pw-checklist li.pass::before { content: '✓ '; }
.pw-checklist li.fail::before { content: '✗ '; }

/* Purani CSS ke sath yeh hissa add/update karein */

.avatar-icon {
font-size: 18px;
color: #555;
background: #e0e0e0;
padding: 8px;
border-radius: 50%;
width: 35px;
height: 35px;
display: flex;
align-items: center;
justify-content: center;
}

.like-btn {
background: none;
border: 1px solid #ff4d4d;
color: #ff4d4d;
padding: 6px 12px;
border-radius: 20px;
cursor: pointer;
margin-bottom: 15px;
font-weight: bold;
transition: 0.2s;
}

/* Hover karne par button thoda bharega */
.like-btn:hover {
background: #ff4d4d;
color: white;
}

.comment {
font-size: 14px;
margin-bottom: 8px;
color: #333;
}

.comment i {
color: #007bff; /* Comment icon ka color blue */
margin-right: 5px;
}

/* ==========================================================================
Book Club Discussion Forum Styles
========================================================================== */

.discussion-container {
max-width: 800px;
margin: 40px auto;
}

.create-post-section {
background: var(--card-bg, rgba(255, 255, 255, 0.03));
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.05));
padding: 25px;
border-radius: 12px;
margin-bottom: 40px;
backdrop-filter: blur(10px);
}

.styled-input {
width: 100%;
padding: 12px 15px;
margin-bottom: 15px;
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--control-border, rgba(255, 255, 255, 0.1));
border-radius: 8px;
color: var(--text-main, #fff);
font-family: inherit;
transition: 0.3s;
}

.styled-input:focus {
outline: none;
border-color: var(--accent-gold, #d4af37);
background: rgba(0, 0, 0, 0.3);
}

textarea.styled-input {
min-height: 100px;
resize: vertical;
}

/* Post Cards */
.post-card {
background: var(--card-bg, rgba(255, 255, 255, 0.02));
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.05));
padding: 20px;
border-radius: 10px;
margin-bottom: 25px;
transition: transform 0.2s ease, border-color 0.2s ease;
}

.post-card:hover {
transform: translateY(-2px);
border-color: var(--accent-gold, rgba(212, 175, 55, 0.5));
}

.post-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
color: var(--text-muted, #a0a0a0);
}

.avatar-icon {
width: 35px;
height: 35px;
background: var(--wood-dark, #3E2723);
color: var(--accent-gold, #d4af37);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
border: 1px solid rgba(255,255,255,0.1);
}

.post-title {
font-family: 'Playfair Display', serif;
font-size: 1.4rem;
margin-bottom: 10px;
color: var(--text-main, #fff);
}

.post-text {
line-height: 1.6;
color: var(--text-muted, #ccc);
margin-bottom: 20px;
}

/* Comments Section */
.comments-section {
background: rgba(0, 0, 0, 0.15);
padding: 15px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.02);
}

.comments-section h4 {
color: var(--text-main, #fff);
margin-bottom: 10px;
font-size: 0.95rem;
}

.comment {
font-size: 0.9rem;
color: var(--text-muted, #bbb);
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}

.comment:last-child {
border-bottom: none;
}

.comment i {
color: var(--accent-gold, #d4af37);
margin-right: 8px;
}

.add-comment {
display: flex;
gap: 10px;
margin-top: 15px;
}
/* ── No-results empty state ── */
#no-results-state {
justify-content: center;
Expand Down
Loading
Loading