Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest

container:
image: elixir:1.13.3-slim
image: elixir:1.18.4-otp-27-slim

services:
redis: redis:alpine
Expand Down Expand Up @@ -73,7 +73,7 @@ jobs:
runs-on: ubuntu-latest

container:
image: elixir:1.13.3-slim
image: elixir:1.18.4-otp-27-slim

steps:
- name: Checkout
Expand All @@ -97,7 +97,7 @@ jobs:
runs-on: ubuntu-latest

container:
image: elixir:1.13.3-slim
image: elixir:1.18.4-otp-27-slim

steps:
- name: Checkout
Expand Down
4 changes: 2 additions & 2 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
elixir 1.14.3-otp-25
erlang 25.3.2.16
elixir 1.18.5-otp-27
erlang 27.3.4.17
nodejs 14.20.1
163 changes: 163 additions & 0 deletions BIGCOMMERCE_SYNC.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
# BigCommerce 계정 동기화 개선

## 개요

이 문서는 BigCommerce와 자사 계정 간 동기화 문제를 해결하기 위한 개선사항을 설명합니다.

## 구현된 개선사항

### 1. 로그인 시 자동 재동기화

**파일**: `lib/recognizer_web/authentication.ex`

**기능**:
- 사용자가 로그인할 때 BigCommerce 계정이 동기화되지 않은 경우 자동으로 백그라운드에서 동기화 시도
- 동기화 실패 시에도 로그인은 정상적으로 진행 (사용자 경험에 영향 없음)
- 상세한 로그를 통해 동기화 성공/실패 추적 가능

**로그 예시**:
```
[info] Attempting BigCommerce sync for user 123 (user@example.com) during login
[info] Successfully synced BigCommerce customer for user 123 during login
```

**작동 방식**:
```elixir
def log_in_user(conn, user, params \\ %{}) do
case Recognizer.Accounts.user_prompts(user) do
{:ok, user} ->
# 로그인 성공 시 백그라운드에서 BigCommerce 동기화 시도
ensure_bigcommerce_user_async(user)
# ... 나머지 로그인 처리
end
end
```

### 2. Two-Factor 세션 설정 공통 함수

**파일**: `lib/recognizer_web/authentication.ex`

**함수**: `put_two_factor_session/2`

**변경 내용**:
- `UserSessionController`와 `UserOAuthController`에 중복되어 있던 코드를 공통 함수로 추출
- 코드 중복 제거로 유지보수성 향상

**Before (중복 코드)**:
```elixir
# UserSessionController
conn
|> put_session(:two_factor_user_id, user.id)
|> put_session(:two_factor_sent, false)
|> put_session(:two_factor_issue_time, System.system_time(:second))

# UserOAuthController
conn
|> put_session(:two_factor_user_id, user.id)
|> put_session(:two_factor_sent, false)
```

**After (공통 함수)**:
```elixir
# 두 컨트롤러 모두
conn |> Authentication.put_two_factor_session(user)
```

### 3. BigCommerce 동기화 헬퍼 함수

**파일**: `lib/recognizer_web/authentication.ex`

**함수**: `ensure_bigcommerce_user_async/1`

**기능**:
- 비동기로 BigCommerce 동기화 수행 (로그인 속도에 영향 없음)
- 이미 동기화된 사용자는 자동으로 스킵
- 상세한 로깅으로 디버깅 용이

## 문제 해결

### apatura.inc@protonmail.com 케이스

**해결 방법**: 사용자가 다시 로그인하면 자동으로 동기화됩니다.

1. 사용자에게 로그인 요청
2. 로그인 시 자동으로 백그라운드에서 BigCommerce 동기화 시도
3. 성공 시 이후 주문 가능

### 긴급 상황: 콘솔 접근

클라우드 환경에서 긴급하게 수동 동기화가 필요한 경우:

```bash
# Kubernetes pod 접속
kubectl exec -it <recognizer-pod-name> -- iex -S mix

# IEx 콘솔에서 실행
iex> user = Recognizer.Accounts.get_user_by_email("apatura.inc@protonmail.com")
iex> Recognizer.BigCommerce.get_or_create_customer(user)
```

또는 Docker Compose:
```bash
docker-compose exec recognizer iex -S mix
```

## 영향 받는 파일

### 수정된 파일
- `lib/recognizer_web/authentication.ex` - 로그인 로직 및 공통 함수 추가
- `lib/recognizer_web/controllers/accounts/user_session_controller.ex` - 중복 코드 제거
- `lib/recognizer_web/controllers/accounts/user_oauth_controller.ex` - 중복 코드 제거

## 로그인 시 자동 동기화가 충분한 이유

### ✅ 대부분의 케이스를 자동 해결
- **계정 생성 시 실패**: 다음 로그인에서 자동 재시도
- **일시적 API 오류**: 다음 로그인에서 자동 재시도
- **네트워크 문제**: 다음 로그인에서 자동 재시도

### ✅ 보안 이점
- **API 엔드포인트 없음**: 악용 가능성 제로
- **Rate limiting 불필요**: 사용자가 자연스럽게 제한됨
- **감사 로그 불필요**: 로그인 로그로 추적 가능
- **권한 관리 불필요**: 사용자 본인만 동기화됨

### ✅ 사용자 경험
- **투명함**: 사용자는 아무것도 할 필요 없음
- **빠름**: 백그라운드 처리로 로그인 속도 영향 없음
- **신뢰성**: 실패해도 로그인은 성공

### ⚠️ 제한 사항
**로그인하지 않는 사용자는 동기화 안 됨**
- 하지만 BigCommerce 동기화는 주문 시 필요
- 주문하려면 로그인 필수
- 따라서 실제로는 문제 없음

### 🚨 긴급 상황 대응
로그인 전에 동기화가 꼭 필요한 경우 (매우 드묾):
- kubectl/docker exec로 콘솔 접속
- IEx에서 수동 동기화
- 완전한 접근 제어 및 감사 추적

## 향후 개선 가능 사항

1. **동기화 재시도 큐**: 실패한 동기화를 주기적으로 재시도하는 백그라운드 작업
2. **동기화 상태 필드**: `users` 테이블에 `bc_sync_status` 필드 추가
3. **모니터링 및 알림**: 동기화 실패율 추적 및 알림 시스템
4. **이벤트 소싱**: 동기화 이벤트를 별도 테이블에 저장하여 추적성 향상

## 테스트

컴파일 확인:
```bash
mix compile
```

## 참고사항

- ✅ 로그인 시 자동 동기화는 백그라운드에서 수행되므로 로그인 속도에 영향 없음
- ✅ 동기화 실패 시에도 사용자는 정상적으로 로그인 가능
- ✅ 모든 동기화 시도는 로그에 기록되어 추적 가능
- ✅ API 엔드포인트가 없어 보안 위험 최소화
- ✅ 사용자가 로그인할 때마다 자동으로 재시도되어 결국 해결됨

2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# -----------------------------------------------
# 1) Build Elixir
# -----------------------------------------------
FROM elixir:1.13.3-slim as build-elixir
FROM elixir:1.18.4-otp-27-slim as build-elixir

# ARG is available during the build and not in the final container
# https://vsupalov.com/docker-arg-vs-env/
Expand Down
4 changes: 0 additions & 4 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,6 @@ config :logger, :console,

config :grpc, start_server: true

config :logger_json, :backend,
formatter: LoggerJSON.Formatters.DatadogLogger,
metadata: :all

config :phoenix, :json_library, Jason

config :recognizer, :message_queues, []
Expand Down
5 changes: 3 additions & 2 deletions config/prod.exs
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import Config

config :recognizer, RecognizerWeb.Endpoint,
url: [scheme: "https", port: 443],
http: [port: 8080],
cache_static_manifest: "priv/static/cache_manifest.json",
gzip: true,
server: true

config :logger,
backends: [LoggerJSON],
level: :info
level: :info,
default_handler: [formatter: {LoggerJSON.Formatters.Datadog, metadata: :all}]

config :recognizer, Recognizer.Repo, log: false

Expand Down
2 changes: 1 addition & 1 deletion config/releases.exs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ config :recognizer,
hal_token: recognizer_config["HAL_TOKEN"]

config :recognizer, RecognizerWeb.Endpoint,
url: [scheme: "https", port: 443, host: recognizer_config["DOMAIN"]],
url: [host: recognizer_config["DOMAIN"]],
secret_key_base: recognizer_config["SECRET_KEY_BASE"]

config :recognizer, Recognizer.Repo,
Expand Down
2 changes: 1 addition & 1 deletion config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ config :recognizer, RecognizerWeb.Endpoint,
http: [port: 4002],
server: false

config :logger, level: :warn
config :logger, level: :warning

config :hammer,
backend:
Expand Down
94 changes: 23 additions & 71 deletions lib/recognizer/accounts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -226,12 +226,7 @@ defmodule Recognizer.Accounts do

{:error, error} ->
# Log the error but continue with account creation
# Auto-sync during login will retry the synchronization
Logger.error(
"[BigCommerce Sync] ✗ REGISTRATION SYNC FAILED for user #{user.id} (#{user.email}) - " <>
"Reason: #{inspect(error)} - Will retry on next login"
)

Logger.error("BigCommerce customer creation failed but continuing account process: #{inspect(error)}")
{:ok, user}
end
else
Expand All @@ -252,48 +247,30 @@ defmodule Recognizer.Accounts do
error
end

defp maybe_send_newsletter_after_registration({:ok, user} = previous_response, attrs) do
newsletter_value = normalize_newsletter_value(Map.get(attrs, "newsletter"))
if newsletter_value, do: start_newsletter_update_task(user, true)
previous_response
end

defp maybe_send_newsletter_after_registration(previous_response, _attrs) do
previous_response
end

defp normalize_newsletter_value(value) when value in [true, "true"], do: true
defp normalize_newsletter_value(value) when value in [false, "false", nil], do: false
defp normalize_newsletter_value(_), do: false

defp start_newsletter_update_task(user, newsletter_value) do
defp maybe_send_newsletter_after_registration({:ok, user} = previous_response, %{"newsletter" => "true"}) do
# Process asynchronously to avoid blocking the account creation if newsletter registration fails
Task.start(fn ->
try do
user_with_newsletter = Map.put(user, :newsletter, newsletter_value)
result = Recognizer.Hal.update_newsletter(user_with_newsletter)
log_newsletter_result(user.id, result)
require Logger
result = Recognizer.Hal.update_newsletter(user)
Logger.info("Newsletter registration completed for user #{user.id}: #{inspect(result)}")
catch
kind, reason ->
Logger.error("Newsletter update crashed for user #{user.id}: #{inspect(kind)}, #{inspect(reason)}")
require Logger
Logger.error("Newsletter registration failed for user #{user.id}: #{inspect(kind)}, #{inspect(reason)}")
Logger.error(Exception.format_stacktrace(__STACKTRACE__))
end
end)
end

defp log_newsletter_result(user_id, result) do
case result do
:ok ->
Logger.info("Newsletter update successful for user #{user_id}")

:ok_not_updated ->
Logger.info("Newsletter already up to date for user #{user_id}")
previous_response
end

{:error, reason} ->
Logger.warn("Newsletter update failed for user #{user_id}: #{inspect(reason)}")
defp maybe_send_newsletter_after_registration(previous_response, %{"newsletter" => false}) do
previous_response
end

other ->
Logger.debug("Newsletter update returned: #{inspect(other)} for user #{user_id}")
end
defp maybe_send_newsletter_after_registration(previous_response, _attrs) do
previous_response
end

@doc """
Expand Down Expand Up @@ -338,10 +315,7 @@ defmodule Recognizer.Accounts do

"""
def update_user(user, attrs) do
if Map.has_key?(attrs, "newsletter") do
start_newsletter_update_task(user, normalize_newsletter_value(Map.get(attrs, "newsletter")))
end

if Map.has_key?(attrs, "newsletter"), do: Recognizer.Hal.update_newsletter(attrs)
changeset = User.changeset(user, attrs)

with {:ok, updated_user} <- Repo.update(changeset),
Expand Down Expand Up @@ -508,9 +482,6 @@ defmodule Recognizer.Accounts do
@doc """
Delivers the reset password email to the given user.

For security reasons, if the user account is OAuth-only (no password set),
we silently skip sending the email to prevent account enumeration attacks.

## Examples

iex> deliver_user_reset_password_instructions(user, &Routes.user_reset_password_url(conn, :edit, &1))
Expand All @@ -519,23 +490,12 @@ defmodule Recognizer.Accounts do
"""
def deliver_user_reset_password_instructions(%User{} = user, reset_password_url_fun)
when is_function(reset_password_url_fun, 1) do
# Preload OAuth associations to check if this is an OAuth-only account
user_with_oauths = Repo.preload(user, :oauths)

if Enum.any?(user_with_oauths.oauths) do
# OAuth account - silently skip sending email for security
# Return success to prevent account enumeration
Logger.info("Password reset requested for OAuth-only account #{user.id}, skipping email")
{:ok, :skipped}
else
# Regular password account - send reset email
{:ok, token, _claims} = Guardian.encode_and_sign(user, %{"typ" => "reset_password"})
{:ok, token, _claims} = Guardian.encode_and_sign(user, %{"typ" => "reset_password"})

Notification.deliver_reset_password_instructions(
user,
reset_password_url_fun.(token)
)
end
Notification.deliver_reset_password_instructions(
user,
reset_password_url_fun.(token)
)
end

@doc """
Expand Down Expand Up @@ -692,16 +652,8 @@ defmodule Recognizer.Accounts do
settings.
"""
def check_two_factor_notification_time(user) do
case get_new_two_factor_settings(user) do
{:ok, attrs} when not is_nil(attrs) ->
check_two_factor_notification_time(attrs, 100)

{:ok, nil} ->
{:error, :no_two_factor_settings}

{:error, reason} ->
{:error, reason}
end
{:ok, attrs} = get_new_two_factor_settings(user)
check_two_factor_notification_time(attrs, 100)
end

def check_two_factor_notification_time(attrs, two_factor_issue_time) do
Expand Down
Loading
Loading