phoenix_passkeys/lib/phoenix_passkeys_web/controllers/user_session_controller.ex

95 lines
3.4 KiB
Elixir

defmodule PhoenixPasskeysWeb.UserSessionController do
use PhoenixPasskeysWeb, :controller
import Bitwise, only: [&&&: 2]
alias PhoenixPasskeys.Accounts
alias PhoenixPasskeysWeb.UserAuth
def new(conn, _params) do
conn
|> put_webauthn_challenge()
|> render(:new, error_message: nil)
end
def create(conn, params) do
id = params |> Map.get("raw_id") |> Base.decode64!()
authenticator_data = params |> Map.get("authenticator_data") |> Base.decode64!()
client_data_json_str = params |> Map.get("client_data_json")
signature = params |> Map.get("signature") |> Base.decode64!()
with credential when not is_nil(credential) <- Accounts.get_credential(id),
# Verify that the authenticator data and client data JSON are signed with the user's key.
true <-
verify_signature(credential, client_data_json_str, authenticator_data, signature),
# Decode the client data JSON.
{:ok, client_data_json} <- Jason.decode(client_data_json_str),
# Make sure the values in the client data JSON are what we expect, and extract the challenge.
{:ok, challenge} <- check_client_data_json(client_data_json),
# Make sure the challenge singed by the user's key is what we generated.
true <- challenge == get_session(conn, :webauthn_challenge),
# Make sure the signed origin matches what we expect.
true <- :binary.part(authenticator_data, 0, 32) == :crypto.hash(:sha256, "localhost"),
# Check the user presence bit is set.
true <- (:binary.at(authenticator_data, 32) &&& 1) == 1 do
conn
|> delete_session(:webauthn_challenge)
|> UserAuth.log_in_user_without_redirect(credential.user)
|> json(%{status: :ok})
else
_ ->
json(conn, %{status: :error})
end
end
def delete(conn, _params) do
conn
|> put_flash(:info, "Logged out successfully.")
|> UserAuth.log_out_user()
end
def credentials(conn, %{"email" => email}) do
ids =
Accounts.get_credentials_by_email(email)
|> Enum.map(fn cred -> Base.encode64(cred.id) end)
json(conn, ids)
end
# Generate a value that will be signed by WebAuthn on the client.
defp put_webauthn_challenge(conn) do
# The challenge is returned by the browser in base 64 URL encoding, so match that.
challenge = :crypto.strong_rand_bytes(64) |> Base.url_encode64(padding: false)
conn
|> put_session(:webauthn_challenge, challenge)
|> assign(:webauthn_challenge, challenge)
end
defp verify_signature(credential, client_data_json_str, authenticator_data, signature) do
with {:ok, pubkey} <- X509.PublicKey.from_der(credential.public_key_spki),
client_data_json_hash <- :crypto.hash(:sha256, client_data_json_str),
signed_message <- authenticator_data <> client_data_json_hash,
true <- :public_key.verify(signed_message, :sha256, signature, pubkey) do
true
else
_ ->
false
end
end
# If crossOrigin is present and is not false, we reject the login attempt.
defp check_client_data_json(%{"crossOrigin" => crossOrigin}) when crossOrigin != false do
false
end
defp check_client_data_json(%{
"type" => "webauthn.get",
"challenge" => challenge,
"origin" => "http://localhost:4000"
}) do
{:ok, challenge}
end
defp check_client_data_json(_), do: false
end