Elixir API 和 Elm SPA - 第二部分
第二部分:添加监护人身份验证
第二部分:添加监护人身份验证
现在我们要为应用添加身份验证功能。由于这是一个 JSON REST API,我们需要使用一个令牌,该令牌需要作为请求头传递到每个 API 请求中。我们将使用Guardian来进行身份验证。
这并非一个完整的解决方案,因为它不会将令牌保存到数据库,也不会检查令牌是否仍然有效。但它确实会检查安全令牌是否有效,这目前已经足够了。
系列
- 第一部分 - Elixir 应用创建
- 第二部分 - 添加监护人身份验证
- 第三部分:Elm 应用创建和路由设置
- 第四部分:添加登录和注册页面
- 第五部分:将会话数据持久化到本地存储
添加 Guardian 依赖项
首先,让我们将 guardian 依赖项添加到 mix.exs 文件中。
defp deps do
[
{:phoenix, "~> 1.3.0"},
{:phoenix_pubsub, "~> 1.0"},
{:phoenix_ecto, "~> 3.2"},
{:postgrex, ">= 0.0.0"},
{:gettext, "~> 0.11"},
{:cowboy, "~> 1.0"},
{:comeonin, "~> 4.0"},
{:argon2_elixir, "~> 1.2"},
{:guardian, "~> 1.0"}
]
end
并检索依赖项
mix deps.get
让我们来配置 Guardian。在使用 Guardian 生成令牌之前,您需要完成一些准备工作。首先,您需要创建一个模块,Guardian 将使用该模块作为回调函数,处理它自身无法完成且完全依赖于您的应用程序的操作。
在 lib/toltec/auth/ 目录下创建一个名为 guardian.ex 的新文件
# /lib/toltec/auth/guardian.ex
defmodule Toltec.Auth.Guardian do
use Guardian, otp_app: :toltec
def subject_for_token(resource, _claims) do
sub = to_string(resource.id)
{:ok, sub}
end
def resource_from_claims(claims) do
case Toltec.Accounts.get_user(claims["sub"]) do
nil -> {:error, "User not found"}
user -> {:ok, user}
end
end
end
此文件的作用是在令牌和资源(用户)之间进行转换,以及在资源(我们的用户)和要编码到令牌中的唯一标识符之间进行转换,该令牌将在各处传递。
思路很简单:生成一个唯一、随机且不可预测的字符串,用来代表我们的用户(主体)。这个字符串就是令牌,会提供给前端,以便在后续的交互(请求)中,我们能够确认用户的身份。
回到守护者模块,你会看到:
- 我们使用资源 ID(用户 ID)作为唯一标识我们应用程序用户的凭证,
- 通过该用户 ID(编码在 claims["sub"],即主题中),我们可以明确地知道我们应用程序中的哪个用户对应于
请注意 get_user 方法。它与我们在第一部分中创建的方法不同。因此,请打开 accounts.ex 文件并进行如下更改:
# lib/toltec/accounts/accounts.ex
def get_user!(id), do: Repo.get!(User, id)
对此
def get_user(id), do: Repo.get(User, id)
我们继续。现在我们需要配置库以正确生成令牌。
由于这些令牌是通过加密生成的,因此令牌的强度将直接取决于我们用于初始化加密算法的种子。所以我们需要生成一个强种子,供 Guardian 用于生成令牌。
请不要使用示例密钥,请使用您自己生成的新密钥。
# this is an example, don't use it in your app
mix guardian.gen.secret
TJD5jd2uGqrOO3zDb/pU85DhH9yzj5cy0WPjKQV6nz3d+XWS+RY5ff8hvhnfK2Dk
现在使用此密钥配置 Guardian。将其添加到 config/dev.exs 文件中。
config :toltec, Toltec.Auth.Guardian,
issuer: "toltec",
secret_key: "TJD5jd2uGqrOO3zDb/pU85DhH9yzj5cy0WPjKQV6nz3d+XWS+RY5ff8hvhnfK2Dk"
如果你要将此文件提交到你的代码仓库,任何有权访问该仓库的人都可以生成有效的令牌。所以我建议你改用环境变量来分配密钥,但最终决定权在你。
总之,既然配置已经完成,我们就使用 Guardian 在用户正确登录并开始会话时创建令牌。
生成 JWT 令牌
添加一个名为 session_controller.ex 的新文件。它的作用是创建新会话、删除会话和刷新会话。
# lib/toltec_web/controllers/session_controller.ex
defmodule ToltecWeb.SessionController do
use ToltecWeb, :controller
alias Toltec.Accounts
alias Toltec.Auth.Guardian
def create(conn, params) do
case authenticate(params) do
{:ok, user} ->
new_conn = Guardian.Plug.sign_in(conn, user)
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", error: "User or email invalid")
end
end
def delete(conn, _) do
conn
|> Guardian.Plug.sign_out()
|> put_status(:no_content)
|> render("delete.json")
end
def refresh(conn, _params) do
user = Guardian.Plug.current_resource(conn)
jwt = Guardian.Plug.current_token(conn)
case Guardian.refresh(jwt, 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("error.json", error: "Not Authenticated")
end
end
defp authenticate(%{"email" => email, "password" => password}) do
Accounts.authenticate(email, password)
end
defp authenticate(_), do: :error
end
如您所见,共有三种方法,分别对应会话操作:创建、终止和刷新。它们使用 Guardian.Plug 函数在用户正确输入电子邮件地址和密码后生成 JWT(JSON Web Token)。
我们有一个辅助函数`authenticate()`,它接受一个邮箱地址和密码作为输入,并在数据库中检查是否存在与该组合匹配的用户。如果存在,则返回一个 `{:ok, user}` 元组;如果不存在,则返回错误。基于此,我们可以理解 ` create()`函数。如果邮箱地址和密码有效,我们会获得一个用户,并将其传递给 ` Guardian.Plug.sign_ing`函数,以便将该用户添加到 `conn` 结构体中。然后,我们会为该用户生成一个令牌,最后发送一个包含用户和令牌的 JSON 响应。
如果邮箱地址和密码组合不存在,则返回一个 JSON 错误响应。
delete/2函数比较简单。它的作用是调用 Guardian 的 sing_out/0 方法,从连接结构中移除资源(记住,就是我们的用户)。
刷新方法目前还不会被使用,但它的作用是获取一个有效的现有令牌,获取一个有效期延长 30 天的新令牌,并将该新令牌作为响应发送以替换旧令牌。
让我们来看看Accounts 中的authenticate/2方法会是什么样子:
# lib/toltec/accounts/accounts.ex
def authenticate(email, password) do
user = Repo.get_by(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.Argon2.dummy_checkpw()
_ -> Comeonin.Argon2.checkpw(password, user.password_hash)
end
end
这很简单,它尝试通过电子邮件地址从数据库中检索用户。然后委托给check_password/2,如果用户不存在,则执行虚拟密码检查;如果用户存在,则将密码与数据库中存储的哈希值进行比对。
好了,会话控制器已经准备就绪。在进入视图之前,我们先添加一个用户控制器,它将负责为我们的应用创建新的用户。它与 session_controller.ex 非常相似。
需要注意的是,用户创建是即时的,也就是说,用户创建请求完成后,该用户即立即存在于系统中并生效。在生产系统中,您需要添加额外的验证步骤,例如发送一封包含验证链接的确认邮件,才能在系统中激活用户。但这超出了本教程的范围。
在 lib/toltec_web/controllers/ 目录下创建一个名为 user_controller 的文件
# lib/toltec_web/controllers/user_controller.ex
defmodule ToltecWeb.UserController do
use ToltecWeb, :controller
alias Toltec.Accounts
alias Toltec.Accounts.User
alias Toltec.Auth.Guardian
action_fallback(ToltecWeb.FallbackController)
def create(conn, params) do
with {:ok, %User{} = user} <- Accounts.create_user(params) do
new_conn = Guardian.Plug.sign_in(conn, user)
jwt = Guardian.Plug.current_token(new_conn)
new_conn
|> put_status(:created)
|> render(ToltecWeb.SessionView, "show.json", user: user, jwt: jwt)
end
end
end
此控制器接收一组参数(姓名、电子邮件、密码),并将其传递给 Accounts 的create_user/1函数以创建新用户。如果创建成功,则自动对用户进行签名并生成 JWT。用户和令牌将通过 show.json 视图以 JSON 格式发送到前端。这很简单,对吧?
接下来我们来看渲染 JSON 的视图。
首先是简单的。在 lib/toltec_web/views 目录下创建一个名为 user_view.ex 的文件,内容如下:
# lib/toltec_web/views
defmodule ToltecWeb.UserView do
use ToltecWeb, :view
def render("user.json", %{user: user}) do
%{
id: user.id,
name: user.name,
email: user.email
}
end
end
一切正常。render 方法接收一个用户对象,并返回一个包含我们希望向客户端公开的属性的映射。Elixir 会自动将其转换为 JSON 格式。因此,我们只公开了 id、name 和 email 这三个属性。
现在在 lib/toltec_web/views 目录下创建一个名为 session_view.ex 的新文件。
# lib/toltec_web/views/session_view.ex
defmodule ToltecWeb.SessionView do
use ToltecWeb, :view
def render("show.json", %{user: user, jwt: jwt}) do
%{
data: render_one(user, ToltecWeb.UserView, "user.json"),
meta: %{token: jwt}
}
end
def render("delete.json", _) do
%{ok: true}
end
def render("error.json", %{error: error}) do
%{errors: %{error: error}}
end
end
这有点类似,但稍微复杂一些。show.json 视图会将用户和令牌渲染到客户端。它通过委托给 UserView 中的 user.json 视图来实现这一点。很棒,对吧?
配置到我们用户 API 的路由
我们快完成了。只差一个简单但至关重要的步骤:路由器。没有路由器,应用程序在收到请求时就不知道该怎么做。所以,让我们来配置路由器。
打开 router.ex 文件,并将其修改为如下所示:
# lib/toltec_web/router.ex
defmodule ToltecWeb.Router do
use ToltecWeb, :router
pipeline :api do
plug(:accepts, ["json"])
end
pipeline :api_auth do
plug(Toltec.Auth.Pipeline)
end
scope "/api", ToltecWeb do
pipe_through(:api)
post("/sessions", SessionController, :create)
post("/users", UserController, :create)
end
scope "/api", ToltecWeb do
pipe_through([:api, :api_auth])
delete("/sessions", SessionController, :delete)
post("/sessions/refresh", SessionController, :refresh)
end
end
描述已经足够详细了。它在 /api 作用域内添加了几个新的路由。其中一些路由的限制比其他路由更多。
如果请求是向 `/sessions` 或 `/users` 发送的 POST 请求,则无需授权即可允许。但如果请求是向 `/sessions` 发送的 DELETE 请求或向 `/sessions/refresh` 发送的 POST 请求,则需要进行身份验证。这是合乎逻辑的,因为我们预期只有已登录的已验证用户才能注销或刷新会话(即刷新代表会话中用户的令牌)。
为了实现此身份验证,我们使用了一个新的管道:`:api_auth`。该管道将使用 Guardian 来检查连接中是否已存在有效的令牌。
让我们添加这个 Guardian Pipeline 辅助函数。在 lib/toltec/auth/ 目录下创建一个名为 pipeline.ex 的文件。
# lib/toltec/auth/pipeline.ex
defmodule Toltec.Auth.Pipeline do
use Guardian.Plug.Pipeline,
otp_app: :toltec,
module: Toltec.Auth.Guardian,
error_handler: Toltec.Auth.ErrorHandler
plug(Guardian.Plug.VerifyHeader)
plug(Guardian.Plug.EnsureAuthenticated)
plug(Guardian.Plug.LoadResource)
end
本质上,它会检查发送到我们应用程序的请求是否包含带有令牌的标头,确保令牌有效,如果有效,则自动加载资源(即,加载与加密令牌中令牌 claims["sub"] 值对应的用户)。
Guardian Pipeline 需要一个模块来处理可能遇到的错误。因此,请在 lib/toltec/auth 目录下添加一个名为 error_handler.ex 的文件,内容如下:
# lib/toltec/auth/error_handler.ex
defmodule Toltec.Auth.ErrorHandler do
import Plug.Conn
def auth_error(conn, {:invalid_token, _reason}, _opts),
do: response(conn, :unauthorized, "Invalid Token")
def auth_error(conn, {:unauthenticated, _reason}, _opts),
do: response(conn, :unauthorized, "Not Authenticated")
def auth_error(conn, {:no_resource_found, _reason}, _opts),
do: response(conn, :unauthorized, "No Resource Found")
def auth_error(conn, {type, _reason}, _opts), do: response(conn, :forbidden, to_string(type))
defp response(conn, status, message) do
body = Poison.encode!(%{error: message})
conn
|> put_resp_content_type("application/json")
|> send_resp(status, body)
end
end
使用 API
让我们来测试一下我们的 API。这有点难,因为这个应用完全没有网页渲染。所有交互都通过 REST 调用完成,请求和响应都会以 JSON 格式编码。所以我们需要一个客户端来连接和使用我们的 API。Chrome
浏览器有一些扩展程序可以用来调用 REST API,比如Advanced REST client。当然,Windows 系统上也有一些原生应用可以实现同样的功能。在 macOS 系统上,我使用的是非常棒的Insomnia应用。在本教程中,我们将使用 curl 来发送请求。
首先,请确保应用程序正在运行。
mix phx.server
[info] Running ToltecWeb.Endpoint with Cowboy using http://0.0.0.0:4000
现在,我们来发送一个登录请求。这需要向 `/api/sessions` 路由发送一个 POST 请求。我们需要将邮箱和密码作为参数传递。我们的应用中已经有一个用户信息存储在 `seeds.exs` 文件中,请使用该用户信息。
curl --request POST \
--url http://localhost:4000/api/sessions \
--header 'authorization: Bearer ' \
--header 'content-type: application/x-www-form-urlencoded' \
--data 'email=user%40toltec&password=user%40toltec'
{
"meta":{
"token":"eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJ0b2x0ZWMiLCJleHAiOjE1MzEzNTEzMzAsImlhdCI6MTUyODkzMjEzMCwiaXNzIjoidG9sdGVjIiwianRpIjoiMjQ1ZjUyMjAtZWRmMi00OWM5LThiZmMtYWJkNTU4ZTRlYjU4IiwibmJmIjoxNTI4OTMyMTI5LCJzdWIiOiIxIiwidHlwIjoiYWNjZXNzIn0.AMtC7MyvkMqXKyMyV3oBvkVBgnWNTPxDAKaFx0xgfq_ubQ9XbUJH2ZqoRKCxWE0BUUEPq_GYxNdGXPzi72W_Tg"
},
"data":{
"name":"some user",
"id":1,
"email":"user@toltec"
}
}
太好了,我们收到了包含用户和令牌值的响应。
现在我们来尝试一下注销请求。这是一个 DELETE 请求,只需要在请求头中包含令牌即可。我没有提到请求头的格式,但它需要采用以下格式:
authorization: Bearer <jwt_value>
因此,对于这个注销请求,我们使用 curl 命令,并采用上一步中的 JWT 值。
curl --request DELETE \
--url http://localhost:4000/api/sessions \
--header 'authorization: Bearer eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJ0b2x0ZWMiLCJleHAiOjE1MzEzNTEzMzAsImlhdCI6MTUyODkzMjEzMCwiaXNzIjoidG9sdGVjIiwianRpIjoiMjQ1ZjUyMjAtZWRmMi00OWM5LThiZmMtYWJkNTU4ZTRlYjU4IiwibmJmIjoxNTI4OTMyMTI5LCJzdWIiOiIxIiwidHlwIjoiYWNjZXNzIn0.AMtC7MyvkMqXKyMyV3oBvkVBgnWNTPxDAKaFx0xgfq_ubQ9XbUJH2ZqoRKCxWE0BUUEPq_GYxNdGXPzi72W_Tg' --verbose
* Trying ::1...
* TCP_NODELAY set
* Connection failed
* connect to ::1 port 4000 failed: Connection refused
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 4000 (#0)
> DELETE /api/sessions HTTP/1.1
> Host: localhost:4000
> User-Agent: curl/7.54.0
> Accept: */*
> authorization: Bearer eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJ0b2x0ZWMiLCJleHAiOjE1MzEzNTEzMzAsImlhdCI6MTUyODkzMjEzMCwiaXNzIjoidG9sdGVjIiwianRpIjoiMjQ1ZjUyMjAtZWRmMi00OWM5LThiZmMtYWJkNTU4ZTRlYjU4IiwibmJmIjoxNTI4OTMyMTI5LCJzdWIiOiIxIiwidHlwIjoiYWNjZXNzIn0.AMtC7MyvkMqXKyMyV3oBvkVBgnWNTPxDAKaFx0xgfq_ubQ9XbUJH2ZqoRKCxWE0BUUEPq_GYxNdGXPzi72W_Tg
>
< HTTP/1.1 204 No Content
< server: Cowboy
< date: Wed, 13 Jun 2018 23:23:27 GMT
< content-length: 11
< content-type: application/json; charset=utf-8
< cache-control: max-age=0, private, must-revalidate
< x-request-id: 2krtnfgdqhj1inb4jg0000r5
<
* Excess found in a non pipelined read: excess = 11 url = /api/sessions (zero-length body)
* Connection #0 to host localhost left intact
如您所见,API 返回了 204 响应(无内容),正如我们在 session_controller.ex 中指定的那样。
最后,我们来尝试创建新用户。我们向 /api/users/ 发送一个 POST 请求。
curl --request POST \
--url http://localhost:4000/api/users \
--header 'authorization: Bearer ' \
--header 'content-type: application/x-www-form-urlencoded' \
--data 'email=miguel%40toltec&password=miguel%40toltec&name=Miguel%20Coba'
{
"meta":{
"token":"eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJ0b2x0ZWMiLCJleHAiOjE1MzE1NjUxODYsImlhdCI6MTUyOTE0NTk4NiwiaXNzIjoidG9sdGVjIiwianRpIjoiNTYxODY1MzQtNGZhNi00YWJkLWEwOTUtZTEyZTgzN2QzMmM4IiwibmJmIjoxNTI5MTQ1OTg1LCJzdWIiOiIyIiwidHlwIjoiYWNjZXNzIn0.CzZO0LgfqxwZ7S1Qy6lgVNrrjqacdl7fdEhVOnmt6LoXEBdN1muK1xRBDQOlll8h_lWV7PIJoZMFWUTzmcPuLg"
},
"data":{
"name":"Miguel Coba",
"id":2,
"email":"miguel@toltec"
}
}
您可以在此处仓库的 part-02 分支中找到源代码和测试。
克隆完成后,运行测试并验证一切是否正常:
mix test
......................................
Finished in 0.6 seconds
38 tests, 0 failures
就这样。我们添加了一个路由器,用于接收创建新用户、登录和注销用户以及延长会话的请求。响应的不是 HTML 数据,而是 JSON 数据。
在第三部分,我们将从前端 Elm 应用程序开始。
文章来源:https://dev.to/miguelcoba/elixir-api-and-elm-spa---part-2-1pca