feat(07-03): update profile, change password, and marketing consent
Changing a password invalidates older tokens and leaves the token that made the call signed in. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -384,6 +384,201 @@ func ActivateByCode(app *backpack.App) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// Update changes the signed-in user's name, surname, and email.
|
||||
func Update(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
|
||||
}
|
||||
errs, err := lagoon.Validate(r.Context(), gdb, user, map[string]string{
|
||||
"name": "required|between:2,255",
|
||||
"surname": "required|between:2,255",
|
||||
"email": "required|between:6,255|email|unique:users",
|
||||
}, fields, nil)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
writeValidation(w, errs)
|
||||
return
|
||||
}
|
||||
name := strField(fields, "name")
|
||||
surname := strField(fields, "surname")
|
||||
if err := gdb.WithContext(r.Context()).Model(user).Updates(map[string]any{
|
||||
"name": name,
|
||||
"surname": surname,
|
||||
"email": strField(fields, "email"),
|
||||
}).Error; err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
user.Name = &name
|
||||
user.Surname = &surname
|
||||
user.Email = strField(fields, "email")
|
||||
payload, err := apiArray(r.Context(), app, user)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"message": "Profile updated", "user": payload})
|
||||
}
|
||||
}
|
||||
|
||||
// ChangePassword replaces a self-set password and invalidates older tokens.
|
||||
// The token that made this call stays valid.
|
||||
func ChangePassword(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gdb, secret, ok := sessionDeps(w, app)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
raw := bearerToken(r)
|
||||
sub, iat, _, _, err := bouncer.VerifyClaims(raw, secret)
|
||||
if err != nil {
|
||||
writeUnauthorized(w)
|
||||
return
|
||||
}
|
||||
var bl bouncer.BlacklistStore
|
||||
if store, ok := app.Lookup[bouncer.BlacklistStore](); ok {
|
||||
bl = store
|
||||
}
|
||||
if _, err := bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, bl).Authenticate(r); err != nil {
|
||||
writeUnauthorized(w)
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseUint(sub, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
writeUnauthorized(w)
|
||||
return
|
||||
}
|
||||
var user models.User
|
||||
if err := gdb.WithContext(r.Context()).Where("id = ?", id).Take(&user).Error; err != nil {
|
||||
writeUnauthorized(w)
|
||||
return
|
||||
}
|
||||
if !user.HasSelfSetPassword {
|
||||
slog.Default().Error("golem15.user: change-password requires a self-set password", "user_id", user.ID)
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
fields, err := readFields(r)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
minLen := 8
|
||||
if app.Config != nil && app.Config.Int("golem15.user.password.min_length") > 0 {
|
||||
minLen = app.Config.Int("golem15.user.password.min_length")
|
||||
}
|
||||
errs, err := lagoon.Validate(r.Context(), gdb, &user, map[string]string{
|
||||
"current_password": "required",
|
||||
"password": "required|between:" + strconv.Itoa(minLen) + ",255|confirmed|different:current_password",
|
||||
}, fields, nil)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
writeValidation(w, errs)
|
||||
return
|
||||
}
|
||||
current := strField(fields, "current_password")
|
||||
if !bouncer.CheckPassword(user.Password, current) {
|
||||
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{
|
||||
"error": "Invalid email or password",
|
||||
"errors": map[string][]string{
|
||||
"current_password": {"Invalid email or password"},
|
||||
},
|
||||
})
|
||||
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")
|
||||
}
|
||||
next := strField(fields, "password")
|
||||
hashed, err := bouncer.HashPassword(cost, next)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
cutoff := iat.Add(-time.Second)
|
||||
if err := gdb.WithContext(r.Context()).Model(&user).Updates(map[string]any{
|
||||
"password": hashed,
|
||||
"must_change_password": false,
|
||||
"has_self_set_password": true,
|
||||
"tokens_valid_after": cutoff,
|
||||
}).Error; err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
user.MustChangePassword = false
|
||||
user.HasSelfSetPassword = true
|
||||
user.TokensValidAfter = &cutoff
|
||||
payload, err := apiArray(r.Context(), app, &user)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"message": "Password changed", "user": payload})
|
||||
}
|
||||
}
|
||||
|
||||
// MarketingConsent sets the signed-in user's marketing_consent flag.
|
||||
func MarketingConsent(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
|
||||
}
|
||||
raw, exists := fields["marketing_consent"]
|
||||
if !exists || raw == nil {
|
||||
writeValidation(w, map[string][]string{
|
||||
"marketing_consent": {"The marketing_consent field is required."},
|
||||
})
|
||||
return
|
||||
}
|
||||
consent, isBool := raw.(bool)
|
||||
if !isBool {
|
||||
writeValidation(w, map[string][]string{
|
||||
"marketing_consent": {"The marketing_consent field must be true or false."},
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := gdb.WithContext(r.Context()).Model(user).Update("marketing_consent", consent).Error; err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
user.MarketingConsent = consent
|
||||
payload, err := apiArray(r.Context(), app, user)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"message": "Marketing consent updated", "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) {
|
||||
@@ -443,6 +638,7 @@ func Register(app *backpack.App) http.HandlerFunc {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
user.HasSelfSetPassword = true
|
||||
if mode == "auto" {
|
||||
user.IsActivated = true
|
||||
now := time.Now()
|
||||
|
||||
119
profile_test.go
Normal file
119
profile_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/fonoteka/plugins/golem15/user/controllers"
|
||||
)
|
||||
|
||||
func TestUpdate(t *testing.T) {
|
||||
app, gdb := sessionApp(t, "0s")
|
||||
token := loginToken(t, app, gdb, "ada-update@example.test")
|
||||
other := insertUser(t, gdb, "other-update@example.test", "correct-horse")
|
||||
_ = other
|
||||
body := `{"name":"Ada","surname":"Lovelace","email":"ada-update@example.test"}`
|
||||
rec := bearerJSON(t, controllers.Update(app), "/_user/api/v1/update", token, body)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"message":"Profile updated"`) || !strings.Contains(rec.Body.String(), `"name":"Ada"`) {
|
||||
t.Fatalf("update %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
again := bearerJSON(t, controllers.Update(app), "/_user/api/v1/update", token, body)
|
||||
if again.Code != http.StatusOK {
|
||||
t.Fatalf("own email %d %s", again.Code, again.Body.String())
|
||||
}
|
||||
dup := bearerJSON(t, controllers.Update(app), "/_user/api/v1/update", token, `{"name":"Ada","surname":"Lovelace","email":"other-update@example.test"}`)
|
||||
if dup.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("duplicate %d %s", dup.Code, dup.Body.String())
|
||||
}
|
||||
missing := bearerJSON(t, controllers.Update(app), "/_user/api/v1/update", token, `{"surname":"Lovelace","email":"ada-update@example.test"}`)
|
||||
if missing.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("missing name %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["name"]; !ok {
|
||||
t.Fatalf("errors = %#v", verr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangePassword(t *testing.T) {
|
||||
app, gdb := sessionApp(t, "0s")
|
||||
user := insertUser(t, gdb, "change@example.test", "correct-horse")
|
||||
if err := gdb.Model(user).Update("has_self_set_password", true).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sub := strconv.FormatUint(uint64(user.ID), 10)
|
||||
older := craftToken(t, sub, "older-token", time.Now().Add(-2*time.Minute), time.Now().Add(time.Hour))
|
||||
if rec := bearer(t, controllers.Fetch(app), http.MethodGet, "/_user/api/v1/fetch", older); rec.Code != http.StatusOK {
|
||||
t.Fatalf("older fetch before change %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
login := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"change@example.test","password":"correct-horse"}`)
|
||||
var logged map[string]any
|
||||
if err := json.Unmarshal(login.Body.Bytes(), &logged); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, _ := logged["token"].(string)
|
||||
if login.Code != http.StatusOK || token == "" {
|
||||
t.Fatalf("login %d %s", login.Code, login.Body.String())
|
||||
}
|
||||
changed := bearerJSON(t, controllers.ChangePassword(app), "/_user/api/v1/change-password", token, `{"current_password":"correct-horse","password":"brand-new-password","password_confirmation":"brand-new-password"}`)
|
||||
if changed.Code != http.StatusOK || !strings.Contains(changed.Body.String(), `"message":"Password changed"`) || strings.Contains(changed.Body.String(), `"token"`) {
|
||||
t.Fatalf("change %d %s", changed.Code, changed.Body.String())
|
||||
}
|
||||
if rec := bearer(t, controllers.Fetch(app), http.MethodGet, "/_user/api/v1/fetch", token); rec.Code != http.StatusOK {
|
||||
t.Fatalf("presenting token rejected %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := bearer(t, controllers.Fetch(app), http.MethodGet, "/_user/api/v1/fetch", older); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("older token still works %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
wrong := bearerJSON(t, controllers.ChangePassword(app), "/_user/api/v1/change-password", token, `{"current_password":"nope","password":"another-password","password_confirmation":"another-password"}`)
|
||||
if wrong.Body.String() != `{"error":"Invalid email or password","errors":{"current_password":["Invalid email or password"]}}` {
|
||||
t.Fatalf("wrong current %d %s", wrong.Code, wrong.Body.String())
|
||||
}
|
||||
same := bearerJSON(t, controllers.ChangePassword(app), "/_user/api/v1/change-password", token, `{"current_password":"brand-new-password","password":"brand-new-password","password_confirmation":"brand-new-password"}`)
|
||||
if same.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("same password %d %s", same.Code, same.Body.String())
|
||||
}
|
||||
|
||||
locked := insertUser(t, gdb, "locked-password@example.test", "correct-horse")
|
||||
if err := gdb.Model(locked).UpdateColumn("has_self_set_password", false).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lockedLogin := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"locked-password@example.test","password":"correct-horse"}`)
|
||||
var lockedBody map[string]any
|
||||
if err := json.Unmarshal(lockedLogin.Body.Bytes(), &lockedBody); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lockedToken, _ := lockedBody["token"].(string)
|
||||
if lockedLogin.Code != http.StatusOK || lockedToken == "" {
|
||||
t.Fatalf("locked login %d %s", lockedLogin.Code, lockedLogin.Body.String())
|
||||
}
|
||||
opaque := bearerJSON(t, controllers.ChangePassword(app), "/_user/api/v1/change-password", lockedToken, `{"current_password":"correct-horse","password":"brand-new-password","password_confirmation":"brand-new-password"}`)
|
||||
if opaque.Code != http.StatusInternalServerError || opaque.Body.String() != `{"error":true,"message":"Internal server error"}` {
|
||||
t.Fatalf("no self-set password %d %s", opaque.Code, opaque.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketingConsent(t *testing.T) {
|
||||
app, gdb := sessionApp(t, "0s")
|
||||
token := loginToken(t, app, gdb, "consent@example.test")
|
||||
on := bearerJSON(t, controllers.MarketingConsent(app), "/_user/api/v1/marketing-consent", token, `{"marketing_consent":true}`)
|
||||
if on.Code != http.StatusOK || !strings.Contains(on.Body.String(), `"marketing_consent":true`) || !strings.Contains(on.Body.String(), `"message":"Marketing consent updated"`) {
|
||||
t.Fatalf("on %d %s", on.Code, on.Body.String())
|
||||
}
|
||||
off := bearerJSON(t, controllers.MarketingConsent(app), "/_user/api/v1/marketing-consent", token, `{"marketing_consent":false}`)
|
||||
if off.Code != http.StatusOK || !strings.Contains(off.Body.String(), `"marketing_consent":false`) {
|
||||
t.Fatalf("off %d %s", off.Code, off.Body.String())
|
||||
}
|
||||
bad := bearerJSON(t, controllers.MarketingConsent(app), "/_user/api/v1/marketing-consent", token, `{"marketing_consent":"yes"}`)
|
||||
if bad.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("non-bool %d %s", bad.Code, bad.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,9 @@ func (p *Plugin) Routes(r pact.Router) error {
|
||||
g.Post("/reset-password", controllers.ResetPassword(p.app))
|
||||
g.Post("/activate", controllers.Activate(p.app))
|
||||
g.Post("/activate-by-code", controllers.ActivateByCode(p.app))
|
||||
g.Post("/update", controllers.Update(p.app))
|
||||
g.Post("/change-password", controllers.ChangePassword(p.app))
|
||||
g.Post("/marketing-consent", controllers.MarketingConsent(p.app))
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user