WebAuthN auth

This commit is contained in:
bluepython508
2023-11-05 01:12:02 +00:00
parent 45e4e9f5da
commit 092930a24f
33 changed files with 1123 additions and 463 deletions

233
lib/sso_bsn/accounts.ex Normal file
View File

@@ -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, <<user_id::64>>} -> 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(<<user.id::64>>, 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -1,3 +0,0 @@
defmodule SsoBsn.Mailer do
use Swoosh.Mailer, otp_app: :sso_bsn
end