repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

5.0 KB · 159 lines · Go Raw History
  1package web
  2
  3import (
  4	"encoding/json"
  5	"io"
  6	"log/slog"
  7	"net/http"
  8	"net/url"
  9	"strings"
 10	"time"
 11)
 12
 13// Single sign-on for every bythewood.me site, against auth.bythewood.me.
 14//
 15// The cookie is opaque, so a site cannot check it for itself and has to ask.
 16// That is the point: a signed cookie is valid until it expires no matter what
 17// the issuer says, and asking is what makes revoking a session take effect
 18// everywhere on the next request rather than whenever the signature ages out.
 19//
 20// The answer is not cached. A call to another container on the bridge is well
 21// under a millisecond and these are dashboards one person reads, so a cache
 22// would buy nothing and would put a window on revocation, which is the feature
 23// this exists for.
 24//
 25// The cost is real and worth stating: with orchard-auth down, every site behind
 26// this is unreachable. That is why auth.bythewood.me has recovery codes and why
 27// nothing public is behind it.
 28const (
 29	SessionCookie = "bw_session"
 30
 31	authVerifyURL = "http://orchard-auth:8000/verify"
 32	authLoginURL  = "https://auth.bythewood.me/login"
 33
 34	// Short. A dashboard that hangs because auth is slow is worse than one
 35	// that says you are signed out.
 36	authTimeout = 3 * time.Second
 37)
 38
 39// Authenticator answers whether a request carries a live session.
 40type Authenticator struct {
 41	client *http.Client
 42	verify string
 43}
 44
 45func NewAuthenticator() *Authenticator { return NewAuthenticatorAt(authVerifyURL) }
 46
 47// NewAuthenticatorAt points at a different verifier, which is what the tests
 48// use to stand one up without a running auth container.
 49func NewAuthenticatorAt(verify string) *Authenticator {
 50	return &Authenticator{
 51		client: &http.Client{
 52			Timeout: authTimeout,
 53			// A verify call must never follow a redirect: the answer is the
 54			// status code, and a 302 to somewhere else is not an answer.
 55			CheckRedirect: func(*http.Request, []*http.Request) error {
 56				return http.ErrUseLastResponse
 57			},
 58		},
 59		verify: verify,
 60	}
 61}
 62
 63// Authenticated reports whether the caller is signed in. Every failure is a no,
 64// including auth being unreachable, because the alternative is failing open.
 65func (a *Authenticator) Authenticated(r *http.Request) bool {
 66	// A site built without one is signed out rather than a panic, which matters
 67	// because this is called from the page data every template renders.
 68	if a == nil || a.client == nil {
 69		return false
 70	}
 71
 72	c, err := r.Cookie(SessionCookie)
 73	if err != nil || c.Value == "" {
 74		return false
 75	}
 76
 77	req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, a.verify, nil)
 78	if err != nil {
 79		return false
 80	}
 81	req.AddCookie(&http.Cookie{Name: SessionCookie, Value: c.Value})
 82
 83	resp, err := a.client.Do(req)
 84	if err != nil {
 85		slog.Error("verifying a session failed",
 86			slog.String("component", "auth"),
 87			slog.Any("err", err))
 88		return false
 89	}
 90	defer resp.Body.Close()
 91
 92	var body struct {
 93		OK bool `json:"ok"`
 94	}
 95	if err := json.NewDecoder(io.LimitReader(resp.Body, 4096)).Decode(&body); err != nil {
 96		return false
 97	}
 98	return resp.StatusCode == http.StatusOK && body.OK
 99}
100
101// RequireAuth gates a handler, sending anyone without a session to the login
102// with a way back.
103func (a *Authenticator) RequireAuth(next http.HandlerFunc) http.HandlerFunc {
104	return func(w http.ResponseWriter, r *http.Request) {
105		if !a.Authenticated(r) {
106			http.Redirect(w, r, LoginURL(r), http.StatusSeeOther)
107			return
108		}
109		next(w, r)
110	}
111}
112
113// RequireAuthJSON gates the endpoints a dashboard's own JavaScript calls, where
114// a redirect to an HTML login page would be parsed as data.
115func (a *Authenticator) RequireAuthJSON(next http.HandlerFunc) http.HandlerFunc {
116	return func(w http.ResponseWriter, r *http.Request) {
117		if !a.Authenticated(r) {
118			w.Header().Set("Content-Type", "application/json; charset=utf-8")
119			w.Header().Set("Cache-Control", "no-store")
120			w.WriteHeader(http.StatusUnauthorized)
121			_, _ = w.Write([]byte(`{"error":"not signed in"}`))
122			return
123		}
124		next(w, r)
125	}
126}
127
128// LoginURL is where a signed out visitor goes, carrying an absolute return
129// address because the login is on another host.
130//
131// The return address is never this site's own /login. That path is itself a
132// redirect to auth, so handing it back as the destination is an infinite loop:
133// auth sends you there, the stub sends you to auth, auth sees a live session and
134// sends you there again. An explicit ?next= on the stub is honoured instead, and
135// the site root is the fallback.
136func LoginURL(r *http.Request) string {
137	scheme := "https"
138	if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") == "http" {
139		scheme = "http"
140	}
141
142	target := r.URL.RequestURI()
143	if r.URL.Path == "/login" {
144		target = "/"
145		if n := r.URL.Query().Get("next"); strings.HasPrefix(n, "/") &&
146			!strings.HasPrefix(n, "//") && !strings.HasPrefix(n, "/\\") &&
147			n != "/login" {
148			target = n
149		}
150	}
151
152	back := scheme + "://" + r.Host + target
153	return authLoginURL + "?next=" + url.QueryEscape(back)
154}
155
156// LogoutURL ends the session for every site at once, which is the only sensible
157// meaning of signing out when one cookie covers all of them.
158func LogoutURL() string { return "https://auth.bythewood.me/" }