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.
# 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.migratePerintah 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:
# 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)
endHash 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:
# 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 14Modul 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:
# 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
endDengan 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:
# 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
# ...
endKlausa 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 pada Phoenix 1.8#
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.
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.
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 usersmenghasilkan contextAccounts, schemaUserdanUserToken, modulUserAuth, 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_usermengisi@current_scope, dan plug penjaga sepertirequire_authenticated_usermenghentikan 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.Tokenmenyediakan token bertanda tangan tanpa database, dengan verifikasi yang mengembalikan{:ok, _}atau{:error, alasan}.
Referensi#
- mix phx.gen.auth — Phoenix Framework — Dokumentasi generator autentikasi dan pilihannya.
- Scopes — Phoenix Framework
— Panduan scope dan
@current_scopepada Phoenix 1.8. - Phoenix.Token — Phoenix Framework — Dokumentasi token bertanda tangan yang digunakan demo.