Autentikasi

phx.gen.auth serta magic link pada Phoenix 1.8.

Daftar isi

Phoenix menyediakan generator mix phx.gen.auth untuk membangun sistem autentikasi lengkap berupa pendaftaran, login, logout, penyetelan akun, dan penyimpanan token. Topik ini membahas arsitektur yang dihasilkan generator pada Phoenix 1.8, mencakup schema, token, scope, plug autentikasi, serta magic link yang menjadi bawaan sejak versi ini. Setelah membaca, berkas-berkas hasil generator dapat ditelusuri beserta alasan desainnya.

Menjalankan generator#

Generator dijalankan di dalam proyek hasil mix phx.new, dengan tiga argumen, yaitu nama context, nama schema, dan nama tabel jamak.

bash
# tidak-dijalankan (butuh proyek Phoenix yang utuh dan unduhan dependensi)

# membuat schema autentikasi Pengguna dengan context Accounts
mix phx.gen.auth Accounts User users

# mengunduh dependensi yang baru, misalnya bcrypt_elixir
mix deps.get

# menjalankan migration tabel users dan users_tokens
mix ecto.migrate

Perintah di atas menghasilkan lebih dari dua puluh berkas. Kelompok pertama berada di lib/nama_proyek/accounts/, berisi accounts.ex sebagai context, lalu user.ex, user_token.ex, user_notifier.ex, dan scope.ex. Kelompok kedua berada di lib/nama_proyek_web/, berisi user_auth.ex beserta controller dan template pendaftaran, login, dan penyetelan. Migration tabel users juga dibuat bersama tabel users_tokens. Penelusuran pada bagian berikut mengikuti berkas-berkas hasil generator Phoenix 1.8.16.

Tiga argumen pada perintah tersebut menetapkan penamaan di sepanjang proyek. Argumen pertama Accounts menjadi nama context, argumen kedua User menjadi nama schema, dan argumen ketiga users menjadi nama tabel sekaligus awalan jalur rute seperti /users/log-in. Nama lain dapat dipilih, misalnya context Anggota untuk aplikasi organisasi, dan generator akan menamai semua fungsi, berkas, dan rute mengikuti pilihan tersebut. Mengganti nama setelah hasilnya menyebar di sepanjang proyek jauh lebih merepotkan daripada memilihnya dengan cermat di awal.

Schema, token, dan scope#

Schema pengguna memisahkan data tersimpan dan data sementara. Potongan asli berikut memperlihatkan dua kolom virtual, yaitu password yang tidak pernah disimpan dan authenticated_at yang diturunkan dari token:

elixir Schema User hasil generator (potongan)
# tidak-dijalankan
schema "users" do
  field :email, :string
  field :password, :string, virtual: true, redact: true
  field :hashed_password, :string, redact: true
  field :confirmed_at, :utc_datetime
  field :authenticated_at, :utc_datetime, virtual: true

  timestamps(type: :utc_datetime)
end

Hash kata sandi dihitung oleh bcrypt_elixir pada changeset, dan hanya hashed_password yang mencapai database. Opsi redact: true pada dua kolom itu membuat nilai keduanya tampil sebagai [FILTERED] pada log dan laporan error, sehingga kata sandi tidak pernah bocor ke tempat lain. Changeset pendaftaran juga memvalidasi format email, panjang maksimum 160 karakter, dan keunikan email pada tabel. Sementara itu, setiap sesi login aktif diwakili satu baris pada tabel users_tokens. Token disimpan sebagai hash SHA-256, diberi context seperti "session" atau "magic", dan memiliki masa berlaku yang berbeda per context:

elixir Masa berlaku token hasil generator (potongan)
# tidak-dijalankan
@hash_algorithm :sha256
@rand_size 32

# It is very important to keep the magic link token expiry short,
# since someone with access to the email may take over the account.
@magic_link_validity_in_minutes 15
@change_email_validity_in_days 7
@session_validity_in_days 14

Modul Scope menampung pemanggil yang sedang bertindak. Struct-nya sederhana, yaitu %Scope{user: nil | %User{}}, dan fungsi Scope.for_user/1 mengembalikan %Scope{} dengan pengguna atau nil. Scope ini diteruskan ke controller maupun LiveView sebagai @current_scope, sehingga kode lapisan web tidak memanipulasi session secara langsung dan otorisasi dapat ditempatkan di satu tempat.

Plug autentikasi dan alur login#

Modul UserAuth menghubungkan token di session dengan scope. Plug fetch_current_scope_for_user dipasang pada akhir pipeline :browser dan mengisi conn.assigns.current_scope pada setiap permintaan:

elixir fetch_current_scope_for_user hasil generator (potongan)
# tidak-dijalankan
def fetch_current_scope_for_user(conn, _opts) do
  with {token, conn} <- ensure_user_token(conn),
       {user, token_inserted_at} <- Accounts.get_user_by_session_token(token) do
    conn
    |> assign(:current_scope, Scope.for_user(user))
    |> maybe_reissue_user_session_token(user, token_inserted_at)
  else
    nil -> assign(conn, :current_scope, Scope.for_user(nil))
  end
end

Dengan scope yang selalu tersedia, penjaga rute menjadi plug pendek. require_authenticated_user menghentikan permintaan pengguna tanpa akun dengan halt/1 dan pengalihan ke halaman login, sedangkan redirect_if_user_is_authenticated mencegah pengguna yang sudah masuk membuka halaman pendaftaran. Fungsi log_in_user/3 dan log_out_user/1 menutup siklusnya. Saat login, session diperbarui dan token sesi baru disimpan, sedangkan saat logout seluruh session dibersihkan dan token di database dihapus, sehingga token lama tidak dapat digunakan lagi.

Pilihan tetap masuk pada formulir login berjalan di atas mekanisme yang sama. Tombol yang menyertakan param remember_me dengan nilai true menulis token ke cookie bertanda tangan dengan masa berlaku 14 hari. Fungsi ensure_user_token/1 memulihkan session dari cookie tersebut saat token di session sudah tidak ada. Penerbitan ulang token juga otomatis: token sesi yang berumur lebih dari 7 hari diganti dengan token baru pada permintaan berikutnya, tanpa perlu pengguna masuk kembali.

Controller sesi menangani tiga bentuk login sekaligus melalui pencocokan pola pada param. Tiga klausa create/2 hasil generator 1.8 berbunyi:

elixir Tiga klausa create/2 pada UserSessionController (potongan)
# tidak-dijalankan
# magic link login
def create(conn, %{"user" => %{"token" => token} = user_params} = params) do
  case Accounts.login_user_by_magic_link(token) do
    {:ok, {user, _expired_tokens}} ->
      conn
      |> put_flash(:info, info)
      |> UserAuth.log_in_user(user, user_params)
    # ...
  end
end

# email + password login
def create(conn, %{"user" => %{"email" => email, "password" => password}}) do
  # ...
end

# magic link request
def create(conn, %{"user" => %{"email" => email}}) do
  if user = Accounts.get_user_by_email(email) do
    Accounts.deliver_login_instructions(user, &url(~p"/users/log-in/#{&1}"))
  end
  # ...
end

Klausa pertama menerima pengunjung yang mengklik tautan magic link yang memuat token, klausa kedua menerima formulir email dan kata sandi, dan klausa ketiga menerima permintaan pengiriman magic link. Ketiga jalur berakhir pada UserAuth.log_in_user/3, yang menerbitkan token sesi baru, memperbarui session, dan mengarahkan pengguna ke tujuan asalnya.

Magic link adalah cara login tanpa kata sandi. Pengguna memasukkan alamat email, aplikasi mengirim tautan yang memuat token context "magic", dan login tuntas ketika tautan tersebut dibuka. Generator 1.8 memasang pola ini sebagai bawaan pada halaman login, lengkap dengan dua formulir sekaligus, yaitu login melalui email dan login melalui kata sandi.

Pengiriman email pada lingkungan pengembangan tidak membutuhkan layanan eksternal apa pun. Proyek bawaan menggunakan adaptor email lokal, dan tautan yang dikirim dapat dilihat pada halaman pratinjau kotak surat di /dev/mailbox. Penyetelan ini memungkinkan alur login magic link diuji sejak hari pertama tanpa konfigurasi tambahan.

Beberapa keputusan keamanan magic link layak dicatat. Token magic link hanya berlaku 15 menit sebagaimana tercantum pada konstanta @magic_link_validity_in_minutes. Permintaan magic link selalu membalas dengan pesan yang sama tanpa mengungkapkan apakah email terdaftar, sehingga pemindai tidak dapat menghitung akun yang terdaftar. Selain itu, login_user_by_magic_link/1 menolak login untuk pengguna yang belum konfirmasi tetapi sudah menetapkan kata sandi, guna mencegah penyusupan melalui campuran dua jalur pendaftaran.

Token yang ditandatangani dengan Phoenix.Token#

Generator menyimpan token sesi di database, sedangkan kebutuhan lain seperti API token atau tautan unduhan terbatas dapat menggunakan Phoenix.Token, yaitu token yang ditandatangani secara kriptografis tanpa tabel tambahan. Demo berikut menerbitkan token, memverifikasinya, lalu memperlihatkan tiga bentuk kegagalan verifikasi.

elixir token.exs
Mix.install([{:phoenix, "~> 1.8"}, {:jason, "~> 1.4"}])
Logger.configure(level: :warning)

# kunci aplikasi: dalam proyek nyata ini adalah secret_key_base milik Endpoint
kunci = "rahasia-kunci-aplikasi-1234567890"

payload = %{user_id: 3}
token = Phoenix.Token.sign(kunci, "salam catatan", payload)
IO.puts("token diterbitkan")

IO.inspect(Phoenix.Token.verify(kunci, "salam catatan", token, max_age: 300), label: "token asli")
IO.inspect(Phoenix.Token.verify(kunci, "salt lain", token, max_age: 300), label: "salt salah")
IO.inspect(Phoenix.Token.verify(kunci, "salam catatan", token, max_age: 0), label: "token kedaluwarsa")
IO.inspect(Phoenix.Token.verify(kunci, "salam catatan", "token-samaran", max_age: 300), label: "token palsu")

Jalankan skripnya dengan elixir token.exs. Keluarannya deterministik dan menunjukkan bahwa verifikasi hanya berhasil apabila salt cocok, token asli, dan masih dalam masa berlaku.

text Keluaran
token diterbitkan
token asli: {:ok, %{user_id: 3}}
salt salah: {:error, :invalid}
token kedaluwarsa: {:error, :expired}
token palsu: {:error, :invalid}

Dalam proyek nyata, argumen pertama Phoenix.Token.sign/3 adalah modul endpoint dan kuncinya diambil dari secret_key_base pada konfigurasi. Demo menggunakan string rahasia sebagai kunci agar skripnya tetap berdiri sendiri. Argumen kedua, yaitu salt, memisahkan penggunaan token di antara fitur yang berbeda, karena token untuk salt tertentu tidak pernah valid untuk salt lain. Opsi max_age menetapkan masa berlaku dalam detik, dan verifikasi menolak token yang lebih tua dari batas tersebut. Keluarga hasil verifikasi ini, yaitu {:ok, data}, {:error, :invalid}, {:error, :expired}, dan {:error, :missing}, dibahas secara lengkap pada dokumentasi Phoenix.Token.

Ringkasan#

  • mix phx.gen.auth Accounts User users menghasilkan context Accounts, schema User dan UserToken, modul UserAuth, serta controller dan template pendaftaran, login, dan penyetelan.
  • Token sesi disimpan sebagai hash SHA-256 dengan context dan masa berlaku yang berbeda, sedangkan kata sandi hanya disimpan sebagai hash bcrypt.
  • Plug fetch_current_scope_for_user mengisi @current_scope, dan plug penjaga seperti require_authenticated_user menghentikan permintaan tanpa akun.
  • Magic link bawaan Phoenix 1.8 mengirim tautan berumur 15 menit dan membalas permintaan dengan pesan seragam untuk mencegah penghitungan akun.
  • Phoenix.Token menyediakan token bertanda tangan tanpa database, dengan verifikasi yang mengembalikan {:ok, _} atau {:error, alasan}.

Referensi#