package store import ( "context" "database/sql" "time" ) // OAuthAccount — связь внешнего провайдера с пользователем инстанса. // Сырого идентификатора и email здесь нет: только blind index (AGENT.md 9.2). type OAuthAccount struct { ID uint64 UserID uint64 Provider string SubjectIndex string EmailIndex string CreatedAt time.Time LastLoginAt *time.Time } // LinkOAuthAccount привязывает внешний аккаунт к пользователю. Повторная // привязка того же провайдера к другому пользователю отклоняется уникальным // индексом (provider, subject_index) — это защита от подмены владельца. func (s *Store) LinkOAuthAccount(ctx context.Context, userID uint64, provider, subjectIndex, emailIndex string) (*OAuthAccount, error) { id := s.NextID() _, err := s.writer.ExecContext(ctx, ` INSERT INTO oauth_accounts (id, user_id, provider, subject_index, email_index, created_at) VALUES (?, ?, ?, ?, ?, ?)`, int64(id), int64(userID), provider, subjectIndex, emailIndex, s.Now()) if err != nil { return nil, mapError(err) } return s.GetOAuthAccount(ctx, id) } func (s *Store) GetOAuthAccount(ctx context.Context, id uint64) (*OAuthAccount, error) { row := s.reader.QueryRowContext(ctx, ` SELECT id, user_id, provider, subject_index, email_index, created_at, last_login_at FROM oauth_accounts WHERE id = ?`, int64(id)) return scanOAuthAccount(row) } // OAuthAccountBySubject ищет привязку по blind index внешнего идентификатора. func (s *Store) OAuthAccountBySubject(ctx context.Context, provider, subjectIndex string) (*OAuthAccount, error) { row := s.reader.QueryRowContext(ctx, ` SELECT id, user_id, provider, subject_index, email_index, created_at, last_login_at FROM oauth_accounts WHERE provider = ? AND subject_index = ?`, provider, subjectIndex) return scanOAuthAccount(row) } // ListOAuthAccounts возвращает привязки пользователя: показываются в настройках // безопасности, чтобы входы через провайдеров не были невидимыми. func (s *Store) ListOAuthAccounts(ctx context.Context, userID uint64) ([]OAuthAccount, error) { rows, err := s.reader.QueryContext(ctx, ` SELECT id, user_id, provider, subject_index, email_index, created_at, last_login_at FROM oauth_accounts WHERE user_id = ? ORDER BY created_at`, int64(userID)) if err != nil { return nil, err } defer rows.Close() accounts := make([]OAuthAccount, 0, 2) for rows.Next() { account, err := scanOAuthAccount(rows) if err != nil { return nil, err } accounts = append(accounts, *account) } return accounts, rows.Err() } // TouchOAuthAccount отмечает вход через провайдера. func (s *Store) TouchOAuthAccount(ctx context.Context, id uint64) error { _, err := s.writer.ExecContext(ctx, `UPDATE oauth_accounts SET last_login_at = ? WHERE id = ?`, s.Now(), int64(id)) return err } func scanOAuthAccount(scanner interface{ Scan(...any) error }) (*OAuthAccount, error) { var ( account OAuthAccount createdAt string lastLoginAt sql.NullString ) if err := scanner.Scan(&account.ID, &account.UserID, &account.Provider, &account.SubjectIndex, &account.EmailIndex, &createdAt, &lastLoginAt); err != nil { return nil, mapError(err) } account.CreatedAt = parseTimestamp(createdAt) if lastLoginAt.Valid { value := parseTimestamp(lastLoginAt.String) account.LastLoginAt = &value } return &account, nil }