wolfiton

wolfiton

Authentication and authorization demystified by example and experience

Hello everyone,

Today I would like to ask for your help on authorization and authentication

So at the moment i have the following migration

defmodule PhxApi.Repo.Migrations.CreateUsers do
  use Ecto.Migration

  def change do
    create table(:users) do
      add :username, :string
      add :email, :string
      add :password_hash, :text
      add :role, :string
      add :confirmed, :boolean
      add :attempts, :integer
      add :locked, :boolean

      timestamps()
    end


    create unique_index(:users, [:email])
    create unique_index(:users, [:username])
  end
end

The following migration is used to take into account the following scenarios:

  • User
    • registration
    • login
    • account confirmation
    • user block by username and email

What would you add more to this migration and in general to take into account other security scenarios?

Also how you deal with users ip where would you store them?

Would you use ETS or mnesia for account confirmation?

Also other suggestions or you person experiences are welcomed
Thanks in advance

First Post!

LostKobrakai

LostKobrakai

I’d take a look at Pow or phauxth and how they handle things.

Why would you want to store IPs in the database?

I’m wondering why :attempts is a string. Sounds like it should be an integer.
Also while you have a confirmed field I’d additionally propose to separate email from unconfirmed_email, so on a change you don’t loose the already confirmed email until the new one is confirmed as well.

Most Liked

OvermindDL1

OvermindDL1

For now, storing IPs in sessions is irritating, when on my phone I change my IP a lot, even mid transactions.

Cachex for Elixir is even faster. Redis is useless unless multiple languages are using it.

amnu3387

amnu3387

Usually I start from this:

defmodule Repo.Migrations.CreateUsersTable do
  use Ecto.Migration

  def change do
    create table(:users) do
      add :email,              :string, null: false
      add :temp_email,         :string # for changing emails until confirmed
      
      add :password_hash,      :string, null: false
      add :temp_password,      :string # for changing passwords until confirmed
      
      add :name,               :string # or divided into first, last, etc
      add :profile_image,      :string

      add :verification_token, :string # for the signup and email verification - this can easily be other table just for the verifications
      add :verified,           :boolean, default: false
      add :locked,             :boolean, default: false

      #add :last_login,         :utc_datetime
      #add :logins,             :map, default: %{}, or {:array, :map}, default []

      timestamps(type: :utc_datetime)
    end

    create unique_index(:users, ["(lower(email))"], name: "users_email_index") #lower case in order to prevent different cased emails, same name as if it was the regular index on a field
  end
end

Then I add other relevant bits depending on what is needed.

The schema itself has at least two additional two additional virtual fields if using password based accounts:

defmodule User do
  use Ecto.Schema

  schema "users" do
    field :email,                 DowncasedString # this would probably be more resilient if done at the db level but if it's only you working with the db through the elixir app and changesets it's enough
    field :temp_email,            DowncasedString
    
    field :password_hash,         :string
    field :temp_password,         :string
    
    field :password,              :string, virtual: true
    field :password_confirmation, :string, virtual: true

    field :verification_token,    :string

    field :name,                  :string
    field :profile_image,         :string

    field :verified,              :boolean
    field :locked,                :boolean


    timestamps(type: :utc_datetime)
  end
end

password and password_confirmation as virtual fields for easier handling on changesets and so that the forms/payload can simply have those two fields in them.

For managing sessions I’ve played with mnesia and this works for having multiple nodes connected and sharing a store of valid & invalid tokens - not sure if it scales though, or if it’s the best approach, but wanted to do some testing with it. This is for a game, usually I wouldn’t implement it for more normal web apps.

defmodule SessionsManager do
  use GenServer
  require Logger

  alias Core.Runtime

  @cleanup_interval 120_000

  def start_link(_) do
    GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
  end

  def init(_) do
    Process.send_after(self(), :cleanup, @cleanup_interval)
    {:ok, %{}}
  end

  def add_sibling_tokens(login_tuple) do
    GenServer.cast(__MODULE__, {:add_sibling_tokens, login_tuple})
  end

  def add_invalid_tokens(token, socket_token) do
    GenServer.cast(__MODULE__, {:add_invalid_tokens, token, socket_token})
  end

  def handle_info(:cleanup, state) do
    Task.start(&clean_up/0)
    Process.send_after(self(), :cleanup, @cleanup_interval)
    {:noreply, state}
  end


  def handle_cast({:add_invalid_tokens, token, socket_token}, state) do
    invalidate_tokens(token, socket_token)
    {:noreply, state}
  end

  def handle_cast({:add_sibling_tokens, {%{id: id}, token, socket_token}}, state) do
    fun = fn() ->
      now = :erlang.system_time(:second)
      :mnesia.write({:sessions_siblings, id, {token, socket_token}, now})
      :mnesia.write({:sessions_siblings, token, id, now})
      :mnesia.write({:sessions_siblings, socket_token, id, now})
    end

    case :mnesia.transaction(fun) do
      {:atomic, _} -> :ok
      {:aborted, reason} ->
        Logger.error("SessionsManager Error adding sibling tokens: #{inspect reason}")
    end
    {:noreply, state}
  end

  @spec invalidate_tokens(String.t(), String.t()) :: boolean()
  def invalidate_tokens(token, socket_token) do
    fun = fn() ->
      now = :erlang.system_time(:second)
      :mnesia.write({:sessions_invalid, token, now})
      :mnesia.write({:sessions_invalid, socket_token, now})
      case :mnesia.wread({:sessions_siblings, token}) do
        [] -> false
        [{_, id, _, _}] ->
          :mnesia.delete({:sessions_siblings, id})
          :mnesia.delete({:sessions_siblings, token})
          :mnesia.delete({:sessions_siblings, socket_token})
      end
    end

    case :mnesia.transaction(fun) do
      {:atomic, _} -> :ok
      {:aborted, reason} ->
        Logger.error("SessionsManager Error invalidating tokens: #{inspect reason}")
    end
  end

  def clean_up do
    case GenServer.whereis(SessionsManager) do
      nil -> :ok

      pid ->
        fun = fn() ->
          case :mnesia.select(:sessions_invalid, match_spec(:invalid)) do
            [] -> :ok
            
            tokens ->
                Enum.each(:lists.flatten(tokens), fn(token) ->
                  :mnesia.delete({:sessions_invalid, token})
                end)
          end
          case :mnesia.select(:sessions_siblings, match_spec(:siblings)) do
            [] -> :ok
              
            tokens ->
                Enum.each(tokens, fn([id, {token, socket_token}]) ->
                  :mnesia.delete({:sessions_siblings, id})
                  :mnesia.delete({:sessions_siblings, token})
                  :mnesia.delete({:sessions_siblings, socket_token})
                end)
          end
        end

        case :mnesia.transaction(fun) do
          {:atomic, _} -> :ok
          {:aborted, reason} ->
            Logger.error("SessionsManager Error cleaning up tokens: #{inspect reason}")
        end
    end
  end

  def match_spec(:invalid) do
    token_life = Runtime.token_validity()
    ttl_threshold = :erlang.system_time(:second) - token_life 
    [{{:_, :"$2", :"$3"}, [{:"=<", :"$3", ttl_threshold}], [:"$2"]}]
  end

  def match_spec(:siblings) do
    token_life = Runtime.token_validity() + 20
    ttl_threshold = :erlang.system_time(:second) - token_life
    [{{:_, :"$1", :"$2", :"$3"}, [{:"=<", :"$3", ttl_threshold}, {:is_tuple, :"$2"}], [[:"$1", :"$2"]]}]
  end
    
end

And I have like a basic plug for it, in this case it’s more convuluted because of the session tokens house keeping, usually I just use Phoenix.Tokens, authorize header.

defmodule Authorize.Plug do
  import Plug.Conn, only: [get_req_header: 2, assign: 3]

  alias Authorize.Helpers
  alias Core.Runtime
  
  def init(opts) do
    opts
  end

  @spec call(%Plug.Conn{}, any()) :: %Plug.Conn{}
  def call(conn, _) do
    case get_req_header(conn, "authorisation") do
      [] -> assign(conn, :user, false)
      ["Bearer " <> header] ->
        case valid_token(header) && Phoenix.Token.verify(conn, Helpers.salt(), header, max_age: Runtime.token_validity()) do
          {:ok, user} -> assign_valid_tokens(conn, user)
          {:error, code} -> assign_valid_tokens(conn, code)
          false -> assign_valid_tokens(conn, :invalid)
        end
    end
  end

  @spec assign_valid_tokens(%Plug.Conn{}, {:admin | :player, integer(), String.t()} | :expired | :invalid | false) :: %Plug.Conn{}
  def assign_valid_tokens(conn, {type, id, username}) do
    case get_sibling_tokens(id) do
      :invalid -> assign_valid_tokens(conn, :invalid)
      {token, socket_token} ->
        conn
        |> assign(:user, {type, id, username})
        |> assign(:token, token)
        |> assign(:socket_token, socket_token)
    end
  end

  def assign_valid_tokens(conn, reason) do
    conn |> assign(:user, reason) |> assign(:token, reason) |> assign(:socket_token, reason)
  end

  @spec valid_token(String.t()) :: boolean()
  def valid_token(token) do
    case :mnesia.dirty_read({:sessions_invalid, token}) do
      [] -> true
      _ -> false
    end
  end

  @spec get_sibling_tokens(integer()) :: :invalid | {String.t(), String.t()}
  def get_sibling_tokens(id) do
    case :mnesia.dirty_read({:sessions_siblings, id}) do
      [] -> :invalid
      [{_, _, {token, socket_token}, _}] -> {token, socket_token}
    end
  end

end

Then both login and logout do calls to the sessions manager gen_server. Expired tokens that a user hasn’t logout explicitly from the interface end up being rejected once their TTL expires.

For the mnesia part I now always create a “bootstrap”(per) in the umbrella, that is the only app started on the release, that sets up everything and connects nodes, and only then starts the remaining parts of the actual “application” (that are set to be :loaded, but not started on the release definition - in fact I have started doing that even if not using mnesia as it allows to control the startup flow).

The token storing like I said not sure how it works in terms of production ready, but all other things pretty much have worked fine and are quite simple.

(this was copy pasta and slightly changed so some things might not be 100% correct)

amnu3387

amnu3387

I usually default to GenServers, to be sincere I don’t use agents that much, or gen_statem if it’s going to have a public “server” like interface but then do complex flows based on it. Agents are designed to work on their own state as you provide a function that takes the state and returns the new one - in this case the state is in mnesia. In the current stage the code I posted is, it wouldn’t even need to be encapsulated in a process - instead be just function calls since mnesia deals with locks, etc. But I was planning to add some more functionality to it.

Last Post!

wolfiton

wolfiton

I would be most definitely interested in a blog post on this and any other:

  • otp
  • genserver stuff implementation in a phoenix application.

My current interest is because of the following topics and technologies:

  • Absinthe

  • Dataloader

  • PWA

  • SSR

  • SEO

So if you could make a detailed guide how the back-end and front-end send data for your auth example.
I would really appreciate it.

Thanks for the follow up on my comment

Where Next?

Popular in Questions Top

JeremM34
Hello, how can I check the Phoenix version ? Thanks !
New
vertexbuffer
Hello, can anybody help here..? I have a list of players and I what to delete an element, but every for loop the list is reverting to ori...
New
sen
Hi All, I set a environment variables in dev.exs , like below code. when i start server, how can i set the ${enable} value? thanks. d...
New
fireproofsocks
Forgive me if this is obvious, but how does one delete a database record WITHOUT selecting it first? Ecto.Repo — Ecto v3.14.0 has exampl...
New
sergio_101
I am VERY much an elixir newbie. I have taken one elixir course and one phoenix course on Udemy. During that course, I saw the instructor...
New
vrod
I am using the Starship cross-shell prompt – it seems pretty nice, but I get some errors: [WARN] - (starship::utils): Executing command ...
New
dblack
I’ve got an issue with an app and I’ve no idea of how to troubleshoot it. I’m hoping someone here might have seen something similar. I p...
New

Other popular topics Top

vertexbuffer
Hello, can anybody help here..? I have a list of players and I what to delete an element, but every for loop the list is reverting to ori...
New
vonH
In asking this question I am more interested about the expressiveness of the language itself and less concerned about the availability of...
New
jononomo
For some reason my phoenix channels are working for me in my local dev environment, but as soon as I deploy via Docker, I get a 403 error...
New
greenz1
I have a phoenix application from which a user can download multiple(5-6) files of size 1MB. I couldn’t find anything related to sending ...
New
sen
Hi All, I set a environment variables in dev.exs , like below code. when i start server, how can i set the ${enable} value? thanks. d...
New
romenigld
I am trying to run a deploy with docker and I successfully runned with this command: docker build -t romenigld/blog-prod . but when I t...
New

We're in Beta

About us Mission Statement