package store
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"time"
bolt "go.etcd.io/bbolt"
)
// Session backs web login: an opaque bearer token (held in a cookie) maps
// to this record. Nothing about it is derivable from the token itself
// (unlike a signed JWT), so deleting the record here is a real, immediate
// logout/revocation — there's no separate secret to rotate.
type Session struct {
Token string `json:"token"`
Principal string `json:"principal"` // "<username>@<domain>"
Username string `json:"username"`
IsAdmin bool `json:"is_admin"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
// CreateSession mints a new random token and stores the session, valid for
// ttl.
func (s *Store) CreateSession(principal, username string, isAdmin bool, ttl time.Duration) (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
token := base64.RawURLEncoding.EncodeToString(buf)
now := time.Now().UTC()
sess := Session{
Token: token,
Principal: principal,
Username: username,
IsAdmin: isAdmin,
CreatedAt: now,
ExpiresAt: now.Add(ttl),
}
err := s.db.Update(func(tx *bolt.Tx) error {
return putJSON(tx, bucketSessions, token, sess)
})
if err != nil {
return "", err
}
return token, nil
}
// GetSession looks up a session by token. An expired session is treated
// (and lazily deleted) as not found.
func (s *Store) GetSession(token string) (Session, error) {
var sess Session
err := s.db.Update(func(tx *bolt.Tx) error {
if err := getJSON(tx, bucketSessions, token, &sess); err != nil {
return err
}
if time.Now().After(sess.ExpiresAt) {
_ = deleteKey(tx, bucketSessions, token)
return ErrNotFound
}
return nil
})
return sess, err
}
func (s *Store) DeleteSession(token string) error {
return s.db.Update(func(tx *bolt.Tx) error {
return deleteKey(tx, bucketSessions, token)
})
}
// PurgeExpiredSessions removes all sessions past their expiry and returns how
// many were deleted. GetSession only evicts a session lazily when it happens
// to be looked up; this sweeps the ones that are never touched again so the
// bucket doesn't grow without bound.
func (s *Store) PurgeExpiredSessions() (int, error) {
var deleted int
err := s.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketSessions)
now := time.Now()
var stale [][]byte
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
var sess Session
if json.Unmarshal(v, &sess) != nil || now.After(sess.ExpiresAt) {
stale = append(stale, append([]byte(nil), k...))
}
}
for _, k := range stale {
if err := b.Delete(k); err != nil {
return err
}
deleted++
}
return nil
})
return deleted, err
}