AshAuthentication을 이용하여 API 리소스 보호 구현하기 (1편)

들어가며

Ash 그리고 AshJsonApi 를 통해 리소스를 JSON API 로 제공하는 코드(이전 글 참조)까지 만들어 봤다. 리소스는 외부로 제공되지만 보호 장치가 없어 접근의 제한이 없다. AshAuthentication 을 활용하여 API 리소스를 보호해 보자.

목표

현재 다음 3개의 API 가 보호 없이 서비스되고 있다.

  • POST /api/json/tickets
  • GET/api/json/tickets
  • GET /api/json/tickets/{id}

이 3가지 API를 API key 를 통해 보호하자. 보호 시나리오는 다음과 같다.

  • 요청 헤더에 Authorization: Bearer {API key} 포함 요구
  • API Key 없는 요청은 401 Unauthorized로 차단
  • 유효한 API Key를 가진 요청만 리소스 접근 허용

이번 글 에서는 AshAuthentication 을 설치하고 사용자와 Api Key 를 만들고 이를 검증하는 과정을 살펴 본다.

AshAuthentication 설치

다음 Igniter 명령을 통해 AshAuthentication을 설치한다.

% mix igniter.install ash_authentication_phoenix --auth-strategy api_key

이 명령어 하나로 다음이 자동 생성된다:

  • User 리소스 (lib/helpdesk/accounts/user.ex)
  • ApiKey 리소스 (lib/helpdesk/accounts/api_key.ex)
  • Token 리소스 (JWT 토큰용)
  • 필요한 마이그레이션 파일들

필요에 따라 --auth-strategy 를 추가할 수 있다. 이번엔 api_key를 추가했기 때문에 ApiKey 리소스가 기본으로 만들어 졌다. strategy 는 Password, OAuth2, MagicLink 등 여러가지 전략이 있다.

기존에 User 리소스를 만들지 않았다면 기본 User 리소스와 Token 리소스를 자동으로 만든다.

사용자와 API Key 생성

먼저 User에 create 액션 추가

기본 생성된 User 리소스에는 create 액션이 없으므로 추가해야 한다. create 액션으로 User 레코드를 만들고 ApiKey 를 할당한다.

# lib/helpdesk/accounts/user.ex에 추가
actions do
  defaults [:read]
  
  # 추가할 부분
  create :create do
    primary? true
    accept []  # 빈 사용자만 생성
  end
  
  # ... 기존 액션들 ...
end

iex에서 실제 생성해보기

iex -S mix
# 1. 사용자 생성
iex> {:ok, user} = Helpdesk.Accounts.User 
|> Ash.Changeset.for_create(:create, %{}) 
|> Ash.create(authorize?: false)

# 2. API Key 생성 (1년 유효기간)
iex> {:ok, api_key} = Helpdesk.Accounts.ApiKey 
|> Ash.Changeset.for_create(:create, %{
  user_id: user.id,
  expires_at: DateTime.add(DateTime.utc_now(), 365 * 24 * 60 * 60, :second)
}) 
|> Ash.create(authorize?: false)

# 🔑 Plain text API key는 여기서만 볼 수 있음!
iex> plain_api_key = api_key.__metadata__.plaintext_api_key
"helpdesk_FlhUEreQrABNZ2XvyDIC5bUeJljjHiOGL9jeiwHPpjeMSmYosY4TupfzhIOF339E_ckbhkf"

중요한 포인트

  1. Plain text API key는 생성 직후에만 접근 가능
    • api_key.__metadata__.plaintext_api_key로만 확인 가능
    • 데이터베이스에는 해시된 값만 저장됨
  2. Prefix가 자동으로 붙음
    • helpdesk_로 시작하는 키 생성

API Key 검증

올바른 API Key로 인증 테스트

iex> api_key_string = "helpdesk_FlhUEreQrABNZ2XvyDIC5bUeJljjHiOGL9jeiwHPpjeMSmYosY4TupfzhIOF339E_ckbhkf"

iex> Helpdesk.Accounts.User 
|> Ash.Query.for_read(:sign_in_with_api_key, %{api_key: api_key_string}) 
|> Ash.read_one()

# ✅ 성공 결과
{:ok,
 %Helpdesk.Accounts.User{
   id: "62ff21cf-d1c4-4fd5-8af1-b4cbc160a4c1",
   valid_api_keys: #Ash.NotLoaded<:relationship, field: :valid_api_keys>,
   __meta__: #Ecto.Schema.Metadata<:loaded, "users">
 }}

잘못된 API Key로 테스트

iex> Helpdesk.Accounts.User 
|> Ash.Query.for_read(:sign_in_with_api_key, %{api_key: "invalid_key"}) 
|> Ash.read_one()

# ✅ 실패 결과 (예상됨)
{:error, %Ash.Error.Forbidden{...}}

마무리

리소스 보호를 위해 먼저 AshAuthentication 설치와 User, API key 생성 및 검증까지 완료했다. 다음 글 에서는 실제로 리소스를 보호하는 설정과 만들어진 API key 를 활용하여 리소스에 접근하는 방법을 살펴 보겠다.

AshJsonApi 사용해 보기

Ash 는 리소스를 JSON 형식으로 API 로 노출하는 익스텐션도 있다. 튜토리얼을 따라가며 살펴 보자.

Ash 기본 튜토리얼의 코드 부터 시작한다. 이 때 mix 프로젝트는 Phoenix 가 설치된 프로젝트여야 한다. 다음 명령어를 실행시켜 AshJsonApi 를 설치한다.

% mix igniter.install ash_json_api

위 명령어를 실행하면 단계적으로 사용자의 확인을 받아가며 AshJsonApi 가 설치 된다. 모두 Y 를 선택해 설치를 완료한 다. 이후 오류 없이 phoenix 서버를 실행 된다면 준비가 완료된 것이다. Swagger UI 가 제공되므로 바로 확인할 수 있다. API 의 기본 경로는 /api/json 이고 Swagger UI 의 경로는 /api/json/swaggerui 이다.

% mix phx.server

# 이후 브라우저에서 http://localhost:4000/api/json/swaggerui 호출
설치 즉시 제공되는 Swagger UI 화면

이제 도메인과 리소스에 익스텐션 설정을 추가하자. 다음 명령어를 실행하면 Ticket 리소스에 익스텐션 설정을 추가할 수 있다. 다른 리소스에 추가하려면 리소스 이름을 변경한다.

% mix ash.patch.extend Helpdesk.Support.Ticket json_api
# 도메인에 다음과 같이 익스텐션 설정이 추가된다
defmodule Helpdesk.Support do
  use Ash.Domain, extensions: [AshJsonApi.Domain]
  ...

# 지정한 리소스에는 다음과 같이 설정이 추가된다.
defmodule Helpdesk.Support.Ticket do
  use Ash.Resource, extensions: [AshJsonApi.Resource]
  # ...
  json_api do
    type "ticket"
  end
end

다음은 해당 리소스에 접근하기 위한 라우트 설정이 필요하다. API 주소를 설정하는 것으로 이해하면 되겠다. 라우트 설정은 도메인 또는 리소스에 선언할 수 있는데 도메인에 설정되는 것이 권장되는 것 같다. 도메인 코드에 라우트 선언을 추가하자.

defmodule Helpdesk.Support do
  use Ash.Domain, extensions: [AshJsonApi.Domain]

  # 추가하는 부분
  json_api do
    routes do
      base_route "/tickets", Helpdesk.Support.Ticket do
        get :read
        index :read
        post :open
      end
    end
  end
  
  ...
end

base_route 는 일종의 스코프처럼 동작하며 해당 리소스의 기본 주소로 사용된다. get 은 단일 리소스 획득, index 는 리소스 목록 획득, post 는 리소스를 만드는 역할을 한다. 뒤에 따라오는 atom 은 리소스의 action 이름이다. action type 과 route type 이 맞지 않으면 에러가 발생한다(예: get :open, post :read 등). routes 에서 사용할 수 있는 route type 의 예시는 다음과 같다.

routes do
  base "/posts"

  get :read
  get :me, route: "/me"
  index :read
  post :confirm_name, route: "/confirm_name"
  patch :update
  related :comments, :read
  relationship :comments, :read
  post_to_relationship :comments
  patch_relationship :comments
  delete_from_relationship :comments
end

도메인에 API 라우트 선언을 추가하고 나서 서버를 재 실행한 뒤 /api/json/swaggerui 를 다시 열어보면 선언한 API 가 문서로 만들어 진 것을 확인할 수 있다.

Swagger UI 에 추가된 API 문서

Swagger UI 에서 직접 테스트를 해 볼 수도 있고 브라우저 및 curl 로도 테스트 해 볼 수 있다.

% curl -X POST 'localhost:4000/api/json/tickets' \
--header 'Accept: application/vnd.api+json' \
--header 'Content-Type: application/vnd.api+json' \
--data-raw '{
  "data": {
    "type": "ticket",
    "attributes": {
      "subject": "This ticket was created through the JSON API"
    }
  }
}'  

% curl 'localhost:4000/api/json/tickets'

% curl 'localhost:4000/api/json/tickets/<uuid>' // uuid 는 조회할 ticket 의 uuid

목록 또는 단일 레코드를 조회 해 보면 데이터 수나 id 값은 표시되는데 레코드의 값은 표시되지 않을 수 있다.

{
  "data": [
    {
      "attributes": {},
      "id": "1731818f-e40f-4376-9356-292e17243d05",
      "links": {},
      "meta": {},
      "type": "ticket",
      "relationships": {}
    },
    {
      "attributes": {},
      "id": "310143d0-3c3a-4507-9ea4-6f84d2ba5122",
      "links": {},
      "meta": {},
      "type": "ticket",
      "relationships": {}
    },
    ...
  ]
}

이 때는 리소스의 attribute 별 public? 설정을 true 로 바꿔주면 해당 attribute 의 값이 표시된다.

defmodule Helpdesk.Support.Ticket do
  ...

  attributes do
    uuid_primary_key :id

    attribute :subject, :string do
      allow_nil? false
      public? true  # 추가
    end

    attribute :status, :atom do
      constraints one_of: [:open, :closed]
      public? true
      default :open
      allow_nil? false  # 추가
    end
  end

  ...
end
{
  "data": [
    {
      "attributes": {
        "status": "open",
        "subject": "Issue 3"
      },
      "id": "1731818f-e40f-4376-9356-292e17243d05",
      "links": {},
      "meta": {},
      "type": "ticket",
      "relationships": {}
    },
    {
      "attributes": {
        "status": "closed",
        "subject": "Issue 4"
      },
      "id": "310143d0-3c3a-4507-9ea4-6f84d2ba5122",
      "links": {},
      "meta": {},
      "type": "ticket",
      "relationships": {}
    },
  ...
}

AshJsonApi 익스텐션을 통해 비교적 손쉽게 리소스에 REST API 를 붙일 수 있었다. 내부 리소스를 외부에 직접 노출하는 것에 대한 이견은 있을 수 있지만 만약 여러가지 이유로 인해 내부 리소스를 바로 외부에 노출하기로 결정했다면 매우 유용한 도구가 될 것 같다. Swagger 형태의 API 문서가 같이 제공되는 점도 큰 장점이라 할 수 있겠다.