From 3828a65481190acf0b450e5f880cf85a14d63ad8 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 23 Oct 2026 00:09:38 +0200 Subject: [PATCH] feat: golem15.user.password.public_routes false leaves POST /login, /forgot-password and /reset-password unregistered, so they 404 like any unknown path The key defaults to true, so the route table is unchanged. False skips only those three registrations: they get the router's own 404, with no custom handler and no framework change. /register keeps allow_registration and the turnstile guard. The key is read at boot, and a value other than true or false fails boot like golem15.user.turnstile. --- README.md | 9 +- config/config.yaml | 4 + password_routes_test.go | 303 ++++++++++++++++++++++++++++++++++++++++ routes.go | 47 ++++++- 4 files changed, 358 insertions(+), 5 deletions(-) create mode 100644 password_routes_test.go diff --git a/README.md b/README.md index 897dca6..3a77b24 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap - **Registration and activation.** `POST /register`, `POST /activate`, `POST /activate-by-code`. Registration can be switched off and throttled; `activate_mode` is `auto`, `user` (a mailed code) or `admin`, with codes from `IssueActivationCode` and `VerifyActivationCode`. A `/register` body sets only the `User.RegisterFillable` columns: `name`, `surname`, `email`, `password` (stored as its hash), `preferred_locale` and `marketing_consent`. It cannot set `organisation_id` or `organisation_role` (membership is the administrator's or the application's), `is_onboarded`, `must_change_password`, or the activation, reset, address proof, handle and permission columns; other keys are ignored (they still reach `RegisterEvent.Payload`). `created_ip_address` and `last_ip_address` are server-set from the client address only. With `require_activation` on (the default), `POST /login` refuses an account that is not activated with the same 401 invalid-login answer as a wrong password and counts the attempt for the login throttle. Such an account is activated by the mailed code (`POST /activate-by-code` answers with a token), by the invitation link or by an administrator. `POST /activate` serves the sessions of not-activated accounts, which exist when `require_activation` is off. `Login` checks in this order: a ban or an active suspension (`RejectIfThrottled`), the password, the activation, then the deactivation restore. - **JWT sessions.** `POST /login`, `GET /fetch`, `POST /refresh`, `POST /logout`. Boot registers the `jwt` guard with the framework's `bouncer.Registry` and exposes it as the `jwt.auth` middleware for other plugins. The guard reads the session cookies `classes.SessionCookieNames()` lists and resolves the published `bouncer.BlacklistStore` on every request. Boot installs the plugin's GORM hooks (`classes.RegisterHooks`), publishes the Postgres `jwt_blacklist` store (`bouncer.NewPostgresBlacklist`) and starts its background sweep, which prunes the blacklist, through `lagoon.OnDatabase`. Under `summer serve`, which publishes the database after Boot, they take effect when `lagoon.Publish` runs, before the router is built; when the database is already published they take effect at once. While no store is published, no token counts as blacklisted. `POST /logout` ends the session however the token travels: the bearer header, or only the `token` or `auth_token` cookie the guard also reads. Every session token the request carries goes to the Postgres `jwt_blacklist` until its refresh window closes (the later of `exp` and `iat` plus `refresh_ttl`, plus a minute), so the guard refuses it on the next request and `POST /refresh` cannot revive it after the access token expired; a token whose access lifetime passed is revoked too while it could still be refreshed. The answer, a 401 included, expires the `auth.cookie.*` token cookie (the one `golem15.sociallogin`'s `token_cookie` sets), and any other session cookie the request carries, with that cookie's path, domain, SameSite and Secure. `POST /refresh` loads the token's user and refuses (401, `Could not refresh token`, `User not found`) a deactivated or banned user and a token issued before the user's `tokens_valid_after`; a suspension alone does not stop a refresh. `GET /fetch`, `POST /refresh`, `POST /update` and `POST /change-password`, like the plugin's other signed-in routes, read the session token as the `jwt` guard does: the bearer header first, then the `token` and `auth_token` cookies, so a cookie-only session works on them and a bearer header wins when both are sent. A `POST /refresh` whose token came from the `auth.cookie.*` cookie (`auth.cookie.name`, default `token`) with no bearer header gets that cookie back with the rotated token: the cookie has the `bouncer.LoadCookieConfig` attributes, HttpOnly and Max-Age `refresh_ttl`, and the answer is `{}`, so the token never reaches JavaScript. This holds whoever wrote the cookie (this plugin's `golem15.user.jwt.token_cookie`, `golem15.sociallogin`'s `token_cookie` or the application); a frontend that writes the `token` cookie itself and refreshes through it gets it back HttpOnly, without a body token, so such a frontend sends the bearer header to keep rotating in the body. A refresh carried by the bearer header (a cookie sent with it included) or by the other session cookie answers `{"token": …}` and writes no cookie; the old token is blacklisted after `golem15.user.jwt.blacklist_grace`, so that caller stores the new token itself. A refused refresh writes and forgets no cookie. When `bouncer.LoadCookieConfig` refuses the `auth.cookie` block, a cookie-carried refresh answers the opaque 500 before anything is rotated; a bearer refresh never reads that block. Keep `auth.cookie.name` at `token` or `auth_token`, the cookies the guard reads. With `golem15.user.jwt.token_cookie` on, `POST /login`, `POST /register` (when it signs the new account in) and `POST /activate-by-code` set that HttpOnly session cookie (`bouncer.LoadCookieConfig` attributes, Max-Age `refresh_ttl`) and leave `token` out of the body; `user`, and `message` on activate-by-code, stay. With it off (the default) the token is in the body and no cookie is set. SameSite stays the `auth.cookie` setting (`lax` by default) and there is no CSRF token, so do not set `same_site: none` for this cookie. With the key on, a broken `auth.cookie` block answers those three routes with the opaque 500 before they sign in, create or activate anything. Personal API tokens (`POST /tokens`) are not sessions and are unaffected. A cookie-carried session relies on the cookie's SameSite attribute against cross-site requests (the `auth.cookie` default is `lax`), as on the `jwt.auth` routes. -- **Password reset and change.** `POST /forgot-password` answers the same body for known and unknown addresses; `POST /reset-password` consumes a code from `IssueResetCode`. `POST /change-password` requires the current password. +- **Password reset and change.** `POST /forgot-password` answers the same body for known and unknown addresses; `POST /reset-password` consumes a code from `IssueResetCode`. `POST /change-password` requires the current password. With `golem15.user.password.public_routes` false, `POST /login`, `POST /forgot-password` and `POST /reset-password` are not registered, so they answer the router's own 404 like any unknown path (any method, so no 405 shows they exist), while the `controllers` handlers stay exported. - **Must-change-password lock.** The `user:require-password-change` console command flags an account. The flag rides on the authenticated principal (`bouncer.Principal.MustChangePassword`) for guards and other plugins to act on, and a successful `POST /change-password` clears it. - **Login throttling.** Failed logins are counted per user and client address in `user_throttle` (`CheckAndRecordLogin`, `RejectIfThrottled`) and suspend the account for a configured time. The client address is `surf.ClientIP` with the framework's `http.trusted_proxies`, as for the `/register` IP limit and the `user-api` rate limit: behind a listed reverse proxy it is the `X-Forwarded-For` client, otherwise the connection address, and an `X-Forwarded-For` header from any other peer is ignored. A deployment behind a proxy lists the proxy's address in `http.trusted_proxies`; otherwise every visitor shares the proxy's counter. - **Rate limiting.** Every route runs behind the `user-api` bucket: 120 requests a minute per authenticated user, or per client IP for anonymous calls. @@ -110,6 +110,8 @@ All routes are mounted under `/_user/api/v1` with the `throttle:user-api` middle `/fetch`, `/refresh`, `/update`, `/change-password`, `/activate`, `/marketing-consent`, `/avatar`, `/avatar/remove` and the `jwt.auth` routes read the session token from the bearer header, then the `token` and `auth_token` cookies. +With `golem15.user.password.public_routes` false the `/login`, `/forgot-password` and `/reset-password` rows are not registered. + ### Host-mounted OAuth identities The host application mounts these constructors on a path it chooses. They are not registered under `/_user/api/v1`. `OAuthIdentitiesOptions.WriteNotFound` is the host's 404 writer so missing, foreign and unknown providers share one body. `OAuthIdentitiesOptions.Providers` is the unlink allow-list; when empty it is read on every request from a published `classes.OAuthProviderSource` (its `KnownOAuthProviders`), else it is `google`, `facebook`, `github` and `microsoft`. Unlinking follows the PHP rule: it succeeds when the user has a self-set password (`has_self_set_password`) or another identity remains, and otherwise answers 409 with the phrase `golem15.user::lang.oauth.last_method_blocked` (body unchanged). The guarded delete runs in a transaction that first locks the user's `users` row, so two concurrent unlinks never remove the last sign-in method. @@ -222,9 +224,10 @@ Defaults live in `config/config.yaml` and are merged under `golem15.user.*`. Any | `golem15.user.throttle.use_throttle` | `true` | Whether login throttling is on. | | `golem15.user.password.bcrypt_cost` | `10` | bcrypt cost for new password hashes. | | `golem15.user.password.min_length` | `8` | Minimum password length. | +| `golem15.user.password.public_routes` | `true` | When `false`, `POST /login`, `POST /forgot-password` and `POST /reset-password` are not registered and answer 404 like any unknown path; `POST /register` (still gated by `allow_registration` and guarded by `golem15.user.turnstile`) and every other route stay. It is read when the routes are built, so a change needs a restart. It takes `true` or `false`, also as a string (`SUMMER_GOLEM15__USER__PASSWORD__PUBLIC_ROUTES=false`); an empty value means `true`; any other value fails boot. | | `golem15.user.api_tokens.prefix` | `usr_` | Prefix for newly minted and accepted shared API-token secrets. Override an existing application's legacy prefix without creating a plugin-specific token table. | | `golem15.user.privileged_groups` | `[admin]` | Codes of the privileged user groups. Membership of such a group, the group's code and its delete, and the email, password and permanent delete of its members need `golem15.users.manage_privileged_groups`. Read on every request. An empty list protects no group. | -| `golem15.user.turnstile` | `""` | Cloudflare Turnstile on `POST /login` and `POST /register`. Empty adds nothing; `open` or `closed` appends the framework's `turnstile:open` or `turnstile:closed` middleware to those two routes only (summercms v0.3.0 and later; secret in `turnstile.secret`, token in the `turnstile_token` field). `open` lets requests pass when Cloudflare is unreachable, `closed` refuses them. Any other value fails boot. | +| `golem15.user.turnstile` | `""` | Cloudflare Turnstile on `POST /login` and `POST /register` (only `/register` while `golem15.user.password.public_routes` is false). Empty adds nothing; `open` or `closed` appends the framework's `turnstile:open` or `turnstile:closed` middleware to those two routes only (summercms v0.3.0 and later; secret in `turnstile.secret`, token in the `turnstile_token` field). `open` lets requests pass when Cloudflare is unreachable, `closed` refuses them. Any other value fails boot. | ## CLI commands @@ -274,6 +277,8 @@ go test ./... # full run, starts Postgres with testcontainers (needs D `TestTokenCookie` covers `golem15.user.jwt.token_cookie`: off by default (body tokens, no cookie); on for `POST /login`, `POST /register` and `POST /activate-by-code`, with the session cookie serving `/fetch` and rotating through a cookie refresh and no `token` in the body; `POST /tokens` unaffected; a configured cookie name and refresh window; and a broken `auth.cookie` block that answers the opaque 500 and signs in, creates and activates nothing. +`TestPasswordPublicRoutes` (in `-short`) and `TestPasswordPublicRoutesAnswer404` cover `golem15.user.password.public_routes`: unset, `true` and the `config.yaml` default register every route; `false` (also from a `SUMMER_` variable) drops exactly the three password routes and keeps every other route and its middleware, the turnstile on `/register` included; an invalid value fails `Routes`. Through the assembled router the dropped routes (and a `GET /login`) answer exactly like an unknown path and sign in, mail and reset nothing, while the default answers are unchanged and `/register`, `/fetch`, `/refresh` and `/logout` still work. + `TestAdminOrganisationMembershipClearsRole` checks that the members manager's unlink and link and a Users form save that changes the organisation clear `organisation_role`, that a save keeping the organisation keeps it, and that deleting an organisation clears both columns. The admin tests (`TestAdmin*`) boot the plugin with the admin API mounted on a database of their own and sign in backend administrators with a chosen permission set; the helpers are in `admin_harness_test.go`. diff --git a/config/config.yaml b/config/config.yaml index a994735..cfca0f8 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -25,6 +25,10 @@ throttle: password: bcrypt_cost: 10 min_length: 8 + # false leaves POST /login, /forgot-password and /reset-password + # unregistered, so they 404 like any unknown path; /register and the other + # routes stay. Read at boot (a change needs a restart). Takes true or false. + public_routes: true api_tokens: prefix: usr_ privileged_groups: diff --git a/password_routes_test.go b/password_routes_test.go new file mode 100644 index 0000000..767c97d --- /dev/null +++ b/password_routes_test.go @@ -0,0 +1,303 @@ +package user + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "git.golem15.com/golem15/sm-user-plugin/classes" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/compass" +) + +const passwordPublicRoutesKey = "golem15.user.password.public_routes" + +// passwordRouteKeys are the registrations golem15.user.password.public_routes +// false leaves out. +var passwordRouteKeys = map[string]bool{ + http.MethodPost + " /_user/api/v1/login": true, + http.MethodPost + " /_user/api/v1/forgot-password": true, + http.MethodPost + " /_user/api/v1/reset-password": true, +} + +// recordRoutesOn records the routes the plugin registers on app and returns +// them with the Routes error. +func recordRoutesOn(t *testing.T, app *backpack.App) ([]recordedRoute, error) { + t.Helper() + rec := newRecordingRouter() + err := (&Plugin{app: app}).Routes(rec) + return *rec.routes, err +} + +// passwordRoutesApp is a bareApp with golem15.user.password.public_routes set to value. +func passwordRoutesApp(t *testing.T, value any) *backpack.App { + t.Helper() + app := bareApp(t) + if err := app.Config.Set(passwordPublicRoutesKey, value); err != nil { + t.Fatal(err) + } + return app +} + +// withoutPasswordRoutes returns routes without POST /login, /forgot-password and /reset-password. +func withoutPasswordRoutes(routes []recordedRoute) []recordedRoute { + out := make([]recordedRoute, 0, len(routes)) + for _, route := range routes { + if !passwordRouteKeys[route.method+" "+route.path] { + out = append(out, route) + } + } + return out +} + +// assertSameRoutes requires got to equal want route by route (method, path, middleware, order). +func assertSameRoutes(t *testing.T, got, want []recordedRoute) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("%d routes, want %d", len(got), len(want)) + } + for i := range got { + g, w := got[i], want[i] + if g.method != w.method || g.path != w.path { + t.Fatalf("route %d is %s %s, want %s %s", i, g.method, g.path, w.method, w.path) + } + if strings.Join(g.middleware, ",") != strings.Join(w.middleware, ",") { + t.Errorf("%s %s middleware %v, want %v", g.method, g.path, g.middleware, w.middleware) + } + } +} + +// assertLikeUnknown requires a 404 with the status, body and Content-Type of unknown and no cookie. +func assertLikeUnknown(t *testing.T, got, unknown *httptest.ResponseRecorder) { + t.Helper() + if unknown.Code != http.StatusNotFound { + t.Fatalf("unknown path = %d %s, want 404", unknown.Code, unknown.Body.String()) + } + if got.Code != unknown.Code || got.Body.String() != unknown.Body.String() { + t.Fatalf("answer %d %q, want %d %q", got.Code, got.Body.String(), unknown.Code, unknown.Body.String()) + } + if g, u := got.Header().Get("Content-Type"), unknown.Header().Get("Content-Type"); g != u { + t.Fatalf("Content-Type %q, want %q", g, u) + } + assertNoCookie(t, got) +} + +// TestPasswordPublicRoutes pins golem15.user.password.public_routes on the +// route table: unset, true and the config.yaml default register every route; +// false drops exactly POST /login, /forgot-password and /reset-password and +// nothing else, the turnstile guard staying on /register; an invalid value +// fails Routes naming the key. +func TestPasswordPublicRoutes(t *testing.T) { + base, err := recordRoutesOn(t, bareApp(t)) + if err != nil { + t.Fatalf("unset: Routes error %v", err) + } + if len(base) != 18 { + t.Fatalf("unset: %d routes, want 18", len(base)) + } + seen := 0 + for _, route := range base { + if passwordRouteKeys[route.method+" "+route.path] { + seen++ + } + } + if seen != len(passwordRouteKeys) { + t.Fatalf("unset: found %d of the password routes, want %d", seen, len(passwordRouteKeys)) + } + off := withoutPasswordRoutes(base) + if len(off) != 15 { + t.Fatalf("base without the password routes has %d routes, want 15", len(off)) + } + + for _, c := range []struct { + name string + value any + want []recordedRoute + }{ + {name: "true", value: true, want: base}, + {name: "true string", value: "true", want: base}, + {name: "upper-case true string", value: "TRUE", want: base}, + {name: "empty means default", value: "", want: base}, + {name: "false", value: false, want: off}, + {name: "false string", value: "false", want: off}, + {name: "padded false string", value: " false ", want: off}, + {name: "mixed-case false string", value: "False", want: off}, + } { + t.Run(c.name, func(t *testing.T) { + got, err := recordRoutesOn(t, passwordRoutesApp(t, c.value)) + if err != nil { + t.Fatalf("Routes error %v", err) + } + assertSameRoutes(t, got, c.want) + }) + } + + t.Run("config.yaml default", func(t *testing.T) { + app := bareApp(t) + if err := app.Config.MergePlugin("golem15.user", configFS); err != nil { + t.Fatal(err) + } + if v, ok := app.Config.Lookup(passwordPublicRoutesKey); !ok || v != true { + t.Fatalf("config.yaml default = %#v (set %v), want true", v, ok) + } + got, err := recordRoutesOn(t, app) + if err != nil { + t.Fatalf("Routes error %v", err) + } + assertSameRoutes(t, got, base) + }) + + t.Run("env false", func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: user-bare\n"), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{ + "SUMMER_ENV=development", + "SUMMER_GOLEM15__USER__PASSWORD__PUBLIC_ROUTES=false", + }}) + if err != nil { + t.Fatal(err) + } + got, err := recordRoutesOn(t, backpack.New(cfg)) + if err != nil { + t.Fatalf("Routes error %v", err) + } + assertSameRoutes(t, got, off) + }) + + t.Run("false with turnstile closed", func(t *testing.T) { + app := passwordRoutesApp(t, false) + if err := app.Config.Set("golem15.user.turnstile", "closed"); err != nil { + t.Fatal(err) + } + got, err := recordRoutesOn(t, app) + if err != nil { + t.Fatalf("Routes error %v", err) + } + want := make([]recordedRoute, len(off)) + for i, route := range off { + want[i] = route + if route.method == http.MethodPost && route.path == "/_user/api/v1/register" { + want[i].middleware = append(append([]string{}, route.middleware...), "turnstile:closed") + } + } + assertSameRoutes(t, got, want) + for _, route := range got { + if names := turnstileOf(route); len(names) > 0 && route.path != "/_user/api/v1/register" { + t.Errorf("%s %s carries %v", route.method, route.path, names) + } + } + }) + + t.Run("invalid", func(t *testing.T) { + for _, bad := range []any{"nope", "yes", "1", "0", "t", "f", 0} { + if _, err := recordRoutesOn(t, passwordRoutesApp(t, bad)); err == nil || !strings.Contains(err.Error(), passwordPublicRoutesKey) { + t.Errorf("%#v: error %v, want one naming %s", bad, err, passwordPublicRoutesKey) + } + } + }) +} + +// TestPasswordPublicRoutesAnswer404 pins that, with +// golem15.user.password.public_routes false, the dropped routes answer +// exactly like an unknown path through the assembled router, while the +// default answers are unchanged and /register, /fetch, /refresh and /logout +// keep working. +func TestPasswordPublicRoutesAnswer404(t *testing.T) { + const unknownPath = "/_user/api/v1/no-such-route" + + t.Run("default answers", func(t *testing.T) { + e := newAdminEnv(t, tokenCookieSettings(t, nil)) + if v, ok := e.app.Config.Lookup(passwordPublicRoutesKey); !ok || v != true { + t.Fatalf("merged default = %#v (set %v), want true", v, ok) + } + if rec := e.call(t, http.MethodPost, unknownPath, "", map[string]string{}); rec.Code != http.StatusNotFound { + t.Fatalf("unknown path = %d %s, want 404", rec.Code, rec.Body.String()) + } + user := e.seedUser(t, "public-routes-default", nil) + if rec := e.call(t, http.MethodPost, userLoginPath, "", map[string]string{"email": user.Email, "password": "wrong-horse"}); rec.Code != http.StatusUnauthorized || rec.Body.String() != invalidLoginBody { + t.Fatalf("login = %d %s, want 401 %s", rec.Code, rec.Body.String(), invalidLoginBody) + } + rec := e.call(t, http.MethodPost, "/_user/api/v1/forgot-password", "", map[string]string{"email": user.Email}) + if rec.Code != http.StatusOK { + t.Fatalf("forgot-password = %d %s, want 200", rec.Code, rec.Body.String()) + } + assertBodyKeys(t, rec, "message") + if rec := e.call(t, http.MethodPost, "/_user/api/v1/reset-password", "", map[string]string{}); rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("reset-password = %d %s, want 422", rec.Code, rec.Body.String()) + } + if rec := e.call(t, http.MethodGet, userLoginPath, "", nil); rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("GET login = %d %s, want 405", rec.Code, rec.Body.String()) + } + }) + + e := newAdminEnv(t, tokenCookieSettings(t, map[string]any{passwordPublicRoutesKey: false})) + + t.Run("off answers like an unknown path", func(t *testing.T) { + unknown := e.call(t, http.MethodPost, unknownPath, "", map[string]string{}) + unknownGet := e.call(t, http.MethodGet, unknownPath, "", nil) + user := e.seedUser(t, "public-routes-off", nil) + + login := e.call(t, http.MethodPost, userLoginPath, "", map[string]string{"email": user.Email, "password": "correct-horse"}) + assertLikeUnknown(t, login, unknown) + + forgot := e.call(t, http.MethodPost, "/_user/api/v1/forgot-password", "", map[string]string{"email": user.Email}) + assertLikeUnknown(t, forgot, unknown) + + code, err := classes.IssueResetCode(t.Context(), e.gdb, user) + if err != nil { + t.Fatal(err) + } + reset := e.call(t, http.MethodPost, "/_user/api/v1/reset-password", "", map[string]string{ + "code": userSub(user) + "!" + code, + "password": "another-horse", + "password_confirmation": "another-horse", + }) + assertLikeUnknown(t, reset, unknown) + + assertLikeUnknown(t, e.call(t, http.MethodGet, userLoginPath, "", nil), unknownGet) + + if got := e.reload(t, user.ID); got.LastSeen != nil { + t.Fatalf("last_seen stamped %v", *got.LastSeen) + } + if got := e.reload(t, user.ID); got.Password != user.Password { + t.Fatal("the password changed") + } + if sent := e.mails(user.Email); len(sent) != 0 { + t.Fatalf("mails sent %#v", sent) + } + }) + + t.Run("off keeps the rest", func(t *testing.T) { + rec := e.call(t, http.MethodPost, userRegisterPath, "", registerBody("public-routes-register")) + if rec.Code != http.StatusOK { + t.Fatalf("register = %d %s", rec.Code, rec.Body.String()) + } + body := assertBodyKeys(t, rec, "token", "user") + token, _ := body["token"].(string) + if token == "" { + t.Fatalf("register token %#v", body["token"]) + } + if code := e.fetchCode(t, token); code != http.StatusOK { + t.Fatalf("fetch = %d", code) + } + refreshed := e.refresh(t, token) + if refreshed.Code != http.StatusOK { + t.Fatalf("refresh = %d %s", refreshed.Code, refreshed.Body.String()) + } + rotated, _ := assertBodyKeys(t, refreshed, "token")["token"].(string) + if rotated == "" { + t.Fatal("refresh returned no token") + } + if rec := e.call(t, http.MethodPost, "/_user/api/v1/logout", rotated, nil); rec.Code != http.StatusOK { + t.Fatalf("logout = %d %s", rec.Code, rec.Body.String()) + } + if rec := e.call(t, http.MethodGet, "/_user/api/v1/oauth-providers", "", nil); rec.Code != http.StatusOK { + t.Fatalf("oauth-providers = %d %s", rec.Code, rec.Body.String()) + } + }) +} diff --git a/routes.go b/routes.go index a89d712..d42c780 100644 --- a/routes.go +++ b/routes.go @@ -14,15 +14,23 @@ func (p *Plugin) Routes(r pact.Router) error { if err != nil { return err } + public, err := p.publicPasswordRoutes() + if err != nil { + return err + } r.Group("/_user/api/v1", surf.Use("throttle:user-api"), func(g pact.Router) { - g.Post("/login", controllers.Login(p.app), guard...) + if public { + g.Post("/login", controllers.Login(p.app), guard...) + } g.Post("/logout", controllers.Logout(p.app)) g.Get("/fetch", controllers.Fetch(p.app)) g.Post("/refresh", controllers.Refresh(p.app)) g.Post("/register", controllers.Register(p.app), guard...) g.Get("/oauth-providers", controllers.OAuthProviders(p.app)) - g.Post("/forgot-password", controllers.ForgotPassword(p.app)) - g.Post("/reset-password", controllers.ResetPassword(p.app)) + if public { + 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)) g.Post("/update", controllers.Update(p.app)) @@ -56,3 +64,36 @@ func (p *Plugin) turnstileGuard() ([]string, error) { return nil, fmt.Errorf("golem15.user: golem15.user.turnstile must be empty, open or closed, got %q", mode) } } + +// publicPasswordRoutes reports golem15.user.password.public_routes, which +// decides whether POST /login, /forgot-password and /reset-password are +// registered. The default is true. False leaves them unregistered, so they +// answer the router's own 404 like any unknown path. The key is read when the +// routes are built, so a change needs a restart. It takes a bool or the +// string true or false (any case, surrounding spaces trimmed; empty or unset +// means true); any other value is refused so a typo cannot silently remove +// (or keep) the sign-in routes. +func (p *Plugin) publicPasswordRoutes() (bool, error) { + if p.app == nil || p.app.Config == nil { + return true, nil + } + const key = "golem15.user.password.public_routes" + raw, ok := p.app.Config.Lookup(key) + if !ok || raw == nil { + return true, nil + } + switch v := raw.(type) { + case bool: + return v, nil + case string: + switch s := strings.TrimSpace(v); { + case s == "", strings.EqualFold(s, "true"): + return true, nil + case strings.EqualFold(s, "false"): + return false, nil + } + return false, fmt.Errorf("golem15.user: %s must be true or false, got %q", key, v) + default: + return false, fmt.Errorf("golem15.user: %s must be true or false, got %v", key, v) + } +}