Part 2: Adding Guardian Authentication
Now we're going to add authentication to our app. As this is going to be a JSON REST API we are going to use a token that needs to be passed as a header in each request made to the API. We are going to use Guardian to do the authentication.
This is NOT complete solution, as it is not saving the tokens to a DB, nor checking if they are still valid. But IT IS checking that the security token is valid and that is enough for now.
Series
- Part 1 - Elixir App creation
- Part 2 - Adds Guardian Authentication
- Part 3: Elm App creation and Routing setup
- Part 4: Adding Login and Register pages
- Part 5: Persisting session data to localStorage
Add the Guardian dependency
First let's add the guardian dependency to the mix.exs file
defp deps do
[
{:phoenix, "~> 1.3.0"},
{:phoenix_pubsub, "~> 1.0"},
{:phoenix_ecto, "~> 3.2"},
{:postgrex, ">= 0.0.0"},
{:gettext, "~> 0.11"},
{:cowboy, "~> 1.0"},
{:comeonin, "~> 4.0"},
{:argon2_elixir, "~> 1.2"},
{:guardian, "~> 1.0"}
]
end
And retrieve the dependencies
mix deps.get
Lets configure Guardian. There are a couple of things you need to do before Guardian can be used to generate tokens for you. The first thing is to create a module that will be used by Guardian as a set of callbacks for the things it doesn't know how to do and depends completely on your app.
Create a new file named guardian.ex in a new folder lib/toltec/auth/
# /lib/toltec/auth/guardian.ex
defmodule Toltec.Auth.Guardian do
use Guardian, otp_app: :toltec
def subject_for_token(resource, _claims) do
sub = to_string(resource.id)
{:ok, sub}
end
def resource_from_claims(claims) do
case Toltec.Accounts.get_user(claims["sub"]) do
nil -> {:error, "User not found"}
user -> {:ok, user}
end
end
end
This file responsibility is to convert between a token an a resource (a user) and between a resource (our user) and a unique identifier to be encoded in the token that will be passed all over the place.
The idea is simple, generate a unique, random, unpredictable string, that will represent our user (the subject). This string is the token, and will be given to the frontend so that in future interactions (requests) we know that a user is who it says it is.
Going back to the guardian module, you see that:
- we use the resource id (the user id) as the thing that uniquely identifies a user of our app and
- that from that user id (encoded in the claims["sub"], the subject) we can unequivocally know which user in our app corresponds to
Pay attention the the get_user method. This is different than the one we created on part 1. So go to accounts.ex and change this:
# lib/toltec/accounts/accounts.ex
def get_user!(id), do: Repo.get!(User, id)
to this
def get_user(id), do: Repo.get(User, id)
Let's continue. Now we need to configure the library to correctly generate the tokens.
As this tokens are cryptographically generated, the strength of the tokens will depend directly on the seed we use to initialise the crypto algorithm. So we need to generate a strong seed to be used by Guardian to generate the tokens.
Please, don't use this example key. Use a new one generated on your side.
# this is an example, don't use it in your app
mix guardian.gen.secret
TJD5jd2uGqrOO3zDb/pU85DhH9yzj5cy0WPjKQV6nz3d+XWS+RY5ff8hvhnfK2Dk
Now use this key to configure Guardian. Add this to config/dev.exs
config :toltec, Toltec.Auth.Guardian,
issuer: "toltec",
secret_key: "TJD5jd2uGqrOO3zDb/pU85DhH9yzj5cy0WPjKQV6nz3d+XWS+RY5ff8hvhnfK2Dk"
If you're going to commit this file to your repo, anyone who has access to that repo can generate valid tokens. So I'd suggest instead to use environment vars to assign a secret key, but that's your call.
Anyway, now that it is configured, let's use Guardian to create tokens when the user correctly logs in and starts a session.
Generate the JWT tokens
Add a new file named session_controller.ex. Its responsibilities are to create new sessions, delete them and refresh them.
# lib/toltec_web/controllers/session_controller.ex
defmodule ToltecWeb.SessionController do
use ToltecWeb, :controller
alias Toltec.Accounts
alias Toltec.Auth.Guardian
def create(conn, params) do
case authenticate(params) do
{:ok, user} ->
new_conn = Guardian.Plug.sign_in(conn, user)
jwt = Guardian.Plug.current_token(new_conn)
new_conn
|> put_status(:created)
|> render("show.json", user: user, jwt: jwt)
:error ->
conn
|> put_status(:unauthorized)
|> render("error.json", error: "User or email invalid")
end
end
def delete(conn, _) do
conn
|> Guardian.Plug.sign_out()
|> put_status(:no_content)
|> render("delete.json")
end
def refresh(conn, _params) do
user = Guardian.Plug.current_resource(conn)
jwt = Guardian.Plug.current_token(conn)
case Guardian.refresh(jwt, ttl: {30, :days}) do
{:ok, _, {new_jwt, _new_claims}} ->
conn
|> put_status(:ok)
|> render("show.json", user: user, jwt: new_jwt)
{:error, _reason} ->
conn
|> put_status(:unauthorized)
|> render("error.json", error: "Not Authenticated")
end
end
defp authenticate(%{"email" => email, "password" => password}) do
Accounts.authenticate(email, password)
end
defp authenticate(_), do: :error
end
As you can see, there are three methods, mapping the session operations: create, terminate and refresh. They use the Guardian.Plug functions to generate a JWT (JSON Web Token) when the user correctly gives an email and a password.
We have a helper authenticate() function that takes an email and password and checks in the database if a users exists for that combination. If it exists, it returns a {:ok, user} tuple. If it doesn't, it will return error. With that contract we can understand the create() function. If it is a valid email/password, we get a user that we can pass to the Guardian.Plug.sign_ing function to put the user in the conn struct. We then generate a token for that user and we finish by sending a JSON response that includes both the user and the token.
In case the email/password combination doesn't exist, we render a JSON error response.
The delete/2 function is simpler. What it does is to call the Guardian sing_out/0 method to remove the resource (our user, remember) from the connection struct.
The refresh method, is not going to be used yet, but what it does is to take a valid, existing token, get a new one with 30 more days of validity and send that new token as the response to substitute the old one.
Let's see how the authenticate/2 method in Accounts will look like:
# lib/toltec/accounts/accounts.ex
def authenticate(email, password) do
user = Repo.get_by(User, email: String.downcase(email))
case check_password(user, password) do
true -> {:ok, user}
_ -> :error
end
end
defp check_password(user, password) do
case user do
nil -> Comeonin.Argon2.dummy_checkpw()
_ -> Comeonin.Argon2.checkpw(password, user.password_hash)
end
end
This is quite simple, it tries to retrieve a user from the DB by the email. Then delegates to the check_password/2 to either do a dummy password check in case the user doesn't exist or check the password against the hash stored in DB, in case it exists.
Ok, the session controller is ready. Before moving to the views, lets add a user_controller, that will be responsible for creating new uses for our app. It will be very similar to the session_contoller.ex.
One point to notice is that the user creation is immediate, that is, as soon as the user creation request is finished, the user exists and is valid in the system. In a production system you'll need to add additional verification steps, like sending a confirmation email with a link to verify the email, before activating a user in the system. That's outside the scope of this tutorial.
Create a file name user_controller in lib/toltec_web/controllers/
# lib/toltec_web/controllers/user_controller.ex
defmodule ToltecWeb.UserController do
use ToltecWeb, :controller
alias Toltec.Accounts
alias Toltec.Accounts.User
alias Toltec.Auth.Guardian
action_fallback(ToltecWeb.FallbackController)
def create(conn, params) do
with {:ok, %User{} = user} <- Accounts.create_user(params) do
new_conn = Guardian.Plug.sign_in(conn, user)
jwt = Guardian.Plug.current_token(new_conn)
new_conn
|> put_status(:created)
|> render(ToltecWeb.SessionView, "show.json", user: user, jwt: jwt)
end
end
end
This controller receives a set of params (name, email, password) and pass it to the Accounts create_user/1 to create a new user. If that is successful, then automatically signs ing the user and creates a JWT. The user and token will be sent to the front end as JSON through the show.json view. Nothing complicated here, right?
Let's move to the views that render the JSON.
First the easy one. Create a user_view.ex file in lib/toltec_web/views like this:
# lib/toltec_web/views
defmodule ToltecWeb.UserView do
use ToltecWeb, :view
def render("user.json", %{user: user}) do
%{
id: user.id,
name: user.name,
email: user.email
}
end
end
Nothing unexpected. The render method receives a user and returns a map with the properties that we want to expos to the clients. Elixir will convert it to JSON for us. For this, we only expose the id, name and email.
Now create a new file named session_view.ex inside lib/toltec_web/views
# lib/toltec_web/views/session_view.ex
defmodule ToltecWeb.SessionView do
use ToltecWeb, :view
def render("show.json", %{user: user, jwt: jwt}) do
%{
data: render_one(user, ToltecWeb.UserView, "user.json"),
meta: %{token: jwt}
}
end
def render("delete.json", _) do
%{ok: true}
end
def render("error.json", %{error: error}) do
%{errors: %{error: error}}
end
end
This is similar although a bit more complex. The show.json view renders the user and the token to the client. It does it by delegating to the user.json view in UserView. Nice, right?
Configure the routes to our Users API
We're almost finished. We're only missing one simple but crucial step, the router. Without it, the app won't know what to do when it receives a request. So let's configure our router.
Open router.ex and make it look like this:
# lib/toltec_web/router.ex
defmodule ToltecWeb.Router do
use ToltecWeb, :router
pipeline :api do
plug(:accepts, ["json"])
end
pipeline :api_auth do
plug(Toltec.Auth.Pipeline)
end
scope "/api", ToltecWeb do
pipe_through(:api)
post("/sessions", SessionController, :create)
post("/users", UserController, :create)
end
scope "/api", ToltecWeb do
pipe_through([:api, :api_auth])
delete("/sessions", SessionController, :delete)
post("/sessions/refresh", SessionController, :refresh)
end
end
This is descriptive enough. It adds several new routes inside the /api scope. A couple ones are more restricted than the others.
If the request is a POST request to /sessions or /users, it will be allowed without authorization. But if the request is a DELETE to /sessions or a POST to /sessions/refresh, we require them to be authenticated. This is logical, as we expect that only authenticated logged in users can log out or refresh the session (that it, to refresh the token that represents a user in a session).
To do this authentication we are using a new pipeline: :api_auth. This pipeline will use Guardian to check if the connection has a valid token already in it or not.
Lets add this Guardian Pipeline helper. Create a file named pipeline.ex inside lib/toltec/auth/
# lib/toltec/auth/pipeline.ex
defmodule Toltec.Auth.Pipeline do
use Guardian.Plug.Pipeline,
otp_app: :toltec,
module: Toltec.Auth.Guardian,
error_handler: Toltec.Auth.ErrorHandler
plug(Guardian.Plug.VerifyHeader)
plug(Guardian.Plug.EnsureAuthenticated)
plug(Guardian.Plug.LoadResource)
end
Essentially checks that a request made to our app has a header with the token, ensure that the token is valid and, if it is, loads the resource automatically (that is, loads the user corresponding to the token's claims["sub"] value in the encrypted token).
The Guardian Pipeline will need a module to handle the error it may found. So add a error_handler.ex file in lib/toltec/auth like this:
# lib/toltec/auth/error_handler.ex
defmodule Toltec.Auth.ErrorHandler do
import Plug.Conn
def auth_error(conn, {:invalid_token, _reason}, _opts),
do: response(conn, :unauthorized, "Invalid Token")
def auth_error(conn, {:unauthenticated, _reason}, _opts),
do: response(conn, :unauthorized, "Not Authenticated")
def auth_error(conn, {:no_resource_found, _reason}, _opts),
do: response(conn, :unauthorized, "No Resource Found")
def auth_error(conn, {type, _reason}, _opts), do: response(conn, :forbidden, to_string(type))
defp response(conn, status, message) do
body = Poison.encode!(%{error: message})
conn
|> put_resp_content_type("application/json")
|> send_resp(status, body)
end
end
Consume the API
Let's try our API. This is hard as the app has no web rendering at all. All the interaction is through REST calls and both the requests and response will be encoded in JSON. So we need a client to connect and use our API.
There are extensions for Chrome browser to consume REST APIs, like Advanced REST client. Surely there are native Window apps to do the same. For macOS I'm using the amazing Insomnia app. For this tutorial we'll just use curl to do the requests.
First, ensure the app is running
mix phx.server
[info] Running ToltecWeb.Endpoint with Cowboy using http://0.0.0.0:4000
Now, let's do a login request. This needs to be a POST request to the /api/sessions route. And we need to pass the email and password as parameters. We already have one user in our app inserted in our seeds.exs. Use that one
curl --request POST \
--url http://localhost:4000/api/sessions \
--header 'authorization: Bearer ' \
--header 'content-type: application/x-www-form-urlencoded' \
--data 'email=user%40toltec&password=user%40toltec'
{
"meta":{
"token":"eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJ0b2x0ZWMiLCJleHAiOjE1MzEzNTEzMzAsImlhdCI6MTUyODkzMjEzMCwiaXNzIjoidG9sdGVjIiwianRpIjoiMjQ1ZjUyMjAtZWRmMi00OWM5LThiZmMtYWJkNTU4ZTRlYjU4IiwibmJmIjoxNTI4OTMyMTI5LCJzdWIiOiIxIiwidHlwIjoiYWNjZXNzIn0.AMtC7MyvkMqXKyMyV3oBvkVBgnWNTPxDAKaFx0xgfq_ubQ9XbUJH2ZqoRKCxWE0BUUEPq_GYxNdGXPzi72W_Tg"
},
"data":{
"name":"some user",
"id":1,
"email":"user@toltec"
}
}
Great, we got a response that includes the user and the token value.
Let's try now the logout request. This is a DELETE request that only needs to include the token as a header. I haven't metioned the format of the header but it needs to be in the format
authorization: Bearer <jwt_value>
So for this logout request, let's use curl like this, using the JWT value from the previous step
curl --request DELETE \
--url http://localhost:4000/api/sessions \
--header 'authorization: Bearer eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJ0b2x0ZWMiLCJleHAiOjE1MzEzNTEzMzAsImlhdCI6MTUyODkzMjEzMCwiaXNzIjoidG9sdGVjIiwianRpIjoiMjQ1ZjUyMjAtZWRmMi00OWM5LThiZmMtYWJkNTU4ZTRlYjU4IiwibmJmIjoxNTI4OTMyMTI5LCJzdWIiOiIxIiwidHlwIjoiYWNjZXNzIn0.AMtC7MyvkMqXKyMyV3oBvkVBgnWNTPxDAKaFx0xgfq_ubQ9XbUJH2ZqoRKCxWE0BUUEPq_GYxNdGXPzi72W_Tg' --verbose
* Trying ::1...
* TCP_NODELAY set
* Connection failed
* connect to ::1 port 4000 failed: Connection refused
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 4000 (#0)
> DELETE /api/sessions HTTP/1.1
> Host: localhost:4000
> User-Agent: curl/7.54.0
> Accept: */*
> authorization: Bearer eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJ0b2x0ZWMiLCJleHAiOjE1MzEzNTEzMzAsImlhdCI6MTUyODkzMjEzMCwiaXNzIjoidG9sdGVjIiwianRpIjoiMjQ1ZjUyMjAtZWRmMi00OWM5LThiZmMtYWJkNTU4ZTRlYjU4IiwibmJmIjoxNTI4OTMyMTI5LCJzdWIiOiIxIiwidHlwIjoiYWNjZXNzIn0.AMtC7MyvkMqXKyMyV3oBvkVBgnWNTPxDAKaFx0xgfq_ubQ9XbUJH2ZqoRKCxWE0BUUEPq_GYxNdGXPzi72W_Tg
>
< HTTP/1.1 204 No Content
< server: Cowboy
< date: Wed, 13 Jun 2018 23:23:27 GMT
< content-length: 11
< content-type: application/json; charset=utf-8
< cache-control: max-age=0, private, must-revalidate
< x-request-id: 2krtnfgdqhj1inb4jg0000r5
<
* Excess found in a non pipelined read: excess = 11 url = /api/sessions (zero-length body)
* Connection #0 to host localhost left intact
As you can see, the API returned a 204 response (no content), just as we specified on the session_controller.ex.
Finally let's try the new user creation. Let's do a POST request to /api/users/
curl --request POST \
--url http://localhost:4000/api/users \
--header 'authorization: Bearer ' \
--header 'content-type: application/x-www-form-urlencoded' \
--data 'email=miguel%40toltec&password=miguel%40toltec&name=Miguel%20Coba'
{
"meta":{
"token":"eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJ0b2x0ZWMiLCJleHAiOjE1MzE1NjUxODYsImlhdCI6MTUyOTE0NTk4NiwiaXNzIjoidG9sdGVjIiwianRpIjoiNTYxODY1MzQtNGZhNi00YWJkLWEwOTUtZTEyZTgzN2QzMmM4IiwibmJmIjoxNTI5MTQ1OTg1LCJzdWIiOiIyIiwidHlwIjoiYWNjZXNzIn0.CzZO0LgfqxwZ7S1Qy6lgVNrrjqacdl7fdEhVOnmt6LoXEBdN1muK1xRBDQOlll8h_lWV7PIJoZMFWUTzmcPuLg"
},
"data":{
"name":"Miguel Coba",
"id":2,
"email":"miguel@toltec"
}
}
You can find the source code, with tests, in the repo here in the branch part-02.
After cloning it, run the tests and verify that everything is alright:
mix test
......................................
Finished in 0.6 seconds
38 tests, 0 failures
That's it. We have added a router that accepts request to create new users, login and logout them and extend their sessions. There is no html responded, only JSON.
In part 3 we'll start with the frontend Elm app.
Top comments (10)
This is great. Thanks to the github repo I got all tests validated. One thing: the seeded user token never seems to successfully delete:
passing in the token on the seeded user always fails(
401
). However, if I create a new user with:and then I call
DELETE /api/sessions
whilst passing in the correct token, I get204
. So it appears ok on new users.Hi Michael, I just tried and I had no problems at all loggin in the seeded user and then loggin it out.
Are you sure that in the delete curl command, you used a valid token that you got from the loggin cur action. Maybe you used the one from my example?
Cheers
Finally got back to this. Yes, that was the problem.
I'm trying to execute the tests for session_controller but in my terminal is loggin out:
undefined function create_user/1
I think this is because the statement
setup["create_user"]
in somehow is not found out by the test engine. How can I fix it?Is it really good idea to allow anyone to create new real users?
This feature allows users to signup to the app. Ideally we would offer also the option to signup with google/facebook/etc.
Or provide some kind of user-assisted account confirmation.
Yes, that's correct. For this tutorial an email confirmation or any other type of user signup improvement is out of scope.
Ok. I agree.
Maybe just add a notice about such an assumption? Just for clarification.
Sure