Part 1 — Project setup | Part 2 — Backend Authentication
上一篇博文中,我们已经搭建好了 Phoenix 和 React 项目。这篇博文我们将添加 User 模型并且实现用户身份认证的 API
我们来创建 user 数据表。使用 Phoenix 内置的 generator。
mix phoenix.gen.json User users username:string email:string password_hash:string
这个命令生成一堆模板文件,比如 model、controller 等。第一个参数是 module 名称 User
,第二个参数是 model 的名称 users
,还是复数(和 rails 很像吧)。接着后面是数据库表的字段名和数据类型。
打开自动生成的 migration 文件,并做一些修改。
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
sling/api/priv/repo/migrations/timestamp_create_user.exs
为保证每个字段都必须有值,我们添加非空约束null: false
。然后我们为字段username
, emial
创建唯一性索引,以确保其字段值不会重复。我们也会在 model 级别添加字段(username
, emial
)值唯一性校验,在数据级别添加也是为了保证数据库的完整性。
使用 mix 运行 mirgation,创建 users table
mix ecto.migrate
运行 migration 时你可能会遇到这个错误
== 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
sling/api/web/models/user.ex
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: "[email protected]", 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: "[email protected]", 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
sling/api/mix.exs
安装依赖运行:
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
sling/api/web/models/user.ex
上面的修改中我们添加虚拟字段 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: "[email protected]", username: "second_user", password: "password"})
Sling.Repo.insert(changeset)
...
{:ok,
%Sling.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">,
email: "[email protected]", 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"
sling/api/config/config.exs
Guardian 也需要配置 secret_key,通过运行mix phoenix.gen.secret
生成。我们为 development 和 production 环境分别设置不同的 secret_key。在 production 环境中我们把 secret_key 保存在环境变量中。
config :guardian, Guardian,
secret_key: "LG17BzmhBeq81Yyyn6vH7GVdrCkQpLktol2vdXlBzkRRHpYsZwluKMG9r6fnu90m"
sling/api/config/dev.exs
config :guardian, Guardian,
secret_key: System.get_env("GUARDIAN_SECRET_KEY")
sling/api/config/prod.exs
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
sling/api/lib/sling/guardian_serializer.ex
查看代码变化 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
sling/api/web/router.ex
注:上述 router 配置中,browser 相关的路由是无效的,故已经注释掉。
在 pipeline api 中添加两个 Plug。(Plug 就像函数,不过它在每次请求时都会执行,类似于 rails 的 before_action,也可称之为拦截器)。
为使这两个 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
sling/api/web/controllers/api/user_controller.ex
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
sling/api/web/views/user_view.ex
如你所见,我们没有在 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
sling/api/web/views/session_view.ex
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
sling/api/web/controllers/api/session_controller.ex
create action 也就是 login 调用私有函数 authenticate(返回用户信息或者错误),这和 signup action 非常像。用户登录并生成 token,最后使用 SessionView show.json 模板构建响应数据。
refresh 看起来也似曾相识,只是不需要创建 connection 和用户登录。我们调用 Guardian 的 refresh 函数,传入当前的 jwt 和 claims, 返回一个新的有效期为 30 天的 jwt。
用户登出只需要简单的调用 Guardian.revoke!(jwt)
即可,其目的就是使当前用户的 token 失效,确保不能再次使用。
我们写了一大堆代码,但都是后端用户认证所必要的。
提交代码,以供对比commit
好了,这段就到此结束,接下来我们在前端使用 JavaScript 代码实现用户注册。