Implement account provisioning and authentication mechanism

This commit is contained in:
Adrian Gruntkowski 2024-11-29 20:54:49 +01:00
parent b4f0175166
commit e55d35d122
16 changed files with 357 additions and 119 deletions

View file

@ -1,5 +1,11 @@
import Config
config :jenot, host: System.fetch_env!("HOST")
config :jenot, secret_key_base: System.fetch_env!("SECRET_KEY_BASE")
config :jenot, secure_cookie: System.fetch_env!("SECURE_COOKIE") == "true"
config :web_push_elixir,
vapid_public_key: System.fetch_env!("VAPID_PUBLIC_KEY"),
vapid_private_key: System.fetch_env!("VAPID_PRIVATE_KEY"),

View file

@ -1,3 +1,5 @@
import Config
config :jenot, Jenot.Repo, database: :memory
config :bcrypt_elixir, :log_rounds, 4

View file

@ -1,18 +1,9 @@
defmodule Jenot do
@moduledoc """
Documentation for `Jenot`.
Note taking app. Spelled ye-not.
"""
@doc """
Hello world.
## Examples
iex> Jenot.hello()
:world
"""
def hello do
:world
def host() do
Application.fetch_env!(:jenot, :host)
end
end

View file

@ -3,14 +3,45 @@ defmodule Jenot.Account do
import Ecto.Changeset
@code_length 10
@primary_key {:id, :binary_id, autogenerate: true}
schema "accounts" do
field :name, :string
field :code_digest, :string
timestamps(type: :utc_datetime_usec)
end
def new() do
def new(code) do
id = Ecto.UUID.generate()
%__MODULE__{}
|> change()
|> put_change(:id, Ecto.UUID.generate())
|> put_change(:id, id)
|> put_change(:name, Hahash.name(id))
|> put_change(:code_digest, hash(code))
end
def match?(account, input_code) do
Bcrypt.verify_pass(input_code, account.code_digest)
end
@safe_disambiguations %{
"O" => "8",
"I" => "7"
}
def generate_code() do
:crypto.strong_rand_bytes(6)
|> Base.encode32(padding: false)
|> String.replace(
Map.keys(@safe_disambiguations),
&Map.fetch!(@safe_disambiguations, &1)
)
end
defp hash(code) when byte_size(code) == @code_length do
Bcrypt.hash_pwd_salt(code)
end
end

View file

@ -2,7 +2,100 @@ defmodule Jenot.Accounts do
alias Jenot.Account
alias Jenot.Repo
def get() do
Repo.one(Account)
@cookie_name "jenot"
@cookie_seconds 30 * 24 * 60 * 60
def get_by_cookie(conn) do
with {:ok, %{account_id: id}} <- get_cookie(conn) do
get_by_id(id)
end
end
def authenticate(name, code) do
with {:ok, account} <- get_by_name(name),
:ok <- validate_code(account, code) do
{:ok, account}
end
end
def new() do
code = Account.generate_code()
account = Account.new(code)
case Repo.insert(account) do
{:ok, account} -> {:ok, %{code: code, account: account}}
{:error, _} -> {:error, :account_creation_failed}
end
end
def set_cookie(conn, account_id) do
conn
|> Plug.Conn.put_resp_cookie(@cookie_name, %{account_id: account_id},
domain: Jenot.host(),
secure: secure_cookie(),
encrypt: true,
max_age: @cookie_seconds,
same_site: "Strict"
)
|> Plug.Conn.put_resp_cookie("#{@cookie_name}_pub", "true",
domain: Jenot.host(),
http_only: false,
max_age: @cookie_seconds,
same_site: "Strict"
)
end
def clear_cookie(conn) do
Plug.Conn.delete_resp_cookie(conn, @cookie_name,
domain: Jenot.host(),
secure: secure_cookie(),
encrypt: true,
max_age: @cookie_seconds,
same_site: "Strict"
)
|> Plug.Conn.delete_resp_cookie("#{@cookie_name}_pub",
domain: Jenot.host(),
http_only: false,
max_age: @cookie_seconds,
same_site: "Strict"
)
end
defp get_by_name(name) do
case Repo.get_by(Account, name: name) do
nil ->
# dummy calculation to prevent timing attacks
Bcrypt.no_user_verify()
{:error, :account_not_found}
account ->
{:ok, account}
end
end
defp validate_code(account, code) do
if Account.match?(account, code) do
:ok
else
{:error, :account_not_found}
end
end
defp get_cookie(conn) do
conn
|> Plug.Conn.fetch_cookies(encrypted: [@cookie_name])
|> Map.fetch!(:cookies)
|> Map.fetch(@cookie_name)
end
defp get_by_id(id) do
case Repo.get(Account, id) do
nil -> {:error, :account_not_found}
account -> {:ok, account}
end
end
def secure_cookie() do
Application.fetch_env!(:jenot, :secure_cookie)
end
end

View file

@ -0,0 +1,48 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Jenot</title>
<link rel="stylesheet" href="/style.css" />
<link
rel="apple-touch-icon"
sizes="180x180"
href="/img/apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/img/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/img/favicon-16x16.png"
/>
</head>
<body>
<div id="content">
<h1>Jenot</h1>
<%= if error do %>
<h2 style="color: red"><%= error %></h2>
<% end %>
<form action="/account/authenticate" method="post">
<fieldset>
<label for="account-name">Your account name:</label>
<input id="account-name" name="name" type="text" value="">
</fieldset>
<fieldset>
<label for="account-code">Your secret code:</label>
<input id="account-code" name="code" type="password" value="">
</fieldset>
<button type="submit">Log in</button>
</form>
<a href="/index.html">Back to Jenot</a>
</div>
</body>
</html>

View file

@ -0,0 +1,43 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Jenot</title>
<link rel="stylesheet" href="/style.css" />
<link
rel="apple-touch-icon"
sizes="180x180"
href="/img/apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/img/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/img/favicon-16x16.png"
/>
</head>
<body>
<div id="content">
<h1>Jenot</h1>
<form>
<fieldset disabled>
<label for="account-name">Your account name:</label>
<input id="account-name" type="text" value="<%= name %>">
</fieldset>
<fieldset disabled>
<label for="account-code">Your secret code:</label>
<input id="account-code" type="text" value="<%= code %>">
</fieldset>
</form>
<a href="/index.html?reset-meta">Open Jenot</a>
</div>
</body>
</html>

View file

@ -8,6 +8,18 @@ defmodule Jenot.Web do
alias Jenot.Accounts
alias Jenot.Notes
@template_dir "lib/jenot/templates"
@templates @template_dir
|> File.ls!()
|> Enum.map(fn file ->
{
String.replace_suffix(file, ".html.heex", ".html"),
File.read!(Path.join(@template_dir, file))
}
end)
|> Map.new()
if Mix.env() == :dev do
plug PlugLiveReload
end
@ -26,6 +38,8 @@ defmodule Jenot.Web do
pass: ["*/*"],
json_decoder: Jason
plug :put_secret_key_base
plug :match
plug :dispatch
@ -35,6 +49,47 @@ defmodule Jenot.Web do
|> put_resp_header("location", "/index.html")
end
post "/account/new" do
case Accounts.new() do
{:ok, %{code: code, account: account}} ->
conn
|> Accounts.set_cookie(account.id)
|> render("new_account.html", name: account.name, code: code)
{:error, _} ->
conn
|> Accounts.clear_cookie()
|> resp(:found, "")
|> put_resp_header("location", "/index.html")
end
end
get "/account/authenticate" do
render(conn, "account_authenticate.html", error: nil)
end
post "/account/authenticate" do
case Accounts.authenticate(conn.params["name"], conn.params["code"]) do
{:ok, account} ->
conn
|> Accounts.set_cookie(account.id)
|> resp(:found, "")
|> put_resp_header("location", "/index.html?reset-meta")
{:error, _} ->
conn
|> put_status(422)
|> render("account_authenticate.html", error: "Account not found")
end
end
post "/account/logout" do
conn
|> Accounts.clear_cookie()
|> resp(:found, "")
|> put_resp_header("location", "/index.html?reset-meta")
end
get "/api" do
send_resp(conn, 200, """
Jenot API says hi!
@ -42,13 +97,13 @@ defmodule Jenot.Web do
end
get "/api/latest" do
account = Accounts.get()
{:ok, account} = Accounts.get_by_cookie(conn)
send_resp(conn, 200, Jason.encode!(%{notes: Notes.latest_change(account)}))
end
get "/api/notes" do
account = Accounts.get()
{:ok, account} = Accounts.get_by_cookie(conn)
notes =
account
@ -59,7 +114,7 @@ defmodule Jenot.Web do
end
get "/api/notes/:internal_id" do
account = Accounts.get()
{:ok, account} = Accounts.get_by_cookie(conn)
case Notes.note_by_internal_id(account, internal_id) do
{:ok, note} ->
@ -72,7 +127,7 @@ defmodule Jenot.Web do
end
post "/api/notes" do
account = Accounts.get()
{:ok, account} = Accounts.get_by_cookie(conn)
case Notes.add(account, conn.params) do
{:ok, note} ->
@ -84,7 +139,7 @@ defmodule Jenot.Web do
end
put "/api/notes/:internal_id" do
account = Accounts.get()
{:ok, account} = Accounts.get_by_cookie(conn)
case Notes.update(account, internal_id, conn.params) do
{:ok, note} ->
@ -99,7 +154,7 @@ defmodule Jenot.Web do
end
delete "/api/notes/:internal_id" do
account = Accounts.get()
{:ok, account} = Accounts.get_by_cookie(conn)
:ok = Notes.delete(account, internal_id)
@ -126,4 +181,17 @@ defmodule Jenot.Web do
defp service_worker_header(conn, _opts) do
put_resp_header(conn, "service-worker-allowed", "/")
end
defp render(%{status: status} = conn, template, assigns) do
body =
@templates
|> Map.fetch!(template)
|> EEx.eval_string(assigns)
send_resp(conn, status || 200, body)
end
def put_secret_key_base(conn, _) do
put_in(conn.secret_key_base, Application.fetch_env!(:jenot, :secret_key_base))
end
end

View file

@ -37,6 +37,8 @@ defmodule Jenot.MixProject do
[
{:burrito, "~> 1.0"},
{:bandit, "~> 1.0"},
{:bcrypt_elixir, "~> 3.0"},
{:hahash, "~> 0.2.0"},
{:web_push_elixir, "~> 0.4.0"},
{:ecto_sqlite3, "~> 0.17"},
{:plug_live_reload, "~> 0.1.0", only: :dev}

View file

@ -1,8 +1,10 @@
%{
"bandit": {:hex, :bandit, "1.5.7", "6856b1e1df4f2b0cb3df1377eab7891bec2da6a7fd69dc78594ad3e152363a50", [:mix], [{:hpax, "~> 1.0.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", "f2dd92ae87d2cbea2fa9aa1652db157b6cba6c405cb44d4f6dd87abba41371cd"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.2.0", "feab711974beba4cb348147170346fe097eea2e840db4e012a145e180ed4ab75", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "563e92a6c77d667b19c5f4ba17ab6d440a085696bdf4c68b9b0f5b30bc5422b8"},
"burrito": {:hex, :burrito, "1.2.0", "88f973469edcb96bd984498fb639d3fc4dbf01b52baab072b40229f03a396789", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:req, ">= 0.4.0", [hex: :req, repo: "hexpm", optional: false]}, {:typed_struct, "~> 0.2.0 or ~> 0.3.0", [hex: :typed_struct, repo: "hexpm", optional: false]}], "hexpm", "7e22158023c6558de615795ab135d27f0cbd9a0602834e3e474fe41b448afba9"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.10", "47c9c08d8869cf09b41da36538f62bc1abd3e19e41701c2cea2675b53c704258", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f6e046254e53cd6b41c6bacd70ae728011aa82b2742a80d6e2214855c6e06b22"},
"certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"},
"comeonin": {:hex, :comeonin, "5.5.0", "364d00df52545c44a139bad919d7eacb55abf39e86565878e17cebb787977368", [:mix], [], "hexpm", "6287fc3ba0aad34883cbe3f7949fc1d1e738e5ccdce77165bc99490aa69f47fb"},
"cowboy": {:hex, :cowboy, "2.12.0", "f276d521a1ff88b2b9b4c54d0e753da6c66dd7be6c9fca3d9418b561828a3731", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "8a7abe6d183372ceb21caa2709bec928ab2b72e18a3911aa1771639bef82651e"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
"cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"},
@ -16,6 +18,7 @@
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
"finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"},
"hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"},
"hahash": {:hex, :hahash, "0.2.0", "5d21c44b1b65d1f591adfd20ea4720e8a81db3caa377c28b20b434b8afc2115f", [:mix], [], "hexpm", "c39ce3f6163bbcf668e7a0bef88b608a9f37f99fcf181e7a17e4d10d02a5fbbd"},
"hpax": {:hex, :hpax, "1.0.0", "28dcf54509fe2152a3d040e4e3df5b265dcb6cb532029ecbacf4ce52caea3fd2", [:mix], [], "hexpm", "7f1314731d711e2ca5fdc7fd361296593fc2542570b3105595bb0bc6d0fad601"},
"httpoison": {:hex, :httpoison, "2.2.1", "87b7ed6d95db0389f7df02779644171d7319d319178f6680438167d7b69b1f3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "51364e6d2f429d80e14fe4b5f8e39719cacd03eb3f9a9286e61e216feac2d2df"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},

View file

@ -4,6 +4,8 @@ defmodule Jenot.Repo.Migrations.InitialSchema do
def change do
create table(:accounts, primary_key: false) do
add :id, :uuid, primary_key: true
add :name, :text, null: false
add :code_digest, :text, null: false
timestamps(type: :datetime_usec)
end

View file

@ -1,16 +0,0 @@
defmodule Jenot.Repo.Migrations.AddDefaultAccount do
use Ecto.Migration
def change do
id = Ecto.UUID.generate()
now = DateTime.utc_now() |> DateTime.to_iso8601()
execute """
INSERT INTO accounts (id, inserted_at, updated_at)
VALUES ('#{id}', '#{now}', '#{now}')
""",
"""
DELETE FROM accounts
"""
end
end

View file

@ -30,6 +30,13 @@
<div id="content">
<h1>Jenot</h1>
<a id="login-link" class="hidden" href="/account/authenticate">Log in</a>
<form id="new-account-form" class="hidden" action="/account/new" method="post">
<button type="submit">New account</button>
</form>
<form id="logout-form" class="hidden" action="/account/logout" method="post">
<button type="submit">Log out</button>
</form>
<button id="enable-notifications" class="hidden"">Enable notifications</button>
<button id="test-notifications">Send test notification</button>

View file

@ -1,6 +1,5 @@
import "./service-worker-init.js";
import { renderText } from "./dom.js";
import { LocalNoteStore } from "./local-store.js";
import { SyncedNoteStore } from "./synced-store.js";
import {
authorizeNotifications,
@ -11,12 +10,25 @@ import "./components.js";
const URL_PARAMS = new URLSearchParams(window.location.search);
// Cookie presence determines login state
const isLoggedIn = !!document.cookie
.split("; ")
.find((row) => row.startsWith("jenot_pub="));
// Notes storage configuration.
// Currently supports either simple, local storage based implementation
// and a more elaborate one, using a combination of IndexedDB + network sync.
const Notes = URL_PARAMS.has("localStorage")
? new LocalNoteStore("jenot-app")
: new SyncedNoteStore("jenot-app", "notes", "/");
// The storage is a combination of IndexedDB + network sync.
// Network sync is only enabled is user is logged in.
const Notes = new SyncedNoteStore("jenot-app", "notes", isLoggedIn && "/");
// Reset metadata to force full sync
if (URL_PARAMS.has("reset-meta")) {
history.replaceState(
null,
"",
location.href.replace("&reset-meta", "").replace("?reset-meta", ""),
);
await Notes.setMeta({ lastSync: null });
}
// Very rudimentary periodic sync. It will be refactored into a more real-time
// solution using either websocket of long-polling, so that server can notify about
@ -26,7 +38,22 @@ const sync = async () => {
Notes.saveStorage();
};
if (isLoggedIn) {
setInterval(sync, 5000);
}
// New account provisioning and login/logout actions
const newAccountForm = document.querySelector("#new-account-form");
const loginLink = document.querySelector("#login-link");
const logoutForm = document.querySelector("#logout-form");
if (!isLoggedIn) {
loginLink.classList.remove("hidden");
newAccountForm.classList.remove("hidden");
} else {
logoutForm.classList.remove("hidden");
}
// Notifications API test - to be reused for push notifications later on
@ -58,7 +85,9 @@ Notes.addEventListener("save", render.bind(this));
// Initial notes render and initial sync.
render();
if (isLoggedIn) {
sync();
}
// note-form component specific event handlers
newNote.addEventListener("addNote", async (e) => {

View file

@ -1,71 +0,0 @@
export class LocalNoteStore extends EventTarget {
localStorageKey;
notes = [];
/*
Note structure:
- id - unique note ID
- type - either `note` or `tasklist`
- content - note's content
- created - timestamp
- updated - timestamp
*/
constructor(localStorageKey) {
super();
this.localStorageKey = localStorageKey;
this.#readStorage();
// handle notes edited in another window
window.addEventListener(
"storage",
() => {
this.#readStorage();
this.saveStorage();
},
false,
);
}
all = () => this.notes;
get = (id) => this.notes.find((note) => note.id === id);
add(note) {
const now = Date.now();
this.notes.unshift({
id: "id_" + now,
type: note.type,
content: note.content,
created: now,
updated: now,
});
}
remove({ id }) {
this.notes = this.notes.filter((note) => note.id !== id);
}
update(note) {
note.updated = Date.now();
this.notes = this.notes.map((n) => (n.id === note.id ? note : n));
}
sync() {}
saveStorage() {
window.localStorage.setItem(
this.localStorageKey + "_notes",
JSON.stringify(this.notes),
);
this.dispatchEvent(new CustomEvent("save"));
}
#readStorage() {
this.notes = JSON.parse(
window.localStorage.getItem(this.localStorageKey + "_notes") || "[]",
);
}
}

View file

@ -123,7 +123,7 @@ export class SyncedNoteStore extends EventTarget {
async setMeta(meta) {
meta.id = "meta";
this.update(meta, true);
return this.update(meta, true);
}
async add(note) {
@ -193,7 +193,7 @@ export class SyncedNoteStore extends EventTarget {
const lastSync = meta?.lastSync;
const currentSync = Date.now();
this.all(lastSync, true)
return this.all(lastSync, true)
.then((notes) => {
return Promise.all(notes.map((n) => that.webStore.add(n)));
})