如何處理 login form, session, password hashing。
Preparing for Authentication
User 在註冊時要提供 credentials (通常就是密碼),然後用加密的方式儲存在 DB 中。當 user 透過 credentials 登入後,要產生一個 session,然後就被授權可以存取資料,直接 session expired 或是 logout。
我們可以利用 comeonin 這個 package 處理 hashing 的功能,comeonin 可選用 Argon2, Bcrypt and Pbkdf2 (sha512 and sha256) 任何一種加密演算法,在範例裡是使用 Bcrypt。
修改 /rumbl/mix.exs,增加 :comeonin :bcrypt_elixir 兩個packages,同時在 applications 裡面加上 :comeonin。
def application do
[mod: {Rumbl, []},
applications: [:phoenix, :phoenix_pubsub, :phoenix_html, :cowboy, :logger, :gettext,
:phoenix_ecto, :mariaex, :comeonin]]
end
defp deps do
[{:phoenix, "~> 1.2.5"},
{:phoenix_pubsub, "~> 1.0"},
{:phoenix_ecto, "~> 3.0"},
{:mariaex, ">= 0.0.0"},
{:phoenix_html, "~> 2.6"},
{:phoenix_live_reload, "~> 1.0", only: :dev},
{:gettext, "~> 0.11"},
{:comeonin, "~> 4.0"},
#{:argon2_elixir, "~> 1.2"},
{:bcrypt_elixir, "~> 1.0"},
#{:pbkdf2_elixir, "~> 0.12"},
{:cowboy, "~> 1.0"}]
end
取得並編譯 deps
$ mix deps.get
$ mix deps.compile
$ mix phoenix.server
Managing Registration Changesets
先前在 /web/models/user.ex 填寫了 changeset
def changeset(model, params \\ %{}) do
model
|> cast(params, ~w(name username), [])
|> validate_length(:username, min: 1, max: 20)
end
Ecto.Changeset.cast 是用來轉換 map 為 changeset,另外為了資安考量,可限制 inbound parameters 一些條件。
defmodule Rumbl.User do
use Rumbl.Web, :model
schema "users" do
field :name, :string
field :username, :string
field :password, :string, virtual: true
field :password_hash, :string
timestamps()
end
# 參數: a User struct and parameters
# User struct 傳入 cast,限制 name 及 username 都是必要欄位
# 並將 requied & optional values 轉成 schema types
# 接下來傳入 validate_length,檢查 username 資料長度
# 如果沒有任何 parameters,必須傳入 empty map %{}
def changeset(model, params \\ %{}) do
model
|> cast(params, ~w(name username), [])
|> validate_length(:username, min: 1, max: 20)
end
# 用上一個 changeset 處理非敏感資料,另外寫這個 changeset 處理密碼
def registration_changeset(model, params) do
model
|> changeset(params)
|> cast(params, ~w(password), [])
|> validate_length(:password, min: 6, max: 100)
|> put_pass_hash()
end
# 取出 changeset 裡面的 password 將他以 comeonin 加密後,儲存到 password_hash
# 回傳新的 changeset
defp put_pass_hash(changeset) do
case changeset do
%Ecto.Changeset{valid?: true, changes: %{password: pass}} ->
put_change(changeset, :password_hash, Comeonin.Bcrypt.hashpwsalt(pass))
_ ->
changeset
end
end
end
測試
$ iex -S mix
iex(1)> alias Rumbl.User
Rumbl.User
# 密碼太短,不滿足密碼的條件,所以 valid? 為 false
iex(2)> changeset = User.registration_changeset(%User{}, %{ username: "max", name: "Max", password: "123" })
#Ecto.Changeset<action: nil,
changes: %{name: "Max", password: "123", username: "max"},
errors: [password: {"should be at least %{count} character(s)",
[count: 6, validation: :length, min: 6]}], data: #Rumbl.User<>,
valid?: false>
iex(3)> changeset.valid?
false
iex(4)> changeset.changes
%{name: "Max", password: "123", username: "max"}
# 把密碼加長,valid? 為 true
iex(5)> changeset = User.registration_changeset(%User{}, %{ username: "max", name: "Max", password: "123456" })
#Ecto.Changeset<action: nil,
changes: %{name: "Max", password: "123456",
password_hash: "$2b$12$ZQ2vLBhdeanadLKHysBctuY9e2jlhWBciBvyM2rAPh5FtUmY0h6IC",
username: "max"}, errors: [], data: #Rumbl.User<>, valid?: true>
iex(6)> changeset.valid? true
iex(7)> changeset.changes %{name: "Max", password: "123456",
password_hash: "$2b$12$ZQ2vLBhdeanadLKHysBctuY9e2jlhWBciBvyM2rAPh5FtUmY0h6IC",
username: "max"}
用以下這個方式,把所有 users 的 password_hash 填上測試用密碼
for u <- Rumbl.Repo.all(User) do
Rumbl.Repo.update!(User.registration_changeset(u, %{
password: u.password_hash || "temppass"
}))
end
iex(15)> for u <- Rumbl.Repo.all(User) do
...(15)> Rumbl.Repo.update!(User.registration_changeset(u, %{
...(15)> password: u.password_hash || "temppass"
...(15)> }))
...(15)> end
[debug] QUERY OK source="users" db=17.9ms
SELECT u0.`id`, u0.`name`, u0.`username`, u0.`password_hash`, u0.`inserted_at`, u0.`updated_at` FROM `users` AS u0 []
[debug] QUERY OK db=5.9ms
UPDATE `users` SET `password_hash` = ?, `updated_at` = ? WHERE `id` = ? ["$2b$12$whoMkt3Va91Mk6yAmaM0sO/3dgh3nhNCem0M6xMB5UIMXo7vERw6O", {{2017, 9, 5}, {1, 22, 52, 877576}}, 1]
[debug] QUERY OK db=2.5ms
UPDATE `users` SET `password_hash` = ?, `updated_at` = ? WHERE `id` = ? ["$2b$12$pIb.b4jFbzBLvxT7Ud9LA.w.dtPyx9yvung/Z2/Ii5JSnD8E3CaK.", {{2017, 9, 5}, {1, 22, 53, 233596}}, 2]
[debug] QUERY OK db=2.4ms
UPDATE `users` SET `password_hash` = ?, `updated_at` = ? WHERE `id` = ? ["$2b$12$soPEIgY.vm6HJi/WTfcF3uHevDkqj.yBV0cW7mOHFnZdiAo3LJ.tK", {{2017, 9, 5}, {1, 22, 53, 589897}}, 3]
[%Rumbl.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">, id: 1,
.....
Create Users
修改 /web/controllers/usercontroller.ex 的 create,改成使用 registrationchangeset
def create(conn, %{"user" => user_params}) do
changeset = User.registration_changeset(%User{}, user_params)
# Repo.insert 的結果有兩種,正確時,會 redirect 到 users index 首頁
# 錯誤時,會停留在 new.html 的畫面
case Repo.insert(changeset) do
{:ok, user} ->
conn
|> put_flash(:info, "#{user.name} created!")
|> redirect(to: user_path(conn, :index))
{:error, changeset} ->
render(conn, "new.html", changeset: changeset)
end
end
Plug for authentication
有兩種 plugs
- module plugs 兩個 functions with some configuration details
- function plugs 單一 function
可在 /lib/rumbl/endpoint.ex 裡面看到有使用 module plug
plug Plug.Logger
在 /web/router.ex 裡面看到有使用 function plug
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_flash
plug :protect_from_forgery
plug :put_secure_browser_headers
end
Module Plugs
有時會需要分享 plug 給不同 modules 使用,這種狀況就要使用 module plug。
module plug 必須要有兩個 functions: init 及 call,以下這是一個最簡單的 module plug。
defmodule NothingPlug do
def init(opts) do
opts
end
def call(conn, _opts) do
conn
end
end
通常 plug 會 transform a connection,主要工作是在 call 裡面實作。 init 是在 compile time 處理,提供 transform options。
init 會在 compilation time 被呼叫,這是驗證 options 並做一些準備工作的地方。
module 及 function plugs 的 request interface 都一樣,第一個參數 conn,是傳入 plug 的資料,裡面有 request 的相關詳細資料,可以在 plug 改變它,所有 plugs 都是以 conn 為參數,結束時回傳 conn。
connections 簡寫為 conn,他是 Plug.Conn struct,這是 Plug 的基礎。
Plug.Conn Fields: 裡面有關於 web request, response 所有資訊
Phoenix 是使用 Cowboy 為 web server,但也可以改用其他的 web server。
- host
method
request method, GET/POST
path_info
splits into List, ex: ["admin", "users"]
script_name
initial portion of the URL's path, 關於 application routing 的部分, 例如 ["sub", "app"]
port
peer
TCP peer ex: {{127,0,0,1},12345}
remote_ip
{192,168,1,1},如果經過HAProxy,會變成 Forwarded-For 的 header 欄位
req_headers
request headers, ex: [{"content-type", "text/plain"}],這些 header 都會是小寫字母
scheme
request protocol, :https
query_string
request query string as binary, ex: "param1=test"
下個部分是 fetchable fields
fetchable field 會在取用時,才會即時運算出來,所以需要一點運算時間,如果沒有取用就會是空的。
cookies
request + response cookies
body_params
request body params,透過 Plug.Parsers 產生的
query_params
request query params,透過 fetchqueryparams/2 產生的
path_params
requestpathparams,透過 Plug.Router 產生的
params
request params,包含 :bodyparams + :queryparams + :path_params
req_cookies
request cookies 不含 response cookie
接下來是用來處理 web requests 的欄位,可用來 encrypt cookies,處理 user-defined functions
assigns
shared user data as a map,user 自訂儲存資料的 map
owner
保存這個 connection 的 Elixir process
halted
是否有被 pipeline 中止的 boolean status flag,例如 authorization failed
secretkeybase
用來 verify and encrypt cookies 的 secret key,可用 Plug.Crypto.KeyGenerator.generate/3 產生 keys
state
conenction state :set, :sent
因為 Plug 會處理任一個 request 的整個過程,包含 request 及 response
Plug.Conn 針對 response 提供了以下這些欄位
resp_body
response body 預設為空字串,會在 reponse 發送後,設定為 nil (除非是 test connections)
resp_charset
response charset,預設為 "utf-8"
resp_cookies
response cookies with name and options
resp_headers
response headers as a list of tuples,預設 cache-control 設定為 "max-age=0, private, must-revalidate"
status
response status
Plug 支援保留給 adapter 及 framework 使用的 private fileds
adapter
儲存 adapter information in a tuple
private
shared library data as a map
Writing an Authentication Plug
authentication process 會有兩個步驟,首先會在 user 註冊或登入時,在 session 儲存 user ID,第二步,會檢查 session 是否有新 user,並在每一次 request 都儲存在 conn.assigns,可在 controllers 及 views 裡面使用這些資訊。
新增 /web/controllers/auth.ex
defmodule Rumbl.Auth do
import Plug.Conn
# 由 repository 取得 options
def init(opts) do
# Keyword.fetch! 會在找不到 key 時,raises an exception
# Rumbl.Auth 會取用 :repo option
Keyword.fetch!(opts, :repo)
end
# 收到 init 的 repository
def call(conn, repo) do
# 檢查 session 是否有存在 :user_id
user_id = get_session(conn, :user_id)
# 如果有 user_id 且 User DB 有這個 user_id
# 利用 assign 把這個 user 資料存放在 conn.assigns
user = user_id && repo.get(Rumbl.User, user_id)
# 後面可以用 :current_user 取得 User 資料
assign(conn, :current_user, user)
end
end
新增 plug 到 router,放在 browser pipeline 的最後一個
/web/router.ex
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_flash
plug :protect_from_forgery
plug :put_secure_browser_headers
plug Rumbl.Auth, repo: Rumbl.Repo
end
Restricting Access
Rumbl.Auth plug 現在會處理 request information 並轉換 conn,增加 :current_user 到 conn.assigns,後面的 plugs 就可以判斷 user 是不是已經登入了。
如果不希望 user 在登入前就能使用 Rumbl.UserController 的 :index 及 :show
要修改 :index 並增加 authenticate(conn)
defmodule Rumbl.UserController do
use Rumbl.Web, :controller
# 取得所有 users
def index(conn, _params) do
case authenticate(conn) do
%Plug.Conn{halted: true} = conn ->
conn
conn ->
users = Repo.all(User)
render conn, "index.html", users: users
end
end
# 用 halt() 中止後面的 transformations
defp authenticate(conn) do
if conn.assigns.current_user do
conn
else
conn
|> put_flash(:error, "You must be logged in to access that page")
|> redirect(to: page_path(conn, :index))
|> halt()
end
end
end
在瀏覽頁面 http://localhost:4000/users 時,就會出現
將剛剛的 authenticate 調整一下,就可以變成 function plug
defmodule Rumbl.UserController do
use Rumbl.Web, :controller
plug :authenticate when action in [:index, :show]
def index(conn, _params) do
users = Repo.all(Rumbl.User)
render conn, "index.html", users: users
end
# 顯示某個 id 的 user
def show(conn, %{"id" => id}) do
user = Repo.get(Rumbl.User, id)
render conn, "show.html", user: user
end
# 用 halt() 中止後面的 transformations
defp authenticate(conn, _opts) do
if conn.assigns.current_user do
conn
else
conn
|> put_flash(:error, "You must be logged in to access that page")
|> redirect(to: page_path(conn, :index))
|> halt()
end
end
end
在瀏覽頁面 http://localhost:4000/users 以及 http://localhost:4000/users/1 時,就會出現一樣的錯誤訊息。
Plug pipelines 會在每一個 plug 執行中間,檢查 halted:true,以便中止後續的 plugs。
如果是這樣的 plugs
plug :one
plug Two
plug :three, some: :option
會編譯為
case one(conn, []) do
%{halted: true} = conn -> conn
conn ->
case Two.call(conn, Two.init([])) do
%{halted: true} = conn -> conn
conn ->
case three(conn, some: :option) do
%{halted: true} = conn -> conn
conn -> conn
end
end
end
Login
/web/controllers/auth.ex 增加 login function
defmodule Rumbl.Auth do
import Plug.Conn
# Plug.Conn struct 有個欄位 assigns,可用 assign 設定
# 將 user 設定為 :current_user
# 把 user.id 放到 session
# 設定 :renew option 為 true,configure_session 很重要,他會發送 session cookie 給 client
def login(conn, user) do
conn
|> assign(:current_user, user)
|> put_session(:user_id, user.id)
|> configure_session(renew: true)
end
end
修改 /web/controllers/user_controller.ex,登入成功後,馬上呼叫 Rumbl.Auth.login(user)
def create(conn, %{"user" => user_params}) do
changeset = User.registration_changeset(%User{}, user_params)
# Repo.insert 的結果有兩種,正確時,會 redirect 到 users index 首頁
# 錯誤時,會停留在 new.html 的畫面
case Repo.insert(changeset) do
{:ok, user} ->
conn
|> Rumbl.Auth.login(user)
|> put_flash(:info, "#{user.name} created!")
|> redirect(to: user_path(conn, :index))
{:error, changeset} ->
render(conn, "new.html", changeset: changeset)
end
end
以瀏覽器 http://localhost:4000/users/new ,註冊一個新 user,就可以轉換到 http://localhost:4000/users 畫面,因為註冊時,馬上就登入了。
Login and Logout
/web/router.ex 增加 resources "/sessions", SessionController, only: [:new, :create, :delete]
scope "/", Rumbl do
pipe_through :browser # Use the default browser stack
get "/", PageController, :index
resources "/users", UserController, only: [:index, :show, :new, :create]
# GET /sessions/new 產生 new session login form
# POST /sessions 用來 login
# DELETE /sessions/:id 用來 logout
resources "/sessions", SessionController, only: [:new, :create, :delete]
end
/web/controller/session_controller.ex
defmodule Rumbl.SessionController do
use Rumbl.Web, :controller
# 產生 login form
def new(conn, _) do
render conn, "new.html"
end
def create(conn, %{"session" => %{"username" => user, "password" =>
pass}}) do
case Rumbl.Auth.login_by_username_and_pass(conn, user, pass, repo:
Repo) do
{:ok, conn} ->
conn
|> put_flash(:info, "Welcome back!")
|> redirect(to: page_path(conn, :index))
{:error, _reason, conn} ->
conn
|> put_flash(:error, "Invalid username/password combination")
|> render("new.html")
end
end
def delete(conn, _) do
conn
|> Rumbl.Auth.logout()
|> redirect(to: page_path(conn, :index))
end
end
/web/controllers/auth.ex 增加 loginbyusernameandpass 及 logout
defmodule Rumbl.Auth do
# 刪除 session
def logout(conn) do
configure_session(conn, drop: true)
end
# 使用 Bcrypt 的兩個 methods
import Comeonin.Bcrypt, only: [checkpw: 2, dummy_checkpw: 0]
# 取得 :repo option,以 get_by 方式取得 user,比對 passoword_hash
def login_by_username_and_pass(conn, username, given_pass, opts) do
repo = Keyword.fetch!(opts, :repo)
user = repo.get_by(Rumbl.User, username: username)
cond do
user && checkpw(given_pass, user.password_hash) ->
{:ok, login(conn, user)}
user ->
# 密碼錯誤 回傳 :unauthorized
{:error, :unauthorized, conn}
true ->
# 用 comeonin's dummy_checkpw() 模擬 動態時間長度 的 password check
# 避免 timing attacks
# 找不到 user 回傳 :not_found
dummy_checkpw()
{:error, :not_found, conn}
end
end
end
新增 /web/views/session_view.ex
defmodule Rumbl.SessionView do
use Rumbl.Web, :view
end
新增 /web/templates/session/new.html.eex
<h1>Login</h1>
<%= form_for @conn, session_path(@conn, :create), [as: :session], fn f -> %>
<div class="form-group">
<%= text_input f, :username, placeholder: "Username", class: "form-control" %>
</div>
<div class="form-group">
<%= password_input f, :password, placeholder: "Password", class: "form-control" %>
</div>
<%= submit "Log in", class: "btn btn-primary" %>
<% end %>
http://localhost:4000/sessions/new 如果帳號密碼填錯,就會出現錯誤訊息
修改 /web/templates/layout/app.html.eex 的 header 區塊,把登入的 username 資訊顯示出來,未登入就顯示 login form 的連結
<div class="header">
<ol class="breadcrumb text-right">
<%= if @current_user do %>
<li><%= @current_user.username %></li>
<li>
<%= link "Log out", to: session_path(@conn, :delete, @current_user),
method: "delete" %>
</li>
<% else %>
<li><%= link "Register", to: user_path(@conn, :new) %></li>
<li><%= link "Log in", to: session_path(@conn, :new) %></li>
<% end %>
</ol>
<span class="logo"></span>
</div>
沒有留言:
張貼留言