Files

151 lines
4.6 KiB
Go

package editor
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
)
// Sessions live in the browser, sealed: AES-GCM-encrypted cookies holding
// who signed in, on which platform, and their token for it. Nothing is kept
// server-side, so there's nothing to leak from the editor's disk and nothing
// to clean up; a session ends when the cookie expires or the person signs
// out. The key comes from HOTDOG_EDITOR_SECRET, shared by every editor
// instance that serves the same people.
//
// A person can be signed in to several accounts at once, on one platform or
// several: each account is its own cookie (hotdog_a0 … hotdog_a7), since one
// platform token alone can fill much of a cookie.
const (
accountPrefix = "hotdog_a"
maxAccounts = 8
stateCookie = "hotdog_oauth"
sessionTTL = 8 * time.Hour
)
func accountCookie(slot int) string { return accountPrefix + strconv.Itoa(slot) }
// Session is one signed-in account.
type Session struct {
Forge string `json:"f"` // platform host
Login string `json:"l"`
Name string `json:"n"`
Email string `json:"m"` // commits are attributed to this address
Token string `json:"t"`
Expires time.Time `json:"e"`
// Refresh renews Token before TokenExp, for platforms whose tokens
// expire (Gitea and Forgejo after an hour by default, GitLab after two).
Refresh string `json:"rt,omitempty"`
TokenExp time.Time `json:"x,omitempty"`
slot int // which cookie holds it
}
// ID names the account in the interface and in requests: platform/login.
func (s *Session) ID() string { return s.Forge + "/" + s.Login }
type sealer struct {
aead cipher.AEAD
// secure: the editor is reached over https (its public_url). Cookies are
// then Secure and named __Host-…, which a page on another subdomain
// (a live preview, a branch preview, the site itself) can't set or
// overwrite. The name sealed into each value stays the plain one.
secure bool
}
// wire is the name a cookie has in the browser.
func (s *sealer) wire(name string) string {
if s.secure {
return "__Host-" + name
}
return name
}
func newSealer(secret string) (*sealer, error) {
if len(secret) < 32 {
return nil, errors.New("HOTDOG_EDITOR_SECRET must be set, at least 32 characters (openssl rand -hex 32)")
}
key := sha256.Sum256([]byte("hotdog-cms editor session v1\x00" + secret))
block, err := aes.NewCipher(key[:])
if err != nil {
return nil, err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
return &sealer{aead: aead}, nil
}
// seal encrypts v for a cookie named name; the name is bound into the
// ciphertext, so one cookie can't be replayed as another.
func (s *sealer) seal(name string, v any) (string, error) {
plain, err := json.Marshal(v)
if err != nil {
return "", err
}
nonce := make([]byte, s.aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(s.aead.Seal(nonce, nonce, plain, []byte(name))), nil
}
func (s *sealer) open(name, value string, v any) error {
raw, err := base64.RawURLEncoding.DecodeString(value)
if err != nil || len(raw) < s.aead.NonceSize() {
return errors.New("bad cookie")
}
plain, err := s.aead.Open(nil, raw[:s.aead.NonceSize()], raw[s.aead.NonceSize():], []byte(name))
if err != nil {
return errors.New("bad cookie")
}
return json.Unmarshal(plain, v)
}
func (s *sealer) setCookie(w http.ResponseWriter, name string, v any, ttl time.Duration) error {
val, err := s.seal(name, v)
if err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: s.wire(name), Value: val, Path: "/", MaxAge: int(ttl.Seconds()),
HttpOnly: true, Secure: s.secure, SameSite: http.SameSiteLaxMode,
})
return nil
}
func (s *sealer) clearCookie(w http.ResponseWriter, name string) {
http.SetCookie(w, &http.Cookie{Name: s.wire(name), Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: s.secure, SameSite: http.SameSiteLaxMode})
}
// cookie reads one of the editor's cookies.
func (s *sealer) cookie(r *http.Request, name string) (*http.Cookie, error) {
return r.Cookie(s.wire(name))
}
// accounts returns every account the browser is signed in to, in slot order.
func (s *sealer) accounts(r *http.Request) []*Session {
var out []*Session
for i := 0; i < maxAccounts; i++ {
c, err := s.cookie(r, accountCookie(i))
if err != nil {
continue
}
var sess Session
if s.open(accountCookie(i), c.Value, &sess) != nil || time.Now().After(sess.Expires) || sess.Token == "" {
continue
}
sess.slot = i
out = append(out, &sess)
}
return out
}