5959 SyncLibraryRequest ,
6060 RegisterRequest ,
6161 LoginRequest ,
62+ ForgotPasswordRequest ,
63+ ResetPasswordRequest ,
6264 SetGoalRequest ,
6365 GetStatsRequest ,
6466 CollectionRequest ,
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+ )
7582from collections import defaultdict , deque
7683from math import ceil
7784from time import time
85+ from urllib .parse import quote
7886from 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 ()
17441821def 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+ )
24962580from collections import defaultdict , deque
24972581from math import ceil
24982582from time import time
2583+ from urllib .parse import quote
24992584from 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
33503435db .init_app (app )
3436+ migrate = Migrate (app , db )
33513437price_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 ()
0 commit comments