mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
]
|
||||
]
|
||||
|
||||
136
.github/workflows/test.yml
vendored
136
.github/workflows/test.yml
vendored
@@ -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
|
||||
|
||||
9
apps/fz_common/.formatter.exs
Normal file
9
apps/fz_common/.formatter.exs
Normal file
@@ -0,0 +1,9 @@
|
||||
[
|
||||
locals_without_parens: [],
|
||||
import_deps: [],
|
||||
inputs: [
|
||||
"*.{ex,exs}",
|
||||
"{lib,test,priv}/**/*.{ex,exs}"
|
||||
],
|
||||
plugins: []
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
18
apps/fz_http/.formatter.exs
Normal file
18
apps/fz_http/.formatter.exs
Normal 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
|
||||
]
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ->
|
||||
%{
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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...")
|
||||
|
||||
|
||||
@@ -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
|
||||
0
apps/fz_http/lib/fz_http/oidc/supervisor.ex
Normal file
0
apps/fz_http/lib/fz_http/oidc/supervisor.ex
Normal 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
68
apps/fz_http/lib/fz_http/users/user/changeset.ex
Normal file
68
apps/fz_http/lib/fz_http/users/user/changeset.ex
Normal 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
|
||||
45
apps/fz_http/lib/fz_http/users/user/query.ex
Normal file
45
apps/fz_http/lib/fz_http/users/user/query.ex
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
_ ->
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) %>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
41
apps/fz_http/lib/fz_http_web/sandbox.ex
Normal file
41
apps/fz_http/lib/fz_http_web/sandbox.ex
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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() == []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
673
apps/fz_http/test/fz_http_web/acceptance/admin_test.exs
Normal file
673
apps/fz_http/test/fz_http_web/acceptance/admin_test.exs
Normal 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
|
||||
414
apps/fz_http/test/fz_http_web/acceptance/authentication_test.exs
Normal file
414
apps/fz_http/test/fz_http_web/acceptance/authentication_test.exs
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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't be blank"
|
||||
assert test_view =~ "should be at least 12 character(s)"
|
||||
end
|
||||
|
||||
test "closes modal", %{unprivileged_conn: conn} do
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
297
apps/fz_http/test/support/acceptance_case.ex
Normal file
297
apps/fz_http/test/support/acceptance_case.ex
Normal 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
|
||||
91
apps/fz_http/test/support/acceptance_case/auth.ex
Normal file
91
apps/fz_http/test/support/acceptance_case/auth.ex
Normal 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
|
||||
113
apps/fz_http/test/support/acceptance_case/vault.ex
Normal file
113
apps/fz_http/test/support/acceptance_case/vault.ex
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
9
apps/fz_vpn/.formatter.exs
Normal file
9
apps/fz_vpn/.formatter.exs
Normal file
@@ -0,0 +1,9 @@
|
||||
[
|
||||
locals_without_parens: [],
|
||||
import_deps: [],
|
||||
inputs: [
|
||||
"*.{ex,exs}",
|
||||
"{lib,test,priv}/**/*.{ex,exs}"
|
||||
],
|
||||
plugins: []
|
||||
]
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user