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}