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}