PHP's device flow on golem15_user_device_auth_sessions (PHP's columns,
the token stored as its SHA-256, plus jwt_jti): POST /devices/initiate
answers a 64-character token, an XXXX-XXXX short code, the configurable
auth_url (golem15.user.device_auth.auth_url) and the expiry; a signed-in
user authorizes it with POST /devices/authorize (short_code or token,
one guarded pending-to-confirmed UPDATE); the device's first poll of
GET /devices/status/{token} after that receives a session JWT through one
guarded confirmed-to-used UPDATE, so exactly one poll gets it. Used and
revoked sessions never turn into expired; each initiate deletes a bounded
batch of stale sessions.
111 lines
4.3 KiB
Go
111 lines
4.3 KiB
Go
package user
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"git.golem15.com/golem15/sm-user-plugin/controllers"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/surf"
|
|
)
|
|
|
|
func (p *Plugin) Routes(r pact.Router) error {
|
|
guard, err := p.turnstileGuard()
|
|
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) {
|
|
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))
|
|
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))
|
|
g.Post("/change-password", controllers.ChangePassword(p.app))
|
|
g.Post("/marketing-consent", controllers.MarketingConsent(p.app))
|
|
g.Post("/avatar", controllers.UploadAvatar(p.app))
|
|
g.Post("/avatar/remove", controllers.RemoveAvatar(p.app))
|
|
g.Post("/tokens", controllers.APITokenStore(p.app), "jwt.auth")
|
|
g.Get("/tokens", controllers.APITokenIndex(p.app), "jwt.auth")
|
|
g.Get("/tokens/scopes", controllers.APITokenScopes(p.app), "jwt.auth")
|
|
g.Delete("/tokens/{id}", controllers.APITokenDestroy(p.app), "jwt.auth")
|
|
g.Where("id", "[0-9]+")
|
|
g.Get("/backend-link/{code}", controllers.BackendLinkShow(p.app))
|
|
g.Where("code", "[a-f0-9]{64}")
|
|
g.Post("/backend-link/show", controllers.BackendLinkShowPost(p.app))
|
|
g.Post("/backend-link/confirm", controllers.BackendLinkConfirm(p.app), "jwt.auth")
|
|
g.Get("/token/me", controllers.TokenMe(p.app), "user.api_token", "user.scope:read")
|
|
// Device authorization (QR and short code). The routes authenticate
|
|
// inside the handlers, as PHP's controller does.
|
|
g.Post("/devices/initiate", controllers.DeviceInitiate(p.app))
|
|
g.Get("/devices/status/{token}", controllers.DeviceStatus(p.app))
|
|
g.Post("/devices/authorize", controllers.DeviceAuthorize(p.app))
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// turnstileGuard returns the middleware that golem15.user.turnstile adds to
|
|
// POST /login and /register. The key is opt-in: empty (the default) adds
|
|
// nothing, "open" and "closed" select the framework's turnstile:{mode}
|
|
// middleware (summercms v0.3.0 and later), and any other value is refused so
|
|
// a typo cannot silently leave the forms unprotected.
|
|
func (p *Plugin) turnstileGuard() ([]string, error) {
|
|
if p.app == nil || p.app.Config == nil {
|
|
return nil, nil
|
|
}
|
|
switch mode := strings.TrimSpace(p.app.Config.String("golem15.user.turnstile")); mode {
|
|
case "":
|
|
return nil, nil
|
|
case "open", "closed":
|
|
return []string{"turnstile:" + mode}, nil
|
|
default:
|
|
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)
|
|
}
|
|
}
|