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