diff --git a/app/routes/auth.py b/app/routes/auth.py index dc5b22c..d832f82 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -1,5 +1,4 @@ from flask import Blueprint, render_template, redirect, url_for, flash, request, abort, session -from urllib.parse import urlparse from flask_login import login_user, logout_user, login_required, current_user from app import db, limiter from app.models.user import User @@ -199,7 +198,7 @@ def profile(): if form.validate_on_submit(): current_user.full_name = form.full_name.data.strip() or None - current_user.email = form.email.data + current_user.email = form.email.data.strip().lower() if form.new_password.data: current_user.set_password(form.new_password.data) @@ -289,7 +288,7 @@ def create_user(): user = User( username=form.username.data, full_name=form.full_name.data.strip() or None, - email=form.email.data, + email=form.email.data.strip().lower(), role=role ) user.set_password(form.password.data) @@ -323,7 +322,7 @@ def edit_user(user_id): if form.validate_on_submit(): user.username = form.username.data user.full_name = form.full_name.data.strip() or None - user.email = form.email.data + user.email = form.email.data.strip().lower() if not director_editing: user.role = form.role.data @@ -536,7 +535,6 @@ def _send_password_reset_email(user, token, base_url=None): from flask import current_app, render_template_string, url_for as _url_for from flask_mail import Message from app import mail - from urllib.parse import urlparse import threading if not current_app.config.get('MAIL_SERVER'): @@ -545,8 +543,17 @@ def _send_password_reset_email(user, token, base_url=None): effective_base = (base_url or current_app.config.get('APP_BASE_URL', '')).rstrip('/') reset_link = f'{effective_base}{_url_for("auth.reset_password", token=token)}' - host = urlparse(effective_base).netloc or 'janitorialqc.local' - sender = f'noreply@{host}' + + # Branded From as a (display_name, address) tuple, exactly as + # customers._send_invite_email does. The display NAME tracks the tenant; the + # ADDRESS is branded only for DNS-authorized domains and otherwise stays the + # authenticated SMTP identity so the mail still delivers. The previous + # `noreply@{host}` sent from whatever host the browser was on, which is not + # an authorized sender for that domain — the mail server accepted it and it + # was then dropped downstream by SPF/DMARC. See app/utils/mail_utils.py and + # CLAUDE.md rule 64. + from app.utils.mail_utils import branded_sender + sender = branded_sender(effective_base) html_body = render_template_string(""" @@ -612,12 +619,26 @@ def forgot_password(): form = ForgotPasswordForm() if form.validate_on_submit(): - user = User.query.filter_by(email=form.email.data.strip().lower()).first() + # Explicit case-insensitive lookup. This is NOT fixing a live bug: no + # table here declares a COLLATE, so `users.email` inherits the utf8mb4 + # default (utf8mb4_general_ci / utf8mb4_0900_ai_ci), both of which are + # case-insensitive — a bare `== lower(input)` already matched a + # mixed-case stored address. The point is to stop depending on that + # server default: under a binary/_bin collation the bare comparison + # would silently find nothing and still show the success message below. + email_input = form.email.data.strip().lower() + user = User.query.filter( + db.func.lower(User.email) == email_input + ).first() if user and user.active: token = user.generate_set_password_token(expires_hours=1) db.session.commit() _send_password_reset_email(user, token, base_url=request.host_url) - logger.info('AUTH | forgot_password | user=%s | email=%s', user.username, user.email) + logger.info('AUTH | forgot_password | reset link dispatched | user=%s | email=%s', + user.username, user.email) + else: + # No leak to the user (generic message below), but log for diagnosis. + logger.info('AUTH | forgot_password | no active account for email=%s', email_input) # Always show the same message — never reveal whether the email exists flash( 'If an account with that email address exists, a password reset link ' diff --git a/app/routes/customers.py b/app/routes/customers.py index 26bd127..fe804ed 100644 --- a/app/routes/customers.py +++ b/app/routes/customers.py @@ -333,7 +333,7 @@ def edit(customer_id): if form.validate_on_submit(): customer.username = form.username.data customer.full_name = form.full_name.data.strip() or None - customer.email = form.email.data + customer.email = form.email.data.strip().lower() if form.password.data: customer.set_password(form.password.data) db.session.commit()