· 8 years ago · Nov 06, 2017, 08:20 PM
1# Elixir + Phoenix Framework + Guardian + JWT + Comeonin
2
3### Preparing environment
4We need to generate secret key for development environment.
5```bash
6mix phoenix.gen.secret
7# ednkXywWll1d2svDEpbA39R5kfkc9l96j0+u7A8MgKM+pbwbeDsuYB8MP2WUW1hf
8```
9
10Let's generate User model and controller.
11
12```bash
13mix ecto.create
14mix phoenix.gen.json User users email:string name:string phone:string password_hash:string is_admin:boolean
15mix ecto.migrate
16```
17
18Guardian 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.
19
20```elixir
21defmodule MyAppName.GuardianSerializer do
22 @behaviour Guardian.Serializer
23
24 alias MyAppName.Repo
25 alias MyAppName.User
26
27 def for_token(user = %User{}), do: { :ok, "User:#{user.id}" }
28 def for_token(_), do: { :error, "Unknown resource type" }
29
30 def from_token("User:" <> id), do: { :ok, Repo.get(User, id) }
31 def from_token(_), do: { :error, "Unknown resource type" }
32end
33```
34
35After that we need to add Guardian configuration. Add `guardian` base configuration to your `config/config.exs`
36
37```elixir
38config :guardian, Guardian,
39 allowed_algos: ["HS512"], # optional
40 verify_module: Guardian.JWT, # optional
41 issuer: "MyAppName",
42 ttl: { 30, :days },
43 allowed_drift: 2000,
44 verify_issuer: true, # optional
45 secret_key: "ednkXywWll1d2svDEpbA39R5kfkc9l96j0+u7A8MgKM+pbwbeDsuYB8MP2WUW1hf", # Insert previously generated secret key!
46 serializer: MyAppName.GuardianSerializer
47```
48
49Add `guardian` dependency to your `mix.exs`
50```elixir
51defp deps do
52 [
53 # ...
54 {:guardian, "~> 0.14"},
55 # ...
56 ]
57end
58```
59
60Fetch and compile dependencies
61
62```bash
63mix do deps.get, compile
64```
65
66#### Guardian is ready!
67
68### Model authentication part
69
70#### User tweaks
71
72Now we need to add users path to our API routes.
73```elixir
74defmodule MyAppName.Router do
75 # ...
76 scope "/api/v1", MyAppName do
77 pipe_through :api
78
79 resources "/users", UserController, except: [:new, :edit]
80 end
81 # ...
82end
83```
84
85Next step is to add validations to `web/models/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.
86
87```elixir
88defmodule MyAppName.User do
89 # ...
90 schema "users" do
91 field :email, :string
92 field :name, :string
93 field :phone, :string
94 field :password, :string, virtual: true # We need to add this row
95 field :password_hash, :string
96 field :is_admin, :boolean, default: false
97
98 timestamps()
99 end
100 # ...
101end
102```
103
104#### Validations and password hashing
105
106Add `comeonin` dependency to your `mix.exs`
107```elixir
108#...
109def application do
110 [applications: [:comeonin]] # Add comeonin to OTP application
111end
112# ...
113defp deps do
114 [
115 # ...
116 {:comeonin, "~> 3.0"} # Add comeonin to dependencies
117 # ...
118 ]
119end
120```
121
122Now we need to edit `web/models/user.ex`, add validations for `[:email, password]` and integrate password hash generation. Also we need separate changeset functions for internal usage and API registration.
123
124```elixir
125defmodule MyAppName.User do
126 #...
127 def changeset(struct, params \\ %{}) do
128 struct
129 |> cast(params, [:email, :name, :phone, :password, :is_admin])
130 |> validate_required([:email, :name, :password])
131 |> validate_changeset
132 end
133
134 def registration_changeset(struct, params \\ %{}) do
135 struct
136 |> cast(params, [:email, :name, :phone, :password])
137 |> validate_required([:email, :name, :phone, :password])
138 |> validate_changeset
139 end
140
141 defp validate_changeset(struct) do
142 struct
143 |> validate_length(:email, min: 5, max: 255)
144 |> validate_format(:email, ~r/@/)
145 |> unique_constraint(:email)
146 |> validate_length(:password, min: 8)
147 |> validate_format(:password, ~r/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).*/, [message: "Must include at least one lowercase letter, one uppercase letter, and one digit"])
148 |> generate_password_hash
149 end
150
151 defp generate_password_hash(changeset) do
152 case changeset do
153 %Ecto.Changeset{valid?: true, changes: %{password: password}} ->
154 put_change(changeset, :password_hash, Comeonin.Bcrypt.hashpwsalt(password))
155 _ ->
156 changeset
157 end
158 end
159 #...
160end
161```
162
163### API authentication with Guardian
164
165Let's add headers check in our `web/router.ex` for further authentication flow.
166
167```elixir
168defmodule MyAppName.Router do
169 # ...
170 pipeline :api do
171 plug :accepts, ["json"]
172 plug Guardian.Plug.VerifyHeader
173 plug Guardian.Plug.LoadResource
174 end
175
176 pipeline :authenticated do
177 plug Guardian.Plug.EnsureAuthenticated
178 end
179 # ...
180 scope "/api/v1", MyAppName do
181 pipe_through :api
182
183 pipe_through :authenticated # restrict unauthenticated access for routes below
184 resources "/users", UserController, except: [:new, :edit]
185 end
186 # ...
187end
188```
189
190### Registration
191
192Now 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.
193
194Let's create RegistrationController. We need to create new file `web/controllers/registration_controller.ex`. Also we need specific `registration_changeset` that we declared before inside of `web/models/user.ex`
195
196```elixir
197defmodule MyAppName.RegistrationController do
198 use MyAppName.Web, :controller
199
200 alias MyAppName.User
201
202 def sign_up(conn, %{"user" => user_params}) do
203 changeset = User.registration_changeset(%User{}, user_params)
204
205 case Repo.insert(changeset) do
206 {:ok, user} ->
207 conn
208 |> put_status(:created)
209 |> put_resp_header("location", user_path(conn, :show, user))
210 |> render("success.json", user: user)
211 {:error, changeset} ->
212 conn
213 |> put_status(:unprocessable_entity)
214 |> render(MyAppName.ChangesetView, "error.json", changeset: changeset)
215 end
216 end
217end
218```
219
220Also we need RegistrationView. So, we need to create one more file named `web/views/registration_view.ex`.
221
222```
223defmodule MyAppName.RegistrationView do
224 use MyAppName.Web, :view
225
226 def render("success.json", %{user: user}) do
227 %{
228 status: :ok,
229 message: """
230 Now you can sign in using your email and password at /api/sign_in. You will receive JWT token.
231 Please put this token into Authorization header for all authorized requests.
232 """
233 }
234 end
235end
236```
237
238After that we need to add /api/sign_up route. Just add it inside of API scope.
239
240```
241defmodule MyAppName.Router do
242 # ...
243 scope "/api", MyAppName do
244 pipe_through :api
245
246 post "/sign_up", RegistrationController, :sign_up
247 # ...
248 end
249 # ...
250end
251```
252
253It'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/sign_up with this JSON body.
254
255```JSON
256{
257 "user": {}
258}
259```
260
261We should receive this response
262
263```JSON
264{
265 "errors": {
266 "phone": [
267 "can't be blank"
268 ],
269 "password": [
270 "can't be blank"
271 ],
272 "name": [
273 "can't be blank"
274 ],
275 "email": [
276 "can't be blank"
277 ]
278 }
279}
280```
281
282It's good point, but we need to create new user. That's why we need to POST correct payload.
283
284```JSON
285{
286 "user": {
287 "email": "hello@world.com",
288 "name": "John Doe",
289 "phone": "033-64-22",
290 "password": "MySuperPa55"
291 }
292}
293```
294
295We must get this response.
296
297```JSON
298{
299 "status": "ok",
300 "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"
301}
302```
303
304### Session management
305
306Wow! We've created new user! Now we have user with password hash in our DB. We need to add password checker function in `web/models/user.ex`.
307
308```elixir
309defmodule MyAppName.User do
310 # ...
311 def find_and_confirm_password(email, password) do
312 case Repo.get_by(User, email: email) do
313 nil ->
314 {:error, :not_found}
315 user ->
316 if Comeonin.Bcrypt.checkpw(password, user.password_hash) do
317 {:ok, user}
318 else
319 {:error, :unauthorized}
320 end
321 end
322 end
323 # ...
324end
325```
326
327It's time to use our credentials for sign in action. We need to add `SessionController` with `sign_in` and `sign_out` actions, so create `web/controllers/session_controller.ex`.
328
329```
330defmodule MyAppName.SessionController do
331 use MyAppName.Web, :controller
332
333 alias MyAppName.User
334
335 def sign_in(conn, %{"session" => %{"email" => email, "password" => password}}) do
336 case User.find_and_confirm_password(email, password) do
337 {:ok, user} ->
338 {:ok, jwt, _full_claims} = Guardian.encode_and_sign(user, :api)
339
340 conn
341 |> render "sign_in.json", user: user, jwt: jwt
342 {:error, _reason} ->
343 conn
344 |> put_status(401)
345 |> render "error.json", message: "Could not login"
346 end
347 end
348end
349```
350
351Good! Next step is to add SessionView in `web/views/session_view.ex`.
352
353```
354defmodule MyAppName.SessionView do
355 use MyAppName.Web, :view
356
357 def render("sign_in.json", %{user: user, jwt: jwt}) do
358 %{
359 status: :ok,
360 data: %{
361 token: jwt,
362 email: user.email
363 },
364 message: "You are successfully logged in! Add this token to authorization header to make authorized requests."
365 }
366 end
367end
368```
369
370Add some routes to handle sign_in action in web/router.ex.
371
372```
373defmodule MyAppName.Router do
374 use MyAppName.Web, :router
375 #...
376 scope "/api/v1", CianExporter.API.V1 do
377 pipe_through :api
378
379 post "/sign_up", RegistrationController, :sign_up
380 post "/sign_in", SessionController, :sign_in # Add this line
381
382 pipe_through :authenticated
383 resources "/users", UserController, except: [:new, :edit]
384 end
385 # ...
386end
387```
388
389Ok. Let's check this stuff. POST `/api/sign_in` with this params.
390
391```JSON
392{
393 "session": {
394 "email": "hello@world.com",
395 "password": "MySuperPa55"
396 }
397}
398```
399
400We should receive this response
401
402```JSON
403{
404 "status": "ok",
405 "message": "You are successfully logged in! Add this token to authorization header to make authorized requests.",
406 "data": {
407 "token": "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJVc2VyOjEiLCJleHAiOjE0OTgwMzc0OTEsImlhdCI6MTQ5NTQ0NTQ5MSwiaXNzIjoiQ2lhbkV4cG9ydGVyIiwianRpIjoiZDNiOGYyYzEtZDU3ZS00NTBlLTg4NzctYmY2MjBiNWIxMmI1IiwicGVtIjp7fSwic3ViIjoiVXNlcjoxIiwidHlwIjoiYXBpIn0.HcJ99Tl_K1UBsiVptPa5YX65jK5qF_L-4rB8HtxisJ2ODVrFbt_TH16kJOWRvJyJIoG2EtQz4dXj7tZgAzJeJw",
408 "email": "hello@world.com"
409 }
410}
411```
412
413Now. You can take this token and add it to `Authorization: Bearer #{token}` header.