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 ():
@@ -2491,6 +2568,8 @@ def get_books():
24912568 SyncLibraryRequest ,
24922569 RegisterRequest ,
24932570 LoginRequest ,
2571+ ForgotPasswordRequest ,
2572+ ResetPasswordRequest ,
24942573 SetGoalRequest ,
24952574 GetStatsRequest ,
24962575 CollectionRequest ,
@@ -2504,9 +2583,15 @@ def get_books():
25042583 validate_jwt_secret ,
25052584 is_production_mode
25062585)
2586+ from password_reset_service import (
2587+ FORGOT_PASSWORD_MESSAGE ,
2588+ request_password_reset ,
2589+ reset_password_with_token ,
2590+ )
25072591from collections import defaultdict , deque
25082592from math import ceil
25092593from time import time
2594+ from urllib .parse import quote
25102595from error_responses import (
25112596 ErrorCodes , error_response , success_response ,
25122597 validation_error , missing_fields_error , invalid_json_error ,
@@ -3359,6 +3444,7 @@ def remove_from_library(item_id):
33593444
33603445
33613446db .init_app (app )
3447+ migrate = Migrate (app , db )
33623448price_tracker = get_price_tracker (db )
33633449
33643450
@@ -3739,6 +3825,68 @@ def logout():
37393825 return resp , status
37403826
37413827
3828+ @app .route ('/api/v1/auth/forgot-password' , methods = ['POST' ])
3829+ @limiter .limit ("5 per minute" )
3830+ def forgot_password ():
3831+ """Request a password reset link (always returns a generic success message)."""
3832+ try :
3833+ data = request .get_json (silent = True )
3834+ if data is None :
3835+ return invalid_json_error ()
3836+
3837+ is_valid , validated_data = validate_request (ForgotPasswordRequest , data )
3838+ if not is_valid :
3839+ return jsonify (validated_data ), 400
3840+
3841+ plain_token = request_password_reset (validated_data .email )
3842+ response_data = {"message" : FORGOT_PASSWORD_MESSAGE }
3843+
3844+ if plain_token and app_config .is_development ():
3845+ frontend_base = os .getenv (
3846+ 'FRONTEND_ORIGIN' ,
3847+ 'http://127.0.0.1:5500' ,
3848+ ).rstrip ('/' )
3849+ response_data ["reset_url" ] = (
3850+ f"{ frontend_base } /pages/auth.html?token={ quote (plain_token )} "
3851+ )
3852+ logger .info (
3853+ "Dev password reset link for %s: %s" ,
3854+ validated_data .email ,
3855+ response_data ["reset_url" ],
3856+ )
3857+
3858+ return success_response (data = response_data )
3859+ except Exception as e :
3860+ logger .error ("forgot-password failed: %s" , e , exc_info = True )
3861+ return internal_error ("Unable to process password reset request." )
3862+
3863+
3864+ @app .route ('/api/v1/auth/reset-password' , methods = ['POST' ])
3865+ @limiter .limit ("5 per minute" )
3866+ def reset_password ():
3867+ """Set a new password using a valid reset token."""
3868+ try :
3869+ data = request .get_json (silent = True )
3870+ if data is None :
3871+ return invalid_json_error ()
3872+
3873+ is_valid , validated_data = validate_request (ResetPasswordRequest , data )
3874+ if not is_valid :
3875+ return jsonify (validated_data ), 400
3876+
3877+ ok , message = reset_password_with_token (
3878+ validated_data .token ,
3879+ validated_data .password ,
3880+ )
3881+ if not ok :
3882+ return jsonify ({"error" : message }), 400
3883+
3884+ return success_response (data = {"message" : message })
3885+ except Exception as e :
3886+ logger .error ("reset-password failed: %s" , e , exc_info = True )
3887+ return internal_error ("Unable to reset password." )
3888+
3889+
37423890# ==================== READING STATS ENDPOINTS ====================
37433891@app .route ('/api/v1/stats/goal' , methods = ['POST' ])
37443892@jwt_required ()
0 commit comments