Raw
  1package session
  2
  3import (
  4	"context"
  5	"crypto/rand"
  6	"crypto/sha256"
  7	"encoding/base64"
  8	"encoding/json"
  9	"errors"
 10	"fmt"
 11	"net/netip"
 12	"slices"
 13	"strings"
 14	"sync"
 15	"time"
 16
 17	"tug/internal/service/identity"
 18	"tug/internal/storage"
 19)
 20
 21const (
 22	SudoWindow           = 5 * time.Minute
 23	secretBytes          = 32
 24	defaultPurgeInterval = time.Hour
 25)
 26
 27var (
 28	ErrNoSession   = errors.New("session: no live session")
 29	ErrUnavailable = errors.New("session: storage is unavailable")
 30)
 31
 32type Config struct {
 33	Store         *storage.Store
 34	Identity      *identity.Service
 35	AdminGroup    string
 36	TTL           time.Duration
 37	Now           func() time.Time
 38	PurgeInterval time.Duration
 39	HostTTL       map[string]time.Duration
 40	SeenFlush     time.Duration
 41}
 42
 43type Principal struct {
 44	SessionID, UserID, Username, Host string
 45	DisplayName                       string
 46	Admin                             bool
 47	Groups                            []string
 48	CreatedAt, ExpiresAt, SudoUntil   time.Time
 49}
 50
 51type OpenInput struct {
 52	UserID, Host, Via, ClientID string
 53	SignIn                      bool
 54	Epoch                       *int64
 55	Browser                     string
 56	Address                     netip.Addr
 57	UserAgent                   string
 58}
 59
 60type Service struct {
 61	store         *storage.Store
 62	identity      *identity.Service
 63	adminGroup    string
 64	ttlMu         sync.RWMutex
 65	ttl           time.Duration
 66	now           func() time.Time
 67	purgeInterval time.Duration
 68	seenFlush     time.Duration
 69	hostTTL       map[string]time.Duration
 70	seen          seenGate
 71}
 72
 73type openDetail struct {
 74	Host   string `json:"host"`
 75	Via    string `json:"via"`
 76	Client string `json:"client"`
 77}
 78
 79type signinDetail struct {
 80	Host   string `json:"host"`
 81	Client string `json:"client"`
 82}
 83
 84type hostDetail struct {
 85	Host string `json:"host"`
 86}
 87
 88type keptDetail struct {
 89	Kept string `json:"kept"`
 90}
 91
 92func New(cfg Config) (*Service, error) {
 93	switch {
 94	case cfg.Store == nil:
 95		return nil, errors.New("session: store is nil")
 96	case cfg.Identity == nil:
 97		return nil, errors.New("session: identity service is nil")
 98	case strings.TrimSpace(cfg.AdminGroup) == "":
 99		return nil, errors.New("session: admin group is empty")
100	case cfg.PurgeInterval < 0:
101		return nil, fmt.Errorf("session: purge interval %s is negative", cfg.PurgeInterval)
102	}
103	hostTTL, err := checkTTLs(cfg.TTL, cfg.HostTTL)
104	if err != nil {
105		return nil, err
106	}
107	now := cfg.Now
108	if now == nil {
109		now = time.Now
110	}
111	purgeInterval := cfg.PurgeInterval
112	if purgeInterval == 0 {
113		purgeInterval = defaultPurgeInterval
114	}
115	seenFlush := cfg.SeenFlush
116	if seenFlush == 0 {
117		seenFlush = SeenFlushEvery
118	}
119	return &Service{
120		store:         cfg.Store,
121		identity:      cfg.Identity,
122		adminGroup:    cfg.AdminGroup,
123		ttl:           cfg.TTL,
124		now:           now,
125		purgeInterval: purgeInterval,
126		seenFlush:     seenFlush,
127		hostTTL:       hostTTL,
128		seen:          seenGate{interval: SeenInterval},
129	}, nil
130}
131
132func checkTTLs(ttl time.Duration, hostTTL map[string]time.Duration) (map[string]time.Duration, error) {
133	if ttl <= 0 {
134		return nil, fmt.Errorf("session: TTL %s is not positive", ttl)
135	}
136	checked := make(map[string]time.Duration, len(hostTTL))
137	for host, d := range hostTTL {
138		if strings.TrimSpace(host) == "" || d <= 0 {
139			return nil, fmt.Errorf("session: host %q TTL %s is not positive", host, d)
140		}
141		checked[host] = d
142	}
143	return checked, nil
144}
145
146func (s *Service) SetTTLs(ttl time.Duration, hostTTL map[string]time.Duration) error {
147	checked, err := checkTTLs(ttl, hostTTL)
148	if err != nil {
149		return err
150	}
151	s.ttlMu.Lock()
152	defer s.ttlMu.Unlock()
153	s.ttl, s.hostTTL = ttl, checked
154	return nil
155}
156
157func (s *Service) Open(ctx context.Context, in OpenInput) ([]byte, Principal, error) {
158	claims, err := s.identity.Claims(ctx, in.UserID, true)
159	if err != nil {
160		return nil, Principal{}, failure("open", err)
161	}
162	secret := make([]byte, secretBytes)
163	rand.Read(secret)
164	now := s.clock()
165	address := in.Address.Unmap().WithZone("")
166	id := rowID(secret)
167	opened := auditInput(claims.UserID, "session.open", id, openDetail{Host: in.Host, Via: in.Via, Client: in.ClientID})
168	opened.Address = address
169	audits := []storage.AppendAuditInput{opened}
170	if in.SignIn {
171		signin := auditInput(claims.UserID, "session.signin", id, signinDetail{Host: in.Host, Client: in.ClientID})
172		signin.Address = address
173		audits = append(audits, signin)
174	}
175	row, err := s.store.CreateForwardSessionAudited(ctx, storage.CreateForwardSessionInput{
176		ID:        id,
177		UserID:    claims.UserID,
178		AppDomain: in.Host,
179		CreatedAt: now,
180		ExpiresAt: now.Add(s.ttlFor(in.Host)),
181		Epoch:     in.Epoch,
182		Browser:   in.Browser,
183		ClientID:  in.ClientID,
184		Address:   address,
185		UserAgent: storage.CleanUserAgent(in.UserAgent),
186	}, audits...)
187	if err != nil {
188		return nil, Principal{}, failure("open", err)
189	}
190	return secret, s.principal(row, claims), nil
191}
192
193func (s *Service) Resolve(ctx context.Context, secret []byte, host string) (Principal, error) {
194	if len(secret) != secretBytes {
195		return Principal{}, fmt.Errorf("session: resolve: %w: secret is %d bytes", ErrNoSession, len(secret))
196	}
197	row, err := s.store.ForwardSessionByID(ctx, rowID(secret))
198	if err != nil {
199		return Principal{}, failure("resolve", err)
200	}
201	now := s.clock()
202	switch {
203	case row.Revoked:
204		return Principal{}, fmt.Errorf("session: resolve: %w: revoked", ErrNoSession)
205	case !now.Before(row.ExpiresAt):
206		return Principal{}, fmt.Errorf("session: resolve: %w: expired", ErrNoSession)
207	case row.AppDomain != host:
208		return Principal{}, fmt.Errorf("session: resolve: %w: another host's session", ErrNoSession)
209	}
210	claims, err := s.identity.Claims(ctx, row.UserID, true)
211	if err != nil {
212		return Principal{}, failure("resolve", err)
213	}
214	s.touch(row, now)
215	return s.principal(row, claims), nil
216}
217
218func (s *Service) Authenticated(ctx context.Context, p Principal, clientID string) error {
219	return s.audit(ctx, auditInput(p.UserID, "session.signin", p.SessionID, signinDetail{Host: p.Host, Client: clientID}))
220}
221
222func (s *Service) Reauthenticate(ctx context.Context, p Principal) (Principal, error) {
223	now := s.clock()
224	if err := s.store.MarkReauthenticatedAudited(ctx, p.SessionID, now, auditInput(p.UserID, "sudo.grant", p.SessionID, hostDetail{Host: p.Host})); err != nil {
225		if errors.Is(err, storage.ErrInvalid) {
226			return Principal{}, fmt.Errorf("session: reauthenticate: %w: %w", ErrNoSession, err)
227		}
228		return Principal{}, failure("reauthenticate", err)
229	}
230	p.SudoUntil = now.Add(SudoWindow)
231	return p, nil
232}
233
234func (s *Service) ReauthenticationRefused(ctx context.Context, p Principal) error {
235	return s.audit(ctx, auditInput(p.UserID, "sudo.refuse", p.SessionID, hostDetail{Host: p.Host}))
236}
237
238func (s *Service) Revoke(ctx context.Context, p Principal) error {
239	if err := s.store.RevokeForwardSessionAuditedAt(ctx, p.SessionID, auditInput(p.UserID, "session.revoke", p.SessionID, hostDetail{Host: p.Host}), s.clock()); err != nil {
240		return failure("revoke", err)
241	}
242	return nil
243}
244
245func (s *Service) RevokeAll(ctx context.Context, userID, actor string) (int64, error) {
246	revoked, err := s.store.RevokeUserCredentialsAuditedAt(ctx, userID, "", storage.AppendAuditInput{Actor: actor, Action: "session.revoke_all", Subject: userID}, s.clock())
247	if err != nil {
248		return 0, failure("revoke all", err)
249	}
250	return revoked.Sessions, nil
251}
252
253func (s *Service) RevokeOthers(ctx context.Context, p Principal) (int64, error) {
254	if p.SessionID == "" {
255		return 0, fmt.Errorf("session: revoke others: %w: no current session to keep", ErrNoSession)
256	}
257	revoked, err := s.store.RevokeUserCredentialsAuditedAt(ctx, p.UserID, p.SessionID, auditInput(p.UserID, "session.revoke_others", p.UserID, keptDetail{Kept: p.SessionID}), s.clock())
258	if errors.Is(err, storage.ErrInvalid) {
259		return 0, fmt.Errorf("session: revoke others: %w: %w", ErrNoSession, err)
260	}
261	if err != nil {
262		return 0, failure("revoke others", err)
263	}
264	return revoked.Sessions, nil
265}
266
267func (s *Service) List(ctx context.Context, userID string) ([]storage.ForwardSession, error) {
268	sessions, err := s.store.LiveForwardSessionsByUser(ctx, userID, s.clock())
269	if err != nil {
270		return nil, failure("list", err)
271	}
272	return sessions, nil
273}
274
275func (s *Service) Purge(ctx context.Context) (int64, error) {
276	count, err := s.store.DeleteExpiredForwardSessions(ctx, s.clock())
277	if err != nil {
278		return 0, failure("purge", err)
279	}
280	return count, nil
281}
282
283func (s *Service) Run(ctx context.Context) {
284	s.Purge(ctx)
285	ticker := time.NewTicker(s.purgeInterval)
286	defer ticker.Stop()
287	seen := time.NewTicker(s.seenFlush)
288	defer seen.Stop()
289	for {
290		select {
291		case <-ctx.Done():
292			final, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Second)
293			s.flushSeen(final)
294			cancel()
295			return
296		case <-ticker.C:
297			s.Purge(ctx)
298		case <-seen.C:
299			s.flushSeen(ctx)
300		}
301	}
302}
303
304func (s *Service) clock() time.Time {
305	return time.UnixMicro(s.now().UnixMicro()).UTC()
306}
307
308func (s *Service) ttlFor(host string) time.Duration {
309	s.ttlMu.RLock()
310	defer s.ttlMu.RUnlock()
311	if d, ok := s.hostTTL[host]; ok {
312		return d
313	}
314	return s.ttl
315}
316
317func (s *Service) principal(row storage.ForwardSession, claims identity.Claims) Principal {
318	p := Principal{
319		SessionID:   row.ID,
320		UserID:      row.UserID,
321		Username:    claims.Username,
322		DisplayName: claims.DisplayName,
323		Host:        row.AppDomain,
324		Admin:       slices.Contains(claims.Groups, s.adminGroup),
325		Groups:      claims.Groups,
326		CreatedAt:   row.CreatedAt,
327		ExpiresAt:   row.ExpiresAt,
328	}
329	if row.ReauthenticatedAt != nil {
330		p.SudoUntil = row.ReauthenticatedAt.Add(SudoWindow)
331	}
332	return p
333}
334
335func (s *Service) audit(ctx context.Context, input storage.AppendAuditInput) error {
336	if _, err := s.store.AppendAudit(ctx, input); err != nil {
337		return failure(input.Action, err)
338	}
339	return nil
340}
341
342func auditInput(actor, action, subject string, detail any) storage.AppendAuditInput {
343	encoded, _ := json.Marshal(detail)
344	return storage.AppendAuditInput{Actor: actor, Action: action, Subject: subject, Detail: string(encoded)}
345}
346
347func rowID(secret []byte) string {
348	sum := sha256.Sum256(secret)
349	return base64.RawURLEncoding.EncodeToString(sum[:])
350}
351
352func failure(operation string, err error) error {
353	switch {
354	case errors.Is(err, identity.ErrNoUser), errors.Is(err, storage.ErrNotFound):
355		return fmt.Errorf("session: %s: %w: %w", operation, ErrNoSession, err)
356	case errors.Is(err, storage.ErrBusy), errors.Is(err, storage.ErrLocked), errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
357		return fmt.Errorf("session: %s: %w: %w", operation, ErrUnavailable, err)
358	}
359	return fmt.Errorf("session: %s: %w", operation, err)
360}