Files
guestbook/auth.go
T

108 lines
3.1 KiB
Go

package main
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"net/http"
"strconv"
"strings"
"time"
"github.com/jkaninda/okapi"
)
const (
sessionCookie = "gb_admin"
sessionTTL = 12 * time.Hour
)
// AdminAuth guards the admin console with a single shared ADMIN_TOKEN.
//
// Sessions are stateless HMAC-signed cookies keyed on the token, so any
// replica can verify a session issued by another, and rotating ADMIN_TOKEN
// logs everyone out.
type AdminAuth struct {
token string
key []byte
}
// NewAdminAuth returns an AdminAuth; an empty token disables the console.
func NewAdminAuth(token string) *AdminAuth {
sum := sha256.Sum256([]byte("guestbook-admin-session:" + token))
return &AdminAuth{token: token, key: sum[:]}
}
// Enabled reports whether an admin token is configured.
func (a *AdminAuth) Enabled() bool { return a.token != "" }
// CheckToken compares a candidate against ADMIN_TOKEN in constant time.
func (a *AdminAuth) CheckToken(candidate string) bool {
return a.Enabled() && subtle.ConstantTimeCompare([]byte(candidate), []byte(a.token)) == 1
}
// Issue returns a signed session value expiring after sessionTTL.
func (a *AdminAuth) Issue(now time.Time) string {
exp := strconv.FormatInt(now.Add(sessionTTL).Unix(), 10)
return exp + "." + a.sign(exp)
}
// Verify reports whether a session value is authentic and unexpired.
func (a *AdminAuth) Verify(value string, now time.Time) bool {
if !a.Enabled() {
return false
}
exp, sig, ok := strings.Cut(value, ".")
if !ok || !hmac.Equal([]byte(sig), []byte(a.sign(exp))) {
return false
}
unix, err := strconv.ParseInt(exp, 10, 64)
return err == nil && now.Unix() < unix
}
func (a *AdminAuth) sign(payload string) string {
m := hmac.New(sha256.New, a.key)
m.Write([]byte(payload))
return hex.EncodeToString(m.Sum(nil))
}
// Authenticated accepts either a session cookie (the console) or an
// "Authorization: Bearer <ADMIN_TOKEN>" header (curl during a demo).
func (a *AdminAuth) Authenticated(c *okapi.Context) bool {
if bearer, ok := strings.CutPrefix(c.Header("Authorization"), "Bearer "); ok {
return a.CheckToken(strings.TrimSpace(bearer))
}
v, err := c.Cookie(sessionCookie)
return err == nil && a.Verify(v, time.Now())
}
// Require is middleware that rejects unauthenticated requests.
func (a *AdminAuth) Require(c *okapi.Context) error {
if !a.Enabled() {
return c.JSON(http.StatusNotFound, okapi.M{"error": "admin console is disabled (set ADMIN_TOKEN)"})
}
if !a.Authenticated(c) {
return c.JSON(http.StatusUnauthorized, okapi.M{"error": "authentication required"})
}
return c.Next()
}
// SetSession writes (or, with an empty value, clears) the session cookie.
func (a *AdminAuth) SetSession(c *okapi.Context, value string) {
maxAge := int(sessionTTL.Seconds())
if value == "" {
maxAge = -1
}
r := c.Request()
http.SetCookie(c.ResponseWriter(), &http.Cookie{
Name: sessionCookie,
Value: value,
Path: "/",
MaxAge: maxAge,
HttpOnly: true,
Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https",
SameSite: http.SameSiteStrictMode,
})
}