74 lines
2.6 KiB
Go
74 lines
2.6 KiB
Go
package auth
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Authenticator turns a presented token into a principal. It is the ONLY place a principal is
|
|
// created, which is what keeps the API portable to the desktop client: no endpoint can grow a
|
|
// dependency on cookies if no endpoint ever sees one.
|
|
type Authenticator struct {
|
|
Sessions SessionStore
|
|
IdleTTL time.Duration
|
|
// Now is injectable so expiry is testable without sleeping.
|
|
Now func() time.Time
|
|
// Deny writes the 401 body. Injected because the error shape belongs to the API layer
|
|
// (problem+json), and auth must not depend on it.
|
|
Deny http.Handler
|
|
}
|
|
|
|
// Require rejects anything that does not carry a live session.
|
|
func (a *Authenticator) Require(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
token, via, ok := present(r)
|
|
// No store means no session can be proven, which is a denial and not a crash: the service
|
|
// is allowed to run without a database (readiness says so), and a caller who presents a
|
|
// token must get the same 401 as a caller who presents none.
|
|
if !ok || a.Sessions == nil {
|
|
a.Deny.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
now := a.now()
|
|
digest := Digest(token)
|
|
s, err := a.Sessions.Lookup(r.Context(), digest, now)
|
|
if err != nil {
|
|
// A store failure denies exactly like an unknown token: an authenticated caller is
|
|
// what a store failure cannot prove, and a distinguishable answer is an oracle.
|
|
a.Deny.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
// Slide the idle window only in its second half. Sliding on every request would turn every
|
|
// read into a write, and the session table is on the hot path of every call.
|
|
if a.IdleTTL > 0 && s.IdleExpiresAt.Sub(now) < a.IdleTTL/2 {
|
|
_ = a.Sessions.Touch(r.Context(), digest, now, a.IdleTTL)
|
|
}
|
|
ctx := withPrincipal(r.Context(), Principal{UserID: s.UserID, Via: via})
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
|
|
func (a *Authenticator) now() time.Time {
|
|
if a.Now != nil {
|
|
return a.Now()
|
|
}
|
|
return time.Now()
|
|
}
|
|
|
|
// present extracts the token. Bearer wins over the cookie when both arrive: an explicit credential
|
|
// beats an ambient one, and it keeps a stray cookie from deciding the CSRF path for an API client.
|
|
func present(r *http.Request) (token string, via Presentation, ok bool) {
|
|
if h := r.Header.Get("Authorization"); h != "" {
|
|
scheme, value, found := strings.Cut(h, " ")
|
|
if !found || !strings.EqualFold(scheme, "Bearer") || value == "" {
|
|
return "", "", false
|
|
}
|
|
return value, ViaBearer, true
|
|
}
|
|
c, err := r.Cookie(CookieName)
|
|
if err != nil || c.Value == "" {
|
|
return "", "", false
|
|
}
|
|
return c.Value, ViaCookie, true
|
|
}
|