Skip to content

48 - Password Reset

Overview

Tradeboard provides a secure multi-step password reset flow that supports both email-based reset tokens and TOTP verification for accounts with 2FA enabled.

Architecture Diagram

┌──────────────────────────────────────────────────────────────────────────────┐
│                        Password Reset Architecture                           │
└──────────────────────────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────────────────────────┐
│                         Step 1: identify the account                         │
│                   POST /auth/reset-password  step="email"                    │
│                                                                              │
│   React /reset-password page posts the email address.                        │
│   find_user_by_email(email)                                                  │
│                                                                              │
│   Match      -> session["reset_email"] = email                               │
│   No match   -> nothing stored                                               │
│                                                                              │
│   Both branches return the same success body, so the response                │
│   never reveals whether the account exists.                                  │
│                                                                              │
└──────────────────────────────────────────────────────────────────────────────┘


┌──────────────────────────────────────────────────────────────────────────────┐
│              Step 2: choose and complete a verification method               │
│                                                                              │
│   step="select_totp"                 step="select_email"                     │
│   ──────────────────────────────────                                         │
│   session["reset_method"]="totp"     Refused with 400 when the account       │
│                                      has password-reset TOTP required,       │
│                                      or when SMTP is not configured.         │
│                                      Otherwise mails a link built from       │
│                                      HOST_SERVER, storing only               │
│                                      sha256(token) in the session.           │
│                                                                              │
│   step="totp"                        GET /auth/reset-password-email/<token>  │
│   ──────────────────────────────────                                         │
│   user.verify_totp(totp_code)        Validates the emailed token, then       │
│   returns a fresh token to the SPA   stores session["email_reset_token"]     │
│                                                                              │
└──────────────────────────────────────────────────────────────────────────────┘


┌──────────────────────────────────────────────────────────────────────────────┐
│                         Step 3: set the new password                         │
│                  POST /auth/reset-password  step="password"                  │
│                                                                              │
│   secrets.compare_digest(sha256(token), session token)                       │
│   and email == session["reset_email"]      failure -> 400                    │
│                                                                              │
│   validate_password_strength(password)     failure -> 400                    │
│     8+ chars, upper, lower, digit, one of !@#$%^&*                           │
│                                                                              │
│   user.set_password(password)   Argon2 with API_KEY_PEPPER                   │
│   clear_user_sessions(username) and force_logout                             │
│   session keys popped: reset_token, email_reset_token,                       │
│   reset_email, reset_method                                                  │
│   SPA redirects to /login                                                    │
└──────────────────────────────────────────────────────────────────────────────┘

Database Schema

There is no password-reset table. No password_reset_tokens table and no PasswordResetToken model exist anywhere in the codebase. Reset state lives entirely in the signed Flask session cookie for the duration of that session:

Session keyHolds
reset_emailThe email the flow is bound to
reset_methodtotp or email
reset_tokensha256() of the token minted by the TOTP or email step
email_reset_tokensha256() of the token carried in the emailed link

The only durable write a reset performs is the new Argon2 hash on users.password_hash and the removal of that user's active_sessions rows. See "Actual Token Handling" below.

Actual Token Handling

Secure Token Creation

Both reset paths mint the same kind of token and keep only its hash in the session. Flask's session is a signed but unencrypted cookie, so storing the raw token there would let the caller who requested the reset read it straight back out of their own cookie without ever seeing the email it was sent to.

python
# blueprints/auth.py
def _hash_reset_token(token: str) -> str:
    return hashlib.sha256(token.encode("utf-8")).hexdigest()

token = secrets.token_urlsafe(32)          # 256 bits of entropy, 43 chars
session["reset_token"] = _hash_reset_token(token)
session["reset_email"] = email

On the TOTP path the raw token is returned in the JSON response, because the caller has already proven possession of the authenticator. On the email path it is embedded in the link built by build_external_url(url_for("auth.reset_password_email", token=token)), which is derived from HOST_SERVER rather than url_for(_external=True) so a poisoned Host header cannot redirect the emailed token to an attacker-controlled origin.

Clicking the link hits GET /auth/reset-password-email/<token>, which checks the length (43 characters), constant-time compares the hash against session["reset_token"], then sets session["email_reset_token"] and redirects to /reset-password?token=...&email=...&verified=true.

Token Validation

python
# blueprints/auth.py, step == "password"
submitted = _hash_reset_token(token) if token else ""
valid_token = any(
    stored and secrets.compare_digest(submitted, stored)
    for stored in (session.get("reset_token"), session.get("email_reset_token"))
)
if not valid_token or email != session.get("reset_email"):
    return jsonify({"status": "error", "message": "Invalid or expired reset token."}), 400

A missing session entry never counts as a match, and the submitted email must equal the one recorded when the token was issued. There is no expires_at column and no used_at column: the token lives exactly as long as the Flask session that holds its hash.

Password Security

Argon2 Hashing with Pepper

Hashing is a method on the User model, and PasswordHasher() is constructed with the library defaults rather than explicit cost parameters.

python
# database/user_db.py
ph = PasswordHasher()
PASSWORD_PEPPER = os.getenv("API_KEY_PEPPER")  # import fails if unset or shorter than 32 chars

class User(Base):
    def set_password(self, password):
        self.password_hash = ph.hash(password + PASSWORD_PEPPER)

    def check_password(self, password):
        try:
            ph.verify(self.password_hash, password + PASSWORD_PEPPER)
            if ph.check_needs_rehash(self.password_hash):
                self.set_password(password)
                db_session.commit()
            return True
        except VerifyMismatchError:
            return False

Password Requirements

utils/auth_utils.validate_password_strength returns on the first failure and yields a single message, not a list. The accepted special characters are exactly !@#$%^&*.

python
# utils/auth_utils.py
def validate_password_strength(password):
    """Returns (is_valid, error_message or None)"""
    if not password:
        return False, "Password is required"
    if len(password) < 8:
        return False, "Password must be at least 8 characters long"
    if not re.search(r"[A-Z]", password):
        return False, "Password must contain at least 1 uppercase letter (A-Z)"
    if not re.search(r"[a-z]", password):
        return False, "Password must contain at least 1 lowercase letter (a-z)"
    if not re.search(r"[0-9]", password):
        return False, "Password must contain at least 1 number (0-9)"
    if not re.search(r"[!@#$%^&*]", password):
        return False, "Password must contain at least 1 special character (!@#$%^&*)"
    return True, None

TOTP Integration

Reset with 2FA

TOTP is not an extra check bolted onto one shared path. It is one of two mutually exclusive methods the user picks at step == "select_totp" or step == "select_email", and each mints its own token.

python
# blueprints/auth.py, step == "totp"
user = find_user_by_email(email)
if user and user.verify_totp(totp_code):
    token = secrets.token_urlsafe(32)
    session["reset_token"] = _hash_reset_token(token)
    session["reset_email"] = email
    return jsonify({"status": "success", "message": "TOTP verified", "token": token})
return jsonify({"status": "error", "message": "Invalid TOTP code. Please try again."}), 400

The per-user flag totp_required_for_password_reset works as a gate on the other direction: at step == "select_email", an account with user.is_totp_required_for("password_reset") is refused the email path with a 400 and told to use the authenticator instead. The check runs only for a known email, so an unknown address still falls through to the generic "sent if the account exists" response and account existence is not leaked.

User.verify_totp() decrypts the at-rest secret through get_totp_secret(). There is no separate verify_totp(secret, code) helper.

API Endpoints

There are no /api/auth/... reset endpoints. The whole flow is one step-dispatched route on auth_bp (url_prefix="/auth"), plus the email-link handler.

Method / pathPurpose
GET /auth/reset-passwordRedirects to the React page at /reset-password
POST /auth/reset-passwordStep-dispatched reset flow, JSON or form encoded
GET /auth/reset-password-email/<token>Validates an emailed token and redirects to the React password step

POST /auth/reset-password is rate limited by RESET_RATE_LIMIT (env var, code default 15 per hour) and requires a CSRF token. The step field selects the behaviour.

step: email

json
{ "step": "email", "email": "user@example.com" }

Stores reset_email in the session when the account exists, and returns the same {"status": "success", "message": "Email verified"} either way so account existence is not disclosed.

step: select_totp / select_email

json
{ "step": "select_email", "email": "user@example.com" }

select_totp records the method and returns immediately. select_email refuses accounts that require TOTP for reset, refuses when no SMTP server is configured, and otherwise mints a token, stores its hash, and sends the reset link.

step: totp

json
{ "step": "totp", "email": "user@example.com", "totp_code": "123456" }

Response on success:

json
{
    "status": "success",
    "message": "TOTP verified",
    "token": "43-character-url-safe-token"
}

step: password

json
{
    "step": "password",
    "email": "user@example.com",
    "token": "43-character-url-safe-token",
    "password": "NewSecurePass123!"
}

The field is password, not new_password, and there is no confirm_password: the React page checks the confirmation client side.

Response:

json
{
    "status": "success",
    "message": "Your password has been reset successfully."
}

Reset Flow Implementation

Full Reset Service

python
# blueprints/auth.py, step == "email"
user = find_user_by_email(email)          # exact match on User.email, no .lower()
if user:
    session["reset_email"] = email
return jsonify({"status": "success", "message": "Email verified"})

# blueprints/auth.py, step == "password", after the token check above
is_valid, error_message = validate_password_strength(password)
if not is_valid:
    return jsonify({"status": "error", "message": error_message}), 400

user = find_user_by_email(email)
if user:
    user.set_password(password)
    db_session.commit()

    # A reset means no other active session for this account can be trusted.
    clear_user_sessions(user.username)
    socketio.emit("force_logout", {
        "message": "Your password was reset. Please log in again with the new password.",
    })

    session.pop("reset_token", None)
    session.pop("reset_email", None)
    session.pop("reset_method", None)
    session.pop("email_reset_token", None)

    return jsonify(
        {"status": "success", "message": "Your password has been reset successfully."}
    )

Behaviour the running code does not implement:

  • No per-email or per-user token counting. The only throttle is the route-level RESET_RATE_LIMIT.
  • No "must differ from the current password" check.
  • No confirmation email after a successful reset.
  • No updated_at column on User.
  • Email lookup is an exact match, so the address must be entered with the same case it was registered with.

Security Measures

Rate Limiting

┌─────────────────────────────────────────────────────────────────────────────┐
│                        Rate Limiting, as implemented                        │
│                                                                             │
│  @limiter.limit(RESET_RATE_LIMIT) on POST /auth/reset-password              │
│  RESET_RATE_LIMIT = os.getenv("RESET_RATE_LIMIT", "15 per hour")            │
│                                                                             │
│  Keyed by remote address through flask_limiter get_remote_address,          │
│  counted in process memory, moving window.                                  │
│                                                                             │
│  There is no per-email counter and no global counter.                       │
└─────────────────────────────────────────────────────────────────────────────┘

The limit covers every step of the flow, because all five steps post to the same route. RESET_RATE_LIMIT is not in the list utils/env_check.py validates, so a malformed value is not caught at startup.

Audit Logging

There is no AuditLog model and password-reset steps are not written to any audit table. The login_attempts table exists and is populated by log_login_attempt(), but only from the login and TOTP-login paths, not from the reset flow. What a reset leaves behind is:

  • Application log lines, for example Password reset email sent to {email} and any send failure.
  • A traffic_logs row per request, carrying the client IP, method, path, status, and duration.
  • The removal of every active_sessions row for the user, visible on the security dashboard.

Token Security

MeasureImplementation
Token entropy256 bits, secrets.token_urlsafe(32), 43 characters
Token storageSHA-256 hash only, held in the signed Flask session, never in a database
Comparisonsecrets.compare_digest, and a missing session entry never matches
BindingThe submitted email must equal session["reset_email"]
ExpirationNo explicit expiry. The token dies with the Flask session that holds its hash
Single useNot marked used. The session keys are popped after a successful reset
Link integrityThe emailed URL is built from HOST_SERVER, so a poisoned Host header cannot redirect it
IP loggingNot recorded against the token. Only the generic traffic log captures the IP
ThrottlingRESET_RATE_LIMIT, code default 15 per hour, keyed on the remote address

Frontend Components

Forgot Password Form

typescript
function ForgotPasswordForm() {
  const [email, setEmail] = useState('');
  const [submitted, setSubmitted] = useState(false);

  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault();
    const csrfToken = await fetchCSRFToken();
    await fetch('/auth/reset-password', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
      body: JSON.stringify({ step: 'email', email }),
    });
    setSubmitted(true);
  };

  if (submitted) {
    return (
      <div className="text-center">
        <h2>Check Your Email</h2>
        <p>If an account exists with {email}, you'll receive a reset link.</p>
      </div>
    );
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Enter your email"
        required
      />
      <button type="submit">Send Reset Link</button>
    </form>
  );
}

Reset Password Form

The real page is a single component, frontend/src/pages/ResetPassword.tsx, driving a local Step state machine of 'email' | 'method' | 'totp' | 'email_sent' | 'password'. Arriving from an email link with ?token=...&email=...&verified=true jumps straight to the password step.

typescript
function ResetPasswordForm({ token, email }: { token: string; email: string }) {
  const [password, setPassword] = useState('');
  const [confirmPassword, setConfirmPassword] = useState('');

  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault();

    // The confirmation match is checked client side only
    if (password !== confirmPassword) {
      toast.error('Passwords do not match');
      return;
    }

    const csrfToken = await fetchCSRFToken();
    await fetch('/auth/reset-password', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
      body: JSON.stringify({ step: 'password', email, token, password }),
    });

    toast.success('Password reset successful');
    navigate('/login');
  };

  return (
    <form onSubmit={handleSubmit}>
      <PasswordInput
        value={password}
        onChange={setPassword}
        showRequirements
      />
      <PasswordInput
        value={confirmPassword}
        onChange={setConfirmPassword}
        label="Confirm Password"
      />
      <button type="submit">Reset Password</button>
    </form>
  );
}

Key Files Reference

FilePurpose
blueprints/auth.pyReset endpoints and core logic
database/user_db.pyUser model with password hash
utils/email_utils.pyPassword reset email sending
database/settings_db.pySMTP settings for email
frontend/src/pages/ResetPassword.tsxRequest, TOTP, email-link, and password steps

Note: Password reset logic is implemented directly in blueprints/auth.py. There are no separate password_reset_db.py or password_reset_service.py files, no PasswordResetToken model, and no password_reset_tokens table. Only the SHA-256 hash of the token is stored, in the signed Flask session, under reset_token for the freshly minted token and email_reset_token once an emailed link has been opened.