In previous posts we built a basic Phoenix application with pages for authentication and then added the ability for users to register for an account using their email address and password and be logged in after so doing. In this post we will expand on that work by adding the ability for existing account holders to log in and log out.
Test driving log in and log out from the outside-in
As with our previous posts, we’ll begin the work for this feature by creating a feature test that uses a browser to emulate the way an end user will interact with this feature. We’ll want these tests to cover at least the following requirements:
- When a user provides their email address and a correct password, the user is logged in and sees their profile page.
- When a user provides their email address and an incorrect password, the user is not logged in and sees a message that they need to try again.
- When a user provides an unknown email address and a password, the user is not logged in and sees a message that they need to try again.
- When a user who has already logged in visits the log in page, the user is redirected to their profile page.
Add the following feature tests to your project to cover those requirements:
# test/yauth_web/features/account_sessions_test.exs
defmodule YauthWeb.AccountSessionsTest do
use YauthWeb.FeatureCase
import Wallaby.Query, only: [fillable_field: 1, button: 1, link: 1]
alias Yauth.Accounts.Account
alias Yauth.Repo
@email_field fillable_field("account[email]")
@password_field fillable_field("account[password]")
@submit_button button("Log In")
@log_out_link link("Log Out")
setup do
account =
%Account{}
|> Account.changeset(%{
"email" => "me@example.com",
"password" => "superdupersecret",
"password_confirmation" => "superdupersecret"
})
|> Repo.insert!()
{:ok, account: account}
end
test "Log in and out of an existing account", %{session: session, account: account} do
session
|> visit("/login")
|> fill_in(@email_field, with: account.email)
|> fill_in(@password_field, with: "superdupersecret")
|> click(@submit_button)
assert_text(session, "Hello, #{account.email}")
click(session, @log_out_link)
assert current_path(session) == "/login"
end
test "Log in with the wrong email and password", %{session: session, account: account} do
session
|> visit("/login")
|> fill_in(@email_field, with: account.email)
|> fill_in(@password_field, with: "thisisnotmypassword")
|> click(@submit_button)
assert_text(session, "Incorrect email or password")
session
|> visit("/login")
|> fill_in(@email_field, with: "notmyemail@example.com")
|> fill_in(@password_field, with: "superdupersecret")
|> click(@submit_button)
assert_text(session, "Incorrect email or password")
end
test "Visit log in when already logged in", %{session: session, account: account} do
session
|> visit("/login")
|> fill_in(@email_field, with: account.email)
|> fill_in(@password_field, with: "superdupersecret")
|> click(@submit_button)
visit(session, "/login")
assert current_path(session) == "/profile"
end
test "Accessing a protected resource without logging in", %{session: session} do
visit(session, "/profile")
assert current_path(session) == "/login"
end
end
Logging in
As with our previous feature test for registering for an account, if we run
through the first test through a browser, we’ll discover a 404 page. We are
missing our POST route to handle the submission of the log in form. Let’s add
that route to our Router
.
# lib/yauth_web/router.ex
defmodule YauthWeb.Router do
# ...
scope "/", YauthWeb do
# ...
post "/login", SessionController, :create
# ...
end
# ...
end
At this point our SessionController
doesn’t have a create/2
function. That
function will receive the email address and plain text password provided by the
user nested under the key “account”. As with our RegistrationController
we can
pattern match on that key to extract the data with which this function is
concerned.
# lib/yauth_web/controllers/session_controller.ex
defmodule YauthWeb.SessionController do
# ...
def create(conn, %{"account" => %{"email" => email, "password" => password}}) do
# ...
end
end
Now that we have that data bound to variables, we need to first get the account holder from the database using that email and then ensure that the password they provided matches the encrypted password we stored previously for that account. As with our previous controller implementations, however, we want to move the bulk of that work out of the controller layer and into our “contexts” layer. Let’s write the code we wish we had and then fill in the details.
# lib/yauth_web/controllers/session_controller.ex
defmodule YauthWeb.SessionController do
# ...
alias Yauth.Accounts
alias YauthWeb.Authentication
# ...
def create(conn, %{"account" => %{"email" => email, "password" => password}}) do
case email |> Accounts.get_by_email() |> Authentication.authenticate(password) do
{:ok, account} ->
conn
|> Authentication.log_in(account)
|> redirect(to: Routes.profile_path(conn, :show))
{:error, :invalid_credentials} ->
conn
|> put_flash(:error, "Incorrect email or password")
|> new(%{})
end
end
end
As you can see, we pass the email data to an Accounts.get_by_email/1
function
and pass the result of that to an Authentication.authenticate/2
function. We
expect that function to return either the account in an :ok
tuple or an error
in an :error
tuple.
We need to define those functions. Let’s add the get_by_email/1
function to
our Accounts
context. As you may recall, we added a database index for this
field so we should have solid performance for this query.
# lib/yauth/accounts.ex
defmodule Yauth.Accounts do
# ...
def get_by_email(email) do
Repo.get_by(Account, email: email)
end
end
This function will return either the account or nil
so we’ll need to handle
both in our Authentication.authenticate/2
function. Let’s define that now.
# lib/yauth_web/authentication.ex
defmodule YauthWeb.Authentication do
# ...
def authenticate(%Account{} = account, password) do
authenticate(
account,
password,
Argon2.verify_pass(password, account.encrypted_password)
)
end
def authenticate(nil, password) do
authenticate(nil, password, Argon2.no_user_verify())
end
defp authenticate(account, _password, true) do
{:ok, account}
end
defp authenticate(_account, _password, false) do
{:error, :invalid_credentials}
end
# ...
end
There is a fair amount going on in these functions so let’s walk through them from top to bottom.
def authenticate(%Account{} = account, password) do
authenticate(
account,
password,
Argon2.verify_pass(password, account.encrypted_password)
)
end
In the first case, if we receive an %Account{}
struct and the password, we
call through to a 3-arity function with the account, password, and the result of
calling Argon2.verify_pass/2
. That function is used to compare a plain text
password with an encrypted password.
def authenticate(nil, password) do
authenticate(nil, password, Argon2.no_user_verify())
end
In the second function head, if we receive nil
and the plain text password, we
call through to our 3-arity function with the result of calling
Argon2.no_user_verify()
. This latter function always returns false
and is
called in order to prevent certain timing attacks.
defp authenticate(account, _password, true) do
{:ok, account}
end
defp authenticate(_account, _password, false) do
{:error, :invalid_credentials}
end
Finally, our 3-arity function returns {:ok, account}
if the password check was
successful and {:error, :invalid_credentials}
otherwise.
For all of this to work, however, we’ll want to make some updates to our
SessionController.new/2
function. Up to now we’ve been using the conn
as our
changeset and passing a hard coded string for the action. We can now update that
to use both an account changeset and the route helper for the action.
# lib/yauth_web/controllers/session_controller.ex
defmodule YauthWeb.SessionController do
# ...
def new(conn, _params) do
render(
conn,
:new,
changeset: Accounts.change_account(),
action: Routes.session_path(conn, :create)
)
end
# ...
end
At this point our tests for incorrect email addresses and passwords are passing but we still have work to do for logging out and for redirecting an already logged in user. Let’s handle logging out first.
Logging out
In order to log out, we’ll want to make a DELETE request that can be handled by
our SessionController
. First, we’ll need to add a route for that request.
# lib/yauth_web/router.ex
defmodule YauthWeb.Router do
# ...
scope "/", YauthWeb do
pipe_through [:browser, :guardian, :browser_auth]
# ...
delete "/logout", SessionController, :delete
end
# ...
end
Next, we’ll need to add that function to the SessionController
. Similar to our
other controller functions, we’ll rely on our Authentication
context for
handling the log out and then we’ll send the user back to the log in page.
# lib/yauth_web/controllers/session_controller.ex
defmodule YauthWeb.SessionController do
# ...
def delete(conn, _params) do
conn
|> Authentication.log_out()
|> redirect(to: Routes.session_path(conn, :new))
end
end
We need to add the log_out/1
function to our Authentication
module.
# lib/yauth_web/authentication.ex
defmodule YauthWeb.Authentication do
# ...
def log_out(conn) do
__MODULE__.Plug.sign_out(conn)
end
# ...
end
As with our log_in/2
function, this function calls out to a Guardian-provided
function. Recall that our Authentication
module is our implementation module
for the Guardian behaviour
and, as such, has access to the Guardian-provided
Plug functions. That function handles clearing the account’s data from the
session.
To complete the log out functionality, we need to provide a link for our user
so they can initiate this request. First, let’s replace the <header>
of our
application layout template with one that contains a menu where we can put this
link. Note, in particular, the method: :delete
option that we pass to the
link
function.
<!-- lib/yauth_web/templates/layout/app.html.eex -->
<!-- ... -->
<header>
<section class="container">
<%= if @conn.assigns[:current_account] do %>
<nav class="navigation" role="navigation">
<section class="container">
<a href="#" class="navigation-title" title="Yauth">Yauth</a>
<ul class="navigation-list float-right">
<li class="navigation-item">
<%= @current_account.email %>
</li>
<li class="navigation-item">
<%= link(
"Log Out",
to: Routes.session_path(@conn, :delete),
method: :delete
) %>
</li>
</ul>
</section>
</nav>
<% end %>
</section>
</header>
<!-- ... -->
We’ll also need to add some CSS to style this menu. Add the following to our main CSS file:
# assets/css/app.css
# ...
.navigation {
background: #f4f5f6;
border-bottom: .1rem solid #d1d1d1;
display: block;
height: 5.2rem;
left: 0;
max-width: 100%;
position: fixed;
right: 0;
top: 0;
width: 100%;
z-index: 1;
}
.navigation .navigation-list,
.navigation .navigation-title {
display: inline;
font-size: 1.6rem;
line-height: 5.2rem;
padding: 0;
text-decoration: none;
}
.navigation .navigation-list {
list-style: none;
margin-bottom: 0;
}
.navigation .navigation-item {
float: left;
margin-bottom: 0;
margin-left: 2.5rem;
position: relative;
}
main.container {
padding-top: 7rem;
}
Redirecting logged in users
At this point all of our tests should be passing with the exception of
redirecting an already logged in account. In the event a user who has already
logged in visits the “/login” route or the “/register” route, our controllers
will send that user to their profile page instead. To accomplish this the
controllers need to see if a user has already logged in. Update our new/2
function to the following:
# lib/yauth_web/controllers/session_controller.ex
defmodule YauthWeb.SessionController do
# ...
def new(conn, _params) do
if Authentication.get_current_account(conn) do
redirect(conn, to: Routes.profile_path(conn, :show))
else
render(
conn,
:new,
changeset: Accounts.change_account(),
action: Routes.session_path(conn, :create)
)
end
end
# ...
end
# lib/yauth_web/controllers/registration_controller.ex
defmodule YauthWeb.RegistrationController do
# ...
def new(conn, _) do
if Authentication.get_current_account(conn) do
redirect(conn, to: Routes.profile_path(conn, :show))
else
render(
conn,
:new,
changeset: Accounts.change_account(),
action: Routes.registration_path(conn, :create)
)
end
end
# ...
end
Our Authentication
module already has the get_current_account/1
function as
we added it in a previous post for getting the resource for our profile
controller. Here we use that function knowing that our Pipeline
plug only
loaded the resource if the session has a valid token. The Guardian-provided
function current_resource
either returns the account it loaded after
validating the session token or it returns nil
. Our SessionController
uses a
simple conditional to check for the presence of that account and redirects to
the profile page in the event it exists. Otherwise it creates the changeset and
renders the new session page.
At this point all of our tests should be passing and we’ve completed the feature of allowing users to log in and out of an existing account!
Recap
In this post we added the ability for an account holder to log in and log out of
their accounts. We did so by adding to our Authentication
module the ability
to check a plain text password against an encrypted password and adding account
tokens to the session in the event emails and passwords matched previously
stored values. We added to our session controller logic to use the
Authentication
module to determine what to show users. We also added the
ability to log out by leveraging the functions Guardian provides to remove
tokens from the session.
In our next post we will add the ability for users to register for accounts using their Google or Twitter accounts instead of providing an email address and password.