πŸ˜€Users & Authentication

Users

The user.ex schema looks like this:

user.ex
defmodule PetalPro.Accounts.User do
  ...
  
  schema "users" do
    field :name, :string
    field :email, :string
    field :password, :string, virtual: true, redact: true
    field :hashed_password, :string, redact: true
    field :confirmed_at, :naive_datetime
    field :is_admin, :boolean, default: false
    field :avatar, :string
    field :last_signed_in_ip, :string
    field :last_signed_in_datetime, :utc_datetime
    field :is_subscribed_to_marketing_notifications, :boolean, default: true
    field :is_suspended, :boolean, default: false
    field :is_deleted, :boolean, default: false
    field :is_onboarded, :boolean, default: false

    field :current_org, :map, virtual: true

    many_to_many :orgs, Org, join_through: "orgs_memberships", unique: true

    timestamps()
  end
  
  ...
end

Users have some extra fields not included by phx.gen.auth:

FieldTypeDescription

name

:string

A users full name

avatar

:string

A URL to the users avatar image

last_signed_in_ip

:string

The IP address of the last login by this user.

is_subscribed_to_marketing_notifications

:boolean

Track whether a user wants to receive marketing emails or not.

is_admin

:boolean

Admins get access to a special dashboard where they can modify users, see logs, etc.

is_suspended

:boolean

An admin can suspend a user, preventing them from logging in.

is_deleted

:boolean

Allows for soft deletion of users

is_onboarded

:boolean

Track whether or not a user has seen an onboarding screen after registering.

Authentication

We used phx.gen.auth (email/password) and modified the templates to use Tailwind and Petal Components.

Setting and accessing the current user

Controller actions

For controller actions we use the plug provided by mix phx.gen.auth to set conn.assigns.current_user .

user_auth.ex
defmodule PetalProWeb.UserAuth do
  ...
  
  def fetch_current_user(conn, _opts) do
    {user_token, conn} = ensure_user_token(conn)
    user = user_token && Accounts.get_user_by_session_token(user_token)
    assign(conn, :current_user, user)
  end
  
  ...
end

You can see the :fetch_current_user plug used in the :browser pipeline in the router.

router.ex
defmodule PetalProWeb.Router do
  ...

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, {PetalProWeb.LayoutView, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
    plug :fetch_current_user
    plug PetalProWeb.SetLocalePlug, gettext: PetalProWeb.Gettext
  end
  
  ...
end

If you want to enforce the user then you can use the :require_authenticated_user plug.

router.ex
scope "/", PetalProWeb do
  pipe_through [:browser, :require_authenticated_user]
  
  # Routes that require a logged in user go here
end

Live views

We can't rely on our plugs in live views, since live views connect over web sockets and avoid the traditional request/response lifecycle. However, a live view will have access to the session, which contains the user token set upon login. Hence, in the live view mount we can use the token to find the user_token set in our database for that users session, and from there obtain the logged in user.

Instead of doing this on every live view mount function, we can extract this out into an on_mount function and then apply it in the router, like a mini pipeline.

user_on_mount_hooks.ex
defmodule PetalProWeb.UserOnMountHooks do
  ...

  def on_mount(:require_authenticated_user, _params, session, socket) do
    socket = maybe_assign_user(socket, session)

    if socket.assigns.current_user do
      {:cont, socket}
    else
      {:halt, redirect(socket, to: Routes.user_session_path(socket, :new))}
    end
  end
  
  defp maybe_assign_user(socket, session) do
    assign_new(socket, :current_user, fn ->
      get_user(session["user_token"])
    end)
  end
  
  ...
end
router.ex
defmodule PetalProWeb.Router do
  ...
  
  scope "/", PetalProWeb do
    pipe_through [:browser, :require_authenticated_user]

    live_session :protected, on_mount: {PetalProWeb.UserOnMountHooks, :require_authenticated_user} do
      # Live routes that require a logged in user go here
    end
  end
  
  ...
end

Social login providers

Petal Pro uses Ueberauth to handle social providers. Ueberauth simplifies the oauth process and have a large number of providers supported. We have set up a couple of providers to get you started:

  • Google

  • Github

How does it work?

A user is redirected to a provider. The URL for this redirect is generated by the Ueberauth library:

Routes.user_ueberauth_path(@conn, :request, "google")
Routes.user_ueberauth_path(@conn, :request, "github")

When you setup either "Google" or "Github", buttons will appear in the sign in and register pages that go to those URL's above.

Once a user has successfully signed in to the provider, they are redirected back to your web application. You can see where they end up by looking in the router:

scope "/", PetalProWeb do
  pipe_through [:browser, :redirect_if_user_is_authenticated]
  get "/auth/:provider", UserUeberauthController, :request
  get "/auth/:provider/callback", UserUeberauthController, :callback
end

In the file user_ueberauth_controller.ex there are callback functions - one for each provider. In these callback functions we take the user info provided by the provider and use that to sign in a user (registering them if they aren't already):

user_ueberauth_controller.ex
  def callback(%{assigns: %{ueberauth_auth: %{info: user_info}}} = conn, %{"provider" => "google"}) do
    user_params = %{
      email: user_info.email,
      name: combine_first_and_last_name(user_info),
      password: random_password(),
      avatar: user_info.image
    }

    case Accounts.fetch_or_create_user(user_params) do
      {:ok, user} ->
        UserAuth.log_in_user(conn, user)

      {:error, _} ->
        conn
        |> put_flash(:error, "Authentication failed")
        |> redirect(to: "/")
    end
  end

How does this fit in with the existing `user` schema?

The user table doesn't change. Everything revolves around a user's email address. A user might register with a password and some time later login via Google. As long as the email used for Google sign in is the same as the registered email, then the login will work. The same goes for Github and Twitter.

If a user doesn't exist and they login via a provider, then they will be registered under the email address given by the provider. Since the user table has a not-null constraint on the hashed_password column, a password will be randomly generated. This way we don't modify the original table structure of the mix phx.gen.auth generator.

If a user logs in with a provider and then wants to change their (randomly generated) password they will have to click "Forgot my password".

What if the user forgets what social login they used?

Under this implementation there is nothing you can do apart from tell the user to try each provider and see which works. You could add a new field to the user schema (user.provider) if you like.

Setup Google sign in

  1. Create Oauth 2.0 Client ID (you’ll likely have to create an OAuth consent screen before you can create the OAuth Client ID)

    • The user type you’ll require will be External

    • Go to "Create credentials" -> "OAuth client ID" and fill in the details - make sure of the following:

      • Authorized JavaScript origins - http://localhost:4000

      • Authorized redirect URIs - http://localhost:4000/auth/google/callback

  2. Add your client ID + client secret to your .env file:

    1. export GOOGLE_OAUTH_CLIENT_ID=""

    2. export GOOGLE_OAUTH_SECRET=""

  3. Stop your server and make sure you enter direnv allow

  4. Start your server again

Setup Github sign in

  1. Create an OAuth App - it can be either under your personal account or an organization:

    1. Personal account:

      1. Click your avatar -> Settings

      2. Developer settings (down the bottom)

      3. Oauth Apps

      4. Create

    2. Organization account:

      1. Go to your organization's profile

      2. Settings tab

      3. Developer settings (down the bottom)

      4. OAuth apps

      5. Create

    3. Fill in the details - the only thing that matters is "Authorization callback URL", which should be http://localhost:4000/auth/github

    4. Generate a new client secret

    5. Paste client ID and secret into your .env file:

      1. export GITHUB_OAUTH_CLIENT_ID=""

      2. export GITHUB_OAUTH_SECRET=""

Adding more providers

See a list of Ueberauth strategies here. You can copy how we've done it for the previous 3 providers. Check out the file user_ueberauth_controller.ex for how to deal with the callbacks. Your main job is taking the data given by the provider and using it to register a user.

Passwordless

If enabled, users can both register and login without a password (it's enabled by default).

Passwordless sign in was added in v1.2.0. You can toggle it on and off in config.exs:

config :petal_pro, :passwordless_enabled, true

When it's enabled, a "Continue with passwordless" button will show up on the sign-in and register pages:

How passwordless works

Passwordless allows users to either register or log in with their email address. They get sent a code and use the code as a temporary password. The code expires in a short timeframe and gets deleted if the user enters the wrong code for more than a couple of attempts.

You can think of passwordless as another social login. However, instead of a company like Github verifying the user's credentials, the user's email service verifies them instead. The main difference is that you don't get other information like a user's name and avatar with passwordless - only the email address.

This means users can register and access your web app with only an email. For Petal Pro this is okay, as during onboarding we ask the user to add their name. We also generate a random password for them (every user requires a password as a database rule set by phx.gen.auth). A user therefore can click "Reset password" if they like and choose to use password-based login in the future.

Passwordless process:

  1. User submits email

  2. Find or create a user using the email (new users are given a random password, which they can reset at any time), and set the user to assigns.auth_user

  3. A user_token is generated with the encrypted pin code in it

  4. Push patch to /passwordless/sign-in-code/:hashed_user_id (user id is obfuscated in a hashed format using the HashId lib)

  5. The user enters the code sent to their email (gets a limited number of attempts before the user_token is deleted

  6. If correct - generates a sign-in token for the user - however live views can't insert cookies, so we need to make a trip to the server

  7. Place the sign-in token in a hidden field in a form (encoded it as base64)

  8. Submit the form using phx-trigger-action

  9. This POSTs it to PetalProWeb.UserSessionController.create_from_token/2

  10. create_from_token/2 will use the token to log the user in (set the appropriate cookie)

Two-factor authentication (2FA) with TOTP

2FA boosts the security of your web application by allowing users to opt into a two-factored sign-in procedure (their ordinary sign-in plus entering a one-time password).

Users can go to their settings and enable 2FA by syncing their account to a tool that implements the Time-based One-time Password (TOTP) algorithm (the main one being Google Authenticator).

Once set up the user will also be provided with some backup codes (in case they lose their phone).

Now, when signing in a user will be forced to enter a TOTP from their authenticator app:

How is TOTP implemented?

The schema looks like this:

schema "users_totps" do
  field :secret, :binary
  field :code, :string, virtual: true
  belongs_to :user, PetalPro.Accounts.User

  embeds_many :backup_codes, BackupCode, on_replace: :delete do
    field :code, :string
    field :used_at, :utc_datetime_usec
  end

  timestamps()
end

The key field is the secret. This is what is passed to the authenticator app and allows the web application to work out which codes are correct.

How to turn off 2FA

The simplest way is to remove access to the 2FA settings. This will prevent users from setting it up. In future it will also be easy enough to turn it back on if you want.

  1. Remove the routes:

2. Remove the menu item in the user settings layout: