Raw
  1package storage
  2
  3import (
  4	"context"
  5	"database/sql"
  6	"errors"
  7	"fmt"
  8	"time"
  9)
 10
 11var (
 12	ErrGoogleLinkedElsewhere = errors.New("storage: the Google account is linked to another user")
 13	ErrGoogleAlreadyLinked   = errors.New("storage: the user already has a linked Google account")
 14	ErrGoogleNotLinked       = errors.New("storage: the user has no linked Google account")
 15)
 16
 17const googleLinkOwner = `SELECT user_id FROM google_links WHERE user_id = ?1 OR google_sub = ?2 ORDER BY user_id = ?1 DESC LIMIT 1`
 18
 19func (s *Store) LinkGoogleAudited(ctx context.Context, input CreateGoogleLinkInput, sessionID, actor string) (GoogleLink, error) {
 20	const operation = "link google account"
 21	if err := requireValue("user id", input.UserID); err != nil {
 22		return GoogleLink{}, invalid(operation, err)
 23	}
 24	if err := requireValue("google subject", input.GoogleSub); err != nil {
 25		return GoogleLink{}, invalid(operation, err)
 26	}
 27	var link GoogleLink
 28	err := s.audited(ctx, operation, actor, func(tx *sql.Tx, at time.Time) error {
 29		var username string
 30		var disabled int
 31		err := tx.QueryRowContext(ctx, `SELECT username, disabled FROM users WHERE id = ?`, input.UserID).Scan(&username, &disabled)
 32		switch {
 33		case errors.Is(err, sql.ErrNoRows):
 34			return fmt.Errorf("%s: %w", operation, ErrNotFound)
 35		case err != nil:
 36			return classify(operation, err)
 37		case disabled != 0:
 38			return fmt.Errorf("%s: %w: the user is disabled", operation, ErrNotFound)
 39		}
 40		if err := requireLiveSession(ctx, tx, operation, sessionID, input.UserID, at); err != nil {
 41			return err
 42		}
 43		var owner string
 44		err = tx.QueryRowContext(ctx, googleLinkOwner, input.UserID, input.GoogleSub).Scan(&owner)
 45		switch {
 46		case err == nil && owner == input.UserID:
 47			return fmt.Errorf("%s: %w", operation, ErrGoogleAlreadyLinked)
 48		case err == nil:
 49			return fmt.Errorf("%s: %w", operation, ErrGoogleLinkedElsewhere)
 50		case !errors.Is(err, sql.ErrNoRows):
 51			return classify(operation, err)
 52		}
 53		if _, err := tx.ExecContext(ctx, `INSERT INTO google_links (user_id, google_sub, linked_at) VALUES (?, ?, ?)`, input.UserID, input.GoogleSub, timeToInt(at)); err != nil {
 54			return classify(operation, err)
 55		}
 56		link = GoogleLink{UserID: input.UserID, GoogleSub: input.GoogleSub, LinkedAt: at}
 57		return writeAudit(ctx, tx, operation, actor, "google.link", input.UserID, at, map[string]any{"username": username})
 58	})
 59	if err != nil {
 60		return GoogleLink{}, err
 61	}
 62	return link, nil
 63}
 64
 65func (s *Store) UnlinkGoogleAudited(ctx context.Context, userID, keepSessionID, actor string) (GoogleLink, Revoked, error) {
 66	const operation = "unlink google account"
 67	var link GoogleLink
 68	var revoked Revoked
 69	err := s.audited(ctx, operation, actor, func(tx *sql.Tx, at time.Time) error {
 70		fields := map[string]any{}
 71		if keepSessionID != "" {
 72			if err := requireLiveSession(ctx, tx, operation, keepSessionID, userID, at); err != nil {
 73				return err
 74			}
 75			fields["kept"] = keepSessionID
 76		}
 77		username, _, err := userNameAndUID(ctx, tx, operation, userID)
 78		if err != nil {
 79			return err
 80		}
 81		var linkedAt int64
 82		err = tx.QueryRowContext(ctx, `DELETE FROM google_links WHERE user_id = ? RETURNING user_id, google_sub, linked_at`, userID).Scan(&link.UserID, &link.GoogleSub, &linkedAt)
 83		switch {
 84		case errors.Is(err, sql.ErrNoRows):
 85			return fmt.Errorf("%s: %w", operation, ErrGoogleNotLinked)
 86		case err != nil:
 87			return classify(operation, err)
 88		}
 89		link.LinkedAt = intToTime(linkedAt)
 90		if revoked, err = revokeCredentials(ctx, tx, operation, userID, keepSessionID, true); err != nil {
 91			return err
 92		}
 93		fields["username"] = username
 94		return writeAudit(ctx, tx, operation, actor, "google.unlink", userID, at, revoked.into(fields))
 95	})
 96	if err != nil {
 97		return GoogleLink{}, Revoked{}, err
 98	}
 99	return link, revoked, nil
100}
101
102func requireLiveSession(ctx context.Context, tx *sql.Tx, operation, sessionID, userID string, at time.Time) error {
103	var dead int
104	var expires int64
105	err := tx.QueryRowContext(ctx, `SELECT revoked, expires_at FROM forward_sessions WHERE id = ? AND user_id = ?`, sessionID, userID).Scan(&dead, &expires)
106	switch {
107	case errors.Is(err, sql.ErrNoRows):
108		return invalid(operation, fmt.Errorf("the session does not belong to the user"))
109	case err != nil:
110		return classify(operation, err)
111	case dead != 0 || expires <= timeToInt(at):
112		return invalid(operation, fmt.Errorf("the session is not live"))
113	}
114	return nil
115}