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