Let's generate User model and controller.
mix ecto.create
mix phoenix.gen.json Accounts User users email:string password_hash:string
Now we need to add users path to our API routes.
defmodule MyAppName.Router do
# ...
scope "/api/v1", MyAppName.Web do
pipe_through :api
resources "/users", UserController, except: [:new, :edit]
end
# ...
end
Also we need to do some fixes in migration file.
If you need uuid
instead of id
we need to add :binary_id
field and disable native primary_key
.
If you have columns with unique values you also need to call unique_index
method.
Also we need to add default
and not null
instructions.
defmodule MyAppName.Repo.Migrations.CreateMyAppName.Accounts.User do
use Ecto.Migration
def change do
create table(:accounts_users, primary_key: false) do
add :id, :binary_id, primary_key: true
add :email, :string, null: false
add :name, :string, null: false
add :phone, :string, null: false
add :password_hash, :string, null: false
add :is_admin, :boolean, null: false, default: false
timestamps()
end
create unique_index(:accounts_users, [:email])
end
end
Let's migrate DB.
mix ecto.migrate
We need to generate secret key for development environment.
mix phoenix.gen.secret
# ednkXywWll1d2svDEpbA39R5kfkc9l96j0+u7A8MgKM+pbwbeDsuYB8MP2WUW1hf
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.
defmodule MyAppName.GuardianSerializer do
@behaviour Guardian.Serializer
alias MyAppName.Repo
alias MyAppName.Accounts.User
def for_token(user = %User{}), do: { :ok, "User:#{user.id}" }
def for_token(_), do: { :error, "Unknown resource type" }
def from_token("User:" <> id), do: { :ok, Repo.get(User, id) }
def from_token(_), do: { :error, "Unknown resource type" }
end
After that we need to add Guardian configuration. Add guardian
base configuration to your config/config.exs
config :guardian, Guardian,
allowed_algos: ["HS512"], # optional
verify_module: Guardian.JWT, # optional
issuer: "MyAppName",
ttl: { 30, :days },
allowed_drift: 2000,
verify_issuer: true, # optional
secret_key: "ednkXywWll1d2svDEpbA39R5kfkc9l96j0+u7A8MgKM+pbwbeDsuYB8MP2WUW1hf", # Insert previously generated secret key!
serializer: MyAppName.GuardianSerializer
Add guardian
dependency to your mix.exs
defp deps do
[
# ...
{:guardian, "~> 0.14"},
# ...
]
end
Fetch and compile dependencies
mix do deps.get, compile
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.
defmodule MyAppName.Accounts.User do
# ...
@primary_key {:id, :binary_id, autogenerate: true}
schema "accounts_users" do
field :email, :string
field :name, :string
field :phone, :string
field :password, :string, virtual: true # We need to add this row
field :password_confirmation, :string, virtual: true # Confirmation for password field
field :password_hash, :string
field :is_admin, :boolean, default: false
timestamps()
end
# ...
end
Add comeonin
dependency to your mix.exs
#...
def application do
[applications: [:comeonin]] # Add comeonin to OTP application
end
# ...
defp deps do
[
# ...
{:comeonin, "~> 3.0"} # Add comeonin to dependencies
# ...
]
end
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.
defmodule MyAppName.Accounts.User do
#...
def changeset(%User{} = user, attrs) do
user
|> cast(attrs, [:email, :name, :phone, :password, :is_admin])
|> validate_required([:email, :name, :password])
|> validate_changeset
end
def registration_changeset(%User{} = user, attrs) do
user
|> cast(attrs, [:email, :name, :phone, :password, :password_confirmation])
|> validate_required([:email, :name, :phone, :password, :password_confirmation])
|> validate_confirmation(:password)
|> validate_changeset
end
defp validate_changeset(user) do
user
|> validate_length(:email, min: 5, max: 255)
|> validate_format(:email, ~r/@/)
|> unique_constraint(:email)
|> validate_length(:password, min: 8)
|> validate_format(:password, ~r/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).*/, [message: "Must include at least one lowercase letter, one uppercase letter, and one digit"])
|> generate_password_hash
end
defp generate_password_hash(changeset) do
case changeset do
%Ecto.Changeset{valid?: true, changes: %{password: password}} ->
put_change(changeset, :password_hash, Comeonin.Bcrypt.hashpwsalt(password))
_ ->
changeset
end
end
#...
end
Let's add headers check in our lib/my_app_name/web/router.ex
for further authentication flow.
defmodule MyAppName.Router do
# ...
pipeline :api do
plug :accepts, ["json"]
plug Guardian.Plug.VerifyHeader
plug Guardian.Plug.LoadResource
end
pipeline :authenticated do
plug Guardian.Plug.EnsureAuthenticated
end
# ...
scope "/api/v1", MyAppName.Web do
pipe_through :api
pipe_through :authenticated # restrict unauthenticated access for routes below
resources "/users", UserController, except: [:new, :edit]
end
# ...
end
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.
Hey we need to add some more logic registration. Let's add register_user
method in lib/my_app_name/accounts/accounts.ex
defmodule MyAppName.Accounts do
@moduledoc """
The boundary for the Accounts system.
"""
import Ecto.Query, warn: false
alias MyAppName.Repo
alias MyAppName.Accounts.User
# ...
@doc """
Creates a user using registration attributes.
"""
def register_user(attrs \\ %{}) do
%User{}
|> User.registration_changeset(attrs)
|> Repo.insert()
end
# ...
end
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
defmodule MyAppName.Web.RegistrationController do
use MyAppName.Web, :controller
alias MyAppName.Accounts
alias MyAppName.Accounts.User
action_fallback MyAppName.Web.FallbackController
def sign_up(conn, %{"user" => user_params}) do
with {:ok, %User{} = user} <- Accounts.register_user(user_params) do
conn
|> put_status(:created)
|> put_resp_header("location", user_path(conn, :show, user))
|> render("success.json", user: user)
end
end
end
Also we need RegistrationView
. So, we need to create one more file named lib/my_app_name/web/views/registration_view.ex
.
defmodule MyAppName.Web.RegistrationView do
use MyAppName.Web, :view
def render("success.json", %{user: _user}) do
%{
status: :ok,
message: """
Now you can sign in using your email and password at /api/v1/sign_in. You will receive JWT token.
Please put this token into Authorization header for all authorized requests.
"""
}
end
end
After that we need to add /api/v1/sign_up route. Just add it inside of API scope.
defmodule MyAppName.Router do
# ...
scope "/api/v1", MyAppName.Web do
pipe_through :api
post "/sign_up", RegistrationController, :sign_up
# ...
end
# ...
end
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.
{
"user": {}
}
We should receive this response
{
"errors": {
"phone": [
"can't be blank"
],
"password": [
"can't be blank"
],
"name": [
"can't be blank"
],
"email": [
"can't be blank"
]
}
}
It's good point, but we need to create new user. That's why we need to POST correct payload.
{
"user": {
"email": "[email protected]",
"name": "John Doe",
"phone": "033-64-22",
"password": "MySuperPa55"
}
}
We must get this response.
{
"status": "ok",
"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"
}
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
.
defmodule MyAppName.Accounts.User do
# ...
def find_and_confirm_password(email, password) do
case Repo.get_by(User, email: email) do
nil ->
{:error, :login_not_found}
user ->
if Comeonin.Bcrypt.checkpw(password, user.password_hash) do
{:ok, user}
else
{:error, :login_failed}
end
end
end
# ...
end
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
defmodule MyAppName.Web.FallbackAPIController do
use MyAppName.Web, :controller
def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
conn
|> put_status(:unprocessable_entity)
|> render(MyAppName.Web.ChangesetView, "error.json", changeset: changeset)
end
def call(conn, {:error, :login_failed}), do: login_failed(conn)
def call(conn, {:error, :login_not_found}), do: login_failed(conn)
defp login_failed(conn) do
conn
|> put_status(401)
|> render(MyAppName.Web.ErrorView, "error.json", status: :unauthorized, message: "Authentication failed!")
end
end
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.
defmodule MyAppName.Web.ErrorView do
use MyAppName.Web, :view
# ...
def render("error.json", %{status: status, message: message}) do
%{status: status, message: message}
end
# ...
end
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
.
defmodule MyAppName.Web.SessionController do
use MyAppName.Web, :controller
alias MyAppName.Accounts.User
action_fallback MyAppName.Web.FallbackAPIController
def sign_in(conn, %{"session" => %{"email" => email, "password" => pass}}) do
with {:ok, user} <- User.find_and_confirm_password(email, pass),
{:ok, jwt, _full_claims} <- Guardian.encode_and_sign(user, :api),
do: render(conn, "sign_in.json", user: user, jwt: jwt)
end
end
Good! Next step is to add SessionView
in lib/my_app_name/web/views/session_view.ex
.
defmodule MyAppName.Web.SessionView do
use MyAppName.Web, :view
def render("sign_in.json", %{user: user, jwt: jwt}) do
%{
status: :ok,
data: %{
token: jwt,
email: user.email
},
message: "You are successfully logged in! Add this token to authorization header to make authorized requests."
}
end
end
Add some routes to handle sign_in action in lib/my_app_name/web/router.ex
.
defmodule MyAppName.Router do
use MyAppName.Web, :router
#...
scope "/api/v1", MyAppName.Web do
pipe_through :api
post "/sign_up", RegistrationController, :sign_up
post "/sign_in", SessionController, :sign_in # Add this line
pipe_through :authenticated
resources "/users", UserController, except: [:new, :edit]
end
# ...
end
Ok. Let's check this stuff. POST /api/v1/sign_in
with this params.
{
"session": {
"email": "[email protected]",
"password": "MySuperPa55"
}
}
We should receive this response
{
"status": "ok",
"message": "You are successfully logged in! Add this token to authorization header to make authorized requests.",
"data": {
"token": "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJVc2VyOjEiLCJleHAiOjE0OTgwMzc0OTEsImlhdCI6MTQ5NTQ0NTQ5MSwiaXNzIjoiQ2lhbkV4cG9ydGVyIiwianRpIjoiZDNiOGYyYzEtZDU3ZS00NTBlLTg4NzctYmY2MjBiNWIxMmI1IiwicGVtIjp7fSwic3ViIjoiVXNlcjoxIiwidHlwIjoiYXBpIn0.HcJ99Tl_K1UBsiVptPa5YX65jK5qF_L-4rB8HtxisJ2ODVrFbt_TH16kJOWRvJyJIoG2EtQz4dXj7tZgAzJeJw",
"email": "[email protected]"
}
}
Now. You can take this token and add it to Authorization: #{token}
header.