Guest User

Untitled

a guest
Jan 18th, 2018
338
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 13.18 KB | None | 0 0
  1. # Elixir + Phoenix Framework 1.3 + Guardian + JWT(Refresh, Revoke, Recover) + Comeonin
  2.  
  3. ### User model bootstrap
  4. Let's generate User model and controller.
  5.  
  6. ```bash
  7. mix ecto.create
  8. mix phoenix.gen.json Accounts User users email:string password_hash:string
  9. ```
  10.  
  11. Now we need to add users path to our API routes.
  12. ```elixir
  13. defmodule 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. # ...
  21. end
  22. ```
  23.  
  24. Also we need to do some fixes in migration file.
  25. If you need `uuid` instead of `id` we need to add `:binary_id` field and disable native `primary_key`.
  26. If you have columns with unique values you also need to call `unique_index` method.
  27. Also we need to add `default` and not `null` instructions.
  28.  
  29. ```elixir
  30. defmodule 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
  47. end
  48. ```
  49.  
  50. Let's migrate DB.
  51. ```bash
  52. mix ecto.migrate
  53. ```
  54.  
  55. ### Preparing environment
  56. We need to generate secret key for development environment.
  57. ```bash
  58. mix phoenix.gen.secret
  59. # ednkXywWll1d2svDEpbA39R5kfkc9l96j0+u7A8MgKM+pbwbeDsuYB8MP2WUW1hf
  60. ```
  61.  
  62. Guardian 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
  65. defmodule 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" }
  76. end
  77. ```
  78.  
  79. After that we need to add Guardian configuration. Add `guardian` base configuration to your `config/config.exs`
  80.  
  81. ```elixir
  82. config :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.  
  93. Add `guardian` dependency to your `mix.exs`
  94. ```elixir
  95. defp deps do
  96. [
  97. # ...
  98. {:guardian, "~> 0.14"},
  99. # ...
  100. ]
  101. end
  102. ```
  103.  
  104. Fetch and compile dependencies
  105.  
  106. ```bash
  107. mix do deps.get, compile
  108. ```
  109.  
  110. #### Guardian is ready!
  111.  
  112. ### Model authentication part
  113.  
  114. #### User tweaks
  115.  
  116. Next 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
  119. defmodule 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. # ...
  135. end
  136. ```
  137.  
  138. #### Validations and password hashing
  139.  
  140. Add `comeonin` dependency to your `mix.exs`
  141. ```elixir
  142. #...
  143. def application do
  144. [applications: [:comeonin]] # Add comeonin to OTP application
  145. end
  146. # ...
  147. defp deps do
  148. [
  149. # ...
  150. {:comeonin, "~> 3.0"} # Add comeonin to dependencies
  151. # ...
  152. ]
  153. end
  154. ```
  155.  
  156. Now 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
  159. defmodule 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. #...
  195. end
  196. ```
  197.  
  198. ### API authentication with Guardian
  199.  
  200. Let's add headers check in our `lib/my_app_name/web/router.ex` for further authentication flow.
  201.  
  202. ```elixir
  203. defmodule 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. # ...
  222. end
  223. ```
  224.  
  225. ### Registration
  226. Now 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.
  227. Hey 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
  230. defmodule 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. # ...
  249. end
  250. ```
  251.  
  252. Let'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
  255. defmodule 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
  271. end
  272.  
  273. ```
  274.  
  275. Also we need `RegistrationView`. So, we need to create one more file named `lib/my_app_name/web/views/registration_view.ex`.
  276.  
  277. ```elixir
  278. defmodule 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
  290. end
  291. ```
  292.  
  293. After that we need to add /api/v1/sign_up route. Just add it inside of API scope.
  294.  
  295. ```elixir
  296. defmodule 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. # ...
  305. end
  306. ```
  307.  
  308. It'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.  
  316. We 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.  
  337. It'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.  
  350. We 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.  
  361. Wow! 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
  364. defmodule 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. # ...
  379. end
  380. ```
  381.  
  382. Before 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
  385. defmodule 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
  402. end
  403. ```
  404.  
  405. Also 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
  408. defmodule 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. # ...
  416. end
  417. ```
  418.  
  419. It'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
  422. defmodule 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
  434. end
  435. ```
  436.  
  437. Good! Next step is to add `SessionView` in `lib/my_app_name/web/views/session_view.ex`.
  438.  
  439. ```elixir
  440. defmodule 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
  453. end
  454. ```
  455.  
  456. Add some routes to handle sign_in action in `lib/my_app_name/web/router.ex`.
  457.  
  458. ```elixir
  459. defmodule 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. # ...
  472. end
  473. ```
  474.  
  475. Ok. 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.  
  486. We 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.  
  499. Now. You can take this token and add it to `Authorization: #{token}` header.
Add Comment
Please, Sign In to add comment