package admin import ( "context" "crypto/rand" "crypto/subtle" "encoding/base64" "encoding/json" "errors" "fmt" "time" "github.com/redis/go-redis/v9" ) // SessionCookieName is the name of the admin session cookie. const SessionCookieName = "admin_session" // sessionKeyPrefix namespaces session keys in Redis. const sessionKeyPrefix = "admin:sess:" // Session is the server-side state for one logged-in admin. type Session struct { AdminID int64 `json:"admin_id"` Username string `json:"username"` CSRFToken string `json:"csrf"` CreatedAt time.Time `json:"created_at"` // TTLSeconds, when > 0, overrides the store's default sliding idle TTL for // this session (used by "记住此设备" persistent sessions). 0 = use default. TTLSeconds int64 `json:"ttl_seconds,omitempty"` } // SessionStore persists admin sessions in Redis with a sliding idle TTL. type SessionStore struct { rdb *redis.Client ttl time.Duration } // NewSessionStore creates a SessionStore. ttl is the sliding idle timeout. func NewSessionStore(rdb *redis.Client, ttl time.Duration) *SessionStore { if ttl <= 0 { ttl = 30 * time.Minute } return &SessionStore{rdb: rdb, ttl: ttl} } // Create starts a new session with the store's default sliding idle TTL. func (s *SessionStore) Create(ctx context.Context, adminID int64, username string) (string, *Session, error) { return s.CreateWithTTL(ctx, adminID, username, 0) } // CreateWithTTL starts a new session. When ttl > 0 the session uses that TTL // (both the initial expiry and the per-request slide) instead of the store // default — this is how "记住此设备" keeps a device logged in for e.g. 30 days. func (s *SessionStore) CreateWithTTL(ctx context.Context, adminID int64, username string, ttl time.Duration) (string, *Session, error) { sid, err := randToken(32) if err != nil { return "", nil, err } csrf, err := randToken(32) if err != nil { return "", nil, err } sess := &Session{ AdminID: adminID, Username: username, CSRFToken: csrf, CreatedAt: time.Now().UTC(), } if ttl > 0 { sess.TTLSeconds = int64(ttl / time.Second) } if err := s.save(ctx, sid, sess); err != nil { return "", nil, err } return sid, sess, nil } // slideTTL is the TTL to (re)apply to a session: its own override if set, // otherwise the store default. func (s *SessionStore) slideTTL(sess *Session) time.Duration { if sess != nil && sess.TTLSeconds > 0 { return time.Duration(sess.TTLSeconds) * time.Second } return s.ttl } // Get loads a session and slides its TTL forward. Returns (nil, nil) when the // session is absent or expired. func (s *SessionStore) Get(ctx context.Context, sid string) (*Session, error) { if sid == "" { return nil, nil } val, err := s.rdb.Get(ctx, sessionKeyPrefix+sid).Bytes() if errors.Is(err, redis.Nil) { return nil, nil } if err != nil { return nil, fmt.Errorf("admin.SessionStore.Get: %w", err) } var sess Session if err := json.Unmarshal(val, &sess); err != nil { return nil, fmt.Errorf("admin.SessionStore.Get unmarshal: %w", err) } // Slide the idle timeout (persistent sessions slide by their own TTL). if err := s.rdb.Expire(ctx, sessionKeyPrefix+sid, s.slideTTL(&sess)).Err(); err != nil { return nil, fmt.Errorf("admin.SessionStore.Get expire: %w", err) } return &sess, nil } // Delete removes a session (logout). func (s *SessionStore) Delete(ctx context.Context, sid string) error { if sid == "" { return nil } return s.rdb.Del(ctx, sessionKeyPrefix+sid).Err() } func (s *SessionStore) save(ctx context.Context, sid string, sess *Session) error { b, err := json.Marshal(sess) if err != nil { return fmt.Errorf("admin.SessionStore.save: %w", err) } if err := s.rdb.Set(ctx, sessionKeyPrefix+sid, b, s.slideTTL(sess)).Err(); err != nil { return fmt.Errorf("admin.SessionStore.save set: %w", err) } return nil } // ValidCSRF reports, in constant time, whether token matches the session's // CSRF token. func (sess *Session) ValidCSRF(token string) bool { if sess == nil || sess.CSRFToken == "" || token == "" { return false } return subtle.ConstantTimeCompare([]byte(sess.CSRFToken), []byte(token)) == 1 } // randToken returns a URL-safe random token of n bytes of entropy. func randToken(n int) (string, error) { buf := make([]byte, n) if _, err := rand.Read(buf); err != nil { return "", fmt.Errorf("admin.randToken: %w", err) } return base64.RawURLEncoding.EncodeToString(buf), nil }