Let’s Build |> 使用Elixir,Phoenix和React打造克隆版的Slack(part 2)

Let’s Build |> 使用Elixir,Phoenix和React打造克隆版的Slack(part 2 — Backend Authentication)

Live Demo---GitHub Repohtml

上一篇博文中,咱們已經搭建好了Phoenix和React項目。這篇博文咱們將添加User模型而且實現用戶身份認證的API前端

咱們來建立user數據表。使用Phoenix內置的generator。react

mix phoenix.gen.json User users username:string email:string password_hash:string

這個命令生成一堆模板文件,好比 model 、controller 等。第一個參數是module名稱 User,第二個參數是model的名稱 users,仍是複數(和rails很像吧)。接着後面是數據庫表的字段名和數據類型。git

打開自動生成的migration文件,並作一些修改。github

defmodule Sling.Repo.Migrations.CreateUser do
  use Ecto.Migration

  def change do
    create table(:users) do
      add :username, :string, null: false
      add :email, :string, null: false
      add :password_hash, :string, null: false

      timestamps()
    end

    create unique_index(:users, [:username])
    create unique_index(:users, [:email])
  end
end

<center>sling/api/priv/repo/migrations/timestamp_create_user.exs</center>web

爲保證每一個字段都必須有值,咱們添加非空約束null: false。而後咱們爲字段username, emial 建立惟一性索引,以確保其字段值不會重複。咱們也會在model級別添加字段(username, emial)值惟一性校驗,在數據級別添加也是爲了保證數據庫的完整性。算法

使用mix運行mirgation,建立users table數據庫

mix ecto.migrate

運行migration時你可能會遇到這個錯誤json

== Compilation error on file web/controllers/user_controller.ex ==
** (CompileError) web/controllers/user_controller.ex:18: undefined function user_path/3
    (stdlib) lists.erl:1338: :lists.foreach/2
    (stdlib) erl_eval.erl:670: :erl_eval.do_apply/6
    (elixir) lib/kernel/parallel_compiler.ex:117: anonymous fn/4 in Kernel.ParallelCompiler.spawn_compilers/1

這是因爲運行 mix phoenix.gen.json自動建立user_controller.ex,而咱們沒有爲該controller在router.ex中配置路由user_path所以報錯。後端

因爲咱們暫時用不到user_controller.ex,因此直接所有註釋掉其內容。再次運行mix ecto.migrate,便可成功建立users table。

咱們來看看users.exs文件

defmodule Sling.User do
  use Sling.Web, :model

  schema "users" do
    field :username, :string
    field :email, :string
    field :password_hash, :string

    timestamps()
  end

  def changeset(struct, params \\ %{}) do
    struct
    |> cast(params, [:username, :email, :password_hash])
    |> validate_required([:username, :email, :password_hash])
    |> unique_constraint(:username)
    |> unique_constraint(:email)
  end
end

<center>sling/api/web/models/user.ex</center>

User Model使用函數unique_constraint爲字段usernameemail添加惟一性校驗。

在Ecto(訪問數據庫的lib, 概念有點相似於Rails的ORM ActiveRecord)中每次對數據庫的insert和update都必須經過執行changeset函數來實現。那麼咱們就能夠定義多種類型的changeset, 並能靈活的設置校驗。

如今咱們來簡單的看看,到目前爲止咱們都幹了些啥:打開iex而後建立user (這一步就相似於rails console)

iex -S mix

而後在iex

changeset = Sling.User.changeset(%Sling.User{}, %{email: "first@user.com", username: "first_user", password_hash: "password"})
Sling.Repo.insert(changeset)

User Model的changeset函數有兩個參數,第一個是struct(一種數據結構,當前爲空的%Sling.User{}),第二個是map。(第二個參數會根據changeset函數中得條件,將值映射到第一個參數)具體以下:

運行成功會返回 :ok元組,表示建立成功。

{:ok,
 %Sling.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">,
  email: "first@user.com", id: 1,
  inserted_at: #Ecto.DateTime<2016-10-20 20:04:07>, password_hash: "password",
  updated_at: #Ecto.DateTime<2016-10-20 20:04:07>, username: "first_user"}}

你應該注意到,咱們上面例子中密碼是以明碼的形式存儲於數據庫中的,這顯然是極其危險的作法。咱們來使用第三方庫Comeonin來解決這個問題。修改mix.exs添加依賴(首先在依賴列表中添加,而後在application列表中添加)

# content above

def application do
  [mod: {Sling, []},
   applications: [:phoenix, :phoenix_pubsub, :phoenix_html, :cowboy, :logger, :gettext,
                  :phoenix_ecto, :postgrex, :comeonin]] # :comeonin added here
end

# ...

defp deps do
  [{:phoenix, "~> 1.2.1"},
   {:phoenix_pubsub, "~> 1.0"},
   {:phoenix_ecto, "~> 3.0"},
   {:postgrex, ">= 0.0.0"},
   {:phoenix_html, "~> 2.6"},
   {:phoenix_live_reload, "~> 1.0", only: :dev},
   {:gettext, "~> 0.11"},
   {:cowboy, "~> 1.0"},
   {:comeonin, "~> 2.5"}] # :comeonin added here
end

# content below

<center>sling/api/mix.exs</center>

安裝依賴運行:

mix deps.get

安裝好Comeonin之後,咱們就可使用hash算法處理密碼。如今更新user.exs

defmodule Sling.User do
  use Sling.Web, :model

  schema "users" do
    field :username, :string
    field :email, :string
    field :password_hash, :string
    field :password, :string, virtual: true

    timestamps()
  end

  def changeset(struct, params \\ %{}) do
    struct
    |> cast(params, [:username, :email])
    |> validate_required([:username, :email])
    |> unique_constraint(:username)
    |> unique_constraint(:email)
  end

  def registration_changeset(struct, params) do
    struct
    |> changeset(params)
    |> cast(params, [:password])
    |> validate_length(:password, min: 6, max: 100)
    |> put_password_hash()
  end

  defp put_password_hash(changeset) do
    case changeset do
      %Ecto.Changeset{valid?: true, changes: %{password: password}} ->
        put_change(changeset, :password_hash, Comeonin.Bcrypt.hashpwsalt(password))
      _ ->
        changeset
    end
  end
end

<center>sling/api/web/models/user.ex</center>

上面的修改中咱們添加虛擬字段password,目的是在數據model中使用它,但並不須要其存儲於數據庫中。在changeset函數中移除password_hash,咱們將不容許changeset函數直接操做該字段。另外新建registration_changeset用於更新用戶的密碼。put_password_hash函數將password值hash運算之後存入password_hash並insert在數據庫中。

咱們在iex -S mix中試試新的registration_changeset函數

changeset = Sling.User.registration_changeset(%Sling.User{}, %{email: "second@user.com", username: "second_user", password: "password"})
Sling.Repo.insert(changeset)
...
{:ok,
 %Sling.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">,
  email: "second@user.com", id: 3,
  inserted_at: #Ecto.DateTime<2016-10-20 20:29:12>, password: "password",
  password_hash: "$2b$12$7mJCI9CGy4I3mf1wek/tA.OZQryn31YImjVDcV/ovU5Xrm4xEn4Mq",
  updated_at: #Ecto.DateTime<2016-10-20 20:29:12>, username: "second_user"}}

看到了吧,密碼已經妥妥的完成哈希化

查看代碼變化 Commit

目前爲止咱們已經可以建立用戶,可是要從前端經過API實現用戶認證,咱們還須要實現一些token策略。我打算使用Json Web Token 庫 Guardian來實現咱們的想法,這個庫有不少用戶認證相關的功能特性。

mix.exs依賴列表末尾添加 {:guardian, "~> 0.13.0"} ,運行mix deps.get安裝依賴。

在config.exs中配置Guardian

# content above

config :guardian, Guardian,
  issuer: "Sling",
  ttl: {30, :days},
  verify_issuer: true,
  serializer: Sling.GuardianSerializer

import_config "#{Mix.env}.exs"

<center>sling/api/config/config.exs</center>

Guardian也須要配置secret_key,經過運行mix phoenix.gen.secret生成。咱們爲development和production環境分別設置不一樣的secret_key。在production環境中咱們把secret_key保存在環境變量中。

config :guardian, Guardian,
  secret_key: "LG17BzmhBeq81Yyyn6vH7GVdrCkQpLktol2vdXlBzkRRHpYsZwluKMG9r6fnu90m"

<center>sling/api/config/dev.exs</center>

config :guardian, Guardian,
  secret_key: System.get_env("GUARDIAN_SECRET_KEY")

<center>sling/api/config/prod.exs</center>

Guardian還須要配置serializer(詳見Guardian readme)

defmodule Sling.GuardianSerializer do
  @behaviour Guardian.Serializer

  alias Sling.Repo
  alias Sling.User

  def for_token(user = %User{}), do: {:ok, "User:#{user.id}"}
  def for_token(_), do: {:error, "Unknown resource type"}

  def from_token("User:" <> id), do: {:ok, Repo.get(User, String.to_integer(id))}
  def from_token(_), do: {:error, "Unknown resource type"}
end

<center>sling/api/lib/sling/guardian_serializer.ex</center>

查看代碼變化 Commit

結合Guardian配置,接下來實現controller中相應的接口。咱們須要實現四個接口,分別用做註冊,登陸,登出以及當用戶在前端刷新頁面時自動再次刷新/認證。首先在router.ex中配置路由。

defmodule Sling.Router do
  use Sling.Web, :router

  # pipeline :browser do
  #   plug :accepts, ["html"]
  #   plug :fetch_session
  #   plug :fetch_flash
  #   plug :protect_from_forgery
  #   plug :put_secure_browser_headers
  # end

  pipeline :api do
    plug :accepts, ["json"]
    plug Guardian.Plug.VerifyHeader, realm: "Bearer"
    plug Guardian.Plug.LoadResource
  end

  # scope "/", Sling do
  #   pipe_through :browser

  #   get "/", PageController, :index
  # end

  scope "/api", Sling do
    pipe_through :api

    post "/sessions", SessionController, :create
    delete "/sessions", SessionController, :delete
    post "/sessions/refresh", SessionController, :refresh
    resources "/users", UserController, only: [:create]
  end
end

<center>sling/api/web/router.ex</center>

注:上述router配置中,browser相關的路由是無效的,故已經註釋掉。

  • SessionController的create action處理Login發出的POST請求;

  • SessionController的delete action處理Logout發出的Delete請求;

  • SessionController的refresh action處理refresh/authenticate發出的POST請求;

  • UserController的create action處理signup發出的POST請求;

在pipeline api中添加兩個Plug。(Plug就像函數,不過它在每次請求時都會執行,相似於rails的 before_action,也可稱之爲攔截器)。

  • VerifyHeader Plug的做用是在請求頭的Authorization: Bearer header中查找並校驗jwt。

  • LoadResource Plug的做用是當請求頭的jwt校驗經過後加載當前用戶(current user)。

爲使這兩個Plug正確工做,咱們還需在controller中配置其餘Guardian方法以便實現對current user 的訪問或者相關權限的檢查。

在router.ex中,咱們添加的路由均放置在 /api下面,爲了方便代碼文件查找咱們從新配置目錄結構將 user_controller放置在 sling/api/web/controllers/api/user_controller.ex路徑下。而後清理掉user_controller中的其餘內容,只實現create action。以下所述,

defmodule Sling.UserController do
  use Sling.Web, :controller

  alias Sling.User

  def create(conn, params) do
    changeset = User.registration_changeset(%User{}, params)

    case Repo.insert(changeset) do
      {:ok, user} ->
        new_conn = Guardian.Plug.api_sign_in(conn, user, :access)
        jwt = Guardian.Plug.current_token(new_conn)

        new_conn
        |> put_status(:created)
        |> render(Sling.SessionView, "show.json", user: user, jwt: jwt)
      {:error, changeset} ->
        conn
        |> put_status(:unprocessable_entity)
        |> render(Sling.ChangesetView, "error.json", changeset: changeset)
    end
  end
end

<center>sling/api/web/controllers/api/user_controller.ex</center>

create action首先使用User的registration_changset函數構建changeset,這樣咱們的密碼就會被哈希化。這一步和咱們在iex中建立User的過程比較類似。

接下來case語句Repo.insert(changeset)要麼返回結果是user成功建立,要麼建立失敗報錯。Phoenix使用ChangesetView去處理上述建立失敗的結果(包括changeset數據和錯誤信息)

若user建立成功,咱們使用Guardian.api_sign_in函數分配這個新用戶到當前的connection中。而後咱們使用已經分配user的connection建立Json Web Token。

Rails中,建立json response須要藉助第三方庫來實現。Phoenix默認提供json response的實現方式。前面運行 mix phoenix.gen.json時已經默認生成 user_view.ex文件,如今咱們來修改它以知足須要。

defmodule Sling.UserView do
  use Sling.Web, :view

  def render("user.json", %{user: user}) do
    %{
      id: user.id,
      username: user.username,
      email: user.email,
    }
  end
end

<center>sling/api/web/views/user_view.ex</center>

如你所見,咱們沒有在controller中實現index和show action,因此咱們也相應的刪去view中的render函數。咱們只實現user.json的render函數,而且沒必要向前端返回password_hash數據。

你可能已經注意到前面的UserController中,咱們沒有用到UserView,相反使用的是render(Sling.SessionView, "show.json", user: user, jwt: jwt)。這麼作是由於當用戶註冊或者登陸完成之後,咱們打算將jwt和用戶數據一塊兒返回,爲了便於理解我新建SessionView。

defmodule Sling.SessionView do
  use Sling.Web, :view

  def render("show.json", %{user: user, jwt: jwt}) do
    %{
      data: render_one(user, Sling.UserView, "user.json"),
      meta: %{token: jwt}
    }
  end

  def render("error.json", _) do
    %{error: "Invalid email or password"}
  end

  def render("delete.json", _) do
    %{ok: true}
  end

  def render("forbidden.json", %{error: error}) do
    %{error: error}
  end
end

<center>sling/api/web/views/session_view.ex</center>

SessionView 的show.json 模板,使用UserView的user.json模板,而且把jwt做爲token值存入meta字段中。在SessionController中,還須要構建json response用於響應無效信息登陸,登出,用戶認證失敗。這些響應將使用 error.json delete.jsonforbidden.json模板渲染構建。

咱們來實現SessionController

defmodule Sling.SessionController do
  use Sling.Web, :controller

  def create(conn, params) do
    case authenticate(params) do
      {:ok, user} ->
        new_conn = Guardian.Plug.api_sign_in(conn, user, :access)
        jwt = Guardian.Plug.current_token(new_conn)

        new_conn
        |> put_status(:created)
        |> render("show.json", user: user, jwt: jwt)
      :error ->
        conn
        |> put_status(:unauthorized)
        |> render("error.json")
    end
  end

  def delete(conn, _) do
    jwt = Guardian.Plug.current_token(conn)
    Guardian.revoke!(jwt)

    conn
    |> put_status(:ok)
    |> render("delete.json")
  end

  def refresh(conn, _params) do
    user = Guardian.Plug.current_resource(conn)
    jwt = Guardian.Plug.current_token(conn)
    {:ok, claims} = Guardian.Plug.claims(conn)

    case Guardian.refresh!(jwt, claims, %{ttl: {30, :days}}) do
      {:ok, new_jwt, _new_claims} ->
        conn
        |> put_status(:ok)
        |> render("show.json", user: user, jwt: new_jwt)
      {:error, _reason} ->
        conn
        |> put_status(:unauthorized)
        |> render("forbidden.json", error: "Not authenticated")
    end
  end

  def unauthenticated(conn, _params) do
    conn
    |> put_status(:forbidden)
    |> render(Sling.SessionView, "forbidden.json", error: "Not Authenticated")
  end

  defp authenticate(%{"email" => email, "password" => password}) do
    user = Repo.get_by(Sling.User, email: String.downcase(email))

    case check_password(user, password) do
      true -> {:ok, user}
      _ -> :error
    end
  end

  defp check_password(user, password) do
    case user do
      nil -> Comeonin.Bcrypt.dummy_checkpw()
      _ -> Comeonin.Bcrypt.checkpw(password, user.password_hash)
    end
  end
end

<center>sling/api/web/controllers/api/session_controller.ex</center>

create action 也就是login 調用私有函數authenticate(返回用戶信息或者錯誤),這和signup action很是像。用戶登陸並生成token,最後使用SessionView show.json模板構建響應數據。

refresh 看起來也似曾相識,只是不須要建立connection和用戶登陸。咱們調用Guardian的refresh函數,傳入當前的jwt和claims, 返回一個新的有效期爲30天的jwt。

用戶登出只須要簡單的調用 Guardian.revoke!(jwt)便可,其目的就是使當前用戶的token失效,確保不能再次使用。

咱們寫了一大堆代碼,但都是後端用戶認證所必要的。

提交代碼,以供對比commit

好了,這段就到此結束,接下來咱們在前端使用JavaScript代碼實現用戶註冊。

首發地址:http://blog.zhulinpinyu.com/2...

相關文章
相關標籤/搜索