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 key | Holds |
|---|---|
reset_email | The email the flow is bound to |
reset_method | totp or email |
reset_token | sha256() of the token minted by the TOTP or email step |
email_reset_token | sha256() 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.
# 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"] = emailOn 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
# 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."}), 400A 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.
# 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 FalsePassword 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 !@#$%^&*.
# 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, NoneTOTP 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.
# 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."}), 400The 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 / path | Purpose |
|---|---|
GET /auth/reset-password | Redirects to the React page at /reset-password |
POST /auth/reset-password | Step-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
{ "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
{ "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
{ "step": "totp", "email": "user@example.com", "totp_code": "123456" }Response on success:
{
"status": "success",
"message": "TOTP verified",
"token": "43-character-url-safe-token"
}step: password
{
"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:
{
"status": "success",
"message": "Your password has been reset successfully."
}Reset Flow Implementation
Full Reset Service
# 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_atcolumn onUser. - 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_logsrow per request, carrying the client IP, method, path, status, and duration. - The removal of every
active_sessionsrow for the user, visible on the security dashboard.
Token Security
| Measure | Implementation |
|---|---|
| Token entropy | 256 bits, secrets.token_urlsafe(32), 43 characters |
| Token storage | SHA-256 hash only, held in the signed Flask session, never in a database |
| Comparison | secrets.compare_digest, and a missing session entry never matches |
| Binding | The submitted email must equal session["reset_email"] |
| Expiration | No explicit expiry. The token dies with the Flask session that holds its hash |
| Single use | Not marked used. The session keys are popped after a successful reset |
| Link integrity | The emailed URL is built from HOST_SERVER, so a poisoned Host header cannot redirect it |
| IP logging | Not recorded against the token. Only the generic traffic log captures the IP |
| Throttling | RESET_RATE_LIMIT, code default 15 per hour, keyed on the remote address |
Frontend Components
Forgot Password Form
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.
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
| File | Purpose |
|---|---|
blueprints/auth.py | Reset endpoints and core logic |
database/user_db.py | User model with password hash |
utils/email_utils.py | Password reset email sending |
database/settings_db.py | SMTP settings for email |
frontend/src/pages/ResetPassword.tsx | Request, TOTP, email-link, and password steps |
Note: Password reset logic is implemented directly in
blueprints/auth.py. There are no separatepassword_reset_db.pyorpassword_reset_service.pyfiles, noPasswordResetTokenmodel, and nopassword_reset_tokenstable. Only the SHA-256 hash of the token is stored, in the signed Flask session, underreset_tokenfor the freshly minted token andemail_reset_tokenonce an emailed link has been opened.
