- SetSessionCookie/ClearSessionCookie/GetSessionToken with HttpOnly, Secure, SameSite=Strict - RequireAuth middleware: validates token, redirects to login, sets TokenInfo in context - RenderTemplate: layout + page block pattern with FuncMap merge - 9 tests with mock MCIAS and fstest Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
105 lines
3.0 KiB
Go
105 lines
3.0 KiB
Go
// Package web provides session cookie management, auth middleware, and
|
|
// template rendering helpers for Metacircular web UIs built with htmx
|
|
// and Go html/template.
|
|
package web
|
|
|
|
import (
|
|
"html/template"
|
|
"io/fs"
|
|
"net/http"
|
|
|
|
"git.wntrmute.dev/kyle/mcdsl/auth"
|
|
)
|
|
|
|
// SetSessionCookie sets a session cookie with the standard Metacircular
|
|
// security flags: HttpOnly, Secure, SameSite=Strict.
|
|
func SetSessionCookie(w http.ResponseWriter, name, token string) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: name,
|
|
Value: token,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
Secure: true,
|
|
SameSite: http.SameSiteStrictMode,
|
|
})
|
|
}
|
|
|
|
// ClearSessionCookie removes a session cookie by setting it to empty
|
|
// with MaxAge=-1.
|
|
func ClearSessionCookie(w http.ResponseWriter, name string) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: name,
|
|
Value: "",
|
|
Path: "/",
|
|
MaxAge: -1,
|
|
HttpOnly: true,
|
|
Secure: true,
|
|
SameSite: http.SameSiteStrictMode,
|
|
})
|
|
}
|
|
|
|
// GetSessionToken extracts the session token from the named cookie.
|
|
// Returns empty string if the cookie is missing or empty.
|
|
func GetSessionToken(r *http.Request, name string) string {
|
|
c, err := r.Cookie(name)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return c.Value
|
|
}
|
|
|
|
// RequireAuth returns middleware that validates the session token via
|
|
// the Authenticator. If the token is missing or invalid, the user is
|
|
// redirected to loginPath. On success, the [auth.TokenInfo] is stored
|
|
// in the request context (retrievable via [auth.TokenInfoFromContext]).
|
|
func RequireAuth(authenticator *auth.Authenticator, cookieName, loginPath string) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
token := GetSessionToken(r, cookieName)
|
|
if token == "" {
|
|
http.Redirect(w, r, loginPath, http.StatusFound)
|
|
return
|
|
}
|
|
|
|
info, err := authenticator.ValidateToken(token)
|
|
if err != nil {
|
|
ClearSessionCookie(w, cookieName)
|
|
http.Redirect(w, r, loginPath, http.StatusFound)
|
|
return
|
|
}
|
|
|
|
ctx := auth.ContextWithTokenInfo(r.Context(), info)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
}
|
|
|
|
// RenderTemplate parses and executes a template from an embedded FS.
|
|
// It parses "templates/layout.html" and "templates/<name>", merges
|
|
// any provided FuncMaps, and executes the "layout" template with data.
|
|
//
|
|
// This matches the layout + page block pattern used by all Metacircular
|
|
// web UIs.
|
|
func RenderTemplate(w http.ResponseWriter, fsys fs.FS, name string, data any, funcs ...template.FuncMap) {
|
|
merged := template.FuncMap{}
|
|
for _, fm := range funcs {
|
|
for k, v := range fm {
|
|
merged[k] = v
|
|
}
|
|
}
|
|
|
|
tmpl, err := template.New("").Funcs(merged).ParseFS(fsys,
|
|
"templates/layout.html",
|
|
"templates/"+name,
|
|
)
|
|
if err != nil {
|
|
http.Error(w, "template error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil {
|
|
http.Error(w, "template error", http.StatusInternalServerError)
|
|
}
|
|
}
|