Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/password validation #5

Merged
merged 3 commits into from
Feb 11, 2025
Merged
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
74 changes: 67 additions & 7 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from llm_functions import convert_feedback_text_to_themes, generate_completed_feedback_report

from config import MINIMUM_SUBMISSIONS_REQUIRED, MAGIC_LINK_EXPIRY_DAYS, FEEDBACK_QUALITIES, STARTING_CREDITS, BASE_URL
from utils import beforeware
from utils import beforeware, validate_email_format, validate_password_strength, validate_passwords_match

import requests
import stripe
Expand Down Expand Up @@ -213,10 +213,6 @@ def get():
def get():
return login_form

@app.get("/registration-form")
def get():
return register_form

@app.post("/login")
def post_login(login: Login, sess):
print(login)
Expand Down Expand Up @@ -261,16 +257,78 @@ def get(req):
logger.debug("Serving registration form")
return register_form

# -----------------------
# Routes: Validation
# -----------------------

@app.post("/validate-password")
def validate_password(pwd: str):
"""Validate password strength and return feedback"""
score, issues = validate_password_strength(pwd)

# Create progress bar color based on score
color = "var(--pico-color-red)" if score < 40 else \
"var(--pico-color-yellow)" if score < 70 else \
"var(--pico-color-green)"

return Article(
Progress(value=str(score), max="100", id="pwd-strength",
style=f"color: {color}; background: {color}"),
Div(
*(Li(issue) for issue in issues) if issues else [P("Password strength is good", cls="success")],
id="password-validation",
role="alert",
cls="error" if issues else "success"
),
id="password-verification-status")

@app.post("/validate-email")
def validate_email(email: str):
"""Validate email format and availability"""
is_valid, message = validate_email_format(email)
if not is_valid:
return Div(message, id="email-validation", role="alert", cls="error")

try:
existing = users[email]
return Div("Email already in use", id="email-validation", role="alert", cls="error")
except Exception:
return Div("Email is available", id="email-validation", role="alert", cls="success")

@app.post("/validate-password-match")
def validate_password_match(pwd: str, pwd_confirm: str):
"""Validate that passwords match"""
match, message = validate_passwords_match(pwd, pwd_confirm)
return Div(message, id="pwd-match-validation", role="alert",
cls="success" if match else "error")

# -----------------------
# Routes: User Registration
# -----------------------
@app.post("/register-new-user")
def post_register(email: str, first_name:str, role: str, company: str, team: str, pwd: str, sess):
def post_register(email: str, first_name:str, role: str, company: str, team: str, pwd: str, pwd_confirm: str, sess):
logger.debug(f"Registration attempt for email: {email}")
if "auth" in sess:
logger.debug("Clearing existing session auth")
del sess["auth"]

# Validate email format
is_valid_email, email_msg = validate_email_format(email)
if not is_valid_email:
return Titled("Registration Failed", P(email_msg))

# Validate password strength
score, issues = validate_password_strength(pwd)
if score < 70: # Require a strong password
return Titled("Registration Failed",
P("Password is not strong enough:"),
Ul(*(Li(issue) for issue in issues)))

# Validate passwords match
match, match_msg = validate_passwords_match(pwd, pwd_confirm)
if not match:
return Titled("Registration Failed", P(match_msg))

user_data = {
"id": secrets.token_hex(16),
"first_name": first_name,
Expand Down Expand Up @@ -639,7 +697,7 @@ def get_report_status_page(process_id : str):
else:
needed_submissions = max(process.min_submissions_required - total_submissions, 0)
missing_text = f"Additional responses required before report is available: {needed_submissions} more submission(s)" if needed_submissions > 0 else ""
opening_text = report_in_progress_text + " " + missing_text
opening_text = report_in_progress_text

try:
created_at_dt = datetime.fromisoformat(process.created_at) # If stored in ISO format (e.g., "2025-02-07T14:30:00")
Expand All @@ -652,6 +710,8 @@ def get_report_status_page(process_id : str):
H3(f"{process.process_title} {' (Complete)' if process.feedback_report else ' (In Progress)'}"),
P(f"Created: {formatted_date}"),
Div(opening_text, cls='marked'),
Div(missing_text)

)

requests_list = []
Expand Down
36 changes: 31 additions & 5 deletions pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,37 @@ def navigation_bar_logged_in(user):
)

register_form = Form(
Div(Input(name="first_name", type="text", placeholder="First Name *", required=True)),
Div(Input(name="email", type="email", placeholder="Email *", required=True)),
Div(Input(name="pwd", type="password", placeholder="Password *", required=True)),
Div(Input(name="pwd_confirm", type="password", placeholder="Confirm Password *", required=True)),
Button("Register", type="submit", cls="secondary"),
Div(
Input(name="first_name", type="text", placeholder="First Name *", required=True),
Div(id="first-name-validation", role="alert")
),
Div(
Input(name="email", type="email", placeholder="Email *", required=True,
hx_post="/validate-email",
hx_trigger="change delay:300ms",
hx_target="#email-validation"),
Div(id="email-validation", role="alert")
),
Div(
Input(name="pwd", type="password", placeholder="Password *", required=True,
hx_post="/validate-password",
hx_trigger="keyup changed delay:300ms",
hx_target="#password-verification-status",
hx_swap='outerHTML'),
Article(
Progress(value="0", max="100", id="pwd-strength"),
Div(id="password-validation", role="alert"),
id='password-verification-status'),
),
Div(
Input(name="pwd_confirm", type="password", placeholder="Confirm Password *", required=True,
hx_post="/validate-password-match",
hx_trigger="keyup changed delay:300ms",
hx_target="#pwd-match-validation",
hx_include="[name='pwd']"),
Div(id="pwd-match-validation", role="alert")
),
Button("Register", type="submit", cls="secondary", id="register-btn"),
P("The below is only helpful if you're taking part in a corporate process"),
Div(Input(name="role", type="text", placeholder="Role (Optional, e.g. Software Engineer)", required=False)),
Div(Input(name="company", type="text", placeholder="Company (Optional)", required=False)),
Expand Down
49 changes: 49 additions & 0 deletions static/styles.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,52 @@
/* Validation styles */
[role="alert"] {
margin: 0.5rem 0;
padding: 0.5rem;
border-radius: 4px;
font-size: 0.9rem;
}

[role="alert"].error {
color: var(--pico-del-color);
background-color: var(--pico-del-background-color);
border: 1px solid var(--pico-del-border-color);
}

[role="alert"].success {
color: var(--pico-ins-color);
background-color: var(--pico-ins-background-color);
border: 1px solid var(--pico-ins-border-color);
}

[role="alert"] li {
margin: 0.25rem 0;
list-style-type: none;
}

/* Password strength meter */
progress {
width: 100%;
height: 0.5rem;
border-radius: 4px;
margin: 0.5rem 0;
transition: all 0.3s ease;
}

progress::-webkit-progress-bar {
background-color: var(--pico-background-color);
border-radius: 4px;
}

progress::-webkit-progress-value {
border-radius: 4px;
transition: all 0.3s ease;
}

progress::-moz-progress-bar {
border-radius: 4px;
transition: all 0.3s ease;
}

/* Basic styling for forms and containers */
.registration-form {
padding: 1.5rem;
Expand Down
73 changes: 73 additions & 0 deletions utils.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
from fasthtml.common import *
from models import users, feedback_process_tb, feedback_request_tb, FeedbackProcess, FeedbackRequest, Login

import re
import logging

# Password validation constants
MIN_PASSWORD_LENGTH = 6
PASSWORD_PATTERNS = {
'lowercase': r'[a-z]',
'uppercase': r'[A-Z]',
'numbers': r'\d',
'special': r'[!@#$%^&*(),.?":{}|<>]'
}

# Configure logging based on environment variable
log_level = os.environ.get("LOG_LEVEL", "INFO").upper()
logging.basicConfig(
Expand All @@ -24,11 +34,74 @@ def auth_before(req, sess):
return RedirectResponse("/", status_code=303)


def validate_password_strength(password: str) -> tuple[int, list[str]]:
"""
Validate password strength and return a score (0-100) and list of issues.
"""
if not password:
return 0, ["Password is required"]

issues = []
score = 0

# Length check (up to 40 points)
length_score = min(len(password) * 3, 40)
score += length_score
if len(password) < MIN_PASSWORD_LENGTH:
issues.append(f"Password must be at least {MIN_PASSWORD_LENGTH} characters")

# Character type checks (15 points each)
for pattern_name, pattern in PASSWORD_PATTERNS.items():
if re.search(pattern, password):
score += 15
else:
issues.append(f"Missing {pattern_name}")

# Common patterns check
common_patterns = [
r'123', r'abc', r'qwerty', r'admin', r'password',
r'([a-zA-Z0-9])\1{2,}' # Three or more repeated characters
]
for pattern in common_patterns:
if re.search(pattern, password.lower()):
score = max(0, score - 20)
issues.append("Contains common pattern")
break

return score, issues

def validate_email_format(email: str) -> tuple[bool, str]:
"""
Validate email format and return (is_valid, message).
"""
if not email:
return False, "Email is required"

email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
if not re.match(email_pattern, email):
return False, "Invalid email format"

return True, "Email format is valid"

def validate_passwords_match(password: str, confirm_password: str) -> tuple[bool, str]:
"""
Validate that passwords match and return (match, message).
"""
if not password or not confirm_password:
return False, "Both passwords are required"

if password != confirm_password:
return False, "Passwords do not match"

return True, "Passwords match"

beforeware = Beforeware(auth_before, skip=[r'/',
r'^/validate-.*', # Match all validate-* endpoints
r'/login',
r'/get-started',
r'/about',
r'/faq',
r'/register'
r'/login-or-register',
r'/confirm-email/.*',
r'/login-form',
Expand Down