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