package api import ( "encoding/json" "errors" "net/http" "github.com/jackc/pgx/v5" "golang.org/x/crypto/bcrypt" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" "git.fabledsword.com/bvandeusen/minstrel/internal/audit" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) type resetPasswordReq struct { Token string `json:"token"` NewPassword string `json:"new_password"` } // handleResetPassword implements POST /api/auth/reset-password. // // Atomically claims the reset token and writes the new password hash. // Returns: // - 204 on successful reset // - 400 password_too_short if new_password < 8 chars // - 400 invalid_token if the token doesn't exist, was already used, // or has expired // - 500 on internal errors func (h *handlers) handleResetPassword(w http.ResponseWriter, r *http.Request) { var req resetPasswordReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, apierror.BadRequest("invalid_body", "")) return } if len(req.NewPassword) < minPasswordLength { writeErr(w, apierror.BadRequest("password_too_short", "password must be at least 8 chars")) return } if req.Token == "" { writeErr(w, apierror.BadRequest("invalid_token", "")) return } q := dbq.New(h.pool) // Look up the reset record so we know which user to update before // we claim the token. We can't reverse-lookup user_id after // UsePasswordReset (it returns rows-affected, not the row). reset, err := q.GetPasswordReset(r.Context(), req.Token) if err != nil { if errors.Is(err, pgx.ErrNoRows) { writeErr(w, apierror.BadRequest("invalid_token", "")) return } h.logger.Error("reset password: lookup failed", "err", err) writeErr(w, apierror.Internal(err)) return } // Atomically claim the token. Returns rows-affected (1 if // claimable, 0 if already used / expired). We do this BEFORE // hashing the password so concurrent reset attempts can't both // succeed. rows, err := q.UsePasswordReset(r.Context(), req.Token) if err != nil { h.logger.Error("reset password: use token failed", "err", err) writeErr(w, apierror.Internal(err)) return } if rows == 0 { writeErr(w, apierror.BadRequest("invalid_token", "")) return } // Hash and update. If hashing or update fails after the token // claim, the token is "burned" but the password isn't reset — // user has to request another. That's acceptable; bcrypt and // UPDATE rarely fail in healthy systems. hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) if err != nil { h.logger.Error("reset password: hash failed", "err", err) writeErr(w, apierror.Internal(err)) return } if err := q.ChangeUserPassword(r.Context(), dbq.ChangeUserPasswordParams{ ID: reset.UserID, PasswordHash: string(hash), }); err != nil { h.logger.Error("reset password: update failed", "err", err) writeErr(w, apierror.Internal(err)) return } if err := audit.Write(r.Context(), h.pool, reset.UserID, reset.UserID, audit.ActionPasswordResetByEmail, nil); err != nil { h.logger.Warn("reset password: audit failed", "err", err) } w.WriteHeader(http.StatusNoContent) }