Files
sendico/api/edge/bff/internal/server/accountapiimp/password.go
2026-03-03 01:07:35 +01:00

197 lines
7.4 KiB
Go

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
}