User Authentication - SolarisJapan/lunaris-wiki GitHub Wiki
Since the basic idea for most user authentications is basically the same, we can set up a basic login flow for most apps.
🔥 In all code examples, don't forget to replace YourApp
and your_app
with the name of your app! 🔥
This wiki includes code examples, please make sure you understand the code, don't just copy and paste. If you find any errors, or have suggestions for improvements feel free to make a PR
Accounts
will be the context responsible for interactions with user authentication. We will be using Comeonin
with Argon2
as hashing algorithm. The basic User
schema is quite simple:
defmodule YourApp.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
alias YourApp.Encryption
schema "users" do
field(:name, :string)
field(:password, :string, virtual: true)
field(:password_hash, :string)
timestamps()
end
@doc false
def changeset(%__MODULE__{} = user, %{password: _} = attrs) do
user
|> cast(attrs, [:name, :password])
|> validate_required([:name, :password])
|> put_password_hash()
|> unique_constraint(:name, message: "Name is already taken")
end
def changeset(%__MODULE__{} = user, attrs) do
user
|> cast(attrs, [:name, :password_hash])
|> validate_required([:name, :password_hash])
|> unique_constraint(:name)
end
defp put_password_hash(changeset) do
case changeset do
%Ecto.Changeset{valid?: true, changes: %{password: password}} ->
put_change(changeset, :password_hash, Encryption.hash(password))
_ ->
changeset
end
end
end
In Accounts
we want some basic functions to be able to create and update our user accordingly.
defmodule YourApp.Accounts do
def create_user(args)
def validate_password(user, password)
def get_user_by_name(name)
# This might or might not be a good idea for your app
def update_password(user, %{old_password: _, new_password: _})
# ..
end
Add Comeonin and Argon2Elixir to mix.exs
and run mix deps.get
defp deps do
[
# ...
{:comeonin, "~> 5.1"},
{:argon2_elixir, "~> 2.0"}
]
Unless you want to use the phoenix scaffold when using mix phx.gen.X
, it is probably easier to create the files yourself and add functions when you need them, instead of having to clean up unused functions in the scaffold.
Run mix ecto.gen.migration create_users
and add this to the generated migration file in priv/repo/migrations
def change do
create table(:users) do
add(:name, :string, null: false)
add(:password_hash, :string, null: false)
timestamps()
end
create(unique_index(:users, [:name]))
end
Add the basic schema, context outline and Encryption modules.
touch lib/your_app/accounts.ex && mkdir lib/your_app/accounts && touch lib/your_app/accounts/user.ex && touch lib/your_app/encryption.ex
# lib/your_app/accounts/user.ex
defmodule YourApp.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
alias YourApp.Encryption
schema "users" do
field(:name, :string)
field(:password, :string, virtual: true)
field(:password_hash, :string)
timestamps()
end
@doc false
def changeset(%__MODULE__{} = user, %{password: _} = attrs) do
user
|> cast(attrs, [:name, :password])
|> validate_required([:name, :password])
|> put_password_hash()
|> unique_constraint(:name, message: "Name is already taken")
end
def changeset(%__MODULE__{} = user, attrs) do
user
|> cast(attrs, [:name, :password_hash])
|> validate_required([:name, :password_hash])
|> unique_constraint(:name)
end
defp put_password_hash(changeset) do
case changeset do
%Ecto.Changeset{valid?: true, changes: %{password: password}} ->
put_change(changeset, :password_hash, Encryption.hash(password))
_ ->
changeset
end
end
end
# lib/your_app/accounts.ex
defmodule YourApp.Accounts do
alias YourApp.{Accounts, Repo}
@doc """
Creates a user entry and creates the password hash.
## Examples
iex> create_user(%{name: "Test User", password: "123password"})
{:ok, %Accounts.User{}}
iex> create_user(%{name: nil, password: nil})
{:error, %Ecto.Changeset{}}
"""
def create_user(args)
@doc """
Finds a user by name, returns the user or `nil`.
## Examples
iex> get_user_by_name("Test User")
%Accounts.User{}
iex> get_user_by_name("Does not exist")
nil
"""
def get_user_by_name(nil), do: nil
def get_user_by_name(name)
@doc """
Securely compares a users password and returns `true` or `false`.
## Examples
iex> validate_password(%Accounts.User{password_hash: "hashed_pwd", "123Password"})
true
iex> validate_password(%Accounts.User{password_hash: "hashed_pwd", "WrongPassword"})
false
"""
def validate_password(user, password)
end
defmodule YourApp.Encryption do
@moduledoc false
def hash(string),
do: Argon2.hash_pwd_salt(string)
def check_pass(user, pwd),
do: Argon2.check_pass(user, pwd)
end
To implement the business logic for these functions, we are going to create the tests first.
Having factories for your tests will probably make things easier, but if you prefer you can also create test database entries in your test code.
# without factories
touch test/accounts_test.exs
# with factories
touch test/accounts_test.exs && mkdir /test/factories && touch test/factories/user_factory.ex && touch test/factories/factory.ex
Factories are a convenient way to populate your database with necessary entries, and provide reasonable, overwritable default values.
Add the factories path to elixir_paths/1
in mix.exs
defp elixirc_paths(:test), do: ["lib", "test/support", "test/factories"]
We want to introduce a generic Factory
to be usable as a DSL in our factories. In this tutorial, we are going to build a custom ex_machina - this is mainly for educational reasons, in most apps using ex_machina works perfectly well. The reason why you would maybe want to implement the factory yourself is that is easier to customize your own code, and especially creating associations is not always exactly the way you might need it with ex_machina.
If you do not want to use a custom made factory, install ex_machina and skip this part
We will implement insert!
and sequence
as well as define the __using__
macro:
defmodule Factory do
@moduledoc """
A DSL to create factories.
## Usage
Create a factory module and implement the build function
defmodule MyFactory do
use Factory
def build(:my_thing, args) do
%MyApp.MyThing{
name: "A thing"
}
end
end
"""
defmacro __using__(_opts) do
quote do
alias YourApp.Repo
import Factory, only: [sequence: 1]
@doc false
def build(factory_name) do
raise(Factory.BuildNotImplementedError, factory_name)
end
defoverridable(build: 1)
@doc false
def build(factory_name, attributes),
do: factory_name |> build() |> struct(attributes)
@doc false
def insert!(factory_name, attributes \\ []),
do: factory_name |> build(attributes) |> Repo.insert!()
end
end
defmodule BuildNotImplementedError do
@moduledoc """
Error raised when a `build/1` function is not implemented.
"""
defexception [:message]
def exception(factory_name) do
message = """
No build function defined for #{inspect(factory_name)}
Please implement the function in your factory:
def build(#{inspect(factory_name)}) do
%MyApp.MyThing{
name: "A thing"
}
end
"""
%BuildNotImplementedError{message: message}
end
end
@doc false
def sequence(fun) when is_function(fun),
do: System.unique_integer([:positive]) |> to_string() |> fun.()
def sequence(argument) do
raise(
ArgumentError,
"Factory.sequence/1 expects the argument to be a function, got #{inspect(argument)}"
)
end
end
Now we can build our user factory
defmodule UserFactory do
use Factory
alias YourApp.{Accounts, Repo, Encryption}
def build(:user) do
%Accounts.User{
password_hash: Encryption.hash("password123"),
name: sequence(&"#{&1}-user")
}
end
end
And use it like this
user = UserFactory.insert!(:user, %{name: "hello there"})
We want to test the 3 basic functions in Accounts
: create_user
, get_user_by_name
, and validate_password
defmodule YourApp.AccountsTest do
use YourApp.DataCase
describe "Accounts.create_user/1" do
test "creates a user when given valid arguments"
test "returns {:error, %Ecto.Changeset{}} when given invalid arguments"
end
describe "Accounts.get_user_by_name/1" do
test "returns %Accounts.User{} when a user with that name exists"
test "returns nil when no user with that name exists"
end
describe "Accounts.validate_password/2" do
test "returns true if the password matches the password hash"
test "returns false if the password does not match the password hash"
end
end
If you are going with the factory and haven't set up the User schema yet, running these empty tests will already give us our first error message
== Compilation error in file test/factories/user_factory.ex ==
** (CompileError) test/factories/user_factory.ex:7: YourApp.Accounts.User.__struct__/1 is undefined, cannot expand struct YourApp.Accounts.User
test/factories/user_factory.ex:6: (module)
This means we haven't implemented the user schema yet, but already used it in our factory. You can use the User schema described earlier. The test should now fail with the message Not Implemented
.
The caveat with the factory is, that we need to create users with the password_hash, instead of just setting the password.
defmodule YourApp.AccountsTest do
use YourApp.DataCase
alias YourApp.Accounts
describe "Accounts.create_user/1" do
test "creates a user when given valid arguments" do
valid_args = %{name: "Some new user", password: "123password"}
assert {:ok, %Accounts.User{}} = Accounts.create_user(valid_args)
end
test "returns {:error, %Ecto.Changeset{}} when given invalid arguments" do
assert {:error, %Ecto.Changeset{}} = Accounts.create_user(%{name: "Some new user"})
assert {:error, %Ecto.Changeset{}} = Accounts.create_user(%{password: "123password"})
end
end
describe "Accounts.get_user_by_name/1" do
setup do
%{user: UserFactory.insert!(:user)}
end
test "returns %Accounts.User{} when a user with that name exists", %{user: user} do
assert user.id == Map.get(Accounts.get_user_by_name(user.name), :id)
end
test "returns nil when no user with that name exists", %{user: user} do
assert is_nil(Accounts.get_user_by_name("#{user.name} - does not exist"))
end
end
describe "Accounts.validate_password/2" do
alias YourApp.Encryption
setup do
password = "greatPassword"
%{
password: password,
user: UserFactory.insert!(:user, %{password_hash: Encryption.hash(password)})
}
end
test "returns true if the password matches the password hash", args do
assert Accounts.validate_password(args.user, args.password)
end
test "returns false if the password does not match the password hash", args do
refute Accounts.validate_password(args.user, "#{args.password} - is wrong")
end
end
end
All the tests should fail with an error message that the function we are trying to test is undefined or private!
The implementation for create_user
and validate_user
is pretty straight_forward:
# lib/accounts.ex
defmodule YourApp.Accounts do
alias YourApp.{Accounts, Repo, Encryption}
# ...
def create_user(args) do
%Accounts.User{}
|> Accounts.User.changeset(args)
|> Repo.insert()
end
# ...
def validate_password(user, password) do
case Encryption.check_pass(user, password) do
{:ok, _} -> true
{:error, _} -> false
end
end
end
For the implementation of get_user_by_name
, we need an Ecto.Query
. To separate concerns, queries should have their own modules.
touch lib/accounts/your_app/queries.ex
defmodule YourApp.Accounts.Queries do
@moduledoc """
Queries in the Accounts context.
All queries should be usable with a `queryable` as the first argument so you can use them
with pipes
iex> MySchema |> Queries.first_query(%{field: "value"}) |> Queries.second_query(%{field2: "value2", field3: "value3"})
"""
import Ecto.Query
@doc """
User where name equals the name given.
## Examples
iex> user_where_name(Accounts.User, %{name: "A User"})
%Ecto.Query{}
"""
def user_where_name(queryable, %{name: name}),
do: from(u in queryable, where: u.name == ^name)
def user_where_name(queryable, _),
do: queryable
end
We can now use the queries module to get a user by name in Accounts
defmodule YourApp.Accounts do
# ...
def get_user_by_name(name) do
Accounts.User
|> Accounts.Queries.user_where_name(%{name: name})
|> Repo.one()
end
end
We can do the same for get_session_by_token
# lib/your_app/accounts/queries.ex
# ...
@doc """
Session where token equals the token given.
## Examples
iex> session_where_token(Accounts.Session, %{token: "A Session"})
%Ecto.Query{}
"""
def session_where_token(queryable, %{token: token}),
do: from(s in queryable, where: s.token == ^token)
def session_where_token(queryable, _),
do: queryable
# lib/your_app/accounts.ex
# ...
@doc """
Finds a session by token, returns the session or `nil`.
## Examples
iex> get_session_by_token("Test Session")
%Accounts.Session{}
iex> get_session_by_token("Does not exist")
nil
"""
def get_session_by_token(nil), do: nil
def get_session_by_token(token) do
Accounts.Session
|> Accounts.Queries.session_where_token(%{token: token})
|> Repo.one()
end
We will set up a basic login page, with Zurb Foundation [Setup-Phoenix-Zurb-Foundation]
Lets use the phoenix generator for a change
mix phx.gen.html Accounts Session sessions token:string user_id:references:users
We are going to add session logic to the Accounts
context, the main object in this context is the session. This creates more than we actually need, so this is more of a showcase why you probably hardly ever want to use the phoenix generators unless you want to look something up in the generated files. We can enforce a single active session if we want, but for this example we will have a user with multiple active sessions, so if you log in on a new device or browser you will not be logged out of your other sessions.
For the session, we only need new
, create
and delete
. Go to the session_controller.ex
file and delete all the other actions (same in session_controller_test.exs
), then delete the templates show.html.eex
, edit.html.eex
and index.html.eex
. We also don't need half of the generated functions in accounts.ex
, you can delete list_sessions
, and update_session
(and again in accounts_test.exs
). We need to clean up the schema a little bit as well, in accounts/session.ex
remove the field user and use the belongs_to
function instead - we also want to be able to put the user association into the changeset. The changeset should be the one to put the session token as well
defmodule YourApp.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
alias YourApp.Accounts
schema "sessions" do
field :token, :string
belongs_to :user, Accounts.User
timestamps()
end
@doc false
def changeset(session, attrs) do
session
|> cast(attrs, [:user_id])
|> validate_required([:user_id])
|> put_token()
end
@doc false
defp put_token(changeset) do
if changeset.valid? do
put_change(changeset, :token, SecureRandom.uuid())
else
changeset
end
end
end
and add the association to accounts/user.ex
defmodule YourApp.Accounts.User do
# ...
alias YourApp.{Accounts, Encryption}
schema "users" do
field(:name, :string)
field(:password, :string, virtual: true)
field(:password_hash, :string)
has_many(:sessions, Accounts.Session)
timestamps()
end
# ...
end
Secure Random is an elixir package to generate unique, random strings. We need to add it to our dependencies and run mix deps.get
# mix.exs
#...
defp deps do
[
#...
{:secure_random, "~> 0.5"}
]
end
For now we can ignore most of the business logic and focus on the templates. We have to add the routes though:
resources "/sessions", SessionController, only: [:new, :create, :delete]
The generated code in the session controller refers to the index and show path we deleted, so it will raise an error if we try to run phx.server
and go to localhost:4000/sessions/new
. For this example, we will create a quick dashboard.
touch lib/your_app_web/controllers/dashboard_controller.ex && touch lib/your_app_web/views/dashboard_view.ex && mkdir lib/your_app_web/templates/dashboard && touch lib/your_app_web/templates/dashboard/inde
x.html.eex
defmodule YourAppWeb.DashboardController do
use YourAppWeb, :controller
def index(conn, _params) do
render(conn, "index.html")
end
end
defmodule YourAppWeb.DashboardView do
use YourAppWeb, :view
end
# router.ex
#...
scope "/", YourAppWeb do
pipe_through :browser
resources "/sessions", SessionController, only: [:new, :create, :delete]
get "/dashboard", DashboardController, :index
end
#...
and in templates/session/new.html.eex
delete <span><%= link "Back", to: Routes.session_path(@conn, :index) %></span>
Now we can redirect to the empty dashboard after creating a new user session, and back to the new session page after deleting a session (logout)
defmodule YourAppWeb.SessionController do
use YourAppWeb, :controller
alias YourApp.Accounts
alias YourApp.Accounts.Session
def new(conn, _params) do
changeset = Accounts.change_session(%Session{})
render(conn, "new.html", changeset: changeset)
end
def create(conn, %{"session" => session_params}) do
case Accounts.create_session(session_params) do
{:ok, session} ->
conn
|> put_flash(:info, "Session created successfully.")
|> redirect(to: Routes.dashboard_path(conn, :index))
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, "new.html", changeset: changeset)
end
end
def delete(conn, %{"id" => id}) do
session = Accounts.get_session!(id)
{:ok, _session} = Accounts.delete_session(session)
conn
|> put_flash(:info, "Session deleted successfully.")
|> redirect(to: Routes.session_path(conn, :new))
end
end
The generated form will have an input field for the token, which is of course not what we want. Lets add virtual fields for name and password to the session schema, and validate the user password. We also want to be ambiguous with the error message if there is no user in the attributes.
defmodule YourApp.Accounts.Session do
# ...
@doc false
def changeset(session, %{user: %Accounts.User{}} = attrs) do
user_id = attrs.user.id
attrs = Map.put_new(attrs, :user_id, user_id)
session
|> cast(attrs, [:user_id, :name, :password])
|> validate_required([:user_id, :name, :password])
|> validate_user_password(attrs)
|> put_token()
end
def changeset(session, attrs) do
session
|> cast(attrs, [:user_id, :name, :password])
|> add_error(:password, "User name-password combination not found.")
end
@doc false
defp validate_user_password(changeset, attrs) do
cond do
!changeset.valid? ->
changeset
Accounts.validate_password(attrs.user, attrs.password) ->
changeset
true ->
add_error(changeset, :password, "User name-password combination not found.")
end
end
# ...
end
Since there is a lot going on in this changeset, we should probably write a test for it
mkdir test/lunaful/accounts && touch test/lunaful/accounts/session_changeset_test.exs
defmodule YourApp.Accounts.SessionChangesetTest do
use ExUnit.Case, async: true
alias YourApp.{Accounts, Encryption}
describe "Accounts.Session.changeset/2" do
test "returns an invalid changeset, if the password does not match the users password" do
user = %Accounts.User{password_hash: Encryption.hash("123password"), id: 1, name: "User"}
session = %Accounts.Session{}
changeset =
Accounts.Session.changeset(session, %{
user: user,
name: user.name,
password: "wrongPassword"
})
refute changeset.valid?
end
test "puts the right error message, if the password does not match the users password" do
user = %Accounts.User{password_hash: Encryption.hash("123password"), id: 1, name: "User"}
session = %Accounts.Session{}
changeset =
Accounts.Session.changeset(session, %{
user: user,
name: user.name,
password: "wrongPassword"
})
assert [password: {"User name-password combination not found.", []}] = changeset.errors
end
test "puts the right error message if no user given" do
session = %Accounts.Session{}
changeset =
Accounts.Session.changeset(session, %{
name: "User not found",
password: "123Password"
})
assert [password: {"User name-password combination not found.", []}] = changeset.errors
end
test "is valid if the password is correct" do
user = %Accounts.User{password_hash: Encryption.hash("123password"), id: 1, name: "User"}
session = %Accounts.Session{}
changeset =
Accounts.Session.changeset(session, %{
user: user,
name: user.name,
password: "123password"
})
assert changeset.valid?
end
test "puts the token if the changeset is valid" do
user = %Accounts.User{password_hash: Encryption.hash("123password"), id: 1, name: "User"}
session = %Accounts.Session{}
changeset =
Accounts.Session.changeset(session, %{
user: user,
name: user.name,
password: "123password"
})
token = Map.get(changeset.changes, :token)
refute is_nil(token)
end
end
end
Change the session form in templates/session/form.html.exs
to use the new fields in the changeset
<%= form_for @changeset, @action, fn f -> %>
<%= if @changeset.action do %>
<div class="alert alert-danger">
<p>Oops, something went wrong! Please check the errors below.</p>
</div>
<% end %>
<%= label f, :name %>
<%= text_input f, :name %>
<%= error_tag f, :name %>
<%= label f, :password %>
<%= text_input f, :password %>
<%= error_tag f, :password %>
<div>
<%= submit "Save" %>
</div>
<% end %>
We can apply a little styling to the page. Lets create a file to hold application wide styling - app.scss
should only hold imports.
touch assets/css/application.scss
.container {
max-width: 1284px;
padding: 4rem 0;
margin-left: auto;
margin-right: auto;
}
We should also use the foundations grid and button class to style our form
<%= form_for @changeset, @action, fn f -> %>
<%= if @changeset.action do %>
<div class="grid-container" data-closable>
<div class="grid-x grid-padding-x align-center">
<div class="callout alert">
<p>Oops, something went wrong! Please check the errors below.</p>
<button class="close-button" aria-label="Dismiss alert" type="button" data-close>
<span aria-hidden="true">×</span>
</button>
</div>
</div>
</div>
<% end %>
<div class="grid-container">
<div class="grid-x grid-padding-x align-center">
<div class="medium-6 cell">
<%= label f, :name %>
<%= text_input f, :name, required: true %>
<%= error_tag f, :name %>
</div>
</div>
<div class="grid-x grid-padding-x align-center">
<div class="medium-6 cell">
<%= label f, :password %>
<%= text_input f, :password, required: true %>
<%= error_tag f, :password %>
</div>
</div>
<div class="grid-x grid-padding-x align-center">
<div class="medium-6 cell">
<%= submit "Submit", class: "button" %>
</div>
</div>
</div>
<% end %>
<h1 class="text-center">New Session</h1>
<%= render "form.html", Map.put(assigns, :action, Routes.session_path(@conn, :create)) %>
and also fix our app.html.eex
just a little bit
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>YourApp · Phoenix Framework</title>
<link rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
</head>
<body>
<header>
<section class="grid-x align-center">
<a href="http://phoenixframework.org/" class="phx-logo">
<img src="<%= Routes.static_path(@conn, "/images/phoenix.png") %>" alt="Phoenix Framework Logo"/>
</a>
</section>
</header>
<main role="main" class="container">
<%= if get_flash(@conn, :info) do %>
<div class="grid-container" data-closable>
<div class="grid-x grid-padding-x align-center">
<div class="callout success">
<p><%= get_flash(@conn, :info) %></p>
<button class="close-button" aria-label="Dismiss alert" type="button" data-close>
<span aria-hidden="true">×</span>
</button>
</div>
</div>
</div>
<% end %>
<%= if get_flash(@conn, :error) do %>
<div class="grid-container" data-closable>
<div class="grid-x grid-padding-x align-center">
<div class="callout alert">
<p><%= get_flash(@conn, :error) %></p>
</div>
<button class="close-button" aria-label="Dismiss alert" type="button" data-close>
<span aria-hidden="true">×</span>
</button>
</div>
</div>
<% end %>
<%= render @view_module, @view_template, assigns %>
</main>
<script type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
</body>
</html>
Your page should now look like this
We are not going to test the get_session/1
function, since that simply passes the schema and the id to the Repo.one/2
function, that one should have tests in Ecto
.
# test/accounts/accounts_test.exs
# ...
describe "sessions" do
test "Accounts.create_session/1 with valid data creates a session"
test "Accounts.create_session/1 with invalid data returns error changeset"
test "Accounts.delete_session/1 deletes the session"
test "Accounts.change_session/1 returns a session changeset"
test "returns %Accounts.Session{} when a session with that token exists"
test "returns nil when no session with that token exists"
end
We need a session factory for these tests
touch test/factories/session_factory.ex
defmodule SessionFactory do
use Factory
alias YourApp.Accounts.Session
def build(:session) do
%{id: user_id} = UserFactory.insert!(:user)
%Session{
token: sequence(&"#{&1}-token"),
user_id: user_id
}
end
end
The first thing we are going to change in the accounts module is adding a get_session/1
function that will either return a session or nil, instead of raising an error if no session found
# ...
@doc """
Gets a single session.
Raises `Ecto.NoResultsError` if the Session does not exist.
## Examples
iex> get_session!(123)
%Session{}
iex> get_session!(456)
** (Ecto.NoResultsError)
"""
def get_session!(id), do: Repo.get!(Session, id)
@doc """
Gets a single session.
Returns nil if the Session does not exist.
## Examples
iex> get_session(123)
%Session{}
iex> get_session(456)
** (Ecto.NoResultsError)
"""
def get_session(id), do: Repo.get(Session, id)
#...
The test implementation for create_session/1
should make sure we get the correct errors for wrong user name - password combinations, other than that, it is a straight forward solution
alias YourApp.Accounts.Session
alias YourApp.{Repo, Encryption}
test "Accounts.create_session/1 with valid data creates a session" do
user =
UserFactory.insert!(:user, %{
password: "123password",
password_hash: Encryption.hash("123password")
})
valid_attrs = %{password: "123password", name: user.name}
assert {:ok, %Session{} = session} = Accounts.create_session(valid_attrs)
refute is_nil(session.token)
assert [user_session] = user |> Ecto.assoc(:sessions) |> Repo.all()
assert user_session.id == session.id
end
test "Accounts.create_session/1 with invalid data returns error changeset" do
user = UserFactory.insert!(:user)
assert {:error, %Ecto.Changeset{}} = Accounts.create_session(%{})
assert {:error, %Ecto.Changeset{errors: [password: _]}} =
Accounts.create_session(%{name: user.name, password: "wrong password"})
assert {:error, %Ecto.Changeset{errors: [password: _]}} =
Accounts.create_session(%{
name: "#{user.name}-does not exist",
password: "123password"
})
end
test "Accounts.delete_session/1 deletes the session" do
session = SessionFactory.insert!(:session)
assert {:ok, %Session{}} = Accounts.delete_session(session)
assert is_nil(Accounts.get_session(session.id))
end
test "change_session/1 returns a session changeset" do
assert %Ecto.Changeset{} = Accounts.change_session(%Session{})
end
test "change_session/2 returns a session changeset" do
assert %Ecto.Changeset{} = Accounts.change_session(%Session{}, %{})
end
describe "Accounts.get_session_by_token/1" do
setup do
%{session: SessionFactory.insert!(:session)}
end
test "returns %Accounts.Session{} when a session with that token exists", %{session: session} do
assert session.id == Map.get(Accounts.get_session_by_token(session.token), :id)
end
test "returns nil when no session with that token exists", %{session: session} do
assert is_nil(Accounts.get_session_by_token("#{session.token} - does not exist"))
end
end
create_session
should use the name to find a user and the changeset checks the password
alias YourApp.Accounts.Session
@doc """
Gets a single session.
Raises `Ecto.NoResultsError` if the Session does not exist.
## Examples
iex> get_session!(123)
%Session{}
iex> get_session!(456)
** (Ecto.NoResultsError)
"""
def get_session!(id), do: Repo.get!(Session, id)
@doc """
Gets a single session.
Returns nil if the Session does not exist.
## Examples
iex> get_session(123)
%Session{}
iex> get_session(456)
** (Ecto.NoResultsError)
"""
def get_session(id), do: Repo.get(Session, id)
@doc """
Creates a session when given the correct `name` `password` combination.
## Examples
iex> create_session(%{name: "User", password: "correctPassword"})
{:ok, %Session{}}
iex> create_session(%{name: "User", password: "WrongPassword"})
{:error, %Ecto.Changeset{}}
iex> create_session(%{name: "wrongUser", password: "correctPassword"})
{:error, %Ecto.Changeset{}}
"""
def create_session(attrs) do
user = attrs |> Map.get(:name) |> get_user_by_name()
attrs = Map.put(attrs, :user, user)
%Session{}
|> change_session(attrs)
|> Repo.insert()
end
@doc """
Deletes a Session.
## Examples
iex> delete_session(session)
{:ok, %Session{}}
iex> delete_session(session)
{:error, %Ecto.Changeset{}}
"""
def delete_session(%Session{} = session) do
Repo.delete(session)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking session changes.
## Examples
iex> change_session(session)
%Ecto.Changeset{source: %Session{}}
"""
def change_session(%Session{} = session, attrs \\ %{}) do
Session.changeset(session, attrs)
end
We haven't added any way for a user to log in or log out yet. Using the basic foundation navigation classes, we can add a navigation bar to our layout app.html.eex
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>YourApp · Phoenix Framework</title>
<link rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
</head>
<body>
<header>
<nav class="top-bar">
<div class="top-bar-right">
<ul class="menu">
<li>
<%= if YourApp.Accounts.logged_in?(@conn) do %>
<%= form_for @conn, Routes.session_path(@conn, :delete), [method: :delete], fn _ -> %>
<%= submit "Logout", class: "button small secondary" %>
<% end %>
<% else %>
<%= link "Login", to: Routes.session_path(@conn, :new), class: "button small secondary" %>
<% end %>
</li>
</ul>
</div>
</nav>
<section class="grid-x align-center">
<a href="http://phoenixframework.org/" class="phx-logo">
<img src="<%= Routes.static_path(@conn, "/images/phoenix.png") %>" alt="Phoenix Framework Logo"/>
</a>
</section>
</header>
<main role="main" class="container">
<%= if get_flash(@conn, :info) do %>
<div class="grid-container" data-closable>
<div class="grid-x grid-padding-x align-center">
<div class="callout success">
<p><%= get_flash(@conn, :info) %></p>
<button class="close-button" aria-label="Dismiss alert" type="button" data-close>
<span aria-hidden="true">×</span>
</button>
</div>
</div>
</div>
<% end %>
<%= if get_flash(@conn, :error) do %>
<div class="grid-container" data-closable>
<div class="grid-x grid-padding-x align-center">
<div class="callout alert">
<p><%= get_flash(@conn, :error) %></p>
</div>
<button class="close-button" aria-label="Dismiss alert" type="button" data-close>
<span aria-hidden="true">×</span>
</button>
</div>
</div>
<% end %>
<%= render @view_module, @view_template, assigns %>
</main>
<script type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
</body>
</html>
This introduces 2 problems we have to deal with before we can actually see anything:
- the session path to delete currently expects an id as a parameter, but we want to read the session from the session cookie
- the
logged_in?
function in the session view
We can remove the session delete from the resources in router.ex
and add our own
scope "/", YourAppWeb do
pipe_through :browser
resources "/sessions", SessionController, only: [:new, :create]
delete "/sessions", SessionController, :delete
get "/dashboard", DashboardController, :index
end
and add the logged_in?
function to accounts.ex
# ...
@doc """
Checks the current session for a session token.
"""
def logged_in?(conn) do
Plug.Conn.get_session(conn, :token) && Map.get(conn.private, :current_user)
end
If we run mix test test/your_app_web/controllers/session_controller.ex
with our current code base, the session controller tests should fail - lets fix that.
defmodule YourAppWeb.SessionControllerTest do
use YourAppWeb.ConnCase
alias YourApp.Encryption
describe "new session" do
test "renders form", %{conn: conn} do
conn = get(conn, Routes.session_path(conn, :new))
assert html_response(conn, 200) =~ "New Session"
end
end
describe "create session" do
test "redirects to dashboard when data is valid", %{conn: conn} do
user =
UserFactory.insert!(:user, %{
password: "123password",
password_hash: Encryption.hash("123password")
})
conn =
post(conn, Routes.session_path(conn, :create),
session: %{password: user.password, name: user.name}
)
assert redirected_to(conn) == Routes.dashboard_path(conn, :index)
end
test "renders errors when data is invalid", %{conn: conn} do
conn =
post(conn, Routes.session_path(conn, :create, %{}),
session: %{name: "does not exist", password: "wrong password"}
)
assert html_response(conn, 200) =~ "New Session"
end
end
describe "delete session" do
setup %{conn: conn} do
user =
UserFactory.insert!(:user, %{
password: "123password",
password_hash: Encryption.hash("123password")
})
conn =
post(conn, Routes.session_path(conn, :create),
session: %{password: user.password, name: user.name}
)
%{conn: conn}
end
test "deletes chosen session", %{conn: conn} do
conn = delete(conn, Routes.session_path(conn, :delete, %{}))
redir_path = redirected_to(conn)
assert redir_path == Routes.session_path(conn, :new, %{})
conn = get(recycle(conn), redir_path)
assert html_response(conn, 200) =~ "Login"
end
end
end
We haven't really done anything in the session controller to actually handle the login and logout yet. Lets put the logic in the account module. For now, the basic logic is really simple but you could - for example - put the current user into the private section.
# lib/your_app/accounts.ex
# ...
@doc """
Puts the necessary information into conn
"""
def login_web_session(%Plug.Conn{} = conn, %Session{} = session),
do: Plug.Conn.put_session(conn, :token, session.token)
@doc """
Resets a conn to a non logged in state
"""
def logout_web_session(%Plug.Conn{} = conn, %Session{} = session),
do: Plug.Conn.delete_session(conn, :token)
With this in place we can implement the session controller
defmodule YourAppWeb.SessionController do
use YourAppWeb, :controller
alias YourApp.Accounts
alias YourApp.Accounts.Session
def new(conn, _params) do
changeset = Accounts.change_session(%Session{})
render(conn, "new.html", changeset: changeset)
end
def create(conn, %{"session" => %{"name" => name, "password" => password}}) do
case Accounts.create_session(%{name: name, password: password}) do
{:ok, session} ->
conn
|> put_flash(:info, "Session created successfully.")
|> Accounts.login_web_session(session)
|> redirect(to: Routes.dashboard_path(conn, :index))
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, "new.html", changeset: changeset)
end
end
def delete(conn, _params) do
session = get_session(conn, :token) |> Accounts.get_session_by_token()
{:ok, _session} = Accounts.delete_session(session)
conn
|> put_flash(:info, "Session deleted successfully.")
|> Accounts.logout_web_session(session)
|> redirect(to: Routes.session_path(conn, :new))
end
end
Creating a session and all that is great, but now we need to be able send people away from pages that should only be available if you are logged in.
If you are not familiar with plugs, you might want to read up on it first
Lets create a plug called BouncerPlug
mkdir lib/your_app/plugs && touch lib/your_app/plugs/bouncer_plug.ex
defmodule YourApp.Plugs.BouncerPlug do
alias YourApp.{Accounts, Repo}
alias YourAppWeb.Router.Helpers, as: Routes
def init(options), do: options
def call(conn, _opts) do
case conn |> Plug.Conn.get_session(:token) |> Accounts.get_session_by_token() do
nil ->
redirect_to_new_session(conn)
session ->
if user = session |> Ecto.assoc(:user) |> Repo.one() do
Plug.Conn.put_private(conn, :current_user, user)
else
redirect_to_new_session(conn)
end
end
end
defp redirect_to_new_session(conn) do
conn
|> Phoenix.Controller.redirect(to: Routes.session_path(conn, :new))
|> Plug.Conn.halt()
end
end
We now have to split up our routes in protected routes you can only access when logged in, or routes that are publically available. The session/new
and session/create
routes have to be public, otherwise you could never log in.
# router.ex
pipeline :protected_browser do
pipe_through :browser
plug YourApp.Plugs.BouncerPlug
end
scope "/", YourAppWeb do
pipe_through :browser
resources "/sessions", SessionController, only: [:new, :create]
end
scope "/admin", YourAppWeb do
pipe_through :protected_browser
delete "/sessions", SessionController, :delete
get "/dashboard", DashboardController, :index
end
After running mix ecto.migrate
, you should now be able to fire up the phoenix server mix phx.server
and go to localhost:4000/session/new
- but you can't log in yet, because there is no user in the database.
Run iex -S mix
and create a user
iex> YourApp.Accounts.create_user(%{name: "myUser", password: "123Password"})
Try out your new user login
There are a lot of things we can do to make this more performant, or make it more versatile.
TODO:
- Speed up Argon2 encryption algorithm for tests
- Delete sessions on logout
- Check sessions in the database to make sure they are active