cbe838cbe3
POST /api/auth/forgot-password and POST /api/auth/reset-password. Forgot-password ALWAYS returns 200 with empty JSON to prevent enumeration of registered emails. Side effect: when email matches a user with email-on-file, generates a 32-byte hex token (24h TTL), inserts into password_resets, and sends the reset email via the mailer. Mailer failures are logged (not surfaced) and the audit log carries metadata.email_match for operator visibility. Reset-password atomically claims the token via UsePasswordReset (:execrows; concurrent calls can't both succeed). On rows=1, hashes the new password and writes via ChangeUserPassword. Returns 204 on success, 400 invalid_token on stale/used/missing tokens, 400 password_too_short for short passwords. Audits ActionPasswordResetByEmail. Wires the mailer.Sender into the handlers struct via Mount; production sender (NewSMTPSender) constructed in server.Router(); tests inject FakeSender via testHandlers default. The reset URL embedded in the email is derived from r.Host (no PublicURL config setting in v1; self-hosted operators see their own hostname). Tests cover happy-path send + token-row insertion, unknown email returns 200 with no send, mailer failure still returns 200, reset happy path verifies bcrypt match + used_at set, already-used token 400, expired token 400, short password 400, bogus token 400. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
100 lines
3.2 KiB
Go
100 lines
3.2 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"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, http.StatusBadRequest, "invalid_body", "")
|
|
return
|
|
}
|
|
if len(req.NewPassword) < minPasswordLength {
|
|
writeErr(w, http.StatusBadRequest, "password_too_short", "password must be at least 8 chars")
|
|
return
|
|
}
|
|
if req.Token == "" {
|
|
writeErr(w, http.StatusBadRequest, "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, http.StatusBadRequest, "invalid_token", "")
|
|
return
|
|
}
|
|
h.logger.Error("reset password: lookup failed", "err", err)
|
|
writeErr(w, http.StatusInternalServerError, "server_error", "")
|
|
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, http.StatusInternalServerError, "server_error", "")
|
|
return
|
|
}
|
|
if rows == 0 {
|
|
writeErr(w, http.StatusBadRequest, "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, http.StatusInternalServerError, "server_error", "")
|
|
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, http.StatusInternalServerError, "server_error", "")
|
|
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)
|
|
}
|