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