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
爲字段username
和email
添加惟一性校驗。
在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.json
和 forbidden.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代碼實現用戶註冊。