package admin import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "errors" "fmt" "strconv" "strings" "time" ) // Sessions are stateless: base64("user::expiryUnix") + "." + HMAC-SHA256. // They survive restarts as long as ONE_SECRET stays the same. The token only // carries the user's DB id — role / ban state is read fresh from the database // on every request, so demotions and bans take effect immediately. type Sessions struct { secret []byte ttl time.Duration } func NewSessions(secret string, ttl time.Duration) *Sessions { if ttl <= 0 { ttl = 7 * 24 * time.Hour } return &Sessions{secret: []byte(secret), ttl: ttl} } var ErrBadSession = errors.New("invalid session") func (s *Sessions) Issue(userID int64) (string, time.Time) { exp := time.Now().Add(s.ttl) payload := base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("user:%d:%d", userID, exp.Unix()))) return payload + "." + s.sign(payload), exp } func (s *Sessions) Verify(token string) (int64, error) { parts := strings.Split(token, ".") if len(parts) != 2 { return 0, ErrBadSession } if !hmac.Equal([]byte(s.sign(parts[0])), []byte(parts[1])) { return 0, ErrBadSession } raw, err := base64.RawURLEncoding.DecodeString(parts[0]) if err != nil { return 0, ErrBadSession } // user:: f := strings.Split(string(raw), ":") if len(f) != 3 || f[0] != "user" { return 0, ErrBadSession } id, err := strconv.ParseInt(f[1], 10, 64) if err != nil || id <= 0 { return 0, ErrBadSession } expUnix, err := strconv.ParseInt(f[2], 10, 64) if err != nil { return 0, ErrBadSession } if time.Now().After(time.Unix(expUnix, 0)) { return 0, ErrBadSession } return id, nil } func (s *Sessions) sign(payload string) string { mac := hmac.New(sha256.New, s.secret) mac.Write([]byte(payload)) return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) } func (s *Sessions) TTL() int { return int(s.ttl.Seconds()) } func (s *Sessions) String() string { return fmt.Sprintf("sessions(ttl=%s)", s.ttl) }