package accountapiimp import ( "context" "encoding/json" "errors" "net/http" "strings" "github.com/tech/sendico/pkg/api/http/response" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/mutil/mzap" "github.com/tech/sendico/server/interface/api/srequest" "github.com/tech/sendico/server/interface/api/sresponse" mutil "github.com/tech/sendico/server/internal/mutil/param" "go.uber.org/zap" ) func (a *AccountAPI) checkPassword(_ *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc { return sresponse.Account(a.logger, account, accessToken) } func (a *AccountAPI) changePassword(r *http.Request, user *model.Account, token *sresponse.TokenData) http.HandlerFunc { // TODO: add rate check var pcr srequest.ChangePassword if err := json.NewDecoder(r.Body).Decode(&pcr); err != nil { a.logger.Warn("Failed to decode password change request", zap.Error(err), mzap.StorableRef(user)) return response.BadPayload(a.logger, a.Name(), err) } if err := a.accService.ValidatePassword(pcr.New, &pcr.Old); err != nil { a.logger.Debug("Password validation failed", zap.Error(err), mzap.StorableRef(user)) return sresponse.BadRPassword(a.logger, a.Name(), err) } ctx := r.Context() if !user.MatchPassword(pcr.Old) { a.logger.Info("Old password does not match", mzap.StorableRef(user)) return a.reportUnauthorized("old password does not match") } user.Password = pcr.New if err := user.HashPassword(); err != nil { a.logger.Warn("Failed to hash new password", zap.Error(err), mzap.StorableRef(user)) return response.Internal(a.logger, a.Name(), err) } if err := a.db.Update(ctx, user); err != nil { a.logger.Warn("Failed to save account", zap.Error(err), mzap.StorableRef(user)) return response.Internal(a.logger, a.Name(), err) } if err := a.rtdb.RevokeAll(ctx, *user.GetID(), pcr.DeviceID); err != nil { a.logger.Warn("Failed to revoke refresh tokens", zap.Error(err), mzap.StorableRef(user), zap.String("device_id", pcr.DeviceID)) } return sresponse.Account(a.logger, user, token) } func (a *AccountAPI) forgotPassword(r *http.Request) http.HandlerFunc { var req srequest.ForgotPassword if err := json.NewDecoder(r.Body).Decode(&req); err != nil { a.logger.Warn("Failed to decode password change request", zap.Error(err)) return response.BadPayload(a.logger, a.Name(), err) } if req.Login == "" { a.logger.Debug("Email is missing in the request") return a.reportEmailMissing() } // Always use the lower case email address req.Login = strings.ToLower(req.Login) // Get user ctx := r.Context() user, err := a.db.GetByEmail(ctx, req.Login) if (errors.Is(err, merrors.ErrNoData)) || (user == nil) { a.logger.Debug("User not found while recovering password", zap.Error(err), zap.String("email", req.Login)) return a.reportNoEmailRegistered() } if err != nil { a.logger.Warn("Failed to fetch user", zap.Error(err), zap.String("email", req.Login)) return response.Auto(a.logger, a.Name(), err) } // Generate reset password token verificationToken, err := a.accService.ResetPassword(ctx, user) if err != nil { a.logger.Warn("Failed to generate reset password token", zap.Error(err), mzap.StorableRef(user)) return response.Auto(a.logger, a.Name(), err) } // Send reset password email if err = a.sendPasswordResetEmail(user, verificationToken); err != nil { a.logger.Warn("Failed to send reset password email", zap.Error(err), mzap.StorableRef(user)) return response.Auto(a.logger, a.Name(), err) } a.logger.Info("Reset password email sent successfully", zap.String("email", user.Login)) return response.Success(a.logger) } func (a *AccountAPI) resetPassword(r *http.Request) http.HandlerFunc { ctx := r.Context() // Get account reference and token from URL parameters using parameter helpers accountRef, err := a.aph.GetRef(r) if err != nil { a.logger.Warn("Failed to get account reference", zap.Error(err), mutil.PLog(a.aph, r)) return response.BadReference(a.logger, a.Name(), a.aph.Name(), a.aph.GetID(r), err) } token := a.tph.GetID(r) if token == "" { a.logger.Warn("Missing token in reset password request") return response.BadRequest(a.logger, a.Name(), "missing_parameters", "token is required") } // Get user from database var user model.Account err = a.db.Get(ctx, accountRef, &user) if errors.Is(err, merrors.ErrNoData) { a.logger.Info("User not found for password reset", mzap.ObjRef("account_ref", accountRef)) return response.NotFound(a.logger, a.Name(), "User not found") } if err != nil { a.logger.Warn("Failed to get user for password reset", zap.Error(err), mzap.ObjRef("account_ref", accountRef)) return response.Auto(a.logger, a.Name(), err) } t, err := a.vdb.Consume(ctx, accountRef, model.PurposePasswordReset, token) if err != nil { a.logger.Warn("Failed to consume password reset token", zap.Error(err), zap.String("token", token)) return a.mapTokenErrorToResponse(err) } if t.Purpose != model.PurposePasswordReset { a.logger.Warn("Invalid token purpose for password reset", zap.String("expected", string(model.PurposePasswordReset)), zap.String("actual", string(t.Purpose))) return response.DataConflict(a.logger, a.Name(), "Invalid token purpose") } if t.AccountRef != accountRef { a.logger.Warn("Token account reference does not match request account reference", mzap.ObjRef("token_account_ref", t.AccountRef), mzap.ObjRef("request_account_ref", accountRef)) return response.DataConflict(a.logger, a.Name(), "Token does not match account") } // Parse new password from request body var req srequest.ResetPassword if err := json.NewDecoder(r.Body).Decode(&req); err != nil { a.logger.Warn("Failed to decode reset password request", zap.Error(err)) return response.BadPayload(a.logger, a.Name(), err) } // Validate new password if err := a.accService.ValidatePassword(req.Password, nil); err != nil { a.logger.Debug("Password validation failed", zap.Error(err), mzap.StorableRef(&user)) return sresponse.BadRPassword(a.logger, a.Name(), err) } // Execute password reset in transaction to ensure atomicity if _, err := a.tf.CreateTransaction().Execute(ctx, func(ctx context.Context) (any, error) { return a.resetPasswordTransactionBody(ctx, &user, req.Password) }); err != nil { a.logger.Warn("Failed to execute password reset transaction", zap.Error(err), mzap.StorableRef(&user)) return response.Auto(a.logger, a.Name(), err) } a.logger.Info("Password reset successful", mzap.StorableRef(&user)) return response.Success(a.logger) } // resetPasswordTransactionBody contains the transaction logic for password reset func (a *AccountAPI) resetPasswordTransactionBody(ctx context.Context, user *model.Account, newPassword string) (any, error) { // Update user with new password and clear reset token user.Password = newPassword // Hash the new password if err := user.HashPassword(); err != nil { a.logger.Warn("Failed to hash new password", zap.Error(err), mzap.StorableRef(user)) return nil, err } // Save the updated user if err := a.db.Update(ctx, user); err != nil { a.logger.Warn("Failed to save user with new password", zap.Error(err), mzap.StorableRef(user)) return nil, err } // Revoke all refresh tokens for this user (force re-login) if err := a.rtdb.RevokeAll(ctx, user.ID, ""); err != nil { a.logger.Warn("Failed to revoke refresh tokens after password reset", zap.Error(err), mzap.StorableRef(user)) // Don't fail the transaction if token revocation fails, but log it } return nil, nil }