Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add u2f #82

Merged
merged 12 commits into from
Jul 22, 2023
21 changes: 21 additions & 0 deletions apps/ex_fleet_yards/lib/ex_fleet_yards/repo/account/user.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ defmodule ExFleetYards.Repo.Account.User do
import Ecto.Changeset
import ExFleetYards.Repo.Changeset

alias ExFleetYards.Repo

@primary_key {:id, Ecto.UUID, []}

typed_schema "users" do
Expand Down Expand Up @@ -49,10 +51,29 @@ defmodule ExFleetYards.Repo.Account.User do
has_many :vehicles, ExFleetYards.Repo.Account.Vehicle
has_many :sso_connections, ExFleetYards.Repo.Account.User.SSOConnection
has_one :totp, ExFleetYards.Repo.Account.User.Totp
has_many :u2f_token, ExFleetYards.Repo.Account.User.U2fToken

timestamps(inserted_at: :created_at, type: :utc_datetime)
end

@doc """
Returns if the user has u2f and totp enabled as tupl
"""
@spec second_factors(__MODULE__.t() | Ecto.UUID.t()) ::
{:ok, {boolean(), boolean()}}
| {:error, any()}
| {:error, Ecto.Multi.name(), any(), any()}
def second_factors(%__MODULE__{id: user_id}), do: second_factors(user_id)

def second_factors(user) when is_binary(user) do
Repo.transaction(fn ->
u2f = Repo.exists?(__MODULE__.U2fToken.user_query(user))
totp = Repo.exists?(__MODULE__.Totp.user_query(user))

{u2f, totp}
end)
end

def info_changeset(user, attrs) do
user
|> cast(attrs, [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ defmodule ExFleetYards.Repo.Account.User.Totp do
end

def user_query(user_id, active \\ true)
def user_query(%User{id: user_id}, active), do: user_query(user_id, active)

def user_query(user_id, nil) when is_binary(user_id) do
from(t in __MODULE__, where: t.user_id == ^user_id)
Expand Down
145 changes: 145 additions & 0 deletions apps/ex_fleet_yards/lib/ex_fleet_yards/repo/account/user/u2f_token.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
defmodule ExFleetYards.Repo.Account.User.U2fToken do
@moduledoc """
U2F authentication data
"""

use TypedEctoSchema
require Logger

alias ExFleetYards.Repo
alias ExFleetYards.Repo.Account.User

@primary_key {:id, Ecto.UUID, []}

typed_schema "user_u2f_tokens" do
field :name, :string
field :credential_id, :binary
field :cose_key, :binary

belongs_to :user, User, type: Ecto.UUID

timestamps(inserted_at: :created_at, updated_at: false)
end

def create(user, credential_id, cose_key, name \\ nil)

def create(%User{id: user_id}, credential_id, cose_key, name),
do: create(user_id, credential_id, cose_key, name)

def create(user_id, credential_id, cose_key, name) when is_binary(user_id) do
%__MODULE__{}
|> create_changeset(%{
user_id: user_id,
credential_id: credential_id,
cose_key: :erlang.term_to_binary(cose_key),
name: name
})
|> Repo.insert()
|> broadcast_change([:create])
end

import Ecto.Query

def get_key(user, key) do
key_query(user, key)
|> Repo.one()
end

def edit(key, params) do
edit_changeset(key, params)
|> Repo.update()
|> broadcast_change([:edit])
end

def user_allow_credentials(%User{id: user_id}), do: user_allow_credentials(user_id)

def user_allow_credentials(user_id) when is_binary(user_id) do
user_query(user_id)
|> select([:credential_id, :cose_key])
|> Repo.all()
|> Enum.map(fn %__MODULE__{credential_id: id, cose_key: key} ->
{id, :erlang.binary_to_term(key)}
end)
end

def key_list(user) do
user_query(user)
|> select([:id, :name])
|> Repo.all()
end

def delete_key(%User{id: user}, key), do: delete_key(user, key)

def delete_key(user, key) when is_binary(user) do
Repo.transaction(fn ->
key_query(user, key)
|> Repo.delete_all()
end)
|> broadcast_change([:delete], user)
end

def user_query(%User{id: user_id}), do: user_query(user_id)

def user_query(user_id) when is_binary(user_id) do
from u in __MODULE__, where: u.user_id == ^user_id
end

def key_query(user, key)
def key_query(user, %__MODULE__{id: key}), do: key_query(user, key)

def key_query(user, key) when is_binary(key) do
user_query(user)
|> where(id: ^key)
end

import Ecto.Changeset

def create_changeset(token \\ %__MODULE__{}, attrs) do
token
|> cast(attrs, [:user_id, :credential_id, :cose_key, :name])
|> validate_required([:user_id, :credential_id, :cose_key])
|> validate_length(:name, min: 2, max: 30)
|> unique_constraint([:credential_id])
end

def edit_changeset(token, attrs) do
token
|> cast(attrs, [:name])
|> validate_length(:name, min: 2, max: 30)
|> validate_required([:name])
end

@doc """
Subscribe to updates to the user webauthn key list.
"""
@spec subscribe(User.t() | Ecto.UUID.t()) :: :ok | {:error, term()}
def subscribe(%User{id: user_id}), do: subscribe(user_id)

def subscribe(user) when is_binary(user) do
Phoenix.PubSub.subscribe(ExFleetYards.PubSub, topic(user))
end

defp broadcast_change({:ok, %__MODULE__{user_id: user_id} = result}, event) do
Phoenix.PubSub.broadcast(ExFleetYards.PubSub, topic(user_id), {__MODULE__, event, result})

{:ok, result}
end

defp broadcast_change({:ok, {n, _}} = v, event, user_id)
when is_integer(n) and is_binary(user_id) do
Phoenix.PubSub.broadcast(ExFleetYards.PubSub, topic(user_id), {__MODULE__, event, n})
v
end

defp broadcast_change(v, _event) do
Logger.debug("Could not send broadcast for #{inspect(v)}")
v
end

defp broadcast_change(v, _event, _user) do
Logger.debug("Could not send broadcast for #{inspect(v)}")
v
end

defp topic(user_id) when is_binary(user_id), do: Atom.to_string(__MODULE__) <> ":" <> user_id
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
defmodule ExFleetYards.Repo.Migrations.UserU2fToken do
use Ecto.Migration

def change do
create table(:user_u2f_tokens, primary_key: false) do
add :id, :uuid, primary_key: true, null: false, default: fragment("gen_random_uuid()")
add :name, :string
add :credential_id, :binary, null: false
add :cose_key, :binary, null: false

add :user_id,
references(:users, type: :uuid, primary_key: true, on_delete: :delete_all, null: false)

timestamps(inserted_at: :created_at, updated_at: false)
end

create index(:user_u2f_tokens, [:user_id])
create unique_index(:user_u2f_tokens, [:credential_id])
end
end
6 changes: 6 additions & 0 deletions apps/ex_fleet_yards_auth/assets/js/vars.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
'use strict';

export const WEBAUTHN_REGISTER_CHALLENGE_URL = '/webauthn/register/challenge';
export const WEBAUTHN_REGISTER_VALIDATE_URL = '/webauthn/register';
export const WEBAUTHN_LOGIN_CHALLENGE_URL = '/login/webauthn/challenge';
export const WEBAUTHN_LOGIN_VALIDATE_URL = '/login/webauthn';
29 changes: 29 additions & 0 deletions apps/ex_fleet_yards_auth/assets/js/webauthn/helper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
'use strict';

export function isWebAuthnSupported() {
return window.PublicKeyCredential !== undefined && typeof window.PublicKeyCredential === "function"
}

export function getCsrfToken() {
return document.head.querySelector('meta[name="csrf-token"]').content
}


export function toBase64(data) {
return btoa(String.fromCharCode.apply(null, new Uint8Array(data)));
}

export function fromBase64(data) {
return toArray(atob(data))
}

export function toArray(str) {
return Uint8Array.from(str, c => c.charCodeAt(0));
}

export function errorMessage(e) {
switch (e.name) {
default:
return `There was a problem communicating with your device. (${e.name})`
}
}
76 changes: 76 additions & 0 deletions apps/ex_fleet_yards_auth/assets/js/webauthn/login.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
'use strict';

import {errorMessage, fromBase64, getCsrfToken, isWebAuthnSupported, toBase64} from "./helper";
import {WEBAUTHN_LOGIN_CHALLENGE_URL, WEBAUTHN_LOGIN_VALIDATE_URL} from "../vars";

function setMsg(msg) {
document.getElementById('webauthn-msg-text').innerText = msg
document.getElementById('webauthn-retry-btn').classList.remove('hidden')
}

function webAuthnLogin() {
if (!isWebAuthnSupported()) {
setMsg("This browser does not support WebAuthn")
return
}
fetch(WEBAUTHN_LOGIN_CHALLENGE_URL, {
method: 'POST',
credentials: 'same-origin',
headers: {
"Content-Type": "application/json",
"X-csrf-token": getCsrfToken(),
}
}).then(res => {
if (res.status != 200) {
setMsg("Failed to communicate with serer. Please try again later.")
throw new Error("Oops")
}
return res;
}).then(res => res.json())
.then(response => {
const challenge = response
challenge.publicKey.challenge = fromBase64(challenge.publicKey.challenge)
challenge.publicKey.allowCredentials = challenge.publicKey.allowCredentials.map(c => {
c.id = fromBase64(c.id)
return c
});
return navigator.credentials.get(challenge)
.then(credentials => {
const pk = {};
pk.id = credentials.id;
pk.rawId = toBase64(credentials.rawId)
pk.response = {};
pk.response.authenticatorData = toBase64(credentials.response.authenticatorData);
pk.response.clientDataJSON = toBase64(credentials.response.clientDataJSON);
pk.response.signature = toBase64(credentials.response.signature);
pk.response.userHandle = toBase64(credentials.response.userHandle);
pk.type = credentials.type;

return fetch(WEBAUTHN_LOGIN_VALIDATE_URL, {
method: 'POST',
body: JSON.stringify(pk),
credentials: 'same-origin',
headers: {
"Content-Type": "application/json",
"X-csrf-token": getCsrfToken()
}
})
.then(res => {
if (res.status != 200) {
setMsg("Failed to authenticate.")
}
return res.text()
})
.then(res => {
window.location.replace(res)
})
})
.catch(e => {
setMsg(errorMessage(e))
console.log(e)
})
})
}

window.addEventListener('load', webAuthnLogin)
document.getElementById('webauthn-retry-btn').addEventListener('click', webAuthnLogin)
Loading
Loading