From 092930a24fc827abab17596448a9ce8ae81e7dc4 Mon Sep 17 00:00:00 2001 From: bluepython508 Date: Sun, 5 Nov 2023 01:12:02 +0000 Subject: [PATCH] WebAuthN auth --- .gitignore | 1 + assets/js/app.js | 96 +++++++- assets/tailwind.config.js | 3 + config/config.exs | 9 - config/dev.exs | 3 +- config/prod.exs | 6 - config/runtime.exs | 17 -- config/test.exs | 31 +-- lib/sso_bsn/accounts.ex | 233 ++++++++++++++++++ lib/sso_bsn/accounts/user.ex | 60 +++++ lib/sso_bsn/accounts/user_key.ex | 30 +++ lib/sso_bsn/accounts/user_token.ex | 83 +++++++ lib/sso_bsn/application.ex | 5 +- lib/sso_bsn/mailer.ex | 3 - lib/sso_bsn_web/components/core_components.ex | 6 +- .../components/layouts/app.html.heex | 26 -- .../components/layouts/root.html.heex | 41 +++ .../controllers/page_html/home.html.heex | 221 ----------------- .../controllers/user_session_controller.ex | 26 ++ lib/sso_bsn_web/live/user_login_live.ex | 65 +++++ .../live/user_registration_live.ex | 108 ++++++++ lib/sso_bsn_web/live/user_settings_live.ex | 69 ++++++ lib/sso_bsn_web/router.ex | 36 ++- lib/sso_bsn_web/user_auth.ex | 227 +++++++++++++++++ mix.exs | 10 +- mix.lock | 6 + ...0231101173047_create_users_auth_tables.exs | 33 +++ .../controllers/error_html_test.exs | 14 -- .../controllers/error_json_test.exs | 12 - .../controllers/page_controller_test.exs | 8 - test/support/conn_case.ex | 38 --- test/support/data_case.ex | 58 ----- test/test_helper.exs | 2 - 33 files changed, 1123 insertions(+), 463 deletions(-) mode change 100644 => 120000 config/test.exs create mode 100644 lib/sso_bsn/accounts.ex create mode 100644 lib/sso_bsn/accounts/user.ex create mode 100644 lib/sso_bsn/accounts/user_key.ex create mode 100644 lib/sso_bsn/accounts/user_token.ex delete mode 100644 lib/sso_bsn/mailer.ex create mode 100644 lib/sso_bsn_web/controllers/user_session_controller.ex create mode 100644 lib/sso_bsn_web/live/user_login_live.ex create mode 100644 lib/sso_bsn_web/live/user_registration_live.ex create mode 100644 lib/sso_bsn_web/live/user_settings_live.ex create mode 100644 lib/sso_bsn_web/user_auth.ex create mode 100644 priv/repo/migrations/20231101173047_create_users_auth_tables.exs delete mode 100644 test/sso_bsn_web/controllers/error_html_test.exs delete mode 100644 test/sso_bsn_web/controllers/error_json_test.exs delete mode 100644 test/sso_bsn_web/controllers/page_controller_test.exs delete mode 100644 test/support/conn_case.ex delete mode 100644 test/support/data_case.ex delete mode 100644 test/test_helper.exs diff --git a/.gitignore b/.gitignore index 263ac5f..2f84867 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ npm-debug.log /.direnv /.nix-mix /.nix-hex +/.elixir_ls diff --git a/assets/js/app.js b/assets/js/app.js index df0cdd9..1bfdb65 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -22,8 +22,102 @@ import {Socket} from "phoenix" import {LiveSocket} from "phoenix_live_view" import topbar from "../vendor/topbar" +function base64ToArray(base64String) { + return Uint8Array.from(window.atob(base64String), (c) => c.charCodeAt(0)); +} + +function arrayToBase64(buffer) { + return window.btoa( + Array.from(new Uint8Array(buffer), (c) => String.fromCharCode(c)).join("") + ); +} + + +const registrationHook = { + mounted() { + this.handleEvent("registration-challenge", (event) => this.handleRegistration(event, this)) + }, + + async handleRegistration(event, context) { + try { + const { + attestation, + challenge, + rp, + user, + timeout, + excludeCredentials, + } = event; + user.id = base64ToArray(user.id) + excludeCredentials.forEach(cred => { + cred.id = base64ToArray(cred.id) + }) + const publicKey = { + attestation, + challenge: base64ToArray(challenge), + excludeCredentials, + pubKeyCredParams: [{ alg: -7, type: "public-key" }, { alg: -8, type: "public-key" }, { alg: -257, type: "public-key"}], + authenticatorSelection: { + authenticatorAttachement: "explicitly invalid, working around bitwarden", + residentKey: "discouraged" + }, + user, + timeout, + rp, + } + const credential = await navigator.credentials.create({ publicKey }) + context.pushEventTo(context.el, "registration-complete", { + attestation64: arrayToBase64(credential.response.attestationObject), + clientData: Array.from(new Uint8Array(credential.response.clientDataJSON)), + id: arrayToBase64(credential.rawId), + type: credential.type + }) + } catch (error) { + console.error(error) + const { message, name, stack } = error; + context.pushEventTo(context.el, "error", { message, name, stack }); + } + } +} + +const authenticationHook = { + mounted() { + this.handleEvent("authentication-challenge", (event) => this.handleAuthentication(event, this)) + }, + + async handleAuthentication(event, context) { + try { + const { + challenge, allowCredentials + } = event; + allowCredentials.forEach(cred => { + cred.id = base64ToArray(cred.id) + }) + const { type, response: { signature, authenticatorData, clientDataJSON, userHandle }, rawId } = await navigator.credentials.get({ + publicKey: { + challenge: base64ToArray(challenge), + allowCredentials, + timeout: 60000 + } + }) + context.pushEventTo(context.el, "authentication-credential", { + type: type, + id: arrayToBase64(rawId), + signature: arrayToBase64(signature), + authenticatorData: arrayToBase64(authenticatorData), + clientData: Array.from(new Uint8Array(clientDataJSON)), + userHandle: arrayToBase64(userHandle) + }) + } catch (error) { + console.error(error) + const { message, name, stack } = error; + context.pushEventTo(context.el, "error", { message, name, stack }); + } + } +} + let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") -let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}}) +let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}, hooks: { registrationHook, authenticationHook }}) // Show progress bar on live navigation and form submits topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js index fcea313..51f4ef1 100644 --- a/assets/tailwind.config.js +++ b/assets/tailwind.config.js @@ -15,6 +15,9 @@ module.exports = { extend: { colors: { brand: "#FD4F00", + }, + fontFamily: { + mono: ["monospace"] } }, }, diff --git a/config/config.exs b/config/config.exs index b30ae5d..0cf9c9a 100644 --- a/config/config.exs +++ b/config/config.exs @@ -22,15 +22,6 @@ config :sso_bsn, SsoBsnWeb.Endpoint, pubsub_server: SsoBsn.PubSub, live_view: [signing_salt: "sMW627iF"] -# Configures the mailer -# -# By default it uses the "Local" adapter which stores the emails -# locally. You can see the emails in your browser, at "/dev/mailbox". -# -# For production it's recommended to configure a different adapter -# at the `config/runtime.exs`. -config :sso_bsn, SsoBsn.Mailer, adapter: Swoosh.Adapters.Local - # Configure esbuild (the version is required) config :esbuild, version: "0.17.11", diff --git a/config/dev.exs b/config/dev.exs index 0427ef0..64a958f 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -75,5 +75,4 @@ config :phoenix, :plug_init_mode, :runtime # Include HEEx debug annotations as HTML comments in rendered markup config :phoenix_live_view, :debug_heex_annotations, true -# Disable swoosh api client as it is only required for production adapters. -config :swoosh, :api_client, false +config :wax_, origin: "http://localhost:4000", rp_id: :auto \ No newline at end of file diff --git a/config/prod.exs b/config/prod.exs index 5c29e0e..a3e245b 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -7,12 +7,6 @@ import Config # before starting your production server. config :sso_bsn, SsoBsnWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json" -# Configures Swoosh API Client -config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: SsoBsn.Finch - -# Disable Swoosh Local Memory Storage -config :swoosh, local: false - # Do not print debug messages in production config :logger, level: :info diff --git a/config/runtime.exs b/config/runtime.exs index 64fa62a..be64ab8 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -93,21 +93,4 @@ if config_env() == :prod do # # Check `Plug.SSL` for all available options in `force_ssl`. - # ## Configuring the mailer - # - # In production you need to configure the mailer to use a different adapter. - # Also, you may need to configure the Swoosh API client of your choice if you - # are not using SMTP. Here is an example of the configuration: - # - # config :sso_bsn, SsoBsn.Mailer, - # adapter: Swoosh.Adapters.Mailgun, - # api_key: System.get_env("MAILGUN_API_KEY"), - # domain: System.get_env("MAILGUN_DOMAIN") - # - # For this example you need include a HTTP client required by Swoosh API client. - # Swoosh supports Hackney and Finch out of the box: - # - # config :swoosh, :api_client, Swoosh.ApiClient.Hackney - # - # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details. end diff --git a/config/test.exs b/config/test.exs deleted file mode 100644 index 9df907f..0000000 --- a/config/test.exs +++ /dev/null @@ -1,30 +0,0 @@ -import Config - -# Configure your database -# -# The MIX_TEST_PARTITION environment variable can be used -# to provide built-in test partitioning in CI environment. -# Run `mix help test` for more information. -config :sso_bsn, SsoBsn.Repo, - database: Path.expand("../sso_bsn_test.db", Path.dirname(__ENV__.file)), - pool_size: 5, - pool: Ecto.Adapters.SQL.Sandbox - -# We don't run a server during test. If one is required, -# you can enable the server option below. -config :sso_bsn, SsoBsnWeb.Endpoint, - http: [ip: {127, 0, 0, 1}, port: 4002], - secret_key_base: "whk08+CMpyhXr7IdWuwHJXMq/y/hwMY10kThociBvOyaKrbMlCc554iSThQj16cs", - server: false - -# In test we don't send emails. -config :sso_bsn, SsoBsn.Mailer, adapter: Swoosh.Adapters.Test - -# Disable swoosh api client as it is only required for production adapters. -config :swoosh, :api_client, false - -# Print only warnings and errors during test -config :logger, level: :warning - -# Initialize plugs at runtime for faster test compilation -config :phoenix, :plug_init_mode, :runtime diff --git a/config/test.exs b/config/test.exs new file mode 120000 index 0000000..a7f4847 --- /dev/null +++ b/config/test.exs @@ -0,0 +1 @@ +./dev.exs \ No newline at end of file diff --git a/lib/sso_bsn/accounts.ex b/lib/sso_bsn/accounts.ex new file mode 100644 index 0000000..377a22b --- /dev/null +++ b/lib/sso_bsn/accounts.ex @@ -0,0 +1,233 @@ +defmodule SsoBsn.Accounts do + @moduledoc """ + The Accounts context. + """ + + import Ecto.Query, warn: false + alias SsoBsn.Repo + + alias SsoBsn.Accounts.{User, UserToken, UserKey} + + ## Database getters + + def reload_user(user), do: Repo.reload(user) |> Repo.preload(:keys) + + @doc """ + Gets a user by username. + + ## Examples + + iex> get_user_by_username("foo") + %User{} + + iex> get_user_by_username("unknown") + nil + + """ + def get_user_by_username(username) when is_binary(username) do + Repo.get_by(User, username: username) |> Repo.preload(:keys) + end + + @doc """ + Gets a single user. + + Raises `Ecto.NoResultsError` if the User does not exist. + + ## Examples + + iex> get_user!(123) + %User{} + + iex> get_user!(456) + ** (Ecto.NoResultsError) + + """ + def get_user!(id), do: Repo.get!(User, id) |> Repo.preload(:keys) + + ## User registration + + @doc """ + Registers a user. + + ## Examples + + iex> register_user(%{field: value}) + {:ok, %User{}} + + iex> register_user(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def register_user(attrs) do + %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: %User{}} + + """ + def change_user_registration(%User{} = user, attrs \\ %{}) do + User.registration_changeset(user, attrs) + end + + ## Settings + + @doc """ + Returns an `%Ecto.Changeset{}` for changing the user username. + + ## Examples + + iex> change_user_username(user) + %Ecto.Changeset{data: %User{}} + + """ + def change_user_username(user, attrs \\ %{}) do + User.username_changeset(user, attrs) + end + + ## Authentication + + def authentication_challenge(username \\ nil) do + allowed_credentials = + if username && String.trim(username) != "", + do: + get_user_by_username(username).keys |> Enum.map(&%{id: &1.key_id, type: "public-key"}), + else: [] + + challenge = Wax.new_authentication_challenge(allowed_credentials: allowed_credentials) + + {{username, challenge}, + %{ + challenge: Base.encode64(challenge.bytes, padding: false), + allowCredentials: allowed_credentials + }} + end + + def authenticate_user({username, challenge}, %{ + "clientData" => client_data_json, + "authenticatorData" => authenticator_data_b64, + "signature" => sig_b64, + "id" => credential_id, + "type" => "public-key", + "userHandle" => maybe_user_handle_b64 + }) do + authenticator_data_raw = Base.decode64!(authenticator_data_b64) + sig_raw = Base.decode64!(sig_b64) + + user = case Base.decode64(maybe_user_handle_b64) do + {:ok, <>} -> get_user!(user_id) + _ ->get_user_by_username(username) + end + + case Wax.authenticate( + credential_id, + authenticator_data_raw, + sig_raw, + client_data_json, + challenge, + user.keys + |> Enum.map(&{&1.key_id, &1.cose_key}) + ) do + {:ok, _result} -> {:ok, user} + {:error, error} -> {:error, error} + end + end + + ## Registration + + @doc """ + Begin registration of a new webauthn key. + """ + def registration_challenge(user) do + challenge = Wax.new_registration_challenge() + + {challenge, + %{ + attestation: "none", + challenge: Base.encode64(challenge.bytes, padding: false), + excludeCredentials: + (user |> Repo.preload([:keys])).keys |> Enum.map(&%{id: &1.key_id, type: "public-key"}), + rp: %{ + id: challenge.rp_id, + name: "BSN SSO" + }, + timeout: 60000, + user: %{ + id: Base.encode64(<>, padding: false), + name: user.username, + displayName: user.username + } + }} + end + + def register_key(user, challenge, %{ + "attestation64" => attestation_64, + "clientData" => client_data, + "id" => id, + "type" => "public-key" + }) do + attestation = Base.decode64!(attestation_64, padding: false) + + case Wax.register(attestation, client_data, challenge) do + {:ok, {authenticator_data, _result}} -> + user + |> User.add_key_changeset() + |> UserKey.new(%{ + key_id: id, + cose_key: authenticator_data.attested_credential_data.credential_public_key + }) + |> Repo.insert() + + {:error, e} -> + {:error, e} + 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 + {:ok, query} = UserToken.verify_session_token_query(token) + Repo.one(query) |> Repo.preload(:keys) + end + + @doc """ + Deletes the signed token with the given context. + """ + def delete_user_session_token(token) do + Repo.delete_all(UserToken.token_and_context_query(token, "session")) + :ok + end + + def generate_user_login_token(user) do + {token, user_token} = UserToken.build_login_token(user) + Repo.insert!(user_token) + token + end + + def consume_login_token(login_token) do + Repo.transaction(fn -> + user_token = UserToken.token_and_context_query(login_token, "login") |> Repo.one() |> Repo.preload(:user) + Repo.delete!(user_token) + user_token.user + end) + end +end diff --git a/lib/sso_bsn/accounts/user.ex b/lib/sso_bsn/accounts/user.ex new file mode 100644 index 0000000..54054cd --- /dev/null +++ b/lib/sso_bsn/accounts/user.ex @@ -0,0 +1,60 @@ +defmodule SsoBsn.Accounts.User do + use Ecto.Schema + import Ecto.Changeset + + alias SsoBsn.Accounts.UserKey + + schema "users" do + field :username, :string + field :confirmed_at, :naive_datetime + + has_many :keys, UserKey + + timestamps(type: :utc_datetime) + end + + @doc """ + A user changeset for registration. + """ + def registration_changeset(user, attrs, _opts \\ []) do + user + |> cast(attrs, [:username]) + |> validate_username() + end + + def validate_username(changeset) do + changeset + |> validate_format(:username, ~r/^[a-zA-Z_0-9.-]+$/) + |> validate_length(:username, min: 3, max: 128) + |> unique_constraint(:username) + 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() + |> case do + %{changes: %{username: _}} = changeset -> changeset + %{} = changeset -> add_error(changeset, :username, "did not change") + end + end + + @doc """ + Confirms the account by setting `confirmed_at`. + """ + def confirm_changeset(user) do + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + change(user, confirmed_at: now) + end + + + def add_key_changeset(user) do + user + |> Ecto.build_assoc(:keys) + end +end diff --git a/lib/sso_bsn/accounts/user_key.ex b/lib/sso_bsn/accounts/user_key.ex new file mode 100644 index 0000000..305eea8 --- /dev/null +++ b/lib/sso_bsn/accounts/user_key.ex @@ -0,0 +1,30 @@ +defmodule SsoBsn.Accounts.UserKey do + use Ecto.Schema + import Ecto.Changeset + + alias SsoBsn.Accounts.User + + defmodule BinaryTerm do + use Ecto.Type + def type, do: :binary + + def cast(term), do: {:ok, term} + def load(data) when is_binary(data), do: {:ok, :erlang.binary_to_term(data)} + def dump(term), do: {:ok, :erlang.term_to_binary(term)} |> dbg() + end + alias SsoBsn.Accounts.UserKey.BinaryTerm + + schema "users_keys" do + field :key_id, :string + field :cose_key, BinaryTerm + + belongs_to :user, User + end + + def new(key, attrs) do + key + |> cast(attrs, [:key_id, :cose_key]) + end + +end + diff --git a/lib/sso_bsn/accounts/user_token.ex b/lib/sso_bsn/accounts/user_token.ex new file mode 100644 index 0000000..6802d83 --- /dev/null +++ b/lib/sso_bsn/accounts/user_token.ex @@ -0,0 +1,83 @@ +defmodule SsoBsn.Accounts.UserToken do + use Ecto.Schema + import Ecto.Query + alias SsoBsn.Accounts.UserToken + + @rand_size 32 + + @session_validity_in_days 60 + + schema "users_tokens" do + field :token, :binary + field :context, :string + field :sent_to, :string + belongs_to :user, SsoBsn.Accounts.User + + timestamps(updated_at: false) + end + + @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, %UserToken{token: token, context: "session", user_id: user.id}} + end + + def build_login_token(user) do + token = Base.encode64(:crypto.strong_rand_bytes(@rand_size)) + {token, %UserToken{token: token, context: "login", user_id: user.id}} + 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 verify_session_token_query(token) do + query = + from token in token_and_context_query(token, "session"), + join: user in assoc(token, :user), + where: token.inserted_at > ago(@session_validity_in_days, "day"), + select: user + + {:ok, query} + end + + @doc """ + Returns the token struct for the given token value and context. + """ + def token_and_context_query(token, context) do + from UserToken, where: [token: ^token, context: ^context] + end + + @doc """ + Gets all tokens for the given user for the given contexts. + """ + def user_and_contexts_query(user, :all) do + from t in UserToken, where: t.user_id == ^user.id + end + + def user_and_contexts_query(user, [_ | _] = contexts) do + from t in UserToken, where: t.user_id == ^user.id and t.context in ^contexts + end +end diff --git a/lib/sso_bsn/application.ex b/lib/sso_bsn/application.ex index af2766a..86cce53 100644 --- a/lib/sso_bsn/application.ex +++ b/lib/sso_bsn/application.ex @@ -11,12 +11,9 @@ defmodule SsoBsn.Application do SsoBsnWeb.Telemetry, SsoBsn.Repo, {Ecto.Migrator, - repos: Application.fetch_env!(:sso_bsn, :ecto_repos), - skip: skip_migrations?()}, + repos: Application.fetch_env!(:sso_bsn, :ecto_repos), skip: skip_migrations?()}, {DNSCluster, query: Application.get_env(:sso_bsn, :dns_cluster_query) || :ignore}, {Phoenix.PubSub, name: SsoBsn.PubSub}, - # Start the Finch HTTP client for sending emails - {Finch, name: SsoBsn.Finch}, # Start a worker by calling: SsoBsn.Worker.start_link(arg) # {SsoBsn.Worker, arg}, # Start to serve requests, typically the last entry diff --git a/lib/sso_bsn/mailer.ex b/lib/sso_bsn/mailer.ex deleted file mode 100644 index 651bc71..0000000 --- a/lib/sso_bsn/mailer.ex +++ /dev/null @@ -1,3 +0,0 @@ -defmodule SsoBsn.Mailer do - use Swoosh.Mailer, otp_app: :sso_bsn -end diff --git a/lib/sso_bsn_web/components/core_components.ex b/lib/sso_bsn_web/components/core_components.ex index 6d4cd78..261b93a 100644 --- a/lib/sso_bsn_web/components/core_components.ex +++ b/lib/sso_bsn_web/components/core_components.ex @@ -115,7 +115,7 @@ defmodule SsoBsnWeb.CoreComponents do phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")} role="alert" class={[ - "fixed top-2 right-2 mr-2 w-80 sm:w-96 z-50 rounded-lg p-3 ring-1", + "fixed bottom-2 right-2 mr-2 w-80 sm:w-96 z-50 rounded-lg p-3 ring-1", @kind == :info && "bg-emerald-50 text-emerald-800 ring-emerald-500 fill-cyan-900", @kind == :error && "bg-rose-50 text-rose-900 shadow-md ring-rose-500 fill-rose-900" ]} @@ -147,8 +147,8 @@ defmodule SsoBsnWeb.CoreComponents do def flash_group(assigns) do ~H"""
- <.flash kind={:info} title="Success!" flash={@flash} /> - <.flash kind={:error} title="Error!" flash={@flash} /> + <.flash kind={:info} title="Success!" flash={@flash} id={"#{@id}-info"} /> + <.flash kind={:error} title="Error!" flash={@flash} id={"#{@id}-error"} /> <.flash id="client-error" kind={:error} diff --git a/lib/sso_bsn_web/components/layouts/app.html.heex b/lib/sso_bsn_web/components/layouts/app.html.heex index e23bfc8..fec9a04 100644 --- a/lib/sso_bsn_web/components/layouts/app.html.heex +++ b/lib/sso_bsn_web/components/layouts/app.html.heex @@ -1,29 +1,3 @@ -
-
-
- - - -

- v<%= Application.spec(:phoenix, :vsn) %> -

-
- -
-
<.flash_group flash={@flash} /> diff --git a/lib/sso_bsn_web/components/layouts/root.html.heex b/lib/sso_bsn_web/components/layouts/root.html.heex index bc3f4cd..998c0c2 100644 --- a/lib/sso_bsn_web/components/layouts/root.html.heex +++ b/lib/sso_bsn_web/components/layouts/root.html.heex @@ -12,6 +12,47 @@ +
    + <%= if @current_user do %> +
  • + <%= @current_user.username %> +
  • +
  • + <.link + href={~p"/users/settings"} + class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700" + > + Settings + +
  • +
  • + <.link + href={~p"/users/log_out"} + method="delete" + class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700" + > + Log out + +
  • + <% else %> +
  • + <.link + href={~p"/users/register"} + class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700" + > + Register + +
  • +
  • + <.link + href={~p"/users/log_in"} + class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700" + > + Log in + +
  • + <% end %> +
<%= @inner_content %> diff --git a/lib/sso_bsn_web/controllers/page_html/home.html.heex b/lib/sso_bsn_web/controllers/page_html/home.html.heex index e9fc48d..637ed6e 100644 --- a/lib/sso_bsn_web/controllers/page_html/home.html.heex +++ b/lib/sso_bsn_web/controllers/page_html/home.html.heex @@ -1,222 +1 @@ <.flash_group flash={@flash} /> - -
-
- -

- Phoenix Framework - - v<%= Application.spec(:phoenix, :vsn) %> - -

-

- Peace of mind from prototype to production. -

-

- Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale. -

- -
-
diff --git a/lib/sso_bsn_web/controllers/user_session_controller.ex b/lib/sso_bsn_web/controllers/user_session_controller.ex new file mode 100644 index 0000000..0be717b --- /dev/null +++ b/lib/sso_bsn_web/controllers/user_session_controller.ex @@ -0,0 +1,26 @@ +defmodule SsoBsnWeb.UserSessionController do + use SsoBsnWeb, :controller + + alias SsoBsn.Accounts + alias SsoBsnWeb.UserAuth + + def login(conn, %{"token" => login_token}) do + case Accounts.consume_login_token(login_token) do + {:ok, user} -> + conn + |> UserAuth.log_in_user(user) # TODO: pass through remember-me value? + |> redirect(to: ~p"/users/settings") + {:error, error} -> + dbg(error) + conn + |> put_flash(:error, "Invalid login token: #{inspect(error)}") + |> redirect(to: ~p"/users/log_in") + end + end + + def delete(conn, _params) do + conn + |> put_flash(:info, "Logged out successfully.") + |> UserAuth.log_out_user() + end +end diff --git a/lib/sso_bsn_web/live/user_login_live.ex b/lib/sso_bsn_web/live/user_login_live.ex new file mode 100644 index 0000000..000f3fd --- /dev/null +++ b/lib/sso_bsn_web/live/user_login_live.ex @@ -0,0 +1,65 @@ +defmodule SsoBsnWeb.UserLoginLive do + use SsoBsnWeb, :live_view + + alias SsoBsn.Accounts + + def render(assigns) do + ~H""" +
+ <.header class="text-center"> + 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" + phx-submit="login" + phx-hook="authenticationHook" + > + <.input field={@form[:username]} type="text" label="Username" /> + + <:actions> + <.input field={@form[:remember_me]} type="checkbox" label="Keep me logged in" /> + + <:actions> + <.button phx-disable-with="Signing in..." class="w-full" disabled={@authenticating}> + Sign in + + + +
+ """ + end + + def mount(_params, _session, socket) do + {:ok, socket |> assign(form: to_form(%{"username" => "", "remember_me" => false}), authenticating: false)} + end + + def handle_event("login", %{"username" => username}, socket) do + {challenge, challenge_client} = Accounts.authentication_challenge(username) + + {:noreply, + socket + |> assign(challenge: challenge, authenticating: true) + |> push_event("authentication-challenge", challenge_client)} + end + + def handle_event("authentication-credential", params, socket) do + case Accounts.authenticate_user(socket.assigns.challenge, params) do + {:ok, user} -> + login_token = Accounts.generate_user_login_token(user) + {:noreply, socket |> redirect(to: ~p"/users/log_in/#{login_token}")} + {:error, error} -> + {:noreply, socket |> put_flash(:error, inspect(error))} + end + end +end diff --git a/lib/sso_bsn_web/live/user_registration_live.ex b/lib/sso_bsn_web/live/user_registration_live.ex new file mode 100644 index 0000000..32ff305 --- /dev/null +++ b/lib/sso_bsn_web/live/user_registration_live.ex @@ -0,0 +1,108 @@ +defmodule SsoBsnWeb.UserRegistrationLive do + use SsoBsnWeb, :live_view + + alias SsoBsn.Accounts + alias SsoBsn.Accounts.User + + def render(assigns) do + ~H""" +
+ <.header class="text-center"> + Register for an account + <:subtitle> + Already registered? + <.link navigate={~p"/users/log_in"} class="font-semibold text-brand hover:underline"> + Sign in + + to your account now. + + + + <.simple_form + for={@form} + id="registration_form" + phx-submit="save" + phx-change="validate" + phx-hook="registrationHook" + phx-trigger-action={@trigger_submit} + action={~p"/users/log_in?_action=registered"} + method="post" + > + <.error :if={@check_errors}> + Oops, something went wrong! Please check the errors below. + + + <.input field={@form[:username]} type="text" label="Username" required /> + + <:actions> + <.button phx-disable-with="Creating account..." class="w-full">Create an account + + +
+ """ + end + + def mount(_params, _session, socket) do + changeset = Accounts.change_user_registration(%User{}) + + socket = + socket + |> assign(trigger_submit: false, check_errors: false) + |> assign_form(changeset) + + {:ok, socket, temporary_assigns: [form: nil]} + end + + def handle_event("save", %{"user" => user_params}, socket) do + case Accounts.register_user(user_params) do + {:ok, user} -> + changeset = Accounts.change_user_registration(user) + {challenge, challenge_client} = Accounts.registration_challenge(user) + + { + :noreply, + socket + # |> assign(trigger_submit: true) + |> assign_form(changeset) + |> assign(user: user) + |> assign(challenge: challenge) + |> push_event("registration-challenge", challenge_client) + } + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, socket |> assign(check_errors: true) |> assign_form(changeset)} + end + end + + def handle_event("validate", %{"user" => user_params}, socket) do + changeset = Accounts.change_user_registration(%User{}, user_params) + {:noreply, assign_form(socket, Map.put(changeset, :action, :validate))} + end + + def handle_event("registration-complete", params, socket) do + %{user: user, challenge: challenge} = socket.assigns + + case Accounts.register_key(user, challenge, params) do + {:ok, _key} -> + login_token = Accounts.generate_user_login_token(user) + {:noreply, socket |> redirect(to: ~p"/users/log_in/#{login_token}")} + + {:error, error} -> + dbg(error) + {:noreply, socket |> put_flash(:error, "An error occured")} + end + end + + def handle_event("error", payload, socket), + do: {:noreply, socket |> put_flash(:error, inspect(payload))} + + defp assign_form(socket, %Ecto.Changeset{} = changeset) do + form = to_form(changeset, as: "user") + + if changeset.valid? do + assign(socket, form: form, check_errors: false) + else + assign(socket, form: form) + end + end +end diff --git a/lib/sso_bsn_web/live/user_settings_live.ex b/lib/sso_bsn_web/live/user_settings_live.ex new file mode 100644 index 0000000..789259e --- /dev/null +++ b/lib/sso_bsn_web/live/user_settings_live.ex @@ -0,0 +1,69 @@ +defmodule SsoBsnWeb.UserSettingsLive do + use SsoBsnWeb, :live_view + + alias SsoBsn.Accounts + + def render(assigns) do + ~H""" + <.header class="text-center"> + Account Settings + + +
+
+ <.header>Keys +
    +
  1. <%= key.key_id %>
  2. +
+ <.button id="settings-add-key" phx-hook="registrationHook" phx-click="register"> + Register additional key + +
+
+ <%= if !@login_url do %> + <.button id="show-login-url" phx-click="show-login-url">Show login url + <% else %> +
<%= @login_url %>
+ <% end %> +
+
+ """ + end + + def mount(_params, _session, socket) do + {:ok, socket |> assign(login_url: nil)} + end + + def handle_event("register", _, socket) do + {challenge, challenge_client} = Accounts.registration_challenge(socket.assigns.current_user) + + {:noreply, + socket + |> assign(challenge: challenge) + |> push_event("registration-challenge", challenge_client)} + end + + def handle_event("registration-complete", params, socket) do + %{current_user: user, challenge: challenge} = socket.assigns + + case Accounts.register_key(user, challenge, params) do + {:ok, _key} -> + {:noreply, + socket |> assign(current_user: Accounts.reload_user(socket.assigns.current_user))} + + {:error, error} -> + dbg(error) + {:noreply, socket |> put_flash(:error, "An error occured")} + end + end + + def handle_event("show-login-url", %{}, socket) do + token = Accounts.generate_user_login_token(socket.assigns.current_user) + + {:noreply, + socket |> assign(login_url: url(socket, ~p"/users/log_in/#{token}"))} + end + + def handle_event("error", payload, socket), + do: {:noreply, socket |> put_flash(:error, inspect(payload))} +end diff --git a/lib/sso_bsn_web/router.ex b/lib/sso_bsn_web/router.ex index 2d07e19..8b272b3 100644 --- a/lib/sso_bsn_web/router.ex +++ b/lib/sso_bsn_web/router.ex @@ -1,6 +1,8 @@ defmodule SsoBsnWeb.Router do use SsoBsnWeb, :router + import SsoBsnWeb.UserAuth + pipeline :browser do plug :accepts, ["html"] plug :fetch_session @@ -8,6 +10,7 @@ defmodule SsoBsnWeb.Router do plug :put_root_layout, html: {SsoBsnWeb.Layouts, :root} plug :protect_from_forgery plug :put_secure_browser_headers + plug :fetch_current_user end pipeline :api do @@ -25,7 +28,7 @@ defmodule SsoBsnWeb.Router do # pipe_through :api # end - # Enable LiveDashboard and Swoosh mailbox preview in development + # Enable LiveDashboard in development if Application.compile_env(:sso_bsn, :dev_routes) do # If you want to use the LiveDashboard in production, you should put # it behind authentication and allow only admins to access it. @@ -38,7 +41,36 @@ defmodule SsoBsnWeb.Router do pipe_through :browser live_dashboard "/dashboard", metrics: SsoBsnWeb.Telemetry - forward "/mailbox", Plug.Swoosh.MailboxPreview end end + + ## Authentication routes + + scope "/", SsoBsnWeb do + pipe_through [:browser, :redirect_if_user_is_authenticated] + + live_session :redirect_if_user_is_authenticated, + on_mount: [{SsoBsnWeb.UserAuth, :redirect_if_user_is_authenticated}] do + live "/users/register", UserRegistrationLive, :new + live "/users/log_in", UserLoginLive, :new + end + + get "/users/log_in/:token", UserSessionController, :login + post "/users/log_in", UserSessionController, :create + end + + scope "/", SsoBsnWeb do + pipe_through [:browser, :require_authenticated_user] + + live_session :require_authenticated_user, + on_mount: [{SsoBsnWeb.UserAuth, :ensure_authenticated}] do + live "/users/settings", UserSettingsLive, :edit + end + end + + scope "/", SsoBsnWeb do + pipe_through [:browser] + + delete "/users/log_out", UserSessionController, :delete + end end diff --git a/lib/sso_bsn_web/user_auth.ex b/lib/sso_bsn_web/user_auth.ex new file mode 100644 index 0000000..6f747fa --- /dev/null +++ b/lib/sso_bsn_web/user_auth.ex @@ -0,0 +1,227 @@ +defmodule SsoBsnWeb.UserAuth do + use SsoBsnWeb, :verified_routes + + import Plug.Conn + import Phoenix.Controller + + alias SsoBsn.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 "_sso_bsn_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 + 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 + SsoBsnWeb.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 SsoBsnWeb.PageLive do + use SsoBsnWeb, :live_view + + on_mount {SsoBsnWeb.UserAuth, :mount_current_user} + ... + end + + Or use the `live_session` of your router to invoke the on_mount callback: + + live_session :authenticated, on_mount: [{SsoBsnWeb.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"/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"/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"/users/settings" +end diff --git a/mix.exs b/mix.exs index 0fe686d..1373d2e 100644 --- a/mix.exs +++ b/mix.exs @@ -23,8 +23,6 @@ defmodule SsoBsn.MixProject do ] end - # Specifies which paths to compile per environment. - defp elixirc_paths(:test), do: ["lib", "test/support"] defp elixirc_paths(_), do: ["lib"] # Specifies your project dependencies. @@ -32,6 +30,7 @@ defmodule SsoBsn.MixProject do # Type `mix help deps` for examples and options. defp deps do [ + {:bcrypt_elixir, "~> 3.0"}, {:phoenix, "~> 1.7.9"}, {:phoenix_ecto, "~> 4.4"}, {:ecto_sql, "~> 3.10"}, @@ -39,18 +38,16 @@ defmodule SsoBsn.MixProject do {:phoenix_html, "~> 3.3"}, {:phoenix_live_reload, "~> 1.2", only: :dev}, {:phoenix_live_view, "~> 0.20.1"}, - {:floki, ">= 0.30.0", only: :test}, {:phoenix_live_dashboard, "~> 0.8.2"}, {:esbuild, "~> 0.7", runtime: Mix.env() == :dev}, {:tailwind, "~> 0.2.0", runtime: Mix.env() == :dev}, - {:swoosh, "~> 1.3"}, - {:finch, "~> 0.13"}, {:telemetry_metrics, "~> 0.6"}, {:telemetry_poller, "~> 1.0"}, {:gettext, "~> 0.20"}, {:jason, "~> 1.2"}, {:dns_cluster, "~> 0.1.1"}, - {:plug_cowboy, "~> 2.5"} + {:plug_cowboy, "~> 2.5"}, + {:wax_, "~> 0.6.0"} ] end @@ -65,7 +62,6 @@ defmodule SsoBsn.MixProject do setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"], "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], "ecto.reset": ["ecto.drop", "ecto.setup"], - test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], "assets.build": ["tailwind default", "esbuild default"], "assets.deploy": ["tailwind default --minify", "esbuild default --minify", "phx.digest"] diff --git a/mix.lock b/mix.lock index ffc3129..efd98f7 100644 --- a/mix.lock +++ b/mix.lock @@ -1,6 +1,10 @@ %{ + "asn1_compiler": {:hex, :asn1_compiler, "0.1.1", "64a4e52b59d1f225878445ace2c75cd2245b13a5a81182304fd9dc5acfc8994e", [:mix], [], "hexpm", "c250d24c22f1a3f305d88864400f9ac2df55c6886e1e3a030e2946efeb94695e"}, + "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.1.0", "0b110a9a6c619b19a7f73fa3004aa11d6e719a67e672d1633dc36b6b2290a0f7", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "2ad2acb5a8bc049e8d5aa267802631912bb80d5f4110a178ae7999e69dca1bf7"}, "castore": {:hex, :castore, "1.0.4", "ff4d0fb2e6411c0479b1d965a814ea6d00e51eb2f58697446e9c41a97d940b28", [:mix], [], "hexpm", "9418c1b8144e11656f0be99943db4caf04612e3eaecefb5dae9a2a87565584f8"}, + "cbor": {:hex, :cbor, "1.0.1", "39511158e8ea5a57c1fcb9639aaa7efde67129678fee49ebbda780f6f24959b0", [:mix], [], "hexpm", "5431acbe7a7908f17f6a9cd43311002836a34a8ab01876918d8cfb709cd8b6a2"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.8", "933a5f4da3b19ee56539a076076ce4d7716d64efc8db46fd066996a7e46e2bfd", [:mix], [{:elixir_make, "~> 0.7.3", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "176bdf4366956e456bf761b54ad70bc4103d0269ca9558fd7cee93d1b3f116db"}, + "comeonin": {:hex, :comeonin, "5.4.0", "246a56ca3f41d404380fc6465650ddaa532c7f98be4bda1b4656b3a37cc13abe", [:mix], [], "hexpm", "796393a9e50d01999d56b7b8420ab0481a7538d0caf80919da493b4a6e51faf1"}, "cowboy": {:hex, :cowboy, "2.10.0", "ff9ffeff91dae4ae270dd975642997afe2a1179d94b1887863e43f681a203e26", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "3afdccb7183cc6f143cb14d3cf51fa00e53db9ec80cdcd525482f5e99bc41d6b"}, "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.12.1", "a9fa9a625f1d2025fe6b462cb865881329b5caff8f1854d1cbc9f9533f00e1e1", [:make, :rebar3], [], "hexpm", "163b73f6367a7341b33c794c4e88e7dbfe6498ac42dcd69ef44c5bc5507c8db0"}, @@ -41,6 +45,8 @@ "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"}, "telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"}, + "wax_": {:hex, :wax_, "0.6.3", "c0cf63819e21f7b6f4e9e3702d52b6837d58c02491b876fe54453729c2cc1a79", [:mix], [{:asn1_compiler, "~> 0.1.0", [hex: :asn1_compiler, repo: "hexpm", optional: false]}, {:cbor, "~> 1.0", [hex: :cbor, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:x509, "~> 0.8", [hex: :x509, repo: "hexpm", optional: false]}], "hexpm", "c25a5ad88edc741c10d030a46fb6f0525fe253f258da0e6ce72ec2b3327c5b1c"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.5.5", "9dfeee8269b27e958a65b3e235b7e447769f66b5b5925385f5a569269164a210", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "4b977ba4a01918acbf77045ff88de7f6972c2a009213c515a445c48f224ffce9"}, + "x509": {:hex, :x509, "0.8.8", "aaf5e58b19a36a8e2c5c5cff0ad30f64eef5d9225f0fd98fb07912ee23f7aba3", [:mix], [], "hexpm", "ccc3bff61406e5bb6a63f06d549f3dba3a1bbb456d84517efaaa210d8a33750f"}, } diff --git a/priv/repo/migrations/20231101173047_create_users_auth_tables.exs b/priv/repo/migrations/20231101173047_create_users_auth_tables.exs new file mode 100644 index 0000000..7d0c8a7 --- /dev/null +++ b/priv/repo/migrations/20231101173047_create_users_auth_tables.exs @@ -0,0 +1,33 @@ +defmodule SsoBsn.Repo.Migrations.CreateUsersAuthTables do + use Ecto.Migration + + def change do + create table(:users) do + add :username, :string, null: false, collate: :nocase + add :confirmed_at, :naive_datetime + timestamps(type: :utc_datetime) + end + + create unique_index(:users, [:username]) + + create table(:users_keys) do + add :key_id, :string, null: false + add :cose_key, :binary, null: false + + add :user_id, references(:users, on_delete: :delete_all), null: false + end + + create unique_index(:users_keys, [:key_id]) + + create table(:users_tokens) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :token, :binary, null: false, size: 32 + 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/test/sso_bsn_web/controllers/error_html_test.exs b/test/sso_bsn_web/controllers/error_html_test.exs deleted file mode 100644 index 908d1f6..0000000 --- a/test/sso_bsn_web/controllers/error_html_test.exs +++ /dev/null @@ -1,14 +0,0 @@ -defmodule SsoBsnWeb.ErrorHTMLTest do - use SsoBsnWeb.ConnCase, async: true - - # Bring render_to_string/4 for testing custom views - import Phoenix.Template - - test "renders 404.html" do - assert render_to_string(SsoBsnWeb.ErrorHTML, "404", "html", []) == "Not Found" - end - - test "renders 500.html" do - assert render_to_string(SsoBsnWeb.ErrorHTML, "500", "html", []) == "Internal Server Error" - end -end diff --git a/test/sso_bsn_web/controllers/error_json_test.exs b/test/sso_bsn_web/controllers/error_json_test.exs deleted file mode 100644 index 51fbe54..0000000 --- a/test/sso_bsn_web/controllers/error_json_test.exs +++ /dev/null @@ -1,12 +0,0 @@ -defmodule SsoBsnWeb.ErrorJSONTest do - use SsoBsnWeb.ConnCase, async: true - - test "renders 404" do - assert SsoBsnWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}} - end - - test "renders 500" do - assert SsoBsnWeb.ErrorJSON.render("500.json", %{}) == - %{errors: %{detail: "Internal Server Error"}} - end -end diff --git a/test/sso_bsn_web/controllers/page_controller_test.exs b/test/sso_bsn_web/controllers/page_controller_test.exs deleted file mode 100644 index 5e531de..0000000 --- a/test/sso_bsn_web/controllers/page_controller_test.exs +++ /dev/null @@ -1,8 +0,0 @@ -defmodule SsoBsnWeb.PageControllerTest do - use SsoBsnWeb.ConnCase - - test "GET /", %{conn: conn} do - conn = get(conn, ~p"/") - assert html_response(conn, 200) =~ "Peace of mind from prototype to production" - end -end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex deleted file mode 100644 index fb719fb..0000000 --- a/test/support/conn_case.ex +++ /dev/null @@ -1,38 +0,0 @@ -defmodule SsoBsnWeb.ConnCase do - @moduledoc """ - This module defines the test case to be used by - tests that require setting up a connection. - - Such tests rely on `Phoenix.ConnTest` and also - import other functionality to make it easier - to build common data structures and query the data layer. - - Finally, if the test case interacts with the database, - we enable the SQL sandbox, so changes done to the database - are reverted at the end of every test. If you are using - PostgreSQL, you can even run database tests asynchronously - by setting `use SsoBsnWeb.ConnCase, async: true`, although - this option is not recommended for other databases. - """ - - use ExUnit.CaseTemplate - - using do - quote do - # The default endpoint for testing - @endpoint SsoBsnWeb.Endpoint - - use SsoBsnWeb, :verified_routes - - # Import conveniences for testing with connections - import Plug.Conn - import Phoenix.ConnTest - import SsoBsnWeb.ConnCase - end - end - - setup tags do - SsoBsn.DataCase.setup_sandbox(tags) - {:ok, conn: Phoenix.ConnTest.build_conn()} - end -end diff --git a/test/support/data_case.ex b/test/support/data_case.ex deleted file mode 100644 index 6473308..0000000 --- a/test/support/data_case.ex +++ /dev/null @@ -1,58 +0,0 @@ -defmodule SsoBsn.DataCase do - @moduledoc """ - This module defines the setup for tests requiring - access to the application's data layer. - - You may define functions here to be used as helpers in - your tests. - - Finally, if the test case interacts with the database, - we enable the SQL sandbox, so changes done to the database - are reverted at the end of every test. If you are using - PostgreSQL, you can even run database tests asynchronously - by setting `use SsoBsn.DataCase, async: true`, although - this option is not recommended for other databases. - """ - - use ExUnit.CaseTemplate - - using do - quote do - alias SsoBsn.Repo - - import Ecto - import Ecto.Changeset - import Ecto.Query - import SsoBsn.DataCase - end - end - - setup tags do - SsoBsn.DataCase.setup_sandbox(tags) - :ok - end - - @doc """ - Sets up the sandbox based on the test tags. - """ - def setup_sandbox(tags) do - pid = Ecto.Adapters.SQL.Sandbox.start_owner!(SsoBsn.Repo, shared: not tags[:async]) - on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) - end - - @doc """ - A helper that transforms changeset errors into a map of messages. - - assert {:error, changeset} = Accounts.create_user(%{password: "short"}) - assert "password is too short" in errors_on(changeset).password - assert %{password: ["password is too short"]} = errors_on(changeset) - - """ - def errors_on(changeset) do - Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> - Regex.replace(~r"%{(\w+)}", message, fn _, key -> - opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() - end) - end) - end -end diff --git a/test/test_helper.exs b/test/test_helper.exs deleted file mode 100644 index 85cc5fc..0000000 --- a/test/test_helper.exs +++ /dev/null @@ -1,2 +0,0 @@ -ExUnit.start() -Ecto.Adapters.SQL.Sandbox.mode(SsoBsn.Repo, :manual)