Raw
  1package storage
  2
  3import (
  4	"context"
  5	"database/sql"
  6	"errors"
  7	"fmt"
  8	"net/netip"
  9	"time"
 10)
 11
 12const forwardSessionColumns = `id, user_id, app_domain, created_at, expires_at, revoked, reauthenticated_at, browser, client_id, address, user_agent, last_seen_at`
 13
 14func (s *Store) CreateForwardSession(ctx context.Context, input CreateForwardSessionInput) (ForwardSession, error) {
 15	const operation = "create forward session"
 16	if err := s.checkOpen(operation); err != nil {
 17		return ForwardSession{}, err
 18	}
 19	return createForwardSession(ctx, s.sql, input)
 20}
 21
 22func createForwardSession(ctx context.Context, q execer, input CreateForwardSessionInput) (ForwardSession, error) {
 23	const operation = "create forward session"
 24	if err := requireValue("session id", input.ID); err != nil {
 25		return ForwardSession{}, invalid(operation, err)
 26	}
 27	if err := requireValue("user id", input.UserID); err != nil {
 28		return ForwardSession{}, invalid(operation, err)
 29	}
 30	if err := requireValue("app domain", input.AppDomain); err != nil {
 31		return ForwardSession{}, invalid(operation, err)
 32	}
 33	if input.CreatedAt.IsZero() {
 34		return ForwardSession{}, invalid(operation, fmt.Errorf("creation time is zero"))
 35	}
 36	createdAt := canonicalTime(input.CreatedAt)
 37	expiresAt := canonicalTime(input.ExpiresAt)
 38	if !expiresAt.After(createdAt) {
 39		return ForwardSession{}, invalid(operation, fmt.Errorf("expiry is not after creation"))
 40	}
 41	if input.Browser != "" && !ValidBrowser(input.Browser) {
 42		return ForwardSession{}, invalid(operation, fmt.Errorf("browser marker is not 32 lowercase hex characters"))
 43	}
 44	if err := validClientColumns(input); err != nil {
 45		return ForwardSession{}, invalid(operation, err)
 46	}
 47	browser := nullString(input.Browser)
 48	client, address, userAgent := nullString(input.ClientID), nullAddress(input.Address), nullString(input.UserAgent)
 49	if input.Epoch != nil {
 50		inserted, err := execCount(ctx, q, operation, `
 51INSERT INTO forward_sessions (id, user_id, app_domain, created_at, expires_at, revoked, browser, client_id, address, user_agent, last_seen_at)
 52	SELECT ?, id, ?, ?, ?, 0, ?, ?, ?, ?, ? FROM users
 53	WHERE id = ? AND disabled = 0 AND credentials_epoch = ?`, input.ID, input.AppDomain,
 54			timeToInt(createdAt), timeToInt(expiresAt), browser, client, address, userAgent, timeToInt(createdAt), input.UserID, *input.Epoch)
 55		if err != nil {
 56			return ForwardSession{}, err
 57		}
 58		if inserted == 0 {
 59			return ForwardSession{}, fmt.Errorf("%s: %w: the user is disabled or its credentials epoch moved", operation, ErrNotFound)
 60		}
 61	} else if _, err := q.ExecContext(ctx, `
 62INSERT INTO forward_sessions (id, user_id, app_domain, created_at, expires_at, revoked, browser, client_id, address, user_agent, last_seen_at)
 63	VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)`, input.ID, input.UserID, input.AppDomain,
 64		timeToInt(createdAt), timeToInt(expiresAt), browser, client, address, userAgent, timeToInt(createdAt)); err != nil {
 65		return ForwardSession{}, classify(operation, err)
 66	}
 67	return ForwardSession{ID: input.ID, UserID: input.UserID, AppDomain: input.AppDomain, CreatedAt: createdAt, ExpiresAt: expiresAt, Browser: input.Browser, ClientID: input.ClientID, Address: input.Address, UserAgent: input.UserAgent, LastSeenAt: &createdAt}, nil
 68}
 69
 70func (s *Store) ForwardSessionByID(ctx context.Context, id string) (ForwardSession, error) {
 71	const operation = "get forward session"
 72	if err := s.checkOpen(operation); err != nil {
 73		return ForwardSession{}, err
 74	}
 75	session, err := scanForwardSession(s.sql.QueryRowContext(ctx, `SELECT `+forwardSessionColumns+` FROM forward_sessions WHERE id = ?`, id))
 76	if errors.Is(err, sql.ErrNoRows) {
 77		return ForwardSession{}, fmt.Errorf("%s: %w", operation, ErrNotFound)
 78	}
 79	if err != nil {
 80		return ForwardSession{}, classify(operation, err)
 81	}
 82	return session, nil
 83}
 84
 85func (s *Store) ForwardSessionsByUser(ctx context.Context, userID string) ([]ForwardSession, error) {
 86	return s.listForwardSessions(ctx, "list forward sessions", `SELECT `+forwardSessionColumns+` FROM forward_sessions WHERE user_id = ? ORDER BY created_at DESC, id`, userID)
 87}
 88
 89func (s *Store) LiveForwardSessionsByUser(ctx context.Context, userID string, at time.Time) ([]ForwardSession, error) {
 90	return s.listForwardSessions(ctx, "list live forward sessions", `SELECT `+forwardSessionColumns+` FROM forward_sessions WHERE user_id = ? AND revoked = 0 AND expires_at > ? ORDER BY created_at DESC, id`, userID, timeToInt(at))
 91}
 92
 93func (s *Store) listForwardSessions(ctx context.Context, operation, query string, args ...any) ([]ForwardSession, error) {
 94	if err := s.checkOpen(operation); err != nil {
 95		return nil, err
 96	}
 97	rows, err := s.sql.QueryContext(ctx, query, args...)
 98	if err != nil {
 99		return nil, classify(operation, err)
100	}
101	defer rows.Close()
102	var sessions []ForwardSession
103	for rows.Next() {
104		session, err := scanForwardSession(rows)
105		if err != nil {
106			return nil, classify(operation, err)
107		}
108		sessions = append(sessions, session)
109	}
110	if err := rows.Err(); err != nil {
111		return nil, classify(operation, err)
112	}
113	return sessions, nil
114}
115
116func (s *Store) MarkReauthenticated(ctx context.Context, id string, at time.Time) error {
117	const operation = "mark forward session reauthenticated"
118	if err := s.checkOpen(operation); err != nil {
119		return err
120	}
121	return markReauthenticated(ctx, s.sql, id, at)
122}
123
124func markReauthenticated(ctx context.Context, q execer, id string, at time.Time) error {
125	const operation = "mark forward session reauthenticated"
126	if at.IsZero() {
127		return invalid(operation, fmt.Errorf("reauthentication time is zero"))
128	}
129	stamp := timeToInt(at)
130	result, err := q.ExecContext(ctx, `
131UPDATE forward_sessions SET reauthenticated_at = ?
132WHERE id = ? AND revoked = 0 AND expires_at > ?`, stamp, id, stamp)
133	if err != nil {
134		return classify(operation, err)
135	}
136	changed, err := result.RowsAffected()
137	if err != nil {
138		return classify(operation+" rows affected", err)
139	}
140	if changed > 0 {
141		return nil
142	}
143	exists, err := forwardSessionExists(ctx, q, operation, id)
144	if err != nil {
145		return err
146	}
147	if !exists {
148		return fmt.Errorf("%s: %w", operation, ErrNotFound)
149	}
150	return invalid(operation, fmt.Errorf("session is revoked or expired"))
151}
152
153func (s *Store) RevokeForwardSession(ctx context.Context, id string) error {
154	const operation = "revoke forward session"
155	if err := s.checkOpen(operation); err != nil {
156		return err
157	}
158	_, err := revokeForwardSession(ctx, s.sql, operation, id)
159	return err
160}
161
162func (s *Store) RevokeForwardSessionAudited(ctx context.Context, id string, audit AppendAuditInput) error {
163	return s.RevokeForwardSessionAuditedAt(ctx, id, audit, s.now())
164}
165
166func (s *Store) RevokeForwardSessionAuditedAt(ctx context.Context, id string, audit AppendAuditInput, now time.Time) error {
167	const operation = "revoke forward session"
168	if err := s.checkOpen(operation); err != nil {
169		return err
170	}
171	tx, err := s.sql.BeginTx(ctx, nil)
172	if err != nil {
173		return classify("begin "+operation, err)
174	}
175	defer tx.Rollback()
176	changed, err := revokeForwardSession(ctx, tx, operation, id)
177	if err != nil || !changed {
178		return err
179	}
180	if _, err := appendAudit(ctx, tx, operation+" audit", audit, canonicalTime(now)); err != nil {
181		return err
182	}
183	return classify("commit "+operation, tx.Commit())
184}
185
186func (s *Store) RevokeForwardSessionsByUser(ctx context.Context, userID string) (int64, error) {
187	const operation = "revoke forward sessions by user"
188	if err := s.checkOpen(operation); err != nil {
189		return 0, err
190	}
191	return revokeForwardSessionsByUser(ctx, s.sql, operation, userID, "")
192}
193
194func (s *Store) DeleteExpiredForwardSessions(ctx context.Context, before time.Time) (int64, error) {
195	const operation = "delete expired forward sessions"
196	if err := s.checkOpen(operation); err != nil {
197		return 0, err
198	}
199	result, err := s.sql.ExecContext(ctx, `DELETE FROM forward_sessions WHERE expires_at <= ?`, timeToInt(before))
200	if err != nil {
201		return 0, classify(operation, err)
202	}
203	count, err := result.RowsAffected()
204	if err != nil {
205		return 0, classify(operation+" rows affected", err)
206	}
207	return count, nil
208}
209
210func revokeForwardSession(ctx context.Context, q execer, operation, id string) (bool, error) {
211	result, err := q.ExecContext(ctx, `UPDATE forward_sessions SET revoked = 1 WHERE id = ? AND revoked = 0`, id)
212	if err != nil {
213		return false, classify(operation, err)
214	}
215	changed, err := result.RowsAffected()
216	if err != nil {
217		return false, classify(operation+" rows affected", err)
218	}
219	if changed > 0 {
220		return true, nil
221	}
222	exists, err := forwardSessionExists(ctx, q, operation, id)
223	if err != nil {
224		return false, err
225	}
226	if !exists {
227		return false, fmt.Errorf("%s: %w", operation, ErrNotFound)
228	}
229	return false, nil
230}
231
232func revokeForwardSessionsByUser(ctx context.Context, q execer, operation, userID, keepID string) (int64, error) {
233	result, err := q.ExecContext(ctx, `UPDATE forward_sessions SET revoked = 1 WHERE user_id = ? AND revoked = 0 AND id <> ?`, userID, keepID)
234	if err != nil {
235		return 0, classify(operation, err)
236	}
237	count, err := result.RowsAffected()
238	if err != nil {
239		return 0, classify(operation+" rows affected", err)
240	}
241	return count, nil
242}
243
244func forwardSessionExists(ctx context.Context, q execer, operation, id string) (bool, error) {
245	var one int
246	err := q.QueryRowContext(ctx, `SELECT 1 FROM forward_sessions WHERE id = ?`, id).Scan(&one)
247	if errors.Is(err, sql.ErrNoRows) {
248		return false, nil
249	}
250	if err != nil {
251		return false, classify(operation, err)
252	}
253	return true, nil
254}
255
256func scanForwardSession(row rowScanner) (ForwardSession, error) {
257	var session ForwardSession
258	var createdAt int64
259	var expiresAt int64
260	var revoked int
261	var reauthenticatedAt sql.NullInt64
262	var browser sql.NullString
263	var client, address, userAgent sql.NullString
264	var lastSeenAt sql.NullInt64
265	if err := row.Scan(&session.ID, &session.UserID, &session.AppDomain, &createdAt, &expiresAt, &revoked, &reauthenticatedAt, &browser, &client, &address, &userAgent, &lastSeenAt); err != nil {
266		return ForwardSession{}, err
267	}
268	session.CreatedAt = intToTime(createdAt)
269	session.ExpiresAt = intToTime(expiresAt)
270	session.Revoked = revoked != 0
271	session.Browser = browser.String
272	session.ClientID = client.String
273	session.UserAgent = userAgent.String
274	if address.Valid {
275		parsed, err := netip.ParseAddr(address.String)
276		if err != nil {
277			return ForwardSession{}, err
278		}
279		session.Address = parsed
280	}
281	if lastSeenAt.Valid {
282		value := intToTime(lastSeenAt.Int64)
283		session.LastSeenAt = &value
284	}
285	if reauthenticatedAt.Valid {
286		value := intToTime(reauthenticatedAt.Int64)
287		session.ReauthenticatedAt = &value
288	}
289	return session, nil
290}