package store import ( "context" "crypto/rand" "crypto/sha256" "crypto/subtle" "database/sql" "encoding/hex" "time" "github.com/cosmic-clash/cosmic-clash/server/domain" ) const ( SessionInsertSQL = `INSERT INTO sessions (session_id, player_id, token_digest, expires_at, created_at) VALUES ($1, $2, $3, $4, $5)` SessionSelectSQL = `SELECT session_id, player_id, token_digest, expires_at, revoked_at FROM sessions WHERE session_id = $1` SessionRevokeSQL = `UPDATE sessions SET revoked_at = COALESCE(revoked_at, $2) WHERE session_id = $1` ) // PostgresSessions persists only a SHA-256 token digest. The plaintext token // is returned once by Issue and is never sent to SQL or logged by this layer. type PostgresSessions struct{ DB *sql.DB } func (s PostgresSessions) Issue(ctx context.Context, playerID string, lifetime time.Duration, now time.Time) (domain.Session, string, error) { if s.DB == nil || playerID == "" || lifetime <= 0 || now.IsZero() { return domain.Session{}, "", domain.ErrSessionRejected } sessionID, err := opaqueSessionValue() if err != nil { return domain.Session{}, "", err } token, err := opaqueSessionValue() if err != nil { return domain.Session{}, "", err } session := domain.Session{SessionID: sessionID, PlayerID: playerID, ExpiresAt: now.Add(lifetime)} digest := sha256.Sum256([]byte(token)) if _, err := s.DB.ExecContext(ctx, SessionInsertSQL, session.SessionID, session.PlayerID, digest[:], session.ExpiresAt, now); err != nil { return domain.Session{}, "", err } return session, token, nil } func (s PostgresSessions) Authenticate(ctx context.Context, sessionID, token string, now time.Time) (domain.Session, error) { if s.DB == nil || sessionID == "" || token == "" || now.IsZero() { return domain.Session{}, domain.ErrSessionRejected } var session domain.Session var digestBytes []byte var revokedAt sql.NullTime if err := s.DB.QueryRowContext(ctx, SessionSelectSQL, sessionID).Scan(&session.SessionID, &session.PlayerID, &digestBytes, &session.ExpiresAt, &revokedAt); err != nil { return domain.Session{}, domain.ErrSessionRejected } provided := sha256.Sum256([]byte(token)) if len(digestBytes) != sha256.Size || subtle.ConstantTimeCompare(digestBytes, provided[:]) != 1 || (revokedAt.Valid && !revokedAt.Time.IsZero()) || !now.Before(session.ExpiresAt) { return domain.Session{}, domain.ErrSessionRejected } return session, nil } func (s PostgresSessions) Revoke(ctx context.Context, sessionID string, now time.Time) error { if s.DB == nil || sessionID == "" || now.IsZero() { return domain.ErrSessionRejected } result, err := s.DB.ExecContext(ctx, SessionRevokeSQL, sessionID, now) if err != nil { return err } changed, err := result.RowsAffected() if err != nil || changed != 1 { return domain.ErrSessionRejected } return nil } func opaqueSessionValue() (string, error) { value := make([]byte, 32) if _, err := rand.Read(value); err != nil { return "", err } return hex.EncodeToString(value), nil }