Files
Yggdrasil/internal/middleware/access.go
T
Klagarge fc1b45058c feat: shiroa book, ACL parser, and OIDC-gated Go server
- docs/: shiroa book with three example categories (thermo, security,
  homelab); pages declare access via '#let access-groups = (...)'
- cmd/parser: scans docs/ and generates acl.json (public by default,
  skips book.typ/template.typ/partials)
- internal/acl: loads acl.json, normalizes URLs to page keys so a page's
  renderer data (*.multi.sir.in) inherits its protection; fail closed on
  malformed input
- internal/oidc: authorization-code flow client, verified ID tokens,
  groups claim from Authentik
- internal/middleware: in-memory sessions, login/logout/callback handlers,
  CSRF states, access gate + security headers
- cmd/server + embed.go: single binary embedding dist/ and acl.json;
  /searchindex.json blocked; graceful shutdown
- Justfile (build/run/parse/check/clean/preview-docs), Gitea Actions
  workflow (shiroa v0.4.0-rc2), rootless Dockerfile, README
- legacy docs moved to legacy_docs/; .idea untracked
2026-10-10 18:42:40 +02:00

90 lines
2.7 KiB
Go

// Package middleware implements the access-control layer of the server:
// OIDC login/logout, cookie sessions, and the gate that decides for every
// request whether its URL may be served to the caller.
package middleware
import (
"net/http"
"net/url"
"sync"
"time"
"github.com/Klagarge/yggdrasil/internal/acl"
"github.com/Klagarge/yggdrasil/internal/oidc"
)
const (
sessionCookie = "ygg_session"
stateTTL = 10 * time.Minute
)
// AccessControl bundles everything the gate needs.
type AccessControl struct {
ACL acl.ACL
Sessions *SessionStore
Auth *oidc.Authenticator // nil: OIDC disabled
CookieSecure bool
stateMu sync.Mutex
states map[string]loginState // pending OIDC flows, keyed by state
}
// NewAccessControl wires the gate. auth may be nil (OIDC disabled): public
// pages keep working and protected pages answer 503.
func NewAccessControl(a acl.ACL, sessions *SessionStore, auth *oidc.Authenticator, cookieSecure bool) *AccessControl {
return &AccessControl{
ACL: a,
Sessions: sessions,
Auth: auth,
CookieSecure: cookieSecure,
states: make(map[string]loginState),
}
}
// Wrap returns a handler that serves next only when the request is allowed.
//
// The request path is reduced to a page key (see acl.PageKey) and looked up
// in the ACL. Public pages pass through; protected pages require a session
// whose groups include at least one of the groups required by the ACL.
func (ac *AccessControl) Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key, err := acl.PageKey(r.URL.Path)
if err != nil {
http.Error(w, "invalid request path", http.StatusBadRequest)
return
}
required, protected := ac.ACL.GroupsFor(key)
if !protected {
next.ServeHTTP(w, r)
return
}
if ac.Auth == nil {
http.Error(w, "this page is protected, but the server has no OIDC configuration",
http.StatusServiceUnavailable)
return
}
sess, ok := ac.session(r)
if !ok {
http.Redirect(w, r, "/login?target="+url.QueryEscape(r.URL.RequestURI()), http.StatusFound)
return
}
if !acl.Allows(sess.Groups, required) {
http.Error(w, "403 Forbidden: your account does not have access to this page.",
http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// SecurityHeaders sets conservative defaults on every response.
func (ac *AccessControl) SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
next.ServeHTTP(w, r)
})
}