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.
This commit is contained in:
Jakub Zych
2026-10-23 00:09:38 +02:00
parent 8bdf2c7d1a
commit 26548c710f
4 changed files with 358 additions and 5 deletions

View File

@@ -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`.

View File

@@ -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:

303
password_routes_test.go Normal file
View File

@@ -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())
}
})
}

View File

@@ -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)
}
}