Skip to content

Commit 8925ca6

Browse files
Merge pull request #761 from Nimra (Add password reset)
2 parents 9431cdf + 0cd327f commit 8925ca6

10 files changed

Lines changed: 877 additions & 0 deletions

File tree

‎backend/app.py‎

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@
5959
SyncLibraryRequest,
6060
RegisterRequest,
6161
LoginRequest,
62+
ForgotPasswordRequest,
63+
ResetPasswordRequest,
6264
SetGoalRequest,
6365
GetStatsRequest,
6466
CollectionRequest,
@@ -72,9 +74,15 @@
7274
validate_jwt_secret,
7375
is_production_mode
7476
)
77+
from password_reset_service import (
78+
FORGOT_PASSWORD_MESSAGE,
79+
request_password_reset,
80+
reset_password_with_token,
81+
)
7582
from collections import defaultdict, deque
7683
from math import ceil
7784
from time import time
85+
from urllib.parse import quote
7886
from error_responses import (
7987
ErrorCodes, error_response, success_response,
8088
validation_error, missing_fields_error, invalid_json_error,
@@ -1739,6 +1747,75 @@ def logout():
17391747
return resp, status
17401748

17411749

1750+
@app.route('/api/v1/auth/forgot-password', methods=['POST'])
1751+
@csrf.exempt
1752+
@limiter.limit("5 per minute")
1753+
def forgot_password():
1754+
"""Request a password reset link (always returns a generic success message)."""
1755+
try:
1756+
data = request.get_json(silent=True)
1757+
if data is None:
1758+
return invalid_json_error()
1759+
1760+
is_valid, validated_data = validate_request(ForgotPasswordRequest, data)
1761+
if not is_valid:
1762+
return jsonify(validated_data), 400
1763+
1764+
plain_token = None
1765+
try:
1766+
plain_token = request_password_reset(validated_data.email)
1767+
except SQLAlchemyError as e:
1768+
logger.error("forgot-password database error: %s", e, exc_info=True)
1769+
1770+
response_data = {"message": FORGOT_PASSWORD_MESSAGE}
1771+
1772+
if plain_token and app_config.is_development():
1773+
frontend_base = os.getenv(
1774+
'FRONTEND_ORIGIN',
1775+
'http://127.0.0.1:5500',
1776+
).rstrip('/')
1777+
response_data["reset_url"] = (
1778+
f"{frontend_base}/pages/auth.html?token={quote(plain_token)}"
1779+
)
1780+
logger.info(
1781+
"Dev password reset link for %s: %s",
1782+
validated_data.email,
1783+
response_data["reset_url"],
1784+
)
1785+
1786+
return success_response(data=response_data)
1787+
except Exception as e:
1788+
logger.error("forgot-password failed: %s", e, exc_info=True)
1789+
return success_response(data={"message": FORGOT_PASSWORD_MESSAGE})
1790+
1791+
1792+
@app.route('/api/v1/auth/reset-password', methods=['POST'])
1793+
@csrf.exempt
1794+
@limiter.limit("5 per minute")
1795+
def reset_password():
1796+
"""Set a new password using a valid reset token."""
1797+
try:
1798+
data = request.get_json(silent=True)
1799+
if data is None:
1800+
return invalid_json_error()
1801+
1802+
is_valid, validated_data = validate_request(ResetPasswordRequest, data)
1803+
if not is_valid:
1804+
return jsonify(validated_data), 400
1805+
1806+
ok, message = reset_password_with_token(
1807+
validated_data.token,
1808+
validated_data.password,
1809+
)
1810+
if not ok:
1811+
return jsonify({"error": message}), 400
1812+
1813+
return success_response(data={"message": message})
1814+
except Exception as e:
1815+
logger.error("reset-password failed: %s", e, exc_info=True)
1816+
return internal_error("Unable to reset password.")
1817+
1818+
17421819
@app.route('/api/v1/auth/verify', methods=['GET'])
17431820
@jwt_required()
17441821
def verify_auth_session():
@@ -2480,6 +2557,8 @@ def delete_price_alert(alert_id):
24802557
SyncLibraryRequest,
24812558
RegisterRequest,
24822559
LoginRequest,
2560+
ForgotPasswordRequest,
2561+
ResetPasswordRequest,
24832562
SetGoalRequest,
24842563
GetStatsRequest,
24852564
CollectionRequest,
@@ -2493,9 +2572,15 @@ def delete_price_alert(alert_id):
24932572
validate_jwt_secret,
24942573
is_production_mode
24952574
)
2575+
from password_reset_service import (
2576+
FORGOT_PASSWORD_MESSAGE,
2577+
request_password_reset,
2578+
reset_password_with_token,
2579+
)
24962580
from collections import defaultdict, deque
24972581
from math import ceil
24982582
from time import time
2583+
from urllib.parse import quote
24992584
from error_responses import (
25002585
ErrorCodes, error_response, success_response,
25012586
validation_error, missing_fields_error, invalid_json_error,
@@ -3348,6 +3433,7 @@ def remove_from_library(item_id):
33483433

33493434

33503435
db.init_app(app)
3436+
migrate = Migrate(app, db)
33513437
price_tracker = get_price_tracker(db)
33523438

33533439

@@ -3728,6 +3814,68 @@ def logout():
37283814
return resp, status
37293815

37303816

3817+
@app.route('/api/v1/auth/forgot-password', methods=['POST'])
3818+
@limiter.limit("5 per minute")
3819+
def forgot_password():
3820+
"""Request a password reset link (always returns a generic success message)."""
3821+
try:
3822+
data = request.get_json(silent=True)
3823+
if data is None:
3824+
return invalid_json_error()
3825+
3826+
is_valid, validated_data = validate_request(ForgotPasswordRequest, data)
3827+
if not is_valid:
3828+
return jsonify(validated_data), 400
3829+
3830+
plain_token = request_password_reset(validated_data.email)
3831+
response_data = {"message": FORGOT_PASSWORD_MESSAGE}
3832+
3833+
if plain_token and app_config.is_development():
3834+
frontend_base = os.getenv(
3835+
'FRONTEND_ORIGIN',
3836+
'http://127.0.0.1:5500',
3837+
).rstrip('/')
3838+
response_data["reset_url"] = (
3839+
f"{frontend_base}/pages/auth.html?token={quote(plain_token)}"
3840+
)
3841+
logger.info(
3842+
"Dev password reset link for %s: %s",
3843+
validated_data.email,
3844+
response_data["reset_url"],
3845+
)
3846+
3847+
return success_response(data=response_data)
3848+
except Exception as e:
3849+
logger.error("forgot-password failed: %s", e, exc_info=True)
3850+
return internal_error("Unable to process password reset request.")
3851+
3852+
3853+
@app.route('/api/v1/auth/reset-password', methods=['POST'])
3854+
@limiter.limit("5 per minute")
3855+
def reset_password():
3856+
"""Set a new password using a valid reset token."""
3857+
try:
3858+
data = request.get_json(silent=True)
3859+
if data is None:
3860+
return invalid_json_error()
3861+
3862+
is_valid, validated_data = validate_request(ResetPasswordRequest, data)
3863+
if not is_valid:
3864+
return jsonify(validated_data), 400
3865+
3866+
ok, message = reset_password_with_token(
3867+
validated_data.token,
3868+
validated_data.password,
3869+
)
3870+
if not ok:
3871+
return jsonify({"error": message}), 400
3872+
3873+
return success_response(data={"message": message})
3874+
except Exception as e:
3875+
logger.error("reset-password failed: %s", e, exc_info=True)
3876+
return internal_error("Unable to reset password.")
3877+
3878+
37313879
# ==================== READING STATS ENDPOINTS ====================
37323880
@app.route('/api/v1/stats/goal', methods=['POST'])
37333881
@jwt_required()

‎backend/config.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,9 +165,16 @@ def from_env(cls) -> 'GoogleOAuthConfig':
165165
client_secret=os.getenv('GOOGLE_CLIENT_SECRET') or os.getenv('GOOGLE_OAUTH_CLIENT_SECRET'),
166166
redirect_uri=os.getenv('GOOGLE_OAUTH_REDIRECT_URI'),
167167
frontend_redirect_url=os.getenv('FRONTEND_URL', 'http://127.0.0.1:5500/frontend/pages/library.html'),
168+
<<<<<<< HEAD
168169
scope=os.getenv('GOOGLE_OAUTH_SCOPE', 'openid email profile')
169170
)
170171

172+
=======
173+
scope=os.getenv('GOOGLE_OAUTH_SCOPE', 'openid email profile'),
174+
)
175+
176+
177+
>>>>>>> pr-761
171178
@dataclass
172179
class EmailConfig:
173180
"""Email service configuration (e.g., SendGrid, Mailgun)."""
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""Add password_reset_token table.
2+
3+
Revision ID: 0004
4+
Revises: 0003
5+
Create Date: 2026-05-18 12:00:00.000000
6+
"""
7+
8+
from alembic import op
9+
import sqlalchemy as sa
10+
11+
12+
revision = '0004'
13+
down_revision = '0003'
14+
branch_labels = None
15+
depends_on = None
16+
17+
18+
def upgrade() -> None:
19+
op.create_table(
20+
'password_reset_token',
21+
sa.Column('id', sa.Integer(), nullable=False),
22+
sa.Column('user_id', sa.Integer(), nullable=False),
23+
sa.Column('token_hash', sa.String(length=64), nullable=False),
24+
sa.Column('expires_at', sa.DateTime(), nullable=False),
25+
sa.Column('used_at', sa.DateTime(), nullable=True),
26+
sa.Column('created_at', sa.DateTime(), nullable=True),
27+
sa.ForeignKeyConstraint(['user_id'], ['user.id']),
28+
sa.PrimaryKeyConstraint('id'),
29+
sa.UniqueConstraint('token_hash'),
30+
)
31+
op.create_index('ix_password_reset_token_user_id', 'password_reset_token', ['user_id'], unique=False)
32+
op.create_index('ix_password_reset_token_token_hash', 'password_reset_token', ['token_hash'], unique=False)
33+
34+
35+
def downgrade() -> None:
36+
op.drop_index('ix_password_reset_token_token_hash', table_name='password_reset_token')
37+
op.drop_index('ix_password_reset_token_user_id', table_name='password_reset_token')
38+
op.drop_table('password_reset_token')

‎backend/models.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,19 @@ def to_dict(self):
131131
"is_deleted": self.is_deleted
132132
}
133133

134+
135+
class PasswordResetToken(db.Model):
136+
"""Single-use token for password reset (stores SHA-256 hash only)."""
137+
id = db.Column(db.Integer, primary_key=True)
138+
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False, index=True)
139+
token_hash = db.Column(db.String(64), nullable=False, unique=True, index=True)
140+
expires_at = db.Column(db.DateTime, nullable=False)
141+
used_at = db.Column(db.DateTime, nullable=True)
142+
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
143+
144+
user = db.relationship('User', backref=db.backref('password_reset_tokens', lazy='dynamic'))
145+
146+
134147
class Book(db.Model, SoftDeleteMixin):
135148
query_class = SoftDeleteQuery
136149
id = db.Column(db.Integer, primary_key=True)

‎backend/password_reset_service.py‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Password reset token issuance and consumption."""
2+
import hashlib
3+
import logging
4+
import secrets
5+
from datetime import datetime, timedelta, timezone
6+
from typing import Optional
7+
8+
from sqlalchemy.exc import SQLAlchemyError
9+
10+
from sqlalchemy import func
11+
12+
try:
13+
from .models import PasswordResetToken, User, db
14+
except ImportError:
15+
from models import PasswordResetToken, User, db
16+
17+
logger = logging.getLogger(__name__)
18+
19+
RESET_TOKEN_BYTES = 32
20+
RESET_TOKEN_TTL = timedelta(hours=1)
21+
22+
FORGOT_PASSWORD_MESSAGE = (
23+
"If an account exists for that email, password reset instructions have been sent."
24+
)
25+
26+
27+
def _hash_token(token: str) -> str:
28+
return hashlib.sha256(token.encode('utf-8')).hexdigest()
29+
30+
31+
def _utcnow() -> datetime:
32+
return datetime.now(timezone.utc)
33+
34+
35+
def _invalidate_active_tokens(user_id: int) -> None:
36+
now = _utcnow()
37+
(
38+
PasswordResetToken.query.filter_by(user_id=user_id)
39+
.filter(PasswordResetToken.used_at.is_(None))
40+
.update({'used_at': now}, synchronize_session=False)
41+
)
42+
43+
44+
def create_password_reset_token(user: User) -> str:
45+
"""Create a new reset token for the user; returns the plaintext token."""
46+
plain_token = secrets.token_urlsafe(RESET_TOKEN_BYTES)
47+
record = PasswordResetToken(
48+
user_id=user.id,
49+
token_hash=_hash_token(plain_token),
50+
expires_at=_utcnow() + RESET_TOKEN_TTL,
51+
)
52+
_invalidate_active_tokens(user.id)
53+
db.session.add(record)
54+
db.session.commit()
55+
return plain_token
56+
57+
58+
def reset_password_with_token(token: str, new_password: str) -> tuple[bool, str]:
59+
"""Validate token and set a new password. Returns (success, message)."""
60+
if not token or not token.strip():
61+
return False, "Invalid or expired reset link."
62+
63+
token_hash = _hash_token(token.strip())
64+
record = PasswordResetToken.query.filter_by(token_hash=token_hash).first()
65+
now = _utcnow()
66+
67+
if not record or record.used_at is not None:
68+
return False, "Invalid or expired reset link."
69+
70+
expires = record.expires_at
71+
if expires.tzinfo is None:
72+
expires = expires.replace(tzinfo=timezone.utc)
73+
74+
if expires < now:
75+
return False, "Invalid or expired reset link."
76+
77+
user = User.query.get(record.user_id)
78+
if not user:
79+
return False, "Invalid or expired reset link."
80+
81+
try:
82+
user.set_password(new_password)
83+
record.used_at = now
84+
db.session.commit()
85+
return True, "Password updated successfully. You can sign in with your new password."
86+
except SQLAlchemyError as exc:
87+
db.session.rollback()
88+
logger.error("Password reset failed for user %s: %s", user.id, exc)
89+
return False, "Unable to reset password. Please try again later."
90+
91+
92+
def request_password_reset(email: str) -> Optional[str]:
93+
"""
94+
Start reset flow for a local account email.
95+
Returns plaintext token when a matching user exists, else None.
96+
"""
97+
normalized = email.strip().lower()
98+
user = User.query.filter(func.lower(User.email) == normalized).first()
99+
if not user:
100+
return None
101+
return create_password_reset_token(user)

0 commit comments

Comments
 (0)