feat(07-03): add password reset and account activation
Reset and activation codes expire on a TTL, and a missing issued-at still accepts a cutover code. Forgot-password always returns the same 200 body. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,8 +5,10 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -192,6 +194,196 @@ func OAuthProviders(app *backpack.App) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
const forgotPasswordBody = "If that email exists, a reset link has been sent."
|
||||
|
||||
// ForgotPassword always returns the same 200 body. A real account also gets a reset mail.
|
||||
func ForgotPassword(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gdb, _, ok := sessionDeps(w, app)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fields, err := readFields(r)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
errs, err := lagoon.Validate(r.Context(), gdb, models.User{}, map[string]string{
|
||||
"email": "required|email|between:6,255",
|
||||
}, fields, nil)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
writeValidation(w, errs)
|
||||
return
|
||||
}
|
||||
ctx := codeContext(r.Context(), app)
|
||||
user, err := classes.LookupByEmail(ctx, gdb, strField(fields, "email"))
|
||||
if err != nil {
|
||||
slog.Default().Error("golem15.user: forgot-password lookup", "err", err)
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"message": forgotPasswordBody})
|
||||
return
|
||||
}
|
||||
if user != nil {
|
||||
code, err := classes.IssueResetCode(ctx, gdb, user)
|
||||
if err != nil {
|
||||
slog.Default().Error("golem15.user: issue reset code", "err", err)
|
||||
} else {
|
||||
name := ""
|
||||
if user.Name != nil {
|
||||
name = *user.Name
|
||||
}
|
||||
link := resetLink(app, user.ID, code)
|
||||
sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.restore"), user.Email, map[string]any{
|
||||
"name": name,
|
||||
"link": link,
|
||||
"code": strconv.FormatUint(uint64(user.ID), 10) + "!" + code,
|
||||
})
|
||||
}
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"message": forgotPasswordBody})
|
||||
}
|
||||
}
|
||||
|
||||
// ResetPassword consumes a "{id}!{code}" reset code and stores a new password.
|
||||
func ResetPassword(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gdb, _, ok := sessionDeps(w, app)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fields, err := readFields(r)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
errs, err := lagoon.Validate(r.Context(), gdb, models.User{}, map[string]string{
|
||||
"code": "required",
|
||||
"password": "required|between:8,255|confirmed",
|
||||
}, fields, nil)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
writeValidation(w, errs)
|
||||
return
|
||||
}
|
||||
id, code, ok := splitCode(strField(fields, "code"))
|
||||
if !ok {
|
||||
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{"error": "Invalid reset code"})
|
||||
return
|
||||
}
|
||||
user, verified := classes.VerifyResetCode(codeContext(r.Context(), app), gdb, id, code)
|
||||
if !verified || user == nil {
|
||||
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{"error": "Invalid or expired reset code"})
|
||||
return
|
||||
}
|
||||
cost := 10
|
||||
if app.Config != nil && app.Config.Int("golem15.user.password.bcrypt_cost") > 0 {
|
||||
cost = app.Config.Int("golem15.user.password.bcrypt_cost")
|
||||
}
|
||||
hashed, err := bouncer.HashPassword(cost, strField(fields, "password"))
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
if err := gdb.WithContext(r.Context()).Model(&models.User{}).Where("id = ?", user.ID).Updates(map[string]any{
|
||||
"password": hashed,
|
||||
"tokens_valid_after": now,
|
||||
}).Error; err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"message": "Password has been reset"})
|
||||
}
|
||||
}
|
||||
|
||||
// Activate calls attempt-activation and always returns the user payload.
|
||||
// A wrong code leaves is_activated unchanged, matching the PHP handler's missing check.
|
||||
func Activate(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gdb, _, ok := sessionDeps(w, app)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
user, ok := authenticate(w, r, app)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fields, err := readFields(r)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
_, _ = classes.VerifyActivationCode(codeContext(r.Context(), app), gdb, user.ID, strField(fields, "code"))
|
||||
if err := gdb.WithContext(r.Context()).Where("id = ?", user.ID).Take(user).Error; err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
payload, err := apiArray(r.Context(), app, user)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"user": payload})
|
||||
}
|
||||
}
|
||||
|
||||
// ActivateByCode activates from a public "{id}!{code}" value and mints a token.
|
||||
func ActivateByCode(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gdb, secret, ok := sessionDeps(w, app)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fields, err := readFields(r)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
errs, err := lagoon.Validate(r.Context(), gdb, models.User{}, map[string]string{
|
||||
"code": "required",
|
||||
}, fields, nil)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
writeValidation(w, errs)
|
||||
return
|
||||
}
|
||||
id, code, ok := splitCode(strField(fields, "code"))
|
||||
if !ok {
|
||||
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{"error": "Invalid activation code"})
|
||||
return
|
||||
}
|
||||
user, verified := classes.VerifyActivationCode(codeContext(r.Context(), app), gdb, id, code)
|
||||
if !verified || user == nil {
|
||||
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{"error": "This activation link is invalid or has expired"})
|
||||
return
|
||||
}
|
||||
token, err := mintFor(app, secret, user, requestURL(r))
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
payload, err := apiArray(r.Context(), app, user)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"message": "Account activated",
|
||||
"token": token,
|
||||
"user": payload,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Register creates an account and follows activate_mode: auto, user, or admin.
|
||||
func Register(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -463,14 +655,68 @@ func mailName(ctx context.Context, user *models.User, base string) string {
|
||||
}
|
||||
|
||||
func sendTemplate(ctx context.Context, app *backpack.App, template, to string) {
|
||||
sendTemplateVars(ctx, app, template, to, nil)
|
||||
}
|
||||
|
||||
func sendTemplateVars(ctx context.Context, app *backpack.App, template, to string, vars map[string]any) {
|
||||
if app == nil || to == "" {
|
||||
return
|
||||
}
|
||||
mailer, ok := app.Lookup[postcard.Mailer]()
|
||||
if !ok || mailer == nil {
|
||||
slog.Default().Error("golem15.user: mailer is not configured", "template", template)
|
||||
return
|
||||
}
|
||||
_ = mailer.Send(ctx, postcard.Message{Template: template, To: []string{to}})
|
||||
if err := mailer.Send(ctx, postcard.Message{Template: template, To: []string{to}, Vars: vars}); err != nil {
|
||||
slog.Default().Error("golem15.user: send mail", "template", template, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeValidation(w http.ResponseWriter, errs map[string][]string) {
|
||||
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{
|
||||
"error": firstValidationMessage(errs),
|
||||
"errors": errs,
|
||||
})
|
||||
}
|
||||
|
||||
func splitCode(raw string) (uint, string, bool) {
|
||||
idPart, code, ok := strings.Cut(raw, "!")
|
||||
if !ok || idPart == "" || code == "" || strings.Contains(code, "!") {
|
||||
return 0, "", false
|
||||
}
|
||||
id, err := strconv.ParseUint(idPart, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return 0, "", false
|
||||
}
|
||||
return uint(id), code, true
|
||||
}
|
||||
|
||||
func codeContext(ctx context.Context, app *backpack.App) context.Context {
|
||||
reset := 60 * time.Minute
|
||||
activation := 72 * time.Hour
|
||||
if app != nil && app.Config != nil {
|
||||
if n := app.Config.Int("golem15.user.activation.reset_ttl_minutes"); n > 0 {
|
||||
reset = time.Duration(n) * time.Minute
|
||||
}
|
||||
if n := app.Config.Int("golem15.user.activation.activation_ttl_hours"); n > 0 {
|
||||
activation = time.Duration(n) * time.Hour
|
||||
}
|
||||
}
|
||||
return classes.ContextWithCodeTTL(ctx, reset, activation)
|
||||
}
|
||||
|
||||
func resetLink(app *backpack.App, id uint, code string) string {
|
||||
base := "/reset-password"
|
||||
if app != nil && app.Config != nil {
|
||||
if v := strings.TrimSpace(app.Config.String("golem15.user.reset_url_base")); v != "" {
|
||||
base = v
|
||||
} else if appURL := strings.TrimRight(strings.TrimSpace(app.Config.String("app.url")), "/"); appURL != "" {
|
||||
base = appURL + "/reset-password"
|
||||
}
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("code", strconv.FormatUint(uint64(id), 10)+"!"+code)
|
||||
return base + "?" + q.Encode()
|
||||
}
|
||||
|
||||
func readFields(r *http.Request) (map[string]any, error) {
|
||||
|
||||
Reference in New Issue
Block a user