From cb50d673c6b855bec616813e72869506c75e0873 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 22 Sep 2026 16:30:22 +0200 Subject: [PATCH] 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 --- account_test.go | 144 ++++++++++++++++++++ classes/codes.go | 179 ++++++++++++++++++++++++ classes/codes_test.go | 99 ++++++++++++++ controllers/api_controller.go | 248 +++++++++++++++++++++++++++++++++- routes.go | 4 + 5 files changed, 673 insertions(+), 1 deletion(-) create mode 100644 account_test.go create mode 100644 classes/codes.go create mode 100644 classes/codes_test.go diff --git a/account_test.go b/account_test.go new file mode 100644 index 0000000..084dd4c --- /dev/null +++ b/account_test.go @@ -0,0 +1,144 @@ +package user + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "git.golem15.com/golem15/fonoteka/plugins/golem15/user/classes" + "git.golem15.com/golem15/fonoteka/plugins/golem15/user/controllers" + "git.golem15.com/golem15/summercms/postcard" +) + +func TestForgotPassword(t *testing.T) { + app, gdb := sessionApp(t, "0s") + loc := "en" + user := insertUser(t, gdb, "forgot@example.test", "correct-horse") + if err := gdb.Model(user).Update("preferred_locale", loc).Error; err != nil { + t.Fatal(err) + } + mail := &captureMail{} + if err := app.Publish[postcard.Mailer](mail); err != nil { + t.Fatal(err) + } + known := postJSON(t, controllers.ForgotPassword(app), "/_user/api/v1/forgot-password", `{"email":"forgot@example.test"}`) + unknown := postJSON(t, controllers.ForgotPassword(app), "/_user/api/v1/forgot-password", `{"email":"missing@example.test"}`) + const body = `{"message":"If that email exists, a reset link has been sent."}` + if known.Code != http.StatusOK || known.Body.String() != body || unknown.Body.String() != body { + t.Fatalf("known %d %s unknown %d %s", known.Code, known.Body.String(), unknown.Code, unknown.Body.String()) + } + if len(mail.got) != 1 || mail.got[0] != "golem15.user::mail.restore-en" { + t.Fatalf("template = %#v", mail.got) + } + bad := postJSON(t, controllers.ForgotPassword(app), "/_user/api/v1/forgot-password", `{"email":"nope"}`) + if bad.Code != http.StatusUnprocessableEntity || !strings.Contains(bad.Body.String(), `"errors":`) { + t.Fatalf("malformed %d %s", bad.Code, bad.Body.String()) + } + var verr map[string]any + if err := json.Unmarshal(bad.Body.Bytes(), &verr); err != nil { + t.Fatal(err) + } + errorsBody, _ := verr["errors"].(map[string]any) + if _, ok := errorsBody["email"]; !ok { + t.Fatalf("errors = %#v", verr) + } +} + +func TestResetPassword(t *testing.T) { + app, gdb := sessionApp(t, "0s") + user := insertUser(t, gdb, "reset-me@example.test", "correct-horse") + code, err := classes.IssueResetCode(t.Context(), gdb, user) + if err != nil { + t.Fatal(err) + } + packed := strconv.FormatUint(uint64(user.ID), 10) + "!" + code + ok := postJSON(t, controllers.ResetPassword(app), "/_user/api/v1/reset-password", `{"code":"`+packed+`","password":"new-password","password_confirmation":"new-password"}`) + if ok.Code != http.StatusOK || ok.Body.String() != `{"message":"Password has been reset"}` { + t.Fatalf("reset %d %s", ok.Code, ok.Body.String()) + } + loginNew := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"reset-me@example.test","password":"new-password"}`) + if loginNew.Code != http.StatusOK { + t.Fatalf("new password %d %s", loginNew.Code, loginNew.Body.String()) + } + loginOld := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"reset-me@example.test","password":"correct-horse"}`) + if loginOld.Code != http.StatusUnauthorized { + t.Fatalf("old password still works: %d %s", loginOld.Code, loginOld.Body.String()) + } + + malformed := postJSON(t, controllers.ResetPassword(app), "/_user/api/v1/reset-password", `{"code":"not-a-code","password":"new-password","password_confirmation":"new-password"}`) + if malformed.Code != http.StatusUnprocessableEntity || malformed.Body.String() != `{"error":"Invalid reset code"}` { + t.Fatalf("malformed %d %s", malformed.Code, malformed.Body.String()) + } + wrong := postJSON(t, controllers.ResetPassword(app), "/_user/api/v1/reset-password", `{"code":"`+strconv.FormatUint(uint64(user.ID), 10)+`!nope","password":"new-password","password_confirmation":"new-password"}`) + if wrong.Body.String() != `{"error":"Invalid or expired reset code"}` { + t.Fatalf("wrong %d %s", wrong.Code, wrong.Body.String()) + } +} + +func TestActivate(t *testing.T) { + app, gdb := sessionApp(t, "0s") + token := loginToken(t, app, gdb, "activate-auth@example.test") + rec := bearerJSON(t, controllers.Activate(app), "/_user/api/v1/activate", token, `{"code":"wrong"}`) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"is_activated":false`) { + t.Fatalf("activate %d %s", rec.Code, rec.Body.String()) + } +} + +func TestActivateByCode(t *testing.T) { + app, gdb := sessionApp(t, "0s") + user := insertUser(t, gdb, "activate-code@example.test", "correct-horse") + code, err := classes.IssueActivationCode(t.Context(), gdb, user) + if err != nil { + t.Fatal(err) + } + packed := strconv.FormatUint(uint64(user.ID), 10) + "!" + code + rec := postJSON(t, controllers.ActivateByCode(app), "/_user/api/v1/activate-by-code", `{"code":"`+packed+`"}`) + if rec.Code != http.StatusOK { + t.Fatalf("activate-by-code %d %s", rec.Code, rec.Body.String()) + } + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + token, _ := body["token"].(string) + userBody, _ := body["user"].(map[string]any) + if token == "" || userBody["is_activated"] != true || body["message"] != "Account activated" { + t.Fatalf("body %#v", body) + } + fetch := bearer(t, controllers.Fetch(app), http.MethodGet, "/_user/api/v1/fetch", token) + if fetch.Code != http.StatusOK { + t.Fatalf("fetch %d %s", fetch.Code, fetch.Body.String()) + } + missing := postJSON(t, controllers.ActivateByCode(app), "/_user/api/v1/activate-by-code", `{}`) + if missing.Code != http.StatusUnprocessableEntity { + t.Fatalf("missing %d %s", missing.Code, missing.Body.String()) + } + var verr map[string]any + if err := json.Unmarshal(missing.Body.Bytes(), &verr); err != nil { + t.Fatal(err) + } + errorsBody, _ := verr["errors"].(map[string]any) + if _, ok := errorsBody["code"]; !ok { + t.Fatalf("errors = %#v", verr) + } + bad := postJSON(t, controllers.ActivateByCode(app), "/_user/api/v1/activate-by-code", `{"code":"1!nope"}`) + if bad.Body.String() != `{"error":"This activation link is invalid or has expired"}` { + t.Fatalf("bad %d %s", bad.Code, bad.Body.String()) + } +} + +func bearerJSON(t *testing.T, h http.HandlerFunc, path, token, body string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) + req.Host = "example.com" + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + h(rec, req) + return rec +} diff --git a/classes/codes.go b/classes/codes.go new file mode 100644 index 0000000..fe56a8d --- /dev/null +++ b/classes/codes.go @@ -0,0 +1,179 @@ +package classes + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "errors" + "time" + + "git.golem15.com/golem15/fonoteka/plugins/golem15/user/models" + "gorm.io/gorm" +) + +type resetTTLKey struct{} +type activationTTLKey struct{} + +// ContextWithCodeTTL stores the reset and activation lifetimes used by Verify. +// A missing value falls back to 60 minutes and 72 hours. +func ContextWithCodeTTL(ctx context.Context, reset, activation time.Duration) context.Context { + if ctx == nil { + ctx = context.Background() + } + ctx = context.WithValue(ctx, resetTTLKey{}, reset) + return context.WithValue(ctx, activationTTLKey{}, activation) +} + +func resetTTL(ctx context.Context) time.Duration { + if ctx != nil { + if d, ok := ctx.Value(resetTTLKey{}).(time.Duration); ok && d > 0 { + return d + } + } + return 60 * time.Minute +} + +func activationTTL(ctx context.Context) time.Duration { + if ctx != nil { + if d, ok := ctx.Value(activationTTLKey{}).(time.Duration); ok && d > 0 { + return d + } + } + return 72 * time.Hour +} + +func newCode() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} + +// IssueResetCode stores a fresh plain-text reset code and returns it. +func IssueResetCode(ctx context.Context, db *gorm.DB, user *models.User) (string, error) { + return issueCode(ctx, db, user, "reset_password_code", "reset_password_code_issued_at") +} + +// IssueActivationCode stores a fresh plain-text activation code and returns it. +func IssueActivationCode(ctx context.Context, db *gorm.DB, user *models.User) (string, error) { + return issueCode(ctx, db, user, "activation_code", "activation_code_issued_at") +} + +func issueCode(ctx context.Context, db *gorm.DB, user *models.User, codeCol, issuedCol string) (string, error) { + if user == nil || user.ID == 0 || db == nil { + return "", errors.New("golem15.user: cannot issue a code without a saved user") + } + code, err := newCode() + if err != nil { + return "", err + } + now := time.Now() + err = db.WithContext(ctx).Model(&models.User{}).Where("id = ?", user.ID).UpdateColumns(map[string]any{ + codeCol: code, + issuedCol: now, + }).Error + if err != nil { + return "", err + } + switch codeCol { + case "reset_password_code": + user.ResetPasswordCode = &code + user.ResetPasswordCodeIssuedAt = &now + default: + user.ActivationCode = &code + user.ActivationCodeIssuedAt = &now + } + return code, nil +} + +// VerifyResetCode accepts the stored code inside the reset TTL. +// A NULL issued-at is treated as issued now. Success clears the code and +// marks the password as self-set. The caller stores the new password. +func VerifyResetCode(ctx context.Context, db *gorm.DB, userID uint, code string) (*models.User, bool) { + user, err := takeUser(ctx, db, userID, false) + if err != nil || user == nil || user.ResetPasswordCode == nil { + return nil, false + } + if !codeFresh(user.ResetPasswordCodeIssuedAt, resetTTL(ctx)) || !codesEqual(code, *user.ResetPasswordCode) { + return nil, false + } + if err := db.WithContext(ctx).Model(&models.User{}).Where("id = ?", user.ID).UpdateColumns(map[string]any{ + "reset_password_code": nil, + "reset_password_code_issued_at": nil, + "has_self_set_password": true, + }).Error; err != nil { + return nil, false + } + user.ResetPasswordCode = nil + user.ResetPasswordCodeIssuedAt = nil + user.HasSelfSetPassword = true + return user, true +} + +// VerifyActivationCode accepts the stored code inside the activation TTL. +// A soft-deleted row is visible and is restored on success. +func VerifyActivationCode(ctx context.Context, db *gorm.DB, userID uint, code string) (*models.User, bool) { + user, err := takeUser(ctx, db, userID, true) + if err != nil || user == nil || user.IsActivated || user.ActivationCode == nil { + return nil, false + } + if !codeFresh(user.ActivationCodeIssuedAt, activationTTL(ctx)) || !codesEqual(code, *user.ActivationCode) { + return nil, false + } + if user.DeletedAt.Valid { + if err := db.WithContext(ctx).Unscoped().Model(&models.User{}).Where("id = ?", user.ID).Update("deleted_at", nil).Error; err != nil { + return nil, false + } + user.DeletedAt = gorm.DeletedAt{} + } + now := time.Now() + if err := db.WithContext(ctx).Model(&models.User{}).Where("id = ?", user.ID).UpdateColumns(map[string]any{ + "is_activated": true, + "activated_at": now, + "activation_code": nil, + "activation_code_issued_at": nil, + }).Error; err != nil { + return nil, false + } + user.IsActivated = true + user.ActivatedAt = &now + user.ActivationCode = nil + user.ActivationCodeIssuedAt = nil + return user, true +} + +func takeUser(ctx context.Context, db *gorm.DB, id uint, unscoped bool) (*models.User, error) { + if db == nil || id == 0 { + return nil, nil + } + q := db.WithContext(ctx) + if unscoped { + q = q.Unscoped() + } + var user models.User + err := q.Where("id = ?", id).Take(&user).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &user, nil +} + +func codeFresh(issued *time.Time, ttl time.Duration) bool { + at := time.Now() + if issued != nil && !issued.IsZero() { + at = *issued + } + return !time.Now().After(at.Add(ttl)) +} + +func codesEqual(got, stored string) bool { + if len(got) != len(stored) { + return false + } + return subtle.ConstantTimeCompare([]byte(got), []byte(stored)) == 1 +} diff --git a/classes/codes_test.go b/classes/codes_test.go new file mode 100644 index 0000000..9e67947 --- /dev/null +++ b/classes/codes_test.go @@ -0,0 +1,99 @@ +package classes + +import ( + "os" + "strings" + "testing" + "time" + + "git.golem15.com/golem15/fonoteka/plugins/golem15/user/models" +) + +func flipHex(b byte) string { + if b == '0' { + return "1" + } + return "0" +} + +func TestCodes(t *testing.T) { + db := throttleDB(t) + src, err := os.ReadFile("codes.go") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(src), "subtle.ConstantTimeCompare") { + t.Fatal("code compare must use crypto/subtle.ConstantTimeCompare") + } + + user := &models.User{Email: "reset@example.test", Password: "x"} + if err := db.Create(user).Error; err != nil { + t.Fatal(err) + } + code, err := IssueResetCode(t.Context(), db, user) + if err != nil || code == "" { + t.Fatalf("issue reset: %q %v", code, err) + } + if user.ResetPasswordCodeIssuedAt == nil || time.Since(*user.ResetPasswordCodeIssuedAt) > 2*time.Second { + t.Fatalf("issued at = %v", user.ResetPasswordCodeIssuedAt) + } + act, err := IssueActivationCode(t.Context(), db, user) + if err != nil || act == "" { + t.Fatalf("issue activation: %q %v", act, err) + } + + got, ok := VerifyResetCode(t.Context(), db, user.ID, code) + if !ok || got == nil || !got.HasSelfSetPassword { + t.Fatalf("verify reset = %v %#v", ok, got) + } + var row models.User + if err := db.Where("id = ?", user.ID).Take(&row).Error; err != nil { + t.Fatal(err) + } + if row.ResetPasswordCode != nil { + t.Fatalf("code not cleared: %#v", row.ResetPasswordCode) + } + if _, ok := VerifyResetCode(t.Context(), db, user.ID, code); ok { + t.Fatal("consumed code still verified") + } + + again, err := IssueResetCode(t.Context(), db, user) + if err != nil { + t.Fatal(err) + } + if _, ok := VerifyResetCode(t.Context(), db, user.ID, again[:len(again)-1]+flipHex(again[len(again)-1])); ok { + t.Fatal("last-byte mismatch verified") + } + if _, ok := VerifyResetCode(t.Context(), db, user.ID, flipHex(again[0])+again[1:]); ok { + t.Fatal("first-byte mismatch verified") + } + + past := time.Now().Add(-61 * time.Minute) + if err := db.Model(&models.User{}).Where("id = ?", user.ID).Update("reset_password_code_issued_at", past).Error; err != nil { + t.Fatal(err) + } + if _, ok := VerifyResetCode(t.Context(), db, user.ID, again); ok { + t.Fatal("expired code verified") + } + + fresh := "cutover-code-value" + if err := db.Exec(`UPDATE users SET reset_password_code = ?, reset_password_code_issued_at = NULL WHERE id = ?`, fresh, user.ID).Error; err != nil { + t.Fatal(err) + } + if _, ok := VerifyResetCode(t.Context(), db, user.ID, fresh); !ok { + t.Fatal("null issued-at must be treated as issued now") + } + + pending := &models.User{Email: "activate@example.test", Password: "x"} + if err := db.Create(pending).Error; err != nil { + t.Fatal(err) + } + activation, err := IssueActivationCode(t.Context(), db, pending) + if err != nil { + t.Fatal(err) + } + activated, ok := VerifyActivationCode(t.Context(), db, pending.ID, activation) + if !ok || activated == nil || !activated.IsActivated { + t.Fatalf("activate = %v %#v", ok, activated) + } +} diff --git a/controllers/api_controller.go b/controllers/api_controller.go index 2c465f4..404ded1 100644 --- a/controllers/api_controller.go +++ b/controllers/api_controller.go @@ -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) { diff --git a/routes.go b/routes.go index 39f83f9..0ff8ea5 100644 --- a/routes.go +++ b/routes.go @@ -14,6 +14,10 @@ func (p *Plugin) Routes(r pact.Router) error { g.Post("/refresh", controllers.Refresh(p.app)) g.Post("/register", controllers.Register(p.app)) g.Get("/oauth-providers", controllers.OAuthProviders(p.app)) + g.Post("/forgot-password", controllers.ForgotPassword(p.app)) + g.Post("/reset-password", controllers.ResetPassword(p.app)) + g.Post("/activate", controllers.Activate(p.app)) + g.Post("/activate-by-code", controllers.ActivateByCode(p.app)) }) return nil }