如果你在前面章節(jié)里,曾在瀏覽器里打開過頁面,注冊過用戶,則打開 http://localhost:4000/users 網(wǎng)址,你會看到類似如下截圖的內(nèi)容:
密碼字段一覽無余,如果數(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ù)中,表示構(gòu)建時應(yīng)將 comeonin
打包進去。
接著在命令行下執(zhí)行:
$ mix do deps.get, deps.compile
該命令從遠(yuǎn)程下載了我們新增的 comeonin
依賴并編譯。
那么,怎么確認(rèn) comeonin
安裝成功?之前,我們一直是用 mix phx.server
命令來啟動服務(wù)器的,接下來,我們要換一種啟動方式:
$ iex -S mix phx.server
區(qū)別在哪?我們來看看后者啟動后的結(jié)果:
$ 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
下自動補全 Comeonin
,證明我們已經(jīng)可以在 TvRecipe 項目中使用它。
password
字段的處理
我們現(xiàn)在面對的情況是,數(shù)據(jù)庫不應(yīng)該存儲 password
,因為它是明文的。我們要把哈希處理后的密碼存入另一個字段,比如 password_hash
。
但我們的數(shù)據(jù)庫現(xiàn)在只有 password
字段,還沒有 password_hash
。怎么辦?我們?nèi)酝ㄟ^遷移(migration)來做增刪。
-
創(chuàng)建 migration 文件
$ mix ecto.gen.migration alter_user_table * creating priv/repo/migrations * creating priv/repo/migrations/20170125015912_alter_user_table.exs
-
打開新建的
20170125015912_alter_user_table.exs
文件,remove
掉password
字段,然后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
-
執(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
-
在上一步里,我們修改了數(shù)據(jù)庫里
users
表的結(jié)構(gòu)。那么,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)
當(dāng)然,沒人喜歡這么寫。
現(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)在,我都沒有提過,學(xué)習(xí) Phoenix Framework 要掌握 Elixir 到什么程度。從我個人的經(jīng)驗說,哪怕不懂 Elixir,也是可以學(xué) Phoenix 的。用是最快的學(xué)習(xí)方式,碰上不懂的,再去借助搜索引擎,這樣才有的放矢。等到時機成熟,再完整地學(xué)習(xí)一遍 Elixir,因為有了實踐經(jīng)驗,一切就會水到渠成。
好了,我們來解釋下上面的幾個新知識點:
defp
- 我們之前就接觸過def
,它用于定義函數(shù),而defp
是定義一個隱私(private)函數(shù),隱私函數(shù)只能在它所在的模塊內(nèi)部使用case do
- 根據(jù)不同匹配結(jié)果執(zhí)行不同代碼,類似if else
- 模式匹配(pattern matching)- 模式匹配 是 Elixir 很重要的一個特性,利用它,我們能夠很方便的從數(shù)據(jù)中提取數(shù)據(jù),以上面定義的
put_password_hash
函數(shù)來說,changeset = %Ecto.Changeset{valid?: true, changes: %{password: password}}
就可以把password
的值提取出來。 put_change
- 修改 changeset 中的數(shù)據(jù)
這樣,我們就完成密碼的安全存儲。
當(dāng)然,我們還要添加一個測試,用 Comeonin.Bcrypt.checkpw 來保證 put_password_hash
函數(shù)的結(jié)果:
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
依賴導(dǎo)致的,密碼加密需要大量時間,而我們在測試時,并不需要高強度的密碼加密。
我們可以在 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
我們的測試又快起來了。
更多建議: