Compare commits

...

5 commits

Author SHA1 Message Date
d7ac169607
add "setup" mode 2025-03-25 13:18:25 -04:00
69db46715f
add forms for user auth 2025-03-25 13:06:28 -04:00
7bd63caae7
restore basic layout 2025-03-24 07:39:10 -04:00
9779520d0c
mix phx.gen.auth 2025-03-24 07:39:09 -04:00
e0de9ae8d9
chore: clear out page/post usage 2025-03-24 05:32:35 -04:00
52 changed files with 1440 additions and 850 deletions

View file

@ -1,5 +1,5 @@
[
import_deps: [:ecto, :ecto_sql, :phoenix, :typed_struct],
import_deps: [:ecto, :ecto_sql, :phoenix],
subdirectories: ["priv/*/migrations"],
plugins: [Styler, Phoenix.LiveView.HTMLFormatter],
inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"]

View file

@ -1,5 +1,8 @@
import Config
# Only in tests, remove the complexity from the password hashing algorithm
config :argon2_elixir, t_cost: 1, m_cost: 8
config :logger, level: :warning
config :phoenix, :plug_init_mode, :runtime

View file

@ -1,4 +1,4 @@
defmodule Core do
@moduledoc false
use Boundary, deps: [Schema], exports: [Author, Posts, Statuses]
use Boundary, deps: [Schema], exports: [Accounts]
end

206
lib/core/accounts.ex Normal file
View file

@ -0,0 +1,206 @@
defmodule Core.Accounts do
@moduledoc """
The Accounts context.
"""
alias Core.Accounts.User
alias Core.Accounts.UserToken
alias Core.Repo
## Database getters
@doc """
Gets a user by username and password.
## Examples
iex> get_user_by_username_and_password("foo@example.com", "correct_password")
%Schema.User{}
iex> get_user_by_username_and_password("foo@example.com", "invalid_password")
nil
"""
def get_user_by_username_and_password(username, password) when is_binary(username) and is_binary(password) do
user = Repo.get_by(Schema.User, username: username)
if User.valid_password?(user, password), do: user
end
@doc """
Gets a single user.
Raises `Ecto.NoResultsError` if the User does not exist.
## Examples
iex> get_user!(123)
%Schema.User{}
iex> get_user!(456)
** (Ecto.NoResultsError)
"""
def get_user!(id), do: Repo.get!(Schema.User, id)
## User registration
@doc """
Registers a user.
## Examples
iex> register_user(%{field: value})
{:ok, %Schema.User{}}
iex> register_user(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def register_user(attrs) do
%Schema.User{}
|> User.registration_changeset(attrs)
|> Repo.insert()
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking user changes.
## Examples
iex> change_user_registration(user)
%Ecto.Changeset{data: %Schema.User{}}
"""
def change_user_registration(%Schema.User{} = user, attrs \\ %{}) do
User.registration_changeset(user, attrs, hash_password: false, validate_username: false)
end
## Settings
@doc """
Returns an `%Ecto.Changeset{}` for changing the user username.
## Examples
iex> change_user_username(user)
%Ecto.Changeset{data: %Schema.User{}}
"""
def change_user_username(user, attrs \\ %{}) do
User.username_changeset(user, attrs, validate_username: false)
end
@doc """
Updates the user username.
If the token matches, the user username is updated and the token is deleted.
The confirmed_at date is also updated to the current time.
"""
def update_user_username(user, password, attrs) do
user
|> User.username_changeset(attrs)
|> User.validate_current_password(password)
|> Repo.update()
end
@doc """
Returns an `%Ecto.Changeset{}` for changing the user password.
## Examples
iex> change_user_password(user)
%Ecto.Changeset{data: %Schema.User{}}
"""
def change_user_password(user, attrs \\ %{}) do
User.password_changeset(user, attrs, hash_password: false)
end
@doc """
Updates the user password.
## Examples
iex> update_user_password(user, "valid password", %{password: ...})
{:ok, %Schema.User{}}
iex> update_user_password(user, "invalid password", %{password: ...})
{:error, %Ecto.Changeset{}}
"""
def update_user_password(user, password, attrs) do
changeset =
user
|> User.password_changeset(attrs)
|> User.validate_current_password(password)
Ecto.Multi.new()
|> Ecto.Multi.update(:user, changeset)
|> Ecto.Multi.delete_all(:tokens, UserToken.Query.for_user(user))
|> Repo.transaction()
|> case do
{:ok, %{user: user}} -> {:ok, user}
{:error, :user, changeset, _} -> {:error, changeset}
end
end
## Session
@doc """
Generates a session token.
"""
def generate_user_session_token(user) do
{token, user_token} = UserToken.build_session_token(user)
Repo.insert!(user_token)
token
end
@doc """
Gets the user with the given signed token.
"""
def get_user_by_session_token(token) do
token
|> UserToken.Query.valid_session_token()
|> Repo.one()
end
@doc """
Deletes the signed token with the given context.
"""
def delete_user_session_token(token) do
Repo.delete_all(UserToken.Query.where_token_and_context(token, "session"))
:ok
end
## Reset password
@doc """
Resets the user password.
## Examples
iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"})
{:ok, %Schema.User{}}
iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"})
{:error, %Ecto.Changeset{}}
"""
def reset_user_password(user, attrs) do
Ecto.Multi.new()
|> Ecto.Multi.update(:user, User.password_changeset(user, attrs))
|> Ecto.Multi.delete_all(:tokens, UserToken.Query.for_user(user))
|> Repo.transaction()
|> case do
{:ok, %{user: user}} -> {:ok, user}
{:error, :user, changeset, _} -> {:error, changeset}
end
end
@doc """
Returns `true` if any users exist.
"""
def has_registered_user? do
Repo.exists?(User.Query.has_users_query())
end
end

155
lib/core/accounts/user.ex Normal file
View file

@ -0,0 +1,155 @@
defmodule Core.Accounts.User do
@moduledoc false
import Ecto.Changeset
@doc """
A user changeset for registration.
It is important to validate the length of both username and password.
Otherwise databases may truncate the username without warnings, which
could lead to unpredictable or insecure behaviour. Long passwords may
also be very expensive to hash for certain algorithms.
## Options
* `:hash_password` - Hashes the password so it can be stored securely
in the database and ensures the password field is cleared to prevent
leaks in the logs. If password hashing is not needed and clearing the
password field is not desired (like when using this changeset for
validations on a LiveView form), this option can be set to `false`.
Defaults to `true`.
* `:validate_username` - Validates the uniqueness of the username, in case
you don't want to validate the uniqueness of the username (like when
using this changeset for validations on a LiveView form before
submitting the form), this option can be set to `false`.
Defaults to `true`.
"""
def registration_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:username, :password])
|> validate_username(opts)
|> validate_password(opts)
end
defp validate_username(changeset, opts) do
changeset
|> validate_required([:username])
|> validate_length(:username, max: 160)
|> maybe_validate_unique_username(opts)
end
defp validate_password(changeset, opts) do
changeset
|> validate_required([:password])
|> validate_length(:password, min: 12, max: 72)
# Examples of additional password validation:
# |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character")
# |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character")
# |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character")
|> maybe_hash_password(opts)
end
defp maybe_hash_password(changeset, opts) do
hash_password? = Keyword.get(opts, :hash_password, true)
password = get_change(changeset, :password)
if hash_password? && password && changeset.valid? do
changeset
# Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that
# would keep the database transaction open longer and hurt performance.
|> put_change(:hashed_password, Argon2.hash_pwd_salt(password))
|> delete_change(:password)
else
changeset
end
end
defp maybe_validate_unique_username(changeset, opts) do
if Keyword.get(opts, :validate_username, true) do
changeset
|> unsafe_validate_unique(:username, Core.Repo)
|> unique_constraint(:username)
else
changeset
end
end
@doc """
A user changeset for changing the username.
It requires the username to change otherwise an error is added.
"""
def username_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:username])
|> validate_username(opts)
|> case do
%{changes: %{username: _}} = changeset -> changeset
%{} = changeset -> add_error(changeset, :username, "did not change")
end
end
@doc """
A user changeset for changing the password.
## Options
* `:hash_password` - Hashes the password so it can be stored securely
in the database and ensures the password field is cleared to prevent
leaks in the logs. If password hashing is not needed and clearing the
password field is not desired (like when using this changeset for
validations on a LiveView form), this option can be set to `false`.
Defaults to `true`.
"""
def password_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:password])
|> validate_confirmation(:password, message: "does not match password")
|> validate_password(opts)
end
@doc """
Verifies the password.
If there is no user or the user doesn't have a password, we call
`Argon2.no_user_verify/0` to avoid timing attacks.
"""
def valid_password?(%Schema.User{hashed_password: hashed_password}, password)
when is_binary(hashed_password) and byte_size(password) > 0 do
Argon2.verify_pass(password, hashed_password)
end
def valid_password?(_, _) do
Argon2.no_user_verify()
false
end
@doc """
Validates the current password otherwise adds an error to the changeset.
"""
def validate_current_password(changeset, password) do
changeset = cast(changeset, %{current_password: password}, [:current_password])
if valid_password?(changeset.data, password) do
changeset
else
add_error(changeset, :current_password, "is not valid")
end
end
defmodule Query do
@moduledoc false
import Ecto.Query
def base do
from _ in Schema.User, as: :users
end
def has_users_query(query \\ base()) do
query
|> limit(1)
|> select([users: _], true)
end
end
end

View file

@ -0,0 +1,68 @@
defmodule Core.Accounts.UserToken do
@moduledoc false
@rand_size 32
@doc """
Generates a token that will be stored in a signed place,
such as session or cookie. As they are signed, those
tokens do not need to be hashed.
The reason why we store session tokens in the database, even
though Phoenix already provides a session cookie, is because
Phoenix' default session cookies are not persisted, they are
simply signed and potentially encrypted. This means they are
valid indefinitely, unless you change the signing/encryption
salt.
Therefore, storing them allows individual user
sessions to be expired. The token system can also be extended
to store additional data, such as the device used for logging in.
You could then use this information to display all valid sessions
and devices in the UI and allow users to explicitly expire any
session they deem invalid.
"""
def build_session_token(user) do
token = :crypto.strong_rand_bytes(@rand_size)
{token, %Schema.UserToken{token: token, context: "session", user_id: user.id}}
end
defmodule Query do
@moduledoc false
import Ecto.Query
@session_validity_in_days 60
def base do
from Schema.UserToken, as: :user_tokens
end
def join_users(query \\ base()) do
join(query, :inner, [user_tokens: ut], u in assoc(ut, :user), as: :users)
end
@doc """
Checks if the token is valid and returns its underlying lookup query.
The query returns the user found by the token, if any.
The token is valid if it matches the value in the database and it has
not expired (after @session_validity_in_days).
"""
def valid_session_token(query \\ base(), token) do
query
|> where_token_and_context(token, "session")
|> join_users()
|> where([user_tokens: ut], ut.inserted_at > ago(@session_validity_in_days, "day"))
|> select([users: user], user)
end
def where_token_and_context(query \\ base(), token, context) do
where(query, [user_tokens: ut], ut.token == ^token and ut.context == ^context)
end
def for_user(query \\ base(), user) do
where(query, [user_tokens: t], t.user_id == ^user.id)
end
end
end

View file

@ -1,46 +0,0 @@
defmodule Core.Author do
@moduledoc """
Properties of the author, Sloane
Modeled after the [h-card] specification.
[h-card]: https://microformats.org/wiki/h-card
"""
use TypedStruct
@public_properties ~w[name nickname url]a
typedstruct do
field :name, String.t()
field :given_name, String.t()
field :additional_name, String.t()
field :family_name, String.t()
field :nickname, String.t()
field :email, String.t()
field :url, String.t()
end
def sloane do
%__MODULE__{
name: "Sloane Perrault",
given_name: "Sloane",
additional_name: "Loretta",
family_name: "Perrault",
nickname: "sloanely_but_surely",
email: "sloane@fastmail.com",
url: "https://sloanelybutsurely.com"
}
end
def full do
sloane()
end
def public do
author = full()
for key <- @public_properties, reduce: %__MODULE__{} do
acc -> Map.put(acc, key, Map.get(author, key))
end
end
end

View file

@ -1,37 +0,0 @@
defmodule Core.Posts do
@moduledoc false
import Ecto.Changeset
import Ecto.Query
alias Core.Repo
def changeset(%Schema.Post{} = post, attrs) do
post
|> cast(attrs, [:title, :body])
|> validate_required([:body])
end
def create_post(attrs) do
%Schema.Post{}
|> changeset(attrs)
|> Repo.insert()
end
def update_post(post, attrs) do
post
|> changeset(attrs)
|> Repo.update()
end
def get_post!(id) do
Repo.get!(Schema.Post, id)
end
def list_posts do
query =
from post in Schema.Post,
order_by: [desc: post.inserted_at]
Repo.all(query)
end
end

View file

@ -1,37 +0,0 @@
defmodule Core.Statuses do
@moduledoc false
import Ecto.Changeset
import Ecto.Query
alias Core.Repo
def changeset(%Schema.Status{} = status, attrs \\ %{}) do
status
|> cast(attrs, [:body])
|> validate_required([:body])
end
def create_status(attrs) do
%Schema.Status{}
|> changeset(attrs)
|> Repo.insert()
end
def update_status(status, attrs) do
status
|> changeset(attrs)
|> Repo.update()
end
def get_status!(id) do
Repo.get!(Schema.Status, id)
end
def list_statuses do
query =
from status in Schema.Status,
order_by: [desc: status.inserted_at]
Repo.all(query)
end
end

View file

@ -1,4 +1,4 @@
defmodule Schema do
@moduledoc false
use Boundary, deps: [], exports: [Post, Status]
use Boundary, deps: [], exports: [User, UserToken]
end

View file

@ -1,12 +0,0 @@
defmodule Schema.Post do
@moduledoc false
use Ecto.Schema
@primary_key {:id, :binary_id, autogenerate: true}
schema "posts" do
field :title, :string
field :body, :string
timestamps()
end
end

View file

@ -1,11 +0,0 @@
defmodule Schema.Status do
@moduledoc false
use Ecto.Schema
@primary_key {:id, :binary_id, autogenerate: true}
schema "statuses" do
field :body
timestamps()
end
end

15
lib/schema/user.ex Normal file
View file

@ -0,0 +1,15 @@
defmodule Schema.User do
@moduledoc false
use Ecto.Schema
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "users" do
field :username, :string
field :password, :string, virtual: true, redact: true
field :hashed_password, :string, redact: true
field :current_password, :string, virtual: true, redact: true
timestamps(type: :utc_datetime_usec)
end
end

15
lib/schema/user_token.ex Normal file
View file

@ -0,0 +1,15 @@
defmodule Schema.UserToken do
@moduledoc false
use Ecto.Schema
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "users_tokens" do
field :token, :binary
field :context, :string
field :sent_to, :string
belongs_to :user, Schema.User
timestamps(type: :utc_datetime_usec, updated_at: false)
end
end

View file

@ -4,99 +4,70 @@ defmodule Web.CoreComponents do
"""
use Phoenix.Component
attr :class, :string, default: nil
attr :global, :global
slot :inner_block
alias Phoenix.HTML.FormField
def title(assigns) do
~H"""
<h1 class={["font-bold text-xl mb-3", @class]} {@global}>{render_slot(@inner_block)}</h1>
"""
end
attr :id, :any, default: nil
attr :name, :any
attr :label, :string, default: nil
attr :value, :any
attr :type, :string, default: "text", values: ~w[text password]
attr :field, FormField
attr :errors, :list, default: []
attr :rest, :global, include: ~w[disabled form pattern placeholder readonly required]
attr :class, :string, default: nil
attr :global, :global
slot :inner_block
def input(%{field: %FormField{} = field} = assigns) do
errors =
if Phoenix.Component.used_input?(field) do
field.errors
else
[]
end
def subtitle(assigns) do
~H"""
<h2 class={["font-bold text-lg mb-2", @class]} {@global}>{render_slot(@inner_block)}</h2>
"""
end
attr :class, :string, default: nil
attr :global, :global,
include: ~w[navigate patch href replace method csrf_token download hreflang referrerpolicy rel target type]
slot :inner_block
def a(assigns) do
~H"""
<.link class={["hover:underline", @class]} {@global}>{render_slot(@inner_block)}</.link>
"""
end
attr :class, :string, default: nil
attr :type, :string, default: "button"
attr :global, :global
slot :inner_block
def button(assigns) do
~H"""
<button type={@type} class={["hover:underline", @class]} {@global}>
{render_slot(@inner_block)}
</button>
"""
end
attr :class, :string, default: nil
attr :field, Phoenix.HTML.FormField, required: true
attr :type, :string, default: "text"
attr :global, :global, include: ~w[required placeholder]
def input(%{type: "textarea"} = assigns) do
~H"""
<textarea
class={["px-2 py-1 border border-gray-400 rounded", @class]}
id={@field.id}
name={@field.name}
{@global}
>{@field.value}</textarea>
"""
assigns
|> assign(field: nil, id: assigns.id || field.id, errors: Enum.map(errors, &translate_error/1))
|> assign_new(:name, fn -> field.name end)
|> assign_new(:value, fn -> field.value end)
|> input()
end
def input(assigns) do
~H"""
<input
class={["px-2 py-1 border border-gray-400 rounded", @class]}
type={@type}
id={@field.id}
name={@field.name}
value={@field.value}
{@global}
/>
<div>
<.label for={@id}>{@label}</.label>
<input
id={@id}
type={@type}
name={@name}
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
{@rest}
/>
<.error :for={error <- @errors}>{error}</.error>
</div>
"""
end
@doc """
Renders a [Heroicon](https://heroicons.com).
attr :for, :string, default: nil
slot :inner_block, required: true
Heroicons come in three styles outline, solid, and mini.
By default, the outline style is used, but solid and mini may
be applied by using the `-solid` and `-mini` suffix.
def label(assigns) do
~H"""
<label for={@for}>
{render_slot(@inner_block)}
</label>
"""
end
You can customize the size and colors of the icons by setting
width, height, and background color classes.
slot :inner_block, required: true
Icons are extracted from the `deps/heroicons` directory and bundled within
your compiled app.css by the plugin in your `assets/tailwind.config.js`.
def error(assigns) do
~H"""
<p>
<.icon name="hero-exclamation-circle-mini" class="h-5 w-5 flex-none" />
{render_slot(@inner_block)}
</p>
"""
end
## Examples
<.icon name="hero-x-mark-solid" />
<.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" />
"""
attr :name, :string, required: true
attr :class, :string, default: nil
@ -106,44 +77,25 @@ defmodule Web.CoreComponents do
"""
end
attr :format, :string, required: true
attr :value, :any, default: nil
attr :formatter, :atom, default: :default
attr :timezone, :string, default: "America/New_York"
attr :global, :global
def timex(%{value: nil} = assigns) do
~H"""
<time datetime="">--</time>
"""
def translate_error({msg, opts}) do
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
end)
end
def timex(%{value: value, timezone: timezone} = assigns) do
assigns =
assign_new(assigns, :local_value, fn ->
case value do
%DateTime{} = datetime ->
datetime
%NaiveDateTime{} = naive ->
naive
|> DateTime.from_naive!("Etc/UTC")
|> DateTime.shift_zone!(timezone)
end
end)
~H"""
<time
datetime={Timex.format!(@local_value, "{ISO:Extended}")}
title={Timex.format!(@local_value, "{Mshort} {D}, {YYYY}, {h12}:{m} {AM} {Zabbr}")}
{@global}
>
{Timex.format!(@local_value, @format, timex_formatter(@formatter))}
</time>
"""
def translate_errors(errors, field) when is_list(errors) do
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
end
defp timex_formatter(formatter) do
Module.concat(Timex.Format.DateTime.Formatters, :string.titlecase("#{formatter}"))
attr :type, :string, default: "button", values: ~w[button submit]
attr :rest, :global
slot :inner_block, required: true
def button(assigns) do
~H"""
<button type={@type} {@rest}>
{render_slot(@inner_block)}
</button>
"""
end
end

View file

@ -10,10 +10,4 @@ defmodule Web.Layouts do
use Web, :html
embed_templates "layouts/*"
defp post?("/writing/" <> _), do: true
defp post?(_), do: false
defp status?("/microblog/" <> _), do: true
defp status?(_), do: false
end

View file

@ -1,51 +1,22 @@
<div class="sticky top-0 z-50 flex flex-row bg-white justify-between py-1 md:mb-2 border-b border-gray-200">
<div class="flex flex-col md:flex-row">
<section class="flex flex-row gap-x-2 md:border-r border-gray-200 px-2">
<.a href={~p"/"} class="font-bold">sloanelybutsurely.com</.a>
<.link href={~p"/"} class="font-bold group">
💜 <span class="group-hover:underline">sloanelybutsurely.com</span>
</.link>
<nav>
<ul class="flex flex-row gap-x-2">
<li>
<.a href={~p"/writing"}>writing</.a>
</li>
<li>
<.a href={~p"/microblog"}>microblog</.a>
</li>
</ul>
</nav>
</section>
<section :if={@admin?} class="flex flex-row gap-x-2 px-2">
<nav>
<ul class="flex flex-row gap-x-2">
<li>
<.a navigate={~p"/admin"}>admin</.a>
</li>
<li>
<.a href={~p"/admin/statuses/new"}>new status</.a>
</li>
<li>
<.a href={~p"/admin/posts/new"}>new post</.a>
</li>
<li :if={post?(@current_path)}>
<.a href={~p"/admin/posts/#{@post}"}>edit post</.a>
</li>
<li :if={status?(@current_path)}>
<.a href={~p"/admin/statuses/#{@status}"}>edit status</.a>
</li>
</ul>
<ul class="flex flex-row gap-x-2"></ul>
</nav>
</section>
</div>
<.a :if={@admin?} class="px-2" href={~p"/admin/session/destroy?return_to=#{@current_path}"}>
sign out
</.a>
<.a
:if={!@admin?}
class="px-2 text-transparent hover:text-current"
href={~p"/sign-in?return_to=#{@current_path}"}
>
sign in
</.a>
<%= if is_nil(@current_user) do %>
<.link class="px-2 text-transparent hover:text-current" href={~p"/admin/users/log_in"}>
sign in
</.link>
<% else %>
<.link class="px-2" href={~p"/admin/users/log_out"} method="delete">sign out</.link>
<% end %>
</div>
<main class="p-2 max-w-2xl mx-auto">

View file

@ -10,11 +10,6 @@
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
<script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
</script>
<%= if @load_trix? do %>
<link rel="stylesheet" type="text/css" href="https://unpkg.com/trix@2.0.8/dist/trix.css" />
<script type="text/javascript" src="https://unpkg.com/trix@2.0.8/dist/trix.umd.min.js">
</script>
<% end %>
</head>
<body class="bg-white">
{@inner_content}

View file

@ -1,62 +0,0 @@
defmodule Web.AdminAuth do
@moduledoc false
use Web, :verified_routes
import Phoenix.Controller
import Plug.Conn
def log_in_admin(conn, params) do
conn
|> renew_session()
|> put_session(:admin?, true)
|> redirect(to: params["return_to"] || ~p"/admin")
end
def log_out_admin(conn, params) do
if live_socket_id = get_session(conn, :live_socket_id) do
Web.Endpoint.broadcast(live_socket_id, "disconnect", %{})
end
conn
|> renew_session()
|> redirect(to: params["return_to"] || ~p"/")
end
def mount_admin(%Plug.Conn{} = conn, _opts) do
assign(conn, :admin?, admin?(conn))
end
def require_admin(%Plug.Conn{assigns: %{admin?: true}} = conn, _opts) do
conn
end
def require_admin(conn, _opts) do
redirect(conn, to: ~p"/sign-in?return_to=#{conn.request_path}")
end
def correct_password?(password) do
password_hash = Application.fetch_env!(:sloanely_but_surely, :password_hash)
Argon2.verify_pass(password, password_hash)
end
def on_mount(:default, _params, session, socket) do
{:cont, Phoenix.Component.assign(socket, :admin?, admin?(session))}
end
## private
defp renew_session(conn) do
delete_csrf_token()
conn
|> configure_session(renew: true)
|> clear_session()
end
defp admin?(%Plug.Conn{} = conn) do
Plug.Conn.get_session(conn, :admin?, false) == true
end
defp admin?(%{} = session), do: Map.get(session, "admin?", false) == true
end

View file

@ -1,21 +0,0 @@
defmodule Web.AdminSessionController do
use Web, :controller
alias Web.AdminAuth
def create(conn, %{"password" => password} = params) do
if AdminAuth.correct_password?(password) do
AdminAuth.log_in_admin(conn, params)
else
redirect(conn, to: ~p"/sign-in")
end
end
def create(conn, _params) do
redirect(conn, to: ~p"/sign-in")
end
def destroy(conn, params) do
AdminAuth.log_out_admin(conn, params)
end
end

View file

@ -1,22 +0,0 @@
defmodule Web.Globals do
@moduledoc false
use Web, :live_view
def assign_globals(%Plug.Conn{} = conn, _opts) do
conn
|> Plug.Conn.assign(:current_path, conn.request_path)
|> Plug.Conn.assign(:load_trix?, false)
end
def on_mount(:default, _params, _session, socket) do
socket =
socket
|> attach_hook(:assign_handle_params_globals, :handle_params, fn _params, uri, socket ->
%URI{path: current_path} = URI.parse(uri)
{:cont, assign(socket, :current_path, current_path)}
end)
|> assign(:load_trix?, false)
{:cont, socket}
end
end

View file

@ -2,12 +2,6 @@ defmodule Web.PageController do
use Web, :controller
def home(conn, _params) do
posts = Enum.take(Core.Posts.list_posts(), 5)
statuses = Enum.take(Core.Statuses.list_statuses(), 10)
conn
|> assign(:posts, posts)
|> assign(:statuses, statuses)
|> render(:home)
render(conn, :home)
end
end

View file

@ -1,9 +1,4 @@
defmodule Web.PageHTML do
@moduledoc """
This module contains pages rendered by PageController.
See the `page_html` directory for all templates available.
"""
use Web, :html
embed_templates "page_html/*"

View file

@ -1,46 +1 @@
<div class="flex flex-col gap-y-4">
<section>
<.title>
<.a href={~p"/writing"}>writing</.a>
</.title>
<ul class="flex flex-col">
<li :for={post <- @posts}>
<.link href={~p"/writing/#{post}"} class="flex flex-row justify-between group">
<span class="group-hover:underline">
<%= if post.title do %>
{post.title}
<% else %>
(no title)
<% end %>
</span>
<.timex
value={post.inserted_at}
format="{YYYY}-{0M}-{0D}"
class="text-gray-500 text-nowrap"
/>
</.link>
</li>
</ul>
</section>
<section>
<.title>
<.a href={~p"/microblog"}>microblog</.a>
</.title>
<ul class="flex flex-col">
<li :for={status <- @statuses}>
<.link href={~p"/microblog/#{status}"} class="flex flex-row justify-between group">
<span class="group-hover:underline overflow-hidden text-ellipsis text-nowrap">
{status.body}
</span>
<.timex
value={status.inserted_at}
format="{relative}"
formatter={:relative}
class="text-gray-500 text-nowrap"
/>
</.link>
</li>
</ul>
</section>
</div>
<h1>Home</h1>

View file

@ -1,19 +0,0 @@
defmodule Web.PostController do
use Web, :controller
def index(conn, _params) do
posts = Core.Posts.list_posts()
conn
|> assign(:posts, posts)
|> render(:index)
end
def show(conn, %{"post_id" => post_id}) do
post = Core.Posts.get_post!(post_id)
conn
|> assign(:post, post)
|> render(:show)
end
end

View file

@ -1,6 +0,0 @@
defmodule Web.PostHTML do
@moduledoc false
use Web, :html
embed_templates "post_html/*"
end

View file

@ -1,19 +0,0 @@
<.title>writing</.title>
<ul class="flex flex-col">
<li :for={post <- @posts}>
<.link href={~p"/writing/#{post}"} class="flex flex-row justify-between group">
<span class="group-hover:underline">
<%= if post.title do %>
{post.title}
<% else %>
(no title)
<% end %>
</span>
<.timex
value={post.inserted_at}
format="{YYYY}-{0M}-{0D}"
class="text-gray-500 text-nowrap"
/>
</.link>
</li>
</ul>

View file

@ -1,9 +0,0 @@
<article>
<header class="flex flex-row">
<h1 :if={@post.title} class="font-bold text-xl mb-3">{@post.title}</h1>
</header>
<section class="prose prose-cms max-w-none">{raw(@post.body)}</section>
<footer class="mt-5 py-1 border-t border-gray-200 relative">
<.link :if={@admin?} class="absolute right-0" href={~p"/admin/posts/#{@post}"}>edit</.link>
</footer>
</article>

View file

@ -1,19 +0,0 @@
defmodule Web.StatusController do
use Web, :controller
def index(conn, _params) do
statuses = Core.Statuses.list_statuses()
conn
|> assign(:statuses, statuses)
|> render(:index)
end
def show(conn, %{"status_id" => status_id}) do
status = Core.Statuses.get_status!(status_id)
conn
|> assign(:status, status)
|> render(:show)
end
end

View file

@ -1,6 +0,0 @@
defmodule Web.StatusHTML do
@moduledoc false
use Web, :html
embed_templates "status_html/*"
end

View file

@ -1,16 +0,0 @@
<.title>microblog</.title>
<ul class="flex flex-col">
<li :for={status <- @statuses}>
<.link href={~p"/microblog/#{status}"} class="flex flex-row justify-between group">
<span class="group-hover:underline overflow-hidden text-ellipsis text-nowrap">
{status.body}
</span>
<.timex
value={status.inserted_at}
format="{relative}"
formatter={:relative}
class="text-gray-500 text-nowrap"
/>
</.link>
</li>
</ul>

View file

@ -1,3 +0,0 @@
<article>
<p>{@status.body}</p>
</article>

View file

@ -0,0 +1,41 @@
defmodule Web.UserSessionController do
use Web, :controller
alias Web.UserAuth
def create(conn, %{"_action" => "registered"} = params) do
create(conn, params, "Account created successfully!")
end
def create(conn, %{"_action" => "password_updated"} = params) do
conn
|> put_session(:user_return_to, ~p"/admin/users/settings")
|> create(params, "Password updated successfully!")
end
def create(conn, params) do
create(conn, params, "Welcome back!")
end
defp create(conn, %{"user" => user_params}, info) do
%{"username" => username, "password" => password} = user_params
if user = Core.Accounts.get_user_by_username_and_password(username, password) do
conn
|> put_flash(:info, info)
|> UserAuth.log_in_user(user, user_params)
else
# In order to prevent user enumeration attacks, don't disclose whether the username is registered.
conn
|> put_flash(:error, "Invalid username or password")
|> put_flash(:username, String.slice(username, 0, 160))
|> redirect(to: ~p"/admin/users/log_in")
end
end
def delete(conn, _params) do
conn
|> put_flash(:info, "Logged out successfully.")
|> UserAuth.log_out_user()
end
end

View file

@ -1,16 +0,0 @@
defmodule Web.AdminLive do
@moduledoc false
use Web, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<h1>AdminLive</h1>
"""
end
end

View file

@ -1,39 +0,0 @@
defmodule Web.AdminLoginLive do
@moduledoc false
use Web, :live_view
@impl true
def mount(params, _session, socket) do
socket =
assign(
socket,
form: to_form(%{"password" => "", "return_to" => params["return_to"]}),
return_to: params["return_to"]
)
{:ok, socket, layout: false}
end
@impl true
def render(assigns) do
~H"""
<main class="flex flex-col w-screen h-screen fixed justify-center items-center">
<.form for={@form} action={~p"/admin/session/create"} class="flex flex-col gap-y-2">
<.input type="hidden" field={@form[:return_to]} />
<.input type="password" placeholder="password" field={@form[:password]} required />
<div class="flex flex-col items-end">
<button type="submit" class="font-bold hover:underline">sign in</button>
<.a href={cancel_href(@return_to)}>
cancel
</.a>
</div>
</.form>
</main>
"""
end
defp cancel_href("/admin"), do: ~p"/"
defp cancel_href("/admin/" <> _), do: ~p"/"
defp cancel_href(nil), do: ~p"/"
defp cancel_href(return_to), do: return_to
end

View file

@ -1,76 +0,0 @@
defmodule Web.PostLive do
@moduledoc false
use Web, :live_view
@impl true
def mount(_params, _session, socket) do
socket = assign(socket, :load_trix?, true)
{:ok, socket}
end
@impl true
def handle_params(_params, _uri, %{assigns: %{live_action: :new}} = socket) do
post = %Schema.Post{}
changeset = Core.Posts.changeset(post, %{})
socket = assign(socket, post: post, form: to_form(changeset))
{:noreply, socket}
end
def handle_params(%{"post_id" => post_id}, _uri, %{assigns: %{live_action: :edit}} = socket) do
post = Core.Posts.get_post!(post_id)
changeset = Core.Posts.changeset(post, %{})
socket = assign(socket, post: post, form: to_form(changeset))
{:noreply, socket}
end
@impl true
def handle_event("save_post", %{"post" => attrs}, %{assigns: %{live_action: :new}} = socket) do
socket =
case Core.Posts.create_post(attrs) do
{:ok, post} -> push_navigate(socket, to: ~p"/admin/posts/#{post}")
{:error, changeset} -> assign(socket, form: to_form(changeset))
end
{:noreply, socket}
end
def handle_event("save_post", %{"post" => attrs}, %{assigns: %{post: post, live_action: :edit}} = socket) do
socket =
case Core.Posts.update_post(post, attrs) do
{:ok, post} ->
assign(socket,
post: post,
form:
post
|> Core.Posts.changeset(%{})
|> to_form()
)
{:error, changeset} ->
assign(socket, form: to_form(changeset))
end
{:noreply, socket}
end
@impl true
def render(assigns) do
~H"""
<.form for={@form} class="flex flex-col gap-y-2" phx-submit="save_post">
<.input type="hidden" field={@form[:body]} />
<.input class="text-lg" field={@form[:title]} placeholder="Title" />
<div id="editor" phx-update="ignore">
<trix-editor input={@form[:body].id} class="prose prose-cms max-w-none"></trix-editor>
</div>
<button type="submit" class="self-end">save</button>
</.form>
"""
end
end

View file

@ -1,70 +0,0 @@
defmodule Web.StatusLive do
@moduledoc false
use Web, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
@impl true
def handle_params(_params, _uri, %{assigns: %{live_action: :new}} = socket) do
status = %Schema.Status{}
changeset = Core.Statuses.changeset(status, %{})
socket = assign(socket, status: status, form: to_form(changeset))
{:noreply, socket}
end
def handle_params(%{"status_id" => status_id}, _uri, %{assigns: %{live_action: :edit}} = socket) do
status = Core.Statuses.get_status!(status_id)
changeset = Core.Statuses.changeset(status, %{})
socket = assign(socket, status: status, form: to_form(changeset))
{:noreply, socket}
end
@impl true
def handle_event("save_status", %{"status" => attrs}, %{assigns: %{live_action: :new}} = socket) do
socket =
case Core.Statuses.create_status(attrs) do
{:ok, status} -> push_navigate(socket, to: ~p"/admin/statuses/#{status}")
{:error, changeset} -> assign(socket, form: to_form(changeset))
end
{:noreply, socket}
end
def handle_event("save_status", %{"status" => attrs}, %{assigns: %{status: status, live_action: :edit}} = socket) do
socket =
case Core.Statuses.update_status(status, attrs) do
{:ok, status} ->
assign(socket,
status: status,
form:
status
|> Core.Statuses.changeset(%{})
|> to_form()
)
{:error, changeset} ->
assign(socket, form: to_form(changeset))
end
{:noreply, socket}
end
@impl true
def render(assigns) do
~H"""
<.form for={@form} class="flex flex-col gap-y-2" phx-submit="save_status">
<.input type="textarea" field={@form[:body]} />
<button type="submit" class="self-end">save</button>
</.form>
"""
end
end

View file

@ -0,0 +1,25 @@
defmodule Web.UserLoginLive do
@moduledoc false
use Web, :live_view
def mount(_params, _session, socket) do
form = to_form(%{}, as: "user")
{:ok, assign(socket, form: form), temporary_assigns: [form: form], layout: false}
end
def render(assigns) do
~H"""
<div class="mx-auto max-w-sm">
<header>sign in</header>
<.form for={@form} id="login_form" action={~p"/admin/users/log_in"} phx-update="ignore">
<.input field={@form[:username]} type="text" label="username" required />
<.input field={@form[:password]} type="password" label="password" required />
<.button phx-disable-with="signing in..." class="w-full" type="submit">
sign in
</.button>
</.form>
</div>
"""
end
end

View file

@ -0,0 +1,64 @@
defmodule Web.UserRegistrationLive do
@moduledoc false
use Web, :live_view
alias Core.Accounts
def mount(_params, _session, socket) do
changeset = Accounts.change_user_registration(%Schema.User{})
socket =
socket
|> assign(trigger_submit: false)
|> assign_form(changeset)
{:ok, socket, temporary_assigns: [form: nil], layout: false}
end
def handle_event("save", %{"user" => user_params}, socket) do
socket =
case Accounts.register_user(user_params) do
{:ok, user} ->
changeset = Accounts.change_user_registration(user)
socket |> assign(trigger_submit: true) |> assign_form(changeset)
{:error, %Ecto.Changeset{} = changeset} ->
assign_form(socket, changeset)
end
{:noreply, socket}
end
def handle_event("validate", %{"user" => user_params}, socket) do
changeset = Accounts.change_user_registration(%Schema.User{}, user_params)
{:noreply, assign_form(socket, Map.put(changeset, :action, :validate))}
end
def render(assigns) do
~H"""
<div class="mx-auto max-w-sm">
<header class="text-center">finish installation</header>
<.form
for={@form}
id="registration_form"
phx-submit="save"
phx-change="validate"
phx-trigger-action={@trigger_submit}
action={~p"/admin/users/log_in?_action=registered"}
method="post"
>
<.input field={@form[:username]} type="text" label="username" />
<.input field={@form[:password]} type="password" label="password" />
<.button type="submit">create administrator</.button>
</.form>
</div>
"""
end
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
form = to_form(changeset, as: "user")
assign(socket, form: form)
end
end

View file

@ -0,0 +1,156 @@
defmodule Web.UserSettingsLive do
@moduledoc false
use Web, :live_view
alias Core.Accounts
def render(assigns) do
# ~H"""
# <.header class="text-center">
# Account Settings
# <:subtitle>Manage your account username address and password settings</:subtitle>
# </.header>
# <div class="space-y-12 divide-y">
# <div>
# <.simple_form
# for={@username_form}
# id="username_form"
# phx-submit="update_username"
# phx-change="validate_username"
# >
# <.input field={@username_form[:username]} type="text" label="Email" required />
# <.input
# field={@username_form[:current_password]}
# name="current_password"
# id="current_password_for_username"
# type="password"
# label="Current password"
# value={@username_form_current_password}
# required
# />
# <:actions>
# <.button phx-disable-with="Changing...">Change Email</.button>
# </:actions>
# </.simple_form>
# </div>
# <div>
# <.simple_form
# for={@password_form}
# id="password_form"
# action={~p/admin/users/log_in?_action=password_updated"}
# method="post"
# phx-change="validate_password"
# phx-submit="update_password"
# phx-trigger-action={@trigger_submit}
# >
# <input
# name={@password_form[:username].name}
# type="hidden"
# id="hidden_user_username"
# value={@current_username}
# />
# <.input field={@password_form[:password]} type="password" label="New password" required />
# <.input
# field={@password_form[:password_confirmation]}
# type="password"
# label="Confirm new password"
# />
# <.input
# field={@password_form[:current_password]}
# name="current_password"
# type="password"
# label="Current password"
# id="current_password_for_password"
# value={@current_password}
# required
# />
# <:actions>
# <.button phx-disable-with="Changing...">Change Password</.button>
# </:actions>
# </.simple_form>
# </div>
# </div>
# """
~H"""
<pre>UserSettingsLive</pre>
"""
end
def mount(_params, _session, socket) do
user = socket.assigns.current_user
username_changeset = Accounts.change_user_username(user)
password_changeset = Accounts.change_user_password(user)
socket =
socket
|> assign(:current_password, nil)
|> assign(:username_form_current_password, nil)
|> assign(:current_username, user.username)
|> assign(:username_form, to_form(username_changeset))
|> assign(:password_form, to_form(password_changeset))
|> assign(:trigger_submit, false)
{:ok, socket}
end
def handle_event("validate_username", params, socket) do
%{"current_password" => password, "user" => user_params} = params
username_form =
socket.assigns.current_user
|> Accounts.change_user_username(user_params)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, username_form: username_form, username_form_current_password: password)}
end
def handle_event("update_username", params, socket) do
%{"current_password" => password, "user" => user_params} = params
user = socket.assigns.current_user
case Accounts.update_user_username(user, password, user_params) do
{:ok, updated_user} ->
username_changeset = Accounts.change_user_username(updated_user)
{:noreply,
socket
|> put_flash(:info, "Email updated")
|> assign(username_form_current_password: nil, username_form: to_form(username_changeset))}
{:error, changeset} ->
{:noreply, assign(socket, :username_form, to_form(Map.put(changeset, :action, :insert)))}
end
end
def handle_event("validate_password", params, socket) do
%{"current_password" => password, "user" => user_params} = params
password_form =
socket.assigns.current_user
|> Accounts.change_user_password(user_params)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, password_form: password_form, current_password: password)}
end
def handle_event("update_password", params, socket) do
%{"current_password" => password, "user" => user_params} = params
user = socket.assigns.current_user
case Accounts.update_user_password(user, password, user_params) do
{:ok, user} ->
password_form =
user
|> Accounts.change_user_password(user_params)
|> to_form()
{:noreply, assign(socket, trigger_submit: true, password_form: password_form)}
{:error, changeset} ->
{:noreply, assign(socket, password_form: to_form(changeset))}
end
end
end

View file

@ -1,11 +1,7 @@
defmodule Web.Router do
use Web, :router
import Web.AdminAuth
import Web.Globals
alias Web.AdminAuth
alias Web.Globals
import Web.UserAuth
pipeline :browser do
plug :accepts, ["html"]
@ -14,47 +10,37 @@ defmodule Web.Router do
plug :put_root_layout, html: {Web.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :assign_globals
plug :fetch_current_user
end
pipeline :supports_admin_action do
plug :mount_admin
end
scope "/", Web do
pipe_through [:browser, :redirect_if_user_is_authenticated]
pipeline :requires_admin do
plug :mount_admin
plug :require_admin
end
live_session :default, on_mount: [AdminAuth, Globals] do
scope "/", Web do
pipe_through :browser
pipe_through :supports_admin_action
get "/", PageController, :home
get "/writing", PostController, :index
get "/writing/:post_id", PostController, :show
get "/microblog", StatusController, :index
get "/microblog/:status_id", StatusController, :show
live "/sign-in", AdminLoginLive
post "/admin/session/create", AdminSessionController, :create
get "/admin/session/destroy", AdminSessionController, :destroy
live_session :redirect_if_user_is_authenticated,
on_mount: [{Web.UserAuth, :redirect_if_user_is_authenticated}] do
live "/admin/users/register", UserRegistrationLive, :new
live "/admin/users/log_in", UserLoginLive, :new
end
scope "/admin", Web do
pipe_through :browser
pipe_through :requires_admin
post "/admin/users/log_in", UserSessionController, :create
end
live "/", AdminLive
scope "/admin", Web do
pipe_through [:browser, :require_authenticated_user]
live "/posts/new", PostLive, :new
live "/posts/:post_id", PostLive, :edit
live "/statuses/new", StatusLive, :new
live "/statuses/:status_id", StatusLive, :edit
live_session :require_authenticated_user, on_mount: [{Web.UserAuth, :ensure_authenticated}] do
live "/users/settings", UserSettingsLive, :edit
end
end
scope "/", Web do
pipe_through [:browser, :require_setup]
get "/", PageController, :home
delete "/admin/users/log_out", UserSessionController, :delete
# live_session :current_user, on_mount: [{Web.UserAuth, :mount_current_user}] do
# end
end
end

241
lib/web/user_auth.ex Normal file
View file

@ -0,0 +1,241 @@
defmodule Web.UserAuth do
@moduledoc false
use Web, :verified_routes
import Phoenix.Controller
import Plug.Conn
alias Core.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 "_core_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_token_in_session(token)
|> maybe_write_remember_me_cookie(token, params)
|> redirect(to: user_return_to || signed_in_path(conn))
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
delete_csrf_token()
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
Web.Endpoint.broadcast(live_socket_id, "disconnect", %{})
end
conn
|> renew_session()
|> delete_resp_cookie(@remember_me_cookie)
|> redirect(to: ~p"/")
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 Web.PageLive do
use Web, :live_view
on_mount {Web.UserAuth, :mount_current_user}
...
end
Or use the `live_session` of your router to invoke the on_mount callback:
live_session :authenticated, on_mount: [{Web.UserAuth, :ensure_authenticated}] do
live "/profile", ProfileLive, :index
end
"""
def on_mount(:mount_current_user, _params, session, socket) do
{:cont, mount_current_user(socket, session)}
end
def on_mount(:ensure_authenticated, _params, session, socket) do
socket = mount_current_user(socket, session)
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"/admin/users/log_in")
{:halt, socket}
end
end
def on_mount(:redirect_if_user_is_authenticated, _params, session, socket) do
socket = mount_current_user(socket, session)
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(socket, session) do
Phoenix.Component.assign_new(socket, :current_user, fn ->
if user_token = session["user_token"] do
Accounts.get_user_by_session_token(user_token)
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"/admin/users/log_in")
|> halt()
end
end
def require_setup(conn, _opts) do
if Core.Accounts.has_registered_user?() do
conn
else
conn
|> maybe_store_return_to()
|> redirect(to: ~p"/admin/users/register")
|> 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

View file

@ -33,6 +33,7 @@ defmodule SlaonelyButSurely.MixProject do
# Type `mix help deps` for examples and options.
defp deps do
[
{:argon2_elixir, "~> 3.0"},
# Default Phoenix dependencies
{:phoenix, "~> 1.7.19"},
{:phoenix_ecto, "~> 4.5"},
@ -51,9 +52,6 @@ defmodule SlaonelyButSurely.MixProject do
{:bandit, "~> 1.5"},
# Added dependencies
{:argon2_elixir, "~> 4.1"},
{:timex, "~> 3.7"},
{:typed_struct, "~> 0.3.0"},
{:boundary, "~> 0.10.4"},
# Added dev and/or test dependencies

View file

@ -1,5 +1,5 @@
%{
"argon2_elixir": {:hex, :argon2_elixir, "4.1.2", "1160a3ccd59b951175525882240651f5ed3303b75c616204713f8b31c76b37bd", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "9222341e1b0d9aa5ca7e26a1c77bd1bd92d2314c92b57ca3e2c7ed847223b51d"},
"argon2_elixir": {:hex, :argon2_elixir, "3.2.1", "f47740bf9f2a39ffef79ba48eb25dea2ee37bcc7eadf91d49615591d1a6fce1a", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "a813b78217394530b5fcf4c8070feee43df03ffef938d044019169c766315690"},
"bandit": {:hex, :bandit, "1.6.7", "42f30e37a1c89a2a12943c5dca76f731a2313e8a2e21c1a95dc8241893e922d1", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "551ba8ff5e4fc908cbeb8c9f0697775fb6813a96d9de5f7fe02e34e76fd7d184"},
"boundary": {:hex, :boundary, "0.10.4", "5fec5d2736c12f9bfe1720c3a2bd8c48c3547c24d6002ebf8e087570afd5bd2f", [:mix], [], "hexpm", "8baf6f23987afdb1483033ed0bde75c9c703613c22ed58d5f23bf948f203247c"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},

View file

@ -1,13 +0,0 @@
defmodule Core.Repo.Migrations.AddPostsTable do
use Ecto.Migration
def change do
create table(:posts, primary_key: false) do
add :id, :uuid, primary_key: true
add :title, :text
add :body, :text, null: false, default: ""
timestamps()
end
end
end

View file

@ -1,14 +0,0 @@
defmodule Core.Repo.Migrations.AddStatusesTable do
use Ecto.Migration
def change do
create table(:statuses, primary_key: false) do
add :id, :uuid, primary_key: true
add :body, :text, null: false
timestamps()
end
create index(:statuses, [:inserted_at])
end
end

View file

@ -0,0 +1,27 @@
defmodule Core.Repo.Migrations.CreateUsersAuthTables do
use Ecto.Migration
def change do
create table(:users, primary_key: false) do
add :id, :binary_id, primary_key: true
add :username, :text, null: false
add :hashed_password, :text, null: false
timestamps(type: :utc_datetime_usec)
end
create unique_index(:users, [:username])
create table(:users_tokens, primary_key: false) do
add :id, :binary_id, primary_key: true
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
add :token, :binary, null: false
add :context, :text, null: false
timestamps(type: :utc_datetime_usec, updated_at: false)
end
create index(:users_tokens, [:user_id])
create unique_index(:users_tokens, [:context, :token])
end
end

View file

@ -36,4 +36,30 @@ defmodule Test.ConnCase do
Test.DataCase.setup_sandbox(tags)
{:ok, conn: Phoenix.ConnTest.build_conn()}
end
# @doc """
# Setup helper that registers and logs in users.
# setup :register_and_log_in_user
# It stores an updated connection and a registered user in the
# test context.
# """
# def register_and_log_in_user(%{conn: conn}) do
# user = Core.AccountsFixtures.user_fixture()
# %{conn: log_in_user(conn, user), user: user}
# end
# @doc """
# Logs the given `user` into the `conn`.
# It returns an updated `conn`.
# """
# def log_in_user(conn, user) do
# token = Core.Accounts.generate_user_session_token(user)
# conn
# |> Phoenix.ConnTest.init_test_session(%{})
# |> Plug.Conn.put_session(:user_token, token)
# end
end

16
test/support/test/fixtures/accounts.ex vendored Normal file
View file

@ -0,0 +1,16 @@
defmodule Test.Fixtures.Accounts do
@moduledoc """
This module defines test helpers for creating
entities via the `Core.Accounts` context.
"""
def unique_user_username, do: "user#{System.unique_integer()}"
def valid_user_password, do: "hello world!"
def user(attrs \\ %{}) do
Enum.into(attrs, %{
username: unique_user_username(),
password: valid_user_password()
})
end
end

View file

@ -1,8 +0,0 @@
defmodule Test.Web.PageControllerTest do
use Test.ConnCase
test "GET /", %{conn: conn} do
conn = get(conn, ~p"/")
assert html_response(conn, 200) =~ "sloanelybutsurely.com"
end
end

View file

@ -0,0 +1,273 @@
defmodule Test.Web.UserAuthTest do
use Test.ConnCase, async: true
alias Core.Accounts
alias Phoenix.LiveView
alias Web.UserAuth
@remember_me_cookie "_core_web_user_remember_me"
setup %{conn: conn} do
conn =
conn
|> Map.replace!(:secret_key_base, Web.Endpoint.config(:secret_key_base))
|> init_test_session(%{})
{:ok, user} = Accounts.register_user(Test.Fixtures.Accounts.user())
%{user: user, conn: conn}
end
describe "log_in_user/3" do
test "stores the user token in the session", %{conn: conn, user: user} do
conn = UserAuth.log_in_user(conn, user)
assert token = get_session(conn, :user_token)
assert get_session(conn, :live_socket_id) == "users_sessions:#{Base.url_encode64(token)}"
assert redirected_to(conn) == ~p"/"
assert Accounts.get_user_by_session_token(token)
end
test "clears everything previously stored in the session", %{conn: conn, user: user} do
conn = conn |> put_session(:to_be_removed, "value") |> UserAuth.log_in_user(user)
refute get_session(conn, :to_be_removed)
end
test "redirects to the configured path", %{conn: conn, user: user} do
conn = conn |> put_session(:user_return_to, "/hello") |> UserAuth.log_in_user(user)
assert redirected_to(conn) == "/hello"
end
test "writes a cookie if remember_me is configured", %{conn: conn, user: user} do
conn = conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"})
assert get_session(conn, :user_token) == conn.cookies[@remember_me_cookie]
assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie]
assert signed_token != get_session(conn, :user_token)
assert max_age == 5_184_000
end
end
describe "logout_user/1" do
test "erases session and cookies", %{conn: conn, user: user} do
user_token = Accounts.generate_user_session_token(user)
conn =
conn
|> put_session(:user_token, user_token)
|> put_req_cookie(@remember_me_cookie, user_token)
|> fetch_cookies()
|> UserAuth.log_out_user()
refute get_session(conn, :user_token)
refute conn.cookies[@remember_me_cookie]
assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie]
assert redirected_to(conn) == ~p"/"
refute Accounts.get_user_by_session_token(user_token)
end
test "broadcasts to the given live_socket_id", %{conn: conn} do
live_socket_id = "users_sessions:abcdef-token"
Web.Endpoint.subscribe(live_socket_id)
conn
|> put_session(:live_socket_id, live_socket_id)
|> UserAuth.log_out_user()
assert_receive %Phoenix.Socket.Broadcast{event: "disconnect", topic: ^live_socket_id}
end
test "works even if user is already logged out", %{conn: conn} do
conn = conn |> fetch_cookies() |> UserAuth.log_out_user()
refute get_session(conn, :user_token)
assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie]
assert redirected_to(conn) == ~p"/"
end
end
describe "fetch_current_user/2" do
test "authenticates user from session", %{conn: conn, user: user} do
user_token = Accounts.generate_user_session_token(user)
conn = conn |> put_session(:user_token, user_token) |> UserAuth.fetch_current_user([])
assert conn.assigns.current_user.id == user.id
end
test "authenticates user from cookies", %{conn: conn, user: user} do
logged_in_conn =
conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"})
user_token = logged_in_conn.cookies[@remember_me_cookie]
%{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie]
conn =
conn
|> put_req_cookie(@remember_me_cookie, signed_token)
|> UserAuth.fetch_current_user([])
assert conn.assigns.current_user.id == user.id
assert get_session(conn, :user_token) == user_token
assert get_session(conn, :live_socket_id) ==
"users_sessions:#{Base.url_encode64(user_token)}"
end
test "does not authenticate if data is missing", %{conn: conn, user: user} do
_ = Accounts.generate_user_session_token(user)
conn = UserAuth.fetch_current_user(conn, [])
refute get_session(conn, :user_token)
refute conn.assigns.current_user
end
end
describe "on_mount :mount_current_user" do
test "assigns current_user based on a valid user_token", %{conn: conn, user: user} do
user_token = Accounts.generate_user_session_token(user)
session = conn |> put_session(:user_token, user_token) |> get_session()
{:cont, updated_socket} =
UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{})
assert updated_socket.assigns.current_user.id == user.id
end
test "assigns nil to current_user assign if there isn't a valid user_token", %{conn: conn} do
user_token = "invalid_token"
session = conn |> put_session(:user_token, user_token) |> get_session()
{:cont, updated_socket} =
UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{})
assert updated_socket.assigns.current_user == nil
end
test "assigns nil to current_user assign if there isn't a user_token", %{conn: conn} do
session = get_session(conn)
{:cont, updated_socket} =
UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{})
assert updated_socket.assigns.current_user == nil
end
end
describe "on_mount :ensure_authenticated" do
test "authenticates current_user based on a valid user_token", %{conn: conn, user: user} do
user_token = Accounts.generate_user_session_token(user)
session = conn |> put_session(:user_token, user_token) |> get_session()
{:cont, updated_socket} =
UserAuth.on_mount(:ensure_authenticated, %{}, session, %LiveView.Socket{})
assert updated_socket.assigns.current_user.id == user.id
end
test "redirects to login page if there isn't a valid user_token", %{conn: conn} do
user_token = "invalid_token"
session = conn |> put_session(:user_token, user_token) |> get_session()
socket = %LiveView.Socket{
endpoint: Web.Endpoint,
assigns: %{__changed__: %{}, flash: %{}}
}
{:halt, updated_socket} = UserAuth.on_mount(:ensure_authenticated, %{}, session, socket)
assert updated_socket.assigns.current_user == nil
end
test "redirects to login page if there isn't a user_token", %{conn: conn} do
session = get_session(conn)
socket = %LiveView.Socket{
endpoint: Web.Endpoint,
assigns: %{__changed__: %{}, flash: %{}}
}
{:halt, updated_socket} = UserAuth.on_mount(:ensure_authenticated, %{}, session, socket)
assert updated_socket.assigns.current_user == nil
end
end
describe "on_mount :redirect_if_user_is_authenticated" do
test "redirects if there is an authenticated user ", %{conn: conn, user: user} do
user_token = Accounts.generate_user_session_token(user)
session = conn |> put_session(:user_token, user_token) |> get_session()
assert {:halt, _updated_socket} =
UserAuth.on_mount(
:redirect_if_user_is_authenticated,
%{},
session,
%LiveView.Socket{}
)
end
test "doesn't redirect if there is no authenticated user", %{conn: conn} do
session = get_session(conn)
assert {:cont, _updated_socket} =
UserAuth.on_mount(
:redirect_if_user_is_authenticated,
%{},
session,
%LiveView.Socket{}
)
end
end
describe "redirect_if_user_is_authenticated/2" do
test "redirects if user is authenticated", %{conn: conn, user: user} do
conn = conn |> assign(:current_user, user) |> UserAuth.redirect_if_user_is_authenticated([])
assert conn.halted
assert redirected_to(conn) == ~p"/"
end
test "does not redirect if user is not authenticated", %{conn: conn} do
conn = UserAuth.redirect_if_user_is_authenticated(conn, [])
refute conn.halted
refute conn.status
end
end
describe "require_authenticated_user/2" do
test "redirects if user is not authenticated", %{conn: conn} do
conn = conn |> fetch_flash() |> UserAuth.require_authenticated_user([])
assert conn.halted
assert redirected_to(conn) == ~p"/admin/users/log_in"
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
"You must log in to access this page."
end
test "stores the path to redirect to on GET", %{conn: conn} do
halted_conn =
%{conn | path_info: ["foo"], query_string: ""}
|> fetch_flash()
|> UserAuth.require_authenticated_user([])
assert halted_conn.halted
assert get_session(halted_conn, :user_return_to) == "/foo"
halted_conn =
%{conn | path_info: ["foo"], query_string: "bar=baz"}
|> fetch_flash()
|> UserAuth.require_authenticated_user([])
assert halted_conn.halted
assert get_session(halted_conn, :user_return_to) == "/foo?bar=baz"
halted_conn =
%{conn | path_info: ["foo"], query_string: "bar", method: "POST"}
|> fetch_flash()
|> UserAuth.require_authenticated_user([])
assert halted_conn.halted
refute get_session(halted_conn, :user_return_to)
end
test "does not redirect if user is authenticated", %{conn: conn, user: user} do
conn = conn |> assign(:current_user, user) |> UserAuth.require_authenticated_user([])
refute conn.halted
refute conn.status
end
end
end