2018年10月1日

Phoenix_4_UserAuthentication

如何處理 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

  1. module plugs 兩個 functions with some configuration details
  2. 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。

  1. host
  2. method

    request method, GET/POST

  3. path_info

    splits into List, ex: ["admin", "users"]

  4. script_name

    initial portion of the URL's path, 關於 application routing 的部分, 例如 ["sub", "app"]

  5. port

  6. peer

    TCP peer ex: {{127,0,0,1},12345}

  7. remote_ip

    {192,168,1,1},如果經過HAProxy,會變成 Forwarded-For 的 header 欄位

  8. req_headers

    request headers, ex: [{"content-type", "text/plain"}],這些 header 都會是小寫字母

  9. scheme

    request protocol, :https

  10. query_string

    request query string as binary, ex: "param1=test"


下個部分是 fetchable fields

fetchable field 會在取用時,才會即時運算出來,所以需要一點運算時間,如果沒有取用就會是空的。

  1. cookies

    request + response cookies

  2. body_params

    request body params,透過 Plug.Parsers 產生的

  3. query_params

    request query params,透過 fetchqueryparams/2 產生的

  4. path_params

    requestpathparams,透過 Plug.Router 產生的

  5. params

    request params,包含 :bodyparams + :queryparams + :path_params

  6. req_cookies

    request cookies 不含 response cookie


接下來是用來處理 web requests 的欄位,可用來 encrypt cookies,處理 user-defined functions

  1. assigns

    shared user data as a map,user 自訂儲存資料的 map

  2. owner

    保存這個 connection 的 Elixir process

  3. halted

    是否有被 pipeline 中止的 boolean status flag,例如 authorization failed

  4. secretkeybase

    用來 verify and encrypt cookies 的 secret key,可用 Plug.Crypto.KeyGenerator.generate/3 產生 keys

  5. state

    conenction state :set, :sent


因為 Plug 會處理任一個 request 的整個過程,包含 request 及 response

Plug.Conn 針對 response 提供了以下這些欄位

  1. resp_body

    response body 預設為空字串,會在 reponse 發送後,設定為 nil (除非是 test connections)

  2. resp_charset

    response charset,預設為 "utf-8"

  3. resp_cookies

    response cookies with name and options

  4. resp_headers

    response headers as a list of tuples,預設 cache-control 設定為 "max-age=0, private, must-revalidate"

  5. status

    response status


Plug 支援保留給 adapter 及 framework 使用的 private fileds

  1. adapter

    儲存 adapter information in a tuple

  2. 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>

References

Programming Phoenix