Revisit Users context, cover UI with e2e tests and introduce first AuditLog features (#1267)

1. `auto_create_users` default value is removed. We want to avoid
situations when admins integrate OIDC/SAML providers and don't expect
anyone that has access to it to automatically gain access to VPN, which
is especially critical for providers like Google Workspace, where all
employees typically have access.
2. OpenID library was completely rewritten and a new version is
integrated. It will allow async tests and better scales for the cloud
version of the panel.
3. `Mox` was removed, we don't test modules by overriding them to
prevent breaking changes that tests can't capture.
4. Deps are reordered and unused ones are removed.
5. Browser/e2e tests are added to ensure we won't break UI features in
the future, allowing for front-end refactoring.
6. Users context was overhauled for better code clarity.
This commit is contained in:
Andrew Dryga
2023-01-16 13:04:59 -06:00
committed by GitHub
parent bff52590e1
commit 218ad006af
109 changed files with 3503 additions and 1329 deletions

View File

@@ -83,7 +83,7 @@
# Priority values are: `low, normal, high, higher`
#
{Credo.Check.Design.AliasUsage,
[priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 2]},
[priority: :low, if_nested_deeper_than: 5, if_called_more_often_than: 6]},
# You can also customize the exit_status of each check.
# If you don't want TODO comments to cause `mix credo` to fail, just
# set this value to 0 (zero).
@@ -112,7 +112,6 @@
{Credo.Check.Readability.TrailingWhiteSpace, []},
{Credo.Check.Readability.UnnecessaryAliasExpansion, []},
{Credo.Check.Readability.VariableNames, []},
{Credo.Check.Readability.WithSingleClause, []},
#
## Refactoring Opportunities

View File

@@ -1,17 +1,7 @@
# Used by "mix format"
[
import_deps: [
:ecto,
:phoenix
],
subdirectories: ["apps/*"],
inputs: [
"*.{heex,ex,exs}",
"{config,priv}/**/*.{heex,ex,exs}",
"apps/{fz_vpn,fz_wall}/**/*.{heex,ex,exs}",
"apps/fz_http/*.exs",
"apps/fz_http/{lib,test,priv}/**/*.{heex,ex,exs}"
],
plugins: [
Phoenix.LiveView.HTMLFormatter
"*.{ex,exs}",
"{config,priv}/**/*.{ex,exs}"
]
]

View File

@@ -54,11 +54,147 @@ jobs:
key: ${{ runner.os }}-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }}
- name: Install Dependencies
run: mix deps.get --only $MIX_ENV
- name: Compile Dependencies
run: mix deps.compile --skip-umbrella-children
- name: Compile Application
run: mix compile
- name: Setup Database
run: |
mix ecto.create
mix ecto.migrate
- name: Run Tests and Upload Coverage Report
env:
E2E_MAX_WAIT_SECONDS: 20
run: |
# XXX: This can fail when coveralls is down
mix coveralls.github --umbrella
- name: Test Report
uses: dorny/test-reporter@v1
if: success() || failure()
with:
name: Elixir Unit Test Report
path: _build/test/lib/*/test-junit-report.xml
reporter: java-junit
acceptance-test:
runs-on: ubuntu-latest
env:
MIX_ENV: test
POSTGRES_HOST: localhost
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
MIX_TEST_PARTITIONS: 4
strategy:
fail-fast: false
matrix:
MIX_TEST_PARTITION: [1, 2, 3, 4]
services:
postgres:
image: postgres:15
ports:
- 5432:5432
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
vault:
image: vault:1.12.2
env:
VAULT_ADDR: 'http://127.0.0.1:8200'
VAULT_DEV_ROOT_TOKEN_ID: 'firezone'
ports:
- 8200:8200/tcp
options:
--cap-add=IPC_LOCK
steps:
- uses: nanasess/setup-chromedriver@v1
with:
chromedriver-version: '108.0.5359.71'
- run: |
export DISPLAY=:99
chromedriver --url-base=/wd/hub &
sudo Xvfb -ac :99 -screen 0 1280x1024x24 > /dev/null 2>&1 &
- name: Install package dependencies
run: |
sudo apt-get install -q -y \
net-tools \
wireguard
- uses: actions/checkout@v3
- uses: erlef/setup-beam@v1
with:
otp-version: '25'
elixir-version: '1.14'
- uses: actions/cache@v3
name: Elixir Deps Cache
env:
cache-name: cache-elixir-deps
with:
path: deps
key: ${{ runner.os }}-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }}
restore-keys: |
${{ runner.os }}-${{ env.cache-name }}-
- uses: actions/cache@v3
name: Elixir Build Cache
env:
cache-name: cache-elixir-build
with:
path: _build
key: ${{ runner.os }}-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }}
- uses: actions/cache@v3
name: Yarn Deps Cache
env:
cache-name: cache-yarn-build
with:
path: apps/fz_http/assets/node_modules
key: ${{ runner.os }}-${{ env.cache-name }}-${{ hashFiles('**/yarn.lock') }}
- uses: actions/cache@v3
name: Assets Cache
env:
cache-name: cache-assets-build
with:
path: apps/fz_http/priv/static/dist
key: ${{ runner.os }}-${{ env.cache-name }}-${{ hashFiles('**/yarn.lock') }}
- name: Install Dependencies
run: mix deps.get --only $MIX_ENV
- name: Compile Dependencies
run: mix deps.compile --skip-umbrella-children
- name: Compile Application
run: mix compile
- name: Install Node Dependencies
run: |
cd apps/fz_http/assets
yarn install --frozen-lockfile
- name: Build Assets
run: |
cd apps/fz_http/assets
yarn deploy
- name: Setup Database
run: |
mix ecto.create
mix ecto.migrate
- name: Run Tests and Upload Coverage Report
env:
MIX_TEST_PARTITION: ${{ matrix.MIX_TEST_PARTITION }}
E2E_MAX_WAIT_SECONDS: 20
run: |
mix test --only acceptance:true \
--partitions=${{ env.MIX_TEST_PARTITIONS }} \
--no-compile \
--no-archives-check \
--no-deps-check \
|| mix test --failed
- name: Save Screenshots
if: always()
uses: actions/upload-artifact@v3
with:
name: screenshots
path: apps/fz_http/screenshots
- name: Test Report
uses: dorny/test-reporter@v1
if: success() || failure()
with:
name: Elixir Acceptance Test Report
path: _build/test/lib/*/test-junit-report.xml
reporter: java-junit

View File

@@ -0,0 +1,9 @@
[
locals_without_parens: [],
import_deps: [],
inputs: [
"*.{ex,exs}",
"{lib,test,priv}/**/*.{ex,exs}"
],
plugins: []
]

View File

@@ -1,8 +1,4 @@
defmodule FzCommon.FzCrypto do
@moduledoc """
Utilities for working with crypto functions
"""
@wg_psk_length 32
def psk do
@@ -20,6 +16,7 @@ defmodule FzCommon.FzCrypto do
defp rand_base64(length, :url) do
:crypto.strong_rand_bytes(length)
# XXX: we want to add `padding: false` to shorten URLs
|> Base.url_encode64()
end
@@ -27,4 +24,10 @@ defmodule FzCommon.FzCrypto do
:crypto.strong_rand_bytes(length)
|> Base.encode64()
end
def hash(value), do: Argon2.hash_pwd_salt(value)
def equal?(token, hash) when is_nil(token) or is_nil(hash), do: Argon2.no_user_verify()
def equal?(token, hash) when token == "" or hash == "", do: Argon2.no_user_verify()
def equal?(token, hash), do: Argon2.verify_pass(token, hash)
end

View File

@@ -32,12 +32,9 @@ defmodule FzCommon.MixProject do
defp deps do
[
{:file_size, "~> 3.0.1"},
{:cidr, github: "firezone/cidr-elixir"},
{:posthog, "~> 0.1"},
{:jason, "~> 1.2"},
{:cidr, github: "firezone/cidr-elixir"}
# {:dep_from_hexpm, "~> 0.3.0"},
# {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"},
# {:sibling_app_in_umbrella, in_umbrella: true}
{:argon2_elixir, "~> 2.0"}
]
end
end

View File

@@ -0,0 +1,18 @@
[
locals_without_parens: [
assert_authenticated: 2,
assert_unauthenticated: 1
],
import_deps: [
:ecto,
:phoenix,
:phoenix_live_view
],
inputs: [
"*.{heex,ex,exs}",
"{lib,test,priv}/**/*.{heex,ex,exs}"
],
plugins: [
Phoenix.LiveView.HTMLFormatter
]
]

View File

@@ -20,8 +20,7 @@ const channelToken = document
.getAttribute("content")
const notificationChannel =
userSocket.channel("notification:session", {
token: channelToken,
user_agent: window.navigator.userAgent
token: channelToken
})
// LiveView setup
@@ -71,3 +70,4 @@ notificationChannel.join()
// >> liveSocket.enableLatencySim(1000)
window.liveSocket = liveSocket
window.userSocket = userSocket

View File

@@ -12,6 +12,19 @@ defmodule FzHttp do
end
end
def changeset do
quote do
import Ecto.Changeset
import FzHttp.Validator
end
end
def query do
quote do
import Ecto.Query
end
end
@doc """
When used, dispatch to the appropriate schema/context/changeset/query/etc.
"""

View File

@@ -11,7 +11,7 @@ defmodule FzHttp.Application do
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
Telemetry.fz_http_started()
opts = [strategy: :one_for_one, name: FzHttp.Supervisor]
opts = [strategy: :one_for_one, name: __MODULE__.Supervisor]
Supervisor.start_link(children(), opts)
end
@@ -31,17 +31,16 @@ defmodule FzHttp.Application do
{Postgrex.Notifications, [name: FzHttp.Repo.Notifications] ++ FzHttp.Repo.config()},
FzHttp.Repo.Notifier,
FzHttp.Vault,
FzHttpWeb.Endpoint,
{Phoenix.PubSub, name: FzHttp.PubSub},
{FzHttp.Notifications, name: FzHttp.Notifications},
FzHttpWeb.Presence,
FzHttp.ConnectivityCheckService,
FzHttp.TelemetryPingService,
FzHttp.VpnSessionScheduler,
FzHttp.OIDC.StartProxy,
FzHttp.SAML.StartProxy,
{DynamicSupervisor, name: FzHttp.RefresherSupervisor, strategy: :one_for_one},
FzHttp.OIDC.RefreshManager
FzHttp.OIDC.RefreshManager,
FzHttpWeb.Endpoint
]
end
@@ -50,12 +49,11 @@ defmodule FzHttp.Application do
FzHttp.Server,
FzHttp.Repo,
FzHttp.Vault,
FzHttpWeb.Endpoint,
{FzHttp.OIDC.StartProxy, :test},
{FzHttp.SAML.StartProxy, :test},
{Phoenix.PubSub, name: FzHttp.PubSub},
{FzHttp.Notifications, name: FzHttp.Notifications},
FzHttpWeb.Presence
FzHttpWeb.Presence,
FzHttpWeb.Endpoint
]
end

View File

@@ -11,21 +11,44 @@ defmodule FzHttp.Configurations do
Map.get(get_configuration!(), key)
end
def fetch_oidc_provider_config(provider_id) do
get!(:openid_connect_providers)
|> Enum.find(&(&1.id == provider_id))
|> case do
nil ->
{:error, :not_found}
provider ->
external_url = FzHttp.Config.fetch_env!(:fz_http, :external_url)
{:ok,
%{
discovery_document_uri: provider.discovery_document_uri,
client_id: provider.client_id,
client_secret: provider.client_secret,
redirect_uri:
provider.redirect_uri || "#{external_url}/auth/oidc/#{provider.id}/callback/",
response_type: provider.response_type,
scope: provider.scope
}}
end
end
def put!(key, val) do
get_configuration!()
|> Configuration.changeset(%{key => val})
|> Repo.update!()
configuration =
get_configuration!()
|> Configuration.changeset(%{key => val})
|> Repo.update!()
FzHttp.SAML.StartProxy.restart()
configuration
end
def get_configuration! do
Repo.one!(Configuration)
end
def get_provider_by_id(field, provider_id) do
FzHttp.Configurations.get!(field)
|> Enum.find(&(&1.id == provider_id))
end
def auto_create_users?(field, provider_id) do
FzHttp.Configurations.get!(field)
|> Enum.find(&(&1.id == provider_id))
@@ -47,7 +70,6 @@ defmodule FzHttp.Configurations do
case Repo.update(Configuration.changeset(config, attrs)) do
{:ok, configuration} ->
FzHttp.SAML.StartProxy.restart()
FzHttp.OIDC.StartProxy.restart()
{:ok, configuration}

View File

@@ -7,7 +7,7 @@ defmodule FzHttp.Configurations.Configuration do
alias FzHttp.{
Configurations.Logo,
Validators.Common
Validator
}
@min_mtu 576
@@ -83,12 +83,12 @@ defmodule FzHttp.Configurations.Configuration do
|> cast_embed(:saml_identity_providers,
with: {FzHttp.Configurations.Configuration.SAMLIdentityProvider, :changeset, []}
)
|> Common.trim_change(:default_client_dns)
|> Common.trim_change(:default_client_allowed_ips)
|> Common.trim_change(:default_client_endpoint)
|> Common.validate_no_duplicates(:default_client_dns)
|> Common.validate_list_of_ips_or_cidrs(:default_client_allowed_ips)
|> Common.validate_no_duplicates(:default_client_allowed_ips)
|> Validator.trim_change(:default_client_dns)
|> Validator.trim_change(:default_client_allowed_ips)
|> Validator.trim_change(:default_client_endpoint)
|> Validator.validate_no_duplicates(:default_client_dns)
|> Validator.validate_list_of_ips_or_cidrs(:default_client_allowed_ips)
|> Validator.validate_no_duplicates(:default_client_allowed_ips)
|> validate_number(:default_client_mtu,
greater_than_or_equal_to: @min_mtu,
less_than_or_equal_to: @max_mtu

View File

@@ -4,7 +4,7 @@ defmodule FzHttp.Configurations.Configuration.OpenIDConnectProvider do
"""
use FzHttp, :schema
import Ecto.Changeset
alias FzHttp.Validators
alias FzHttp.Validator
@reserved_config_ids [
"identity",
@@ -23,7 +23,7 @@ defmodule FzHttp.Configurations.Configuration.OpenIDConnectProvider do
field :client_secret, :string
field :discovery_document_uri, :string
field :redirect_uri, :string
field :auto_create_users, :boolean, default: true
field :auto_create_users, :boolean
end
def changeset(struct \\ %__MODULE__{}, data) do
@@ -54,9 +54,22 @@ defmodule FzHttp.Configurations.Configuration.OpenIDConnectProvider do
])
# Don't allow users to enter reserved config ids
|> validate_exclusion(:id, @reserved_config_ids)
|> Validators.OpenIDConnect.validate_discovery_document_uri()
|> Validators.Common.validate_uri([
|> validate_discovery_document_uri()
|> Validator.validate_uri([
:redirect_uri
])
end
def validate_discovery_document_uri(changeset) do
changeset
|> validate_change(:discovery_document_uri, fn :discovery_document_uri, value ->
case OpenIDConnect.Document.fetch_document(value) do
{:ok, _update_result} ->
[]
{:error, reason} ->
[discovery_document_uri: "is invalid. Reason: #{inspect(reason)}"]
end
end)
end
end

View File

@@ -4,7 +4,6 @@ defmodule FzHttp.Configurations.Configuration.SAMLIdentityProvider do
"""
use FzHttp, :schema
import Ecto.Changeset
alias FzHttp.Validators
@primary_key false
embedded_schema do
@@ -16,7 +15,7 @@ defmodule FzHttp.Configurations.Configuration.SAMLIdentityProvider do
field :sign_metadata, :boolean, default: true
field :signed_assertion_in_resp, :boolean, default: true
field :signed_envelopes_in_resp, :boolean, default: true
field :auto_create_users, :boolean, default: true
field :auto_create_users, :boolean
end
def changeset(struct \\ %__MODULE__{}, data) do
@@ -38,6 +37,19 @@ defmodule FzHttp.Configurations.Configuration.SAMLIdentityProvider do
:metadata,
:auto_create_users
])
|> Validators.SAML.validate_metadata()
|> validate_metadata()
end
def validate_metadata(changeset) do
changeset
|> validate_change(:metadata, fn :metadata, value ->
try do
Samly.IdpData.from_xml(value, %Samly.IdpData{})
[]
catch
:exit, e ->
[metadata: "is invalid. Details: #{inspect(e)}."]
end
end)
end
end

View File

@@ -120,14 +120,12 @@ defmodule FzHttp.Devices do
end
def to_peer_list do
vpn_duration = Configurations.vpn_duration()
Repo.all(
from d in Device,
preload: :user
)
|> Enum.filter(fn device ->
!device.user.disabled_at && !Users.vpn_session_expired?(device.user, vpn_duration)
!device.user.disabled_at && !Users.vpn_session_expired?(device.user)
end)
|> Enum.map(fn device ->
%{

View File

@@ -4,7 +4,7 @@ defmodule FzHttp.Devices.Device do
"""
use FzHttp, :schema
import Ecto.Changeset
alias FzHttp.Validators.Common
alias FzHttp.Validator
alias FzHttp.Devices
require Logger
@@ -72,8 +72,8 @@ defmodule FzHttp.Devices.Device do
def create_changeset(attrs) do
%__MODULE__{}
|> cast(attrs, @fields)
|> Common.put_default_value(:name, &FzHttp.Devices.new_name/0)
|> Common.put_default_value(:preshared_key, &FzCommon.FzCrypto.psk/0)
|> Validator.put_default_value(:name, &FzHttp.Devices.new_name/0)
|> Validator.put_default_value(:preshared_key, &FzCommon.FzCrypto.psk/0)
|> changeset()
|> validate_max_devices()
|> validate_required(@required_fields)
@@ -88,13 +88,13 @@ defmodule FzHttp.Devices.Device do
defp changeset(changeset) do
changeset
|> Common.trim_change(:allowed_ips)
|> Common.trim_change(:dns)
|> Common.trim_change(:endpoint)
|> Common.trim_change(:name)
|> Common.trim_change(:description)
|> Common.validate_base64(:public_key)
|> Common.validate_base64(:preshared_key)
|> Validator.trim_change(:allowed_ips)
|> Validator.trim_change(:dns)
|> Validator.trim_change(:endpoint)
|> Validator.trim_change(:name)
|> Validator.trim_change(:description)
|> Validator.validate_base64(:public_key)
|> Validator.validate_base64(:preshared_key)
|> validate_length(:public_key, is: @key_length)
|> validate_length(:preshared_key, is: @key_length)
|> validate_length(:description, max: @description_max_length)
@@ -108,8 +108,8 @@ defmodule FzHttp.Devices.Device do
persistent_keepalive
mtu
]a)
|> Common.validate_list_of_ips_or_cidrs(:allowed_ips)
|> Common.validate_no_duplicates(:dns)
|> Validator.validate_list_of_ips_or_cidrs(:allowed_ips)
|> Validator.validate_no_duplicates(:dns)
|> validate_number(:persistent_keepalive,
greater_than_or_equal_to: 0,
less_than_or_equal_to: 120
@@ -194,7 +194,7 @@ defmodule FzHttp.Devices.Device do
end
defp validate_omitted_if_default(changeset, fields) when is_list(fields) do
Common.validate_omitted(
Validator.validate_omitted(
changeset,
filter_default_fields(changeset, fields, use_default: true)
)

View File

@@ -1,5 +1,5 @@
defmodule FzHttp.Devices.Device.Query do
import Ecto.Query
use FzHttp, :query
@doc """
Returns IP address at given integer offset relative to start of CIDR range.
@@ -40,7 +40,7 @@ defmodule FzHttp.Devices.Device.Query do
end
def all do
from(device in FzHttp.Devices.Device, as: :device)
from(device in FzHttp.Devices.Device, as: :devices)
end
@doc """
@@ -103,13 +103,19 @@ defmodule FzHttp.Devices.Device.Query do
end
defp select_not_used_ips(queryable, network_cidr, reserved_ips) do
host_as_string = network_cidr.address |> :inet.ntoa() |> List.to_string()
queryable
|> where(
[q: q],
offset_to_ip(q.ip, ^network_cidr) not in subquery(used_ips_subquery(network_cidr))
)
|> where([q: q], offset_to_ip(q.ip, ^network_cidr) not in ^reserved_ips)
|> where([q: q], acquire_advisory_lock(q.ip) == true)
|> where(
[q: q],
acquire_advisory_lock(fragment("hashtext(?) + ?", ^host_as_string, q.ip)) ==
true
)
|> select([q: q], offset_to_ip(q.ip, ^network_cidr))
end
@@ -117,10 +123,10 @@ defmodule FzHttp.Devices.Device.Query do
defp used_ips_subquery(queryable, %Postgrex.INET{address: address})
when tuple_size(address) == 4 do
select(queryable, [device: device], device.ipv4)
select(queryable, [devices: devices], devices.ipv4)
end
defp used_ips_subquery(queryable, %Postgrex.INET{address: _address}) do
select(queryable, [device: device], device.ipv6)
select(queryable, [devices: devices], devices.ipv6)
end
end

View File

@@ -23,7 +23,7 @@ defmodule FzHttp.Events do
information.
""",
timestamp: DateTime.utc_now(),
user: Users.get_user!(device.user_id).email
user: Users.fetch_user_by_id!(device.user_id).email
})
end
end
@@ -54,7 +54,7 @@ defmodule FzHttp.Events do
information.
""",
timestamp: DateTime.utc_now(),
user: Users.get_user!(device.user_id).email
user: Users.fetch_user_by_id!(device.user_id).email
})
end
end

View File

@@ -3,11 +3,8 @@ defmodule FzHttp.OIDC.Refresher do
Worker module for refreshing OIDC connections
"""
use GenServer, restart: :temporary
import Ecto.{Changeset, Query}
import FzHttpWeb.OIDC.Helpers
alias FzHttp.{OIDC, OIDC.Connection, Repo, Users}
alias FzHttp.{Configurations, OIDC, OIDC.Connection, Repo, Users}
require Logger
def start_link(init_opts) do
@@ -36,22 +33,16 @@ defmodule FzHttp.OIDC.Refresher do
defp do_refresh(user_id, %{provider: provider_id, refresh_token: refresh_token} = conn) do
Logger.info("Refreshing user\##{user_id} @ #{provider_id}...")
result =
openid_connect().fetch_tokens(
provider_id,
%{grant_type: "refresh_token", refresh_token: refresh_token}
)
refresh_response =
case result do
{:ok, refreshed} ->
refreshed
{:error, :fetch_tokens, %{body: body}} ->
%{error: body}
_ ->
%{error: "unknown error"}
with {:ok, config} <- Configurations.fetch_oidc_provider_config(provider_id),
{:ok, tokens} <-
OpenIDConnect.fetch_tokens(config, %{
grant_type: "refresh_token",
refresh_token: refresh_token
}) do
tokens
else
{:error, reason} -> %{error: inspect(reason)}
end
OIDC.update_connection(conn, %{
@@ -60,7 +51,7 @@ defmodule FzHttp.OIDC.Refresher do
})
with %{error: _} <- refresh_response do
user = Users.get_user!(user_id)
user = Users.fetch_user_by_id!(user_id)
Logger.info("Disabling user #{user.email} due to OIDC token refresh failure...")

View File

@@ -1,55 +0,0 @@
defmodule FzHttp.OIDC.StartProxy do
@moduledoc """
This proxy simply gets the relevant config at an appropriate timing
"""
require Logger
def child_spec(arg) do
%{id: __MODULE__, start: {__MODULE__, :start_link, [arg]}}
end
def start_link(:test) do
:ignore
end
def start_link(_) do
FzHttp.Configurations.get!(:openid_connect_providers)
|> parse()
|> OpenIDConnect.Worker.start_link()
end
# XXX: Remove when configurations support test fixtures
if Mix.env() == :test do
def restart, do: :ignore
else
def restart do
:ok = Supervisor.terminate_child(FzHttp.Supervisor, __MODULE__)
Supervisor.restart_child(FzHttp.Supervisor, __MODULE__)
end
end
# Convert the configuration record to something openid_connect expects,
# atom-keyed configs eg. [provider: [client_id: "CLIENT_ID" ...]]
defp parse(nil), do: []
defp parse(auth_oidc_config) when is_list(auth_oidc_config) do
external_url = FzHttp.Config.fetch_env!(:fz_http, :external_url)
Enum.map(auth_oidc_config, fn provider ->
{
provider.id,
[
discovery_document_uri: provider.discovery_document_uri,
client_id: provider.client_id,
client_secret: provider.client_secret,
redirect_uri:
provider.redirect_uri || "#{external_url}/auth/oidc/#{provider.id}/callback/",
response_type: provider.response_type,
scope: provider.scope,
label: provider.label
]
}
end)
end
end

View File

@@ -31,11 +31,11 @@ defmodule FzHttp.Release do
change_password(email(), default_password())
reset_role(email(), :admin)
else
Users.create_admin_user(
Users.create_admin_user(%{
email: email(),
password: default_password(),
password_confirmation: default_password()
)
})
end
# Notify the user
@@ -57,14 +57,13 @@ defmodule FzHttp.Release do
"password_confirmation" => password
}
{:ok, _user} =
Users.get_user!(email: email)
|> Users.admin_update_user(params)
{:ok, user} = Users.fetch_user_by_email(email)
{:ok, _user} = Users.admin_update_user(user, params)
end
def reset_role(email, role) do
Users.get_user!(email: email)
|> Users.update_user_role(role)
{:ok, user} = Users.fetch_user_by_email(email)
Users.update_user_role(user, role)
end
def repos do
@@ -80,7 +79,10 @@ defmodule FzHttp.Release do
end
defp default_admin_user do
Users.get_by_email(email())
case Users.fetch_user_by_email(email()) do
{:ok, user} -> user
{:error, :not_found} -> nil
end
end
defp mint_jwt(%User{} = user) do

View File

@@ -3,5 +3,23 @@ defmodule FzHttp.Repo do
otp_app: :fz_http,
adapter: Ecto.Adapters.Postgres
require Logger
@doc """
Similar to `Ecto.Repo.one/2`, fetches a single result from the query.
Returns `{:ok, schema}` or `{:error, :not_found}` if no result was found.
Raises if there is more than one row matching the query.
"""
@spec fetch(queryable :: Ecto.Queryable.t(), opts :: Keyword.t()) ::
{:ok, Ecto.Schema.t()} | {:error, :not_found}
def fetch(queryable, opts \\ []) do
case __MODULE__.one(queryable, opts) do
nil -> {:error, :not_found}
schema -> {:ok, schema}
end
end
@doc """
Alias of `Ecto.Repo.one!/2` added for naming convenience.
"""
def fetch!(queryable, opts \\ []), do: __MODULE__.one!(queryable, opts)
end

View File

@@ -97,7 +97,7 @@ defmodule FzHttp.Telemetry do
common_fields() ++
[
devices_active_within_24h: Devices.count_active_within(@active_device_window),
admin_count: Users.count(role: :admin),
admin_count: Users.count_by_role(:admin),
user_count: Users.count(),
in_docker: in_docker?(),
device_count: Devices.count(),

View File

@@ -1,140 +1,127 @@
defmodule FzHttp.Users do
@moduledoc """
The Users context.
"""
import Ecto.Changeset
import Ecto.Query, warn: false
alias FzHttp.Devices.Device
alias FzHttp.Repo
alias FzHttp.{Repo, Validator, Configurations}
alias FzHttp.Telemetry
alias FzHttp.Users.User
alias FzHttpWeb.Mailer
require Logger
# one hour
@sign_in_token_validity_secs 3600
require Ecto.Query
def count do
Repo.one(from u in User, select: count(u.id))
User.Query.all()
|> Repo.aggregate(:count)
end
def count(role: role) do
Repo.one(from u in User, select: count(u.id), where: u.role == ^role)
def count_by_role(role) do
User.Query.by_role(role)
|> Repo.aggregate(:count)
end
def consume_sign_in_token(token) when is_binary(token) do
case find_and_clear_token(token) do
{:ok, {:ok, user}} -> {:ok, user}
{:ok, {:error, msg}} -> {:error, msg}
def fetch_user_by_id(id) do
if Validator.valid_uuid?(id) do
User.Query.by_id(id)
|> Repo.fetch()
else
{:error, :not_found}
end
end
def exists?(user_id) when is_nil(user_id) do
false
def fetch_user_by_id!(id) do
User.Query.by_id(id)
|> Repo.fetch!()
end
def exists?(user_id) do
Repo.exists?(from u in User, where: u.id == ^user_id)
def fetch_user_by_email(email) do
User.Query.by_email(email)
|> Repo.fetch()
end
def list_admins do
Repo.all(from User, where: [role: :admin])
end
def get_user!(email: email) do
Repo.get_by!(User, email: email)
end
def get_user!(id), do: Repo.get!(User, id)
def get_user(id), do: Repo.get(User, id)
def get_by_email(email) do
Repo.get_by(User, email: email)
end
def get_by_email!(email) do
Repo.get_by!(User, email: email)
end
def create_admin_user(attrs) do
create_user_with_role(attrs, :admin)
end
def create_unprivileged_user(attrs) do
create_user_with_role(attrs, :unprivileged)
end
def create_user_with_role(attrs, role) do
attrs
|> Enum.into(%{})
|> create_user(role: role)
end
def create_user(attrs, overwrites \\ []) do
changeset =
User
|> struct(sign_in_keys())
|> User.create_changeset(attrs)
result =
overwrites
|> Enum.reduce(changeset, fn {k, v}, cs -> put_change(cs, k, v) end)
|> Repo.insert()
case result do
{:ok, _user} ->
Telemetry.add_user()
_ ->
nil
def fetch_user_by_id_or_email(id_or_email) do
if Validator.valid_uuid?(id_or_email) do
fetch_user_by_id(id_or_email)
else
fetch_user_by_email(id_or_email)
end
result
end
def sign_in_keys do
%{
sign_in_token: FzCommon.FzCrypto.rand_string(),
sign_in_token_created_at: DateTime.utc_now()
}
def list_users(opts \\ []) do
{hydrate, _opts} = Keyword.pop(opts, :hydrate, [])
User.Query.all()
|> hydrate_fields(hydrate)
|> Repo.all()
end
def admin_update_user(%User{} = user, attrs) do
defp hydrate_fields(queryable, []), do: queryable
defp hydrate_fields(queryable, [:device_count | rest]) do
queryable
|> User.Query.hydrate_device_count()
|> hydrate_fields(rest)
end
def request_sign_in_token(%User{} = user) do
user
|> User.update_email(attrs)
|> User.update_role(attrs)
|> User.update_password(attrs)
|> User.Changeset.generate_sign_in_token()
|> Repo.update()
end
def admin_update_self(%User{} = user, attrs) do
def consume_sign_in_token(%User{sign_in_token_hash: nil}, _token) do
{:error, :no_token}
end
def consume_sign_in_token(%User{} = user, token) when is_binary(token) do
if FzCommon.FzCrypto.equal?(token, user.sign_in_token_hash) do
User.Query.by_id(user.id)
|> User.Query.where_sign_in_token_is_not_expired()
|> Ecto.Query.update(set: [sign_in_token_hash: nil, sign_in_token_created_at: nil])
|> Ecto.Query.select([users: users], users)
|> Repo.update_all([])
|> case do
{1, [user]} -> {:ok, user}
{0, []} -> {:error, :token_expired}
end
else
{:error, :invalid_token}
end
end
def create_admin_user(attrs) do
create_user(attrs, :admin)
end
def create_unprivileged_user(attrs) do
create_user(attrs, :unprivileged)
end
def create_user(attrs, role \\ :unprivileged) do
User.Changeset.create_changeset(role, attrs)
|> insert_user()
end
defp insert_user(%Ecto.Changeset{} = changeset) do
with {:ok, user} <- Repo.insert(changeset) do
Telemetry.add_user()
{:ok, user}
end
end
# XXX: This should go down to single function update_user(self, attrs, subject)
# where subject will know role of an updater and if he is updating himself.
def admin_update_user(%User{} = user, attrs) do
user
|> User.update_email(attrs)
|> User.update_password(attrs)
|> User.require_current_password(attrs)
|> User.Changeset.update_user_role(attrs)
|> User.Changeset.update_user_email(attrs)
|> User.Changeset.update_user_password(attrs)
|> Repo.update()
end
def unprivileged_update_self(%User{} = user, attrs) do
user
|> User.require_password_change(attrs)
|> User.update_password(attrs)
|> User.Changeset.update_user_password(attrs)
|> Repo.update()
end
def update_user_role(%User{} = user, role) do
user
|> User.update_role(%{role: role})
|> Repo.update()
end
def update_user_sign_in_token(%User{} = user, attrs) do
user
|> User.update_sign_in_token(attrs)
|> User.Changeset.update_user_role(%{role: role})
|> Repo.update()
end
@@ -143,20 +130,14 @@ defmodule FzHttp.Users do
Repo.delete(user)
end
def change_user(%User{} = user \\ struct(User)) do
change(user)
end
def new_user do
change_user(%User{})
end
def list_users do
Repo.all(User)
# XXX: This should return real changeset not just a dummy one listing all the fields
def change_user(%User{} = user \\ %User{}) do
Ecto.Changeset.change(user)
end
def as_settings do
Repo.all(from u in User, select: %{id: u.id})
User.Query.select_id_map()
|> Repo.all()
|> Enum.map(&setting_projection/1)
|> MapSet.new()
end
@@ -165,120 +146,35 @@ defmodule FzHttp.Users do
user.id
end
@doc """
Fetches all users and groups into an Enumerable that can be used for an HTML form input.
"""
def as_options_for_select do
Repo.all(from u in User, select: {u.email, u.id})
end
def list_users(:with_device_counts) do
query =
from(
user in User,
left_join: device in Device,
on: device.user_id == user.id,
group_by: user.id,
select_merge: %{device_count: count(device.id)}
)
Repo.all(query)
end
def update_last_signed_in(user, %{provider: provider} = _auth) do
def update_last_signed_in(user, %{provider: provider}) do
method =
case provider do
:identity -> "email"
m -> to_string(m)
other -> to_string(other)
end
user
|> User.update_last_signed_in(%{
|> User.Changeset.update_last_signed_in(%{
last_signed_in_at: DateTime.utc_now(),
last_signed_in_method: method
})
|> Repo.update()
end
def enable_vpn_connection(user, %{provider: :identity}), do: user
def enable_vpn_connection(user, %{provider: :magic_link}), do: user
def enable_vpn_connection(user, %{provider: _oidc_provider}) do
user
|> change()
|> put_change(:disabled_at, nil)
|> Repo.update!()
def vpn_session_expires_at(user) do
DateTime.add(user.last_signed_in_at, Configurations.vpn_duration())
end
@doc """
Returns DateTime that VPN sessions expire based on last_signed_in_at
and the security.require_auth_for_vpn_frequency setting.
"""
def vpn_session_expires_at(user, duration) do
DateTime.add(user.last_signed_in_at, duration)
end
def vpn_session_expired?(user, duration) do
max = FzHttp.Configurations.Configuration.max_vpn_session_duration()
case duration do
0 ->
def vpn_session_expired?(user) do
cond do
is_nil(user.last_signed_in_at) ->
false
^max ->
is_nil(user.last_signed_in_at)
not Configurations.vpn_sessions_expire?() ->
false
_num ->
is_nil(user.last_signed_in_at) ||
DateTime.diff(vpn_session_expires_at(user, duration), DateTime.utc_now()) <= 0
end
end
def reset_sign_in_token(email) do
with %User{} = user <- Repo.get_by(User, email: email),
{:ok, user} <- update_user_sign_in_token(user, sign_in_keys()) do
Mailer.AuthEmail.magic_link(user) |> Mailer.deliver!()
:ok
else
nil ->
Logger.info("Attempt to reset password of non-existing email: #{email}")
:ok
{:error, _changeset} ->
# failed to update user, something wrong internally
Logger.error("Could not update user #{email} for magic link.")
:error
end
end
defp find_by_token(token) do
validity_secs = -1 * @sign_in_token_validity_secs
now = DateTime.utc_now()
Repo.one(
from(u in User,
where:
u.sign_in_token == ^token and
u.sign_in_token_created_at > datetime_add(^now, ^validity_secs, "second")
)
)
end
defp find_and_clear_token(token) do
Repo.transaction(fn ->
case find_by_token(token) do
nil -> {:error, "Token invalid."}
user -> clear_token(user)
end
end)
end
defp clear_token(user) do
result = update_user_sign_in_token(user, %{sign_in_token: nil, sign_in_token_created_at: nil})
case result do
{:ok, user} -> {:ok, user}
_ -> {:error, "Unexpected error attempting to clear sign in token."}
true ->
DateTime.diff(vpn_session_expires_at(user), DateTime.utc_now()) <= 0
end
end
end

View File

@@ -1,43 +0,0 @@
defmodule FzHttp.Users.PasswordHelpers do
@moduledoc """
Helpers for validating changesets with passwords
"""
import Ecto.Changeset
def validate_password_equality(%Ecto.Changeset{valid?: true} = changeset) do
password = changeset.changes[:password]
password_confirmation = changeset.changes[:password_confirmation]
if password != password_confirmation do
add_error(changeset, :password, "does not match password confirmation.")
else
changeset
end
end
def validate_password_equality(changeset), do: changeset
def put_password_hash(%Ecto.Changeset{changes: %{password: password}} = changeset)
when password in ["", nil] do
changeset
end
def put_password_hash(
%Ecto.Changeset{
valid?: true,
changes: %{password: password}
} = changeset
) do
changeset
|> put_change(:password_hash, Argon2.hash_pwd_salt(password))
|> delete_change(:password)
|> delete_change(:password_confirmation)
end
def put_password_hash(changeset) do
changeset
|> delete_change(:password)
|> delete_change(:password_confirmation)
end
end

View File

@@ -1,123 +1,30 @@
defmodule FzHttp.Users.User do
@moduledoc """
Represents a User.
"""
use FzHttp, :schema
import Ecto.Changeset
import FzHttp.Users.PasswordHelpers
alias FzHttp.{
ApiTokens.ApiToken,
Devices.Device,
OIDC.Connection,
Validators.Common
}
@min_password_length 12
@max_password_length 64
schema "users" do
field :role, Ecto.Enum, values: [:unprivileged, :admin], default: :unprivileged
field :role, Ecto.Enum, values: [:unprivileged, :admin]
field :email, :string
field :password_hash, :string
field :last_signed_in_at, :utc_datetime_usec
field :last_signed_in_method, :string
field :password_hash, :string
field :sign_in_token, :string
field :sign_in_token, :string, virtual: true, redact: true
field :sign_in_token_hash, :string
field :sign_in_token_created_at, :utc_datetime_usec
field :disabled_at, :utc_datetime_usec
# VIRTUAL FIELDS
# Virtual fields
field :password, :string, virtual: true, redact: true
field :password_confirmation, :string, virtual: true, redact: true
# Virtual fields that can be hydrated
field :device_count, :integer, virtual: true
field :password, :string, virtual: true
field :password_confirmation, :string, virtual: true
field :current_password, :string, virtual: true
has_many :devices, Device
has_many :oidc_connections, Connection
has_many :api_tokens, ApiToken
has_many :devices, FzHttp.Devices.Device
has_many :oidc_connections, FzHttp.OIDC.Connection
has_many :api_tokens, FzHttp.ApiTokens.ApiToken
field :disabled_at, :utc_datetime_usec
timestamps()
end
def create_changeset(user, attrs \\ %{}) do
user
|> cast(attrs, [
:email,
:password_hash,
:password,
:password_confirmation
])
|> update_change(:email, &String.trim/1)
|> validate_required([:email])
|> validate_password_equality()
|> validate_length(:password, min: @min_password_length, max: @max_password_length)
|> validate_format(:email, ~r/@/)
|> unique_constraint(:email)
|> put_password_hash()
end
def require_current_password(user, attrs) do
user
|> cast(attrs, [:current_password])
|> validate_required([:current_password])
|> verify_current_password()
end
def update_password(user, attrs) do
user
|> cast(attrs, [:password, :password_confirmation])
|> then(fn
%{changes: %{password: _}} = changeset ->
validate_length(changeset, :password, min: @min_password_length, max: @max_password_length)
changeset ->
changeset
end)
|> validate_password_equality()
|> put_password_hash()
|> validate_required([:password_hash])
end
def require_password_change(user, attrs) do
user
|> cast(attrs, [:password, :password_confirmation])
|> validate_required([:password, :password_confirmation])
end
def update_email(user, attrs) do
user
|> cast(attrs, [:email])
|> Common.trim_change(:email)
|> validate_required([:email])
|> validate_format(:email, ~r/@/)
end
def update_role(user, attrs) do
user
|> cast(attrs, [:role])
|> validate_required([:role])
end
def update_sign_in_token(user, attrs) do
cast(user, attrs, [:sign_in_token, :sign_in_token_created_at])
end
def update_last_signed_in(user, attrs) do
cast(user, attrs, [:last_signed_in_method, :last_signed_in_at])
end
defp verify_current_password(
%Ecto.Changeset{
data: %{password_hash: password_hash},
changes: %{current_password: current_password}
} = changeset
) do
if Argon2.verify_pass(current_password, password_hash) do
delete_change(changeset, :current_password)
else
add_error(changeset, :current_password, "invalid password")
end
end
defp verify_current_password(changeset), do: changeset
end

View File

@@ -0,0 +1,68 @@
defmodule FzHttp.Users.User.Changeset do
use FzHttp, :changeset
alias FzHttp.Users
@min_password_length 12
@max_password_length 64
def create_changeset(role, attrs) when is_atom(role) do
%Users.User{}
|> cast(attrs, ~w[
email
password
password_confirmation
]a)
|> put_change(:role, role)
|> change_email_changeset()
|> validate_if_changed(:password, &change_password_changeset/1)
end
def update_user_password(user, attrs) do
user
|> cast(attrs, [:password])
|> validate_if_changed(:password, &change_password_changeset/1)
end
def update_user_email(user, attrs) do
user
|> cast(attrs, [:email])
|> validate_if_changed(:email, &change_email_changeset/1)
end
def update_user_role(user, attrs) do
user
|> cast(attrs, [:role])
|> validate_required([:role])
end
defp change_email_changeset(%Ecto.Changeset{} = changeset) do
changeset
|> trim_change(:email)
|> validate_required([:email, :role])
|> validate_email(:email)
|> unique_constraint(:email)
end
defp change_password_changeset(%Ecto.Changeset{} = changeset) do
changeset
|> validate_required([:password])
|> validate_confirmation(:password, required: true)
|> validate_length(:password, min: @min_password_length, max: @max_password_length)
|> put_hash(:password, to: :password_hash)
|> redact_field(:password)
|> redact_field(:password_confirmation)
|> validate_required([:password_hash])
end
def generate_sign_in_token(%Users.User{} = user) do
user
|> change()
|> put_change(:sign_in_token, FzCommon.FzCrypto.rand_string())
|> put_hash(:sign_in_token, to: :sign_in_token_hash)
|> put_change(:sign_in_token_created_at, DateTime.utc_now())
end
def update_last_signed_in(user, attrs) do
cast(user, attrs, [:last_signed_in_method, :last_signed_in_at])
end
end

View File

@@ -0,0 +1,45 @@
defmodule FzHttp.Users.User.Query do
use FzHttp, :query
def all do
from(users in FzHttp.Users.User, as: :users)
end
def by_id(queryable \\ all(), id) do
where(queryable, [users: users], users.id == ^id)
end
def by_email(queryable \\ all(), email) do
where(queryable, [users: users], users.email == ^email)
end
def by_role(queryable \\ all(), role) do
where(queryable, [users: users], users.role == ^role)
end
def where_sign_in_token_is_not_expired(queryable \\ all()) do
queryable
|> where(
[users: users],
datetime_add(users.sign_in_token_created_at, 1, "hour") >= fragment("NOW()")
)
end
def select_id_map(queryable \\ all()) do
queryable
|> select([users: users], %{id: users.id})
end
def hydrate_device_count(queryable \\ all()) do
queryable
|> with_assoc(:devices)
|> group_by([users: users], users.id)
|> select_merge([users: users, devices: devices], %{device_count: count(devices.id)})
end
def with_assoc(queryable \\ all(), assoc) do
with_named_binding(queryable, assoc, fn query, binding ->
join(query, :left, [users: users], a in assoc(users, ^binding), as: ^binding)
end)
end
end

View File

@@ -1,15 +1,13 @@
defmodule FzHttp.Validators.Common do
@moduledoc """
Shared validators to use between schemas.
defmodule FzHttp.Validator do
@doc """
A set of changeset helpers and schema extensions to simplify our changesets and make validation more reliable.
"""
import Ecto.Changeset
alias FzCommon.FzNet
import FzCommon.FzNet,
only: [
valid_ip?: 1,
valid_cidr?: 1
]
def validate_email(changeset, field) do
validate_format(changeset, field, ~r/@/, message: "is invalid email address")
end
def validate_uri(changeset, fields) when is_list(fields) do
Enum.reduce(fields, changeset, fn field, accumulated_changeset ->
@@ -46,7 +44,7 @@ defmodule FzHttp.Validators.Common do
validate_change(changeset, field, fn _current_field, value ->
value
|> split_comma_list()
|> Enum.find(&(not valid_ip?(&1)))
|> Enum.find(&(not FzNet.valid_ip?(&1)))
|> error_if(
&(!is_nil(&1)),
&{field, "is invalid: #{&1} is not a valid IPv4 / IPv6 address"}
@@ -58,7 +56,7 @@ defmodule FzHttp.Validators.Common do
validate_change(changeset, field, fn _current_field, value ->
value
|> split_comma_list()
|> Enum.find(&(not (valid_ip?(&1) or valid_cidr?(&1))))
|> Enum.find(&(not (FzNet.valid_ip?(&1) or FzNet.valid_cidr?(&1))))
|> error_if(
&(!is_nil(&1)),
&{field, "is invalid: #{&1} is not a valid IPv4 / IPv6 address or CIDR range"}
@@ -105,6 +103,73 @@ defmodule FzHttp.Validators.Common do
end
end
@doc """
Takes value from `value_field` and puts it's hash to `hash_field`.
"""
def put_hash(%Ecto.Changeset{} = changeset, value_field, to: hash_field) do
with {:ok, value} when is_binary(value) and value != "" <-
fetch_change(changeset, value_field) do
put_change(changeset, hash_field, FzCommon.FzCrypto.hash(value))
else
_ -> changeset
end
end
@doc """
Validates that value in a given `value_field` equals to hash stored in `hash_field`.
"""
def validate_hash(changeset, value_field, hash_field: hash_field) do
with {:data, hash} <- fetch_field(changeset, hash_field) do
validate_change(changeset, value_field, fn value_field, token ->
if FzCommon.FzCrypto.equal?(token, hash) do
[]
else
[{value_field, {"is invalid", [validation: :hash]}}]
end
end)
else
{:changes, _hash} ->
add_error(changeset, value_field, "can not be verified", validation: :hash)
:error ->
add_error(changeset, value_field, "is already verified", validation: :hash)
end
end
def validate_if_true(%Ecto.Changeset{} = changeset, field, callback)
when is_function(callback, 1) do
case fetch_field(changeset, field) do
{_data_or_changes, true} ->
callback.(changeset)
_else ->
changeset
end
end
def validate_if_changed(%Ecto.Changeset{} = changeset, field, callback)
when is_function(callback, 1) do
with {:ok, _value} <- fetch_change(changeset, field) do
callback.(changeset)
else
_ -> changeset
end
end
@doc """
Removes change for a given field and original value from it from `changeset.params`.
Even though `changeset.params` considered to be a private field it leaks values even
after they are removed from a changeset if you `inspect(struct, structs: false)` or
just access it directly.
"""
def redact_field(%Ecto.Changeset{} = changeset, field) do
changeset = delete_change(changeset, field)
%{changeset | params: Map.drop(changeset.params, field_variations(field))}
end
defp field_variations(field) when is_atom(field), do: [field, Atom.to_string(field)]
@doc """
Puts the change if field is not changed or it's value is set to `nil`.
"""
@@ -126,4 +191,13 @@ defmodule FzHttp.Validators.Common do
def trim_change(changeset, field) do
update_change(changeset, field, &if(!is_nil(&1), do: String.trim(&1)))
end
@doc """
Returns `true` when binary representation of Ecto UUID is valid, otherwise - `false`.
"""
def valid_uuid?(binary) when is_binary(binary),
do: match?(<<_::64, ?-, _::32, ?-, _::32, ?-, _::32, ?-, _::96>>, binary)
def valid_uuid?(_binary),
do: false
end

View File

@@ -1,21 +0,0 @@
defmodule FzHttp.Validators.OpenIDConnect do
@moduledoc """
Validators various fields related to OpenID Connect
before they're saved and passed to the underlying
openid_connect library where they could become an issue.
"""
import Ecto.Changeset
def validate_discovery_document_uri(changeset) do
changeset
|> validate_change(:discovery_document_uri, fn :discovery_document_uri, value ->
case OpenIDConnect.update_documents(discovery_document_uri: value) do
{:ok, _update_result} ->
[]
{:error, :update_documents, reason} ->
[discovery_document_uri: "is invalid. Reason: #{inspect(reason)}"]
end
end)
end
end

View File

@@ -1,21 +0,0 @@
defmodule FzHttp.Validators.SAML do
@moduledoc """
Validators for SAML configs.
"""
alias Samly.IdpData
import Ecto.Changeset
def validate_metadata(changeset) do
changeset
|> validate_change(:metadata, fn :metadata, value ->
try do
IdpData.from_xml(value, %IdpData{})
[]
catch
:exit, e ->
[metadata: "is invalid. Details: #{inspect(e)}."]
end
end)
end
end

View File

@@ -4,15 +4,11 @@ defmodule FzHttpWeb.Auth.HTML.Authentication do
"""
use Guardian, otp_app: :fz_http
use FzHttpWeb, :controller
alias FzHttp.Configurations
alias FzHttp.Telemetry
alias FzHttp.Users
alias FzHttp.Users.User
import FzHttpWeb.OIDC.Helpers
require Logger
@guardian_token_name "guardian_default_token"
@impl Guardian
@@ -22,9 +18,9 @@ defmodule FzHttpWeb.Auth.HTML.Authentication do
@impl Guardian
def resource_from_claims(%{"sub" => id}) do
case Users.get_user(id) do
nil -> {:error, :resource_not_found}
user -> {:ok, user}
case Users.fetch_user_by_id(id) do
{:ok, user} -> {:ok, user}
{:error, :not_found} -> {:error, :resource_not_found}
end
end
@@ -76,12 +72,10 @@ defmodule FzHttpWeb.Auth.HTML.Authentication do
def sign_out(conn) do
with provider_id when not is_nil(provider_id) <- Plug.Conn.get_session(conn, "login_method"),
provider when not is_nil(provider) <-
FzHttp.Configurations.get_provider_by_id(:openid_connect_providers, provider_id),
token when not is_nil(token) <- Plug.Conn.get_session(conn, "id_token"),
end_session_uri when not is_nil(end_session_uri) <-
openid_connect().end_session_uri(provider_id, %{
client_id: provider.client_id,
{:ok, config} <- Configurations.fetch_oidc_provider_config(provider_id),
{:ok, end_session_uri} <-
OpenIDConnect.end_session_uri(config, %{
id_token_hint: token,
post_logout_redirect_uri: url(~p"/")
}) do

View File

@@ -19,7 +19,7 @@ defmodule FzHttpWeb.Auth.JSON.Authentication do
@impl Guardian
def resource_from_claims(%{"api" => api_token_id}) do
with %ApiTokens.ApiToken{} = api_token <- ApiTokens.get_unexpired_api_token(api_token_id),
%Users.User{} = user <- Users.get_user(api_token.user_id) do
{:ok, %Users.User{} = user} <- Users.fetch_user_by_id(api_token.user_id) do
{:ok, user}
else
_ ->

View File

@@ -6,25 +6,16 @@ defmodule FzHttpWeb.NotificationChannel do
alias FzHttp.Users
alias FzHttpWeb.Presence
@token_verify_opts [max_age: 86_400]
@impl Phoenix.Channel
def join("notification:session", %{"user_agent" => user_agent, "token" => token}, socket) do
case Phoenix.Token.verify(socket, "channel auth", token, @token_verify_opts) do
{:ok, user_id} ->
socket =
socket
|> assign(:current_user, Users.get_user!(user_id))
|> assign(:user_agent, user_agent)
def join("notification:session", _attrs, socket) do
socket = FzHttpWeb.Sandbox.allow_channel_sql_sandbox(socket)
send(self(), :after_join)
{:ok,
socket
|> assign(:current_user, Users.get_user!(user_id))}
{:error, _} ->
{:error, %{reason: "unauthorized"}}
with {:ok, user} <- Users.fetch_user_by_id(socket.assigns.current_user_id) do
socket = assign(socket, :current_user, user)
send(self(), :after_join)
{:ok, socket}
else
_ -> {:error, %{reason: "unauthorized"}}
end
end

View File

@@ -6,6 +6,10 @@ defmodule FzHttpWeb.ControllerHelpers do
alias FzHttp.Users.User
def root_path_for_user(nil) do
~p"/"
end
def root_path_for_user(%User{role: :admin}) do
~p"/users"
end

View File

@@ -3,17 +3,15 @@ defmodule FzHttpWeb.AuthController do
Implements the CRUD for a Session
"""
use FzHttpWeb, :controller
require Logger
@local_auth_providers [:identity, :magic_link]
alias FzHttp.Users
alias FzHttp.Configurations
alias FzHttpWeb.Auth.HTML.Authentication
alias FzHttpWeb.OAuth.PKCE
alias FzHttpWeb.OIDC.State
alias FzHttpWeb.UserFromAuth
require Logger
import FzHttpWeb.OIDC.Helpers
@local_auth_providers [:identity, :magic_link]
# Uncomment when Helpers.callback_url/1 is fixed
# alias Ueberauth.Strategy.Helpers
@@ -40,6 +38,14 @@ defmodule FzHttpWeb.AuthController do
{:ok, user} ->
maybe_sign_in(conn, user, auth)
{:error, reason} when reason in [:not_found, :invalid_credentials] ->
conn
|> put_flash(
:error,
"Error signing in: user credentials are invalid or user does not exist"
)
|> request(%{})
{:error, reason} ->
conn
|> put_flash(:error, "Error signing in: #{reason}")
@@ -62,8 +68,9 @@ defmodule FzHttpWeb.AuthController do
token_params = Map.merge(params, PKCE.token_params(conn))
with :ok <- State.verify_state(conn, state),
{:ok, tokens} <- openid_connect().fetch_tokens(provider_id, token_params),
{:ok, claims} <- openid_connect().verify(provider_id, tokens["id_token"]) do
{:ok, config} <- Configurations.fetch_oidc_provider_config(provider_id),
{:ok, tokens} <- OpenIDConnect.fetch_tokens(config, token_params),
{:ok, claims} <- OpenIDConnect.verify(config, tokens["id_token"]) do
case UserFromAuth.find_or_create(provider_id, claims) do
{:ok, user} ->
# only first-time connect will include refresh token
@@ -117,25 +124,28 @@ defmodule FzHttpWeb.AuthController do
end
def magic_link(conn, %{"email" => email}) do
case Users.reset_sign_in_token(email) do
:ok ->
conn
|> put_flash(:info, "Please check your inbox for the magic link.")
|> redirect(to: ~p"/")
with {:ok, user} <- Users.fetch_user_by_email(email),
{:ok, user} <- Users.request_sign_in_token(user) do
FzHttpWeb.Mailer.AuthEmail.magic_link(user)
|> FzHttpWeb.Mailer.deliver!()
:error ->
conn
|> put_flash(:info, "Please check your inbox for the magic link.")
|> redirect(to: ~p"/")
else
{:error, :not_found} ->
conn
|> put_flash(:warning, "Failed to send magic link email.")
|> redirect(to: ~p"/auth/reset_password")
end
end
def magic_sign_in(conn, %{"token" => token}) do
case Users.consume_sign_in_token(token) do
{:ok, user} ->
maybe_sign_in(conn, user, %{provider: :magic_link})
{:error, _} ->
def magic_sign_in(conn, %{"user_id" => user_id, "token" => token}) do
with {:ok, user} <- Users.fetch_user_by_id(user_id),
{:ok, _user} <- Users.consume_sign_in_token(user, token) do
maybe_sign_in(conn, user, %{provider: :magic_link})
else
{:error, _reason} ->
conn
|> put_flash(:error, "The magic link is not valid or has expired.")
|> redirect(to: ~p"/")
@@ -152,12 +162,23 @@ defmodule FzHttpWeb.AuthController do
code_challenge: PKCE.code_challenge(verifier)
}
uri = openid_connect().authorization_uri(provider_id, params)
with {:ok, config} <- Configurations.fetch_oidc_provider_config(provider_id),
{:ok, uri} <- OpenIDConnect.authorization_uri(config, params) do
conn
|> PKCE.put_cookie(verifier)
|> State.put_cookie(params.state)
|> redirect(external: uri)
else
{:error, :not_found} ->
{:error, :not_found}
conn
|> PKCE.put_cookie(verifier)
|> State.put_cookie(params.state)
|> redirect(external: uri)
{:error, reason} ->
Logger.error("Can not redirect user to OIDC auth uri", reason: inspect(reason))
conn
|> put_flash(:error, "Error while processing OpenID request.")
|> redirect(to: ~p"/")
end
end
defp maybe_sign_in(conn, user, %{provider: provider_key} = auth)

View File

@@ -14,7 +14,6 @@ defmodule FzHttpWeb.JSON.UserController do
"""
use FzHttpWeb, :controller
alias FzHttp.Users
alias FzHttp.Users.User
action_fallback(FzHttpWeb.JSON.FallbackController)
@@ -50,7 +49,7 @@ defmodule FzHttpWeb.JSON.UserController do
"""
@doc api_doc: [action: "Create a User"]
def create(conn, %{"user" => %{"role" => "admin"} = user_params}) do
with {:ok, %User{} = user} <- Users.create_admin_user(user_params) do
with {:ok, %Users.User{} = user} <- Users.create_admin_user(user_params) do
conn
|> put_status(:created)
|> put_resp_header("location", ~p"/v0/users/#{user}")
@@ -59,7 +58,7 @@ defmodule FzHttpWeb.JSON.UserController do
end
def create(conn, %{"user" => user_params}) do
with {:ok, %User{} = user} <- Users.create_unprivileged_user(user_params) do
with {:ok, %Users.User{} = user} <- Users.create_unprivileged_user(user_params) do
conn
|> put_status(:created)
|> put_resp_header("location", ~p"/v0/users/#{user}")
@@ -69,8 +68,9 @@ defmodule FzHttpWeb.JSON.UserController do
@doc api_doc: [summary: "Get User by ID or Email"]
def show(conn, %{"id" => id_or_email}) do
user = get_user_by_id_or_email(id_or_email)
render(conn, "show.json", user: user)
with {:ok, %Users.User{} = user} <- Users.fetch_user_by_id_or_email(id_or_email) do
render(conn, "show.json", user: user)
end
end
@doc """
@@ -78,27 +78,19 @@ defmodule FzHttpWeb.JSON.UserController do
"""
@doc api_doc: [action: "Update a User"]
def update(conn, %{"id" => id_or_email, "user" => user_params}) do
user = get_user_by_id_or_email(id_or_email)
with {:ok, %User{} = user} <- Users.admin_update_user(user, user_params) do
with {:ok, %Users.User{} = user} <- Users.fetch_user_by_id_or_email(id_or_email),
{:ok, %Users.User{} = user} <- Users.admin_update_user(user, user_params) do
render(conn, "show.json", user: user)
end
end
@doc api_doc: [summary: "Delete a User"]
def delete(conn, %{"id" => id_or_email}) do
user = get_user_by_id_or_email(id_or_email)
with {:ok, %User{}} <- Users.delete_user(user) do
send_resp(conn, :no_content, "")
end
end
defp get_user_by_id_or_email(id_or_email) do
if String.contains?(id_or_email, "@") do
Users.get_by_email!(id_or_email)
else
Users.get_user!(id_or_email)
with {:ok, %Users.User{} = user} <- Users.fetch_user_by_id_or_email(id_or_email),
{:ok, %Users.User{}} <- Users.delete_user(user) do
conn
|> put_resp_content_type("application/json")
|> send_resp(:no_content, "")
end
end
end

View File

@@ -13,7 +13,7 @@ defmodule FzHttpWeb.UserController do
user = Authentication.get_current_user(conn)
with %{role: :admin} <- user do
unless length(Users.list_admins()) > 1 do
unless Users.count_by_role(:admin) > 1 do
raise "Cannot delete one last admin"
end
end

View File

@@ -12,7 +12,7 @@ defmodule FzHttpWeb.Endpoint do
socket "/socket", FzHttpWeb.UserSocket,
websocket: [
connect_info: [:peer_data, :x_headers, :uri],
connect_info: [:user_agent, :peer_data, :x_headers, :uri],
# XXX: channel token should prevent CSWH but double check
check_origin: false
],
@@ -21,6 +21,7 @@ defmodule FzHttpWeb.Endpoint do
socket "/live", Phoenix.LiveView.Socket,
websocket: [
connect_info: [
:user_agent,
:peer_data,
:x_headers,
:uri,

View File

@@ -46,7 +46,7 @@ defmodule FzHttpWeb.DeviceLive.Admin.Show do
defp assigns(device) do
[
device: device,
user: Users.get_user!(device.user_id),
user: Users.fetch_user_by_id!(device.user_id),
page_title: device.name,
allowed_ips: Devices.allowed_ips(device),
dns: Devices.dns(device),

View File

@@ -53,7 +53,7 @@ defmodule FzHttpWeb.DeviceLive.Unprivileged.Show do
defp assigns(device) do
[
device: device,
user: Users.get_user!(device.user_id),
user: Users.fetch_user_by_id!(device.user_id),
page_title: device.name,
allowed_ips: Devices.allowed_ips(device),
port: FzHttp.Config.fetch_env!(:fz_vpn, :wireguard_port),

View File

@@ -0,0 +1,6 @@
defmodule FzHttpWeb.Hooks.AllowEctoSandbox do
def on_mount(:default, _params, _session, socket) do
socket = FzHttpWeb.Sandbox.allow_live_ecto_sandbox(socket)
{:cont, socket}
end
end

View File

@@ -19,7 +19,8 @@ defmodule FzHttpWeb.MFA.RegisterStepsComponent do
<div class="control">
<div>
<label class="radio">
<input type="radio" name="type" value="totp" checked /> Time-Based One-Time Password
<input type="radio" name="type" value="totp" id="mfa-method-totp" checked />
Time-Based One-Time Password
</label>
</div>
<!-- Coming Soon

View File

@@ -255,7 +255,7 @@
</h4>
<%= form_for @changeset, ~p"/sign_out", [id: "delete-account", method: :delete], fn _f -> %>
<%= submit(class: "button is-danger", data: [confirm: "Are you sure?"], disabled: !@allow_delete) do %>
<%= submit(class: "button is-danger", data: if(@allow_delete, do: [confirm: "Are you sure?"], else: []), disabled: !@allow_delete) do %>
<span class="icon is-small">
<i class="fas fa-trash"></i>
</span>

View File

@@ -18,7 +18,7 @@ defmodule FzHttpWeb.SettingLive.AccountFormComponent do
def handle_event("save", %{"user" => user_params}, socket) do
user = socket.assigns.user
case Users.admin_update_self(user, user_params) do
case Users.admin_update_user(user, user_params) do
{:ok, _user} ->
{:noreply,
socket

View File

@@ -39,20 +39,5 @@
autocomplete: "new-password",
label: "Password Confirmation"
) %>
<hr />
<div class="block">
<p>Enter your current password to make these changes.</p>
</div>
<div class="field">
<%= label(f, :current_password, class: "label") %>
<%= password_input(f, :current_password, class: "input password") %>
<p class="help is-danger">
<%= error_tag(f, :current_password) %>
</p>
</div>
</.form>
</div>

View File

@@ -27,7 +27,7 @@ defmodule FzHttpWeb.SettingLive.Account do
socket
|> assign(:api_token_id, params["api_token_id"])
|> assign(:subscribe_link, subscribe_link())
|> assign(:allow_delete, length(Users.list_admins()) > 1)
|> assign(:allow_delete, Users.count_by_role(:admin) > 1)
|> assign(:api_tokens, ApiTokens.list_api_tokens(socket.assigns.current_user.id))
|> assign(:changeset, Users.change_user(socket.assigns.current_user))
|> assign(:methods, MFA.list_methods(socket.assigns.current_user))
@@ -51,7 +51,7 @@ defmodule FzHttpWeb.SettingLive.Account do
def handle_params(_params, _url, socket) do
{:noreply,
socket
|> assign(:allow_delete, length(Users.list_admins()) > 1)
|> assign(:allow_delete, Users.count_by_role(:admin) > 1)
|> assign(:api_tokens, ApiTokens.list_api_tokens(socket.assigns.current_user.id))}
end

View File

@@ -77,6 +77,7 @@
<input
type="checkbox"
phx-click="toggle"
name="local_auth_enabled"
phx-value-config="local_auth_enabled"
checked={FzHttp.Configurations.get!(:local_auth_enabled)}
value={if(!FzHttp.Configurations.get!(:local_auth_enabled), do: "on")}
@@ -99,6 +100,7 @@
<input
type="checkbox"
phx-click="toggle"
name="allow_unprivileged_device_management"
phx-value-config="allow_unprivileged_device_management"
checked={FzHttp.Configurations.get!(:allow_unprivileged_device_management)}
value={
@@ -125,6 +127,7 @@
<input
type="checkbox"
phx-click="toggle"
name="allow_unprivileged_device_configuration"
phx-value-config="allow_unprivileged_device_configuration"
checked={FzHttp.Configurations.get!(:allow_unprivileged_device_configuration)}
value={
@@ -159,6 +162,7 @@
<input
type="checkbox"
phx-click="toggle"
name="disable_vpn_on_oidc_error"
phx-value-config="disable_vpn_on_oidc_error"
checked={FzHttp.Configurations.get!(:disable_vpn_on_oidc_error)}
value={if(!FzHttp.Configurations.get!(:disable_vpn_on_oidc_error), do: "on")}

View File

@@ -46,7 +46,7 @@ defmodule FzHttpWeb.SettingLive.ShowApiTokenComponent do
<hr />
<div class="block">
<h6 class="title is-6">cURL example:</h6>
<pre><code><i># List all users</i>
<pre><code id="api-usage-example"><i># List all users</i>
curl -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <%= @secret %>' \
<%= Application.fetch_env!(:fz_http, :external_url) %>/v0/users</code></pre>

View File

@@ -8,7 +8,7 @@ defmodule FzHttpWeb.UserLive.FormComponent do
@impl Phoenix.LiveComponent
def update(%{action: :new} = assigns, socket) do
changeset = Users.new_user()
changeset = Users.change_user()
{:ok,
socket

View File

@@ -17,10 +17,7 @@
<%= label(f, :email, class: "label") %>
<div class="control">
<%= text_input(f, :email,
class: "input #{input_error_class(f, :email)}",
disabled: @user && @user.id == @current_user.id
) %>
<%= text_input(f, :email, class: "input #{input_error_class(f, :email)}") %>
</div>
<p class="help is-danger">
<%= error_tag(f, :email) %>

View File

@@ -12,8 +12,8 @@ defmodule FzHttpWeb.UserLive.Index do
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:users, Users.list_users(:with_device_counts))
|> assign(:changeset, Users.new_user())
|> assign(:users, Users.list_users(hydrate: [:device_count]))
|> assign(:changeset, Users.change_user())
|> assign(:page_title, @page_title)}
end

View File

@@ -11,7 +11,7 @@ defmodule FzHttpWeb.UserLive.Show do
@impl Phoenix.LiveView
def mount(%{"id" => user_id} = _params, _session, socket) do
user = Users.get_user!(user_id)
user = Users.fetch_user_by_id!(user_id)
devices = Devices.list_devices(user)
connections = OIDC.list_connections(user)
@@ -30,7 +30,7 @@ defmodule FzHttpWeb.UserLive.Show do
"""
@impl Phoenix.LiveView
def handle_params(%{"id" => user_id} = _params, _url, socket) do
user = Users.get_user!(user_id)
user = Users.fetch_user_by_id!(user_id)
devices = Devices.list_devices(user.id)
{:noreply,
@@ -45,7 +45,7 @@ defmodule FzHttpWeb.UserLive.Show do
socket
|> put_flash(:error, "Use the account section to delete your account.")}
else
user = Users.get_user!(user_id)
user = Users.fetch_user_by_id!(user_id)
case Users.delete_user(user) do
{:ok, _} ->
@@ -74,7 +74,7 @@ defmodule FzHttpWeb.UserLive.Show do
socket
|> put_flash(:error, "Changing your own role is not supported.")}
else
user = Users.get_user!(user_id)
user = Users.fetch_user_by_id!(user_id)
role =
case action do

View File

@@ -14,6 +14,7 @@ defmodule FzHttpWeb.UserLive.VPNConnectionComponent do
<input
type="checkbox"
phx-target={@myself}
name="toggle_disabled_at"
phx-click="toggle_disabled_at"
data-confirm="Are you sure? This may affect this user's internet connectivity."
disabled={assigns[:disabled]}

View File

@@ -44,11 +44,11 @@ defmodule FzHttpWeb.LiveHelpers do
end
def vpn_expires_at(user) do
Users.vpn_session_expires_at(user, Configurations.vpn_duration())
Users.vpn_session_expires_at(user)
end
def vpn_expired?(user) do
Users.vpn_session_expired?(user, Configurations.vpn_duration())
Users.vpn_session_expired?(user)
end
defp status_digit(response_code) when is_integer(response_code) do

View File

@@ -15,7 +15,7 @@ defmodule FzHttpWeb.Mailer.AuthEmail do
|> subject("Firezone Magic Link")
|> to(user.email)
|> render_body(:magic_link,
link: url(~p"/auth/magic/#{user.sign_in_token}")
link: url(~p"/auth/magic/#{user.id}/#{user.sign_in_token}")
)
end
end

View File

@@ -1,9 +0,0 @@
defmodule FzHttpWeb.OIDC.Helpers do
@moduledoc """
Just some, ya know, helpers for OIDC flows.
"""
def openid_connect do
FzHttp.Config.fetch_env!(:fz_http, :openid_connect)
end
end

View File

@@ -59,7 +59,7 @@ defmodule FzHttpWeb.Router do
get "/reset_password", AuthController, :reset_password
post "/magic_link", AuthController, :magic_link
get "/magic/:token", AuthController, :magic_sign_in
get "/magic/:user_id/:token", AuthController, :magic_sign_in
get "/:provider", AuthController, :request
get "/:provider/callback", AuthController, :callback
@@ -93,7 +93,11 @@ defmodule FzHttpWeb.Router do
live_session(
:authenticated,
on_mount: [{FzHttpWeb.LiveAuth, :any}, {FzHttpWeb.LiveNav, nil}],
on_mount: [
FzHttpWeb.Hooks.AllowEctoSandbox,
{FzHttpWeb.LiveAuth, :any},
{FzHttpWeb.LiveNav, nil}
],
root_layout: {FzHttpWeb.LayoutView, :root}
) do
live "/auth", MFALive.Auth, :auth
@@ -125,7 +129,12 @@ defmodule FzHttpWeb.Router do
# Unprivileged Live routes
live_session(
:unprivileged,
on_mount: [{FzHttpWeb.LiveAuth, :unprivileged}, {FzHttpWeb.LiveNav, nil}, FzHttpWeb.LiveMFA],
on_mount: [
FzHttpWeb.Hooks.AllowEctoSandbox,
{FzHttpWeb.LiveAuth, :unprivileged},
{FzHttpWeb.LiveNav, nil},
FzHttpWeb.LiveMFA
],
root_layout: {FzHttpWeb.LayoutView, :unprivileged}
) do
live "/user_devices", DeviceLive.Unprivileged.Index, :index
@@ -153,7 +162,12 @@ defmodule FzHttpWeb.Router do
# Admin Live routes
live_session(
:admin,
on_mount: [{FzHttpWeb.LiveAuth, :admin}, FzHttpWeb.LiveNav, FzHttpWeb.LiveMFA],
on_mount: [
FzHttpWeb.Hooks.AllowEctoSandbox,
{FzHttpWeb.LiveAuth, :admin},
FzHttpWeb.LiveNav,
FzHttpWeb.LiveMFA
],
root_layout: {FzHttpWeb.LayoutView, :admin}
) do
live "/users", UserLive.Index, :index

View File

@@ -0,0 +1,41 @@
defmodule FzHttpWeb.Sandbox do
@moduledoc """
A set of helpers that allow Phoenix components (Channels and LiveView) to access SQL sandbox in test environment.
"""
def allow_channel_sql_sandbox(socket) do
if Map.has_key?(socket.assigns, :user_agent) do
allow(socket.assigns.user_agent)
end
socket
end
def allow_live_ecto_sandbox(socket) do
if Phoenix.LiveView.connected?(socket) do
socket
|> Phoenix.LiveView.get_connect_info(:user_agent)
|> allow()
end
socket
end
if Mix.env() in [:test, :dev] do
defp allow(metadata) do
# We notify the test process that there is someone trying to access the sandbox,
# so that it can optionally await after test has passed for the sandbox to be
# closed gracefully
case Phoenix.Ecto.SQL.Sandbox.decode_metadata(metadata) do
%{owner: owner_pid} -> send(owner_pid, {:sandbox_access, self()})
_ -> :ok
end
Phoenix.Ecto.SQL.Sandbox.allow(metadata, Ecto.Adapters.SQL.Sandbox)
end
else
defp allow(_metadata) do
:ok
end
end
end

View File

@@ -1,7 +1,5 @@
defmodule FzHttpWeb.UserSocket do
use Phoenix.Socket
alias FzHttp.Users
alias FzHttpWeb.HeaderHelpers
@blank_ip_warning """
@@ -31,6 +29,8 @@ defmodule FzHttpWeb.UserSocket do
# See `Phoenix.Token` documentation for examples in
# performing token verification on connect.
def connect(%{"token" => token}, socket, connect_info) do
socket = assign(socket, :user_agent, connect_info[:user_agent])
parse_ip(connect_info)
|> verify_token_and_assign_remote_ip(token, socket)
end
@@ -50,16 +50,28 @@ defmodule FzHttpWeb.UserSocket do
defp verify_token_and_assign_remote_ip(ip, token, socket) do
case Phoenix.Token.verify(socket, "user auth", token, @token_verify_opts) do
{:ok, user_id} ->
{:ok,
socket
|> assign(:current_user, Users.get_user!(user_id))
|> assign(:remote_ip, ip)}
socket =
socket
|> assign(:current_user_id, user_id)
|> assign(:remote_ip, ip)
{:ok, socket}
{:error, reason} ->
{:error, reason}
end
end
# No proxy
defp get_ip_address(%{peer_data: %{address: address}, x_headers: []}) do
address
end
# Proxied
defp get_ip_address(%{x_headers: x_headers}) do
RemoteIp.from(x_headers, HeaderHelpers.remote_ip_opts())
end
# Socket id's are topics that allow you to identify all sockets for a given user:
#
# def id(socket), do: "user_socket:#{socket.assigns.user_id}"
@@ -71,15 +83,5 @@ defmodule FzHttpWeb.UserSocket do
#
# Returning `nil` makes this socket anonymous.
# def id(_socket), do: nil
def id(socket), do: "user_socket:#{socket.assigns.current_user.id}"
# No proxy
defp get_ip_address(%{peer_data: %{address: address}, x_headers: []}) do
address
end
# Proxied
defp get_ip_address(%{x_headers: x_headers}) do
RemoteIp.from(x_headers, HeaderHelpers.remote_ip_opts())
end
def id(socket), do: "user_socket:#{socket.assigns.current_user_id}"
end

View File

@@ -13,22 +13,26 @@ defmodule FzHttpWeb.UserFromAuth do
credentials: %Ueberauth.Auth.Credentials{other: %{password: password}}
} = _auth
) do
Users.get_by_email(email) |> Authentication.authenticate(password)
with {:ok, user} <- Users.fetch_user_by_email(email) do
Authentication.authenticate(user, password)
end
end
# SAML
def find_or_create(:saml, provider_id, %{"email" => email}) do
case Users.get_by_email(email) do
nil -> maybe_create_user(:saml_identity_providers, provider_id, email)
user -> {:ok, user}
with {:ok, user} <- Users.fetch_user_by_email(email) do
{:ok, user}
else
{:error, :not_found} -> maybe_create_user(:saml_identity_providers, provider_id, email)
end
end
# OIDC
def find_or_create(provider_id, %{"email" => email, "sub" => _sub}) do
case Users.get_by_email(email) do
nil -> maybe_create_user(:openid_connect_providers, provider_id, email)
user -> {:ok, user}
with {:ok, user} <- Users.fetch_user_by_email(email) do
{:ok, user}
else
{:error, :not_found} -> maybe_create_user(:openid_connect_providers, provider_id, email)
end
end

View File

@@ -7,6 +7,10 @@ defmodule FzHttpWeb.ErrorView do
# "Internal Server Error"
# end
def render("404.json", _assigns) do
%{"error" => "not_found"}
end
# By default, Phoenix returns the status message from
# the template name. For example, "404.html" becomes
# "Not Found".

View File

@@ -1,11 +1,6 @@
defmodule FzHttp.MixProject do
use Mix.Project
def version do
# Use dummy version for dev and test
System.get_env("VERSION", "0.0.0+git.0.deadbeef")
end
def project do
[
app: :fz_http,
@@ -30,16 +25,17 @@ defmodule FzHttp.MixProject do
]
end
# Configuration for the OTP application.
#
# Type `mix help compile.app` for more information.
def version do
# Use dummy version for dev and test
System.get_env("VERSION", "0.0.0+git.0.deadbeef")
end
def application do
[
mod: {FzHttp.Application, []},
extra_applications: [
:logger,
:runtime_tools,
:ueberauth_identity
:runtime_tools
],
registered: [:fz_http_server]
]
@@ -49,63 +45,63 @@ defmodule FzHttp.MixProject do
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]
# Specifies your project dependencies.
#
# Type `mix help deps` for examples and options.
defp deps do
[
# Umbrella deps
{:fz_common, in_umbrella: true},
{:bypass, "~> 2.1", only: :test},
# Phoenix/Plug deps
{:plug, "~> 1.13"},
{:plug_cowboy, "~> 2.5"},
{:phoenix, "~> 1.7.0-rc.1", override: true},
{:phoenix_ecto, "~> 4.4"},
{:phoenix_html, "~> 3.2"},
{:phoenix_pubsub, "~> 2.0"},
{:phoenix_live_view, "~> 0.18.3"},
{:phoenix_live_dashboard, "~> 0.7.2"},
{:phoenix_live_reload, "~> 1.3", only: :dev},
{:phoenix_swoosh, "~> 1.0"},
{:gettext, "~> 0.18"},
# Ecto-related deps
{:postgrex, "~> 0.16"},
{:decimal, "~> 2.0"},
{:ecto_sql, "~> 3.7"},
{:ecto_network,
github: "firezone/ecto_network", ref: "7dfe65bcb6506fb0ed6050871b433f3f8b1c10cb"},
{:cloak, "~> 1.1"},
{:cloak_ecto, "~> 1.2"},
{:excoveralls, "~> 0.14", only: :test},
{:floki, ">= 0.0.0", only: :test},
{:mox, "~> 1.0.1", only: :test},
# Auth-related deps
{:guardian, "~> 2.0"},
{:guardian_db, "~> 2.0"},
{:openid_connect, github: "firezone/openid_connect", branch: "andrew/rewrite"},
# XXX: All github deps should use ref instead of always updating from master branch
{:openid_connect, github: "firezone/openid_connect"},
{:esaml, github: "firezone/esaml", override: true},
{:samly, github: "firezone/samly"},
{:ueberauth, "~> 0.7"},
{:ueberauth_identity, "~> 0.4"},
{:httpoison, "~> 1.8"},
{:argon2_elixir, "~> 2.0"},
{:phoenix_pubsub, "~> 2.0"},
{:phoenix_ecto, "~> 4.4"},
{:ecto_sql, "~> 3.7"},
{:ecto_network,
github: "firezone/ecto_network", ref: "7dfe65bcb6506fb0ed6050871b433f3f8b1c10cb"},
{:inflex, "~> 2.1"},
{:plug, "~> 1.13"},
{:postgrex, "~> 0.16"},
{:phoenix_html, "~> 3.2"},
{:phoenix_live_view, "~> 0.18.3"},
{:phoenix, "~> 1.7.0-rc.0", override: true},
{:phoenix_live_dashboard, "~> 0.7.2"},
{:phoenix_live_reload, "~> 1.3", only: :dev},
{:gettext, "~> 0.18"},
{:jason, "~> 1.2"},
{:phoenix_swoosh, "~> 1.0"},
{:gen_smtp, "~> 1.0"},
{:nimble_totp, "~> 0.2"},
# Other deps
{:remote_ip, "~> 1.0"},
# XXX: Drop it, it's not maintained anymore
{:httpoison, "~> 1.8"},
# XXX: Change this when hex package is updated
{:cidr, github: "firezone/cidr-elixir"},
{:telemetry, "~> 1.0"},
{:plug_cowboy, "~> 2.5"},
{:credo, "~> 1.5", only: [:dev, :test], runtime: false},
{:remote_ip, "~> 1.0"},
{:bureaucrat, "~> 0.2.9", only: :test}
# Used in Swoosh SMTP adapter
{:gen_smtp, "~> 1.0"},
# Test and dev deps
{:bypass, "~> 2.1", only: :test},
{:wallaby, "~> 0.30.0", only: :test},
{:bureaucrat, "~> 0.2.9", only: :test},
{:floki, "~> 0.34.0"}
]
end
# Aliases are shortcuts or tasks specific to the current project.
# For example, to create, migrate and run the seeds file at once:
#
# $ mix ecto.setup
#
# See the documentation for `Mix` for more info on aliases.
defp aliases do
[
"ecto.seed": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],

View File

@@ -0,0 +1,10 @@
defmodule FzHttp.Repo.Migrations.AddUsersSignInTokenHash do
use Ecto.Migration
def change do
alter table(:users) do
remove(:sign_in_token, :string)
add(:sign_in_token_hash, :string)
end
end
end

View File

@@ -0,0 +1,11 @@
defmodule FzHttp.Repo.Migrations.ChangeUsersEmailToCitext do
use Ecto.Migration
def change do
execute("CREATE EXTENSION IF NOT EXISTS citext")
alter table(:users) do
modify(:email, :citext)
end
end
end

View File

@@ -24,7 +24,6 @@ defmodule FzHttp.ConnectivityCheckServiceTest do
@expected_response %{reason: :nxdomain}
@url "invalid-url"
@tag capture_log: true
test "returns error reason" do
assert @expected_response = ConnectivityCheckService.post_request(@url)
assert ConnectivityChecks.list_connectivity_checks() == []

View File

@@ -108,7 +108,7 @@ defmodule FzHttp.DevicesTest do
end
test "shows devices scoped to a user", %{device: device} do
user = Users.get_user!(device.user_id)
user = Users.fetch_user_by_id!(device.user_id)
assert Devices.list_devices(user) == [device]
end
end

View File

@@ -1,57 +1,44 @@
defmodule FzHttp.OIDC.RefresherTest do
use FzHttp.DataCase, async: true
import Mox
alias FzHttp.{OIDC.Refresher, Repo}
setup :create_user
setup %{user: user} do
{bypass, [provider_attrs]} = FzHttp.ConfigurationsFixtures.start_openid_providers(["google"])
conn =
Repo.insert!(%FzHttp.OIDC.Connection{
user_id: user.id,
provider: "test",
provider: "google",
refresh_token: "REFRESH_TOKEN"
})
{:ok, conn: conn}
{:ok, conn: conn, bypass: bypass, provider_attrs: provider_attrs}
end
describe "refresh failed" do
setup do
expect(OpenIDConnect.Mock, :fetch_tokens, fn _, _ ->
{:error, :fetch_tokens, "TEST_ERROR"}
end)
test "disable user", %{user: user, conn: conn, bypass: bypass} do
FzHttp.ConfigurationsFixtures.expect_refresh_token_failure(bypass)
:ok
end
test "disable user", %{user: user, conn: conn} do
Refresher.refresh(user.id)
assert Refresher.refresh(user.id) == {:stop, :shutdown, user.id}
user = Repo.reload(user)
conn = Repo.reload(conn)
assert user.disabled_at
conn = Repo.reload(conn)
assert %{"error" => _} = conn.refresh_response
end
end
describe "refresh succeeded" do
setup do
expect(OpenIDConnect.Mock, :fetch_tokens, fn _, _ ->
{:ok, %{data: "success"}}
end)
test "does not change user", %{user: user, conn: conn, bypass: bypass} do
FzHttp.ConfigurationsFixtures.expect_refresh_token(bypass)
:ok
end
test "does not change user", %{user: user, conn: conn} do
Refresher.refresh(user.id)
assert Refresher.refresh(user.id) == {:stop, :shutdown, user.id}
user = Repo.reload(user)
conn = Repo.reload(conn)
refute user.disabled_at
conn = Repo.reload(conn)
refute match?(%{"error" => _}, conn.refresh_response)
end
end

View File

@@ -23,8 +23,9 @@ defmodule FzHttp.ReleaseTest do
describe "create_admin_user/0" do
test "creates admin when none exists" do
Release.create_admin_user()
user = Users.get_user!(email: Application.fetch_env!(:fz_http, :admin_email))
assert %User{} = user
assert {:ok, %User{}} =
Users.fetch_user_by_email(Application.fetch_env!(:fz_http, :admin_email))
end
test "reset admin password when user exists" do
@@ -54,7 +55,7 @@ defmodule FzHttp.ReleaseTest do
test "changes password", %{user: user} do
Release.change_password(user.email, "this password should be different")
new_user = Users.get_user!(email: user.email)
assert {:ok, new_user} = Users.fetch_user_by_email(user.email)
assert new_user.password_hash != user.password_hash
end

View File

@@ -41,10 +41,15 @@ defmodule FzHttp.TelemetryTest do
describe "auth" do
test "count openid providers" do
FzHttp.Configurations.put!(
:openid_connect_providers,
FzHttp.ConfigurationsFixtures.openid_connect_providers_attrs()
)
FzHttp.ConfigurationsFixtures.start_openid_providers([
"google",
"okta",
"auth0",
"azure",
"onelogin",
"keycloak",
"vault"
])
ping_data = Telemetry.ping_data()

View File

@@ -1,308 +1,520 @@
defmodule FzHttp.UsersTest do
use FzHttp.DataCase, async: true
alias FzHttp.{Repo, Users}
alias FzHttp.UsersFixtures
alias FzHttp.DevicesFixtures
alias FzHttp.Configurations
alias FzHttp.Users
describe "count/0" do
setup :create_user
test "returns correct count of all users" do
assert Users.count() == 0
UsersFixtures.create_user()
assert Users.count() == 1
UsersFixtures.create_user()
assert Users.count() == 2
end
end
describe "count/1" do
setup :create_users
@tag count: 3
test "returns the correct count of admin users" do
assert Users.count(role: :admin) == 3
describe "count_by_role/0" do
test "returns 0 when there are no users" do
assert Users.count_by_role(:unprivileged) == 0
assert Users.count_by_role(:admin) == 0
end
@tag count: 7, role: :unprivileged
test "returns the correct count of unprivileged users" do
assert Users.count(role: :unprivileged) == 7
test "returns correct count of admin users" do
UsersFixtures.create_user_with_role(:admin)
assert Users.count_by_role(:admin) == 1
assert Users.count_by_role(:unprivileged) == 0
UsersFixtures.create_user_with_role(:unprivileged)
assert Users.count_by_role(:admin) == 1
assert Users.count_by_role(:unprivileged) == 1
for _ <- 1..5, do: UsersFixtures.create_user_with_role(:unprivileged)
assert Users.count_by_role(:admin) == 1
assert Users.count_by_role(:unprivileged) == 6
end
end
describe "trimmed fields" do
test "trims expected fields" do
changeset =
Users.User.create_changeset(struct(Users.User), %{
"email" => " foo "
})
describe "fetch_user_by_id/1" do
test "returns error when user is not found" do
assert Users.fetch_user_by_id(Ecto.UUID.generate()) == {:error, :not_found}
end
assert %Ecto.Changeset{
changes: %{
email: "foo"
}
} = changeset
test "returns error when id is not a valid UUIDv4" do
assert Users.fetch_user_by_id("foo") == {:error, :not_found}
end
test "returns user" do
user = UsersFixtures.create_user()
assert {:ok, returned_user} = Users.fetch_user_by_id(user.id)
assert returned_user.id == user.id
end
end
describe "consume_sign_in_token/1 valid token" do
setup [:create_user_with_valid_sign_in_token]
describe "fetch_user_by_id!/1" do
test "raises when user is not found" do
assert_raise(Ecto.NoResultsError, fn ->
Users.fetch_user_by_id!(Ecto.UUID.generate())
end)
end
test "returns user when token is valid", %{user: user} do
{:ok, signed_in_user} = Users.consume_sign_in_token(user.sign_in_token)
test "raises when id is not a valid UUIDv4" do
assert_raise(Ecto.Query.CastError, fn ->
assert Users.fetch_user_by_id!("foo")
end)
end
test "returns user" do
user = UsersFixtures.create_user()
assert returned_user = Users.fetch_user_by_id!(user.id)
assert returned_user.id == user.id
end
end
describe "fetch_user_by_email/1" do
test "returns error when user is not found" do
assert Users.fetch_user_by_email("foo@bar") == {:error, :not_found}
end
test "returns user" do
user = UsersFixtures.create_user()
assert {:ok, returned_user} = Users.fetch_user_by_email(user.email)
assert returned_user.id == user.id
end
test "email is not case sensitive" do
user = UsersFixtures.create_user()
assert {:ok, user} = Users.fetch_user_by_email(String.upcase(user.email))
assert {:ok, ^user} = Users.fetch_user_by_email(String.downcase(user.email))
end
end
describe "fetch_user_by_id_or_email/1" do
test "returns error when user is not found" do
assert Users.fetch_user_by_id_or_email(Ecto.UUID.generate()) == {:error, :not_found}
assert Users.fetch_user_by_id_or_email("foo@bar.com") == {:error, :not_found}
assert Users.fetch_user_by_id_or_email("foo") == {:error, :not_found}
end
test "returns user by id" do
user = UsersFixtures.create_user()
assert {:ok, returned_user} = Users.fetch_user_by_id(user.id)
assert returned_user.id == user.id
end
test "returns user by email" do
user = UsersFixtures.create_user()
assert {:ok, returned_user} = Users.fetch_user_by_email(user.email)
assert returned_user.id == user.id
end
end
describe "list_users/1" do
test "returns empty list when there are not users" do
assert Users.list_users() == []
assert Users.list_users(hydrate: [:device_count]) == []
end
test "returns list of users in all roles" do
user1 = UsersFixtures.create_user_with_role(:admin)
user2 = UsersFixtures.create_user_with_role(:unprivileged)
assert users = Users.list_users()
assert length(users) == 2
assert Enum.sort(Enum.map(users, & &1.id)) == Enum.sort([user1.id, user2.id])
end
test "hydrates users with device count" do
user1 = UsersFixtures.create_user_with_role(:admin)
DevicesFixtures.create_device_for_user(user1)
user2 = UsersFixtures.create_user_with_role(:unprivileged)
DevicesFixtures.create_device_for_user(user2)
DevicesFixtures.create_device_for_user(user2)
assert users = Users.list_users(hydrate: [:device_count])
assert length(users) == 2
assert Enum.sort(Enum.map(users, &{&1.id, &1.device_count})) ==
Enum.sort([{user1.id, 1}, {user2.id, 2}])
assert users = Users.list_users(hydrate: [:device_count, :device_count])
assert Enum.sort(Enum.map(users, &{&1.id, &1.device_count})) ==
Enum.sort([{user1.id, 1}, {user2.id, 2}])
end
end
describe "request_sign_in_token/1" do
test "returns user with updated sign-in token" do
user = UsersFixtures.create_user()
refute user.sign_in_token_hash
assert {:ok, user} = Users.request_sign_in_token(user)
assert user.sign_in_token
assert user.sign_in_token_hash
assert user.sign_in_token_created_at
end
end
describe "consume_sign_in_token/1" do
test "returns user when token is valid" do
{:ok, user} =
UsersFixtures.create_user()
|> Users.request_sign_in_token()
assert {:ok, signed_in_user} = Users.consume_sign_in_token(user, user.sign_in_token)
assert signed_in_user.id == user.id
end
test "clears the sign in token when consumed", %{user: user} do
Users.consume_sign_in_token(user.sign_in_token)
test "clears the sign in token when consumed" do
{:ok, user} =
UsersFixtures.create_user()
|> Users.request_sign_in_token()
assert is_nil(Users.get_user!(user.id).sign_in_token)
assert is_nil(Users.get_user!(user.id).sign_in_token_created_at)
assert {:ok, user} = Users.consume_sign_in_token(user, user.sign_in_token)
assert is_nil(user.sign_in_token)
assert is_nil(user.sign_in_token_created_at)
assert user = Repo.one(Users.User)
assert is_nil(user.sign_in_token)
assert is_nil(user.sign_in_token_created_at)
end
test "returns error when token doesn't exist" do
user = UsersFixtures.create_user()
assert Users.consume_sign_in_token(user, "foo") == {:error, :no_token}
end
test "token expires in one hour" do
about_one_hour_ago =
DateTime.utc_now()
|> DateTime.add(-1, :hour)
|> DateTime.add(30, :second)
{:ok, user} =
UsersFixtures.create_user()
|> Users.request_sign_in_token()
user
|> Ecto.Changeset.change(sign_in_token_created_at: about_one_hour_ago)
|> Repo.update!()
assert {:ok, _user} = Users.consume_sign_in_token(user, user.sign_in_token)
end
test "returns error when token is expired" do
one_hour_and_one_second_ago =
DateTime.utc_now()
|> DateTime.add(-1, :hour)
|> DateTime.add(-1, :second)
{:ok, user} =
UsersFixtures.create_user()
|> Users.request_sign_in_token()
user =
user
|> Ecto.Changeset.change(sign_in_token_created_at: one_hour_and_one_second_ago)
|> Repo.update!()
assert Users.consume_sign_in_token(user, user.sign_in_token) == {:error, :token_expired}
end
end
describe "consume_sign_in_token/1 invalid token" do
setup [:create_user_with_expired_sign_in_token]
describe "create_user/2" do
test "returns changeset error when attrs are missing" do
assert {:error, changeset} = Users.create_user(%{})
refute changeset.valid?
test "returns {:error, msg} when token doesn't exist", %{user: _user} do
assert {:error, "Token invalid."} = Users.consume_sign_in_token("blah")
assert errors_on(changeset) == %{email: ["can't be blank"]}
end
test "returns {:error, msg} when token is expired", %{user: user} do
assert {:error, "Token invalid."} = Users.consume_sign_in_token(user.sign_in_token)
end
end
test "returns error on invalid attrs" do
assert {:error, changeset} = Users.create_user(%{email: "invalid_email", password: "short"})
refute changeset.valid?
describe "get_user!/1" do
setup [:create_user]
test "gets user by id", %{user: user} do
assert Users.get_user!(user.id).id == user.id
end
test "raises Ecto.NoResultsError for missing Users", %{user: _user} do
assert_raise(Ecto.NoResultsError, fn ->
Users.get_user!(Ecto.UUID.generate())
end)
end
end
describe "get_user/1" do
setup [:create_user]
test "returns user if found", %{user: user} do
assert Users.get_user(user.id).id == user.id
end
test "returns nil if not found" do
assert nil == Users.get_user(Ecto.UUID.generate())
end
end
describe "create_user/1" do
@valid_attrs_map %{
email: "valid@test",
password: "password1234",
password_confirmation: "password1234"
}
@valid_attrs_list [
email: "valid@test",
password: "password1234",
password_confirmation: "password1234"
]
@invalid_attrs_map %{
email: "invalid_email",
password: "password1234",
password_confirmation: "password1234"
}
@invalid_attrs_list [
email: "valid@test",
password: "password1234",
password_confirmation: "different_password1234"
]
@too_short_password [
email: "valid@test",
password: "short11",
password_confirmation: "short11"
]
@too_long_password [
email: "valid@test",
password: String.duplicate("a", 65),
password_confirmation: String.duplicate("a", 65)
]
test "doesn't create user with password too short" do
assert {:error, changeset} = Users.create_admin_user(@too_short_password)
assert changeset.errors[:password] == {
"should be at least %{count} character(s)",
[count: 12, validation: :length, kind: :min, type: :string]
assert errors_on(changeset) == %{
email: ["is invalid email address"],
password: ["should be at least 12 character(s)"],
password_confirmation: ["can't be blank"]
}
assert {:error, changeset} =
Users.create_user(%{email: "invalid_email", password: String.duplicate("A", 65)})
refute changeset.valid?
assert "should be at most 64 character(s)" in errors_on(changeset).password
assert {:error, changeset} = Users.create_user(%{email: String.duplicate(" ", 18)})
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).email
end
test "doesn't create user with password too long" do
assert {:error, changeset} = Users.create_admin_user(@too_long_password)
test "requires password confirmation to match the password" do
assert {:error, changeset} =
Users.create_user(%{password: "foo", password_confirmation: "bar"})
assert changeset.errors[:password] == {
"should be at most %{count} character(s)",
[count: 64, validation: :length, kind: :max, type: :string]
}
assert "does not match confirmation" in errors_on(changeset).password_confirmation
assert {:error, changeset} =
Users.create_user(%{
password: "password1234",
password_confirmation: "password1234"
})
refute Map.has_key?(errors_on(changeset), :password_confirmation)
end
test "creates user with valid map of attributes" do
assert {:ok, _user} = Users.create_admin_user(@valid_attrs_map)
test "returns error when email is already taken" do
attrs = UsersFixtures.user_attrs()
assert {:ok, _user} = Users.create_user(attrs)
assert {:error, changeset} = Users.create_user(attrs)
refute changeset.valid?
assert "has already been taken" in errors_on(changeset).email
end
test "creates user with valid list of attributes" do
assert {:ok, _user} = Users.create_admin_user(@valid_attrs_list)
test "returns error when role is invalid" do
attrs = UsersFixtures.user_attrs()
assert_raise Ecto.ChangeError, fn ->
Users.create_user(attrs, :foo)
end
end
test "doesn't create user with invalid map of attributes" do
assert {:error, _changeset} = Users.create_admin_user(@invalid_attrs_map)
test "creates a user in given role" do
for role <- [:admin, :unprivileged] do
attrs = UsersFixtures.user_attrs()
assert {:ok, user} = Users.create_user(attrs, role)
assert user.role == role
end
end
test "doesn't create user with invalid list of attributes" do
assert {:error, _changeset} = Users.create_admin_user(@invalid_attrs_list)
test "creates an unprivileged user" do
attrs = UsersFixtures.user_attrs()
assert {:ok, user} = Users.create_user(attrs)
assert user.role == :unprivileged
assert user.email == attrs.email
assert FzCommon.FzCrypto.equal?(attrs.password, user.password_hash)
assert is_nil(user.password)
assert is_nil(user.password_confirmation)
assert is_nil(user.last_signed_in_at)
assert is_nil(user.last_signed_in_method)
assert is_nil(user.sign_in_token)
assert is_nil(user.sign_in_token_hash)
assert is_nil(user.sign_in_token_created_at)
end
test "allows creating a user without password" do
email = UsersFixtures.user_attrs().email
attrs = %{email: email, password: nil, password_confirmation: nil}
assert {:ok, user} = Users.create_user(attrs)
assert is_nil(user.password_hash)
email = UsersFixtures.user_attrs().email
attrs = %{email: email, password: "", password_confirmation: ""}
assert {:ok, user} = Users.create_user(attrs)
assert is_nil(user.password_hash)
end
test "trims email" do
attrs = UsersFixtures.user_attrs()
assert {:ok, user} =
attrs
|> Map.put(:email, " #{attrs.email} ")
|> Users.create_user()
assert user.email == attrs.email
end
end
describe "sign_in_keys/0" do
test "generates sign in token and created at" do
params = Users.sign_in_keys()
assert is_binary(params.sign_in_token)
assert %DateTime{} = params.sign_in_token_created_at
end
end
@change_password_valid_params %{
password: "new_password",
password_confirmation: "new_password",
current_password: "password1234"
}
@change_password_invalid_params %{
"password" => "new_password",
"password_confirmation" => "new_password",
"current_password" => "invalid"
}
@password_params %{"password" => "new_password", "password_confirmation" => "new_password"}
@email_params %{"email" => "new_email@test", "current_password" => "password1234"}
@email_and_password_params %{
"password" => "new_password",
"password_confirmation" => "new_password",
"email" => "new_email@test",
"current_password" => "password1234"
}
@clear_hash_params %{"password_hash" => nil, "current_password" => "password1234"}
@empty_password_params %{
"password" => nil,
"password_confirmation" => nil,
"current_password" => "password1234"
}
@email_empty_password_params %{
"email" => "foobar@test",
"password" => "",
"password_confirmation" => "",
"current_password" => "password1234"
}
describe "admin_update_user/2" do
setup :create_user
test "changes password", %{user: user} do
{:ok, new_user} = Users.admin_update_user(user, @password_params)
assert new_user.password_hash != user.password_hash
test "returns ok on empty attrs" do
user = UsersFixtures.create_user()
assert {:ok, _user} = Users.admin_update_user(user, %{})
end
test "prevents clearing the password", %{user: user} do
{:ok, new_user} = Users.admin_update_user(user, @clear_hash_params)
assert new_user.password_hash == user.password_hash
test "allows changing user password" do
user = UsersFixtures.create_user()
attrs =
UsersFixtures.user_attrs()
|> Map.take([:password, :password_confirmation])
assert {:ok, updated_user} = Users.admin_update_user(user, attrs)
assert updated_user.password_hash != user.password_hash
end
test "nil password params", %{user: user} do
{:ok, new_user} = Users.admin_update_user(user, @empty_password_params)
assert new_user.password_hash == user.password_hash
test "allows changing user email" do
user = UsersFixtures.create_user()
attrs =
UsersFixtures.user_attrs()
|> Map.take([:email])
assert {:ok, updated_user} = Users.admin_update_user(user, attrs)
assert updated_user.email == attrs.email
assert updated_user.email != user.email
end
test "changes email", %{user: user} do
{:ok, new_user} = Users.admin_update_user(user, @email_params)
assert new_user.email == "new_email@test"
# XXX: This doesn't feel right as the outcome is a completely new user
test "allows changing both email and password" do
user = UsersFixtures.create_user()
attrs = UsersFixtures.user_attrs()
assert {:ok, updated_user} = Users.admin_update_user(user, attrs)
assert updated_user.password_hash != user.password_hash
assert updated_user.email != user.email
end
test "handles empty params", %{user: user} do
assert {:ok, _new_user} = Users.admin_update_user(user, %{})
end
test "does not allow to clear the password" do
password = "password1234"
user = UsersFixtures.create_user(%{password: password})
test "handles nil password", %{user: user} do
assert {:ok, _new_user} = Users.admin_update_user(user, @email_empty_password_params)
end
attrs = %{
"password" => nil,
"password_hash" => nil
}
test "changes email and password", %{user: user} do
{:ok, new_user} = Users.admin_update_user(user, @email_and_password_params)
assert new_user.email == "new_email@test"
assert new_user.password_hash != user.password_hash
assert {:ok, updated_user} = Users.admin_update_user(user, attrs)
assert updated_user.password_hash == user.password_hash
attrs = %{
"password" => "",
"password_hash" => ""
}
assert {:ok, updated_user} = Users.admin_update_user(user, attrs)
assert updated_user.password_hash == user.password_hash
end
end
describe "unprivileged_update_self/2" do
setup :create_user
test "changes password", %{user: user} do
{:ok, new_user} = Users.unprivileged_update_self(user, @password_params)
assert new_user.password_hash != user.password_hash
test "returns ok on empty attrs" do
user = UsersFixtures.create_user()
assert {:ok, _user} = Users.unprivileged_update_self(user, %{})
end
test "prevents clearing the password", %{user: user} do
assert {:error, _changeset} = Users.unprivileged_update_self(user, @clear_hash_params)
test "allows changing user password" do
user = UsersFixtures.create_user()
attrs =
UsersFixtures.user_attrs()
|> Map.take([:password, :password_confirmation])
assert {:ok, updated_user} = Users.unprivileged_update_self(user, attrs)
assert updated_user.password_hash != user.password_hash
end
test "prevents changing email", %{user: user} do
{:ok, new_user} = Users.unprivileged_update_self(user, @email_and_password_params)
assert new_user.email == user.email
test "does not allow changing user email" do
user = UsersFixtures.create_user()
attrs =
UsersFixtures.user_attrs()
|> Map.take([:email])
assert {:ok, updated_user} = Users.unprivileged_update_self(user, attrs)
assert updated_user.email != attrs.email
assert updated_user.email == user.email
end
test "does not allow to clear the password" do
password = "password1234"
user = UsersFixtures.create_user(%{password: password})
attrs = %{
"password" => nil,
"password_hash" => nil
}
assert {:ok, updated_user} = Users.unprivileged_update_self(user, attrs)
assert updated_user.password_hash == user.password_hash
attrs = %{
"password" => "",
"password_hash" => ""
}
assert {:ok, updated_user} = Users.unprivileged_update_self(user, attrs)
assert updated_user.password_hash == user.password_hash
end
end
describe "admin_update_self/2" do
setup :create_user
test "does not change password when current_password invalid", %{user: user} do
{:error, changeset} = Users.admin_update_self(user, @change_password_invalid_params)
assert [current_password: _] = changeset.errors
describe "update_user_role/2" do
test "allows to change user role" do
user = UsersFixtures.create_user()
assert {:ok, %{role: :unprivileged}} = Users.update_user_role(user, :unprivileged)
assert {:ok, %{role: :admin}} = Users.update_user_role(user, :admin)
end
test "changes password when current_password valid", %{user: user} do
{:ok, new_user} = Users.admin_update_self(user, @change_password_valid_params)
assert new_user.password_hash != user.password_hash
test "raises on invalid role" do
user = UsersFixtures.create_user()
assert {:error, changeset} = Users.update_user_role(user, :foo)
assert errors_on(changeset) == %{role: ["is invalid"]}
end
end
describe "update_*" do
setup :create_user
describe "delete_user/1" do
test "deletes a user" do
user = UsersFixtures.create_user()
assert {:ok, _user} = Users.delete_user(user)
assert is_nil(Repo.one(Users.User))
end
end
@sign_in_token_params %{
sign_in_token: "foobar",
sign_in_token_created_at: DateTime.utc_now()
}
describe "change_user/1" do
test "returns changeset" do
user = UsersFixtures.create_user()
assert %Ecto.Changeset{} = Users.change_user(user)
end
end
test "update sign_in_token", %{user: user} do
{:ok, new_user} = Users.update_user_sign_in_token(user, @sign_in_token_params)
describe "as_settings/0" do
test "returns list of user-id maps" do
assert Users.as_settings() == MapSet.new([])
assert new_user.sign_in_token == @sign_in_token_params.sign_in_token
expected_users =
[
UsersFixtures.create_user(),
UsersFixtures.create_user()
]
|> Enum.map(& &1.id)
{:ok, new_user} =
Users.update_user_sign_in_token(new_user, %{
sign_in_token: nil,
sign_in_token_created_at: nil
})
assert Users.as_settings() == MapSet.new(expected_users)
end
end
assert is_nil(new_user.sign_in_token)
describe "setting_projection/1" do
test "projects expected fields with user" do
user = UsersFixtures.create_user()
assert user.id == Users.setting_projection(user)
end
test "update role", %{user: user} do
{:ok, user} = Users.update_user_role(user, :admin)
assert user.role == :admin
{:ok, user} = Users.update_user_role(user, :unprivileged)
assert user.role == :unprivileged
test "projects expected fields with user map" do
user = UsersFixtures.create_user()
user_map = Map.from_struct(user)
assert user.id == Users.setting_projection(user_map)
end
end
describe "update_last_signed_in/2" do
test "updates last_signed_in_* fields" do
user = UsersFixtures.create_user()
test "update last_signed_in_*", %{user: user} do
{:ok, user} = Users.update_last_signed_in(user, %{provider: :test})
assert user.last_signed_in_method == "test"
@@ -311,81 +523,61 @@ defmodule FzHttp.UsersTest do
end
end
describe "delete_user/1" do
setup :create_user
describe "vpn_session_expires_at/1" do
test "returns expiration datetime of VPN session" do
now = DateTime.utc_now()
Configurations.put!(:vpn_session_duration, 30)
test "raises Ecto.NoResultsError when a deleted user is fetched", %{user: user} do
Users.delete_user(user)
user =
UsersFixtures.create_user()
|> change(%{last_signed_in_at: now})
|> Repo.update!()
assert_raise(Ecto.NoResultsError, fn ->
Users.get_user!(user.id)
end)
assert DateTime.diff(Users.vpn_session_expires_at(user), now, :second) in 28..32
end
end
describe "change_user/1" do
setup :create_user
test "returns changeset", %{user: user} do
assert %Ecto.Changeset{} = Users.change_user(user)
end
end
describe "new_user/0" do
test "returns changeset" do
assert %Ecto.Changeset{} = Users.new_user()
end
end
describe "enable_vpn_connection/2" do
import Ecto.Changeset
setup :create_user
setup %{user: user} do
user = user |> change |> put_change(:disabled_at, DateTime.utc_now()) |> Repo.update!()
{:ok, user: user}
describe "vpn_session_expired?/1" do
test "returns false when user did not sign in" do
Configurations.put!(:vpn_session_duration, 30)
user = UsersFixtures.create_user()
assert Users.vpn_session_expired?(user) == false
end
@tag role: :unprivileged
test "enable via OIDC", %{user: user} do
Users.enable_vpn_connection(user, %{provider: :oidc})
test "returns false when VPN session is not expired" do
Configurations.put!(:vpn_session_duration, 30)
user = UsersFixtures.create_user()
user = Repo.reload(user)
user =
user
|> change(%{last_signed_in_at: DateTime.utc_now()})
|> Repo.update!()
assert %{disabled_at: nil} = user
assert Users.vpn_session_expired?(user) == false
end
@tag role: :unprivileged
test "no change via password", %{user: user} do
Users.enable_vpn_connection(user, %{provider: :identity})
test "returns true when VPN session is expired" do
Configurations.put!(:vpn_session_duration, 30)
user = UsersFixtures.create_user()
user = Repo.reload(user)
user =
user
|> change(%{last_signed_in_at: DateTime.utc_now() |> DateTime.add(-31, :second)})
|> Repo.update!()
assert user.disabled_at
end
end
describe "setting_projection/1" do
setup [:create_rule_with_user_and_device]
test "projects expected fields with user", %{user: user} do
assert user.id == Users.setting_projection(user)
assert Users.vpn_session_expired?(user) == true
end
test "projects expected fields with user map", %{user: user} do
user_map = Map.from_struct(user)
assert user.id == Users.setting_projection(user_map)
end
end
test "returns false when VPN session never expires" do
Configurations.put!(:vpn_session_duration, 0)
user = UsersFixtures.create_user()
describe "as_settings/0" do
setup [:create_rules]
user =
user
|> change(%{last_signed_in_at: ~U[1990-01-01 01:01:01.000001Z]})
|> Repo.update!()
test "Maps rules to projections", %{users: users} do
expected_users = Enum.map(users, &Users.setting_projection/1) |> MapSet.new()
assert Users.as_settings() == expected_users
assert Users.vpn_session_expired?(user) == false
end
end
end

View File

@@ -0,0 +1,673 @@
defmodule FzHttpWeb.Acceptance.AdminTest do
use FzHttpWeb.AcceptanceCase, async: true
alias FzHttp.UsersFixtures
alias FzHttp.DevicesFixtures
setup tags do
user = UsersFixtures.create_user_with_role(:admin)
session =
tags.session
|> visit(~p"/")
|> Auth.authenticate(user)
tags
|> Map.put(:session, session)
|> Map.put(:user, user)
end
describe "user management" do
feature "create new unprivileged users without password", %{session: session, user: user} do
attrs = UsersFixtures.user_attrs()
session
|> visit(~p"/users/new")
|> assert_el(Query.text("Add User", minimum: 1))
|> fill_in(Query.fillable_field("user[email]"), with: "xxx")
|> click(Query.button("Save"))
|> assert_el(Query.text("is invalid email address"))
|> fill_in(Query.fillable_field("user[email]"), with: user.email)
|> click(Query.button("Save"))
|> assert_el(Query.text("has already been taken"))
|> fill_in(Query.fillable_field("user[email]"), with: attrs.email)
|> click(Query.button("Save"))
|> assert_el(Query.text("User created successfully."))
|> assert_el(Query.text(attrs.email, minimum: 1))
assert Repo.get_by(FzHttp.Users.User, email: attrs.email)
end
feature "create new unprivileged users with password auth", %{session: session, user: user} do
attrs = UsersFixtures.user_attrs()
session
|> visit(~p"/users/new")
|> assert_el(Query.text("Add User", minimum: 1))
|> fill_form(%{
"user[email]" => "xxx",
"user[password]" => "yyy",
"user[password_confirmation]" => "zzz"
})
|> click(Query.button("Save"))
|> assert_el(Query.text("is invalid email address"))
|> assert_el(Query.text("should be at least 12 character(s)"))
|> assert_el(Query.text("does not match confirmation"))
|> fill_form(%{
"user[email]" => user.email,
"user[password]" => "firezone1234",
"user[password_confirmation]" => "firezone1234"
})
|> click(Query.button("Save"))
|> assert_el(Query.text("has already been taken"))
# XXX: for some reason form rests when email has already been taken
|> fill_form(%{
"user[email]" => attrs.email,
"user[password]" => attrs.password,
"user[password_confirmation]" => attrs.password_confirmation
})
|> click(Query.button("Save"))
|> assert_el(Query.text("User created successfully."))
|> assert_el(Query.text("unprivileged", minimum: 1))
|> assert_el(Query.text(attrs.email, minimum: 1))
assert user = Repo.get_by(FzHttp.Users.User, email: attrs.email)
assert user.role == :unprivileged
assert FzCommon.FzCrypto.equal?(attrs.password, user.password_hash)
end
feature "change user email and password", %{session: session} do
user = UsersFixtures.create_user_with_role(:admin)
session
|> visit(~p"/users/#{user.id}")
|> assert_el(Query.link("Change Email or Password"))
|> click(Query.link("Change Email or Password"))
|> assert_el(Query.text("Change user email or enter new password below."))
|> fill_form(%{
"user[email]" => "foo",
"user[password]" => "123",
"user[password_confirmation]" => "1234"
})
|> click(Query.button("Save"))
|> assert_el(Query.text("is invalid email address"))
|> assert_el(Query.text("should be at least 12 character(s)"))
|> assert_el(Query.text("does not match confirmation"))
|> fill_form(%{
"user[email]" => "foo@xample.com",
"user[password]" => "mynewpassword",
"user[password_confirmation]" => "mynewpassword"
})
|> click(Query.button("Save"))
|> assert_el(Query.text("User updated successfully."))
assert updated_user = Repo.get(FzHttp.Users.User, user.id)
assert updated_user.password_hash != user.password_hash
assert updated_user.email == "foo@xample.com"
end
feature "promote and demote users", %{session: session} do
user = UsersFixtures.create_user_with_role(:admin)
session =
session
|> visit(~p"/users/#{user.id}")
|> assert_el(Query.link("Change Email or Password"))
accept_confirm(session, fn session ->
session
|> click(Query.button("demote"))
|> assert_el(Query.text("User updated successfully."))
end)
assert updated_user = Repo.get(FzHttp.Users.User, user.id)
assert updated_user.role == :unprivileged
accept_confirm(session, fn session ->
session
|> click(Query.button("promote"))
|> assert_el(Query.text("User updated successfully."))
end)
assert updated_user = Repo.get(FzHttp.Users.User, user.id)
assert updated_user.role == :admin
end
feature "disable and enable user VPN connection", %{session: session} do
user = UsersFixtures.create_user_with_role(:admin)
session =
session
|> visit(~p"/users/#{user.id}")
|> assert_el(Query.link("Change Email or Password"))
accept_confirm(session, fn session ->
session
|> toggle("toggle_disabled_at")
end)
# XXX: We need to show some kind of message when status changed
Process.sleep(100)
assert updated_user = Repo.get(FzHttp.Users.User, user.id)
assert updated_user.disabled_at
accept_confirm(session, fn session ->
session
|> toggle("toggle_disabled_at")
end)
Process.sleep(100)
assert updated_user = Repo.get(FzHttp.Users.User, user.id)
refute updated_user.disabled_at
end
feature "delete user", %{session: session, user: user} do
unprivileged_user = UsersFixtures.create_user_with_role(:unprivileged)
session =
session
|> visit(~p"/users/#{user.id}")
|> assert_el(Query.button("Delete User"))
accept_confirm(session, fn session ->
click(session, Query.button("Delete User"))
end)
assert_el(session, Query.text("Use the account section to delete your account."))
assert Repo.get(FzHttp.Users.User, user.id)
session
|> visit(~p"/users/#{unprivileged_user.id}")
|> assert_el(Query.button("Delete User"))
accept_confirm(session, fn session ->
click(session, Query.button("Delete User"))
end)
assert_el(session, Query.text("User deleted successfully."))
refute Repo.get(FzHttp.Users.User, unprivileged_user.id)
end
end
describe "device management" do
feature "can add devices for users", %{session: session} do
user = UsersFixtures.create_user_with_role(:unprivileged)
session
|> visit(~p"/users/#{user.id}")
|> assert_el(Query.text("No devices."))
|> assert_el(Query.link("Add Device"))
|> click(Query.link("Add Device"))
|> assert_el(Query.button("Generate Configuration"))
|> set_value(Query.css("#create-device_use_default_allowed_ips_false"), :selected)
|> set_value(Query.css("#create-device_use_default_dns_false"), :selected)
|> set_value(Query.css("#create-device_use_default_endpoint_false"), :selected)
|> set_value(Query.css("#create-device_use_default_mtu_false"), :selected)
|> set_value(
Query.css("#create-device_use_default_persistent_keepalive_false"),
:selected
)
|> fill_form(%{
"device[allowed_ips]" => "127.0.0.1",
"device[name]" => "big-leg-007",
"device[description]" => "Dummy description",
"device[dns]" => "1.1.1.1,2.2.2.2",
"device[endpoint]" => "example.com:51820",
"device[mtu]" => "1400",
"device[persistent_keepalive]" => "10",
"device[ipv4]" => "10.10.11.1",
"device[ipv6]" => "fd00::1e:3f96"
})
|> click(Query.button("Generate Configuration"))
|> assert_el(Query.text("Device added!"))
|> click(Query.css("button[phx-click=\"close\"]"))
|> assert_el(Query.link("Add Device"))
|> assert_el(Query.link("big-leg-007"))
assert device = Repo.one(FzHttp.Devices.Device)
assert device.name == "big-leg-007"
assert device.description == "Dummy description"
assert device.allowed_ips == "127.0.0.1"
assert device.dns == "1.1.1.1,2.2.2.2"
assert device.endpoint == "example.com:51820"
assert device.mtu == 1400
assert device.persistent_keepalive == 10
assert device.ipv4 == %Postgrex.INET{address: {10, 10, 11, 1}}
assert device.ipv6 == %Postgrex.INET{address: {64_768, 0, 0, 0, 0, 0, 30, 16_278}}
end
feature "can see devices, their details and delete them", %{session: session} do
device1 = DevicesFixtures.device()
device2 = DevicesFixtures.device()
session =
session
|> visit(~p"/devices")
|> assert_el(Query.text("All Devices"))
|> assert_el(Query.link(device1.name))
|> click(Query.link(device2.name))
|> assert_el(Query.text("Danger Zone"))
|> assert_el(Query.text(device2.public_key))
accept_confirm(session, fn session ->
click(session, Query.button("Delete Device #{device2.name}"))
end)
assert_el(session, Query.text("All Devices"))
assert Repo.aggregate(FzHttp.Devices.Device, :count) == 1
end
end
describe "rules" do
feature "manage allow rules", %{session: session, user: user} do
session =
session
|> visit(~p"/rules")
|> assert_has(Query.text("Egress Rules"))
|> find(Query.css("#accept-form"), fn parent ->
parent
|> set_value(Query.select("rule[port_type]"), "tcp")
|> set_value(Query.select("rule[user_id]"), user.email)
|> fill_form(%{
"rule[destination]" => "8.8.4.4",
"rule[port_range]" => "1-8000"
})
|> click(Query.button("Add"))
end)
|> assert_has(Query.text("8.8.4.4"))
|> assert_has(Query.link("Delete"))
assert rule = Repo.one(FzHttp.Rules.Rule)
assert rule.destination == %Postgrex.INET{address: {8, 8, 4, 4}}
assert rule.port_range == "1 - 8000"
assert rule.port_type == :tcp
click(session, Query.link("Delete"))
# XXX: We need to show a confirmation dialog on delete,
# and message once record was saved or deleted.
Process.sleep(100)
assert is_nil(Repo.one(FzHttp.Rules.Rule))
end
end
describe "settings" do
feature "change default settings", %{session: session} do
session
|> visit(~p"/settings/client_defaults")
|> assert_el(Query.text("Client Defaults", count: 2))
|> fill_form(%{
"configuration[default_client_allowed_ips]" => "192.0.0.0/0,::/0",
"configuration[default_client_dns]" => "1.1.1.1,2.2.2.2",
"configuration[default_client_endpoint]" => "example.com:8123",
"configuration[default_client_persistent_keepalive]" => "10",
"configuration[default_client_mtu]" => "1234"
})
|> click(Query.button("Save"))
# XXX: We need to show a flash that settings are saved
|> visit(~p"/settings/client_defaults")
|> assert_el(Query.text("Client Defaults", count: 2))
assert configuration = FzHttp.Configurations.get_configuration!()
assert configuration.default_client_persistent_keepalive == 10
assert configuration.default_client_mtu == 1234
assert configuration.default_client_endpoint == "example.com:8123"
assert configuration.default_client_dns == "1.1.1.1,2.2.2.2"
assert configuration.default_client_allowed_ips == "192.0.0.0/0,::/0"
end
end
describe "customization" do
feature "allows to change logo using a URL", %{session: session} do
session
|> visit(~p"/settings/customization")
|> assert_el(Query.text("Customization", count: 2))
|> set_value(Query.css("input[value=\"URL\"]"), :selected)
|> fill_in(Query.fillable_field("url"), with: "https://http.cat/200")
|> click(Query.button("Save"))
|> assert_el(Query.css("img[src=\"https://http.cat/200\"]"))
assert configuration = FzHttp.Configurations.get_configuration!()
assert configuration.logo.url == "https://http.cat/200"
end
end
describe "security" do
feature "change security settings", %{
session: session
} do
assert configuration = FzHttp.Configurations.get_configuration!()
assert configuration.local_auth_enabled == true
assert configuration.allow_unprivileged_device_management == true
assert configuration.allow_unprivileged_device_configuration == true
assert configuration.disable_vpn_on_oidc_error == false
session
|> visit(~p"/settings/security")
|> assert_el(Query.text("Security Settings"))
|> toggle("local_auth_enabled")
|> toggle("allow_unprivileged_device_management")
|> toggle("allow_unprivileged_device_configuration")
|> toggle("disable_vpn_on_oidc_error")
|> assert_el(Query.text("Security Settings"))
assert configuration = FzHttp.Configurations.get_configuration!()
assert configuration.local_auth_enabled == false
assert configuration.allow_unprivileged_device_management == false
assert configuration.allow_unprivileged_device_configuration == false
assert configuration.disable_vpn_on_oidc_error == true
end
feature "change required authentication timeout", %{session: session} do
assert configuration = FzHttp.Configurations.get_configuration!()
assert configuration.vpn_session_duration == 0
session
|> visit(~p"/settings/security")
|> assert_el(Query.text("Security Settings"))
|> find(Query.select("configuration[vpn_session_duration]"), fn select ->
click(select, Query.option("Every Week"))
end)
|> click(Query.css("[type=\"submit\""))
|> assert_el(Query.text("Security Settings"))
# XXX: We need to show a flash that settings are saved
Process.sleep(200)
assert configuration = FzHttp.Configurations.get_configuration!()
assert configuration.vpn_session_duration == 604_800
end
feature "manage OpenIDConnect providers", %{session: session} do
{_bypass, uri} = FzHttp.ConfigurationsFixtures.discovery_document_server()
# Create
session =
session
|> visit(~p"/settings/security")
|> assert_el(Query.text("Security Settings"))
|> click(Query.link("Add OpenID Connect Provider"))
|> assert_el(Query.text("OIDC Configuration"))
|> fill_in(Query.fillable_field("open_id_connect_provider[id]"), with: "oidc-foo-bar")
|> fill_in(Query.fillable_field("open_id_connect_provider[label]"), with: "Firebook")
|> fill_in(Query.fillable_field("open_id_connect_provider[scope]"),
with: "openid email eyes_color"
)
|> fill_in(Query.fillable_field("open_id_connect_provider[client_id]"), with: "CLIENT_ID")
|> fill_in(Query.fillable_field("open_id_connect_provider[client_secret]"),
with: "CLIENT_SECRET"
)
|> fill_in(Query.fillable_field("open_id_connect_provider[discovery_document_uri]"),
with: uri
)
|> fill_in(Query.fillable_field("open_id_connect_provider[redirect_uri]"),
with: "http://localhost/redirect"
)
|> toggle("open_id_connect_provider[auto_create_users]")
|> click(Query.css("button[form=\"oidc-form\"]"))
|> assert_el(Query.text("Updated successfully."))
|> assert_el(Query.text("oidc-foo-bar"))
|> assert_el(Query.text("Firebook"))
assert [open_id_connect_provider] = FzHttp.Configurations.get!(:openid_connect_providers)
assert open_id_connect_provider ==
%FzHttp.Configurations.Configuration.OpenIDConnectProvider{
id: "oidc-foo-bar",
label: "Firebook",
scope: "openid email eyes_color",
response_type: "code",
client_id: "CLIENT_ID",
client_secret: "CLIENT_SECRET",
discovery_document_uri: uri,
redirect_uri: "http://localhost/redirect",
auto_create_users: true
}
# Edit
session =
session
|> click(Query.link("Edit"))
|> assert_el(Query.text("OIDC Configuration"))
|> fill_in(Query.fillable_field("open_id_connect_provider[label]"), with: "Metabook")
|> click(Query.css("button[form=\"oidc-form\"]"))
|> assert_el(Query.text("Updated successfully."))
|> assert_el(Query.text("Metabook"))
assert [open_id_connect_provider] = FzHttp.Configurations.get!(:openid_connect_providers)
assert open_id_connect_provider.label == "Metabook"
# Delete
accept_confirm(session, fn session ->
click(session, Query.button("Delete"))
end)
assert_el(session, Query.text("Updated successfully."))
assert FzHttp.Configurations.get!(:openid_connect_providers) == []
end
feature "manage SAML providers", %{session: session} do
saml_metadata = FzHttp.SAMLIdentityProviderFixtures.metadata()
# Create
session =
session
|> visit(~p"/settings/security")
|> assert_el(Query.text("Security Settings"))
|> click(Query.link("Add SAML Identity Provider"))
|> assert_el(Query.text("SAML Configuration"))
|> toggle("saml_identity_provider[sign_requests]")
|> toggle("saml_identity_provider[sign_metadata]")
|> toggle("saml_identity_provider[signed_assertion_in_resp]")
|> toggle("saml_identity_provider[signed_envelopes_in_resp]")
|> toggle("saml_identity_provider[auto_create_users]")
|> fill_in(Query.fillable_field("saml_identity_provider[id]"), with: "foo-bar-buz")
|> fill_in(Query.fillable_field("saml_identity_provider[label]"), with: "Sneaky ID")
|> fill_in(Query.fillable_field("saml_identity_provider[base_url]"),
with: "http://localhost:4002/autX/saml#foo"
)
|> fill_in(Query.fillable_field("saml_identity_provider[metadata]"),
with: saml_metadata
)
|> click(Query.css("button[form=\"saml-form\"]"))
|> assert_el(Query.text("Updated successfully."))
|> assert_el(Query.text("foo-bar-buz"))
|> assert_el(Query.text("Sneaky ID"))
assert [saml_identity_provider] = FzHttp.Configurations.get!(:saml_identity_providers)
assert saml_identity_provider ==
%FzHttp.Configurations.Configuration.SAMLIdentityProvider{
id: "foo-bar-buz",
label: "Sneaky ID",
base_url: "http://localhost:4002/autX/saml#foo",
metadata: saml_metadata,
sign_requests: false,
sign_metadata: false,
signed_assertion_in_resp: false,
signed_envelopes_in_resp: false,
auto_create_users: true
}
# Edit
session =
session
|> click(Query.link("Edit"))
|> assert_el(Query.text("SAML Configuration"))
|> fill_in(Query.fillable_field("saml_identity_provider[label]"), with: "Sneaky XID")
|> click(Query.css("button[form=\"saml-form\"]"))
|> assert_el(Query.text("Updated successfully."))
|> assert_el(Query.text("Sneaky XID"))
assert [saml_identity_provider] = FzHttp.Configurations.get!(:saml_identity_providers)
assert saml_identity_provider.label == "Sneaky XID"
# Delete
accept_confirm(session, fn session ->
click(session, Query.button("Delete"))
end)
assert_el(session, Query.text("Updated successfully."))
assert FzHttp.Configurations.get!(:saml_identity_providers) == []
end
end
describe "profile" do
feature "edit profile", %{
session: session,
user: user
} do
session
|> visit(~p"/settings/account")
|> assert_el(Query.link("Change Email or Password"))
|> click(Query.link("Change Email or Password"))
|> assert_el(Query.text("Edit Account"))
|> fill_form(%{
"user[email]" => "foo",
"user[password]" => "123",
"user[password_confirmation]" => "1234"
})
|> click(Query.button("Save"))
|> assert_el(Query.text("is invalid email address"))
|> assert_el(Query.text("should be at least 12 character(s)"))
|> assert_el(Query.text("does not match confirmation"))
|> fill_form(%{
"user[email]" => "foo@xample.com",
"user[password]" => "mynewpassword",
"user[password_confirmation]" => "mynewpassword"
})
|> click(Query.button("Save"))
|> assert_el(Query.text("Account updated successfully."))
assert updated_user = Repo.one(FzHttp.Users.User)
assert updated_user.password_hash != user.password_hash
assert updated_user.email == "foo@xample.com"
end
feature "can see active user sessions", %{
session: session,
user_agent: user_agent
} do
session
|> visit(~p"/settings/account")
|> assert_el(Query.text("Active Sessions"))
|> assert_el(Query.text(user_agent))
end
feature "can delete own account if there are other admins", %{session: session} do
session =
session
|> visit(~p"/settings/account")
|> assert_el(Query.text("Danger Zone"))
assert attr(session, Query.button("Delete Your Account"), "disabled") ==
"true"
UsersFixtures.create_user_with_role(:admin)
session =
session
|> visit(~p"/settings/account")
|> assert_el(Query.text("Danger Zone"))
accept_confirm(session, fn session ->
click(session, Query.button("Delete Your Account"))
end)
session
|> Auth.assert_unauthenticated()
|> assert_path("/")
end
end
describe "api tokens" do
feature "create, use using curl and delete API tokens", %{
session: session,
user: user,
user_agent: user_agent
} do
session =
session
|> visit(~p"/settings/account")
|> assert_el(Query.text("API Tokens"))
|> assert_el(Query.text("No API tokens."))
|> click(Query.css("[href=\"/settings/account/api_token\"]"))
|> assert_el(Query.text("Add API Token", minimum: 1))
|> fill_form(%{
"api_token[expires_in]" => 1
})
|> click(Query.button("Save"))
|> assert_el(Query.text("API token secret:"))
api_token_secret = text(session, Query.css("#api-token-secret"))
curl_example = text(session, Query.css("#api-usage-example"))
curl_example = String.replace(curl_example, ~r/^.*curl/is, "curl")
assert String.contains?(curl_example, api_token_secret)
assert api_token = Repo.one(FzHttp.ApiTokens.ApiToken)
assert api_token.user_id == user.id
args =
curl_example
|> String.trim_leading("curl ")
|> String.replace("\\\n", "")
|> String.replace(~r/[ ]+/, " ")
|> String.replace("'", "")
|> String.split(" ")
|> curl_args([])
args = ["-s", "-H", "User-Agent:#{user_agent}"] ++ args
{resp, _} = System.cmd("curl", args, stderr_to_stdout: true)
assert %{"data" => [%{"id" => user_id}]} = Jason.decode!(resp)
assert user_id == user.id
session =
session
|> click(Query.css("button[aria-label=\"close\"]"))
|> assert_el(Query.text("API Tokens"))
|> assert_el(Query.link("Delete"))
|> click(Query.link(api_token.id))
|> assert_el(Query.text("API token secret:"))
|> click(Query.css("button[aria-label=\"close\"]"))
|> assert_el(Query.link("Delete"))
accept_confirm(session, fn session ->
click(session, Query.link("Delete"))
end)
assert_el(session, Query.text("No API tokens."))
assert is_nil(Repo.one(FzHttp.ApiTokens.ApiToken))
end
end
defp curl_args([], acc) do
acc
end
defp curl_args(["-H", header, "Bearer", token | rest], acc) do
acc = acc ++ ["-H", "#{header}Bearer #{token}"]
curl_args(rest, acc)
end
defp curl_args(["-H", header, value | rest], acc) do
acc = acc ++ ["-H", "#{header}#{value}"]
curl_args(rest, acc)
end
defp curl_args(["http" <> _ = url | rest], acc) do
acc = acc ++ [url]
curl_args(rest, acc)
end
defp curl_args([other | rest], acc) do
curl_args(rest, acc ++ [other])
end
end

View File

@@ -0,0 +1,414 @@
defmodule FzHttpWeb.Acceptance.AuthenticationTest do
use FzHttpWeb.AcceptanceCase, async: true
alias FzHttp.UsersFixtures
describe "using login and password" do
feature "renders error on invalid login or password", %{session: session} do
session
|> password_login_flow("foo@bar.com", "firezone1234")
|> assert_error_flash(
"Error signing in: user credentials are invalid or user does not exist"
)
end
feature "renders error on invalid password", %{session: session} do
user = UsersFixtures.create_user()
session
|> password_login_flow(user.email, "firezone1234")
|> assert_error_flash(
"Error signing in: user credentials are invalid or user does not exist"
)
|> Auth.assert_unauthenticated()
end
feature "redirects to /users after successful log in as admin", %{session: session} do
password = "firezone1234"
user = UsersFixtures.create_user(password: password, password_confirmation: password)
session
|> password_login_flow(user.email, password)
|> assert_el(Query.css(".is-user-name span"))
|> assert_path("/users")
|> Auth.assert_authenticated(user)
end
feature "redirects to /user_devices after successful log in as unprivileged user", %{
session: session
} do
password = "firezone1234"
user =
UsersFixtures.create_user_with_role(
[password: password, password_confirmation: password],
:unprivileged
)
session
|> password_login_flow(user.email, password)
|> assert_el(Query.text("Your Devices"))
|> assert_path("/user_devices")
|> Auth.assert_authenticated(user)
end
end
describe "using OIDC provider" do
feature "creates a user when auto_create_users is true", %{session: session} do
oidc_login = "firezone-1"
oidc_password = "firezone1234_oidc"
attrs = UsersFixtures.user_attrs()
:ok = Vault.setup_oidc_provider(@endpoint.url, %{"auto_create_users" => true})
:ok = Vault.upsert_user(oidc_login, attrs.email, oidc_password)
session
|> visit(~p"/")
|> click(Query.link("OIDC Vault"))
|> Vault.userpass_flow(oidc_login, oidc_password)
|> assert_el(Query.text("Your Devices"))
|> assert_path("/user_devices")
assert user = FzHttp.Repo.one(FzHttp.Users.User)
assert user.email == attrs.email
assert user.role == :unprivileged
assert user.last_signed_in_method == "vault"
end
feature "authenticates existing user", %{session: session} do
user = UsersFixtures.create_user()
oidc_login = "firezone-2"
oidc_password = "firezone1234_oidc"
:ok = Vault.setup_oidc_provider(@endpoint.url, %{"auto_create_users" => false})
:ok = Vault.upsert_user(oidc_login, user.email, oidc_password)
session
|> visit(~p"/")
|> click(Query.link("OIDC Vault"))
|> Vault.userpass_flow(oidc_login, oidc_password)
|> find(Query.text("Users", count: 2), fn _ -> :ok end)
|> assert_path("/users")
assert user = FzHttp.Repo.one(FzHttp.Users.User)
assert user.email == user.email
assert user.role == :admin
assert user.last_signed_in_method == "vault"
end
feature "does not create new users when auto_create_users is false", %{session: session} do
user_attrs = UsersFixtures.user_attrs()
oidc_login = "firezone-2"
oidc_password = "firezone1234_oidc"
:ok = Vault.setup_oidc_provider(@endpoint.url, %{"auto_create_users" => false})
:ok = Vault.upsert_user(oidc_login, user_attrs.email, oidc_password)
session
|> visit(~p"/")
|> click(Query.link("OIDC Vault"))
|> Vault.userpass_flow(oidc_login, oidc_password)
|> assert_error_flash("Error signing in: user not found and auto_create_users disabled")
|> Auth.assert_unauthenticated()
end
feature "allows to use OIDC when password auth is disabled", %{session: session} do
user_attrs = UsersFixtures.user_attrs()
oidc_login = "firezone-2"
oidc_password = "firezone1234_oidc"
:ok = Vault.setup_oidc_provider(@endpoint.url, %{"auto_create_users" => false})
:ok = Vault.upsert_user(oidc_login, user_attrs.email, oidc_password)
FzHttp.Configurations.put!(:local_auth_enabled, false)
session = visit(session, ~p"/")
assert find(session, Query.css(".input", count: 0))
assert_el(session, Query.text("Please sign in via one of the methods below."))
end
end
describe "MFA" do
feature "allows unprivileged user to add and remove MFA method", %{
session: session
} do
user = UsersFixtures.create_user_with_role(:unprivileged)
session
|> visit(~p"/")
|> Auth.authenticate(user)
|> visit(~p"/user_devices")
|> assert_el(Query.text("Your Devices"))
|> click(Query.link("My Account"))
|> assert_el(Query.text("Account Settings"))
|> click(Query.link("Add MFA Method"))
|> mfa_create_flow()
|> remove_mfa_flow()
end
feature "allows admin user to add and remove MFA method", %{
session: session
} do
user = UsersFixtures.create_user_with_role(:admin)
session
|> visit(~p"/")
|> Auth.authenticate(user)
|> visit(~p"/users")
|> hover(Query.css(".is-user-name span"))
|> click(Query.link("Account Settings"))
|> assert_el(Query.text("Multi Factor Authentication"))
|> click(Query.link("Add MFA Method"))
|> mfa_create_flow()
|> remove_mfa_flow()
end
feature "MFA code is requested on unprivileged user login", %{session: session} do
password = "firezone1234"
user =
UsersFixtures.create_user_with_role(
[password: password, password_confirmation: password],
:unprivileged
)
secret = NimbleTOTP.secret()
verification_code = NimbleTOTP.verification_code(secret)
{:ok, method} =
FzHttp.MFA.create_method(
%{
name: "Test",
type: :totp,
secret: Base.encode64(secret),
code: verification_code
},
user.id
)
# Newly created method has a very recent last_used_at timestamp,
# It being used in NimbleTOTP.valid?(code, since: last_used_at) always
# fails. Need to set it to be something in the past (more than 30s in the past).
{:ok, _method} = FzHttp.MFA.update_method(method, %{last_used_at: ~U[1970-01-01T00:00:00Z]})
session
|> password_login_flow(user.email, password)
|> mfa_login_flow(verification_code)
|> assert_el(Query.text("Your Devices"))
|> assert_path("/user_devices")
|> Auth.assert_authenticated(user)
end
feature "MFA code is requested on admin user login", %{session: session} do
password = "firezone1234"
user =
UsersFixtures.create_user_with_role(
[password: password, password_confirmation: password],
:admin
)
secret = NimbleTOTP.secret()
verification_code = NimbleTOTP.verification_code(secret)
{:ok, method} =
FzHttp.MFA.create_method(
%{
name: "Test",
type: :totp,
secret: Base.encode64(secret),
code: verification_code
},
user.id
)
# Newly created method has a very recent last_used_at timestamp,
# It being used in NimbleTOTP.valid?(code, since: last_used_at) always
# fails. Need to set it to be something in the past (more than 30s in the past).
{:ok, _method} = FzHttp.MFA.update_method(method, %{last_used_at: ~U[1970-01-01T00:00:00Z]})
session
|> password_login_flow(user.email, password)
|> mfa_login_flow(verification_code)
|> assert_el(Query.css(".is-user-name"))
|> assert_path("/users")
|> Auth.assert_authenticated(user)
end
feature "user can sign out during MFA flow", %{session: session} do
password = "firezone1234"
user =
UsersFixtures.create_user_with_role(
[password: password, password_confirmation: password],
:admin
)
secret = NimbleTOTP.secret()
verification_code = NimbleTOTP.verification_code(secret)
{:ok, method} =
FzHttp.MFA.create_method(
%{
name: "Test",
type: :totp,
secret: Base.encode64(secret),
code: verification_code
},
user.id
)
# Newly created method has a very recent last_used_at timestamp,
# It being used in NimbleTOTP.valid?(code, since: last_used_at) always
# fails. Need to set it to be something in the past (more than 30s in the past).
{:ok, _method} = FzHttp.MFA.update_method(method, %{last_used_at: ~U[1970-01-01T00:00:00Z]})
session
|> password_login_flow(user.email, password)
|> assert_el(Query.text("Multi-factor Authentication"))
|> click(Query.css("[data-to=\"/sign_out\"]"))
|> assert_el(Query.text("Sign In"))
|> Auth.assert_unauthenticated()
|> assert_path("/")
end
feature "user can see other methods during MFA flow", %{session: session} do
password = "firezone1234"
user =
UsersFixtures.create_user_with_role(
[password: password, password_confirmation: password],
:admin
)
secret = NimbleTOTP.secret()
verification_code = NimbleTOTP.verification_code(secret)
{:ok, method} =
FzHttp.MFA.create_method(
%{
name: "Test",
type: :totp,
secret: Base.encode64(secret),
code: verification_code
},
user.id
)
# Newly created method has a very recent last_used_at timestamp,
# It being used in NimbleTOTP.valid?(code, since: last_used_at) always
# fails. Need to set it to be something in the past (more than 30s in the past).
{:ok, _method} = FzHttp.MFA.update_method(method, %{last_used_at: ~U[1970-01-01T00:00:00Z]})
session
|> password_login_flow(user.email, password)
|> assert_el(Query.text("Multi-factor Authentication"))
|> click(Query.css("[href=\"/mfa/types\"]"))
|> assert_el(Query.css("[href=\"/mfa/auth/#{method.id}\"]"))
end
end
describe "sign out" do
feature "signs out unprivileged user", %{session: session} do
user = UsersFixtures.create_user_with_role(:unprivileged)
session
|> visit(~p"/")
|> Auth.authenticate(user)
|> visit(~p"/user_devices")
|> click(Query.link("Sign out"))
|> assert_el(Query.text("Sign In"))
|> Auth.assert_unauthenticated()
|> assert_path("/")
end
feature "signs out admin user", %{session: session} do
user = UsersFixtures.create_user_with_role(:admin)
session
|> visit(~p"/")
|> Auth.authenticate(user)
|> visit(~p"/users")
|> hover(Query.css(".is-user-name span"))
|> click(Query.link("Log Out"))
|> assert_el(Query.text("Sign In"))
|> Auth.assert_unauthenticated()
|> assert_path("/")
end
end
defp assert_error_flash(session, text) do
assert_text(session, Query.css(".flash-error"), text)
session
end
defp password_login_flow(session, email, password) do
session
|> visit(~p"/")
|> click(Query.link("Sign in with email"))
|> fill_form(%{
"Email" => email,
"Password" => password
})
|> click(Query.button("Sign In"))
end
defp mfa_login_flow(session, verification_code) do
session
|> assert_el(Query.text("Multi-factor Authentication"))
|> fill_form(%{"code" => "111111"})
|> click(Query.button("Verify"))
|> assert_el(Query.text("is not valid"))
|> fill_form(%{"code" => verification_code})
|> click(Query.button("Verify"))
end
defp mfa_create_flow(session) do
assert selected?(session, Query.radio_button("mfa-method-totp"))
session =
session
|> click(Query.button("Next"))
|> assert_el(Query.text("Register Authenticator"))
|> fill_in(Query.fillable_field("name"), with: "My MFA Name")
secret =
Browser.text(session, Query.css("#copy-totp-key"))
|> String.replace(" ", "")
|> Base.decode32!()
session =
session
|> click(Query.button("Next"))
|> assert_el(Query.text("Verify Code"))
|> fill_in(Query.fillable_field("code"), with: "123456")
|> click(Query.button("Next"))
|> assert_el(Query.css("input.is-danger"))
|> fill_in(Query.fillable_field("code"), with: NimbleTOTP.verification_code(secret))
|> click(Query.button("Next"))
|> assert_el(Query.text("Confirm to save this Authentication method."))
|> click(Query.button("Save"))
|> assert_el(Query.text("MFA method added!"))
assert mfa_method = Repo.one(FzHttp.MFA.Method)
assert mfa_method.name == "My MFA Name"
assert mfa_method.payload["secret"] == Base.encode64(secret)
session
end
defp remove_mfa_flow(session) do
session =
session
|> assert_el(Query.text("Multi Factor Authentication"))
accept_confirm(session, fn session ->
click(session, Query.css("[phx-click=\"delete_authenticator\"]"))
end)
session
|> assert_el(Query.text("No MFA methods added."))
end
end

View File

@@ -0,0 +1,166 @@
defmodule FzHttpWeb.Acceptance.UnprivilegedUserTest do
use FzHttpWeb.AcceptanceCase, async: true
alias FzHttp.{UsersFixtures, DevicesFixtures}
describe "device management" do
setup tags do
user = UsersFixtures.create_user_with_role(:unprivileged)
session =
tags.session
|> visit(~p"/")
|> Auth.authenticate(user)
tags
|> Map.put(:session, session)
|> Map.put(:user, user)
end
feature "allows user to add and configure a device", %{
session: session
} do
FzHttp.Configurations.put!(:allow_unprivileged_device_configuration, true)
session
|> visit(~p"/user_devices")
|> assert_el(Query.text("Your Devices"))
|> click(Query.link("Add Device"))
|> assert_el(Query.button("Generate Configuration"))
|> set_value(Query.css("#create-device_use_default_allowed_ips_false"), :selected)
|> set_value(Query.css("#create-device_use_default_dns_false"), :selected)
|> set_value(Query.css("#create-device_use_default_endpoint_false"), :selected)
|> set_value(Query.css("#create-device_use_default_mtu_false"), :selected)
|> set_value(
Query.css("#create-device_use_default_persistent_keepalive_false"),
:selected
)
|> fill_form(%{
"device[allowed_ips]" => "127.0.0.1",
"device[name]" => "big-head-007",
"device[description]" => "Dummy description",
"device[dns]" => "1.1.1.1,2.2.2.2",
"device[endpoint]" => "example.com:51820",
"device[mtu]" => "1400",
"device[persistent_keepalive]" => "10",
"device[ipv4]" => "10.10.11.1",
"device[ipv6]" => "fd00::1e:3f96"
})
|> fill_in(Query.fillable_field("device[description]"), with: "Dummy description")
|> click(Query.button("Generate Configuration"))
|> assert_el(Query.text("Device added!"))
|> click(Query.css("button[phx-click=\"close\"]"))
|> assert_el(Query.text("big-head-007"))
assert device = Repo.one(FzHttp.Devices.Device)
assert device.name == "big-head-007"
assert device.description == "Dummy description"
assert device.allowed_ips == "127.0.0.1"
assert device.dns == "1.1.1.1,2.2.2.2"
assert device.endpoint == "example.com:51820"
assert device.mtu == 1400
assert device.persistent_keepalive == 10
assert device.ipv4 == %Postgrex.INET{address: {10, 10, 11, 1}}
assert device.ipv6 == %Postgrex.INET{address: {64_768, 0, 0, 0, 0, 0, 30, 16_278}}
end
feature "allows user to add a device", %{
session: session
} do
FzHttp.Configurations.put!(:allow_unprivileged_device_configuration, false)
session
|> visit(~p"/user_devices")
|> assert_el(Query.text("Your Devices"))
|> click(Query.link("Add Device"))
|> assert_el(Query.button("Generate Configuration"))
|> fill_form(%{
"device[name]" => "big-hand-007",
"device[description]" => "Dummy description"
})
|> fill_in(Query.fillable_field("device[description]"), with: "Dummy description")
|> click(Query.button("Generate Configuration"))
|> assert_el(Query.text("Device added!"))
|> click(Query.css("button[phx-click=\"close\"]"))
|> assert_el(Query.text("big-hand-007"))
assert device = Repo.one(FzHttp.Devices.Device)
assert device.name == "big-hand-007"
assert device.description == "Dummy description"
end
feature "does not allow adding devices", %{session: session} do
FzHttp.Configurations.put!(:allow_unprivileged_device_management, false)
session
|> visit(~p"/user_devices")
|> assert_el(Query.text("Your Devices"))
|> refute_has(Query.link("Add Device"))
end
feature "allows user to delete a device", %{
session: session,
user: user
} do
device = DevicesFixtures.create_device_for_user(user)
session =
session
|> visit(~p"/user_devices")
|> assert_el(Query.text("Your Devices"))
|> assert_el(Query.text(device.public_key))
|> click(Query.link(device.name))
|> assert_el(Query.text(device.description))
accept_confirm(session, fn session ->
click(session, Query.button("Delete Device #{device.name}"))
end)
assert_el(session, Query.text("No devices to show."))
assert Repo.one(FzHttp.Devices.Device) == nil
end
end
describe "profile" do
setup tags do
user = UsersFixtures.create_user_with_role(:unprivileged)
session =
tags.session
|> visit(~p"/")
|> Auth.authenticate(user)
tags
|> Map.put(:session, session)
|> Map.put(:user, user)
end
feature "allows to change password", %{
session: session,
user: user
} do
session
|> visit(~p"/user_devices")
|> assert_el(Query.text("Your Devices"))
|> click(Query.link("My Account"))
|> assert_el(Query.text("Account Settings"))
|> click(Query.link("Change Password"))
|> assert_el(Query.text("Enter new password below."))
|> fill_form(%{
"user[password]" => "foo",
"user[password_confirmation]" => ""
})
|> click(Query.button("Save"))
|> assert_el(Query.text("should be at least 12 character(s)"))
|> assert_el(Query.text("does not match confirmation"))
|> fill_form(%{
"user[password]" => "new_password",
"user[password_confirmation]" => "new_password"
})
|> click(Query.button("Save"))
|> assert_el(Query.text("Password updated successfully"))
assert Repo.one(FzHttp.Users.User).password_hash != user.password_hash
end
end
end

View File

@@ -10,7 +10,7 @@ defmodule FzHttpWeb.NotificationChannelTest do
socket =
FzHttpWeb.UserSocket
|> socket(user.id, %{remote_ip: "127.0.0.1"})
|> socket(user.id, %{remote_ip: "127.0.0.1", user_agent: "test", current_user_id: user.id})
%{
user: user,
@@ -19,28 +19,12 @@ defmodule FzHttpWeb.NotificationChannelTest do
}
end
test "joins channel with valid token", %{token: token, socket: socket, user: user} do
payload = %{
"token" => token,
"user_agent" => "test"
}
test "joins channel ", %{socket: socket, user: user} do
{:ok, _, test_socket} =
socket
|> subscribe_and_join(NotificationChannel, "notification:session", payload)
|> subscribe_and_join(NotificationChannel, "notification:session", %{})
assert test_socket.assigns.current_user.id == user.id
end
test "prevents joining with invalid token", %{token: _token, socket: socket, user: _user} do
payload = %{
"token" => "foobar",
"user_agent" => "test"
}
assert {:error, %{reason: "unauthorized"}} ==
socket
|> subscribe_and_join(NotificationChannel, "notification:session", payload)
end
end
end

View File

@@ -1,27 +1,32 @@
defmodule FzHttpWeb.AuthControllerTest do
use FzHttpWeb.ConnCase, async: true
import Mox
alias FzHttp.ConfigurationsFixtures
alias FzHttp.Repo
setup do
FzHttp.Configurations.put!(
:openid_connect_providers,
FzHttp.ConfigurationsFixtures.openid_connect_providers_attrs()
)
{bypass, _openid_connect_providers_attrs} =
ConfigurationsFixtures.start_openid_providers([
"google",
"okta",
"auth0",
"azure",
"onelogin",
"keycloak",
"vault"
])
FzHttp.Configurations.put!(
:saml_identity_providers,
[FzHttp.SAMLIdentityProviderFixtures.saml_attrs() |> Map.put("label", "SAML")]
)
%{}
%{bypass: bypass}
end
describe "new" do
setup [:create_user]
test "unauthed: loads the sign in form", %{unauthed_conn: conn} do
expect(OpenIDConnect.Mock, :authorization_uri, fn _, _ -> "https://auth.url" end)
test_conn = get(conn, ~p"/")
# Assert that we have email, OIDC and Oauth2 buttons provided
@@ -71,7 +76,7 @@ defmodule FzHttpWeb.AuthControllerTest do
assert test_conn.request_path == ~p"/auth/identity/callback"
assert Phoenix.Flash.get(test_conn.assigns.flash, :error) ==
"Error signing in: invalid_credentials"
"Error signing in: user credentials are invalid or user does not exist"
end
test "invalid password", %{unauthed_conn: conn, user: user} do
@@ -85,7 +90,7 @@ defmodule FzHttpWeb.AuthControllerTest do
assert test_conn.request_path == ~p"/auth/identity/callback"
assert Phoenix.Flash.get(test_conn.assigns.flash, :error) ==
"Error signing in: invalid_credentials"
"Error signing in: user credentials are invalid or user does not exist"
end
test "valid params", %{unauthed_conn: conn, user: user} do
@@ -138,31 +143,46 @@ defmodule FzHttpWeb.AuthControllerTest do
{:ok, unauthed_conn: put_req_cookie(conn, "fz_oidc_state", signed_state)}
end
test "when a user returns with a valid claim", %{unauthed_conn: conn, user: user} do
expect(OpenIDConnect.Mock, :fetch_tokens, fn _, _ -> {:ok, %{"id_token" => "abc"}} end)
test "when a user returns with a valid claim", %{
unauthed_conn: conn,
user: user,
bypass: bypass
} do
jwk = ConfigurationsFixtures.jwks_attrs()
expect(OpenIDConnect.Mock, :verify, fn _, _ ->
{:ok, %{"email" => user.email, "sub" => "12345"}}
end)
claims = %{"email" => user.email, "sub" => user.id}
{_alg, token} =
jwk
|> JOSE.JWK.from()
|> JOSE.JWS.sign(Jason.encode!(claims), %{"alg" => "RS256"})
|> JOSE.JWS.compact()
ConfigurationsFixtures.expect_refresh_token(bypass, %{"id_token" => token})
test_conn = get(conn, ~p"/auth/oidc/google/callback", @params)
assert redirected_to(test_conn) == ~p"/users"
assert get_session(test_conn, "id_token") == "abc"
assert get_session(test_conn, "id_token")
end
@moduletag :capture_log
test "when a user returns with an invalid claim", %{unauthed_conn: conn, bypass: bypass} do
jwk = ConfigurationsFixtures.jwks_attrs()
test "when a user returns with an invalid claim", %{unauthed_conn: conn} do
expect(OpenIDConnect.Mock, :fetch_tokens, fn _, _ -> {:ok, %{}} end)
claims = %{"email" => "foo@example.com", "sub" => Ecto.UUID.generate()}
expect(OpenIDConnect.Mock, :verify, fn _, _ ->
{:error, "Invalid token for user!"}
end)
{_alg, token} =
jwk
|> JOSE.JWK.from()
|> JOSE.JWS.sign(Jason.encode!(claims), %{"alg" => "RS256"})
|> JOSE.JWS.compact()
ConfigurationsFixtures.expect_refresh_token(bypass, %{"id_token" => token})
test_conn = get(conn, ~p"/auth/oidc/google/callback", @params)
assert Phoenix.Flash.get(test_conn.assigns.flash, :error) ==
"OpenIDConnect Error: Invalid token for user!"
"Error signing in: user not found and auto_create_users disabled"
end
test "when a user returns with an invalid state", %{unauthed_conn: conn} do
@@ -203,25 +223,43 @@ defmodule FzHttpWeb.AuthControllerTest do
setup :create_user
test "redirects to root path", %{unauthed_conn: conn, user: user} do
refute user.sign_in_token
test_conn = post(conn, ~p"/auth/magic_link", %{"email" => user.email})
assert redirected_to(test_conn) == ~p"/"
assert Phoenix.Flash.get(test_conn.assigns.flash, :info) ==
"Please check your inbox for the magic link."
user = Repo.get(FzHttp.Users.User, user.id)
assert user.sign_in_token_hash
assert_receive {:email, email}
assert email.subject == "Firezone Magic Link"
assert email.to == [{"", user.email}]
assert email.text_body =~ "/auth/magic/#{user.id}/"
token = String.split(email.assigns.link, "/") |> List.last()
assert {:ok, _user} = FzHttp.Users.consume_sign_in_token(user, token)
end
end
describe "when using magic link" do
setup :create_user
alias FzHttp.Repo
setup context do
{:ok, user} = FzHttp.Users.request_sign_in_token(context.user)
Map.put(context, :user, user)
end
test "user sign_in_token is cleared", %{unauthed_conn: conn, user: user} do
assert not is_nil(user.sign_in_token)
assert not is_nil(user.sign_in_token_created_at)
get(conn, ~p"/auth/magic/#{user.sign_in_token}")
get(conn, ~p"/auth/magic/#{user.id}/#{user.sign_in_token}")
user = Repo.reload!(user)
@@ -230,7 +268,7 @@ defmodule FzHttpWeb.AuthControllerTest do
end
test "user last signed in with magic_link provider", %{unauthed_conn: conn, user: user} do
get(conn, ~p"/auth/magic/#{user.sign_in_token}")
get(conn, ~p"/auth/magic/#{user.id}/#{user.sign_in_token}")
user = Repo.reload!(user)
@@ -238,7 +276,7 @@ defmodule FzHttpWeb.AuthControllerTest do
end
test "user is signed in", %{unauthed_conn: conn, user: user} do
test_conn = get(conn, ~p"/auth/magic/#{user.sign_in_token}")
test_conn = get(conn, ~p"/auth/magic/#{user.id}/#{user.sign_in_token}")
assert current_user(test_conn).id == user.id
end
@@ -246,49 +284,37 @@ defmodule FzHttpWeb.AuthControllerTest do
test "prevents signing in when local_auth_disabled", %{unauthed_conn: conn, user: user} do
FzHttp.Configurations.put!(:local_auth_enabled, false)
test_conn = get(conn, ~p"/auth/magic/#{user.sign_in_token}")
test_conn = get(conn, ~p"/auth/magic/#{user.id}/#{user.sign_in_token}")
assert text_response(test_conn, 401) == "Local auth disabled"
end
end
describe "oidc signout url" do
@oidc_end_session_uri "https://end-session.url"
@params %{
"id_token_hint" => "abc",
"post_logout_redirect_uri" => "https://localhost",
"client_id" => "okta-client-id"
}
@tag session: [login_method: "okta", id_token: "abc"]
test "redirects to oidc end_session_uri", %{admin_conn: conn} do
# mimics OpenID Connect
query = URI.encode_query(@params)
query =
URI.encode_query(%{
"id_token_hint" => "abc",
"post_logout_redirect_uri" => "http://localhost:4002/",
"client_id" => "okta-client-id"
})
complete_uri =
@oidc_end_session_uri
"https://example.com"
|> URI.merge("?#{query}")
|> URI.to_string()
expect(OpenIDConnect.Mock, :end_session_uri, fn _provider, _params -> complete_uri end)
test_conn = delete(conn, ~p"/sign_out")
assert redirected_to(test_conn) == complete_uri
end
end
describe "oidc signin url" do
@oidc_auth_uri "https://auth.url"
test "redirects to oidc auth uri", %{unauthed_conn: conn} do
expect(OpenIDConnect.Mock, :authorization_uri, fn provider, _ ->
case provider do
"google" -> @oidc_auth_uri
end
end)
test "redirects to oidc auth uri", %{unauthed_conn: conn, bypass: bypass} do
test_conn = get(conn, ~p"/auth/oidc/google")
assert redirected_to(test_conn) == @oidc_auth_uri
bypass_url = "http://localhost:#{bypass.port}/authorize"
assert String.starts_with?(redirected_to(test_conn), bypass_url)
end
end
end

View File

@@ -99,10 +99,8 @@ defmodule FzHttpWeb.JSON.UserControllerTest do
|> doc(example_description: "Error due to invalid parameters")
assert json_response(conn, 422)["errors"] == %{
"password" => [
"should be at least 12 character(s)",
"does not match password confirmation."
]
"password" => ["should be at least 12 character(s)"],
"password_confirmation" => ["can't be blank"]
}
end
@@ -139,7 +137,7 @@ defmodule FzHttpWeb.JSON.UserControllerTest do
params = %{"password" => "update-password", "password_confirmation" => "update-password"}
conn = put(authed_conn(), ~p"/v0/users/#{user}", user: params)
assert Users.get_user!(json_response(conn, 200)["data"]["id"]).password_hash !=
assert Users.fetch_user_by_id!(json_response(conn, 200)["data"]["id"]).password_hash !=
old_hash
end
@@ -163,7 +161,7 @@ defmodule FzHttpWeb.JSON.UserControllerTest do
params = %{"password" => "update-password", "password_confirmation" => "update-password"}
conn = put(authed_conn(), ~p"/v0/users/#{user}", user: params)
assert Users.get_user!(json_response(conn, 200)["data"]["id"]).password_hash !=
assert Users.fetch_user_by_id!(json_response(conn, 200)["data"]["id"]).password_hash !=
old_hash
end
@@ -207,14 +205,14 @@ defmodule FzHttpWeb.JSON.UserControllerTest do
conn = put(conn, ~p"/v0/users/#{user}", user: @invalid_attrs)
assert json_response(conn, 422)["errors"] == %{
"password" => ["should be at least 12 character(s)"]
"password" => ["should be at least 12 character(s)"],
"password_confirmation" => ["can't be blank"]
}
end
test "renders 404 for user not found" do
assert_error_sent(404, fn ->
put(authed_conn(), ~p"/v0/users/003da73d-2dd9-4492-8136-3282843545e8", user: %{})
end)
conn = put(authed_conn(), ~p"/v0/users/#{Ecto.UUID.generate()}", user: %{})
assert json_response(conn, 404) == %{"error" => "not_found"}
end
test "renders 401 for missing authorization header" do
@@ -245,9 +243,8 @@ defmodule FzHttpWeb.JSON.UserControllerTest do
end
test "renders 404 for user not found" do
assert_error_sent(404, fn ->
get(authed_conn(), ~p"/v0/users/003da73d-2dd9-4492-8136-3282843545e8")
end)
conn = get(authed_conn(), ~p"/v0/users/003da73d-2dd9-4492-8136-3282843545e8")
assert json_response(conn, 404) == %{"error" => "not_found"}
end
test "renders 401 for missing authorization header" do
@@ -266,9 +263,8 @@ defmodule FzHttpWeb.JSON.UserControllerTest do
assert response(conn, 204)
assert_error_sent(404, fn ->
get(conn, ~p"/v0/users/#{user}")
end)
conn = get(conn, ~p"/v0/users/#{user}")
assert json_response(conn, 404) == %{"error" => "not_found"}
end
test "deletes user by email" do
@@ -280,15 +276,13 @@ defmodule FzHttpWeb.JSON.UserControllerTest do
assert response(conn, 204)
assert_error_sent(404, fn ->
get(conn, ~p"/v0/users/#{user}")
end)
conn = get(conn, ~p"/v0/users/#{user}")
assert json_response(conn, 404) == %{"error" => "not_found"}
end
test "renders 404 for user not found" do
assert_error_sent(404, fn ->
delete(authed_conn(), ~p"/v0/users/003da73d-2dd9-4492-8136-3282843545e8")
end)
conn = delete(authed_conn(), ~p"/v0/users/003da73d-2dd9-4492-8136-3282843545e8")
assert json_response(conn, 404) == %{"error" => "not_found"}
end
test "renders 401 for missing authorization header" do

View File

@@ -33,7 +33,7 @@ defmodule FzHttpWeb.UserControllerTest do
test "returns 404", %{admin_user: user, admin_conn: conn} do
user.id
|> Users.get_user!()
|> Users.fetch_user_by_id!()
|> Users.delete_user()
assert_raise(Ecto.StaleEntryError, fn ->

View File

@@ -17,7 +17,7 @@ defmodule FzHttpWeb.SettingLive.AccountTest do
path = ~p"/settings/account"
{:ok, _view, html} = live(conn, path)
user = Users.get_user!(user.id)
user = Users.fetch_user_by_id!(user.id)
assert html =~ "Delete Your Account"
assert html =~ user.email
@@ -25,7 +25,7 @@ defmodule FzHttpWeb.SettingLive.AccountTest do
end
describe "when live_action is edit" do
@valid_params %{"user" => %{"email" => "foobar@test", "current_password" => "password1234"}}
@valid_params %{"user" => %{"email" => "foobar@test"}}
@invalid_params %{"user" => %{"email" => "foobar"}}
test "loads the form" do
@@ -55,7 +55,6 @@ defmodule FzHttpWeb.SettingLive.AccountTest do
|> render_submit(%{
"user" => %{
"email" => "",
"current_password" => "",
"password" => "",
"password_confirmation" => ""
}
@@ -74,7 +73,7 @@ defmodule FzHttpWeb.SettingLive.AccountTest do
|> element("#account-edit")
|> render_submit(@invalid_params)
assert test_view =~ "has invalid format"
assert test_view =~ "is invalid email address"
end
test "closes modal", %{admin_conn: conn} do

View File

@@ -106,7 +106,8 @@ defmodule FzHttpWeb.SettingLive.SecurityTest do
"client_id" => "foo",
"client_secret" => "bar",
"discovery_document_uri" =>
"https://common.auth0.com/.well-known/openid-configuration"
"https://common.auth0.com/.well-known/openid-configuration",
"auto_create_users" => false
}
],
saml_identity_providers: []
@@ -159,7 +160,7 @@ defmodule FzHttpWeb.SettingLive.SecurityTest do
discovery_document_uri:
"https://common.auth0.com/.well-known/openid-configuration",
redirect_uri: nil,
auto_create_users: true
auto_create_users: false
}
]
end
@@ -229,9 +230,9 @@ defmodule FzHttpWeb.SettingLive.SecurityTest do
assert length(saml_identity_providers) == 2
assert %FzHttp.Configurations.Configuration.SAMLIdentityProvider{
auto_create_users: true,
auto_create_users: false,
# XXX this field would be nil if we don't "guess" the url when we load the record in StartServer
base_url: "https://localhost/auth/saml",
base_url: "http://localhost:4002/auth/saml",
id: "FAKEID",
label: "FOO",
metadata: attrs["metadata"],

View File

@@ -17,7 +17,7 @@ defmodule FzHttpWeb.SettingLive.Unprivileged.AccountTest do
path = ~p"/user_account"
{:ok, _view, html} = live(conn, path)
user = Users.get_user!(user.id)
user = Users.fetch_user_by_id!(user.id)
assert html =~ user.email
end
@@ -27,8 +27,7 @@ defmodule FzHttpWeb.SettingLive.Unprivileged.AccountTest do
@valid_params %{
"user" => %{
"password" => "newpassword1234",
"password_confirmation" => "newpassword1234",
"current_password" => "password1234"
"password_confirmation" => "newpassword1234"
}
}
@invalid_params %{"user" => %{"password" => "foobar"}}
@@ -50,7 +49,7 @@ defmodule FzHttpWeb.SettingLive.Unprivileged.AccountTest do
assert flash["info"] == "Password updated successfully."
end
test "doesn't allow empty password", %{unprivileged_conn: conn} do
test "doesn't allow invalid password", %{unprivileged_conn: conn} do
path = ~p"/user_account/change_password"
{:ok, view, _html} = live(conn, path)
@@ -60,7 +59,7 @@ defmodule FzHttpWeb.SettingLive.Unprivileged.AccountTest do
|> render_submit(@invalid_params)
refute_redirected(view, ~p"/user_account")
assert test_view =~ "can&#39;t be blank"
assert test_view =~ "should be at least 12 character(s)"
end
test "closes modal", %{unprivileged_conn: conn} do

View File

@@ -27,7 +27,7 @@ defmodule FzHttpWeb.UserLive.IndexTest do
path = ~p"/users"
{:ok, _view, html} = live(conn, path)
for user <- Users.list_users(:with_device_counts) do
for user <- Users.list_users(hydrate: [:device_count]) do
assert html =~ "<td id=\"user-#{user.id}-device-count\">#{user.device_count}</td>"
end
end
@@ -84,7 +84,7 @@ defmodule FzHttpWeb.UserLive.IndexTest do
{new_path, flash} = assert_redirect(view)
assert flash["info"] == "User created successfully."
user = Users.get_user!(email: @valid_user_attrs["user"]["email"])
assert {:ok, user} = Users.fetch_user_by_email(@valid_user_attrs["user"]["email"])
assert new_path == ~p"/users/#{user}"
end
@@ -97,7 +97,7 @@ defmodule FzHttpWeb.UserLive.IndexTest do
|> element("form#user-form")
|> render_submit(@invalid_user_attrs)
assert new_view =~ "has invalid format"
assert new_view =~ "is invalid email address"
assert new_view =~ "should be at least 12 character(s)"
end
end

View File

@@ -607,7 +607,7 @@ defmodule FzHttpWeb.UserLive.ShowTest do
|> element("form#user-form")
|> render_submit(@invalid_attrs)
assert test_view =~ "has invalid format"
assert test_view =~ "is invalid email address"
assert test_view =~ "should be at least 12 character(s)"
end
end

View File

@@ -1,28 +0,0 @@
defmodule FzHttpWeb.AuthEmailTest do
use FzHttpWeb.MailerCase, async: true
alias FzHttp.Users
describe "reset_sign_in_token/1" do
setup :create_user
import Swoosh.TestAssertions
test "when email exists sends a magic link", %{user: user} do
Users.reset_sign_in_token(user.email)
user = Users.get_user!(user.id)
assert_email_sent(
subject: "Firezone Magic Link",
to: [{"", user.email}],
text_body: ~r(#{url(~p"/auth/magic/#{user.sign_in_token}")})
)
end
test "when email does not exist logs the attempt" do
Users.reset_sign_in_token("foobar@example.com")
refute_email_sent()
end
end
end

View File

@@ -26,16 +26,12 @@ defmodule FzHttpWeb.UserFromAuthTest do
describe "find_or_create/2 via OIDC with auto create enabled" do
test "sign in creates user", %{email: email} do
openid_connect_provider =
List.first(FzHttp.ConfigurationsFixtures.openid_connect_providers_attrs())
FzHttp.Configurations.put!(
:openid_connect_providers,
[openid_connect_provider]
)
FzHttp.ConfigurationsFixtures.start_openid_providers(["google"], %{
"auto_create_users" => true
})
assert {:ok, result} =
UserFromAuth.find_or_create(openid_connect_provider["id"], %{
UserFromAuth.find_or_create("google", %{
"email" => email,
"sub" => :noop
})
@@ -46,22 +42,24 @@ defmodule FzHttpWeb.UserFromAuthTest do
describe "find_or_create/2 via OIDC with auto create disabled" do
test "sign in returns error", %{email: email} do
openid_connect_provider =
List.first(FzHttp.ConfigurationsFixtures.openid_connect_providers_attrs())
|> Map.put("auto_create_users", false)
{_bypass, [openid_connect_provider_attrs]} =
FzHttp.ConfigurationsFixtures.start_openid_providers(["google"])
openid_connect_provider_attrs =
Map.put(openid_connect_provider_attrs, "auto_create_users", false)
FzHttp.Configurations.put!(
:openid_connect_providers,
[openid_connect_provider]
[openid_connect_provider_attrs]
)
assert {:error, "user not found and auto_create_users disabled"} =
UserFromAuth.find_or_create(openid_connect_provider["id"], %{
UserFromAuth.find_or_create(openid_connect_provider_attrs["id"], %{
"email" => email,
"sub" => :noop
})
assert Users.get_by_email(email) == nil
assert Users.fetch_user_by_email(email) == {:error, :not_found}
end
end
@@ -87,7 +85,7 @@ defmodule FzHttpWeb.UserFromAuthTest do
assert {:error, "user not found and auto_create_users disabled"} =
UserFromAuth.find_or_create(:saml, "test", %{"email" => email, "sub" => :noop})
assert Users.get_by_email(email) == nil
assert Users.fetch_user_by_email(email) == {:error, :not_found}
end
end
end

View File

@@ -0,0 +1,297 @@
defmodule FzHttpWeb.AcceptanceCase do
use ExUnit.CaseTemplate
alias Wallaby.Query
import Wallaby.Browser
using do
quote do
# Import conveniences for testing with browser
use Wallaby.DSL
use FzHttpWeb, :verified_routes
import FzHttpWeb.AcceptanceCase
alias FzHttp.Repo
alias FzHttpWeb.AcceptanceCase.{Vault, Auth}
# The default endpoint for testing
@endpoint FzHttpWeb.Endpoint
@moduletag :acceptance
@moduletag timeout: 120_000
setup tags do
Application.put_env(:wallaby, :base_url, @endpoint.url)
tags
end
end
end
setup tags do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(FzHttp.Repo)
unless tags[:async] do
Ecto.Adapters.SQL.Sandbox.mode(FzHttp.Repo, {:shared, self()})
end
headless? =
if tags[:debug] do
false
else
true
end
metadata = Phoenix.Ecto.SQL.Sandbox.metadata_for(FzHttp.Repo, self())
{:ok, session} = start_session(headless?, metadata)
user_agent =
Wallaby.Metadata.append(
"Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36",
metadata
)
%{
timeout: if(tags[:debug] == true, do: :infinity, else: 120_000),
session: session,
debug?: tags[:debug] == true,
sql_sandbox_metadata: metadata,
user_agent: user_agent
}
end
defp start_session(headless?, metadata) do
capabilities =
[
metadata: metadata,
window_size: [width: 1280, height: 720]
]
|> Wallaby.Chrome.default_capabilities()
|> update_in(
[:chromeOptions, :args],
fn args ->
args = args ++ ["--ignore-ssl-errors", "yes", "--ignore-certificate-errors"]
if headless? do
# defaults args already have --headless arg
args
else
args -- ["--headless"]
end
end
)
Wallaby.start_session(capabilities: capabilities)
end
def take_screenshot(name) do
time = :erlang.system_time(:second) |> to_string()
name = String.replace(name, " ", "_")
Wallaby.SessionStore.list_sessions_for(owner_pid: self())
|> Enum.with_index(1)
|> Enum.flat_map(fn {s, i} ->
filename = time <> "_" <> name <> "(#{i})"
take_screenshot(s, name: filename, log: true).screenshots
end)
end
def assert_el(session, query, started_at \\ nil)
def assert_el(session, %Query{} = query, started_at) do
now = :erlang.monotonic_time(:milli_seconds)
started_at = started_at || now
try do
case execute_query(session, query) do
{:ok, _query_result} ->
session
{:error, {:not_found, results}} ->
query = %Query{query | result: results}
raise Wallaby.ExpectationNotMetError,
Query.ErrorMessage.message(query, :not_found)
{:error, e} ->
raise Wallaby.QueryError,
Query.ErrorMessage.message(query, e)
error ->
raise Wallaby.ExpectationNotMetError,
"Wallaby has encountered an internal error: #{inspect(error)} with session: #{inspect(session)}"
end
assert_has(session, query)
rescue
e in [
Wallaby.ExpectationNotMetError,
Wallaby.StaleReferenceError,
Wallaby.QueryError
] ->
time_spent = now - started_at
max_wait_seconds = fetch_max_wait_seconds!()
if time_spent > :timer.seconds(max_wait_seconds) do
reraise(e, __STACKTRACE__)
else
floor(time_spent / 10)
|> max(100)
|> :timer.sleep()
assert_el(session, query, started_at)
end
end
end
defp fetch_max_wait_seconds! do
if env = System.get_env("E2E_MAX_WAIT_SECONDS") do
String.to_integer(env)
else
2
end
end
def fill_form(session, %{} = fields) do
# Wait for form to be rendered
{form_el, _opts} = Enum.at(fields, 0)
session = assert_el(session, Query.fillable_field(form_el))
# Make sure test covers all form fields
element_names =
session
|> find(Query.css(".input,.textarea", visible: true, count: :any))
|> Enum.map(&Wallaby.Element.attr(&1, "name"))
unless Enum.count(fields) == length(element_names) do
flunk(
"Expected #{Enum.count(fields)} elements, " <>
"got #{length(element_names)}: #{inspect(element_names)}"
)
end
Enum.reduce(fields, session, fn {field, value}, session ->
fill_in(session, Query.fillable_field(field), with: value)
end)
end
def toggle(session, selector) do
selector = ~s|document.querySelector("input[name=\\\"#{selector}\\\"]").click()|
# For some reason Wallaby can't click on checkboxes,
# probably because they have absolute positioning
session = execute_script(session, selector)
# If we don't sleep animations won't be finished on form submit
Process.sleep(50)
session
end
def assert_path(session, path) do
assert current_path(session) == path
session
end
def shutdown_live_socket(session) do
Wallaby.end_session(session)
Process.sleep(10)
# await_for_sandbox_processes()
end
# defp await_for_sandbox_processes() do
# receive do
# {:sandbox_access, pid} ->
# await_for_process_death(pid)
# await_for_sandbox_processes()
# _ ->
# await_for_sandbox_processes()
# after
# 10 -> :ok
# end
# end
# defp await_for_process_death(pid, retries_left \\ 5) do
# cond do
# not Process.alive?(pid) ->
# :ok
# retries_left > 0 ->
# Process.sleep(10)
# await_for_process_death(pid, retries_left - 1)
# true ->
# Process.exit(pid, :kill)
# end
# end
@doc """
This is an extension of ExUnit's `test` macro but:
- it rescues the exceptions from Wallaby and prints them while sleeping the process
(to allow you interacting with the browser) if test has `debug: true` tag;
- it takes a screenshot on failure if `debug` tag is not set to `true` or unset.
Additionally, it will try to await for all the sandboxed processes to finish their work
after the test has passed to prevent spamming logs with a lot of crash reports.
"""
defmacro feature(message, var \\ quote(do: _), contents) do
contents =
case contents do
[do: block] ->
quote do
try do
unquote(block)
if var!(debug?) == true, do: Process.sleep(360_000)
shutdown_live_socket(var!(session))
:ok
rescue
e ->
cond do
var!(debug?) == true ->
IO.puts(
IO.ANSI.red() <>
"Warning! This test runs in browser-debug mode, " <>
"it sleep the test process on failure for 50 seconds." <> IO.ANSI.reset()
)
IO.puts("")
IO.puts(IO.ANSI.yellow())
IO.puts("Exception was rescued:")
IO.puts(Exception.format(:error, e, __STACKTRACE__))
IO.puts(IO.ANSI.reset())
Process.sleep(:timer.seconds(50))
Wallaby.screenshot_on_failure?() ->
unquote(__MODULE__).take_screenshot(unquote(message))
true ->
:ok
end
reraise(e, __STACKTRACE__)
end
end
end
# Always insert debug? tag from module attributes,
# which is used by rescue block above
{op, meta, bindings} = var
debug_var_binding = {:debug?, {:debug?, meta, nil}}
var = {op, meta, bindings ++ [debug_var_binding]}
var = Macro.escape(var)
contents = Macro.escape(contents, unquote: true)
%{module: mod, file: file, line: line} = __CALLER__
quote bind_quoted: [
var: var,
contents: contents,
message: message,
mod: mod,
file: file,
line: line
] do
name = ExUnit.Case.register_test(mod, file, line, :test, message, [])
def unquote(name)(unquote(var)), do: unquote(contents)
end
end
end

View File

@@ -0,0 +1,91 @@
defmodule FzHttpWeb.AcceptanceCase.Auth do
import ExUnit.Assertions
def fetch_session_cookie(session) do
options = FzHttpWeb.Session.options()
key = Keyword.fetch!(options, :key)
encryption_salt = Keyword.fetch!(options, :encryption_salt)
signing_salt = Keyword.fetch!(options, :signing_salt)
secret_key_base = FzHttpWeb.Endpoint.config(:secret_key_base)
with {:ok, cookie} <- fetch_cookie(session, key),
encryption_key = Plug.Crypto.KeyGenerator.generate(secret_key_base, encryption_salt, []),
signing_key = Plug.Crypto.KeyGenerator.generate(secret_key_base, signing_salt, []),
{:ok, decrypted} <-
Plug.Crypto.MessageEncryptor.decrypt(
cookie,
encryption_key,
signing_key
) do
{:ok, Plug.Crypto.non_executable_binary_to_term(decrypted)}
end
end
defp fetch_cookie(session, key) do
cookies = Wallaby.Browser.cookies(session)
if cookie = Enum.find(cookies, fn cookie -> Map.get(cookie, "name") == key end) do
Map.fetch(cookie, "value")
else
:error
end
end
def authenticate(session, %FzHttp.Users.User{} = user) do
options = FzHttpWeb.Session.options()
key = Keyword.fetch!(options, :key)
encryption_salt = Keyword.fetch!(options, :encryption_salt)
signing_salt = Keyword.fetch!(options, :signing_salt)
secret_key_base = FzHttpWeb.Endpoint.config(:secret_key_base)
with {:ok, token, _claims} <- FzHttpWeb.Auth.HTML.Authentication.encode_and_sign(user) do
encryption_key = Plug.Crypto.KeyGenerator.generate(secret_key_base, encryption_salt, [])
signing_key = Plug.Crypto.KeyGenerator.generate(secret_key_base, signing_salt, [])
cookie =
%{
"guardian_default_token" => token,
"login_method" => "identity"
}
|> :erlang.term_to_binary()
encrypted =
Plug.Crypto.MessageEncryptor.encrypt(
cookie,
encryption_key,
signing_key
)
Wallaby.Browser.set_cookie(session, key, encrypted)
end
end
def assert_unauthenticated(session) do
with {:ok, cookie} <- fetch_session_cookie(session) do
if token = cookie["guardian_default_token"] do
{:ok, claims} = FzHttpWeb.Auth.HTML.Authentication.decode_and_verify(token)
flunk("User is authenticated, claims: #{inspect(claims)}")
else
session
end
else
:error -> session
end
end
def assert_authenticated(session, user) do
with {:ok, cookie} <- fetch_session_cookie(session),
{:ok, claims} <-
FzHttpWeb.Auth.HTML.Authentication.decode_and_verify(cookie["guardian_default_token"]),
{:ok, authenticated_user} <-
FzHttpWeb.Auth.HTML.Authentication.resource_from_claims(claims) do
assert authenticated_user.id == user.id
session
else
:error -> flunk("No session cookie found")
other -> flunk("User is not authenticated: #{inspect(other)}")
end
end
end

View File

@@ -0,0 +1,113 @@
defmodule FzHttpWeb.AcceptanceCase.Vault do
use Wallaby.DSL
@vault_root_token "firezone"
@vault_endpoint "http://127.0.0.1:8200"
def ensure_userpass_auth_enabled do
request(:put, "sys/auth/userpass", %{"type" => "userpass"})
:ok
end
def upsert_user(username, email, password) do
:ok = ensure_userpass_auth_enabled()
:ok = request(:put, "auth/userpass/users/#{username}", %{password: password})
# User Entity and Entity Alias are created automatically when user logs-in for
# the first time
{:ok, {200, params}} =
request(:post, "auth/userpass/login/#{username}", %{password: password})
entity_id = params["auth"]["entity_id"]
:ok = request(:put, "identity/entity/id/#{entity_id}", %{metadata: %{email: email}})
:ok
end
def setup_oidc_provider(endpoint_url, attrs_overrides \\ %{"auto_create_users" => true}) do
:ok =
request(:put, "identity/oidc/client/firezone", %{
assignments: "allow_all",
redirect_uris: "#{endpoint_url}/auth/oidc/vault/callback/",
scopes_supported: "openid,email"
})
:ok =
request(
:put,
"identity/oidc/scope/email",
%{template: Base.encode64("{\"email\": {{identity.entity.metadata.email}}}")}
)
:ok =
request(
:put,
"identity/oidc/provider/default",
%{scopes_supported: "email"}
)
{:ok, {200, params}} = request(:get, "identity/oidc/client/firezone")
FzHttp.Configurations.put!(
:openid_connect_providers,
[
%{
"id" => "vault",
"discovery_document_uri" =>
"http://127.0.0.1:8200/v1/identity/oidc/provider/default/.well-known/openid-configuration",
"client_id" => params["data"]["client_id"],
"client_secret" => params["data"]["client_secret"],
"redirect_uri" => "#{endpoint_url}/auth/oidc/vault/callback/",
"response_type" => "code",
"scope" => "openid email offline_access",
"label" => "OIDC Vault"
}
|> Map.merge(attrs_overrides)
]
)
:ok
end
def userpass_flow(session, oidc_login, oidc_password) do
session
|> assert_text("Method")
|> fill_in(Query.css("#select-ember40"), with: "userpass")
|> fill_in(Query.fillable_field("username"), with: oidc_login)
|> fill_in(Query.fillable_field("password"), with: oidc_password)
|> click(Query.button("Sign In"))
end
defp request(method, path, params_or_body \\ nil) do
headers = [
{"X-Vault-Request", "true"},
{"X-Vault-Token", @vault_root_token}
]
body =
cond do
is_map(params_or_body) ->
Jason.encode!(params_or_body)
is_binary(params_or_body) ->
params_or_body
true ->
""
end
:hackney.request(method, "#{@vault_endpoint}/v1/#{path}", headers, body, [:with_body])
|> case do
{:ok, _status, _headers, ""} ->
:ok
{:ok, status, _headers, body} ->
{:ok, {status, Jason.decode!(body)}}
{:error, reason} ->
{:error, reason}
end
end
end

View File

@@ -13,7 +13,6 @@ defmodule FzHttp.DataCase do
by setting `use FzHttp.DataCase, async: true`, although
this option is not recommended for other databases.
"""
use ExUnit.CaseTemplate
use FzHttp.CaseTemplate
@@ -23,8 +22,10 @@ defmodule FzHttp.DataCase do
import Ecto
import Ecto.Changeset
# XXX: Remove this import
import Ecto.Query
import FzHttp.DataCase
# XXX: Remove this import
import FzHttp.TestHelpers
end
end

View File

@@ -19,9 +19,25 @@ defmodule FzHttp.ConfigurationsFixtures do
configuration
end
def openid_connect_providers_attrs do
discovery_document_url = discovery_document_server()
def start_openid_providers(provider_names, overrides \\ %{}) do
{bypass, discovery_document_url} = discovery_document_server()
openid_connect_providers_attrs =
discovery_document_url
|> openid_connect_providers_attrs()
|> Enum.filter(&(&1["id"] in provider_names))
|> Enum.map(fn config ->
config
|> Enum.into(%{})
|> Map.merge(overrides)
end)
Configurations.put!(:openid_connect_providers, openid_connect_providers_attrs)
{bypass, openid_connect_providers_attrs}
end
defp openid_connect_providers_attrs(discovery_document_url) do
[
%{
"id" => "google",
@@ -31,7 +47,8 @@ defmodule FzHttp.ConfigurationsFixtures do
"redirect_uri" => "https://firezone.example.com/auth/oidc/google/callback/",
"response_type" => "code",
"scope" => "openid email profile",
"label" => "OIDC Google"
"label" => "OIDC Google",
"auto_create_users" => false
},
%{
"id" => "okta",
@@ -41,7 +58,8 @@ defmodule FzHttp.ConfigurationsFixtures do
"redirect_uri" => "https://firezone.example.com/auth/oidc/okta/callback/",
"response_type" => "code",
"scope" => "openid email profile offline_access",
"label" => "OIDC Okta"
"label" => "OIDC Okta",
"auto_create_users" => false
},
%{
"id" => "auth0",
@@ -51,7 +69,8 @@ defmodule FzHttp.ConfigurationsFixtures do
"redirect_uri" => "https://firezone.example.com/auth/oidc/auth0/callback/",
"response_type" => "code",
"scope" => "openid email profile",
"label" => "OIDC Auth0"
"label" => "OIDC Auth0",
"auto_create_users" => false
},
%{
"id" => "azure",
@@ -61,7 +80,8 @@ defmodule FzHttp.ConfigurationsFixtures do
"redirect_uri" => "https://firezone.example.com/auth/oidc/azure/callback/",
"response_type" => "code",
"scope" => "openid email profile offline_access",
"label" => "OIDC Azure"
"label" => "OIDC Azure",
"auto_create_users" => false
},
%{
"id" => "onelogin",
@@ -71,7 +91,8 @@ defmodule FzHttp.ConfigurationsFixtures do
"redirect_uri" => "https://firezone.example.com/auth/oidc/onelogin/callback/",
"response_type" => "code",
"scope" => "openid email profile offline_access",
"label" => "OIDC Onelogin"
"label" => "OIDC Onelogin",
"auto_create_users" => false
},
%{
"id" => "keycloak",
@@ -81,7 +102,8 @@ defmodule FzHttp.ConfigurationsFixtures do
"redirect_uri" => "https://firezone.example.com/auth/oidc/keycloak/callback/",
"response_type" => "code",
"scope" => "openid email profile offline_access",
"label" => "OIDC Keycloak"
"label" => "OIDC Keycloak",
"auto_create_users" => false
},
%{
"id" => "vault",
@@ -91,25 +113,79 @@ defmodule FzHttp.ConfigurationsFixtures do
"redirect_uri" => "https://firezone.example.com/auth/oidc/vault/callback/",
"response_type" => "code",
"scope" => "openid email profile offline_access",
"label" => "OIDC Vault"
"label" => "OIDC Vault",
"auto_create_users" => false
}
]
end
def jwks_attrs do
%{
"alg" => "RS256",
"d" =>
"X8TM24Zqbiha9geYYk_vZpANu16IadJLJLJ7ucTc3JaMbK8NCYNcHMoXKnNYPFxmq-UWAEIwh-2" <>
"txOiOxuChVrblpfyE4SBJio1T0AUcCwmm8U6G-CsSHMMzWTt2dMTnArHjdyAIgOVRW5SVzhTT" <>
"taf4JY-47S-fbcJ7g0hmBbVih5i1sE2fad4I4qFHT-YFU_pnUHbteR6GQuRW4r03Eon8Aje6a" <>
"l2AxcYnfF8_cSOIOpkDgGavTtGYhhZPi2jZ7kPm6QGkNW5CyfEq5PGB6JOihw-XIFiiMzYgx0" <>
"52rnzoqALoLheXrI0By4kgHSmcqOOmq7aiOff45rlSbpsR",
"e" => "AQAB",
"kid" => "example@firezone.dev",
"kty" => "RSA",
"n" =>
"qlKll8no4lPYXNSuTTnacpFHiXwPOv_htCYvIXmiR7CWhiiOHQqj7KWXIW7TGxyoLVIyeRM4mwv" <>
"kLI-UgsSMYdEKTT0j7Ydjrr0zCunPu5Gxr2yOmcRaszAzGxJL5DwpA0V40RqMlm5OuwdqS4To" <>
"_p9LlLxzMF6RZe1OqslV5RZ4Y8FmrWq6BV98eIziEHL0IKdsAIrrOYkkcLDdQeMNuTp_yNB8X" <>
"l2TdWSdsbRomrs2dCtCqZcXTsy2EXDceHvYhgAB33nh_w17WLrZQwMM-7kJk36Kk54jZd7i80" <>
"AJf_s_plXn1mEh-L5IAL1vg3a9EOMFUl-lPiGqc3td_ykH",
"use" => "sig"
}
end
def expect_refresh_token(bypass, attrs \\ %{}) do
test_pid = self()
Bypass.expect(bypass, "POST", "/oauth/token", fn conn ->
conn = fetch_conn_params(conn)
send(test_pid, {:request, conn})
Plug.Conn.resp(conn, 200, Jason.encode!(attrs))
end)
end
def expect_refresh_token_failure(bypass, attrs \\ %{}) do
test_pid = self()
Bypass.expect(bypass, "POST", "/oauth/token", fn conn ->
conn = fetch_conn_params(conn)
send(test_pid, {:request, conn})
Plug.Conn.resp(conn, 401, Jason.encode!(attrs))
end)
end
def discovery_document_server do
bypass = Bypass.open()
endpoint = "http://localhost:#{bypass.port}"
test_pid = self()
Bypass.expect(bypass, "GET", "/.well-known/jwks.json", fn conn ->
attrs = %{"keys" => [jwks_attrs()]}
Plug.Conn.resp(conn, 200, Jason.encode!(attrs))
end)
Bypass.expect(bypass, "GET", "/.well-known/openid-configuration", fn conn ->
conn = fetch_conn_params(conn)
send(test_pid, {:request, conn})
attrs = %{
"issuer" => "https://common.auth0.com/",
"authorization_endpoint" => "https://common.auth0.com/authorize",
"token_endpoint" => "https://common.auth0.com/oauth/token",
"device_authorization_endpoint" => "https://common.auth0.com/oauth/device/code",
"userinfo_endpoint" => "https://common.auth0.com/userinfo",
"mfa_challenge_endpoint" => "https://common.auth0.com/mfa/challenge",
"jwks_uri" => "https://common.auth0.com/.well-known/jwks.json",
"registration_endpoint" => "https://common.auth0.com/oidc/register",
"revocation_endpoint" => "https://common.auth0.com/oauth/revoke",
"issuer" => "#{endpoint}/",
"authorization_endpoint" => "#{endpoint}/authorize",
"token_endpoint" => "#{endpoint}/oauth/token",
"device_authorization_endpoint" => "#{endpoint}/oauth/device/code",
"userinfo_endpoint" => "#{endpoint}/userinfo",
"mfa_challenge_endpoint" => "#{endpoint}/mfa/challenge",
"jwks_uri" => "#{endpoint}/.well-known/jwks.json",
"registration_endpoint" => "#{endpoint}/oidc/register",
"revocation_endpoint" => "#{endpoint}/oauth/revoke",
"end_session_endpoint" => "https://example.com",
"scopes_supported" => [
"openid",
"profile",
@@ -180,7 +256,15 @@ defmodule FzHttp.ConfigurationsFixtures do
Plug.Conn.resp(conn, 200, Jason.encode!(attrs))
end)
"http://localhost:#{bypass.port}/.well-known/openid-configuration"
{bypass, "#{endpoint}/.well-known/openid-configuration"}
end
def fetch_conn_params(conn) do
opts = Plug.Parsers.init(parsers: [:urlencoded, :json], pass: ["*/*"], json_decoder: Jason)
conn
|> Plug.Conn.fetch_query_params()
|> Plug.Parsers.call(opts)
end
def saml_identity_providers_attrs do

View File

@@ -9,6 +9,19 @@ defmodule FzHttp.DevicesFixtures do
UsersFixtures
}
def create_device_for_user(user, attrs \\ %{}) do
attrs =
Enum.into(attrs, %{
user_id: user.id,
public_key: public_key(),
name: "factory #{counter()}",
description: "factory description"
})
{:ok, device} = Devices.create_device(attrs)
device
end
@doc """
Generate a device.
"""

View File

@@ -3,33 +3,34 @@ defmodule FzHttp.UsersFixtures do
This module defines test helpers for creating
entities via the `FzHttp.Users` context.
"""
alias FzHttp.Users
alias FzHttp.{Repo, Users, Users.User}
def user_attrs(attrs \\ %{}) do
Enum.into(attrs, %{
email: "test-#{counter()}@test",
password: "password1234",
password_confirmation: "password1234"
})
end
def create_user_with_role(attrs \\ %{}, role) do
attrs
|> Enum.into(%{role: role})
|> user()
end
def create_user(attrs \\ %{}) do
user(attrs)
end
@doc """
Generate a user specified by email, or generate a new otherwise.
"""
def user(attrs \\ %{}) do
email = attrs[:email] || "test-#{counter()}@test"
case Repo.get_by(User, email: email) do
nil ->
{:ok, user} =
Users.create_user(
%{
email: email,
role: :admin,
password: "password1234",
password_confirmation: "password1234"
},
Enum.into(attrs, %{role: :admin})
)
user
%User{} = user ->
user
end
attrs = user_attrs(attrs)
{role, attrs} = Map.pop(attrs, :role, :admin)
{:ok, user} = Users.create_user(attrs, role)
user
end
defp counter do

View File

@@ -1,9 +0,0 @@
defmodule OpenIDConnect.MockBehaviour do
@moduledoc """
Mock Behaviour for OpenIDConnect so that we can use Mox
"""
@callback end_session_uri(any, map) :: String.t()
@callback authorization_uri(any, map) :: String.t()
@callback fetch_tokens(any, map) :: {:ok, any} | {:error, :fetch_tokens, any}
@callback verify(any, map) :: {:ok, any} | {:error, :verify, any}
end

View File

@@ -10,7 +10,6 @@ defmodule FzHttp.TestHelpers do
NotificationsFixtures,
Repo,
RulesFixtures,
Users,
Users.User,
UsersFixtures
}
@@ -168,7 +167,7 @@ defmodule FzHttp.TestHelpers do
end
def create_user_with_valid_sign_in_token(_) do
{:ok, user: %User{} = UsersFixtures.user(Users.sign_in_keys())}
{:ok, user: %User{}} = UsersFixtures.user()
end
def create_user_with_expired_sign_in_token(_) do

View File

@@ -1,9 +1,10 @@
Ecto.Adapters.SQL.Sandbox.mode(FzHttp.Repo, :manual)
Mox.defmock(OpenIDConnect.Mock, for: OpenIDConnect.MockBehaviour)
# Delete screenshots from previous acceptance test executions
Path.join(File.cwd!(), "screenshots") |> File.rm_rf!()
Bureaucrat.start(
writer: Firezone.DocusaurusWriter,
default_path: "../../docs/docs/reference/rest-api"
)
ExUnit.start(formatters: [ExUnit.CLIFormatter, Bureaucrat.Formatter])
Ecto.Adapters.SQL.Sandbox.mode(FzHttp.Repo, :manual)
ExUnit.start(formatters: [ExUnit.CLIFormatter, JUnitFormatter, Bureaucrat.Formatter])

View File

@@ -0,0 +1,9 @@
[
locals_without_parens: [],
import_deps: [],
inputs: [
"*.{ex,exs}",
"{lib,test,priv}/**/*.{ex,exs}"
],
plugins: []
]

View File

@@ -1,14 +1,8 @@
defmodule FzVpn.Application do
# See https://hexdocs.pm/elixir/Application.html
# for more information on OTP Applications
@moduledoc false
use Application
def start(_type, _args) do
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: FzVpn.Supervisor]
opts = [strategy: :one_for_one, name: __MODULE__.Supervisor]
Supervisor.start_link(children(), opts)
end

View File

@@ -41,8 +41,6 @@ defmodule FzVpn.MixProject do
[
{:fz_http, in_umbrella: true},
{:fz_common, in_umbrella: true},
{:credo, "~> 1.4", only: [:dev, :test], runtime: false},
{:excoveralls, "~> 0.13", only: :test},
{:wireguardex, "~> 0.3.5"}
]
end

Some files were not shown because too many files have changed in this diff Show More