- 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
90 lines
2.7 KiB
Go
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)
|
|
})
|
|
}
|