diff --git a/classes/events_test.go b/classes/events_test.go new file mode 100644 index 0000000..2a2b417 --- /dev/null +++ b/classes/events_test.go @@ -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) + } +} diff --git a/controllers/api_controller.go b/controllers/api_controller.go index adb7a32..2c465f4 100644 --- a/controllers/api_controller.go +++ b/controllers/api_controller.go @@ -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 { diff --git a/register_test.go b/register_test.go new file mode 100644 index 0000000..f9abd92 --- /dev/null +++ b/register_test.go @@ -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 + } +}