So I’ve been spending the last few months working on an up and coming streaming platform. Obviously, we would need a way to globally ban accounts from the site without removing them. This is not the best way to do it, but it works for what we needed for an alpha launch. You can view the entire PR here.


Prepping the user table for bans

First, we needed to track who was banned, not really hard; we just needed to edit our users schema to include an is_banned flag. Super simple, only required to update the schema to:

schema "users" do
    # Various more fields
    field :is_banned, :boolean, default: false
    field :ban_reason, :string
end

For our case, we also needed to add the is_banned flag to the register changeset with:

def registration_changeset(user, attrs) do
    user
    |> cast(attrs, [
        :username,
        :email,
        :password,
        :is_banned
    ])
end

With that, you can generate a new migration, mix ecto.gen.migration add_global_bans, and we’re ready to actually implement the bans into the site.


Stopping banned users from logging in

This was also pretty simple. For Glimesh, we have a login page for our users; we just needed to add a check for if the user is banned. This was all handled in our user_session_controller.ex file.

We replaced our create function with

  def create(conn, %{"user" => %{"email" => email, "password" => password} = user_params}) do
    if user = Accounts.get_user_by_email_and_password(email, password) do
      attempt_login(conn, user, user_params)
    else
      render(conn, "new.html", error_message: gettext("Invalid e-mail or password"))
    end
  end

And then from there, we made some good ol’ Elixir pattern matching on attempt_login:

# Attempts a login if the user isn't banned
def attempt_login(conn, %{is_banned: false} = user, user_params) do
    # Since we use 2FA we check to see if they have it enabled
    if user.tfa_token do
        # If they do, we want to render the view for entering the code.
        # Later was changed to a different page
        conn
        |> put_session(:tfa_user_id, user.id)
        |> render("tfa.html", error_message: nil)
    else
        # And if they don't have 2fa then we log them in
        UserAuth.log_in_user(conn, user, user_params)
    end
end

# Attempts a login if the user is banned
# With pattern matching we don't do any checks, this will only fire if the user is banned
def attempt_login(conn, %{is_banned: true} = user, user_params) do
    # Basically re-renders the login page with an error message
    render(conn, "new.html",
       error_message:
         gettext(
             "User account is banned. Please contact support at %{email} for more information.",
             email: "[email protected]"
         )
    )
end

This essentially stops any banned account from logging in. Now how did we handle already logged-in users that get banned? Relatively simply.


Handling logged in users that get banned

For this, I basically created an entire plug to check if the user is banned as they navigate the site, and if they get banned, it invalidates their session(basically logging them out). The plug I created is somewhat simple; it is called on every page load; it will get the user from the connection and check if they’re banned. If they are indeed banned, it will trigger a ban user function in our user auth module(basically the same as logging out minus a redirect), and then replacing their path to our homepage(needed otherwise Cowboy gets very angry).

plugs/ban_plug.ex

defmodule GlimeshWeb.Plugs.Ban do
  import Plug.Conn

  alias GlimeshWeb.UserAuth

  def init(_opts), do: nil

  # This is called every time a new page is loaded(so it needed to be quick)
  def call(conn, _opts) do
    user = conn.assigns.current_user

    if is_user_banned(user) do
      conn
      |> UserAuth.ban_user()
      |> replace_path("/")
    else
      conn
    end
  end

  defp is_user_banned(user) do
    case user do
      nil -> false
      _ -> if user.is_banned, do: true, else: false
    end
  end

  defp replace_path(conn, path) do
    conn
    |> Map.replace!(:request_path, path)
    |> Map.replace!(:path_info, ["banned"])
  end
end

And inside of the user_auth.ex file I added a new function for when a user is banned.

  def ban_user(conn) do
    user_token = get_session(conn, :user_token)
    user_token && Accounts.delete_session_token(user_token)

    if live_socket_id = get_session(conn, :live_socket_id) do
      Endpoint.broadcast(live_socket_id, "disconnect", %{})
    end

    conn
    |> renew_session()
    |> delete_resp_cookie(@remember_me_cookie)
  end

This here is what actually does the “logging out” of the user. You may notice the log_out_user/1 function above it but the redirect/2 at the end seems to break the funkiness we had to do for bans. Definitely not ideal but it works for now and we plan on revamping that part.


Finishing up the feature

After the schema was updated, logins properly handled banned users, and sessions were invalidated on the ban; there were just a few small things to clean up here and there.

Making it so banned users couldn’t chat

For this, I just added a check to see if the user was banned and if so, it raised an ArgumentError with a User must not be banned message. This was later swapped to our chat error handler once moderation tools were added to the chat.

Adding an ignore_banned argument to our user lookup functions

Another simple task, since we wanted to hide any banned user from being searched, I added ignore_banned \\ false to our username lookup functions that, by default, would have Ecto skip over any banned users. This argument will come back later when we’re creating our admin team’s dashboard, and they need to be able to search for banned users. But pretty much everything else will ignore banned users.


All done!

That’s pretty much it for the site-wide ban feature. We wanted to make sure it wasn’t overly complicated and could be reliable. It’s most definitely not the best way, and I’ve already admitted that to the team; we’ll be re-doing it in the future but needed something that we could use during our alpha. This was also created on 09/30/2020, which was about 2 months into my journey learning Elixir. Again, if you wanna view the entire PR to see the code around this, you can do so here.