+ <.pagination thread={@thread} />
+
+ <%= for %{userinfo: author, postdate: date, postbody: article} <- @thread.posts do %>
+ <.post author={author} date={date}>
+ <%= raw(article) %>
+
+ <% end %>
+
+ <.pagination thread={@thread} />
+
+ """
+ end
+
+ def render(assigns) do
+ ~H"""
+
+ <.user info={@author} />
+
+ <%= render_slot(@inner_block) %>
+
+ <.toolbar date={@date} />
+
+ """
+ end
+
+ def user(assigns) do
+ ~H"""
+
+ <.header class="text-center text-neutral-content">
+ Sign in to account
+ <:subtitle>
+ Don't have an account?
+ <.link navigate={~p"/users/register"} class="font-semibold text-brand hover:underline">
+ Sign up
+
+ for an account now.
+
+
+
+ <.simple_form for={@form} id="login_form" action={~p"/users/log_in"} phx-update="ignore">
+ <.input field={@form[:username]} type="text" label="Username" required />
+ <.input field={@form[:password]} type="password" label="Password" required />
+
+ <:actions>
+ <.input field={@form[:remember_me]} type="checkbox" label="Keep me logged in" />
+ <.link href={~p"/users/reset_password"} class="text-sm font-semibold">
+ Forgot your password?
+
+
+ <:actions>
+ <.button phx-disable-with="Signing in..." class="w-full">
+ Sign in →
+
+
+
+
+ """
+ end
+
+ def mount(_params, _session, socket) do
+ email = live_flash(socket.assigns.flash, :email)
+ form = to_form(%{"email" => email}, as: "user")
+ {:ok, assign(socket, form: form), temporary_assigns: [form: form]}
+ end
+end
diff --git a/lib/something_erlang_web/router.ex b/lib/something_erlang_web/router.ex
index d577c02..712109c 100644
--- a/lib/something_erlang_web/router.ex
+++ b/lib/something_erlang_web/router.ex
@@ -1,23 +1,53 @@
defmodule SomethingErlangWeb.Router do
use SomethingErlangWeb, :router
+ import SomethingErlangWeb.UserAuth
+
pipeline :browser do
- plug :accepts, ["html"]
- plug :fetch_session
- plug :fetch_live_flash
- plug :put_root_layout, html: {SomethingErlangWeb.Layouts, :root}
- plug :protect_from_forgery
- plug :put_secure_browser_headers
+ plug(:accepts, ["html"])
+ plug(:fetch_session)
+ plug(:fetch_live_flash)
+ plug(:put_root_layout, html: {SomethingErlangWeb.Layouts, :root})
+ plug(:protect_from_forgery)
+ plug(:put_secure_browser_headers)
+ plug(:fetch_current_user)
+ plug(:load_bbcookie)
end
pipeline :api do
- plug :accepts, ["json"]
+ plug(:accepts, ["json"])
end
scope "/", SomethingErlangWeb do
- pipe_through :browser
+ pipe_through(:browser)
- get "/", PageController, :home
+ get("/", PageController, :home)
+ post("/", PageController, :to_forum_path)
+
+ live_session :user_browsing,
+ on_mount: [{SomethingErlangWeb.UserAuth, :ensure_authenticated}] do
+ live("/thread", ThreadLive)
+ live("/thread/:id", ThreadLive)
+ end
+ end
+
+ ## Authentication routes
+
+ scope "/", SomethingErlangWeb do
+ pipe_through([:browser, :redirect_if_user_is_authenticated])
+
+ live_session :redirect_if_user_is_authenticated,
+ on_mount: [{SomethingErlangWeb.UserAuth, :redirect_if_user_is_authenticated}] do
+ live("/users/log_in", UserLoginLive, :new)
+ end
+
+ post("/users/log_in", UserSessionController, :create)
+ end
+
+ scope "/", SomethingErlangWeb do
+ pipe_through([:browser])
+
+ delete("/users/log_out", UserSessionController, :delete)
end
# Other scopes may use custom stacks.
@@ -35,10 +65,10 @@ defmodule SomethingErlangWeb.Router do
import Phoenix.LiveDashboard.Router
scope "/dev" do
- pipe_through :browser
+ pipe_through(:browser)
- live_dashboard "/dashboard", metrics: SomethingErlangWeb.Telemetry
- forward "/mailbox", Plug.Swoosh.MailboxPreview
+ live_dashboard("/dashboard", metrics: SomethingErlangWeb.Telemetry)
+ forward("/mailbox", Plug.Swoosh.MailboxPreview)
end
end
end
diff --git a/lib/something_erlang_web/user_auth.ex b/lib/something_erlang_web/user_auth.ex
new file mode 100644
index 0000000..c1cf867
--- /dev/null
+++ b/lib/something_erlang_web/user_auth.ex
@@ -0,0 +1,242 @@
+defmodule SomethingErlangWeb.UserAuth do
+ use SomethingErlangWeb, :verified_routes
+
+ import Plug.Conn
+ import Phoenix.Controller
+
+ alias SomethingErlang.Accounts
+
+ # Make the remember me cookie valid for 60 days.
+ # If you want bump or reduce this value, also change
+ # the token expiry itself in UserToken.
+ @max_age 60 * 60 * 24 * 60
+ @remember_me_cookie "_something_erlang_web_user_remember_me"
+ @remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"]
+
+ @doc """
+ Logs the user in.
+
+ It renews the session ID and clears the whole session
+ to avoid fixation attacks. See the renew_session
+ function to customize this behaviour.
+
+ It also sets a `:live_socket_id` key in the session,
+ so LiveView sessions are identified and automatically
+ disconnected on log out. The line can be safely removed
+ if you are not using LiveView.
+ """
+ def log_in_user(conn, user, params \\ %{}) do
+ token = Accounts.generate_user_session_token(user)
+ user_return_to = get_session(conn, :user_return_to)
+
+ conn
+ |> renew_session()
+ |> put_hashcookie_in_session(user.bbpassword)
+ |> put_token_in_session(token)
+ |> maybe_write_remember_me_cookie(token, params)
+ |> redirect(to: user_return_to || signed_in_path(conn))
+ end
+
+ defp put_hashcookie_in_session(conn, bbpassword) do
+ put_resp_cookie(conn, "bbpassword", bbpassword)
+ end
+
+ defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do
+ put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options)
+ end
+
+ defp maybe_write_remember_me_cookie(conn, _token, _params) do
+ conn
+ end
+
+ # This function renews the session ID and erases the whole
+ # session to avoid fixation attacks. If there is any data
+ # in the session you may want to preserve after log in/log out,
+ # you must explicitly fetch the session data before clearing
+ # and then immediately set it after clearing, for example:
+ #
+ # defp renew_session(conn) do
+ # preferred_locale = get_session(conn, :preferred_locale)
+ #
+ # conn
+ # |> configure_session(renew: true)
+ # |> clear_session()
+ # |> put_session(:preferred_locale, preferred_locale)
+ # end
+ #
+ defp renew_session(conn) do
+ conn
+ |> configure_session(renew: true)
+ |> clear_session()
+ end
+
+ @doc """
+ Logs the user out.
+
+ It clears all session data for safety. See renew_session.
+ """
+ def log_out_user(conn) do
+ user_token = get_session(conn, :user_token)
+ user_token && Accounts.delete_user_session_token(user_token)
+
+ if live_socket_id = get_session(conn, :live_socket_id) do
+ SomethingErlangWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
+ end
+
+ conn
+ |> renew_session()
+ |> delete_resp_cookie(@remember_me_cookie)
+ |> redirect(to: "/")
+ end
+
+ def load_bbcookie(conn, _opts) do
+ conn
+ |> put_session(:bbpassword, conn.cookies["bbpassword"])
+ end
+
+ @doc """
+ Authenticates the user by looking into the session
+ and remember me token.
+ """
+ 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
+
+ defp ensure_user_token(conn) do
+ if token = get_session(conn, :user_token) do
+ {token, conn}
+ else
+ conn = fetch_cookies(conn, signed: [@remember_me_cookie])
+
+ if token = conn.cookies[@remember_me_cookie] do
+ {token, put_token_in_session(conn, token)}
+ else
+ {nil, conn}
+ end
+ end
+ end
+
+ @doc """
+ Handles mounting and authenticating the current_user in LiveViews.
+
+ ## `on_mount` arguments
+
+ * `:mount_current_user` - Assigns current_user
+ to socket assigns based on user_token, or nil if
+ there's no user_token or no matching user.
+
+ * `:ensure_authenticated` - Authenticates the user from the session,
+ and assigns the current_user to socket assigns based
+ on user_token.
+ Redirects to login page if there's no logged user.
+
+ * `:redirect_if_user_is_authenticated` - Authenticates the user from the session.
+ Redirects to signed_in_path if there's a logged user.
+
+ ## Examples
+
+ Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate
+ the current_user:
+
+ defmodule SomethingErlangWeb.PageLive do
+ use SomethingErlangWeb, :live_view
+
+ on_mount {SomethingErlangWeb.UserAuth, :mount_current_user}
+ ...
+ end
+
+ Or use the `live_session` of your router to invoke the on_mount callback:
+
+ live_session :authenticated, on_mount: [{SomethingErlangWeb.UserAuth, :ensure_authenticated}] do
+ live "/profile", ProfileLive, :index
+ end
+ """
+ def on_mount(:mount_current_user, _params, session, socket) do
+ {:cont, mount_current_user(session, socket)}
+ end
+
+ def on_mount(:ensure_authenticated, _params, session, socket) do
+ socket = mount_current_user(session, socket)
+
+ if socket.assigns.current_user do
+ {:cont, socket}
+ else
+ socket =
+ socket
+ |> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.")
+ |> Phoenix.LiveView.redirect(to: ~p"/users/log_in")
+
+ {:halt, socket}
+ end
+ end
+
+ def on_mount(:redirect_if_user_is_authenticated, _params, session, socket) do
+ socket = mount_current_user(session, socket)
+
+ if socket.assigns.current_user do
+ {:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket))}
+ else
+ {:cont, socket}
+ end
+ end
+
+ defp mount_current_user(session, socket) do
+ case session do
+ %{"user_token" => user_token} ->
+ Phoenix.Component.assign_new(socket, :current_user, fn ->
+ Accounts.get_user_by_session_token(user_token)
+ end)
+
+ %{} ->
+ Phoenix.Component.assign_new(socket, :current_user, fn -> nil end)
+ end
+ end
+
+ @doc """
+ Used for routes that require the user to not be authenticated.
+ """
+ def redirect_if_user_is_authenticated(conn, _opts) do
+ if conn.assigns[:current_user] do
+ conn
+ |> redirect(to: signed_in_path(conn))
+ |> halt()
+ else
+ conn
+ end
+ end
+
+ @doc """
+ Used for routes that require the user to be authenticated.
+
+ If you want to enforce the user email is confirmed before
+ they use the application at all, here would be a good place.
+ """
+ def require_authenticated_user(conn, _opts) do
+ if conn.assigns[:current_user] do
+ conn
+ else
+ conn
+ |> put_flash(:error, "You must log in to access this page.")
+ |> maybe_store_return_to()
+ |> redirect(to: ~p"/users/log_in")
+ |> halt()
+ end
+ end
+
+ defp put_token_in_session(conn, token) do
+ conn
+ |> put_session(:user_token, token)
+ |> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}")
+ end
+
+ defp maybe_store_return_to(%{method: "GET"} = conn) do
+ put_session(conn, :user_return_to, current_path(conn))
+ end
+
+ defp maybe_store_return_to(conn), do: conn
+
+ defp signed_in_path(_conn), do: ~p"/"
+end
diff --git a/mix.exs b/mix.exs
index 394c89f..15a0f66 100644
--- a/mix.exs
+++ b/mix.exs
@@ -39,7 +39,8 @@ defmodule SomethingErlang.MixProject do
{:phoenix_html, "~> 4.0"},
{:phoenix_live_reload, "~> 1.2", only: :dev},
{:phoenix_live_view, "~> 0.20.2"},
- {:floki, ">= 0.30.0", only: :test},
+ # {:floki, ">= 0.30.0", only: :test},
+ {:floki, ">= 0.30.0"},
{:phoenix_live_dashboard, "~> 0.8.3"},
{:esbuild, "~> 0.8", runtime: Mix.env() == :dev},
{:tailwind, "~> 0.2", runtime: Mix.env() == :dev},
@@ -57,7 +58,8 @@ defmodule SomethingErlang.MixProject do
{:gettext, "~> 0.20"},
{:jason, "~> 1.2"},
{:dns_cluster, "~> 0.1.1"},
- {:bandit, "~> 1.2"}
+ {:bandit, "~> 1.2"},
+ {:req, "~> 0.5.0"}
]
end
diff --git a/mix.lock b/mix.lock
index c88b0e9..a1c37fe 100644
--- a/mix.lock
+++ b/mix.lock
@@ -30,6 +30,7 @@
"plug": {:hex, :plug, "1.16.0", "1d07d50cb9bb05097fdf187b31cf087c7297aafc3fed8299aac79c128a707e47", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cbf53aa1f5c4d758a7559c0bd6d59e286c2be0c6a1fac8cc3eee2f638243b93e"},
"plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"},
"postgrex": {:hex, :postgrex, "0.18.0", "f34664101eaca11ff24481ed4c378492fed2ff416cd9b06c399e90f321867d7e", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a042989ba1bc1cca7383ebb9e461398e3f89f868c92ce6671feb7ef132a252d1"},
+ "req": {:hex, :req, "0.5.0", "6d8a77c25cfc03e06a439fb12ffb51beade53e3fe0e2c5e362899a18b50298b3", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "dda04878c1396eebbfdec6db6f3d4ca609e5c8846b7ee88cc56eb9891406f7a3"},
"swoosh": {:hex, :swoosh, "1.16.9", "20c6a32ea49136a4c19f538e27739bb5070558c0fa76b8a95f4d5d5ca7d319a1", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.0", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "878b1a7a6c10ebbf725a3349363f48f79c5e3d792eb621643b0d276a38acc0a6"},
"tailwind": {:hex, :tailwind, "0.2.2", "9e27288b568ede1d88517e8c61259bc214a12d7eed271e102db4c93fcca9b2cd", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "ccfb5025179ea307f7f899d1bb3905cd0ac9f687ed77feebc8f67bdca78565c4"},
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
diff --git a/priv/repo/migrations/20230118110156_create_users_auth_tables.exs b/priv/repo/migrations/20230118110156_create_users_auth_tables.exs
new file mode 100644
index 0000000..0cb6c00
--- /dev/null
+++ b/priv/repo/migrations/20230118110156_create_users_auth_tables.exs
@@ -0,0 +1,27 @@
+defmodule SomethingErlang.Repo.Migrations.CreateUsersAuthTables do
+ use Ecto.Migration
+
+ def change do
+ execute "CREATE EXTENSION IF NOT EXISTS citext", ""
+
+ create table(:users) do
+ add :email, :citext, null: false
+ add :hashed_password, :string, null: false
+ add :confirmed_at, :naive_datetime
+ timestamps()
+ end
+
+ create unique_index(:users, [:email])
+
+ create table(:users_tokens) do
+ add :user_id, references(:users, on_delete: :delete_all), null: false
+ add :token, :binary, null: false
+ add :context, :string, null: false
+ add :sent_to, :string
+ timestamps(updated_at: false)
+ end
+
+ create index(:users_tokens, [:user_id])
+ create unique_index(:users_tokens, [:context, :token])
+ end
+end
diff --git a/priv/repo/migrations/20240329091549_add_bbuserid.exs b/priv/repo/migrations/20240329091549_add_bbuserid.exs
new file mode 100644
index 0000000..80265e1
--- /dev/null
+++ b/priv/repo/migrations/20240329091549_add_bbuserid.exs
@@ -0,0 +1,15 @@
+defmodule SomethingErlang.Repo.Migrations.AddBbuserid do
+ use Ecto.Migration
+
+ def change do
+ alter table(:users) do
+ remove :email
+ remove :hashed_password
+ remove :confirmed_at
+ add :bbuserid, :citext
+ end
+
+ # drop index(:users, [:email])
+ create unique_index(:users, [:bbuserid])
+ end
+end