How to build multiple web apps with Elixir thanks to umbrella — part 3.2: Set up an admin website with users

A (wannabe) guide to learn how to create web apps with Elixir 1.9, Phoenix 1.4, Docker (for development), Git (including submodules) to version each app, PostgresQL, Redis, API calls and deployment.

Cédric Paumard
11 min readSep 1, 2019

--

This guide is addressed to developers who have a few projects behind them and are willing to give a try to Elixir. Here is how to set up users for your back-office website.

For this guide, it is advised to know some CSS and Javascript, and possibly Bootstrap. SQL is a plus as I will not explain the commands, but nothing is mandatory.

Summary

This is the second part of the third chapter. In the last part, we saw how to set up tests, Bootstarp, but also any entity. Unfortunately, the article being already long, we will not do more tests for now. I might create a bonus article for testing (and another for the admin front), but for now, let’s focus on finishing the user entity.

Add libraries for data encryption (Pbkdf2) and login (guardian)

Note: The purpose here is to teach you how to add dependencies and how to configure them. This part will be done in the Database app, even if it could have been done in a specific SSO app. The last possibility was adding complexity I wanted to avoid, as there is already a lot of things to learn. But you should be able to do it in your next project! :)

First, let’s upgrade the mix.ex of our Database app by including two deps:

defp deps do
[
...
{:pbkdf2_elixir, "~> 1.0"},
{:guardian, "~> 1.0"}
]

Because we added some new dependencies, we need to get then compile the dependencies:

$> docker-compose exec elixir mix deps.get
$> docker-compose exec elixir mix deps.compile

Pbkdf2 is a password hashing library. It’ll handle the hashing of any password, and the comparison of hashes when a user try to log into our application. We could have used Bcrypt or Argon2, but both of them need Makefile. It would not be generally a problem, but for those of you who use Windows and doing elixir without docker, it can make the compilation of the dependencies fails.

Note: all three hashing libraries are excellent. There is no bad choice. But if you do not know which one to pick and can use Makefile, take Argon2 as it is the official winner of the Password Hashing Competition from 2013 to 2015.
To do so, just replace the pbkdf2 line by either {:argon2_elixir, "~> 2.0"} (or {:bcrypt_elixir, "~> 2.0"} for Bcrypt)

Guardian is a token based authentication library for use with Elixir applications. This will allow us to let user access to the website without the need of entering their password each time.

Note: you can find in the documentation how to setup guardian, which means you may not need this article. However, be aware that the documentation is written for a simple app, not an umbrella with separation of concern per app. I should encourage you to try doing it by yourself, but only if you have time and want to.

Guardian requires some configuration to work. You do not even have to look at the documentation, because it is explained in the Readme! But because we work in an umbrella app, we need to place the files in specific folders. To do so, let’s create a folder named common in apps/database/lib/database. Once created, let’s add our first file in it: guardian.ex, which will contain the following code:

Note: We have named this module “AdminGuardian”, but we have put it in “guardian.ex”. There are two purpose here:
1- As suggested in a part of the documentation, you may need multiple configuration. Because a module takes only un configuration, we will need to set-up multiple modules.
2- Du to the small number of line (~20), there is no need to have one file per module. Just add each Guardian module in this file, in a reasonable way (like, do not put 50 guardian modules in it)

Next thing is to edit the configuration file. Open apps/database/config/config.exs and add, before import_config “#{Mix.env()}.exs", the following lines:

config :admin, Database.Common.AdminGuardian,
issuer: "database",
secret_key: System.get_env("ADMIN_GUARDIAN_KEY")

As you can see, we added another System.get_env with a new env variable. I will not tell you again how to edit .env, .env.dist and docker-compose.yml. Just do not forget to add the variable in them. Also, you may want to generate the key with a command coming with Guardian: docker-compose exec elixir mix guardian.gen.secret. I shall advise you to put it in the .env.dist.

Restart the elixir container, so the configuration files can be used, and you should be good to continue.

Limit the administration website to logged user

Because it is an administration, we do not want it to be accessible. There is some things which should be done on the server, such as an IP white list, but, for now, we will simply put a login form to access any other pages.

Change router.ex in Admin

To do so, we need to apply four changes in router.ex. Some are explained in the documentation. As a reminder, the path is: apps/admin/lib/admin_web

  • Add two pipelines, which will be created and explained just after
pipeline :auth do
plug Database.Common.Pipelines.AdminAuth
end
pipeline :ensure_auth do
plug Guardian.Plug.EnsureAuthenticated
end
  • change the scope "/", AdminWeb do to:
scope "/", AdminWeb do
pipe_through [:browser, :auth]
get "/login", AuthController, :login_page
post "/login", AuthController, :login
end
  • add another scope "/", AdminWeb. Every URL under this scope will only be accessible if you are logged in, thanks to the pipe_through
scope "", AdminWeb do
pipe_through [:browser, :auth, :ensure_auth]
get "/", PageController, :index
post "/logout", AuthController, :logout
end
  • Finally, update the pipe_through line of the scope "/admin_user" so it become inaccessible from any unauthenticated user:
scope "/admin_user/", AdminWeb do
pipe_through [:browser, :auth, :ensure_auth]
resources "/", AdminUserController
end

You may have observed a mention to two modules we have not created yet: Database.Common.Pipelines.AdminAuth and AuthController.

Let’s handle the Pipelines first

Pipelines (not the operator) in Phoenix are, as we saw in the last article, code which run before and/or after a controller call. The purpose of the Auth pipeline is to check is there is any data which will allow to connect the user with a single request. Let’s create it in a new folder: database/lib/database/common/pipelines/auth.ex

There is three things to note here:

  • The module’s name contain Admin, which means we will be able to add multiple auth modules, like the Guardian module.
  • There is two module mentioned in the use Guardian. One we already created ( AdminGuardian ) and one we need to create ( Database.Common.errorHandler).
  • Finally the two last commented lines were added. As you can see there non existence in the documentation. We will see there use later.

For now, let’s focus on Database.Common.ErrorHandler, as we need to create it. To do so, go to database/lib/database/common and create a file name error_handler.ex which will contain the following code:

Note: @beaviour and @impl are here to create a warning during compilation if auth_error does not match the method in Guardian.Plug.ErrorHandler. You can find more information about it in the Typespecs and behaviours page of the Elixir website.

There is one last thing to do: encrypt the password. To do so, we will create a specific module, so we will not need to have multiple code doing the same thing, in case we create different type of users.

To do so, let’s create a file in database/lib/database/common called password_handler.ex. this file will contains the following code:

Once the file is created, we just have to add PasswordHandler to our Database.AdminUsers.AdminUser and we will be ready to handle the web part, beginning with the controller. To add it, open database/lib/database/admin_users/admin_user.ex and add the two following lines:

# after import Ecto.Changeset
alias Database.Common.PasswordHandler
# after |> validate_required([:email, :username, :password, :accreditation])|> PasswordHandler.put_password_hash()

And we are done with the pipeline. Let’s focus on web part with the other file from router.ex: the controller.

Create the controller and the form

Back to the router! As you saw in the router, we mentioned another file, which is a controller. But we need to create it, and fill it. To do so, go to the controllers folder of admin/lib/admin_web, and create a file named auth_controller.ex. It will contain:

It is a simple controller, with a code speaking for itself, so I will not detail it. But, there are few things worth explaining. The alias, for example, allow us to fetch multiple modules within the same application. The "@spec" is another directive allowing us to type the functions. You can learn more here.

And, as usual, there is one file we have not created: the login.html, which will contain the html for our login. So, as usual, let’s create it.

To do so, add a new folder in admin/lib/admin_web/templates called auth. It is important to follow this rule because use AdminWeb, :controller will tell our controller to look for a folder in templates and view which shares the same name as our controller. Once the folder is created, we just need to add the file login.html.eex, which just need the few lines (feel free to add any amount of Bootstrap to it!)

<%= form_for @changeset, Routes.auth_path(@conn, :login), fn f -> %>
<label>
Email: <%= email_input f, :email %>
</label>
<label>
Password: <%= password_input f, :password %>
</label>
<%= submit "Submit" %>
<% end %>

If you try now, you should get an error. Unfortunately, use AdminWeb, :controller force us to create another file, this time in apps/admin/lib/admin_web/view called auth_view.ex.

Each controller needs a view named after it, containing at least just the three next lines:

defmodule AdminWeb.AuthView do
use AdminWeb, :view
end

And that’s it! You should now be able to check http://localhost:4001/login and see the form, or any other page as “unauthenticated”.

Seed your first administrator

Note: the purpose of this part is to teach you how to create a seed.

Unfortunately, we cannot log in. We did not create any user (nor any ways to register a user without being logged in). So, we need to have an account already created to register new users. Thanks to a phoenix guide, there is a simple way to seed data, and this without using any phoenix tools commands!

To do so, let’s create a file called seed_admin_user.exs in apps/database/priv/repo/seeds. This file will contain few lines:

alias Database.Repo
alias Database.AdminUsers.AdminUser
Repo.insert(%AdminUser{
id: 1,
username: "FirstAdmin",
email: "admin@dev.com",
password: Pbkdf2.hash_pwd_salt(System.get_env("ADMIN_PWD")),
accreditation: "super_admin"
})

As you can see, it as no Module. The reason is because it is a script file with the only purpose to call the function insert of the Repo module.

By security, we put the password in an environment variable, as, in this way, it will not shared with any people working on the project nor will it be with the server. We will just need to add another variable to our environment. You should know how to do it by now. Just, do not forget to stop and restart your elixir container.

Let pursue this guide and put the seed in our database:

$> docker-compose exec elixir mix run apps/database/priv/repo/seeds/seed_admin_user.exs

note: in the case you want to check if the user is created and the password hashed, you can do the following set of commands:

$> docker-compose exec postgres psql user role
PsqlPrompt> \c test_dev
PsqlPrompt> SELECT * FROM admin_users;

Since we have created our first user, let’s try to log in. Go to http://localhost:4001/login and enter the email and the password of your first user, and voilà! You should be in! But you cannot get out, because we forgot the logout. No worries, it is just 3 lines, so it should be easy. You can put them where you want (though it should be accessible only if you are connected):

<%= form_for @conn, Routes.auth_path(@conn, :logout), fn f -> %>
<%= submit "Logout" %>
<% end %>

Redirect to the login page if not logged

Note: this part is more of a bonus, allowing you to redirect unlogged user to /login from existing pages. Its purpose to give you a base to create plugs more than it is to really bring something to this application, as you’ll probably want to redirect from any url (and not just from the one in the route). I may update it later, as I work on this part for myself, but for now it is what I have to give.

Earlier in this guide, we created the AdminAuth module, (in database/lib/database/common/pipelines/auth.ex ). Open it and uncomment the last line, which call a plug named RedirectToLogin. As it does not exist, we need to create it.

To do so, let’s place it in the folder database/lib/database/common/plugs (where all our future shared plugs should be) and create a file named redirect_to_login.ex.

Unlike Guardian, I did not found any guide to create this, so I just had to do it on my own. Because there is no guides, allow me to create this file step by step, with explanation each time:

defmodule Database.Common.Plugs.RedirectToLogin do
import Plug.Conn
import Phoenix.Controller
def init(opts), do: opts

So far nothing far fetched. We need to import Plug.Conn to have the Conn struct. The Phoenix.Controller will allow us to use the redirect/2 function. Finally, init/1 is one of the two required function to have a working plug.

def call(%Plug.Conn{host: host, port: port, request_path: path, private: private} = conn, _opts) do
paths = Application.get_env(:database, :paths)
ports = Application.get_env(:database, :ports)
hosts = Application.get_env(:database, :hosts)

call/2 is the second function required to have a plug. Here, we use the pattern matching to get host, port and request_path variables from conn. We also get paths, ports and hosts from our configuration files, which we will see after this file.

This two variables groups will allow us to compare the request to authorized behavior to know if we have to redirect or not. For this, we use a with such has:

with true <- Enum.member?(ports, port),
true <- Enum.member?(hosts, host),
false <- Map.has_key?(private, :guardian_default_resource),
false <- Enum.member?(paths, path)
do
conn
|> redirect(external: "http://#{host}:#{port}/login")
|> halt
else
_ -> conn
end
end
end

First of all:

  • We need to validate if the port and the host are authorized to be checked. This is optional, but it allows you to add some behavior to this plug, such as using it in other applications or to not redirect if they are not in the port or host list.
  • Then, we need to check if there is a key left by Guardian. If there is one, this means the request is made by a logged user. The key here is :guardian_default_resource (there are others, but it does the trick).
  • Finally, if there is no key, we need to check if the path is not the same as those in the list of paths set in the configuration. For example, if the path is /login, you should not be redirected to it. It would create a redirection loop, creating problems.

And that’s it for the plug! We just need to add three lines in the configuration file, and we will be set. Open database/config/config.exs and, under config :database, add the three following lines:

paths: ["/login"], # Not redirect
ports: [4001], # redirect
hosts: ["localhost"] # redirect

You will be able to edit this whenever you want, to add hosts, ports or paths to extend the reach and impact of your plug without modifying the code.

That’s all for today! I hope this third tutorial pleased you and helped you to set up an administration website. I also hope it gave you a base to create user on other applications, but also plugs, pipeline, seeds and how to modify an entity.

You might want to check the first bonus guide about using Bootstrap to improve the admin website appearance, or the second bonus guide, which is about testing with an authenticated connection.

Next guide, we will put it online. I’m still not sure if I want to create a tutorial on AWS or GCP. Maybe both? Well I will see. I used distillery before, but since Elixir 1.9 comes with deployment, I’ll take some time to find the best way to deplay. In fact, I may even skip this one if there is a really good guide of how to do it with umbrella app (but I do not think so, because there is a lot of configuration and environment variable to use). We will see.

In the mean time, I may write bonus guides, such as styling the administration website with bootstrap or implementing the log in in the test.

Until next time, happy coding!

--

--

Cédric Paumard

Elixir developer, bass player and probably too curious.