- inventory: resolve the image reference via ContainerInspect when a moved tag makes ContainerList report the image ID - store: allow reusing a revoked agent's name (partial unique index) and mark the revoked agent's compose/container services removed - web: publish a services event on revoke; no-store on the one-time token page - webauth: reject empty OIDC state; accept the CSRF form token from the body only - agent conn: bound the WebSocket handshake to 30s - hub: log agent_replaced when a connection replaces an older one
185 lines
6.4 KiB
Go
185 lines
6.4 KiB
Go
// Package webauth logs people into the master UI through OIDC (Authentik),
|
|
// keeps their sessions, and protects state-changing requests with a CSRF
|
|
// token.
|
|
package webauth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/oauth2"
|
|
|
|
"github.com/shcizo/package-updater/internal/master/model"
|
|
)
|
|
|
|
const (
|
|
sessionCookieName = "pu_session"
|
|
flowCookieName = "pu_oidc_flow"
|
|
sessionTTL = 12 * time.Hour
|
|
)
|
|
|
|
// Store keeps sessions. *store.Store satisfies it.
|
|
type Store interface {
|
|
CreateSession(ctx context.Context, s model.Session) error
|
|
GetSession(ctx context.Context, id string, now time.Time) (model.Session, error)
|
|
DeleteSession(ctx context.Context, id string) error
|
|
}
|
|
|
|
// Auth holds the login handlers and the session middleware.
|
|
type Auth struct {
|
|
provider Provider
|
|
store Store
|
|
allowedGroup string
|
|
secure bool
|
|
log *slog.Logger
|
|
now func() time.Time
|
|
}
|
|
|
|
// New returns an Auth. Only members of allowedGroup may log in. secureCookies
|
|
// should be true whenever the UI is served over HTTPS.
|
|
func New(p Provider, s Store, allowedGroup string, secureCookies bool, log *slog.Logger) *Auth {
|
|
return &Auth{provider: p, store: s, allowedGroup: allowedGroup, secure: secureCookies, log: log, now: time.Now}
|
|
}
|
|
|
|
// Login starts the OIDC flow. State and PKCE verifier travel in a short-lived
|
|
// cookie scoped to /auth/; tampering with it can only break the user's own
|
|
// login.
|
|
func (a *Auth) Login(w http.ResponseWriter, r *http.Request) {
|
|
state := randomString()
|
|
verifier := oauth2.GenerateVerifier()
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: flowCookieName, Value: state + "." + verifier, Path: "/auth/",
|
|
MaxAge: 600, HttpOnly: true, Secure: a.secure, SameSite: http.SameSiteLaxMode,
|
|
})
|
|
http.Redirect(w, r, a.provider.AuthCodeURL(state, verifier), http.StatusFound)
|
|
}
|
|
|
|
// Callback finishes the OIDC flow, checks group membership and creates the
|
|
// session.
|
|
func (a *Auth) Callback(w http.ResponseWriter, r *http.Request) {
|
|
flow, err := r.Cookie(flowCookieName)
|
|
if err != nil {
|
|
http.Error(w, "login flow expired, start again", http.StatusBadRequest)
|
|
return
|
|
}
|
|
state, verifier, ok := strings.Cut(flow.Value, ".")
|
|
if !ok || state == "" || subtle.ConstantTimeCompare([]byte(r.URL.Query().Get("state")), []byte(state)) != 1 {
|
|
http.Error(w, "invalid login state", http.StatusBadRequest)
|
|
return
|
|
}
|
|
http.SetCookie(w, &http.Cookie{Name: flowCookieName, Path: "/auth/", MaxAge: -1})
|
|
|
|
claims, err := a.provider.Exchange(r.Context(), r.URL.Query().Get("code"), verifier)
|
|
if err != nil {
|
|
a.log.Warn("login_failed", "error", err)
|
|
http.Error(w, "login failed", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if !slices.Contains(claims.Groups, a.allowedGroup) {
|
|
a.log.Warn("login_forbidden", "subject", claims.Subject, "required_group", a.allowedGroup)
|
|
http.Error(w, "forbidden: you are not a member of the group allowed to use package-updater", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
sess := model.Session{
|
|
ID: randomString(), Subject: claims.Subject, Name: claims.Name, Email: claims.Email,
|
|
CSRFToken: randomString(), ExpiresAt: a.now().Add(sessionTTL),
|
|
}
|
|
if err := a.store.CreateSession(r.Context(), sess); err != nil {
|
|
a.log.Error("create_session_failed", "error", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookieName, Value: sess.ID, Path: "/", Expires: sess.ExpiresAt,
|
|
HttpOnly: true, Secure: a.secure, SameSite: http.SameSiteLaxMode,
|
|
})
|
|
a.log.Info("login", "subject", claims.Subject, "name", claims.Name)
|
|
http.Redirect(w, r, "/", http.StatusFound)
|
|
}
|
|
|
|
// Logout ends the session. Mount behind Require so it is CSRF-protected.
|
|
func (a *Auth) Logout(w http.ResponseWriter, r *http.Request) {
|
|
if s, ok := SessionFrom(r.Context()); ok {
|
|
if err := a.store.DeleteSession(r.Context(), s.ID); err != nil {
|
|
a.log.Error("delete_session_failed", "error", err)
|
|
}
|
|
}
|
|
http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Path: "/", MaxAge: -1})
|
|
_, _ = w.Write([]byte("Logged out."))
|
|
}
|
|
|
|
// Require lets a request through only with a valid session, and for
|
|
// anything but GET/HEAD only with the session's CSRF token in the
|
|
// X-CSRF-Token header or the csrf_token form field. htmx requests without a
|
|
// session get 401 + HX-Redirect so the whole page navigates to the login,
|
|
// instead of the login page being swapped into a fragment. Event-stream
|
|
// (SSE) requests without a session get a plain 401: a redirect would make
|
|
// the browser's EventSource retry /auth/login repeatedly and overwrite the
|
|
// login flow cookie of a login in progress in another tab.
|
|
func (a *Auth) Require(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
sess, err := a.session(r)
|
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
|
a.log.Error("session_lookup_failed", "error", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if err != nil {
|
|
switch {
|
|
case r.Header.Get("HX-Request") == "true":
|
|
w.Header().Set("HX-Redirect", "/auth/login")
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
case strings.Contains(r.Header.Get("Accept"), "text/event-stream"):
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
case r.Method == http.MethodGet:
|
|
http.Redirect(w, r, "/auth/login", http.StatusFound)
|
|
default:
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
}
|
|
return
|
|
}
|
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
|
token := r.Header.Get("X-CSRF-Token")
|
|
if token == "" {
|
|
token = r.PostFormValue("csrf_token") // body only: a token in the URL leaks via logs and Referer
|
|
}
|
|
if subtle.ConstantTimeCompare([]byte(token), []byte(sess.CSRFToken)) != 1 {
|
|
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
|
return
|
|
}
|
|
}
|
|
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), sessionKey{}, sess)))
|
|
})
|
|
}
|
|
|
|
func (a *Auth) session(r *http.Request) (model.Session, error) {
|
|
c, err := r.Cookie(sessionCookieName)
|
|
if err != nil {
|
|
return model.Session{}, model.ErrNotFound
|
|
}
|
|
return a.store.GetSession(r.Context(), c.Value, a.now())
|
|
}
|
|
|
|
type sessionKey struct{}
|
|
|
|
// SessionFrom returns the session Require stored in ctx.
|
|
func SessionFrom(ctx context.Context) (model.Session, bool) {
|
|
s, ok := ctx.Value(sessionKey{}).(model.Session)
|
|
return s, ok
|
|
}
|
|
|
|
func randomString() string {
|
|
var b [32]byte
|
|
_, _ = rand.Read(b[:])
|
|
return base64.RawURLEncoding.EncodeToString(b[:])
|
|
}
|