diff --git a/controllers/api_controller.go b/controllers/api_controller.go index 404ded1..6c0dc05 100644 --- a/controllers/api_controller.go +++ b/controllers/api_controller.go @@ -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() diff --git a/profile_test.go b/profile_test.go new file mode 100644 index 0000000..c1b6426 --- /dev/null +++ b/profile_test.go @@ -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()) + } +} diff --git a/routes.go b/routes.go index 0ff8ea5..f049b24 100644 --- a/routes.go +++ b/routes.go @@ -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 }