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:
144
account_test.go
Normal file
144
account_test.go
Normal file
@@ -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
|
||||
}
|
||||
179
classes/codes.go
Normal file
179
classes/codes.go
Normal file
@@ -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
|
||||
}
|
||||
99
classes/codes_test.go
Normal file
99
classes/codes_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user