Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion lib/basenji/accounts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@ defmodule Basenji.Accounts do
import Basenji.ContextUtils
import Ecto.Query, warn: false

alias Basenji.Accounts.APIToken
alias Basenji.Accounts.User
alias Basenji.Accounts.UserNotifier
alias Basenji.Accounts.UserToken
alias Basenji.Repo

def register_user(attrs) do
%User{}
|> User.email_changeset(attrs)
|> User.changeset(attrs)
|> Repo.insert()
end

Expand Down Expand Up @@ -145,6 +146,37 @@ defmodule Basenji.Accounts do
:ok
end

def create_api_token(%User{} = user) do
{token, user_token} = APIToken.build_api_token(user)
Repo.insert!(user_token)
token
end

def verify_api_token(token) do
case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} ->
hashed_token = :crypto.hash(APIToken.hash_algorithm(), decoded_token)

from(t in APIToken,
where: t.token == ^hashed_token and t.inserted_at > ago(365, "day"),
join: user in assoc(t, :user),
select: user
)
|> Repo.one()

:error ->
:error
end
end

def delete_api_token(token) do
from(t in APIToken,
where: t.token == ^token
)
|> Repo.all()
|> Enum.each(&Repo.delete(&1))
end

defp update_user_and_delete_all_tokens(changeset) do
Repo.transact(fn ->
with {:ok, user} <- Repo.update(changeset) do
Expand Down
49 changes: 49 additions & 0 deletions lib/basenji/accounts/api_token.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
defmodule Basenji.Accounts.APIToken do
@moduledoc false
use Ecto.Schema

import Ecto.Changeset

alias Basenji.Accounts.APIToken
alias Basenji.Accounts.User

@attrs [:token, :user_id]

@hash_algorithm :sha256
@rand_size 32

schema "api_tokens" do
field :token, :binary
belongs_to :user, User

timestamps(type: :utc_datetime)
end

def changeset(api_token, attrs) do
api_token
|> cast(attrs, @attrs)
|> validate_changeset()
end

def build_api_token(user) do
build_hashed_token(user)
end

defp validate_changeset(changeset) do
changeset
|> validate_required([:token, :user_id])
end

defp build_hashed_token(user) do
token = :crypto.strong_rand_bytes(@rand_size)
hashed_token = :crypto.hash(@hash_algorithm, token)

{Base.url_encode64(token, padding: false),
%APIToken{
token: hashed_token,
user_id: user.id
}}
end

def hash_algorithm, do: @hash_algorithm
end
7 changes: 7 additions & 0 deletions lib/basenji/accounts/user.ex
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ defmodule Basenji.Accounts.User do
timestamps(type: :utc_datetime)
end

def changeset(user, attrs \\ %{}, opts \\ []) do
user
|> cast(attrs, [:email, :password, :confirmed_at, :hashed_password])
|> validate_email(opts)
|> validate_password(opts)
end

@doc """
A user changeset for registering or changing the email.

Expand Down
2 changes: 0 additions & 2 deletions lib/basenji/accounts/user_token.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ defmodule Basenji.Accounts.UserToken do
@hash_algorithm :sha256
@rand_size 32

# It is very important to keep the magic link token expiry short,
# since someone with access to the email may take over the account.
@magic_link_validity_in_minutes 15
@change_email_validity_in_days 7
@session_validity_in_days 14
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
defmodule BasenjiWeb.UserLive.Confirmation do
defmodule BasenjiWeb.Accounts.ConfirmationLive do
use BasenjiWeb, :live_view

alias Basenji.Accounts
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
defmodule BasenjiWeb.UserLive.Login do
defmodule BasenjiWeb.Accounts.LoginLive do
use BasenjiWeb, :live_view

alias Basenji.Accounts
Expand All @@ -12,23 +12,6 @@ defmodule BasenjiWeb.UserLive.Login do

def handle_event("submit_password", _params, socket), do: {:noreply, assign(socket, :trigger_submit, true)}

def handle_event("submit_magic", %{"user" => %{"email" => email}}, socket) do
with [user] <- Accounts.list_users(email: email) do
Accounts.deliver_login_instructions(
user,
&url(~p"/users/log-in/#{&1}")
)
end

info =
"If your email is in our system, you will receive instructions for logging in shortly."

socket
|> put_flash(:info, info)
|> push_navigate(to: ~p"/users/log-in")
|> then(&{:noreply, &1})
end

defp assign_form(socket) do
email =
Phoenix.Flash.get(socket.assigns.flash, :email) ||
Expand All @@ -38,8 +21,6 @@ defmodule BasenjiWeb.UserLive.Login do
assign(socket, form: form, trigger_submit: false)
end

defp local_mail_adapter?, do: Application.get_env(:basenji, Basenji.Mailer)[:adapter] == Local

def render(assigns) do
~H"""
<div class="mx-auto max-w-sm space-y-4">
Expand All @@ -59,40 +40,6 @@ defmodule BasenjiWeb.UserLive.Login do
</:subtitle>
</.header>
</div>

<div :if={local_mail_adapter?()} class="alert alert-info">
<.icon name="hero-information-circle" class="size-6 shrink-0" />
<div>
<p>You are running the local mail adapter.</p>
<p>
To see sent emails, visit <.link href="/dev/mailbox" class="underline">the mailbox page</.link>.
</p>
</div>
</div>

<.form
:let={f}
for={@form}
id="login_form_magic"
action={~p"/users/log-in"}
phx-submit="submit_magic"
>
<.input
readonly={!!@current_scope}
field={f[:email]}
type="email"
label="Email"
autocomplete="username"
required
phx-mounted={JS.focus()}
/>
<.button class="btn btn-primary w-full">
Log in with email <span aria-hidden="true">→</span>
</.button>
</.form>

<div class="divider">or</div>

<.form
:let={f}
for={@form}
Expand All @@ -116,10 +63,7 @@ defmodule BasenjiWeb.UserLive.Login do
autocomplete="current-password"
/>
<.button class="btn btn-primary w-full" name={@form[:remember_me].name} value="true">
Log in and stay logged in <span aria-hidden="true">→</span>
</.button>
<.button class="btn btn-primary btn-soft w-full mt-2">
Log in only this time
Log in
</.button>
</.form>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
defmodule BasenjiWeb.UserLive.Registration do
defmodule BasenjiWeb.Accounts.RegistrationLive do
use BasenjiWeb, :live_view

alias Basenji.Accounts
Expand All @@ -12,17 +12,15 @@ defmodule BasenjiWeb.UserLive.Registration do
end

def handle_event("save", %{"user" => user_params}, socket) do
user_params = Map.put(user_params, "confirmed_at", DateTime.utc_now())

case Accounts.register_user(user_params) do
{:ok, user} ->
with {:ok, _} <- Accounts.deliver_login_instructions(user, &url(~p"/users/log-in/#{&1}")) do
socket
|> put_flash(
:info,
"An email was sent to #{user.email}, please access it to confirm your account."
)
|> push_navigate(to: ~p"/users/log-in")
|> then(&{:noreply, &1})
end
IO.inspect(user)

socket
|> push_navigate(to: ~p"/users/log-in")
|> then(&{:noreply, &1})

{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign_form(socket, changeset)}
Expand Down Expand Up @@ -64,6 +62,15 @@ defmodule BasenjiWeb.UserLive.Registration do
phx-mounted={JS.focus()}
/>

<.input
field={@form[:password]}
type="password"
label="Password"
autocomplete="password"
required
phx-mounted={JS.focus()}
/>

<.button phx-disable-with="Creating account..." class="btn btn-primary w-full">
Create an account
</.button>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
defmodule BasenjiWeb.UserLive.Settings do
defmodule BasenjiWeb.Accounts.SettingsLive do
use BasenjiWeb, :live_view

alias Basenji.Accounts
Expand Down
10 changes: 5 additions & 5 deletions lib/basenji_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,8 @@ defmodule BasenjiWeb.Router do

live_session :require_authenticated_user,
on_mount: [{BasenjiWeb.UserAuth, :require_authenticated}] do
live "/users/settings", UserLive.Settings, :edit
live "/users/settings/confirm-email/:token", UserLive.Settings, :confirm_email
live "/users/settings", Accounts.SettingsLive, :edit
live "/users/settings/confirm-email/:token", Accounts.SettingsLive, :confirm_email
end

post "/users/update-password", UserSessionController, :update_password
Expand All @@ -131,9 +131,9 @@ defmodule BasenjiWeb.Router do

live_session :current_user,
on_mount: [{BasenjiWeb.UserAuth, :mount_current_scope}] do
live "/users/register", UserLive.Registration, :new
live "/users/log-in", UserLive.Login, :new
live "/users/log-in/:token", UserLive.Confirmation, :new
live "/users/register", Accounts.RegistrationLive, :new
live "/users/log-in", Accounts.LoginLive, :new
live "/users/log-in/:token", Accounts.ConfirmationLive, :new
end

post "/users/log-in", UserSessionController, :create
Expand Down
12 changes: 12 additions & 0 deletions priv/repo/migrations/20250816193833_api_tokens.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
defmodule Basenji.Repo.Migrations.ApiTokens do
use Ecto.Migration

def change do
create table(:api_tokens) do
add :user_id, references(:users, on_delete: :delete_all), null: false
add :token, :binary, null: false

timestamps(type: :utc_datetime)
end
end
end
Loading
Loading