feat(07-02): register accounts and extend the user payload
Registration follows activate_mode and hides a disabled or throttled cause in production. Fonoteka adds organisation fields through GetApiArrayEvent. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
48
classes/events_test.go
Normal file
48
classes/events_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/fonoteka/plugins/golem15/user/models"
|
||||
"git.golem15.com/golem15/summercms/festival"
|
||||
)
|
||||
|
||||
func TestGetApiArrayEventMerge(t *testing.T) {
|
||||
bus := festival.New()
|
||||
bus.ListenPriority[*GetApiArrayEvent]("first", 0, func(_ context.Context, e *GetApiArrayEvent) error {
|
||||
e.Collected()["organisation_id"] = 1
|
||||
e.Collected()["kept"] = "a"
|
||||
return nil
|
||||
})
|
||||
bus.ListenPriority[*GetApiArrayEvent]("second", 0, func(_ context.Context, e *GetApiArrayEvent) error {
|
||||
e.Collected()["organisation_id"] = 2
|
||||
return nil
|
||||
})
|
||||
got, err := bus.Collect(context.Background(), &GetApiArrayEvent{User: &models.User{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["organisation_id"] != 2 {
|
||||
t.Fatalf("later listener lost: %#v", got["organisation_id"])
|
||||
}
|
||||
if got["kept"] != "a" {
|
||||
t.Fatalf("non-colliding key dropped: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterEventFires(t *testing.T) {
|
||||
bus := festival.New()
|
||||
var seen *models.User
|
||||
bus.Listen[*RegisterEvent]("golem15.user", func(_ context.Context, e *RegisterEvent) error {
|
||||
seen = e.User
|
||||
return nil
|
||||
})
|
||||
user := &models.User{ID: 4, Email: "evt@example.test"}
|
||||
if err := bus.Fire(context.Background(), &RegisterEvent{User: user}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if seen == nil || seen.Email != user.Email {
|
||||
t.Fatalf("event user = %#v", seen)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -15,7 +16,10 @@ import (
|
||||
"git.golem15.com/golem15/fonoteka/plugins/golem15/user/models"
|
||||
"git.golem15.com/golem15/summercms/backpack"
|
||||
"git.golem15.com/golem15/summercms/bouncer"
|
||||
"git.golem15.com/golem15/summercms/compass"
|
||||
"git.golem15.com/golem15/summercms/lagoon"
|
||||
"git.golem15.com/golem15/summercms/postcard"
|
||||
"git.golem15.com/golem15/summercms/surf"
|
||||
"git.golem15.com/golem15/summercms/wire"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -188,13 +192,159 @@ func OAuthProviders(app *backpack.App) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// Register is filled in by the registration task. The route is declared now.
|
||||
// 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) {
|
||||
writeOpaque500(w)
|
||||
gdb, secret, ok := sessionDeps(w, app)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
allow, throttle, mode, require := registrationSettings(app)
|
||||
ip := surf.ClientIP(r, surf.TrustedProxies(appConfig(app)))
|
||||
if !allow {
|
||||
writeSafe500(w, app, "Registrations are currently disabled.")
|
||||
return
|
||||
}
|
||||
if throttle && ip != "" && registrationsFromIP(r.Context(), gdb, ip) >= 3 {
|
||||
writeSafe500(w, app, "Registration is throttled. Please try again later.")
|
||||
return
|
||||
}
|
||||
fields, err := readFields(r)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
if _, exists := fields["password_confirmation"]; !exists {
|
||||
fields["password_confirmation"] = fields["password"]
|
||||
}
|
||||
rules := (models.User{}).Rules()
|
||||
errs, err := lagoon.Validate(r.Context(), gdb, models.User{}, rules, fields, nil)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{
|
||||
"error": firstValidationMessage(errs),
|
||||
"errors": errs,
|
||||
})
|
||||
return
|
||||
}
|
||||
password := strField(fields, "password")
|
||||
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, password)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
fields["password"] = hashed
|
||||
if ip != "" {
|
||||
fields["created_ip_address"] = ip
|
||||
fields["last_ip_address"] = ip
|
||||
}
|
||||
user := &models.User{}
|
||||
production := app.Config == nil || !app.Config.Bool("app.debug")
|
||||
if err := lagoon.Fill(user, user.Fillable(), fields, production); err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
if mode == "auto" {
|
||||
user.IsActivated = true
|
||||
now := time.Now()
|
||||
user.ActivatedAt = &now
|
||||
}
|
||||
if err := gdb.WithContext(r.Context()).Create(user).Error; err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
if app.Events != nil {
|
||||
_ = app.Events.Fire(r.Context(), &classes.RegisterEvent{User: user})
|
||||
}
|
||||
ctx := r.Context()
|
||||
if mode == "user" {
|
||||
sendTemplate(ctx, app, mailName(ctx, user, "golem15.user::mail.activate"), user.Email)
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"message": "Activation email sent"})
|
||||
return
|
||||
}
|
||||
if mode == "auto" || !require {
|
||||
token, err := mintFor(app, secret, user, requestURL(r))
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
payload, err := apiArray(ctx, app, user)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"token": token, "user": payload})
|
||||
return
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{})
|
||||
}
|
||||
}
|
||||
|
||||
func registrationSettings(app *backpack.App) (allow, throttle bool, mode string, require bool) {
|
||||
allow, throttle, require = true, true, true
|
||||
mode = "auto"
|
||||
if app == nil || app.Config == nil {
|
||||
return allow, throttle, mode, require
|
||||
}
|
||||
if app.Config.Has("golem15.user.registration.allow_registration") {
|
||||
allow = app.Config.Bool("golem15.user.registration.allow_registration")
|
||||
}
|
||||
if app.Config.Has("golem15.user.registration.use_register_throttle") {
|
||||
throttle = app.Config.Bool("golem15.user.registration.use_register_throttle")
|
||||
}
|
||||
if app.Config.Has("golem15.user.activation.activate_mode") {
|
||||
if s := strings.TrimSpace(app.Config.String("golem15.user.activation.activate_mode")); s != "" {
|
||||
mode = s
|
||||
}
|
||||
}
|
||||
if app.Config.Has("golem15.user.activation.require_activation") {
|
||||
require = app.Config.Bool("golem15.user.activation.require_activation")
|
||||
}
|
||||
return allow, throttle, mode, require
|
||||
}
|
||||
|
||||
func registrationsFromIP(ctx context.Context, gdb *gorm.DB, ip string) int64 {
|
||||
var n int64
|
||||
_ = gdb.WithContext(ctx).Model(&models.User{}).
|
||||
Where("created_ip_address = ? AND created_at > ?", ip, time.Now().Add(-60*time.Minute)).
|
||||
Count(&n).Error
|
||||
return n
|
||||
}
|
||||
|
||||
func firstValidationMessage(errs map[string][]string) string {
|
||||
keys := make([]string, 0, len(errs))
|
||||
for k := range errs {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
if len(keys) == 0 || len(errs[keys[0]]) == 0 {
|
||||
return "The given data was invalid."
|
||||
}
|
||||
return errs[keys[0]][0]
|
||||
}
|
||||
|
||||
func writeSafe500(w http.ResponseWriter, app *backpack.App, message string) {
|
||||
if app != nil && app.Config != nil && app.Config.Bool("app.debug") {
|
||||
wire.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": message})
|
||||
return
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "Internal server error"})
|
||||
}
|
||||
|
||||
func appConfig(app *backpack.App) *compass.Config {
|
||||
if app == nil {
|
||||
return nil
|
||||
}
|
||||
return app.Config
|
||||
}
|
||||
|
||||
func authenticate(w http.ResponseWriter, r *http.Request, app *backpack.App) (*models.User, bool) {
|
||||
gdb, secret, ok := sessionDeps(w, app)
|
||||
if !ok {
|
||||
|
||||
224
register_test.go
Normal file
224
register_test.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"context"
|
||||
|
||||
"git.golem15.com/golem15/fonoteka/plugins/golem15/user/classes"
|
||||
"git.golem15.com/golem15/fonoteka/plugins/golem15/user/controllers"
|
||||
"git.golem15.com/golem15/fonoteka/plugins/golem15/user/models"
|
||||
"git.golem15.com/golem15/summercms/backpack"
|
||||
"git.golem15.com/golem15/summercms/postcard"
|
||||
)
|
||||
|
||||
func TestRegister(t *testing.T) {
|
||||
app, _ := sessionApp(t, "0s")
|
||||
disableRegisterThrottle(t, app)
|
||||
body := `{"email":"new-ada@example.test","password":"correct-horse","name":"Ada"}`
|
||||
rec := postJSON(t, controllers.Register(app), "/_user/api/v1/register", body)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("register %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var created map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, _ := created["token"].(string)
|
||||
if token == "" {
|
||||
t.Fatalf("body %s", rec.Body.String())
|
||||
}
|
||||
userBody, _ := created["user"].(map[string]any)
|
||||
if userBody["feedback_widget_hidden"] != false {
|
||||
t.Fatalf("feedback_widget_hidden = %#v", userBody["feedback_widget_hidden"])
|
||||
}
|
||||
fetch := bearer(t, controllers.Fetch(app), http.MethodGet, "/_user/api/v1/fetch", token)
|
||||
if fetch.Code != http.StatusOK || !strings.Contains(fetch.Body.String(), `"email":"new-ada@example.test"`) {
|
||||
t.Fatalf("fetch %d %s", fetch.Code, fetch.Body.String())
|
||||
}
|
||||
|
||||
missing := postJSON(t, controllers.Register(app), "/_user/api/v1/register", `{"password":"correct-horse"}`)
|
||||
if missing.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("missing email %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["email"]; !ok || verr["error"] == "" {
|
||||
t.Fatalf("validation body %#v", verr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterDisabled(t *testing.T) {
|
||||
app, _ := sessionApp(t, "0s")
|
||||
if err := app.Config.Set("golem15.user.registration.allow_registration", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.Config.Set("app.debug", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := postJSON(t, controllers.Register(app), "/_user/api/v1/register", `{"email":"off@example.test","password":"correct-horse"}`)
|
||||
if rec.Code != http.StatusInternalServerError || rec.Body.String() != `{"error":"Internal server error"}` {
|
||||
t.Fatalf("production %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if err := app.Config.Set("app.debug", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
debug := postJSON(t, controllers.Register(app), "/_user/api/v1/register", `{"email":"off@example.test","password":"correct-horse"}`)
|
||||
if debug.Code != http.StatusInternalServerError || debug.Body.String() != `{"error":"Registrations are currently disabled."}` {
|
||||
t.Fatalf("debug %d %s", debug.Code, debug.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterThrottle(t *testing.T) {
|
||||
app, gdb := sessionApp(t, "0s")
|
||||
ip := "203.0.113.9"
|
||||
for i := 1; i <= 3; i++ {
|
||||
addr := ip
|
||||
user := &models.User{Email: "throttle-reg-" + string(rune('a'+i-1)) + "@example.test", Password: "x", CreatedIPAddress: &addr}
|
||||
if err := gdb.Create(user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
rec := postRegister(t, app, ip, `{"email":"fourth@example.test","password":"correct-horse"}`)
|
||||
if rec.Code != http.StatusInternalServerError || rec.Body.String() != `{"error":"Internal server error"}` {
|
||||
t.Fatalf("throttled %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if err := app.Config.Set("app.debug", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
debug := postRegister(t, app, ip, `{"email":"fifth@example.test","password":"correct-horse"}`)
|
||||
if debug.Body.String() != `{"error":"Registration is throttled. Please try again later."}` {
|
||||
t.Fatalf("debug throttle %d %s", debug.Code, debug.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterUserModeMail(t *testing.T) {
|
||||
app, _ := sessionApp(t, "0s")
|
||||
disableRegisterThrottle(t, app)
|
||||
if err := app.Config.Set("golem15.user.activation.activate_mode", "user"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mail := &captureMail{}
|
||||
if err := app.Publish[postcard.Mailer](mail); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := postJSON(t, controllers.Register(app), "/_user/api/v1/register", `{"email":"en-user@example.test","password":"correct-horse","preferred_locale":"en"}`)
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != `{"message":"Activation email sent"}` {
|
||||
t.Fatalf("user mode %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(mail.got) != 1 || mail.got[0] != "golem15.user::mail.activate-en" {
|
||||
t.Fatalf("template = %#v", mail.got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterAdminMode(t *testing.T) {
|
||||
app, _ := sessionApp(t, "0s")
|
||||
disableRegisterThrottle(t, app)
|
||||
if err := app.Config.Set("golem15.user.activation.activate_mode", "admin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.Config.Set("golem15.user.activation.require_activation", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := postJSON(t, controllers.Register(app), "/_user/api/v1/register", `{"email":"admin-mode@example.test","password":"correct-horse"}`)
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != `{}` {
|
||||
t.Fatalf("admin mode %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterEvent(t *testing.T) {
|
||||
app, _ := sessionApp(t, "0s")
|
||||
disableRegisterThrottle(t, app)
|
||||
var seen string
|
||||
app.Events.Listen[*classes.RegisterEvent]("test", func(_ context.Context, e *classes.RegisterEvent) error {
|
||||
if e != nil && e.User != nil {
|
||||
seen = e.User.Email
|
||||
}
|
||||
return nil
|
||||
})
|
||||
rec := postJSON(t, controllers.Register(app), "/_user/api/v1/register", `{"email":"event@example.test","password":"correct-horse"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("register %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if seen != "event@example.test" {
|
||||
t.Fatalf("event email = %q", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterImportDirection(t *testing.T) {
|
||||
cmd := exec.Command("go", "list", "-deps", "./...")
|
||||
cmd.Dir = moduleRoot(t)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("go list: %v\n%s", err, out)
|
||||
}
|
||||
if strings.Contains(string(out), "plugins/golem15/fonoteka") {
|
||||
t.Fatal("golem15.user imports golem15.fonoteka")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterCORSPath(t *testing.T) {
|
||||
body, err := os.ReadFile(filepath.Join(appRoot(t), "config", "http.yaml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(body), "_user/api/*") {
|
||||
t.Fatal("cors paths missing _user/api/*")
|
||||
}
|
||||
}
|
||||
|
||||
func disableRegisterThrottle(t *testing.T, app *backpack.App) {
|
||||
t.Helper()
|
||||
if err := app.Config.Set("golem15.user.registration.use_register_throttle", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func postRegister(t *testing.T, app *backpack.App, ip, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, "/_user/api/v1/register", strings.NewReader(body))
|
||||
req.Host = "example.com"
|
||||
req.RemoteAddr = ip + ":9999"
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
controllers.Register(app)(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func moduleRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
return findUp(t, "go.mod")
|
||||
}
|
||||
|
||||
func appRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
return findUp(t, filepath.Join("config", "http.yaml"))
|
||||
}
|
||||
|
||||
func findUp(t *testing.T, rel string) string {
|
||||
t.Helper()
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(dir, rel)); err == nil {
|
||||
return dir
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
t.Fatalf("%s not found", rel)
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user