· 8 years ago · Jan 18, 2018, 08:28 AM
1# Elixir + Phoenix Framework 1.3 + Guardian + JWT(Refresh, Revoke, Recover) + Comeonin
2
3### User model bootstrap
4Let's generate User model and controller.
5
6```bash
7mix ecto.create
8mix phoenix.gen.json Accounts User users email:string password_hash:string
9```
10
11Now we need to add users path to our API routes.
12```elixir
13defmodule MyAppName.Router do
14 # ...
15 scope "/api/v1", MyAppNameWeb do
16 pipe_through :api
17
18 resources "/users", UserController, except: [:new, :edit]
19 end
20 # ...
21end
22```
23
24Also we need to do some fixes in migration file.
25If you need `uuid` instead of `id` we need to add `:binary_id` field and disable native `primary_key`.
26If you have columns with unique values you also need to call `unique_index` method.
27Also we need to add `default` and not `null` instructions.
28
29```elixir
30defmodule MyAppName.Repo.Migrations.CreateMyAppName.Accounts.User do
31 use Ecto.Migration
32
33 def change do
34 create table(:accounts_users, primary_key: false) do
35 add :id, :binary_id, primary_key: true
36 add :email, :string, null: false
37 add :name, :string, null: false
38 add :phone, :string, null: true
39 add :password_hash, :string, null: false
40 add :is_admin, :boolean, null: false, default: false
41
42 timestamps()
43 end
44
45 create unique_index(:accounts_users, [:email])
46 end
47end
48```
49
50Let's migrate DB.
51```bash
52mix ecto.migrate
53```
54
55### Preparing environment
56We need to generate secret key for development environment.
57```bash
58mix phoenix.gen.secret
59# ednkXywWll1d2svDEpbA39R5kfkc9l96j0+u7A8MgKM+pbwbeDsuYB8MP2WUW1hf
60```
61
62Guardian requires serializer for JWT token generation, so we need to create it `lib/my_app_name/token_serializer.ex`. You need to restart your server, after adding files to `lib` folder.
63
64```elixir
65defmodule MyAppName.GuardianSerializer do
66 @behaviour Guardian.Serializer
67
68 alias MyAppName.Repo
69 alias MyAppName.Accounts.User
70
71 def for_token(user = %User{}), do: { :ok, "User:#{user.id}" }
72 def for_token(_), do: { :error, "Unknown resource type" }
73
74 def from_token("User:" <> id), do: { :ok, Repo.get(User, id) }
75 def from_token(_), do: { :error, "Unknown resource type" }
76end
77```
78
79After that we need to add Guardian configuration. Add `guardian` base configuration to your `config/config.exs`
80
81```elixir
82config :guardian, Guardian,
83 allowed_algos: ["HS512"], # optional
84 verify_module: Guardian.JWT, # optional
85 issuer: "MyAppName",
86 ttl: { 30, :days },
87 allowed_drift: 2000,
88 verify_issuer: true, # optional
89 secret_key: "ednkXywWll1d2svDEpbA39R5kfkc9l96j0+u7A8MgKM+pbwbeDsuYB8MP2WUW1hf", # Insert previously generated secret key!
90 serializer: MyAppName.GuardianSerializer
91```
92
93Add `guardian` dependency to your `mix.exs`
94```elixir
95defp deps do
96 [
97 # ...
98 {:guardian, "~> 0.14"},
99 # ...
100 ]
101end
102```
103
104Fetch and compile dependencies
105
106```bash
107mix do deps.get, compile
108```
109
110#### Guardian is ready!
111
112### Model authentication part
113
114#### User tweaks
115
116Next step is to add validations to `lib/my_app_name/accounts/user.ex`. Virtual `:password` field will exist in Ecto structure, but not in the database, so we are able to provide password to the model’s changesets and, therefore, validate that field.
117
118```elixir
119defmodule MyAppName.Accounts.User do
120 # ...
121 @primary_key {:id, :binary_id, autogenerate: true}
122
123 schema "accounts_users" do
124 field :email, :string
125 field :name, :string
126 field :phone, :string
127 field :password, :string, virtual: true # We need to add this row
128 field :password_confirmation, :string, virtual: true # Confirmation for password field
129 field :password_hash, :string
130 field :is_admin, :boolean, default: false
131
132 timestamps()
133 end
134 # ...
135end
136```
137
138#### Validations and password hashing
139
140Add `comeonin` dependency to your `mix.exs`
141```elixir
142#...
143def application do
144 [applications: [:comeonin]] # Add comeonin to OTP application
145end
146# ...
147defp deps do
148 [
149 # ...
150 {:comeonin, "~> 3.0"} # Add comeonin to dependencies
151 # ...
152 ]
153end
154```
155
156Now we need to edit `lib/my_app_name/accounts/user.ex`, add validations for `[:email, password]` and integrate password hash generation. Also we need separate changeset functions for internal usage and API registration.
157
158```elixir
159defmodule MyAppName.Accounts.User do
160 #...
161 def changeset(%User{} = user, attrs) do
162 user
163 |> cast(attrs, [:email, :name, :phone, :password, :is_admin])
164 |> validate_required([:email, :name, :password])
165 |> validate_changeset
166 end
167
168 def registration_changeset(%User{} = user, attrs) do
169 user
170 |> cast(attrs, [:email, :name, :phone, :password, :password_confirmation])
171 |> validate_required([:email, :name, :phone, :password, :password_confirmation])
172 |> validate_confirmation(:password)
173 |> validate_changeset
174 end
175
176 defp validate_changeset(user) do
177 user
178 |> validate_length(:email, min: 5, max: 255)
179 |> validate_format(:email, ~r/@/)
180 |> unique_constraint(:email)
181 |> validate_length(:password, min: 8)
182 |> validate_format(:password, ~r/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).*/, [message: "Must include at least one lowercase letter, one uppercase letter, and one digit"])
183 |> generate_password_hash
184 end
185
186 defp generate_password_hash(changeset) do
187 case changeset do
188 %Ecto.Changeset{valid?: true, changes: %{password: password}} ->
189 put_change(changeset, :password_hash, Comeonin.Bcrypt.hashpwsalt(password))
190 _ ->
191 changeset
192 end
193 end
194 #...
195end
196```
197
198### API authentication with Guardian
199
200Let's add headers check in our `lib/my_app_name/web/router.ex` for further authentication flow.
201
202```elixir
203defmodule MyAppName.Router do
204 # ...
205 pipeline :api do
206 plug :accepts, ["json"]
207 plug Guardian.Plug.VerifyHeader
208 plug Guardian.Plug.LoadResource
209 end
210
211 pipeline :authenticated do
212 plug Guardian.Plug.EnsureAuthenticated
213 end
214 # ...
215 scope "/api/v1", MyAppName.Web do
216 pipe_through :api
217
218 pipe_through :authenticated # restrict unauthenticated access for routes below
219 resources "/users", UserController, except: [:new, :edit]
220 end
221 # ...
222end
223```
224
225### Registration
226Now we can't get access to /users route without Bearer JWT Token in header. That's why we need to add `RegistrationController` and `SessionController`. It's a good time to make commit before further changes.
227Hey we need to add some more logic registration. Let's add `register_user` method in `lib/my_app_name/accounts/accounts.ex`
228
229```elixir
230defmodule MyAppName.Accounts do
231 @moduledoc """
232 The boundary for the Accounts system.
233 """
234
235 import Ecto.Query, warn: false
236 alias MyAppName.Repo
237 alias MyAppName.Accounts.User
238
239 # ...
240 @doc """
241 Creates a user using registration attributes.
242 """
243 def register_user(attrs \\ %{}) do
244 %User{}
245 |> User.registration_changeset(attrs)
246 |> Repo.insert()
247 end
248 # ...
249end
250```
251
252Let's create `RegistrationController`. We need to create new file `lib/my_app_name/web/controllers/registration_controller.ex`. Also we need specific `registration_changeset` that we declared before inside of `lib/my_app_name/accounts/user.ex`
253
254```elixir
255defmodule MyAppName.Web.RegistrationController do
256 use MyAppName.Web, :controller
257
258 alias MyAppName.Accounts
259 alias MyAppName.Accounts.User
260
261 action_fallback MyAppName.Web.FallbackController
262
263 def sign_up(conn, %{"user" => user_params}) do
264 with {:ok, %User{} = user} <- Accounts.register_user(user_params) do
265 conn
266 |> put_status(:created)
267 |> put_resp_header("location", user_path(conn, :show, user))
268 |> render("success.json", user: user)
269 end
270 end
271end
272
273```
274
275Also we need `RegistrationView`. So, we need to create one more file named `lib/my_app_name/web/views/registration_view.ex`.
276
277```elixir
278defmodule MyAppName.Web.RegistrationView do
279 use MyAppName.Web, :view
280
281 def render("success.json", %{user: _user}) do
282 %{
283 status: :ok,
284 message: """
285 Now you can sign in using your email and password at /api/v1/sign_in. You will receive JWT token.
286 Please put this token into Authorization header for all authorized requests.
287 """
288 }
289 end
290end
291```
292
293After that we need to add /api/v1/sign_up route. Just add it inside of API scope.
294
295```elixir
296defmodule MyAppName.Router do
297 # ...
298 scope "/api/v1", MyAppName.Web do
299 pipe_through :api
300
301 post "/sign_up", RegistrationController, :sign_up
302 # ...
303 end
304 # ...
305end
306```
307
308It's time to check our registration controller. If you don't know how to write request tests. You can use Postman app. Let's POST /api/v1/sign_up with this JSON body.
309
310```json
311{
312 "user": {}
313}
314```
315
316We should receive this response
317
318```json
319{
320 "errors": {
321 "phone": [
322 "can't be blank"
323 ],
324 "password": [
325 "can't be blank"
326 ],
327 "name": [
328 "can't be blank"
329 ],
330 "email": [
331 "can't be blank"
332 ]
333 }
334}
335```
336
337It's good point, but we need to create new user. That's why we need to POST correct payload.
338
339```json
340{
341 "user": {
342 "email": "hello@world.com",
343 "name": "John Doe",
344 "phone": "033-64-22",
345 "password": "MySuperPa55"
346 }
347}
348```
349
350We must get this response.
351
352```json
353{
354 "status": "ok",
355 "message": " Now you can sign in using your email and password at /api/v1/sign_in. You will receive JWT token.\n Please put this token into Authorization header for all authorized requests.\n"
356}
357```
358
359### Session management
360
361Wow! We've created new user! Now we have user with password hash in our DB. We need to add password checker function in `lib/my_app_name/accounts/user.ex`.
362
363```elixir
364defmodule MyAppName.Accounts.User do
365 # ...
366 def find_and_confirm_password(email, password) do
367 case Repo.get_by(User, email: email) do
368 nil ->
369 {:error, :login_not_found}
370 user ->
371 if Comeonin.Bcrypt.checkpw(password, user.password_hash) do
372 {:ok, user}
373 else
374 {:error, :login_failed}
375 end
376 end
377 end
378 # ...
379end
380```
381
382Before we add `SessionController`, we need to handle `:not_found` and `:unauthorized` errors. So, let's create `FallbackAPIController` module in `lib/my_app_name/web/controllers/fallback_api_controller.ex`
383
384```elixir
385defmodule MyAppName.Web.FallbackAPIController do
386 use MyAppName.Web, :controller
387
388 def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
389 conn
390 |> put_status(:unprocessable_entity)
391 |> render(MyAppName.Web.ChangesetView, "error.json", changeset: changeset)
392 end
393
394 def call(conn, {:error, :login_failed}), do: login_failed(conn)
395 def call(conn, {:error, :login_not_found}), do: login_failed(conn)
396
397 defp login_failed(conn) do
398 conn
399 |> put_status(401)
400 |> render(MyAppName.Web.ErrorView, "error.json", status: :unauthorized, message: "Authentication failed!")
401 end
402end
403```
404
405Also we need to add "error.json" to `MyAppName.Web.ErrorView` module in `lib/my_app_name/web/views/error_view.ex`. This part is required for correct JSON errors handling.
406
407```elixir
408defmodule MyAppName.Web.ErrorView do
409 use MyAppName.Web, :view
410
411 # ...
412 def render("error.json", %{status: status, message: message}) do
413 %{status: status, message: message}
414 end
415 # ...
416end
417```
418
419It's time to use our credentials for sign in action. We need to add `SessionController` with `sign_in` actions, so just create `lib/my_app_name/web/controllers/session_controller.ex`.
420
421```elixir
422defmodule MyAppName.Web.SessionController do
423 use MyAppName.Web, :controller
424
425 alias MyAppName.Accounts.User
426
427 action_fallback MyAppName.Web.FallbackAPIController
428
429 def sign_in(conn, %{"session" => %{"email" => email, "password" => pass}}) do
430 with {:ok, user} <- User.find_and_confirm_password(email, pass),
431 {:ok, jwt, _full_claims} <- Guardian.encode_and_sign(user, :api),
432 do: render(conn, "sign_in.json", user: user, jwt: jwt)
433 end
434end
435```
436
437Good! Next step is to add `SessionView` in `lib/my_app_name/web/views/session_view.ex`.
438
439```elixir
440defmodule MyAppName.Web.SessionView do
441 use MyAppName.Web, :view
442
443 def render("sign_in.json", %{user: user, jwt: jwt}) do
444 %{
445 status: :ok,
446 data: %{
447 token: jwt,
448 email: user.email
449 },
450 message: "You are successfully logged in! Add this token to authorization header to make authorized requests."
451 }
452 end
453end
454```
455
456Add some routes to handle sign_in action in `lib/my_app_name/web/router.ex`.
457
458```elixir
459defmodule MyAppName.Router do
460 use MyAppName.Web, :router
461 #...
462 scope "/api/v1", MyAppName.Web do
463 pipe_through :api
464
465 post "/sign_up", RegistrationController, :sign_up
466 post "/sign_in", SessionController, :sign_in # Add this line
467
468 pipe_through :authenticated
469 resources "/users", UserController, except: [:new, :edit]
470 end
471 # ...
472end
473```
474
475Ok. Let's check this stuff. POST `/api/v1/sign_in` with this params.
476
477```json
478{
479 "session": {
480 "email": "hello@world.com",
481 "password": "MySuperPa55"
482 }
483}
484```
485
486We should receive this response
487
488```json
489{
490 "status": "ok",
491 "message": "You are successfully logged in! Add this token to authorization header to make authorized requests.",
492 "data": {
493 "token": "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJVc2VyOjEiLCJleHAiOjE0OTgwMzc0OTEsImlhdCI6MTQ5NTQ0NTQ5MSwiaXNzIjoiQ2lhbkV4cG9ydGVyIiwianRpIjoiZDNiOGYyYzEtZDU3ZS00NTBlLTg4NzctYmY2MjBiNWIxMmI1IiwicGVtIjp7fSwic3ViIjoiVXNlcjoxIiwidHlwIjoiYXBpIn0.HcJ99Tl_K1UBsiVptPa5YX65jK5qF_L-4rB8HtxisJ2ODVrFbt_TH16kJOWRvJyJIoG2EtQz4dXj7tZgAzJeJw",
494 "email": "hello@world.com"
495 }
496}
497```
498
499Now. You can take this token and add it to `Authorization: #{token}` header.