Phoenix 安全存儲密碼

2023-12-18 14:30 更新

如果你在前面章節(jié)里,曾在瀏覽器里打開過頁面,注冊過用戶,則打開 http://localhost:4000/users 網(wǎng)址,你會看到類似如下截圖的內(nèi)容:

users list

密碼字段一覽無余,如果數(shù)據(jù)庫被人入侵,則用戶密碼全部暴露。

所以,這一章里,我們要先對用戶密碼做哈希處理,然后才保存到數(shù)據(jù)庫中。我們要用到第三方的 Comeonin 庫。

添加依賴

首先,打開項目依賴管理文件 mix.exs,在文件中添加 comeonin 依賴:

diff --git a/mix.exs b/mix.exs
index a71d654..3320fc8 100644
--- a/mix.exs
+++ b/mix.exs
@@ -19,7 +19,7 @@ defmodule TvRecipe.Mixfile do
   def application do
     [
       mod: {TvRecipe.Application, []},
-      extra_applications: [:logger, :runtime_tools]
+      extra_applications: [:logger, :runtime_tools, :comeonin]
     ]
   end

   # Specifies which paths to compile per environment.
@@ -37,7 +37,8 @@ defmodule TvRecipe.Mixfile do
      {:phoenix_html, "~> 2.6"},
      {:phoenix_live_reload, "~> 1.0", only: :dev},
      {:gettext, "~> 0.11"},
-     {:plug_cowboy, "~> 2.0"}
+     {:plug_cowboy, "~> 2.0"},
+     {:comeonin, "~> 3.0"}
   end

我們在 mix.exs 文件中共添加了兩處代碼,一處是 deps 函數(shù)中,定義我們要用的 comeonin 版本;另一處是 application 函數(shù)中,表示構建時應將 comeonin 打包進去。

接著在命令行下執(zhí)行:

$ mix do deps.get, deps.compile

該命令從遠程下載了我們新增的 comeonin 依賴并編譯。

那么,怎么確認 comeonin 安裝成功?之前,我們一直是用 mix phx.server 命令來啟動服務器的,接下來,我們要換一種啟動方式:

$ iex -S mix phx.server

區(qū)別在哪?我們來看看后者啟動后的結果:

$ iex -S mix phx.server
Erlang/OTP 19 [erts-8.2] [source] [64-bit] [smp:4:4] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]

[info] Running TvRecipe.Endpoint with Cowboy using http://localhost:4000
Interactive Elixir (1.4.0) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> 25 Jan 09:53:09 - info: compiled 6 files into 2 files, copied 3 in 2.1 sec

看到區(qū)別了么?我們用 iex -S mix phx.server 啟動后,可以使用 Elixir 的 iex。

比如,我們可以輸入 Com 然后按 Tab 鍵:

iex tab 補全

iex 下自動補全 Comeonin,證明我們已經(jīng)可以在 TvRecipe 項目中使用它。

password 字段的處理

我們現(xiàn)在面對的情況是,數(shù)據(jù)庫不應該存儲 password,因為它是明文的。我們要把哈希處理后的密碼存入另一個字段,比如 password_hash。

但我們的數(shù)據(jù)庫現(xiàn)在只有 password 字段,還沒有 password_hash。怎么辦?我們?nèi)酝ㄟ^遷移(migration)來做增刪。

  1. 創(chuàng)建 migration 文件

    $ mix ecto.gen.migration alter_user_table
    * creating priv/repo/migrations
    * creating priv/repo/migrations/20170125015912_alter_user_table.exs
  2. 打開新建的 20170125015912_alter_user_table.exs 文件,removepassword 字段,然后 add password_hash 字段:

    diff --git a/priv/repo/migrations/20170125015912_alter_user_table.exs b/priv/repo/migrations/20170125015912_alter_user_table.exs
    index 2a25ba8..e783c65 100644
    --- a/priv/repo/migrations/20170125015912_alter_user_table.exs
    +++ b/priv/repo/migrations/20170125015912_alter_user_table.exs
    @@ -2,6 +2,9 @@ defmodule TvRecipe.Repo.Migrations.AlterUserTable do
      use Ecto.Migration
    
      def change do
    -
    +    alter table(:users) do
    +      remove :password
    +      add :password_hash, :string
    +    end
      end
    end
  3. 執(zhí)行 mix ecto.migrate 修改數(shù)據(jù)庫:

    $ mix ecto.migrate
    
    10:17:57.648 [info]  == Running TvRecipe.Repo.Migrations.AlterUserTable.change/0 forward
    
    10:17:57.648 [info]  alter table users
    
    10:17:57.685 [info]  == Migrated in 0.0s
  4. 在上一步里,我們修改了數(shù)據(jù)庫里 users 表的結構。那么,user.ex 文件中的 password 字段怎么辦?要刪除嗎?刪除了話,前面做的那些圍繞 password 的驗證怎么辦?

    不,我們留著 password,但要給它加上 virtual: true,表示它是個臨時字段,不存儲到數(shù)據(jù)庫中:

    diff --git a/lib/tv_recipe/users/user.ex b/lib/tv_recipe/users/user.ex
    index 3069e79..e60e839 100644
    --- a/lib/tv_recipe/users/user.ex
    +++ b/lib/tv_recipe/users/user.ex
    @@ -4,7 +4,8 @@ defmodule TvRecipe.User do
      schema "users" do
        field :username, :string
        field :email, :string
    -    field :password, :string
    +    field :password, :string, virtual: true
    +    field :password_hash, :string
    
        timestamps()
      end
      end

    你可能會好奇,不加 virtual: true 會怎樣,會這樣:

    ** (Postgrex.Error) ERROR (undefined_column): column "password" of relation "users" does not exist
    (ecto) lib/ecto/adapters/sql.ex:463: Ecto.Adapters.SQL.struct/6
    (ecto) lib/ecto/repo/schema.ex:397: Ecto.Repo.Schema.apply/4
    (ecto) lib/ecto/repo/schema.ex:193: anonymous fn/11 in Ecto.Repo.Schema.do_insert/4
    (ecto) lib/ecto/repo/schema.ex:124: Ecto.Repo.Schema.insert!/4

    因為數(shù)據(jù)表里已經(jīng)移除了 password 字段,數(shù)據(jù)也就無法插入。

話說回來,我們做了這么多的修改,是否破壞了代碼呢?我們可以運行測試,確證下。

測試證明,一切順利。我們繼續(xù)。

存儲哈希后的密碼

我們至今還沒有提過 Changeset 的涵義。

官方的文檔是這樣說的:

Changesets allow filtering, casting, validation and definition of constraints when manipulating structs.

通俗點講,它是一種數(shù)據(jù)處理機制,數(shù)據(jù)在插入數(shù)據(jù)庫前,先要經(jīng)過一系列流程,驗證數(shù)據(jù)的正確,保證數(shù)據(jù)的唯一等等,沒有錯誤,數(shù)據(jù)才插入到表中,如果有錯誤,則將錯誤寫入統(tǒng)一的格式中,方便我們處理。

再回到我們的問題,我們能夠從 changeset 里得到 password 的數(shù)據(jù),接下來要怎么處理?

user.ex 文件現(xiàn)在的 changeset 函數(shù)是這樣的:

  def changeset(struct, params \\ %{}) do
    struct
    |> cast(params, [:username, :email, :password])
    |> validate_required([:username, :email, :password], message: "請?zhí)顚?)
    |> validate_format(:username, ~r/^[a-zA-Z0-9_]+$/, message: "用戶名只允許使用英文字母、數(shù)字及下劃線")
    |> validate_length(:username, min: 3, message: "用戶名最短 3 位")
    |> validate_length(:username, max: 15, message: "用戶名最長 15 位")
    |> validate_exclusion(:username, ~w(admin administrator), message: "系統(tǒng)保留,無法注冊,請更換")
    |> unique_constraint(:username, name: :users_lower_username_index, message: "用戶名已被人占用")
    |> validate_format(:email, ~r/@/, message: "郵箱格式錯誤")
    |> unique_constraint(:email, name: :users_lower_email_index, message: "郵箱已被人占用")
    |> validate_length(:password, min: 6, message: "密碼最短 6 位")
  end

我們可以在 changeset 末尾再加一道工序:

diff --git a/lib/tv_recipe/users/user.ex b/lib/tv_recipe/users/user.ex
index e60e839..58447c0 100644
--- a/lib/tv_recipe/users/user.ex
+++ b/lib/tv_recipe/users/user.ex
@@ -25,5 +25,6 @@ defmodule TvRecipe.User do
     |> validate_format(:email, ~r/@/, message: "郵箱格式錯誤")
     |> unique_constraint(:email, name: :users_lower_email_index, message: "郵箱已被人占用")
     |> validate_length(:password, min: 6, message: "密碼最短 6 位")
+    |> put_password_hash()
   end
 end

順便解釋一下,|> 是 Elixir 的管道操作符,如果你用過 Linux/Unix 的 pipe,你可能已經(jīng)很清楚。

如果你沒用過,則可以這么理解,|> 前的函數(shù)會返回一個數(shù)據(jù),這個數(shù)據(jù)作為第一個參數(shù)傳入給 |> 后函數(shù)。

拿上面的 changeset 函數(shù)說,它等同于:

# 接收上一個 changeset,返回一個新的 changeset
changeset = validate_length(changeset, :password, min: 6, message: "密碼最短 6 位")
# 接收上一個 changeset,返回一個新的 changeset
changeset = put_password_hash(changeset)

當然,沒人喜歡這么寫。

現(xiàn)在,我們來定義 put_password_hash 函數(shù):

diff --git a/lib/tv_recipe/users/user.ex b/lib/tv_recipe/users/user.ex
index 58447c0..690a1ed 100644
--- a/lib/tv_recipe/users/user.ex
+++ b/lib/tv_recipe/users/user.ex
@@ -27,4 +27,13 @@ defmodule TvRecipe.User do
     |> validate_length(:password, min: 6, message: "密碼最短 6 位")
     |> 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

這里,涉及了 Elixir 的幾個知識。

不過先插一段閑話。不知道你發(fā)現(xiàn)沒有,從第一章到現(xiàn)在,我都沒有提過,學習 Phoenix Framework 要掌握 Elixir 到什么程度。從我個人的經(jīng)驗說,哪怕不懂 Elixir,也是可以學 Phoenix 的。是最快的學習方式,碰上不懂的,再去借助搜索引擎,這樣才有的放矢。等到時機成熟,再完整地學習一遍 Elixir,因為有了實踐經(jīng)驗,一切就會水到渠成。

好了,我們來解釋下上面的幾個新知識點:

  1. defp - 我們之前就接觸過 def,它用于定義函數(shù),而 defp 是定義一個隱私(private)函數(shù),隱私函數(shù)只能在它所在的模塊內(nèi)部使用
  2. case do - 根據(jù)不同匹配結果執(zhí)行不同代碼,類似 if else
  3. 模式匹配(pattern matching)- 模式匹配 是 Elixir 很重要的一個特性,利用它,我們能夠很方便的從數(shù)據(jù)中提取數(shù)據(jù),以上面定義的 put_password_hash 函數(shù)來說,changeset = %Ecto.Changeset{valid?: true, changes: %{password: password}} 就可以把 password 的值提取出來。
  4. put_change - 修改 changeset 中的數(shù)據(jù)

這樣,我們就完成密碼的安全存儲。

當然,我們還要添加一個測試,用 Comeonin.Bcrypt.checkpw 來保證 put_password_hash 函數(shù)的結果:

diff --git a/test/tv_recipe/users_test.exs b/test/tv_recipe/users_test.exs
index 8689f4e..6e946b0 100644
--- a/test/tv_recipe/users_test.exs
+++ b/test/tv_recipe/users_test.exs
@@ -112,4 +112,9 @@ defmodule TvRecipe.UserTest do
     attrs = %{@valid_attrs | password: String.duplicate("1", 5)}
     assert %{password: ["密碼最短 6 位"]} = errors_on(%User{}, attrs)
   end
+
+  test "password should be hashed" do
+    %{changes: changes} = User.changeset(%User{}, @valid_attrs)
+    assert Comeonin.Bcrypt.checkpw(changes.password, changes.password_hash)
+  end
 end

運行測試:

mix test test/tv_recipe/users_test.exs
.................

Finished in 4.2 seconds
17 tests, 0 failures

如果你眼尖,可能已經(jīng)已經(jīng)注意到,我們的測試時間變長了,之前多是零點幾秒,現(xiàn)在一下變成 4.2 秒。

這是引入的 comeonin 依賴導致的,密碼加密需要大量時間,而我們在測試時,并不需要高強度的密碼加密。

我們可以在 test.exs 文件中 調(diào)整 comeonin配置

diff --git a/config/test.exs b/config/test.exs
index 0ff4a98..1743d57 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -17,3 +17,7 @@ config :tv_recipe, TvRecipe.Repo,
   database: "tv_recipe_test",
   hostname: "localhost",
   pool: Ecto.Adapters.SQL.Sandbox
+
+config :comeonin,
+  bcrypt_log_rounds: 4,
+  pbkdf2_rounds: 1_000

再次運行測試:

mix test test/tv_recipe/users_test.exs
.................

Finished in 0.2 seconds
17 tests, 0 failures

我們的測試又快起來了。


以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號