package storage import ( "context" "database/sql" "errors" "fmt" "time" ) var ( ErrGoogleLinkedElsewhere = errors.New("storage: the Google account is linked to another user") ErrGoogleAlreadyLinked = errors.New("storage: the user already has a linked Google account") ErrGoogleNotLinked = errors.New("storage: the user has no linked Google account") ) const googleLinkOwner = `SELECT user_id FROM google_links WHERE user_id = ?1 OR google_sub = ?2 ORDER BY user_id = ?1 DESC LIMIT 1` func (s *Store) LinkGoogleAudited(ctx context.Context, input CreateGoogleLinkInput, sessionID, actor string) (GoogleLink, error) { const operation = "link google account" if err := requireValue("user id", input.UserID); err != nil { return GoogleLink{}, invalid(operation, err) } if err := requireValue("google subject", input.GoogleSub); err != nil { return GoogleLink{}, invalid(operation, err) } var link GoogleLink err := s.audited(ctx, operation, actor, func(tx *sql.Tx, at time.Time) error { var username string var disabled int err := tx.QueryRowContext(ctx, `SELECT username, disabled FROM users WHERE id = ?`, input.UserID).Scan(&username, &disabled) switch { case errors.Is(err, sql.ErrNoRows): return fmt.Errorf("%s: %w", operation, ErrNotFound) case err != nil: return classify(operation, err) case disabled != 0: return fmt.Errorf("%s: %w: the user is disabled", operation, ErrNotFound) } if err := requireLiveSession(ctx, tx, operation, sessionID, input.UserID, at); err != nil { return err } var owner string err = tx.QueryRowContext(ctx, googleLinkOwner, input.UserID, input.GoogleSub).Scan(&owner) switch { case err == nil && owner == input.UserID: return fmt.Errorf("%s: %w", operation, ErrGoogleAlreadyLinked) case err == nil: return fmt.Errorf("%s: %w", operation, ErrGoogleLinkedElsewhere) case !errors.Is(err, sql.ErrNoRows): return classify(operation, err) } if _, err := tx.ExecContext(ctx, `INSERT INTO google_links (user_id, google_sub, linked_at) VALUES (?, ?, ?)`, input.UserID, input.GoogleSub, timeToInt(at)); err != nil { return classify(operation, err) } link = GoogleLink{UserID: input.UserID, GoogleSub: input.GoogleSub, LinkedAt: at} return writeAudit(ctx, tx, operation, actor, "google.link", input.UserID, at, map[string]any{"username": username}) }) if err != nil { return GoogleLink{}, err } return link, nil } func (s *Store) UnlinkGoogleAudited(ctx context.Context, userID, keepSessionID, actor string) (GoogleLink, Revoked, error) { const operation = "unlink google account" var link GoogleLink var revoked Revoked err := s.audited(ctx, operation, actor, func(tx *sql.Tx, at time.Time) error { fields := map[string]any{} if keepSessionID != "" { if err := requireLiveSession(ctx, tx, operation, keepSessionID, userID, at); err != nil { return err } fields["kept"] = keepSessionID } username, _, err := userNameAndUID(ctx, tx, operation, userID) if err != nil { return err } var linkedAt int64 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) switch { case errors.Is(err, sql.ErrNoRows): return fmt.Errorf("%s: %w", operation, ErrGoogleNotLinked) case err != nil: return classify(operation, err) } link.LinkedAt = intToTime(linkedAt) if revoked, err = revokeCredentials(ctx, tx, operation, userID, keepSessionID, true); err != nil { return err } fields["username"] = username return writeAudit(ctx, tx, operation, actor, "google.unlink", userID, at, revoked.into(fields)) }) if err != nil { return GoogleLink{}, Revoked{}, err } return link, revoked, nil } func requireLiveSession(ctx context.Context, tx *sql.Tx, operation, sessionID, userID string, at time.Time) error { var dead int var expires int64 err := tx.QueryRowContext(ctx, `SELECT revoked, expires_at FROM forward_sessions WHERE id = ? AND user_id = ?`, sessionID, userID).Scan(&dead, &expires) switch { case errors.Is(err, sql.ErrNoRows): return invalid(operation, fmt.Errorf("the session does not belong to the user")) case err != nil: return classify(operation, err) case dead != 0 || expires <= timeToInt(at): return invalid(operation, fmt.Errorf("the session is not live")) } return nil }