feat(master): OIDC login with group check, sessions and CSRF protection

This commit is contained in:
2026-10-12 14:54:11 +02:00
parent 049324b2f4
commit b4894db6d7
5 changed files with 505 additions and 0 deletions
+184
View File
@@ -0,0 +1,184 @@
// 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 || 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.FormValue("csrf_token")
}
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[:])
}