283 lines
10 KiB
Go
283 lines
10 KiB
Go
|
|
package httpapi
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"errors"
|
||
|
|
"net/http"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/urpi/go-bananas/internal/auth"
|
||
|
|
"github.com/urpi/go-bananas/internal/config"
|
||
|
|
"github.com/urpi/go-bananas/internal/domain"
|
||
|
|
"github.com/urpi/go-bananas/internal/store"
|
||
|
|
"github.com/urpi/go-bananas/internal/tokenengine"
|
||
|
|
)
|
||
|
|
|
||
|
|
// AuthHandlers serves the browser-facing authentication routes and the session
|
||
|
|
// introspection the admin UI needs.
|
||
|
|
type AuthHandlers struct {
|
||
|
|
authenticator *auth.Authenticator
|
||
|
|
// store is used only to read the caller's own interface preferences, which
|
||
|
|
// are reported alongside the session so the UI can preselect a theme from the
|
||
|
|
// same round trip that establishes who the caller is. It is nil in tests that
|
||
|
|
// do not exercise preferences.
|
||
|
|
store *store.Store
|
||
|
|
log Logger
|
||
|
|
config config.Config
|
||
|
|
}
|
||
|
|
|
||
|
|
// NewAuthHandlers builds the auth handler set.
|
||
|
|
func NewAuthHandlers(authenticator *auth.Authenticator, st *store.Store, log Logger, cfg config.Config) *AuthHandlers {
|
||
|
|
return &AuthHandlers{authenticator: authenticator, store: st, log: log, config: cfg}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Login starts the OIDC login flow.
|
||
|
|
func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) {
|
||
|
|
// Sanitised here as well as in the state store, so that the value that is
|
||
|
|
// reflected back to the provider-side cookie is already known to be local.
|
||
|
|
returnTo := auth.SafeReturnTo(r.URL.Query().Get("returnTo"))
|
||
|
|
|
||
|
|
if h.authenticator == nil || !h.authenticator.ModeConfigured() {
|
||
|
|
writeError(w, r, http.StatusNotFound, CodeNotConfigured,
|
||
|
|
"this deployment does not use interactive login", nil)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := h.authenticator.Begin(r.Context(), r, w, returnTo); err != nil {
|
||
|
|
h.log.Error("begin login", "error", err)
|
||
|
|
writeError(w, r, http.StatusBadGateway, CodeInternal,
|
||
|
|
"could not start the login flow with the identity provider", nil)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Callback completes the OIDC login flow.
|
||
|
|
func (h *AuthHandlers) Callback(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if h.authenticator == nil || !h.authenticator.ModeConfigured() {
|
||
|
|
writeError(w, r, http.StatusNotFound, CodeNotConfigured,
|
||
|
|
"this deployment does not use interactive login", nil)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
login, err := h.authenticator.Complete(r.Context(), r)
|
||
|
|
if err != nil {
|
||
|
|
// A failed login is a normal occurrence, not a server fault: the user may
|
||
|
|
// have declined consent or the state cookie may have expired. Log it and
|
||
|
|
// show a clear message rather than a stack trace.
|
||
|
|
if errors.Is(err, domain.ErrUnauthorized) {
|
||
|
|
h.log.Info("login rejected", "reason", err.Error(), "ip", clientIPFrom(r.Context()))
|
||
|
|
writeError(w, r, http.StatusUnauthorized, CodeUnauthorized,
|
||
|
|
"the login could not be completed: "+cleanError(err), nil)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
h.log.Error("complete login", "error", err)
|
||
|
|
writeError(w, r, http.StatusBadGateway, CodeInternal,
|
||
|
|
"the identity provider returned an unexpected response", nil)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// The login transaction is single-use; discard it so a replayed callback
|
||
|
|
// cannot reuse the PKCE verifier.
|
||
|
|
h.authenticator.ClearState(w)
|
||
|
|
|
||
|
|
if err := h.authenticator.IssueSession(r.Context(), w, login.Identity); err != nil {
|
||
|
|
h.log.Error("issue session", "error", err)
|
||
|
|
writeError(w, r, http.StatusInternalServerError, CodeInternal,
|
||
|
|
"could not establish a session", nil)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
h.log.Info("login succeeded",
|
||
|
|
"subject", login.Identity.Subject,
|
||
|
|
"email", login.Identity.Email,
|
||
|
|
"roles", login.Identity.Roles,
|
||
|
|
"ip", clientIPFrom(r.Context()),
|
||
|
|
)
|
||
|
|
|
||
|
|
// The destination comes from the transaction cookie, which was sanitised when
|
||
|
|
// the login began and signed, so it cannot have been influenced by whatever
|
||
|
|
// the provider chose to append to the callback URL.
|
||
|
|
//nolint:gosec // G710: the value is not the raw request parameter. It is the
|
||
|
|
// sanitised destination the signed transaction cookie carried, and SafeReturnTo
|
||
|
|
// is the only way to produce one: it rejects absolute URLs, protocol-relative
|
||
|
|
// URLs, backslashes and control characters, and falls back to "/" otherwise.
|
||
|
|
http.Redirect(w, r, auth.SafeReturnTo(login.ReturnTo), http.StatusFound)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Logout ends the session and, when an OIDC provider is configured, the provider
|
||
|
|
// session too.
|
||
|
|
func (h *AuthHandlers) Logout(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if h.authenticator != nil {
|
||
|
|
identity := mustIdentity(r.Context())
|
||
|
|
h.authenticator.ClearSession(w)
|
||
|
|
if identity.Method != "anonymous" {
|
||
|
|
h.log.Info("logout", "subject", identity.Subject, "ip", clientIPFrom(r.Context()))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if h.authenticator == nil || !h.authenticator.ProviderEnabled() {
|
||
|
|
http.Redirect(w, r, "/", http.StatusFound)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
target := h.authenticator.LogoutURL(h.config.PostLogoutRedirectURL())
|
||
|
|
if target == "" {
|
||
|
|
http.Redirect(w, r, "/", http.StatusFound)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
http.Redirect(w, r, target, http.StatusFound)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Me returns the current session, if any.
|
||
|
|
//
|
||
|
|
// It always answers 200 with an explicit authenticated flag rather than 401 when
|
||
|
|
// there is no session, because "not logged in yet" is the normal state on first
|
||
|
|
// load and is not an error the UI should have to handle as one.
|
||
|
|
func (h *AuthHandlers) Me(w http.ResponseWriter, r *http.Request) {
|
||
|
|
identity, err := identityFrom(r.Context())
|
||
|
|
authenticated := err == nil && identity != nil && identity.Method != "anonymous"
|
||
|
|
|
||
|
|
payload := map[string]any{
|
||
|
|
"authenticated": authenticated,
|
||
|
|
"mode": h.authenticator.Mode(),
|
||
|
|
}
|
||
|
|
if authenticated {
|
||
|
|
payload["identity"] = identity
|
||
|
|
payload["permissions"] = permissionsFor(identity)
|
||
|
|
// Reported with the session because the UI needs it to decide what to
|
||
|
|
// paint on the first frame. A failure is reported as no choice rather than
|
||
|
|
// as an error: a theme is a preference, not something worth refusing a
|
||
|
|
// login over.
|
||
|
|
if h.store != nil {
|
||
|
|
if prefs, err := h.store.Preferences(r.Context(), identity.Subject); err != nil {
|
||
|
|
h.log.Warn("read preferences for session", "subject", identity.Subject, "error", err)
|
||
|
|
payload["preferences"] = domain.UserPreferences{}
|
||
|
|
} else {
|
||
|
|
payload["preferences"] = prefs
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if h.authenticator != nil && h.authenticator.DevBypassEnabled() {
|
||
|
|
payload["devBypass"] = true
|
||
|
|
}
|
||
|
|
if h.authenticator != nil && h.authenticator.ProviderEnabled() {
|
||
|
|
// Deliberately this service's /auth/login rather than the provider's
|
||
|
|
// authorization endpoint. A direct link to the provider would skip the
|
||
|
|
// transaction cookie and the PKCE verifier, so the callback could not be
|
||
|
|
// validated and the login would fail.
|
||
|
|
payload["loginUrl"] = h.config.Path("/auth/login") + "?returnTo=%2F"
|
||
|
|
}
|
||
|
|
|
||
|
|
writeJSON(w, r, http.StatusOK, payload)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Config returns the client-visible configuration the UI needs before login.
|
||
|
|
func (h *AuthHandlers) Config(w http.ResponseWriter, r *http.Request) {
|
||
|
|
payload := map[string]any{
|
||
|
|
"mode": h.authenticator.Mode(),
|
||
|
|
"issuer": h.config.OIDCIssuer,
|
||
|
|
"runtimeBase": h.config.CDNBasis(),
|
||
|
|
"publicBase": h.config.Origin(),
|
||
|
|
"basePath": h.config.BasePath,
|
||
|
|
"runtimeApiVersion": tokenengine.RuntimeAPIVersion,
|
||
|
|
"variablePrefix": tokenengine.VariablePrefix,
|
||
|
|
"defaultTheme": "",
|
||
|
|
}
|
||
|
|
writeJSON(w, r, http.StatusOK, payload)
|
||
|
|
}
|
||
|
|
|
||
|
|
// permissionsFor describes what the identity may do, so the UI can hide controls
|
||
|
|
// the API would reject anyway. The server still enforces every check: this is a
|
||
|
|
// convenience, not a security boundary.
|
||
|
|
func permissionsFor(identity *domain.Identity) map[string]bool {
|
||
|
|
role := identity.HighestRole()
|
||
|
|
return map[string]bool{
|
||
|
|
"read": role.AtLeast(domain.RoleViewer),
|
||
|
|
"write": role.AtLeast(domain.RoleEditor),
|
||
|
|
"publish": role.AtLeast(domain.RoleEditor),
|
||
|
|
"delete": role.AtLeast(domain.RoleAdmin),
|
||
|
|
"manageKeys": role.AtLeast(domain.RoleAdmin),
|
||
|
|
"manageUsers": role.AtLeast(domain.RoleAdmin),
|
||
|
|
"manageSettings": role.AtLeast(domain.RoleAdmin),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// -------------------------------------------------------- health probes --
|
||
|
|
|
||
|
|
// HealthHandlers serves the liveness and readiness probes.
|
||
|
|
type HealthHandlers struct {
|
||
|
|
store *store.Store
|
||
|
|
cache *ThemeCache
|
||
|
|
version string
|
||
|
|
config config.Config
|
||
|
|
// started is used to report uptime.
|
||
|
|
started time.Time
|
||
|
|
}
|
||
|
|
|
||
|
|
// NewHealthHandlers builds the probe handlers.
|
||
|
|
func NewHealthHandlers(st *store.Store, cache *ThemeCache, version string, cfg config.Config) *HealthHandlers {
|
||
|
|
return &HealthHandlers{
|
||
|
|
store: st,
|
||
|
|
cache: cache,
|
||
|
|
version: version,
|
||
|
|
config: cfg,
|
||
|
|
started: time.Now(),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Live is the liveness probe. It answers as long as the process can serve HTTP at
|
||
|
|
// all, and deliberately does not touch the database: a liveness probe that failed
|
||
|
|
// on a slow database would have the orchestrator restart a process that is
|
||
|
|
// perfectly healthy.
|
||
|
|
func (h *HealthHandlers) Live(w http.ResponseWriter, r *http.Request) {
|
||
|
|
writeJSON(w, r, http.StatusOK, map[string]any{
|
||
|
|
"status": "ok",
|
||
|
|
"version": h.version,
|
||
|
|
"uptime": time.Since(h.started).Round(time.Second).String(),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// Ready is the readiness probe. It reports whether the service can actually serve
|
||
|
|
// traffic, which means the database must answer within a bounded time.
|
||
|
|
func (h *HealthHandlers) Ready(w http.ResponseWriter, r *http.Request) {
|
||
|
|
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
||
|
|
defer cancel()
|
||
|
|
|
||
|
|
checks := map[string]any{}
|
||
|
|
ready := true
|
||
|
|
|
||
|
|
if err := h.store.Ping(ctx); err != nil {
|
||
|
|
ready = false
|
||
|
|
checks["database"] = map[string]any{"status": "error", "error": cleanError(err)}
|
||
|
|
} else {
|
||
|
|
entry := map[string]any{"status": "ok"}
|
||
|
|
if h.store.InMemory() {
|
||
|
|
// An in-memory database is legitimate in a test or a throwaway
|
||
|
|
// container but never in production, so it is surfaced loudly rather
|
||
|
|
// than silently accepted.
|
||
|
|
entry["warning"] = "database is in-memory; all data is lost on restart"
|
||
|
|
}
|
||
|
|
checks["database"] = entry
|
||
|
|
}
|
||
|
|
|
||
|
|
if stats, err := h.store.Stats(ctx); err == nil {
|
||
|
|
checks["themes"] = map[string]any{
|
||
|
|
"status": "ok",
|
||
|
|
"count": stats.Themes,
|
||
|
|
"databaseBytes": stats.DatabaseSize,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if h.cache != nil {
|
||
|
|
checks["cache"] = map[string]any{"status": "ok", "entries": h.cache.Stats().Entries}
|
||
|
|
}
|
||
|
|
|
||
|
|
status := http.StatusOK
|
||
|
|
state := "ready"
|
||
|
|
if !ready {
|
||
|
|
status = http.StatusServiceUnavailable
|
||
|
|
state = "not ready"
|
||
|
|
}
|
||
|
|
|
||
|
|
writeJSON(w, r, status, map[string]any{"status": state, "checks": checks})
|
||
|
|
}
|