Our forum
is nearly ready for deployment, but
presently logging in requires a GitHub account. Some users won't have a GitHub
account, and we'd still like them to be able to log in to our system. Let's
provide a basic username/password login mechanism. To do this, we'll use
comeonin
to hash passwords and tie it
into
Ueberauth
via
ueberauth_identity
. Let's
get started.
Project
We're starting with the dailydrip/firestorm repo tagged before this episode .
comeonin
To begin with, we'll bring in
comeonin
so we can hash passwords. We'll use
comeonin
for a
bcrypt
implementation.
vim mix.exs
defmodule FirestormWeb.Mixfile do
# ...
defp deps do
[
# ...
{:comeonin, "~> 3.0.2"},
# We'll also install ueberauth_identity now, for later
{:ueberauth_identity, "~> 0.2.3"},
# ...
]
end
# ...
end
mix deps.get
bcrypt
is designed as a hashing algorithm that's intentionally slow. This
helps as it takes longer for an attacker that's gotten hold of your database to
brute force passwords. However, this is the sort of thing that can make your
tests unacceptably slow. We can configure it to be less secure in test mode, to
speed it up.
vim config/test.exs
# ...
config :comeonin, :bcrypt_log_rounds, 4
config :comeonin, :pbkdf2_rounds, 1
Next, let's add a
password_hash
field to our
forums_users
table to store our
hashed passwords in:
mix ecto.gen.migration add_password_hash_to_forums_users
defmodule FirestormWeb.Repo.Migrations.AddPasswordHashToForumsUsers do
use Ecto.Migration
def change do
alter table(:forums_users) do
add :password_hash, :string
end
end
end
mix ecto.migrate
Next, we'll update our
User
schema to add this field as well as a virtual
field for entering the password when registering initially:
vim lib/firestorm_web/forums/user.ex
defmodule FirestormWeb.Forums.User do
# ...
schema "forums_users" do
# ...
field :password_hash, :string
field :password, :string, virtual: true
# ...
end
end
We'll also add a changeset explicitly meant for modifying a user's password at registration.
vim lib/firestorm_web/forums/forums.ex
defmodule FirestormWeb.Forums do
# ...
def register_user(attrs \\ %{}) do
%User{}
|> user_registration_changeset(attrs)
|> Repo.insert()
end
def user_registration_changeset(%User{} = user, attrs) do
user
|> user_changeset(attrs)
|> cast(attrs, [:password])
|> validate_length(:password, min: 6)
|> put_password_hash()
end
defp put_password_hash(changeset) do
case changeset do
%Ecto.Changeset{valid?: true, changes: %{password: pass}} ->
changeset
|> put_change(:password_hash, Comeonin.Bcrypt.hashpwsalt(pass))
_ ->
changeset
end
end
# We'll also stop requiring emails for users - we'll have to let them update
# them later
defp user_changeset(%User{} = user, attrs) do
user
|> cast(attrs, [:username, :email, :name])
|> validate_required([:username, :name])
|> unique_constraint(:username)
end
# ...
end
This should be enough to hash the password and store it. We'll move on from here
to bring in
ueberauth_identity
, because we're going to handle registration a
little unusually.
ueberauth_identity
We've already installed the package. We need to configure this strategy for ueberauth:
vim config/config.exs
# ...
# Configures Ueberauth for OAuth stuff
config :ueberauth, Ueberauth,
providers: [
github: {Ueberauth.Strategy.Github, [default_scope: ""]},
identity: {Ueberauth.Strategy.Identity, [
callback_methods: ["POST"]
]},
]
# ...
Here we've told ueberauth that for this strategy, the callback should be a
POST
rather than the default
GET
. This way the form that we'll generate in
our "request" phase for this provider will be posted back as the response.
Next, let's create a form to show in the request phase for our identity provider:
vim lib/firestorm_web/web/controllers/auth_controller.ex
defmodule FirestormWeb.Web.AuthController do
# ...
alias Ueberauth.Strategy.Helpers
alias FirestormWeb.Forums.User
def request(conn, %{"provider" => "identity"}) do
# We'll create a registration changeset and use Ueberauth helpers to provide
# a callback url for our form to submit to
changeset = Forums.user_registration_changeset(%User{}, %{})
render(conn, "request.html", callback_url: Helpers.callback_url(conn), changeset: changeset)
end
# ...
end
We need to make the
AuthView
and the corresponding templates directory:
mkdir lib/firestorm_web/web/templates/auth
vim lib/firestorm_web/web/views/auth_view.ex
defmodule FirestormWeb.Web.AuthView do
use FirestormWeb.Web, :view
end
Next, we'll generate a form to be used for this authentication mechanism.
vim lib/firestorm_web/web/templates/auth/request.html.eex
<h2>Log In</h2>
<%= form_for @changeset, @callback_url, [class: "pure-form"], fn f -> %>
<div>
<label>
Username: <%= text_input f, :username %>
</label>
<%= error_tag f, :username %>
</div>
<div>
<label>
Password: <%= password_input f, :password %>
</label>
<%= error_tag f, :password %>
</div>
<%= submit "Log in or register", class: "pure-button button-primary" %>
<% end %>
If a user already exists in our system, we'll confirm the password. Otherwise, we'll create the user and log them in. Let's add a link to the side drawer to let us log in with either github or via the identity provider:
vim lib/firestorm_web/web/templates/layout/_drawer.html.eex
<ul>
<%= if current_user(@conn) do %>
<li><%= link "Log Out #{current_user(@conn).username}", to: auth_path(@conn, :delete) %></li>
<% else %>
<li><%= link "Log In (github)", to: auth_path(@conn, :request, :github) %></li>
<li><%= link "Log In (username)", to: auth_path(@conn, :request, :identity) %></li>
<% end %>
</ul>
Now if we go to the browser and click the username login link, we should see our
form. If we fill it out with a new username and submit it, we get an error
because we haven't handled the callback in our
AuthController
for the
identity
provider. Let's do that:
vim lib/firestorm_web/web/controllers/auth_controller.ex
defmodule FirestormWeb.Web.AuthController do
# ...
def callback(%{assigns: %{ueberauth_auth: auth}} = conn, params) do
case auth.provider do
:github ->
with %{name: name, nickname: nickname, email: email} <- auth.info do
case Forums.login_or_register_from_github(%{name: name, nickname: nickname, email: email}) do
{:ok, user} ->
conn
|> ok_login(user)
{:error, reason} ->
conn
|> put_flash(:error, reason)
|> redirect(to: "/")
end
end
:identity ->
case Forums.login_or_register_from_identity(%{username: params["user"]["username"], password: params["user"]["password"]}) do
{:ok, user} ->
conn
|> ok_login(user)
{:error, %Ecto.Changeset{} = changeset} ->
conn
|> put_flash(:error, "Login unsuccessful")
|> render("request.html", callback_url: Helpers.callback_url(conn), changeset: changeset)
{:error, reason} ->
changeset = Forums.user_registration_changeset(%User{}, %{})
conn
|> put_flash(:error, reason)
|> render("request.html", callback_url: Helpers.callback_url(conn), changeset: changeset)
end
end
end
defp ok_login(conn, user) do
conn
|> put_flash(:info, "Successfully authenticated.")
|> put_session(:current_user, user.id)
|> redirect(to: "/")
end
end
Now we just need to implement
Forums.login_or_register_from_identity
:
vim lib/firestorm_web/forums/forums.ex
defmodule FirestormWeb.Forums do
# ...
def login_or_register_from_identity(%{username: username, password: password}) do
import Comeonin.Bcrypt, only: [checkpw: 2, dummy_checkpw: 0]
case get_user_by_username(username) do
nil ->
# No user, let's register one!
register_user(%{username: username, name: username, password: password})
user ->
# We'll check the password with checkpw against the user's stored
# password hash
case checkpw(password, user.password_hash) do
true ->
# Everything checks out, success
{:ok, user}
_ ->
# User existed, we checked the password, but no dice
{:error, "No user found with that username or password"}
end
end
end
# ...
end
We can try it out in the browser, and everything should work!
Summary
In today's episode, we introduced
comeonin
and
ueberauth_identity
. Now, we
can log in with a username and password, and if no user exists with that
username we'll create one automatically. This might not be what we want to do,
but it's easy enough to change. For now, it works. I hope you enjoyed it. See
you soon!
Comments for User Authentication with comeonin and ueberauth_identity