security(portal): Rework auth tokens (#2696)

- [x] make sure that session cookie for client is stored separately from
session cookie for the portal (will close #2647 and #2032)
- [x] #2622
- [ ] #2501
- [ ] show identity tokens and allow rotating/deleting them (#2138)
- [ ] #2042
- [ ] use Tokens context for Relays and Gateways to remove duplication
- [x] #2823
- [ ] Expire LiveView sockets when subject is expired
- [ ] Service Accounts UI is ambiguous now because of token identity and
actual token shown
- [ ] Limit subject permissions based on token type

Closes #2924. Now we extend the lifetime for client tokens, but not for
browsers.
This commit is contained in:
Andrew Dryga
2024-01-09 13:36:21 -06:00
committed by GitHub
parent 6a9ba5412c
commit ed5437c881
154 changed files with 5988 additions and 3170 deletions

View File

@@ -81,8 +81,8 @@ services:
# Auth
AUTH_PROVIDER_ADAPTERS: "email,openid_connect,userpass,token,google_workspace"
# Secrets
AUTH_TOKEN_KEY_BASE: "5OVYJ83AcoQcPmdKNksuBhJFBhjHD1uUa9mDOHV/6EIdBQ6pXksIhkVeWIzFk5S2"
AUTH_TOKEN_SALT: "t01wa0K4lUd7mKa0HAtZdE+jFOPDDej2"
TOKENS_KEY_BASE: "5OVYJ83AcoQcPmdKNksuBhJFBhjHD1uUa9mDOHV/6EIdBQ6pXksIhkVeWIzFk5S2"
TOKENS_SALT: "t01wa0K4lUd7mKa0HAtZdE+jFOPDDej2"
RELAYS_AUTH_TOKEN_KEY_BASE: "5OVYJ83AcoQcPmdKNksuBhJFBhjHD1uUa9mDOHV/6EIdBQ6pXksIhkVeWIzFk5S2"
RELAYS_AUTH_TOKEN_SALT: "t01wa0K4lUd7mKa0HAtZdE+jFOPDDej2"
GATEWAYS_AUTH_TOKEN_KEY_BASE: "5OVYJ83AcoQcPmdKNksuBhJFBhjHD1uUa9mDOHV/6EIdBQ6pXksIhkVeWIzFk5S2"
@@ -113,7 +113,7 @@ services:
client:
environment:
FIREZONE_TOKEN: "SFMyNTY.g2gDaAN3CGlkZW50aXR5bQAAACQ3ZGE3ZDFjZC0xMTFjLTQ0YTctYjVhYy00MDI3YjlkMjMwZTVtAAAAIBn8Xu1jtFlxZxp4ZvAz0f0QEN2PZThA-7awHMPxn_tHbgYAbLRvQokBYgHhM38.pM-prhb7uvvCVKf51-tAUMEtMzLPZk1n3nLsY44dGFA"
FIREZONE_TOKEN: "n.SFMyNTY.g2gDaANtAAAAJGM4OWJjYzhjLTkzOTItNGRhZS1hNDBkLTg4OGFlZjZkMjhlMG0AAAAkN2RhN2QxY2QtMTExYy00NGE3LWI1YWMtNDAyN2I5ZDIzMGU1bQAAACtBaUl5XzZwQmstV0xlUkFQenprQ0ZYTnFJWktXQnMyRGR3XzJ2Z0lRdkZnbgYAGUmu74wBYgABUYA.UN3vSLLcAMkHeEh5VHumPOutkuue8JA6wlxM9JxJEPE"
RUST_LOG: firezone_linux_client=trace,wire=trace,connlib_client_shared=trace,firezone_tunnel=trace,connlib_shared=trace,warn
FIREZONE_API_URL: ws://api:8081
FIREZONE_ID: D0455FDE-8F65-4960-A778-B934E4E85A5F
@@ -223,7 +223,7 @@ services:
image: us-east1-docker.pkg.dev/firezone-staging/firezone/relay:${VERSION:-main}
healthcheck:
test: ["CMD-SHELL", "lsof -i UDP | grep firezone-relay"]
start_period: 20s
start_period: 3s
interval: 30s
retries: 5
timeout: 5s
@@ -273,8 +273,8 @@ services:
# Auth
AUTH_PROVIDER_ADAPTERS: "email,openid_connect,userpass,token,google_workspace"
# Secrets
AUTH_TOKEN_KEY_BASE: "5OVYJ83AcoQcPmdKNksuBhJFBhjHD1uUa9mDOHV/6EIdBQ6pXksIhkVeWIzFk5S2"
AUTH_TOKEN_SALT: "t01wa0K4lUd7mKa0HAtZdE+jFOPDDej2"
TOKENS_KEY_BASE: "5OVYJ83AcoQcPmdKNksuBhJFBhjHD1uUa9mDOHV/6EIdBQ6pXksIhkVeWIzFk5S2"
TOKENS_SALT: "t01wa0K4lUd7mKa0HAtZdE+jFOPDDej2"
RELAYS_AUTH_TOKEN_KEY_BASE: "5OVYJ83AcoQcPmdKNksuBhJFBhjHD1uUa9mDOHV/6EIdBQ6pXksIhkVeWIzFk5S2"
RELAYS_AUTH_TOKEN_SALT: "t01wa0K4lUd7mKa0HAtZdE+jFOPDDej2"
GATEWAYS_AUTH_TOKEN_KEY_BASE: "5OVYJ83AcoQcPmdKNksuBhJFBhjHD1uUa9mDOHV/6EIdBQ6pXksIhkVeWIzFk5S2"
@@ -302,7 +302,7 @@ services:
condition: "service_healthy"
healthcheck:
test: ["CMD-SHELL", "curl -f localhost:8081/healthz"]
start_period: 20s
start_period: 10s
interval: 30s
retries: 5
timeout: 5s
@@ -338,8 +338,8 @@ services:
# Auth
AUTH_PROVIDER_ADAPTERS: "email,openid_connect,userpass,token,google_workspace"
# Secrets
AUTH_TOKEN_KEY_BASE: "5OVYJ83AcoQcPmdKNksuBhJFBhjHD1uUa9mDOHV/6EIdBQ6pXksIhkVeWIzFk5S2"
AUTH_TOKEN_SALT: "t01wa0K4lUd7mKa0HAtZdE+jFOPDDej2"
TOKENS_KEY_BASE: "5OVYJ83AcoQcPmdKNksuBhJFBhjHD1uUa9mDOHV/6EIdBQ6pXksIhkVeWIzFk5S2"
TOKENS_SALT: "t01wa0K4lUd7mKa0HAtZdE+jFOPDDej2"
RELAYS_AUTH_TOKEN_KEY_BASE: "5OVYJ83AcoQcPmdKNksuBhJFBhjHD1uUa9mDOHV/6EIdBQ6pXksIhkVeWIzFk5S2"
RELAYS_AUTH_TOKEN_SALT: "t01wa0K4lUd7mKa0HAtZdE+jFOPDDej2"
GATEWAYS_AUTH_TOKEN_KEY_BASE: "5OVYJ83AcoQcPmdKNksuBhJFBhjHD1uUa9mDOHV/6EIdBQ6pXksIhkVeWIzFk5S2"

View File

@@ -102,7 +102,7 @@ Now you can verify that it's working by connecting to a websocket:
```bash
# Note: The token value below is an example. The token value you will need is generated and printed out when seeding the database, as described earlier in the document.
export CLIENT_TOKEN_FROM_SEEDS="SFMyNTY.g2gDaAN3CGlkZW50aXR5bQAAACQ3ZGE3ZDFjZC0xMTFjLTQ0YTctYjVhYy00MDI3YjlkMjMwZTV3Bmlnbm9yZW4GAJhGr7WKAWIACTqA.mrPu5eFVwkfRml7zzHb5uYfosLGaYVHq03-wE02xUNc"
export CLIENT_TOKEN_FROM_SEEDS="n.SFMyNTY.g2gDaANtAAAAJGM4OWJjYzhjLTkzOTItNGRhZS1hNDBkLTg4OGFlZjZkMjhlMG0AAAAkN2RhN2QxY2QtMTExYy00NGE3LWI1YWMtNDAyN2I5ZDIzMGU1bQAAACtBaUl5XzZwQmstV0xlUkFQenprQ0ZYTnFJWktXQnMyRGR3XzJ2Z0lRdkZnbgYAGUmu74wBYgABUYA.UN3vSLLcAMkHeEh5VHumPOutkuue8JA6wlxM9JxJEPE"
# Panel will only accept token if it's coming with this User-Agent header and from IP 172.28.0.1
export CLIENT_USER_AGENT="iOS/12.5 (iPhone) connlib/0.7.412"
@@ -209,12 +209,12 @@ user_agent = "User-Agent: iOS/12.7 (iPhone) connlib/0.7.412"
remote_ip = {127, 0, 0, 1}
# For a client
{:ok, subject} = Domain.Auth.sign_in(client_token, user_agent, remote_ip)
{:ok, subject} = Domain.Auth.sign_in(client_token, %Domain.Auth.Context{type: :client, user_agent: user_agent, remote_ip: remote_ip})
# For an admin user
provider = Domain.Repo.get_by(Domain.Auth.Provider, adapter: :userpass)
identity = Domain.Repo.get_by(Domain.Auth.Identity, provider_id: provider.id, provider_identifier: "firezone@localhost")
subject = Domain.Auth.build_subject(identity, nil, %Domain.Auth.Context{user_agent: user_agent, remote_ip: remote_ip})
subject = Domain.Auth.build_subject(identity, nil, %Domain.Auth.Context{type: :browser, user_agent: user_agent, remote_ip: remote_ip})
```
Listing connected gateways, relays, clients for an account:
@@ -340,7 +340,7 @@ iex(web@web-2f4j.us-east1-d.c.firezone-staging.internal)1> [actor | _] = Domain.
iex(web@web-2f4j.us-east1-d.c.firezone-staging.internal)2> [identity | _] = Domain.Auth.Identity.Query.by_actor_id(actor.id) |> Domain.Repo.all()
...
iex(web@web-2f4j.us-east1-d.c.firezone-staging.internal)3> subject = Domain.Auth.build_subject(identity, nil, %Domain.Auth.Context{user_agent: "CLI", remote_ip: {127, 0, 0, 1}})
iex(web@web-2f4j.us-east1-d.c.firezone-staging.internal)3> subject = Domain.Auth.build_subject(identity, nil, %Domain.Auth.Context{type: :browser, user_agent: "CLI", remote_ip: {127, 0, 0, 1}})
```
### Rotate relay token

View File

@@ -27,6 +27,7 @@ defmodule API.Client.Socket do
API.Sockets.load_balancer_ip_location(x_headers)
context = %Auth.Context{
type: :client,
user_agent: user_agent,
remote_ip: real_ip,
remote_ip_location_region: location_region,
@@ -35,7 +36,7 @@ defmodule API.Client.Socket do
remote_ip_location_lon: location_lon
}
with {:ok, subject} <- Auth.sign_in(token, context),
with {:ok, subject} <- Auth.authenticate(token, context),
{:ok, client} <- Clients.upsert_client(attrs, subject) do
OpenTelemetry.Tracer.set_attributes(%{
client_id: client.id,

View File

@@ -1,33 +0,0 @@
defmodule API.Session do
def options do
[
store: :cookie,
key: "_firezone_api_key",
same_site: "Lax",
# 4 hours
max_age: 14_400,
sign: true,
encrypt: true,
secure: cookie_secure(),
signing_salt: signing_salt(),
encryption_salt: encryption_salt()
]
end
defp cookie_secure do
Domain.Config.fetch_env!(:api, :cookie_secure)
end
defp signing_salt do
[vsn | _] =
Application.spec(:domain, :vsn)
|> to_string()
|> String.split("+")
Domain.Config.fetch_env!(:api, :cookie_signing_salt) <> vsn
end
defp encryption_salt do
Domain.Config.fetch_env!(:api, :cookie_encryption_salt)
end
end

View File

@@ -2,7 +2,6 @@ defmodule API.Client.SocketTest do
use API.ChannelCase, async: true
import API.Client.Socket, only: [id: 1]
alias API.Client.Socket
alias Domain.Auth
@geo_headers [
{"x-geo-location-region", "Ukraine"},
@@ -31,31 +30,74 @@ defmodule API.Client.SocketTest do
end
test "renders error on invalid attrs" do
subject = Fixtures.Auth.create_subject()
{:ok, token} = Auth.create_session_token_from_subject(subject)
context = Fixtures.Auth.build_context(type: :client)
{_token, encoded_token} = Fixtures.Auth.create_and_encode_token(context: context)
attrs = %{token: token}
attrs = %{token: encoded_token}
assert {:error, changeset} = connect(Socket, attrs, connect_info: connect_info(subject))
assert {:error, changeset} = connect(Socket, attrs, connect_info: @connect_info)
errors = API.Sockets.changeset_error_to_string(changeset)
assert errors =~ "public_key: can't be blank"
assert errors =~ "external_id: can't be blank"
end
test "creates a new client" do
subject = Fixtures.Auth.create_subject()
{:ok, token} = Auth.create_session_token_from_subject(subject)
test "returns error when token is created for a different context" do
context = Fixtures.Auth.build_context(type: :browser)
{_token, encoded_token} = Fixtures.Auth.create_and_encode_token(context: context)
attrs = connect_attrs(token: token)
attrs = connect_attrs(token: encoded_token)
assert {:ok, socket} = connect(Socket, attrs, connect_info: connect_info(subject))
assert connect(Socket, attrs, connect_info: @connect_info) == {:error, :invalid_token}
end
test "creates a new client for user identity" do
context = Fixtures.Auth.build_context(type: :client)
{_token, encoded_token} = Fixtures.Auth.create_and_encode_token(context: context)
attrs = connect_attrs(token: encoded_token)
assert {:ok, socket} = connect(Socket, attrs, connect_info: connect_info(context))
assert client = Map.fetch!(socket.assigns, :client)
assert client.external_id == attrs["external_id"]
assert client.public_key == attrs["public_key"]
assert client.last_seen_user_agent == subject.context.user_agent
assert client.last_seen_remote_ip.address == subject.context.remote_ip
assert client.last_seen_user_agent == context.user_agent
assert client.last_seen_remote_ip.address == context.remote_ip
assert client.last_seen_remote_ip_location_region == "Ukraine"
assert client.last_seen_remote_ip_location_city == "Kyiv"
assert client.last_seen_remote_ip_location_lat == 50.4333
assert client.last_seen_remote_ip_location_lon == 30.5167
assert client.last_seen_version == "0.7.412"
end
test "creates a new client for service account identity" do
context = Fixtures.Auth.build_context(type: :client)
account = Fixtures.Accounts.create_account()
provider = Fixtures.Auth.create_token_provider(account: account)
identity =
Fixtures.Auth.create_identity(
account: account,
provider: provider,
provider_virtual_state: %{
"expires_at" => DateTime.utc_now() |> DateTime.add(60, :second)
}
)
subject = Fixtures.Auth.create_subject(account: account, actor: [type: :account_admin_user])
{:ok, encoded_token} = Domain.Auth.create_service_account_token(provider, identity, subject)
attrs = connect_attrs(token: encoded_token)
assert {:ok, socket} = connect(Socket, attrs, connect_info: connect_info(context))
assert client = Map.fetch!(socket.assigns, :client)
assert client.external_id == attrs["external_id"]
assert client.public_key == attrs["public_key"]
assert client.last_seen_user_agent == context.user_agent
assert client.last_seen_remote_ip.address == context.remote_ip
assert client.last_seen_remote_ip_location_region == "Ukraine"
assert client.last_seen_remote_ip_location_city == "Kyiv"
assert client.last_seen_remote_ip_location_lat == 50.4333
@@ -64,32 +106,41 @@ defmodule API.Client.SocketTest do
end
test "propagates trace context" do
subject = Fixtures.Auth.create_subject()
{:ok, token} = Auth.create_session_token_from_subject(subject)
context = Fixtures.Auth.build_context(type: :client)
{_token, encoded_token} = Fixtures.Auth.create_and_encode_token(context: context)
span_ctx = OpenTelemetry.Tracer.start_span("test")
OpenTelemetry.Tracer.set_current_span(span_ctx)
attrs = connect_attrs(token: token)
attrs = connect_attrs(token: encoded_token)
trace_context_headers = [
{"traceparent", "00-a1bf53221e0be8000000000000000002-f316927eb144aa62-01"}
]
connect_info = %{connect_info(subject) | trace_context_headers: trace_context_headers}
connect_info = %{connect_info(context) | trace_context_headers: trace_context_headers}
assert {:ok, _socket} = connect(Socket, attrs, connect_info: connect_info)
assert span_ctx != OpenTelemetry.Tracer.current_span_ctx()
end
test "updates existing client" do
subject = Fixtures.Auth.create_subject()
existing_client = Fixtures.Clients.create_client(subject: subject)
{:ok, token} = Auth.create_session_token_from_subject(subject)
account = Fixtures.Accounts.create_account()
context = Fixtures.Auth.build_context(type: :client)
identity = Fixtures.Auth.create_identity(account: account)
attrs = connect_attrs(token: token, external_id: existing_client.external_id)
{_token, encoded_token} =
Fixtures.Auth.create_and_encode_token(
account: account,
identity: identity,
context: context
)
assert {:ok, socket} = connect(Socket, attrs, connect_info: connect_info(subject))
existing_client = Fixtures.Clients.create_client(account: account, identity: identity)
attrs = connect_attrs(token: encoded_token, external_id: existing_client.external_id)
assert {:ok, socket} = connect(Socket, attrs, connect_info: connect_info(context))
assert client = Repo.one(Domain.Clients.Client)
assert client.id == socket.assigns.client.id
assert client.last_seen_remote_ip_location_region == "Ukraine"
@@ -99,13 +150,22 @@ defmodule API.Client.SocketTest do
end
test "uses region code to put default coordinates" do
subject = Fixtures.Auth.create_subject()
existing_client = Fixtures.Clients.create_client(subject: subject)
{:ok, token} = Auth.create_session_token_from_subject(subject)
account = Fixtures.Accounts.create_account()
context = Fixtures.Auth.build_context(type: :client)
identity = Fixtures.Auth.create_identity(account: account)
attrs = connect_attrs(token: token, external_id: existing_client.external_id)
{_token, encoded_token} =
Fixtures.Auth.create_and_encode_token(
account: account,
identity: identity,
context: context
)
connect_info = %{connect_info(subject) | x_headers: [{"x-geo-location-region", "UA"}]}
existing_client = Fixtures.Clients.create_client(account: account, identity: identity)
attrs = connect_attrs(token: encoded_token, external_id: existing_client.external_id)
connect_info = %{connect_info(context) | x_headers: [{"x-geo-location-region", "UA"}]}
assert {:ok, socket} = connect(Socket, attrs, connect_info: connect_info)
assert client = Repo.one(Domain.Clients.Client)
@@ -126,10 +186,10 @@ defmodule API.Client.SocketTest do
end
end
defp connect_info(subject) do
defp connect_info(context) do
%{
user_agent: subject.context.user_agent,
peer_data: %{address: subject.context.remote_ip},
user_agent: context.user_agent,
peer_data: %{address: context.remote_ip},
x_headers: @geo_headers,
trace_context_headers: []
}

View File

@@ -40,6 +40,9 @@ defmodule Domain.Accounts do
end
end
def fetch_account_by_id_or_slug(nil), do: {:error, :not_found}
def fetch_account_by_id_or_slug(""), do: {:error, :not_found}
def fetch_account_by_id_or_slug(id_or_slug) do
if Validator.valid_uuid?(id_or_slug) do
Account.Query.by_id(id_or_slug)

View File

@@ -33,6 +33,8 @@ defmodule Domain.Accounts.Account do
has_many :relay_groups, Domain.Relays.Group, where: [deleted_at: nil]
has_many :relay_tokens, Domain.Relays.Token, where: [deleted_at: nil]
has_many :tokens, Domain.Tokens.Token, where: [deleted_at: nil]
timestamps()
end
end

View File

@@ -309,8 +309,8 @@ defmodule Domain.Actors do
|> Repo.fetch_and_update(
with: fn actor ->
if actor.type != :account_admin_user or other_enabled_admins_exist?(actor) do
:ok = Auth.delete_actor_identities(actor)
:ok = Clients.delete_actor_clients(actor)
:ok = Auth.delete_actor_identities(actor, subject)
:ok = Clients.delete_actor_clients(actor, subject)
Actor.Changeset.delete_actor(actor)
else

View File

@@ -32,6 +32,7 @@ defmodule Domain.Application do
Domain.Cluster,
# Application
Domain.Tokens,
Domain.Auth,
Domain.Relays,
Domain.Gateways,

View File

@@ -1,16 +1,90 @@
defmodule Domain.Auth do
@doc """
This module is the core of our security, it is designed to have multiple layers of
protection and provide guidance for the developers to avoid common security pitfalls.
## Authentication
Authentication is split into two core components:
1. *Sign In* - exchange of a secret (IdP ID token or username/password) for our internal token.
This token is stored in the database (see `Domain.Tokens` module) and then encoded to be
stored in browser session or on mobile clients. For more details see "Tokens" section below.
2. Authentication - verification of the token and extraction of the subject from it.
## Authorization and Subject
Authorization is a domain concern because it's tightly coupled with the business logic
and allows better control over the access to the data. Plus makes it more secure iterating
faster on the UI/UX without risking to compromise security.
Every function directly or indirectly called by the end user MUST have a Subject
as last or second to last argument, implementation of the functions MUST use
it's own context `Authorizer` module (that implements behaviour `Domain.Auth.Authorizer`)
to filter the data based on the account and permissions of the subject.
As an extra measure, whenever a function performs an action on an object that is not
further re-queried using the `for_subject/1` the implementation MUST check that the subject
has access to given object. It can be done by one of `ensure_has_access_to?/2` functions
added to domain contexts responsible for the given schema, eg. `Domain.Accounts.ensure_has_access_to/2`.
Only exception is the authentication flow where user can not contain the subject yet,
but such queries MUST be filtered by the `account_id` and use indexes to prevent
simple DDoS attacks.
## Tokens
### Color Coding
The tokens are color coded using a `type` field, which means that a token issued for a browser session
can not be used for client calls and vice versa. Type of the token also limits permissions that will
be later added to the subject.
You can find all the token types in enum value of `type` field in `Domain.Tokens.Token` schema.
### Secure client exchange
The tokens consists of two parts: client-supplied nonce (typically 32-byte hex-encoded string) and
server-generated fragment.
The server-generated fragment is additionally signed using `Phoenix.Token` to prevent tampering with it
and make sure that database lookups won't be made for invalid tokens. See `Domain.Tokens.encode_fragment!/1` for
more details.
### Expiration
Token expiration depends on the context in which it can be used and is limited by
`@max_session_duration_hours` to prevent extremely long-lived tokens for
`clients` and `browsers`. For more details see `token_expires_at/3`.
## Identity Providers
You can find all the IdP adapters in `Domain.Auth.Adapters` module.
"""
use Supervisor
alias Domain.{Repo, Config, Validator}
alias Domain.{Accounts, Actors}
alias Domain.Auth.{Authorizer, Subject, Context, Permission, Roles, Role, Identity}
alias Domain.{Repo, Validator}
alias Domain.{Accounts, Actors, Tokens}
alias Domain.Auth.{Authorizer, Subject, Context, Permission, Roles, Role}
alias Domain.Auth.{Adapters, Provider}
alias Domain.Auth.Identity
@default_session_duration_hours %{
account_admin_user: 24 * 7 - 1,
account_user: 24 * 7,
service_account: 20 * 365 * 24 * 7
}
# This session duration is used when IdP doesn't return the token expiration date,
# or no IdP is used (eg. sign in via magic link or userpass).
@default_session_duration_hours [
browser: [
account_admin_user: 10,
account_user: 10
],
client: [
account_admin_user: 24 * 7,
account_user: 24 * 7
]
]
# We don't want to allow extremely long-lived sessions for clients and browsers
# even if IdP returns them.
@max_session_duration_hours @default_session_duration_hours
def start_link(opts) do
@@ -53,6 +127,17 @@ defmodule Domain.Auth do
end
end
# used to during auth flow in the UI where Subject doesn't exist yet
def fetch_active_provider_by_id(id) do
if Validator.valid_uuid?(id) do
Provider.Query.by_id(id)
|> Provider.Query.not_disabled()
|> Repo.fetch()
else
{:error, :not_found}
end
end
@doc """
This functions allows to fetch singleton providers like `email` or `token`.
"""
@@ -75,55 +160,15 @@ defmodule Domain.Auth do
end
end
def fetch_provider_by_id(id) do
if Validator.valid_uuid?(id) do
Provider.Query.by_id(id)
|> Repo.fetch()
else
{:error, :not_found}
end
end
def fetch_active_provider_by_id(id, %Subject{} = subject, opts \\ []) do
with :ok <- ensure_has_permissions(subject, Authorizer.manage_providers_permission()),
true <- Validator.valid_uuid?(id) do
{preload, _opts} = Keyword.pop(opts, :preload, [])
Provider.Query.by_id(id)
|> Provider.Query.not_disabled()
def list_providers(%Subject{} = subject) do
with :ok <- ensure_has_permissions(subject, Authorizer.manage_providers_permission()) do
Provider.Query.not_deleted()
|> Authorizer.for_subject(Provider, subject)
|> Repo.fetch()
|> case do
{:ok, provider} ->
{:ok, Repo.preload(provider, preload)}
{:error, reason} ->
{:error, reason}
end
else
false -> {:error, :not_found}
other -> other
end
end
def fetch_active_provider_by_id(id) do
if Validator.valid_uuid?(id) do
Provider.Query.by_id(id)
|> Provider.Query.not_disabled()
|> Repo.fetch()
else
{:error, :not_found}
end
end
def list_providers_for_account(%Accounts.Account{} = account, %Subject{} = subject) do
with :ok <- ensure_has_permissions(subject, Authorizer.manage_providers_permission()),
:ok <- Accounts.ensure_has_access_to(subject, account) do
Provider.Query.by_account_id(account.id)
|> Repo.list()
end
end
# used to build list of auth options for the UI
def list_active_providers_for_account(%Accounts.Account{} = account) do
Provider.Query.by_account_id(account.id)
|> Provider.Query.not_disabled()
@@ -165,6 +210,8 @@ defmodule Domain.Auth do
end
end
# used for testing and seeding the database
@doc false
def create_provider(%Accounts.Account{} = account, attrs) do
changeset =
Provider.Changeset.create(account, attrs)
@@ -181,63 +228,50 @@ defmodule Domain.Auth do
end
def update_provider(%Provider{} = provider, attrs, %Subject{} = subject) do
with :ok <- ensure_has_permissions(subject, Authorizer.manage_providers_permission()) do
Provider.Query.by_id(provider.id)
|> Authorizer.for_subject(Provider, subject)
|> Repo.fetch_and_update(
with: fn provider ->
Provider.Changeset.update(provider, attrs)
|> Adapters.provider_changeset()
end
)
end
mutate_provider(provider, subject, fn provider ->
Provider.Changeset.update(provider, attrs)
|> Adapters.provider_changeset()
end)
end
def disable_provider(%Provider{} = provider, %Subject{} = subject) do
with :ok <- ensure_has_permissions(subject, Authorizer.manage_providers_permission()) do
Provider.Query.by_id(provider.id)
|> Authorizer.for_subject(Provider, subject)
|> Repo.fetch_and_update(
with: fn provider ->
if other_active_providers_exist?(provider) do
Provider.Changeset.disable_provider(provider)
else
:cant_disable_the_last_provider
end
end
)
end
mutate_provider(provider, subject, fn provider ->
if other_active_providers_exist?(provider) do
Provider.Changeset.disable_provider(provider)
else
:cant_disable_the_last_provider
end
end)
end
def enable_provider(%Provider{} = provider, %Subject{} = subject) do
with :ok <- ensure_has_permissions(subject, Authorizer.manage_providers_permission()) do
Provider.Query.by_id(provider.id)
|> Authorizer.for_subject(Provider, subject)
|> Repo.fetch_and_update(with: &Provider.Changeset.enable_provider/1)
end
mutate_provider(provider, subject, &Provider.Changeset.enable_provider/1)
end
def delete_provider(%Provider{} = provider, %Subject{} = subject) do
provider
|> mutate_provider(subject, fn provider ->
if other_active_providers_exist?(provider) do
Provider.Changeset.delete_provider(provider)
else
:cant_delete_the_last_provider
end
end)
|> case do
{:ok, provider} ->
Adapters.ensure_deprovisioned(provider)
{:error, reason} ->
{:error, reason}
end
end
defp mutate_provider(%Provider{} = provider, %Subject{} = subject, callback)
when is_function(callback, 1) do
with :ok <- ensure_has_permissions(subject, Authorizer.manage_providers_permission()) do
Provider.Query.by_id(provider.id)
|> Authorizer.for_subject(Provider, subject)
|> Repo.fetch_and_update(
with: fn provider ->
if other_active_providers_exist?(provider) do
provider
|> Provider.Changeset.delete_provider()
else
:cant_delete_the_last_provider
end
end
)
|> case do
{:ok, provider} ->
Adapters.ensure_deprovisioned(provider)
{:error, reason} ->
{:error, reason}
end
|> Repo.fetch_and_update(with: callback)
end
end
@@ -256,28 +290,44 @@ defmodule Domain.Auth do
# Identities
def fetch_identity_by_id(id, %Subject{} = subject) do
with :ok <- ensure_has_permissions(subject, Authorizer.manage_identities_permission()) do
Identity.Query.by_id(id)
|> Authorizer.for_subject(Identity, subject)
|> Repo.fetch()
# used during magic link auth flow
def fetch_active_identity_by_provider_and_identifier(
%Provider{adapter: :email} = provider,
provider_identifier,
opts \\ []
) do
{preload, _opts} = Keyword.pop(opts, :preload, [])
Identity.Query.not_disabled()
|> Identity.Query.by_provider_id(provider.id)
|> Identity.Query.by_account_id(provider.account_id)
|> Identity.Query.by_provider_identifier(provider_identifier)
|> Repo.fetch()
|> case do
{:ok, identity} ->
{:ok, Repo.preload(identity, preload)}
{:error, reason} ->
{:error, reason}
end
end
def fetch_active_identity_by_id(id) do
defp fetch_active_identity_by_id(id) do
Identity.Query.by_id(id)
|> Identity.Query.not_disabled()
|> Repo.fetch()
end
def fetch_identity_by_id(id) do
Identity.Query.by_id(id)
|> Repo.fetch()
end
def fetch_identity_by_id!(id) do
Identity.Query.by_id(id)
|> Repo.fetch!()
def fetch_identity_by_id(id, %Subject{} = subject) do
with :ok <- ensure_has_permissions(subject, Authorizer.manage_identities_permission()),
true <- Validator.valid_uuid?(id) do
Identity.Query.by_id(id)
|> Authorizer.for_subject(Identity, subject)
|> Repo.fetch()
else
false -> {:error, :not_found}
other -> other
end
end
def fetch_identities_count_grouped_by_provider_id(%Subject{} = subject) do
@@ -300,6 +350,7 @@ defmodule Domain.Auth do
Identity.Sync.sync_provider_identities_multi(provider, attrs_list)
end
# used by IdP adapters
def upsert_identity(%Actors.Actor{} = actor, %Provider{} = provider, attrs) do
Identity.Changeset.create_identity(actor, provider, attrs)
|> Adapters.identity_changeset(provider)
@@ -307,14 +358,7 @@ defmodule Domain.Auth do
conflict_target:
{:unsafe_fragment,
~s/(account_id, provider_id, provider_identifier) WHERE deleted_at IS NULL/},
on_conflict:
{:replace,
[
:provider_state,
:last_seen_user_agent,
:last_seen_remote_ip,
:last_seen_at
]},
on_conflict: {:replace, [:provider_state]},
returning: true
)
end
@@ -335,7 +379,12 @@ defmodule Domain.Auth do
end
end
def create_identity(%Actors.Actor{} = actor, %Provider{} = provider, attrs) do
# used during sign up flow
def create_identity(
%Actors.Actor{account_id: account_id} = actor,
%Provider{account_id: account_id} = provider,
attrs
) do
Identity.Changeset.create_identity(actor, provider, attrs)
|> Adapters.identity_changeset(provider)
|> Repo.insert()
@@ -395,12 +444,16 @@ defmodule Domain.Auth do
end
end
def delete_actor_identities(%Actors.Actor{} = actor) do
{_count, nil} =
Identity.Query.by_actor_id(actor.id)
|> Repo.update_all(set: [deleted_at: DateTime.utc_now(), provider_state: %{}])
def delete_actor_identities(%Actors.Actor{} = actor, %Subject{} = subject) do
with :ok <- ensure_has_permissions(subject, Authorizer.manage_identities_permission()) do
{_count, nil} =
Identity.Query.by_actor_id(actor.id)
|> Identity.Query.by_account_id(actor.account_id)
|> Authorizer.for_subject(Identity, subject)
|> Repo.update_all(set: [deleted_at: DateTime.utc_now(), provider_state: %{}])
:ok
:ok
end
end
def identity_disabled?(%{disabled_at: nil}), do: false
@@ -411,69 +464,206 @@ defmodule Domain.Auth do
# Sign Up / In / Off
def sign_in(%Provider{} = provider, id_or_provider_identifier, secret, user_agent, remote_ip) do
@doc """
Sign In is an exchange of a secret (IdP token or username/password) for a token tied to it's original context.
"""
def sign_in(
%Provider{disabled_at: disabled_at},
_id_or_provider_identifier,
_token_nonce,
_secret,
%Context{}
)
when not is_nil(disabled_at) do
{:error, :unauthorized}
end
def sign_in(
%Provider{deleted_at: deleted_at},
_id_or_provider_identifier,
_token_nonce,
_secret,
%Context{}
)
when not is_nil(deleted_at) do
{:error, :unauthorized}
end
def sign_in(
%Provider{} = provider,
id_or_provider_identifier,
token_nonce,
secret,
%Context{} = context
) do
identity_queryable =
Identity.Query.not_disabled()
|> Identity.Query.by_account_id(provider.account_id)
|> Identity.Query.by_provider_id(provider.id)
|> Identity.Query.by_id_or_provider_identifier(id_or_provider_identifier)
with {:ok, identity} <- Repo.fetch(identity_queryable),
{:ok, identity, expires_at} <- Adapters.verify_secret(provider, identity, secret) do
context = %Context{remote_ip: remote_ip, user_agent: user_agent}
{:ok, build_subject(identity, expires_at, context)}
{:ok, identity, expires_at} <- Adapters.verify_secret(provider, identity, secret),
identity = Repo.preload(identity, :actor),
{:ok, token} <- create_token(identity, context, token_nonce, expires_at) do
{:ok, identity, Tokens.encode_fragment!(token)}
else
{:error, :not_found} -> {:error, :unauthorized}
{:error, :invalid_secret} -> {:error, :unauthorized}
{:error, :expired_secret} -> {:error, :unauthorized}
{:error, %Ecto.Changeset{}} -> {:error, :malformed_request}
end
end
def sign_in(%Provider{} = provider, payload, user_agent, remote_ip) do
with {:ok, identity, expires_at} <- Adapters.verify_and_update_identity(provider, payload) do
context = %Context{remote_ip: remote_ip, user_agent: user_agent}
{:ok, build_subject(identity, expires_at, context)}
def sign_in(%Provider{disabled_at: disabled_at}, _token_nonce, _payload, %Context{})
when not is_nil(disabled_at) do
{:error, :unauthorized}
end
def sign_in(%Provider{deleted_at: deleted_at}, _token_nonce, _payload, %Context{})
when not is_nil(deleted_at) do
{:error, :unauthorized}
end
def sign_in(%Provider{} = provider, token_nonce, payload, %Context{} = context) do
with {:ok, identity, expires_at} <- Adapters.verify_and_update_identity(provider, payload),
identity = Repo.preload(identity, :actor),
{:ok, token} <- create_token(identity, context, token_nonce, expires_at) do
{:ok, identity, Tokens.encode_fragment!(token)}
else
{:error, :not_found} -> {:error, :unauthorized}
{:error, :invalid} -> {:error, :unauthorized}
{:error, :expired} -> {:error, :unauthorized}
{:error, %Ecto.Changeset{}} -> {:error, :malformed_request}
end
end
def sign_in(token, %Context{} = context) when is_binary(token) do
with {:ok, identity, expires_at} <- verify_token(token, context.user_agent, context.remote_ip) do
{:ok, build_subject(identity, expires_at, context)}
else
{:error, :not_found} -> {:error, :unauthorized}
{:error, :invalid_token} -> {:error, :unauthorized}
{:error, :expired_token} -> {:error, :unauthorized}
{:error, :unauthorized_browser} -> {:error, :unauthorized}
end
end
def fetch_identity_by_provider_and_identifier(
%Provider{} = provider,
provider_identifier,
opts \\ []
) do
{preload, _opts} = Keyword.pop(opts, :preload, [])
Identity.Query.by_provider_id(provider.id)
|> Identity.Query.by_provider_identifier(provider_identifier)
|> Repo.fetch()
|> case do
{:ok, identity} ->
{:ok, Repo.preload(identity, preload)}
{:error, reason} ->
{:error, reason}
end
end
# used in tests and seeds
@doc false
def build_subject(%Identity{} = identity, expires_at, context) do
def create_token(identity, %{type: type} = context, nonce, expires_at)
when type in [:browser, :client] do
identity = Repo.preload(identity, :actor)
expires_at = token_expires_at(identity.actor, context, expires_at)
Tokens.create_token(%{
type: type,
secret_nonce: nonce,
secret_fragment: Domain.Crypto.random_token(32),
account_id: identity.account_id,
identity_id: identity.id,
expires_at: expires_at,
created_by_user_agent: context.user_agent,
created_by_remote_ip: context.remote_ip
})
end
# default expiration is used when IdP/adapter doesn't set the expiration date
defp token_expires_at(%Actors.Actor{} = actor, %Context{} = context, nil) do
default_session_duration_hours =
@default_session_duration_hours
|> Keyword.fetch!(context.type)
|> Keyword.fetch!(actor.type)
DateTime.utc_now() |> DateTime.add(default_session_duration_hours, :hour)
end
# For client tokens we extend the expiration to the default one
# for the sake of user experience, because:
#
# - some of the IdPs don't allow to refresh the token without user interaction;
# - some of the IdPs have short-lived hardcoded tokens
#
defp token_expires_at(%Actors.Actor{} = actor, %Context{type: :client}, _expires_at) do
default_session_duration_hours =
@default_session_duration_hours
|> Keyword.fetch!(:client)
|> Keyword.fetch!(actor.type)
DateTime.utc_now() |> DateTime.add(default_session_duration_hours, :hour)
end
# when IdP sets the expiration we ensure it's not longer than the default session duration
# to prevent extremely long-lived browser sessions
defp token_expires_at(%Actors.Actor{} = actor, %Context{type: :browser}, expires_at) do
max_session_duration_hours =
@max_session_duration_hours
|> Keyword.fetch!(:browser)
|> Keyword.fetch!(actor.type)
max_expires_at = DateTime.utc_now() |> DateTime.add(max_session_duration_hours, :hour)
Enum.min([expires_at, max_expires_at], DateTime)
end
@doc """
Revokes the Firezone token used by the given subject and,
if IdP was used for Sign In, revokes the IdP token too by redirecting user to IdP logout endpoint.
"""
def sign_out(%Subject{} = subject, redirect_url) do
{:ok, _count} = Tokens.delete_token_by_id(subject.token_id)
identity = Repo.preload(subject.identity, :provider)
Adapters.sign_out(identity.provider, identity, redirect_url)
end
# Tokens
def create_service_account_token(
%Provider{adapter: :token} = provider,
%Identity{} = identity,
%Subject{} = subject
) do
{:ok, expires_at, 0} = DateTime.from_iso8601(identity.provider_state["expires_at"])
{:ok, token} =
Tokens.create_token(
%{
type: :client,
secret_fragment: Domain.Crypto.random_token(32),
account_id: provider.account_id,
identity_id: identity.id,
expires_at: expires_at,
created_by_user_agent: subject.context.user_agent,
created_by_remote_ip: subject.context.remote_ip
},
subject
)
{:ok, Tokens.encode_fragment!(token)}
end
# Authentication
def authenticate(encoded_token, %Context{} = context)
when is_binary(encoded_token) do
with {:ok, token} <- Tokens.use_token(encoded_token, context),
:ok <- maybe_enforce_token_context(token, context),
{:ok, identity} <- fetch_active_identity_by_id(token.identity_id) do
{:ok, build_subject(token, identity, context)}
else
{:error, :invalid_or_expired_token} -> {:error, :unauthorized}
{:error, :invalid_remote_ip} -> {:error, :unauthorized}
{:error, :invalid_user_agent} -> {:error, :unauthorized}
{:error, :not_found} -> {:error, :unauthorized}
end
end
defp maybe_enforce_token_context(%Tokens.Token{} = token, %Context{type: :browser} = context) do
cond do
token.created_by_remote_ip.address != context.remote_ip -> {:error, :invalid_remote_ip}
token.created_by_user_agent != context.user_agent -> {:error, :invalid_user_agent}
true -> :ok
end
end
defp maybe_enforce_token_context(%Tokens.Token{}, %Context{}) do
:ok
end
# used in tests and seeds
@doc false
def build_subject(%Tokens.Token{} = token, %Identity{} = identity, %Context{} = context) do
identity =
identity
|> Identity.Changeset.sign_in_identity(context)
|> Identity.Changeset.track_identity(context)
|> Repo.update!()
identity_with_preloads = Repo.preload(identity, [:account, :actor])
@@ -484,165 +674,12 @@ defmodule Domain.Auth do
actor: identity_with_preloads.actor,
permissions: permissions,
account: identity_with_preloads.account,
expires_at: build_subject_expires_at(identity_with_preloads.actor, expires_at),
context: context
expires_at: token.expires_at,
context: context,
token_id: token.id
}
end
defp build_subject_expires_at(%Actors.Actor{} = actor, expires_at) do
now = DateTime.utc_now()
default_session_duration_hours = Map.fetch!(@default_session_duration_hours, actor.type)
expires_at = expires_at || DateTime.add(now, default_session_duration_hours, :hour)
max_session_duration_hours = Map.fetch!(@max_session_duration_hours, actor.type)
max_expires_at = DateTime.add(now, max_session_duration_hours, :hour)
Enum.min([expires_at, max_expires_at], DateTime)
end
def sign_out(%Identity{} = identity, redirect_url) do
identity = Repo.preload(identity, :provider)
Adapters.sign_out(identity.provider, identity, redirect_url)
end
# Session
@doc """
This token is used to authenticate the user in the Portal UI and should be saved in user session.
"""
def create_session_token_from_subject(%Subject{} = subject) do
# TODO: we want to show all sessions in a UI so persist them to DB
payload = session_token_payload(subject)
sign_token(payload, subject.expires_at)
end
@doc """
This token is used to authenticate the client and should be used in the Client WebSocket API.
"""
def create_client_token_from_subject(%Subject{} = subject) do
# TODO: we want to show all sessions in a UI so persist them to DB
payload = client_token_payload(subject)
sign_token(payload, subject.expires_at)
end
@doc """
This token is used to authenticate the service account and should be used for REST API requests.
"""
def create_access_token_for_identity(%Identity{} = identity) do
payload = access_token_payload(identity)
{:ok, expires_at, 0} = DateTime.from_iso8601(identity.provider_state["expires_at"])
sign_token(payload, expires_at)
end
defp sign_token(payload, expires_at) do
config = fetch_config!()
key_base = Keyword.fetch!(config, :key_base)
salt = Keyword.fetch!(config, :salt)
max_age = DateTime.diff(expires_at, DateTime.utc_now(), :second)
{:ok, Plug.Crypto.sign(key_base, salt, payload, max_age: max_age)}
end
def fetch_session_token_expires_at(token, opts \\ []) do
config = fetch_config!()
key_base = Keyword.fetch!(config, :key_base)
salt = Keyword.fetch!(config, :salt)
iterations = Keyword.get(opts, :key_iterations, 1000)
length = Keyword.get(opts, :key_length, 32)
digest = Keyword.get(opts, :key_digest, :sha256)
cache = Keyword.get(opts, :cache, Plug.Crypto.Keys)
secret = Plug.Crypto.KeyGenerator.generate(key_base, salt, iterations, length, digest, cache)
with {:ok, message} <- Plug.Crypto.MessageVerifier.verify(token, secret) do
{_data, signed, max_age} = Plug.Crypto.non_executable_binary_to_term(message)
{:ok, datetime} = DateTime.from_unix(signed + trunc(max_age * 1000), :millisecond)
{:ok, datetime}
else
:error -> {:error, :invalid_token}
end
end
defp session_context_payload(remote_ip, user_agent)
when is_tuple(remote_ip) and is_binary(user_agent) do
:crypto.hash(:sha256, :erlang.term_to_binary({remote_ip, user_agent}))
end
defp verify_token(token, user_agent, remote_ip) do
config = fetch_config!()
key_base = Keyword.fetch!(config, :key_base)
salt = Keyword.fetch!(config, :salt)
case Plug.Crypto.verify(key_base, salt, token) do
{:ok, payload} -> verify_token_payload(token, payload, user_agent, remote_ip)
{:error, :invalid} -> {:error, :invalid_token}
{:error, :expired} -> {:error, :expired_token}
end
end
defp verify_token_payload(
_token,
{:identity, identity_id, secret, :ignore},
_user_agent,
_remote_ip
) do
with {:ok, identity} <- fetch_active_identity_by_id(identity_id),
{:ok, provider} <- fetch_active_provider_by_id(identity.provider_id),
{:ok, identity, expires_at} <-
Adapters.verify_secret(provider, identity, secret) do
{:ok, identity, expires_at}
else
{:error, :invalid_secret} -> {:error, :invalid_token}
{:error, :expired_secret} -> {:error, :expired_token}
{:error, :not_found} -> {:error, :not_found}
end
end
defp verify_token_payload(
token,
{:identity, identity_id, :ignore},
_user_agent,
_remote_ip
) do
with {:ok, identity} <- fetch_active_identity_by_id(identity_id),
{:ok, expires_at} <- fetch_session_token_expires_at(token) do
{:ok, identity, expires_at}
end
end
defp verify_token_payload(
token,
{:identity, identity_id, _context_payload},
_user_agent,
_remote_ip
) do
with {:ok, identity} <- fetch_active_identity_by_id(identity_id),
# XXX: Don't pin tokens to remote_ip and user_agent -- use device external_id instead?
# true <- context_payload == session_context_payload(remote_ip, user_agent),
{:ok, expires_at} <- fetch_session_token_expires_at(token) do
{:ok, identity, expires_at}
else
false -> {:error, :unauthorized_browser}
other -> other
end
end
defp session_token_payload(%Subject{identity: %Identity{} = identity, context: context}) do
{:identity, identity.id, session_context_payload(context.remote_ip, context.user_agent)}
end
defp client_token_payload(%Subject{identity: %Identity{} = identity}) do
{:identity, identity.id, :ignore}
end
defp access_token_payload(%Identity{} = identity) do
{:identity, identity.id, identity.provider_virtual_state.changes.secret, :ignore}
end
defp fetch_config! do
Config.fetch_env!(:domain, __MODULE__)
end
# Permissions
def has_permission?(
@@ -658,10 +695,6 @@ defmodule Domain.Auth do
end)
end
def has_permissions?(%Subject{} = subject, required_permissions) do
ensure_has_permissions(subject, required_permissions) == :ok
end
def fetch_type_permissions!(%Role{} = type),
do: type.permissions
@@ -682,15 +715,33 @@ defmodule Domain.Auth do
end
def ensure_has_permissions(%Subject{} = subject, required_permissions) do
required_permissions
|> List.wrap()
|> Enum.reject(fn required_permission ->
has_permission?(subject, required_permission)
end)
|> Enum.uniq()
|> case do
[] -> :ok
missing_permissions -> {:error, {:unauthorized, missing_permissions: missing_permissions}}
with :ok <- ensure_permissions_are_not_expired(subject) do
required_permissions
|> List.wrap()
|> Enum.reject(fn required_permission ->
has_permission?(subject, required_permission)
end)
|> Enum.uniq()
|> case do
[] ->
:ok
missing_permissions ->
{:error,
{:unauthorized, reason: :missing_permissions, missing_permissions: missing_permissions}}
end
end
end
defp ensure_permissions_are_not_expired(%Subject{expires_at: nil}) do
:ok
end
defp ensure_permissions_are_not_expired(%Subject{expires_at: expires_at}) do
if DateTime.after?(expires_at, DateTime.utc_now()) do
:ok
else
{:error, {:unauthorized, reason: :subject_expired}}
end
end

View File

@@ -7,6 +7,10 @@ defmodule Domain.Auth.Adapter do
The `:custom` is a special key which means that the IdP adapter implements
its own provisioning logic (eg. API integration), so it should be rendered
in the UI on pre-provider basis.
Setting it to `:custom` will also allow running recurrent jobs for the provider,
for more details see `Domain.Auth.list_providers_pending_token_refresh_by_adapter/1`
and `Domain.Auth.list_providers_pending_sync_by_adapter/1`.
"""
@type provisioner :: :manual | :just_in_time | :custom

View File

@@ -78,7 +78,7 @@ defmodule Domain.Auth.Adapters.Email do
{
%{
"sign_in_token_salt" => salt,
"sign_in_token_hash" => Domain.Crypto.hash(sign_in_token <> salt),
"sign_in_token_hash" => Domain.Crypto.hash(:argon2, sign_in_token <> salt),
"sign_in_token_created_at" => DateTime.utc_now()
},
%{
@@ -127,7 +127,7 @@ defmodule Domain.Auth.Adapters.Email do
sign_in_token_expired?(sign_in_token_created_at) ->
:expired_secret
not Domain.Crypto.equal?(token <> sign_in_token_salt, sign_in_token_hash) ->
not Domain.Crypto.equal?(:argon2, token <> sign_in_token_salt, sign_in_token_hash) ->
track_failed_attempt!(identity)
true ->

View File

@@ -39,7 +39,7 @@ defmodule Domain.Auth.Adapters.Token do
defp put_hash_and_expiration(changeset) do
secret = Domain.Crypto.random_token(32)
secret_hash = Domain.Crypto.hash(secret)
secret_hash = Domain.Crypto.hash(:argon2, secret)
data = Map.get(changeset.data, :provider_virtual_state) || %{}
attrs = Ecto.Changeset.get_change(changeset, :provider_virtual_state) || %{}
@@ -112,7 +112,7 @@ defmodule Domain.Auth.Adapters.Token do
sign_in_token_expired?(secret_expires_at) ->
:expired_secret
not Domain.Crypto.equal?(secret, secret_hash) ->
not Domain.Crypto.equal?(:argon2, secret, secret_hash) ->
:invalid_secret
true ->

View File

@@ -103,7 +103,7 @@ defmodule Domain.Auth.Adapters.UserPass do
is_nil(password_hash) ->
:invalid_secret
not Domain.Crypto.equal?(password, password_hash) ->
not Domain.Crypto.equal?(:argon2, password, password_hash) ->
:invalid_secret
true ->

View File

@@ -23,7 +23,7 @@ defmodule Domain.Auth.Adapters.UserPass.Password.Changeset do
# |> validate_no_repetitive_characters(:password)
# |> validate_no_sequential_characters(:password)
# |> validate_no_public_context(:password)
|> put_hash(:password, to: :password_hash)
|> put_hash(:password, :argon2, to: :password_hash)
|> validate_required([:password_hash])
end
end

View File

@@ -6,6 +6,7 @@ defmodule Domain.Auth.Context do
the client and IP address used to perform the action.
"""
@type t :: %__MODULE__{
type: :browser | :client | :relay | :gateway | :api_client,
remote_ip: :inet.ip_address(),
remote_ip_location_region: String.t(),
remote_ip_location_city: String.t(),
@@ -14,7 +15,9 @@ defmodule Domain.Auth.Context do
user_agent: String.t()
}
defstruct remote_ip: nil,
@enforce_keys [:type, :remote_ip, :user_agent]
defstruct type: nil,
remote_ip: nil,
remote_ip_location_region: nil,
remote_ip_location_city: nil,
remote_ip_location_lat: nil,

View File

@@ -74,7 +74,7 @@ defmodule Domain.Auth.Identity.Changeset do
|> put_change(:provider_virtual_state, virtual_state)
end
def sign_in_identity(identity_or_changeset, context) do
def track_identity(identity_or_changeset, context) do
identity_or_changeset
|> change()
|> put_change(:last_seen_user_agent, context.user_agent)

View File

@@ -10,6 +10,16 @@ defmodule Domain.Auth.Identity.Query do
|> where([identities: identities], is_nil(identities.deleted_at))
end
def not_disabled(queryable \\ not_deleted()) do
queryable
|> with_assoc(:inner, :actor)
|> where([actor: actor], is_nil(actor.deleted_at))
|> where([actor: actor], is_nil(actor.disabled_at))
|> with_assoc(:inner, :provider)
|> where([provider: provider], is_nil(provider.deleted_at))
|> where([provider: provider], is_nil(provider.disabled_at))
end
def by_id(queryable \\ not_deleted(), id)
def by_id(queryable, {:not, id}) do
@@ -31,8 +41,6 @@ defmodule Domain.Auth.Identity.Query do
def by_provider_id(queryable \\ not_deleted(), provider_id) do
queryable
|> where([identities: identities], identities.provider_id == ^provider_id)
|> with_assoc(:inner, :provider)
|> where([provider: provider], is_nil(provider.disabled_at) and is_nil(provider.deleted_at))
end
def by_adapter(queryable \\ not_deleted(), adapter) do
@@ -70,13 +78,6 @@ defmodule Domain.Auth.Identity.Query do
end
end
def not_disabled(queryable \\ not_deleted()) do
queryable
|> join(:inner, [identities: identities], actors in assoc(identities, :actor), as: :actors)
|> where([actors: actors], is_nil(actors.deleted_at))
|> where([actors: actors], is_nil(actors.disabled_at))
end
def lock(queryable \\ not_deleted()) do
lock(queryable, "FOR UPDATE")
end

View File

@@ -10,6 +10,14 @@ defmodule Domain.Auth.Provider.Query do
|> where([provider: provider], is_nil(provider.deleted_at))
end
def not_disabled(queryable \\ not_deleted()) do
where(queryable, [provider: provider], is_nil(provider.disabled_at))
end
def not_exceeded_attempts(queryable \\ not_deleted()) do
where(queryable, [provider: provider], provider.last_syncs_failed <= 10)
end
def by_id(queryable \\ not_deleted(), id)
def by_id(queryable, {:not, id}) do
@@ -75,14 +83,6 @@ defmodule Domain.Auth.Provider.Query do
where(queryable, [provider: provider], provider.account_id == ^account_id)
end
def not_disabled(queryable \\ not_deleted()) do
where(queryable, [provider: provider], is_nil(provider.disabled_at))
end
def not_exceeded_attempts(queryable \\ not_deleted()) do
where(queryable, [provider: provider], provider.last_syncs_failed <= 10)
end
def lock(queryable \\ not_deleted()) do
lock(queryable, "FOR UPDATE")
end

View File

@@ -19,7 +19,8 @@ defmodule Domain.Auth.Roles do
Domain.Policies.Authorizer,
Domain.Relays.Authorizer,
Domain.Resources.Authorizer,
Domain.Flows.Authorizer
Domain.Flows.Authorizer,
Domain.Tokens.Authorizer
]
end

View File

@@ -11,15 +11,17 @@ defmodule Domain.Auth.Subject do
actor: actor(),
permissions: MapSet.t(permission),
account: %Domain.Accounts.Account{},
token_id: Ecto.UUID.t(),
expires_at: DateTime.t(),
context: Context.t()
}
@enforce_keys [:identity, :actor, :permissions, :account, :context, :expires_at]
@enforce_keys [:identity, :actor, :permissions, :account, :token_id, :expires_at, :context]
defstruct identity: nil,
actor: nil,
permissions: MapSet.new(),
account: nil,
token_id: nil,
expires_at: nil,
context: %Context{}
context: nil
end

View File

@@ -204,12 +204,16 @@ defmodule Domain.Clients do
end
end
def delete_actor_clients(%Actors.Actor{} = actor) do
{_count, nil} =
Client.Query.by_actor_id(actor.id)
|> Repo.update_all(set: [deleted_at: DateTime.utc_now()])
def delete_actor_clients(%Actors.Actor{} = actor, %Auth.Subject{} = subject) do
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_clients_permission()) do
{_count, nil} =
Client.Query.by_actor_id(actor.id)
|> Client.Query.by_account_id(actor.account_id)
|> Authorizer.for_subject(subject)
|> Repo.update_all(set: [deleted_at: DateTime.utc_now()])
:ok
:ok
end
end
def authorize_actor_client_management(%Actors.Actor{} = actor, %Auth.Subject{} = subject) do

View File

@@ -80,8 +80,8 @@ defmodule Domain.Config.Definitions do
All secrets should be a **base64-encoded string**.
""",
[
:auth_token_key_base,
:auth_token_salt,
:tokens_key_base,
:tokens_salt,
:relays_auth_token_key_base,
:relays_auth_token_salt,
:gateways_auth_token_key_base,
@@ -195,7 +195,7 @@ defmodule Domain.Config.Definitions do
You can see all supported options at https://ninenines.eu/docs/en/cowboy/2.5/manual/cowboy_http/.
"""
defconfig(:phoenix_http_protocol_options, :map,
default: %{},
default: %{max_header_value_length: 8192},
dump: &Dumper.keyword/1
)
@@ -357,17 +357,17 @@ defmodule Domain.Config.Definitions do
##############################################
@doc """
Secret which is used to encode and sign auth tokens.
Secret which is used to encode and sign tokens.
"""
defconfig(:auth_token_key_base, :string,
defconfig(:tokens_key_base, :string,
sensitive: true,
changeset: &Domain.Validator.validate_base64/2
)
@doc """
Salt which is used to encode and sign auth tokens.
Salt which is used to encode and sign tokens.
"""
defconfig(:auth_token_salt, :string,
defconfig(:tokens_salt, :string,
sensitive: true,
changeset: &Domain.Validator.validate_base64/2
)

View File

@@ -42,6 +42,10 @@ defmodule Domain.Crypto do
Base.encode64(binary)
end
defp encode_random_token(binary, _length, :hex32) do
Base.hex_encode32(binary)
end
defp encode_random_token(binary, length, :user_friendly) do
encode_random_token(binary, length, :url_encode64)
|> String.downcase()
@@ -61,15 +65,34 @@ defmodule Domain.Crypto do
defp replace_ambiguous_characters(<<char::utf8, rest::binary>>, acc),
do: replace_ambiguous_characters(rest, <<acc::binary, char::utf8>>)
def hash(type, value) do
:crypto.hash(type, value)
def hash(:argon2, value) when byte_size(value) > 0 do
Argon2.hash_pwd_salt(value)
end
def hash(algo, value) when byte_size(value) > 0 do
:crypto.hash(algo, value)
|> Base.encode16()
|> String.downcase()
end
def hash(value), do: Argon2.hash_pwd_salt(value)
@doc """
Compares two secret and hash in a constant-time avoiding timing attacks.
"""
def equal?(:argon2, secret, hash) when is_nil(secret) or is_nil(hash),
do: Argon2.no_user_verify()
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)
def equal?(:argon2, secret, hash) when secret == "" or hash == "",
do: Argon2.no_user_verify()
def equal?(:argon2, secret, hash),
do: Argon2.verify_pass(secret, hash)
def equal?(algo, secret, hash) when is_nil(secret) or is_nil(hash),
do: Plug.Crypto.secure_compare(hash(algo, "a"), "b")
def equal?(algo, secret, hash) when secret == "" or hash == "",
do: Plug.Crypto.secure_compare(hash(algo, "a"), "b")
def equal?(algo, secret, hash),
do: Plug.Crypto.secure_compare(hash(algo, secret), hash)
end

View File

@@ -170,7 +170,7 @@ defmodule Domain.Gateways do
Token.Query.by_id(id)
|> Repo.fetch_and_update(
with: fn token ->
if Domain.Crypto.equal?(secret, token.hash) do
if Domain.Crypto.equal?(:argon2, secret, token.hash) do
Token.Changeset.use(token)
else
:not_found

View File

@@ -10,7 +10,8 @@ defmodule Domain.Gateways.Gateway.Changeset do
last_seen_remote_ip_location_city
last_seen_remote_ip_location_lat
last_seen_remote_ip_location_lon]a
@conflict_replace_fields ~w[public_key
@conflict_replace_fields ~w[name
public_key
last_seen_user_agent
last_seen_remote_ip
last_seen_remote_ip_location_region

View File

@@ -9,7 +9,7 @@ defmodule Domain.Gateways.Token.Changeset do
|> change()
|> put_change(:account_id, account.id)
|> put_change(:value, Domain.Crypto.random_token(64))
|> put_hash(:value, to: :hash)
|> put_hash(:value, :argon2, to: :hash)
|> assoc_constraint(:group)
|> check_constraint(:hash, name: :hash_not_null, message: "can't be blank")
|> put_change(:created_by, :identity)

View File

@@ -6,7 +6,7 @@ defmodule Domain.Jobs do
use Supervisor
def start_link(module) do
Supervisor.start_link(__MODULE__, module, name: __MODULE__)
Supervisor.start_link(__MODULE__, module)
end
def init(module) do

View File

@@ -170,7 +170,7 @@ defmodule Domain.Relays do
Token.Query.by_id(id)
|> Repo.fetch_and_update(
with: fn token ->
if Domain.Crypto.equal?(secret, token.hash) do
if Domain.Crypto.equal?(:argon2, secret, token.hash) do
Token.Changeset.use(token)
else
:not_found

View File

@@ -8,7 +8,7 @@ defmodule Domain.Relays.Token.Changeset do
%Relays.Token{}
|> change()
|> put_change(:value, Domain.Crypto.random_token(64))
|> put_hash(:value, to: :hash)
|> put_hash(:value, :argon2, to: :hash)
|> assoc_constraint(:group)
|> check_constraint(:hash, name: :hash_not_null, message: "can't be blank")
|> put_change(:created_by, :system)

View File

@@ -0,0 +1,215 @@
defmodule Domain.Tokens do
use Supervisor
alias Domain.{Repo, Validator}
alias Domain.{Auth, Actors}
alias Domain.Tokens.{Token, Authorizer, Jobs}
require Ecto.Query
def start_link(_init_arg) do
Supervisor.start_link(__MODULE__, nil, name: __MODULE__)
end
@impl true
def init(_init_arg) do
children = [
{Domain.Jobs, Jobs}
]
Supervisor.init(children, strategy: :one_for_one)
end
def fetch_token_by_id(id, %Auth.Subject{} = subject) do
required_permissions =
{:one_of,
[
Authorizer.manage_tokens_permission(),
Authorizer.manage_own_tokens_permission()
]}
with :ok <- Auth.ensure_has_permissions(subject, required_permissions),
true <- Validator.valid_uuid?(id) do
Token.Query.by_id(id)
|> Authorizer.for_subject(subject)
|> Repo.fetch()
else
false -> {:error, :not_found}
other -> other
end
end
def list_tokens_for(%Auth.Subject{} = subject) do
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_own_tokens_permission()) do
Token.Query.by_actor_id(subject.actor.id)
|> list_tokens(subject, [])
end
end
def list_tokens_for(%Actors.Actor{} = actor, %Auth.Subject{} = subject, opts \\ []) do
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_tokens_permission()) do
Token.Query.by_actor_id(actor.id)
|> list_tokens(subject, opts)
end
end
defp list_tokens(queryable, subject, opts) do
{preload, _opts} = Keyword.pop(opts, :preload, [])
{:ok, tokens} =
queryable
|> Authorizer.for_subject(subject)
|> Ecto.Query.order_by([tokens: tokens], desc: tokens.inserted_at, desc: tokens.id)
|> Repo.list()
{:ok, Repo.preload(tokens, preload)}
end
def create_token(attrs) do
Token.Changeset.create(attrs)
|> Repo.insert()
end
def create_token(attrs, %Auth.Subject{} = subject) do
Token.Changeset.create(attrs, subject)
|> Repo.insert()
end
@doc """
Update allows to extend the token expiration, which is useful for situations where we can use
IdP API to refresh the ID token and don't want users to go through redirects every hour
(hardcoded token duration for Okta and Google Workspace).
"""
def update_token(%Token{} = token, attrs) do
Token.Query.by_id(token.id)
|> Token.Query.not_expired()
|> Repo.fetch_and_update(with: &Token.Changeset.update(&1, attrs))
end
@doc """
Token `secret` is used to verify that token can be used only by one source and that it's
impossible to impersonate a session by knowing what's inside our database.
It then additionally signed and encoded using `Plug.Crypto.sign/3` to make sure that
you can't hit our database with requests using a random token id and secret.
"""
def encode_fragment!(%Token{secret_fragment: fragment, type: type} = token)
when not is_nil(fragment) do
body = {token.account_id, token.id, fragment}
config = fetch_config!()
key_base = Keyword.fetch!(config, :key_base)
salt = Keyword.fetch!(config, :salt)
"." <> Plug.Crypto.sign(key_base, salt <> to_string(type), body)
end
def use_token(encoded_token, %Auth.Context{} = context) do
with {:ok, {account_id, id, nonce, secret}} <- peek_token(encoded_token, context),
queryable =
Token.Query.by_id(id)
|> Token.Query.by_account_id(account_id)
|> Token.Query.by_type(context.type)
|> Token.Query.not_expired(),
{:ok, token} <- Repo.fetch(queryable),
true <-
Domain.Crypto.equal?(
:sha3_256,
nonce <> secret <> token.secret_salt,
token.secret_hash
) do
Token.Changeset.use(token, context)
|> Repo.update()
else
{:error, :invalid} -> {:error, :invalid_or_expired_token}
{:ok, _token_payload} -> {:error, :invalid_or_expired_token}
{:error, :not_found} -> {:error, :invalid_or_expired_token}
false -> {:error, :invalid_or_expired_token}
_other -> {:error, :invalid_or_expired_token}
end
end
def peek_token(encoded_token, %Auth.Context{} = context) do
with [nonce, encoded_fragment] <- String.split(encoded_token, ".", parts: 2),
{:ok, {account_id, id, secret}} <- verify_token(encoded_fragment, context) do
{:ok, {account_id, id, nonce, secret}}
end
end
defp verify_token(encrypted_token, %Auth.Context{} = context) do
config = fetch_config!()
key_base = Keyword.fetch!(config, :key_base)
shared_salt = Keyword.fetch!(config, :salt)
salt = shared_salt <> to_string(context.type)
Plug.Crypto.verify(key_base, salt, encrypted_token, max_age: :infinity)
end
def delete_token(%Token{} = token, %Auth.Subject{} = subject) do
required_permissions =
{:one_of,
[
Authorizer.manage_tokens_permission(),
Authorizer.manage_own_tokens_permission()
]}
with :ok <- Auth.ensure_has_permissions(subject, required_permissions) do
Token.Query.by_id(token.id)
|> Authorizer.for_subject(subject)
|> Repo.fetch_and_update(with: &Token.Changeset.delete/1)
|> case do
{:ok, token} ->
Phoenix.PubSub.broadcast(Domain.PubSub, "sessions:#{token.id}", "disconnect")
{:ok, token}
{:error, reason} ->
{:error, reason}
end
end
end
def delete_tokens_for(%Auth.Subject{} = subject) do
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_own_tokens_permission()) do
Token.Query.by_actor_id(subject.actor.id)
|> Authorizer.for_subject(subject)
|> delete_tokens()
end
end
def delete_tokens_for(%Actors.Actor{} = actor, %Auth.Subject{} = subject) do
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_tokens_permission()) do
Token.Query.by_actor_id(actor.id)
|> Authorizer.for_subject(subject)
|> delete_tokens()
end
end
def delete_expired_tokens do
Token.Query.expired()
|> delete_tokens()
end
defp delete_tokens(queryable) do
{count, ids} =
queryable
|> Ecto.Query.select([tokens: tokens], tokens.id)
|> Repo.update_all(set: [deleted_at: DateTime.utc_now()])
:ok =
Enum.each(ids, fn id ->
# TODO: use Domain.PubSub once it's in the codebase
Phoenix.PubSub.broadcast(Domain.PubSub, "sessions:#{id}", "disconnect")
end)
{:ok, count}
end
def delete_token_by_id(token_id) do
if Validator.valid_uuid?(token_id) do
Token.Query.by_id(token_id)
|> delete_tokens()
else
{:ok, 0}
end
end
defp fetch_config! do
Domain.Config.fetch_env!(:domain, __MODULE__)
end
end

View File

@@ -0,0 +1,38 @@
defmodule Domain.Tokens.Authorizer do
use Domain.Auth.Authorizer
alias Domain.Tokens.Token
def manage_own_tokens_permission, do: build(Token, :manage_own)
def manage_tokens_permission, do: build(Token, :manage)
@impl Domain.Auth.Authorizer
def list_permissions_for_role(:account_admin_user) do
[
manage_tokens_permission(),
manage_own_tokens_permission()
]
end
def list_permissions_for_role(:account_user) do
[
manage_own_tokens_permission()
]
end
def list_permissions_for_role(_) do
[]
end
@impl true
def for_subject(queryable, %Subject{} = subject) do
cond do
has_permission?(subject, manage_tokens_permission()) ->
Token.Query.by_account_id(queryable, subject.account.id)
has_permission?(subject, manage_own_tokens_permission()) ->
queryable
|> Token.Query.by_account_id(subject.account.id)
|> Token.Query.by_actor_id(subject.actor.id)
end
end
end

View File

@@ -0,0 +1,10 @@
defmodule Domain.Tokens.Jobs do
use Domain.Jobs.Recurrent, otp_app: :domain
alias Domain.Tokens
require Logger
every minutes(5), :delete_expired_tokens do
{:ok, _count} = Tokens.delete_expired_tokens()
:ok
end
end

View File

@@ -0,0 +1,38 @@
# TODO: service accounts auth as clients and as API clients?
defmodule Domain.Tokens.Token do
use Domain, :schema
schema "tokens" do
field :type, Ecto.Enum, values: [:browser, :client, :relay, :gateway, :email, :api_client]
belongs_to :identity, Domain.Auth.Identity
# belongs_to :relay_group, Domain.Relays.Group
# belongs_to :gateway_group, Domain.Relays.Group
# we store just hash(nonce+fragment+salt)
field :secret_nonce, :string, virtual: true, redact: true
field :secret_fragment, :string, virtual: true, redact: true
field :secret_salt, :string, redact: true
field :secret_hash, :string, redact: true
belongs_to :account, Domain.Accounts.Account
field :last_seen_user_agent, :string
field :last_seen_remote_ip, Domain.Types.IP
field :last_seen_remote_ip_location_region, :string
field :last_seen_remote_ip_location_city, :string
field :last_seen_remote_ip_location_lat, :float
field :last_seen_remote_ip_location_lon, :float
field :last_seen_at, :utc_datetime_usec
# Maybe this is not needed and they should be in the join tables (eg. relay_group_tokens)
field :created_by, Ecto.Enum, values: ~w[system identity]a
belongs_to :created_by_identity, Domain.Auth.Identity
field :created_by_user_agent, :string
field :created_by_remote_ip, Domain.Types.IP
field :expires_at, :utc_datetime_usec
field :deleted_at, :utc_datetime_usec
timestamps()
end
end

View File

@@ -0,0 +1,97 @@
defmodule Domain.Tokens.Token.Changeset do
use Domain, :changeset
alias Domain.Auth
alias Domain.Tokens.Token
@required_attrs ~w[
type
account_id
secret_fragment
created_by_user_agent created_by_remote_ip
expires_at
]a
@create_attrs ~w[identity_id secret_nonce]a ++ @required_attrs
@update_attrs ~w[expires_at]a
def create(attrs) do
%Token{}
|> cast(attrs, @create_attrs)
|> validate_required(@required_attrs)
|> validate_inclusion(:type, [:email, :browser, :client])
|> changeset()
|> put_change(:created_by, :system)
end
def create(attrs, %Auth.Subject{} = subject) do
%Token{}
|> cast(attrs, @create_attrs)
|> put_change(:account_id, subject.account.id)
|> validate_required(@required_attrs)
|> validate_inclusion(:type, [:client, :relay, :gateway, :api_client])
|> changeset()
|> put_change(:created_by, :identity)
|> put_change(:created_by_identity_id, subject.identity.id)
end
defp changeset(changeset) do
changeset
|> put_change(:secret_salt, Domain.Crypto.random_token(16))
|> validate_format(:secret_nonce, ~r/^[^\.]{0,128}$/)
|> put_hash(:secret_fragment, :sha3_256,
with_nonce: :secret_nonce,
with_salt: :secret_salt,
to: :secret_hash
)
|> delete_change(:secret_nonce)
|> validate_datetime(:expires_at, greater_than: DateTime.utc_now())
|> validate_required(~w[secret_salt secret_hash]a)
|> validate_required_assocs()
|> assoc_constraint(:account)
end
defp validate_required_assocs(changeset) do
case fetch_field(changeset, :context) do
{_data_or_changes, :browser} ->
changeset
|> validate_required(:identity_id)
|> assoc_constraint(:identity)
{_data_or_changes, :client} ->
changeset
|> validate_required(:identity_id)
|> assoc_constraint(:identity)
# TODO: relay, gateway, api_client
_ ->
changeset
end
end
def update(%Token{} = token, attrs) do
token
|> cast(attrs, @update_attrs)
|> validate_required(@update_attrs)
|> validate_datetime(:expires_at, greater_than: DateTime.utc_now())
end
def use(%Token{} = token, %Auth.Context{} = context) do
token
|> change()
|> put_change(:last_seen_user_agent, context.user_agent)
|> put_change(:last_seen_remote_ip, %Postgrex.INET{address: context.remote_ip})
|> put_change(:last_seen_remote_ip_location_region, context.remote_ip_location_region)
|> put_change(:last_seen_remote_ip_location_city, context.remote_ip_location_city)
|> put_change(:last_seen_remote_ip_location_lat, context.remote_ip_location_lat)
|> put_change(:last_seen_remote_ip_location_lon, context.remote_ip_location_lon)
|> put_change(:last_seen_at, DateTime.utc_now())
|> validate_required(~w[last_seen_user_agent last_seen_remote_ip]a)
end
def delete(%Token{} = token) do
token
|> change()
|> put_default_value(:deleted_at, DateTime.utc_now())
end
end

View File

@@ -0,0 +1,50 @@
defmodule Domain.Tokens.Token.Query do
use Domain, :query
def all do
from(tokens in Domain.Tokens.Token, as: :tokens)
end
def not_deleted do
all()
|> where([tokens: tokens], is_nil(tokens.deleted_at))
end
def not_expired(queryable \\ not_deleted()) do
where(queryable, [tokens: tokens], tokens.expires_at > ^DateTime.utc_now())
end
def expired(queryable \\ not_deleted()) do
where(queryable, [tokens: tokens], tokens.expires_at <= ^DateTime.utc_now())
end
def by_id(queryable \\ not_deleted(), id) do
where(queryable, [tokens: tokens], tokens.id == ^id)
end
def by_type(queryable \\ not_deleted(), type) do
where(queryable, [tokens: tokens], tokens.type == ^type)
end
def by_account_id(queryable \\ not_deleted(), account_id) do
where(queryable, [tokens: tokens], tokens.account_id == ^account_id)
end
def by_actor_id(queryable \\ not_deleted(), actor_id) do
queryable
|> with_joined_identity()
|> where([identity: identity], identity.actor_id == ^actor_id)
end
def with_joined_account(queryable \\ not_deleted()) do
with_named_binding(queryable, :account, fn queryable, binding ->
join(queryable, :inner, [tokens: tokens], account in assoc(tokens, ^binding), as: ^binding)
end)
end
def with_joined_identity(queryable \\ not_deleted()) do
with_named_binding(queryable, :identity, fn queryable, binding ->
join(queryable, :inner, [tokens: tokens], identity in assoc(tokens, ^binding), as: ^binding)
end)
end
end

View File

@@ -298,24 +298,48 @@ defmodule Domain.Validator do
end
@doc """
Takes value from `value_field` and puts it's hash to `hash_field`.
Takes value from `value_field` and puts it's hash of a given type 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, Domain.Crypto.hash(value))
def put_hash(%Ecto.Changeset{} = changeset, value_field, type, opts) do
hash_field = Keyword.fetch!(opts, :to)
salt_field = Keyword.get(opts, :with_salt)
nonce_field = Keyword.get(opts, :with_nonce)
with {:ok, value} <- fetch_value(changeset, value_field),
{:ok, nonce} <- fetch_hash_component(changeset, nonce_field),
{:ok, salt} <- fetch_hash_component(changeset, salt_field) do
put_change(changeset, hash_field, Domain.Crypto.hash(type, nonce <> value <> salt))
else
_ -> changeset
end
end
defp fetch_value(changeset, value_field) do
case fetch_change(changeset, value_field) do
{:ok, ""} -> :error
{:ok, value} when is_binary(value) -> {:ok, value}
_other -> :error
end
end
defp fetch_hash_component(_changeset, nil) do
{:ok, ""}
end
defp fetch_hash_component(changeset, salt_field) do
case fetch_change(changeset, salt_field) do
{:ok, salt} when is_binary(salt) -> {:ok, salt}
:error -> {:ok, ""}
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
def validate_hash(changeset, value_field, type, hash_field: hash_field) do
with {:data, hash} <- fetch_field(changeset, hash_field) do
validate_change(changeset, value_field, fn value_field, token ->
if Domain.Crypto.equal?(token, hash) do
if Domain.Crypto.equal?(type, token, hash) do
[]
else
[{value_field, {"is invalid", [validation: :hash]}}]

View File

@@ -0,0 +1,53 @@
defmodule Domain.Repo.Migrations.AddTokens do
use Ecto.Migration
def change do
create table(:tokens, primary_key: false) do
add(:id, :uuid, primary_key: true)
add(:type, :string, null: false)
add(:secret_salt, :string, null: false)
add(:secret_hash, :string, null: false)
add(
:identity_id,
references(:auth_identities, type: :binary_id, on_delete: :delete_all)
# TODO? null: false?
)
add(:created_by, :string, null: false)
add(:created_by_identity_id, references(:auth_identities, type: :binary_id))
add(:created_by_user_agent, :string, null: false)
add(:created_by_remote_ip, :inet, null: false)
add(:last_seen_at, :utc_datetime_usec)
add(:last_seen_remote_ip, :inet)
add(:last_seen_remote_ip_location_region, :text)
add(:last_seen_remote_ip_location_city, :text)
add(:last_seen_remote_ip_location_lat, :float)
add(:last_seen_remote_ip_location_lon, :float)
add(:last_seen_user_agent, :string)
add(:last_seen_version, :string)
add(:account_id, references(:accounts, type: :binary_id, on_delete: :delete_all),
null: false
)
add(:expires_at, :utc_datetime_usec, null: false)
add(:deleted_at, :utc_datetime_usec)
timestamps()
end
create(
constraint(:tokens, :assoc_not_null,
check: """
(type = 'browser' AND identity_id IS NOT NULL)
OR (type = 'client' AND identity_id IS NOT NULL)
OR (type IN ('relay', 'gateway', 'email', 'api_client'))
"""
)
)
create(index(:tokens, [:account_id, :type], where: "deleted_at IS NULL"))
end
end

View File

@@ -185,36 +185,59 @@ other_admin_actor_email = "other@localhost"
}
})
unprivileged_actor_token = unprivileged_actor_email_identity.provider_virtual_state.sign_in_token
admin_actor_token = admin_actor_email_identity.provider_virtual_state.sign_in_token
unprivileged_actor_email_token =
unprivileged_actor_email_identity.provider_virtual_state.sign_in_token
admin_actor_email_token = admin_actor_email_identity.provider_virtual_state.sign_in_token
unprivileged_actor_context = %Auth.Context{
type: :browser,
user_agent: "Debian/11.0.0 connlib/0.1.0",
remote_ip: {172, 28, 0, 100},
remote_ip_location_region: "UA",
remote_ip_location_city: "Kyiv",
remote_ip_location_lat: 50.4333,
remote_ip_location_lon: 30.5167
}
nonce = "n"
{:ok, unprivileged_actor_token} =
Auth.create_token(unprivileged_actor_email_identity, unprivileged_actor_context, nonce, nil)
unprivileged_subject =
Auth.build_subject(
unprivileged_actor_token,
unprivileged_actor_userpass_identity,
DateTime.utc_now() |> DateTime.add(365, :day),
%Auth.Context{
user_agent: "Debian/11.0.0 connlib/0.1.0",
remote_ip: {172, 28, 0, 100},
remote_ip_location_region: "UA",
remote_ip_location_city: "Kyiv",
remote_ip_location_lat: 50.4333,
remote_ip_location_lon: 30.5167
}
unprivileged_actor_context
)
admin_actor_context = %Auth.Context{
type: :browser,
user_agent: "iOS/12.5 (iPhone) connlib/0.7.412",
remote_ip: {100, 64, 100, 58},
remote_ip_location_region: "UA",
remote_ip_location_city: "Kyiv",
remote_ip_location_lat: 50.4333,
remote_ip_location_lon: 30.5167
}
{:ok, admin_actor_token} =
Auth.create_token(admin_actor_email_identity, admin_actor_context, nonce, nil)
admin_subject =
Auth.build_subject(
admin_actor_token,
admin_actor_email_identity,
nil,
%Auth.Context{user_agent: "iOS/12.5 (iPhone) connlib/0.7.412", remote_ip: {100, 64, 100, 58}}
admin_actor_context
)
IO.puts("Created users: ")
for {type, login, password, email_token} <- [
{unprivileged_actor.type, unprivileged_actor_email, "Firezone1234",
unprivileged_actor_token},
{admin_actor.type, admin_actor_email, "Firezone1234", admin_actor_token}
unprivileged_actor_email_token},
{admin_actor.type, admin_actor_email, "Firezone1234", admin_actor_email_token}
] do
IO.puts(" #{login}, #{type}, password: #{password}, email token: #{email_token} (exp in 15m)")
end
@@ -599,10 +622,27 @@ IO.puts("Policies Created")
IO.puts("")
{:ok, unprivileged_subject_client_token} =
Auth.create_client_token_from_subject(unprivileged_subject)
Auth.create_token(
unprivileged_actor_email_identity,
%{unprivileged_actor_context | type: :client},
nonce,
nil
)
unprivileged_subject_client_token =
maybe_repo_update.(unprivileged_subject_client_token,
id: "7da7d1cd-111c-44a7-b5ac-4027b9d230e5",
secret_salt: "kKKA7dtf3TJk0-1O2D9N1w",
secret_fragment: "AiIy_6pBk-WLeRAPzzkCFXNqIZKWBs2Ddw_2vgIQvFg",
secret_hash: "5c1d6795ea1dd08b6f4fd331eeaffc12032ba171d227f328446f2d26b96437e5"
)
IO.puts("Created client tokens:")
IO.puts(" #{unprivileged_actor_email} token: #{unprivileged_subject_client_token}")
IO.puts(
" #{unprivileged_actor_email} token: #{nonce <> Domain.Tokens.encode_fragment!(unprivileged_subject_client_token)}"
)
IO.puts("")
{:ok, _resource, flow} =

View File

@@ -37,7 +37,8 @@ defmodule Domain.AccountsTest do
assert fetch_account_by_id(Ecto.UUID.generate(), subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Accounts.Authorizer.view_accounts_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Accounts.Authorizer.view_accounts_permission()]}}
end
end
@@ -74,7 +75,8 @@ defmodule Domain.AccountsTest do
assert fetch_account_by_id_or_slug(Ecto.UUID.generate(), subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Accounts.Authorizer.view_accounts_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Accounts.Authorizer.view_accounts_permission()]}}
end
end

View File

@@ -70,7 +70,8 @@ defmodule Domain.ActorsTest do
assert fetch_group_by_id(Ecto.UUID.generate(), subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Actors.Authorizer.manage_actors_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Actors.Authorizer.manage_actors_permission()]}}
end
end
@@ -130,7 +131,8 @@ defmodule Domain.ActorsTest do
assert list_groups(subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Actors.Authorizer.manage_actors_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Actors.Authorizer.manage_actors_permission()]}}
end
end
@@ -252,7 +254,8 @@ defmodule Domain.ActorsTest do
assert peek_group_actors([], 3, subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Actors.Authorizer.manage_actors_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Actors.Authorizer.manage_actors_permission()]}}
end
end
@@ -371,7 +374,8 @@ defmodule Domain.ActorsTest do
assert peek_actor_groups([], 3, subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Actors.Authorizer.manage_actors_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Actors.Authorizer.manage_actors_permission()]}}
end
end
@@ -919,7 +923,8 @@ defmodule Domain.ActorsTest do
assert create_group(%{}, subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Actors.Authorizer.manage_actors_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Actors.Authorizer.manage_actors_permission()]}}
end
end
@@ -1037,7 +1042,8 @@ defmodule Domain.ActorsTest do
assert update_group(group, %{}, subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Actors.Authorizer.manage_actors_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Actors.Authorizer.manage_actors_permission()]}}
end
test "raises if group is synced", %{
@@ -1091,7 +1097,8 @@ defmodule Domain.ActorsTest do
assert delete_group(group, subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Actors.Authorizer.manage_actors_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Actors.Authorizer.manage_actors_permission()]}}
end
test "raises if group is synced", %{
@@ -1152,7 +1159,8 @@ defmodule Domain.ActorsTest do
assert fetch_actors_count_by_type(:foo, subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Actors.Authorizer.manage_actors_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Actors.Authorizer.manage_actors_permission()]}}
end
end
@@ -1260,7 +1268,8 @@ defmodule Domain.ActorsTest do
assert fetch_actor_by_id("foo", subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Actors.Authorizer.manage_actors_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Actors.Authorizer.manage_actors_permission()]}}
end
test "associations are preloaded when opts given" do
@@ -1318,6 +1327,7 @@ defmodule Domain.ActorsTest do
identity: nil,
actor: %{id: Ecto.UUID.generate()},
account: %{id: Ecto.UUID.generate()},
token_id: nil,
context: nil,
expires_at: nil,
permissions: MapSet.new()
@@ -1350,7 +1360,8 @@ defmodule Domain.ActorsTest do
assert list_actors(subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Actors.Authorizer.manage_actors_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Actors.Authorizer.manage_actors_permission()]}}
end
test "associations are preloaded when opts given" do
@@ -1451,7 +1462,8 @@ defmodule Domain.ActorsTest do
assert create_actor(account, attrs, subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Actors.Authorizer.manage_actors_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Actors.Authorizer.manage_actors_permission()]}}
end
test "returns error when subject tries to create an account in another account", %{
@@ -1554,7 +1566,8 @@ defmodule Domain.ActorsTest do
assert update_actor(actor, %{type: :foo}, subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Actors.Authorizer.manage_actors_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Actors.Authorizer.manage_actors_permission()]}}
end
test "allows changing not synced memberships", %{account: account, subject: subject} do
@@ -1778,7 +1791,8 @@ defmodule Domain.ActorsTest do
assert disable_actor(actor, subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Actors.Authorizer.manage_actors_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Actors.Authorizer.manage_actors_permission()]}}
end
end
@@ -1837,7 +1851,8 @@ defmodule Domain.ActorsTest do
assert enable_actor(actor, subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Actors.Authorizer.manage_actors_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Actors.Authorizer.manage_actors_permission()]}}
end
end
@@ -2007,7 +2022,8 @@ defmodule Domain.ActorsTest do
assert delete_actor(actor, subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Actors.Authorizer.manage_actors_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Actors.Authorizer.manage_actors_permission()]}}
end
end

View File

@@ -30,7 +30,11 @@ defmodule Domain.Auth.Adapters.EmailTest do
provider_virtual_state: %{sign_in_token: sign_in_token}
} = changeset.changes
assert Domain.Crypto.equal?(sign_in_token <> sign_in_token_salt, sign_in_token_hash)
assert Domain.Crypto.equal?(
:argon2,
sign_in_token <> sign_in_token_salt,
sign_in_token_hash
)
end
test "trims provider identifier", %{provider: provider, changeset: changeset} do
@@ -108,7 +112,12 @@ defmodule Domain.Auth.Adapters.EmailTest do
sign_in_token: sign_in_token
} = identity.provider_virtual_state
assert Domain.Crypto.equal?(sign_in_token <> sign_in_token_salt, sign_in_token_hash)
assert Domain.Crypto.equal?(
:argon2,
sign_in_token <> sign_in_token_salt,
sign_in_token_hash
)
assert %DateTime{} = sign_in_token_created_at
end
end
@@ -158,7 +167,7 @@ defmodule Domain.Auth.Adapters.EmailTest do
account: account,
provider: provider,
provider_state: %{
"sign_in_token_hash" => Domain.Crypto.hash("dummy_token" <> "salty"),
"sign_in_token_hash" => Domain.Crypto.hash(:argon2, "dummy_token" <> "salty"),
"sign_in_token_created_at" => DateTime.to_iso8601(forty_seconds_ago),
"sign_in_token_salt" => "salty"
}

View File

@@ -28,7 +28,7 @@ defmodule Domain.Auth.Adapters.TokenTest do
assert %{"secret_hash" => secret_hash} = state
assert %{changes: %{secret: secret}} = virtual_state
assert Domain.Crypto.equal?(secret, secret_hash)
assert Domain.Crypto.equal?(:argon2, secret, secret_hash)
end
test "returns error on invalid attrs", %{provider: provider} do
@@ -118,7 +118,7 @@ defmodule Domain.Auth.Adapters.TokenTest do
|> Ecto.Changeset.change(
provider_state: %{
"expires_at" => DateTime.utc_now() |> DateTime.add(-1, :second),
"secret_hash" => Domain.Crypto.hash("foo")
"secret_hash" => Domain.Crypto.hash(:argon2, "foo")
}
)
|> Repo.update!()

View File

@@ -28,7 +28,7 @@ defmodule Domain.Auth.Adapters.UserPassTest do
assert %{provider_state: state, provider_virtual_state: %{}} = changeset.changes
assert %{"password_hash" => password_hash} = state
assert Domain.Crypto.equal?("Firezone1234", password_hash)
assert Domain.Crypto.equal?(:argon2, "Firezone1234", password_hash)
end
test "returns error on invalid attrs", %{provider: provider} do

File diff suppressed because it is too large Load Diff

View File

@@ -135,14 +135,13 @@ defmodule Domain.ClientsTest do
assert fetch_client_by_id(Ecto.UUID.generate(), subject) ==
{:error,
{:unauthorized,
[
missing_permissions: [
{:one_of,
[
Clients.Authorizer.manage_clients_permission(),
Clients.Authorizer.manage_own_clients_permission()
]}
]
reason: :missing_permissions,
missing_permissions: [
{:one_of,
[
Clients.Authorizer.manage_clients_permission(),
Clients.Authorizer.manage_own_clients_permission()
]}
]}}
end
end
@@ -212,14 +211,13 @@ defmodule Domain.ClientsTest do
assert list_clients(subject) ==
{:error,
{:unauthorized,
[
missing_permissions: [
{:one_of,
[
Clients.Authorizer.manage_clients_permission(),
Clients.Authorizer.manage_own_clients_permission()
]}
]
reason: :missing_permissions,
missing_permissions: [
{:one_of,
[
Clients.Authorizer.manage_clients_permission(),
Clients.Authorizer.manage_own_clients_permission()
]}
]}}
end
end
@@ -294,6 +292,7 @@ defmodule Domain.ClientsTest do
{:error,
{:unauthorized,
[
reason: :missing_permissions,
missing_permissions: [
{:one_of,
[
@@ -506,7 +505,8 @@ defmodule Domain.ClientsTest do
assert upsert_client(%{}, subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Clients.Authorizer.manage_own_clients_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Clients.Authorizer.manage_own_clients_permission()]}}
end
end
@@ -554,7 +554,8 @@ defmodule Domain.ClientsTest do
assert update_client(client, attrs, subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Clients.Authorizer.manage_clients_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Clients.Authorizer.manage_clients_permission()]}}
end
test "does not allow admin actor to update clients in other accounts", %{
@@ -618,14 +619,16 @@ defmodule Domain.ClientsTest do
assert update_client(client, %{}, subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Clients.Authorizer.manage_own_clients_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Clients.Authorizer.manage_own_clients_permission()]}}
client = Fixtures.Clients.create_client()
assert update_client(client, %{}, subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Clients.Authorizer.manage_clients_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Clients.Authorizer.manage_clients_permission()]}}
end
end
@@ -683,14 +686,16 @@ defmodule Domain.ClientsTest do
assert delete_client(client, subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Clients.Authorizer.manage_clients_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Clients.Authorizer.manage_clients_permission()]}}
client = Fixtures.Clients.create_client(account: account)
assert delete_client(client, subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Clients.Authorizer.manage_clients_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Clients.Authorizer.manage_clients_permission()]}}
assert Repo.aggregate(Clients.Client, :count) == 2
end
@@ -706,35 +711,56 @@ defmodule Domain.ClientsTest do
assert delete_client(client, subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Clients.Authorizer.manage_own_clients_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Clients.Authorizer.manage_own_clients_permission()]}}
client = Fixtures.Clients.create_client()
assert delete_client(client, subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Clients.Authorizer.manage_clients_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Clients.Authorizer.manage_clients_permission()]}}
end
end
describe "delete_actor_clients/1" do
test "removes all clients that belong to an actor" do
actor = Fixtures.Actors.create_actor()
describe "delete_actor_clients/2" do
test "removes all clients that belong to an actor", %{
account: account,
admin_subject: subject
} do
actor = Fixtures.Actors.create_actor(account: account)
Fixtures.Clients.create_client(actor: actor)
Fixtures.Clients.create_client(actor: actor)
Fixtures.Clients.create_client(actor: actor)
assert Repo.aggregate(Clients.Client.Query.not_deleted(), :count) == 3
assert delete_actor_clients(actor) == :ok
assert Repo.aggregate(Clients.Client.Query.not_deleted(), :count) == 0
assert Repo.aggregate(Clients.Client.Query.by_actor_id(actor.id), :count) == 3
assert delete_actor_clients(actor, subject) == :ok
assert Repo.aggregate(Clients.Client.Query.by_actor_id(actor.id), :count) == 0
end
test "does not remove clients that belong to another actor" do
actor = Fixtures.Actors.create_actor()
test "does not remove clients that belong to another actor", %{
account: account,
admin_subject: subject
} do
actor = Fixtures.Actors.create_actor(account: account)
Fixtures.Clients.create_client()
assert delete_actor_clients(actor) == :ok
assert delete_actor_clients(actor, subject) == :ok
assert Repo.aggregate(Clients.Client.Query.all(), :count) == 1
end
test "doesn't allow regular user to delete other user's clients", %{
unprivileged_subject: subject
} do
actor = Fixtures.Actors.create_actor()
Fixtures.Clients.create_client(actor: actor)
assert delete_actor_clients(actor, subject) ==
{:error,
{:unauthorized,
reason: :missing_permissions,
missing_permissions: [Clients.Authorizer.manage_clients_permission()]}}
end
end
end

View File

@@ -424,7 +424,9 @@ defmodule Domain.ConfigTest do
assert fetch_account_config(subject) ==
{:error,
{:unauthorized, [missing_permissions: [Config.Authorizer.manage_permission()]]}}
{:unauthorized,
reason: :missing_permissions,
missing_permissions: [Config.Authorizer.manage_permission()]}}
end
end
@@ -454,7 +456,9 @@ defmodule Domain.ConfigTest do
assert update_config(config, %{}, subject) ==
{:error,
{:unauthorized, [missing_permissions: [Config.Authorizer.manage_permission()]]}}
{:unauthorized,
reason: :missing_permissions,
missing_permissions: [Config.Authorizer.manage_permission()]}}
end
end

View File

@@ -48,4 +48,62 @@ defmodule Domain.CryptoTest do
end
end
end
describe "hash/2" do
test "raises an error when secret is an empty string" do
assert_raise FunctionClauseError, fn ->
hash(:argon2, "")
end
assert_raise FunctionClauseError, fn ->
hash(:sha, "")
end
end
test "raises an error when secret is not a binary" do
assert_raise FunctionClauseError, fn ->
hash(:argon2, 1)
end
assert_raise FunctionClauseError, fn ->
hash(:sha, 1)
end
end
end
describe "equal?/3" do
test "returns false for empty strings" do
refute equal?(:argon2, "a", "")
refute equal?(:argon2, "", "a")
refute equal?(:sha, "a", "")
refute equal?(:sha, "", "a")
refute equal?(:sha3_256, "a", "")
refute equal?(:sha3_256, "", "a")
end
test "returns false for nils" do
refute equal?(:argon2, nil, "")
refute equal?(:argon2, "", nil)
refute equal?(:sha, nil, "")
refute equal?(:sha, "", nil)
refute equal?(:sha3_256, nil, "")
refute equal?(:sha3_256, "", nil)
end
end
describe "hash/2 and equal?/3" do
test "generates a valid hash of a given value" do
for algo <- [:sha, :sha3_256, :argon2, :blake2b], value <- ["foo", random_token()] do
hash = hash(algo, value)
assert is_binary(hash)
assert hash != value
assert equal?(algo, value, hash)
refute equal?(algo, random_token(), hash)
refute equal?(algo, "", hash)
refute equal?(algo, nil, hash)
end
end
end
end

View File

@@ -152,10 +152,9 @@ defmodule Domain.FlowsTest do
assert authorize_flow(client, gateway, resource.id, subject) ==
{:error,
{:unauthorized,
[
missing_permissions: [
Flows.Authorizer.create_flows_permission()
]
reason: :missing_permissions,
missing_permissions: [
Flows.Authorizer.create_flows_permission()
]}}
subject = Fixtures.Auth.add_permission(subject, Flows.Authorizer.create_flows_permission())
@@ -163,10 +162,9 @@ defmodule Domain.FlowsTest do
assert authorize_flow(client, gateway, resource.id, subject) ==
{:error,
{:unauthorized,
[
missing_permissions: [
Domain.Resources.Authorizer.view_available_resources_permission()
]
reason: :missing_permissions,
missing_permissions: [
Domain.Resources.Authorizer.view_available_resources_permission()
]}}
end
@@ -228,7 +226,9 @@ defmodule Domain.FlowsTest do
assert fetch_flow_by_id(Ecto.UUID.generate(), subject) ==
{:error,
{:unauthorized, [missing_permissions: [Authorizer.view_flows_permission()]]}}
{:unauthorized,
reason: :missing_permissions,
missing_permissions: [Authorizer.view_flows_permission()]}}
end
test "associations are preloaded when opts given", %{
@@ -358,7 +358,9 @@ defmodule Domain.FlowsTest do
expected_error =
{:error,
{:unauthorized, [missing_permissions: [Flows.Authorizer.view_flows_permission()]]}}
{:unauthorized,
reason: :missing_permissions,
missing_permissions: [Flows.Authorizer.view_flows_permission()]}}
assert list_flows_for(policy, subject) == expected_error
assert list_flows_for(resource, subject) == expected_error
@@ -587,11 +589,15 @@ defmodule Domain.FlowsTest do
assert list_flow_activities_for(account, ended_after, started_before, subject) ==
{:error,
{:unauthorized, [missing_permissions: [Flows.Authorizer.view_flows_permission()]]}}
{:unauthorized,
reason: :missing_permissions,
missing_permissions: [Flows.Authorizer.view_flows_permission()]}}
assert list_flow_activities_for(flow, ended_after, started_before, subject) ==
{:error,
{:unauthorized, [missing_permissions: [Flows.Authorizer.view_flows_permission()]]}}
{:unauthorized,
reason: :missing_permissions,
missing_permissions: [Flows.Authorizer.view_flows_permission()]}}
end
end
end

View File

@@ -69,7 +69,8 @@ defmodule Domain.GatewaysTest do
assert fetch_group_by_id(Ecto.UUID.generate(), subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]}}
end
end
@@ -139,7 +140,8 @@ defmodule Domain.GatewaysTest do
assert list_groups(subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]}}
end
end
@@ -219,7 +221,8 @@ defmodule Domain.GatewaysTest do
assert create_group(%{}, subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]}}
end
end
@@ -294,7 +297,8 @@ defmodule Domain.GatewaysTest do
assert update_group(group, %{}, subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]}}
end
end
@@ -340,7 +344,8 @@ defmodule Domain.GatewaysTest do
assert delete_group(group, subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]}}
end
end
@@ -425,14 +430,13 @@ defmodule Domain.GatewaysTest do
assert fetch_gateway_by_id(Ecto.UUID.generate(), subject) ==
{:error,
{:unauthorized,
[
missing_permissions: [
{:one_of,
[
Gateways.Authorizer.manage_gateways_permission(),
Gateways.Authorizer.connect_gateways_permission()
]}
]
reason: :missing_permissions,
missing_permissions: [
{:one_of,
[
Gateways.Authorizer.manage_gateways_permission(),
Gateways.Authorizer.connect_gateways_permission()
]}
]}}
end
@@ -489,7 +493,8 @@ defmodule Domain.GatewaysTest do
assert list_gateways(subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]}}
end
# TODO: add a test that soft-deleted assocs are not preloaded
@@ -674,7 +679,7 @@ defmodule Domain.GatewaysTest do
assert Repo.aggregate(Gateways.Gateway, :count, :id) == 1
assert updated_gateway.name
assert updated_gateway.name != gateway.name
assert updated_gateway.last_seen_remote_ip.address == attrs.last_seen_remote_ip
assert updated_gateway.last_seen_remote_ip != gateway.last_seen_remote_ip
assert updated_gateway.last_seen_user_agent == attrs.last_seen_user_agent
@@ -815,7 +820,8 @@ defmodule Domain.GatewaysTest do
assert update_gateway(gateway, %{}, subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]}}
end
end
@@ -845,7 +851,8 @@ defmodule Domain.GatewaysTest do
assert delete_gateway(gateway, subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]}}
end
end

View File

@@ -54,14 +54,13 @@ defmodule Domain.PoliciesTest do
assert fetch_policy_by_id(Ecto.UUID.generate(), subject) ==
{:error,
{:unauthorized,
[
missing_permissions: [
{:one_of,
[
Policies.Authorizer.manage_policies_permission(),
Policies.Authorizer.view_available_policies_permission()
]}
]
reason: :missing_permissions,
missing_permissions: [
{:one_of,
[
Policies.Authorizer.manage_policies_permission(),
Policies.Authorizer.view_available_policies_permission()
]}
]}}
end
@@ -165,14 +164,13 @@ defmodule Domain.PoliciesTest do
assert list_policies(subject) ==
{:error,
{:unauthorized,
[
missing_permissions: [
{:one_of,
[
Policies.Authorizer.manage_policies_permission(),
Policies.Authorizer.view_available_policies_permission()
]}
]
reason: :missing_permissions,
missing_permissions: [
{:one_of,
[
Policies.Authorizer.manage_policies_permission(),
Policies.Authorizer.view_available_policies_permission()
]}
]}}
end
end
@@ -203,10 +201,9 @@ defmodule Domain.PoliciesTest do
assert create_policy(%{}, subject) ==
{:error,
{:unauthorized,
[
missing_permissions: [
{:one_of, [Policies.Authorizer.manage_policies_permission()]}
]
reason: :missing_permissions,
missing_permissions: [
{:one_of, [Policies.Authorizer.manage_policies_permission()]}
]}}
end
@@ -319,10 +316,9 @@ defmodule Domain.PoliciesTest do
assert update_policy(policy, attrs, subject) ==
{:error,
{:unauthorized,
[
missing_permissions: [
{:one_of, [Policies.Authorizer.manage_policies_permission()]}
]
reason: :missing_permissions,
missing_permissions: [
{:one_of, [Policies.Authorizer.manage_policies_permission()]}
]}}
end
@@ -394,7 +390,8 @@ defmodule Domain.PoliciesTest do
assert disable_policy(policy, subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Policies.Authorizer.manage_policies_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Policies.Authorizer.manage_policies_permission()]}}
end
end
@@ -447,7 +444,8 @@ defmodule Domain.PoliciesTest do
assert enable_policy(policy, subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Policies.Authorizer.manage_policies_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Policies.Authorizer.manage_policies_permission()]}}
end
end
@@ -476,10 +474,9 @@ defmodule Domain.PoliciesTest do
assert delete_policy(policy, subject) ==
{:error,
{:unauthorized,
[
missing_permissions: [
{:one_of, [Policies.Authorizer.manage_policies_permission()]}
]
reason: :missing_permissions,
missing_permissions: [
{:one_of, [Policies.Authorizer.manage_policies_permission()]}
]}}
end

View File

@@ -77,7 +77,8 @@ defmodule Domain.RelaysTest do
assert fetch_group_by_id(Ecto.UUID.generate(), subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Relays.Authorizer.manage_relays_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Relays.Authorizer.manage_relays_permission()]}}
end
end
@@ -129,7 +130,8 @@ defmodule Domain.RelaysTest do
assert list_groups(subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Relays.Authorizer.manage_relays_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Relays.Authorizer.manage_relays_permission()]}}
end
end
@@ -191,7 +193,8 @@ defmodule Domain.RelaysTest do
assert create_group(%{}, subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Relays.Authorizer.manage_relays_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Relays.Authorizer.manage_relays_permission()]}}
end
end
@@ -311,7 +314,8 @@ defmodule Domain.RelaysTest do
assert update_group(group, %{}, subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Relays.Authorizer.manage_relays_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Relays.Authorizer.manage_relays_permission()]}}
end
end
@@ -362,7 +366,8 @@ defmodule Domain.RelaysTest do
assert delete_group(group, subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Relays.Authorizer.manage_relays_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Relays.Authorizer.manage_relays_permission()]}}
end
end
@@ -447,7 +452,8 @@ defmodule Domain.RelaysTest do
assert fetch_relay_by_id(Ecto.UUID.generate(), subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Relays.Authorizer.manage_relays_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Relays.Authorizer.manage_relays_permission()]}}
end
end
@@ -494,7 +500,8 @@ defmodule Domain.RelaysTest do
assert list_relays(subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Relays.Authorizer.manage_relays_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Relays.Authorizer.manage_relays_permission()]}}
end
end
@@ -804,7 +811,8 @@ defmodule Domain.RelaysTest do
assert delete_relay(relay, subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Relays.Authorizer.manage_relays_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Relays.Authorizer.manage_relays_permission()]}}
end
end

View File

@@ -80,14 +80,13 @@ defmodule Domain.ResourcesTest do
assert fetch_resource_by_id(Ecto.UUID.generate(), subject) ==
{:error,
{:unauthorized,
[
missing_permissions: [
{:one_of,
[
Resources.Authorizer.manage_resources_permission(),
Resources.Authorizer.view_available_resources_permission()
]}
]
reason: :missing_permissions,
missing_permissions: [
{:one_of,
[
Resources.Authorizer.manage_resources_permission(),
Resources.Authorizer.view_available_resources_permission()
]}
]}}
end
@@ -307,11 +306,8 @@ defmodule Domain.ResourcesTest do
assert fetch_and_authorize_resource_by_id(Ecto.UUID.generate(), subject) ==
{:error,
{:unauthorized,
[
missing_permissions: [
Resources.Authorizer.view_available_resources_permission()
]
]}}
reason: :missing_permissions,
missing_permissions: [Resources.Authorizer.view_available_resources_permission()]}}
end
test "associations are preloaded when opts given", %{
@@ -483,11 +479,8 @@ defmodule Domain.ResourcesTest do
assert list_authorized_resources(subject) ==
{:error,
{:unauthorized,
[
missing_permissions: [
Resources.Authorizer.view_available_resources_permission()
]
]}}
reason: :missing_permissions,
missing_permissions: [Resources.Authorizer.view_available_resources_permission()]}}
end
end
@@ -533,10 +526,9 @@ defmodule Domain.ResourcesTest do
assert list_resources(subject) ==
{:error,
{:unauthorized,
[
missing_permissions: [
Resources.Authorizer.manage_resources_permission()
]
reason: :missing_permissions,
missing_permissions: [
Resources.Authorizer.manage_resources_permission()
]}}
end
end
@@ -617,14 +609,13 @@ defmodule Domain.ResourcesTest do
assert list_resources_for_gateway(gateway, subject) ==
{:error,
{:unauthorized,
[
missing_permissions: [
{:one_of,
[
Resources.Authorizer.manage_resources_permission(),
Resources.Authorizer.view_available_resources_permission()
]}
]
reason: :missing_permissions,
missing_permissions: [
{:one_of,
[
Resources.Authorizer.manage_resources_permission(),
Resources.Authorizer.view_available_resources_permission()
]}
]}}
end
end
@@ -743,7 +734,8 @@ defmodule Domain.ResourcesTest do
assert peek_resource_actor_groups([], 3, subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Resources.Authorizer.manage_resources_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Resources.Authorizer.manage_resources_permission()]}}
end
end
@@ -813,14 +805,13 @@ defmodule Domain.ResourcesTest do
assert count_resources_for_gateway(gateway, subject) ==
{:error,
{:unauthorized,
[
missing_permissions: [
{:one_of,
[
Resources.Authorizer.manage_resources_permission(),
Resources.Authorizer.view_available_resources_permission()
]}
]
reason: :missing_permissions,
missing_permissions: [
{:one_of,
[
Resources.Authorizer.manage_resources_permission(),
Resources.Authorizer.view_available_resources_permission()
]}
]}}
end
end
@@ -985,7 +976,8 @@ defmodule Domain.ResourcesTest do
assert create_resource(%{}, subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Resources.Authorizer.manage_resources_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Resources.Authorizer.manage_resources_permission()]}}
end
end
@@ -1078,7 +1070,8 @@ defmodule Domain.ResourcesTest do
assert update_resource(resource, %{}, subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Resources.Authorizer.manage_resources_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Resources.Authorizer.manage_resources_permission()]}}
end
end
@@ -1116,7 +1109,8 @@ defmodule Domain.ResourcesTest do
assert delete_resource(resource, subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Resources.Authorizer.manage_resources_permission()]]}}
reason: :missing_permissions,
missing_permissions: [Resources.Authorizer.manage_resources_permission()]}}
end
end

View File

@@ -0,0 +1,558 @@
defmodule Domain.TokensTest do
use Domain.DataCase, async: true
import Domain.Tokens
alias Domain.Tokens
setup do
account = Fixtures.Accounts.create_account()
actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account)
identity = Fixtures.Auth.create_identity(account: account, actor: actor)
subject = Fixtures.Auth.create_subject(account: account, actor: actor, identity: identity)
%{
account: account,
actor: actor,
identity: identity,
subject: subject
}
end
describe "fetch_token_by_id/2" do
test "returns error when subject does not have required permissions", %{
subject: subject
} do
subject = Fixtures.Auth.remove_permissions(subject)
assert fetch_token_by_id(Ecto.UUID.generate(), subject) ==
{:error,
{:unauthorized,
reason: :missing_permissions,
missing_permissions: [
{:one_of,
[
Tokens.Authorizer.manage_tokens_permission(),
Tokens.Authorizer.manage_own_tokens_permission()
]}
]}}
end
test "returns error when token is not found", %{subject: subject} do
assert fetch_token_by_id(Ecto.UUID.generate(), subject) == {:error, :not_found}
assert fetch_token_by_id("foo", subject) == {:error, :not_found}
end
test "returns token for admin user", %{account: account, subject: subject} do
token = Fixtures.Tokens.create_token(account: account)
assert {:ok, _token} = fetch_token_by_id(token.id, subject)
end
test "does not return other user tokens for non-admin users", %{account: account} do
actor = Fixtures.Actors.create_actor(type: :account_user, account: account)
subject = Fixtures.Auth.create_subject(account: account, actor: actor)
token = Fixtures.Tokens.create_token(account: account)
assert fetch_token_by_id(token.id, subject) == {:error, :not_found}
end
end
describe "list_tokens_for/1" do
test "returns current subject's tokens", %{
account: account,
identity: identity,
subject: subject
} do
token = Fixtures.Tokens.create_token(account: account, identity: identity)
Fixtures.Tokens.create_token(account: account)
Fixtures.Tokens.create_token()
assert {:ok, tokens} = list_tokens_for(subject)
token_ids = Enum.map(tokens, & &1.id)
assert token.id in token_ids
assert subject.token_id in token_ids
assert length(tokens) == 2
end
test "returns error when subject does not have required permissions", %{
subject: subject
} do
subject = Fixtures.Auth.remove_permissions(subject)
assert list_tokens_for(subject) ==
{:error,
{:unauthorized,
reason: :missing_permissions,
missing_permissions: [Tokens.Authorizer.manage_own_tokens_permission()]}}
end
end
describe "list_tokens_for/2" do
test "returns tokens of a given actor", %{
account: account,
subject: subject
} do
actor = Fixtures.Actors.create_actor(account: account)
identity = Fixtures.Auth.create_identity(account: account, actor: actor)
token = Fixtures.Tokens.create_token(account: account, identity: identity)
Fixtures.Tokens.create_token(account: account)
Fixtures.Tokens.create_token()
assert {:ok, [fetched_token]} = list_tokens_for(actor, subject)
assert fetched_token.id == token.id
end
test "returns error when subject does not have required permissions", %{
actor: actor,
subject: subject
} do
subject = Fixtures.Auth.remove_permissions(subject)
assert list_tokens_for(actor, subject) ==
{:error,
{:unauthorized,
reason: :missing_permissions,
missing_permissions: [Tokens.Authorizer.manage_tokens_permission()]}}
end
end
describe "create_token/2" do
test "returns errors on missing required attrs" do
assert {:error, changeset} = create_token(%{})
assert errors_on(changeset) == %{
type: ["can't be blank"],
account_id: ["can't be blank"],
expires_at: ["can't be blank"],
secret_fragment: ["can't be blank"],
secret_hash: ["can't be blank"],
created_by_remote_ip: ["can't be blank"],
created_by_user_agent: ["can't be blank"]
}
end
test "returns errors on invalid attrs" do
attrs = %{
type: :relay,
secret_nonce: -1,
secret_fragment: -1,
expires_at: DateTime.utc_now(),
created_by_user_agent: -1,
created_by_remote_ip: -1,
account_id: Ecto.UUID.generate()
}
assert {:error, changeset} = create_token(attrs)
assert %{
type: ["is invalid"],
expires_at: ["must be greater than" <> _],
secret_nonce: ["is invalid"],
secret_fragment: ["is invalid"],
secret_hash: ["can't be blank"],
created_by_remote_ip: ["is invalid"],
created_by_user_agent: ["is invalid"]
} = errors_on(changeset)
end
test "inserts a token", %{account: account, identity: identity} do
type = :email
nonce = "nonce"
fragment = Domain.Crypto.random_token(32)
expires_at = DateTime.utc_now() |> DateTime.add(1, :day)
user_agent = Fixtures.Tokens.user_agent()
remote_ip = Fixtures.Tokens.remote_ip()
attrs = %{
type: type,
account_id: account.id,
identity_id: identity.id,
secret_nonce: nonce,
secret_fragment: fragment,
expires_at: expires_at,
created_by_user_agent: user_agent,
created_by_remote_ip: remote_ip
}
assert {:ok, %Tokens.Token{} = token} = create_token(attrs)
assert token.type == type
assert token.expires_at == expires_at
assert token.created_by_user_agent == user_agent
assert token.created_by_remote_ip.address == remote_ip
refute token.secret_nonce
assert token.secret_fragment == fragment
assert token.secret_salt
assert token.secret_hash
assert token.account_id == account.id
end
end
describe "create_token/3" do
test "returns errors on missing required attrs", %{subject: subject} do
assert {:error, changeset} = create_token(%{}, subject)
assert errors_on(changeset) == %{
type: ["can't be blank"],
expires_at: ["can't be blank"],
secret_fragment: ["can't be blank"],
secret_hash: ["can't be blank"],
created_by_remote_ip: ["can't be blank"],
created_by_user_agent: ["can't be blank"]
}
end
test "returns errors on invalid attrs", %{subject: subject} do
attrs = %{
type: -1,
secret_nonce: "x.o",
secret_fragment: -1,
expires_at: DateTime.utc_now(),
created_by_user_agent: -1,
created_by_remote_ip: -1
}
assert {:error, changeset} = create_token(attrs, subject)
assert %{
type: ["is invalid"],
expires_at: ["must be greater than" <> _],
secret_nonce: ["has invalid format"],
secret_fragment: ["is invalid"],
secret_hash: ["can't be blank"],
created_by_remote_ip: ["is invalid"],
created_by_user_agent: ["is invalid"]
} = errors_on(changeset)
end
test "inserts a token", %{account: account, subject: subject} do
type = :client
nonce = "nonce"
fragment = Domain.Crypto.random_token(32)
expires_at = DateTime.utc_now() |> DateTime.add(1, :day)
user_agent = Fixtures.Tokens.user_agent()
remote_ip = Fixtures.Tokens.remote_ip()
attrs = %{
type: type,
secret_nonce: nonce,
secret_fragment: fragment,
identity_id: subject.identity.id,
expires_at: expires_at,
created_by_user_agent: user_agent,
created_by_remote_ip: remote_ip
}
assert {:ok, %Tokens.Token{} = token} = create_token(attrs, subject)
assert token.type == type
assert token.expires_at == expires_at
assert token.created_by_user_agent == user_agent
assert token.created_by_remote_ip.address == remote_ip
assert token.secret_fragment == fragment
refute token.secret_nonce
assert token.secret_salt
assert token.secret_hash
assert Domain.Crypto.equal?(
:sha3_256,
nonce <> fragment <> token.secret_salt,
token.secret_hash
)
assert token.account_id == account.id
end
end
describe "use_token/4" do
test "returns token when nonce, context and secret are valid", %{account: account} do
nonce = "nonce"
token = Fixtures.Tokens.create_token(account: account, secret_nonce: nonce)
context = Fixtures.Auth.build_context(type: token.type)
encoded_fragment = encode_fragment!(token)
assert {:ok, used_token} = use_token(nonce <> encoded_fragment, context)
assert used_token.account_id == account.id
assert used_token.id == token.id
end
test "updates last seen fields when token is used", %{account: account} do
nonce = "nonce"
token = Fixtures.Tokens.create_token(account: account, secret_nonce: nonce)
context = Fixtures.Auth.build_context(type: token.type)
encoded_fragment = encode_fragment!(token)
assert {:ok, token} = use_token(nonce <> encoded_fragment, context)
assert token.last_seen_user_agent == context.user_agent
assert token.last_seen_remote_ip.address == context.remote_ip
assert token.last_seen_remote_ip_location_region == context.remote_ip_location_region
assert token.last_seen_remote_ip_location_city == context.remote_ip_location_city
assert token.last_seen_remote_ip_location_lat == context.remote_ip_location_lat
assert token.last_seen_remote_ip_location_lon == context.remote_ip_location_lon
assert token.last_seen_at
end
test "returns error when secret is invalid", %{account: account} do
nonce = "nonce"
token = Fixtures.Tokens.create_token(account: account, secret_nonce: nonce)
context = Fixtures.Auth.build_context(type: token.type)
encoded_fragment = encode_fragment!(%{token | secret_fragment: "bar"})
assert use_token(nonce <> encoded_fragment, context) ==
{:error, :invalid_or_expired_token}
end
test "returns error when nonce is invalid", %{account: account} do
token = Fixtures.Tokens.create_token(account: account)
context = Fixtures.Auth.build_context(type: token.type)
encoded_fragment = encode_fragment!(token)
assert use_token("foo" <> encoded_fragment, context) ==
{:error, :invalid_or_expired_token}
end
test "returns error when signed token is invalid", %{account: account} do
token = Fixtures.Tokens.create_token(account: account)
context = Fixtures.Auth.build_context(type: token.type)
assert use_token("nonce.bar", context) == {:error, :invalid_or_expired_token}
assert use_token("bar", context) == {:error, :invalid_or_expired_token}
assert use_token("", context) == {:error, :invalid_or_expired_token}
end
test "returns error when type is invalid", %{account: account} do
nonce = "nonce"
token = Fixtures.Tokens.create_token(account: account, secret_nonce: nonce)
context = Fixtures.Auth.build_context(type: :other)
encoded_fragment = encode_fragment!(token)
assert use_token(nonce <> encoded_fragment, context) ==
{:error, :invalid_or_expired_token}
end
end
describe "update_token/2" do
setup %{account: account} do
token = Fixtures.Tokens.create_token(account: account)
%{token: token}
end
test "no-op on empty attrs", %{token: token} do
assert {:ok, refreshed_token} = update_token(token, %{})
assert refreshed_token.expires_at == token.expires_at
end
test "returns errors on invalid attrs", %{token: token} do
attrs = %{
expires_at: DateTime.utc_now()
}
assert {:error, changeset} = update_token(token, attrs)
assert %{
expires_at: ["must be greater than" <> _]
} = errors_on(changeset)
end
test "updates token expiration", %{token: token} do
attrs = %{
expires_at: DateTime.utc_now() |> DateTime.add(1, :day)
}
assert {:ok, token} = update_token(token, attrs)
assert token == %{token | expires_at: attrs.expires_at}
end
test "does not extend expiration of expired tokens", %{token: token} do
token = Fixtures.Tokens.expire_token(token)
assert update_token(token, %{}) == {:error, :not_found}
end
test "does not extend expiration of deleted tokens", %{token: token} do
token = Fixtures.Tokens.delete_token(token)
assert update_token(token, %{}) == {:error, :not_found}
end
end
describe "delete_token/2" do
test "admin can delete any account token", %{account: account, subject: subject} do
token = Fixtures.Tokens.create_token(account: account)
Phoenix.PubSub.subscribe(Domain.PubSub, "sessions:#{token.id}")
assert {:ok, token} = delete_token(token, subject)
assert token.deleted_at
assert_receive "disconnect"
end
test "user can delete own token", %{account: account, identity: identity, subject: subject} do
token = Fixtures.Tokens.create_token(account: account, identity: identity)
Phoenix.PubSub.subscribe(Domain.PubSub, "sessions:#{token.id}")
assert {:ok, token} = delete_token(token, subject)
assert token.deleted_at
assert_receive "disconnect"
end
test "user can not delete other users token", %{
account: account
} do
actor = Fixtures.Actors.create_actor(type: :account_user, account: account)
subject = Fixtures.Auth.create_subject(account: account, actor: actor)
token = Fixtures.Tokens.create_token(account: account)
Phoenix.PubSub.subscribe(Domain.PubSub, "sessions:#{token.id}")
assert delete_token(token, subject) == {:error, :not_found}
refute Repo.get(Tokens.Token, token.id).deleted_at
refute_receive "disconnect"
end
test "does not delete tokens that belong to other accounts", %{
subject: subject
} do
token = Fixtures.Tokens.create_token()
Phoenix.PubSub.subscribe(Domain.PubSub, "sessions:#{token.id}")
assert delete_token(token, subject) == {:error, :not_found}
refute Repo.get(Tokens.Token, token.id).deleted_at
refute_receive "disconnect"
end
test "returns error when subject does not have required permissions", %{
account: account,
subject: subject
} do
token = Fixtures.Tokens.create_token(account: account)
subject = Fixtures.Auth.remove_permissions(subject)
assert delete_token(token, subject) ==
{:error,
{:unauthorized,
reason: :missing_permissions,
missing_permissions: [
{:one_of,
[
Tokens.Authorizer.manage_tokens_permission(),
Tokens.Authorizer.manage_own_tokens_permission()
]}
]}}
end
end
describe "delete_tokens_for/1" do
test "deletes tokens for current subject", %{
account: account,
identity: identity,
subject: subject
} do
token = Fixtures.Tokens.create_token(account: account, identity: identity)
Phoenix.PubSub.subscribe(Domain.PubSub, "sessions:#{token.id}")
assert delete_tokens_for(subject) == {:ok, 2}
assert Repo.get(Tokens.Token, token.id).deleted_at
assert_receive "disconnect"
end
test "does not delete tokens for other actors", %{account: account, subject: subject} do
token = Fixtures.Tokens.create_token(account: account)
Phoenix.PubSub.subscribe(Domain.PubSub, "sessions:#{token.id}")
assert delete_tokens_for(subject) == {:ok, 1}
refute Repo.get(Tokens.Token, token.id).deleted_at
refute_receive "disconnect"
end
test "returns error when subject does not have required permissions", %{
subject: subject
} do
subject = Fixtures.Auth.remove_permissions(subject)
assert delete_tokens_for(subject) ==
{:error,
{:unauthorized,
reason: :missing_permissions,
missing_permissions: [Tokens.Authorizer.manage_own_tokens_permission()]}}
end
end
describe "delete_tokens_for/2" do
test "deletes tokens for given actor", %{account: account, subject: subject} do
actor = Fixtures.Actors.create_actor(account: account)
identity = Fixtures.Auth.create_identity(account: account, actor: actor)
token = Fixtures.Tokens.create_token(account: account, identity: identity)
Phoenix.PubSub.subscribe(Domain.PubSub, "sessions:#{token.id}")
assert delete_tokens_for(actor, subject) == {:ok, 1}
assert Repo.get(Tokens.Token, token.id).deleted_at
assert_receive "disconnect"
end
test "returns error when subject does not have required permissions", %{
actor: actor,
subject: subject
} do
subject = Fixtures.Auth.remove_permissions(subject)
assert delete_tokens_for(actor, subject) ==
{:error,
{:unauthorized,
reason: :missing_permissions,
missing_permissions: [Tokens.Authorizer.manage_tokens_permission()]}}
end
end
describe "delete_expired_tokens/0" do
test "deletes expired tokens" do
token =
Fixtures.Tokens.create_token()
|> Fixtures.Tokens.expire_token()
Phoenix.PubSub.subscribe(Domain.PubSub, "sessions:#{token.id}")
assert delete_expired_tokens() == {:ok, 1}
assert Repo.get(Tokens.Token, token.id).deleted_at
assert_receive "disconnect"
end
test "does not delete non-expired tokens" do
in_one_minute = DateTime.utc_now() |> DateTime.add(1, :minute)
token = Fixtures.Tokens.create_token(expires_at: in_one_minute)
Phoenix.PubSub.subscribe(Domain.PubSub, "sessions:#{token.id}")
assert delete_expired_tokens() == {:ok, 0}
refute Repo.get(Tokens.Token, token.id).deleted_at
refute_receive "disconnect"
end
end
describe "delete_token_by_id/1" do
test "marks token as deleted" do
token = Fixtures.Tokens.create_token()
assert delete_token_by_id(token.id) == {:ok, 1}
assert Repo.get(Tokens.Token, token.id).deleted_at
end
test "returns error when token is already deleted" do
token = Fixtures.Tokens.create_token()
token = Fixtures.Tokens.delete_token(token)
assert delete_token_by_id(token.id) == {:ok, 0}
end
test "returns error when token id is invalid" do
assert delete_token_by_id("foo") == {:ok, 0}
end
end
end

View File

@@ -239,8 +239,6 @@ defmodule Domain.Fixtures.Auth do
random_provider_identifier(provider)
end)
{provider_state, attrs} = Map.pop(attrs, :provider_state)
{actor, attrs} =
pop_assoc_fixture(attrs, :actor, fn assoc_attrs ->
assoc_attrs
@@ -257,19 +255,63 @@ defmodule Domain.Fixtures.Auth do
{:ok, identity} = Auth.upsert_identity(actor, provider, attrs)
if provider_state do
identity
|> Ecto.Changeset.change(provider_state: provider_state)
|> Repo.update!()
else
identity
end
attrs = Map.take(attrs, [:provider_state, :created_by])
identity
|> Ecto.Changeset.change(attrs)
|> Repo.update!()
end
def delete_identity(identity) do
update!(identity, deleted_at: DateTime.utc_now())
end
def build_context(attrs \\ %{}) do
attrs = Enum.into(attrs, %{})
{type, attrs} = Map.pop(attrs, :type, :browser)
{user_agent, attrs} =
Map.pop_lazy(attrs, :user_agent, fn ->
user_agent()
end)
{remote_ip, attrs} =
Map.pop_lazy(attrs, :remote_ip, fn ->
remote_ip()
end)
{remote_ip_location_region, attrs} =
Map.pop_lazy(attrs, :remote_ip_location_region, fn ->
Enum.random(["US", "UA"])
end)
{remote_ip_location_city, attrs} =
Map.pop_lazy(attrs, :remote_ip_location_city, fn ->
Enum.random(["Odessa", "New York"])
end)
{remote_ip_location_lat, attrs} =
Map.pop_lazy(attrs, :remote_ip_location_city, fn ->
Enum.random([37.7758, 40.7128])
end)
{remote_ip_location_lon, _attrs} =
Map.pop_lazy(attrs, :remote_ip_location_city, fn ->
Enum.random([-122.4128, -74.0060])
end)
%Auth.Context{
type: type,
remote_ip: remote_ip,
remote_ip_location_region: remote_ip_location_region,
remote_ip_location_city: remote_ip_location_city,
remote_ip_location_lat: remote_ip_location_lat,
remote_ip_location_lon: remote_ip_location_lon,
user_agent: user_agent
}
end
def create_subject(attrs \\ %{}) do
attrs = Enum.into(attrs, %{})
@@ -340,46 +382,109 @@ defmodule Domain.Fixtures.Auth do
DateTime.utc_now() |> DateTime.add(60, :second)
end)
{user_agent, attrs} =
Map.pop_lazy(attrs, :user_agent, fn ->
user_agent()
{context, attrs} =
pop_assoc_fixture(attrs, :context, fn assoc_attrs ->
build_context(assoc_attrs)
end)
{remote_ip, attrs} =
Map.pop_lazy(attrs, :remote_ip, fn ->
remote_ip()
{token, _attrs} =
pop_assoc_fixture(attrs, :token, fn assoc_attrs ->
assoc_attrs
|> Enum.into(%{
account: account,
type: context.type,
secret_nonce: Domain.Crypto.random_token(32, encoder: :hex32),
identity_id: identity.id,
expires_at: expires_at
})
|> Fixtures.Tokens.create_token()
end)
{remote_ip_location_region, attrs} =
Map.pop_lazy(attrs, :remote_ip_location_region, fn ->
Enum.random(["US", "UA"])
Auth.build_subject(token, identity, context)
end
def create_and_encode_token(attrs) do
attrs = Enum.into(attrs, %{})
{account, attrs} =
pop_assoc_fixture(attrs, :account, fn assoc_attrs ->
relation = attrs[:provider] || attrs[:actor] || attrs[:identity]
if not is_nil(relation) and is_struct(relation) do
Repo.get!(Domain.Accounts.Account, relation.account_id)
else
Fixtures.Accounts.create_account(assoc_attrs)
end
end)
{remote_ip_location_city, attrs} =
Map.pop_lazy(attrs, :remote_ip_location_city, fn ->
Enum.random(["Odessa", "New York"])
{provider, attrs} =
pop_assoc_fixture(attrs, :provider, fn assoc_attrs ->
relation = attrs[:identity]
if not is_nil(relation) and is_struct(relation) do
Repo.get!(Domain.Auth.Provider, relation.provider_id)
else
{provider, _bypass} =
assoc_attrs
|> Enum.into(%{account: account})
|> start_and_create_openid_connect_provider()
provider
end
end)
{remote_ip_location_lat, attrs} =
Map.pop_lazy(attrs, :remote_ip_location_city, fn ->
Enum.random([37.7758, 40.7128])
{provider_identifier, attrs} =
Map.pop_lazy(attrs, :provider_identifier, fn ->
random_provider_identifier(provider)
end)
{remote_ip_location_lon, _attrs} =
Map.pop_lazy(attrs, :remote_ip_location_city, fn ->
Enum.random([-122.4128, -74.0060])
{actor, attrs} =
pop_assoc_fixture(attrs, :actor, fn assoc_attrs ->
relation = attrs[:identity]
if not is_nil(relation) and is_struct(relation) do
Repo.get!(Domain.Actors.Actor, relation.actor_id)
else
assoc_attrs
|> Enum.into(%{
type: :account_admin_user,
account: account,
provider: provider,
provider_identifier: provider_identifier
})
|> Fixtures.Actors.create_actor()
end
end)
context = %Auth.Context{
remote_ip: remote_ip,
remote_ip_location_region: remote_ip_location_region,
remote_ip_location_city: remote_ip_location_city,
remote_ip_location_lat: remote_ip_location_lat,
remote_ip_location_lon: remote_ip_location_lon,
user_agent: user_agent
}
{identity, attrs} =
pop_assoc_fixture(attrs, :identity, fn assoc_attrs ->
assoc_attrs
|> Enum.into(%{
actor: actor,
account: account,
provider: provider,
provider_identifier: provider_identifier
})
|> create_identity()
end)
Auth.build_subject(identity, expires_at, context)
{expires_at, attrs} =
Map.pop_lazy(attrs, :expires_at, fn ->
DateTime.utc_now() |> DateTime.add(60, :second)
end)
{context, attrs} =
pop_assoc_fixture(attrs, :context, fn assoc_attrs ->
build_context(assoc_attrs)
end)
{nonce, _attrs} =
Map.pop_lazy(attrs, :nonce, fn ->
Domain.Crypto.random_token(32, encoder: :hex32)
end)
{:ok, token} = Auth.create_token(identity, context, nonce, expires_at)
{token, nonce <> Domain.Tokens.encode_fragment!(token)}
end
def remove_permissions(%Auth.Subject{} = subject) do

View File

@@ -0,0 +1,105 @@
defmodule Domain.Fixtures.Tokens do
use Domain.Fixture
alias Domain.Tokens
def remote_ip, do: Enum.random([unique_ipv4(), unique_ipv6()])
def user_agent, do: "iOS/12.5 (iPhone; #{unique_integer()}) connlib/0.7.412"
def token_attrs(attrs \\ %{}) do
type = :browser
nonce = Domain.Crypto.random_token(32, encoder: :hex32)
fragment = Domain.Crypto.random_token(32)
expires_at = DateTime.utc_now() |> DateTime.add(1, :day)
user_agent = Fixtures.Auth.user_agent()
remote_ip = Fixtures.Auth.remote_ip()
Enum.into(attrs, %{
type: type,
secret_nonce: nonce,
secret_fragment: fragment,
expires_at: expires_at,
created_by_user_agent: user_agent,
created_by_remote_ip: remote_ip
})
end
def create_email_token(attrs \\ %{}) do
attrs = attrs |> Enum.into(%{type: :email}) |> token_attrs()
{account, attrs} =
pop_assoc_fixture(attrs, :account, fn assoc_attrs ->
Fixtures.Accounts.create_account(assoc_attrs)
end)
{identity_id, attrs} =
pop_assoc_fixture_id(attrs, :identity, fn ->
Fixtures.Auth.create_identity(account: account)
end)
attrs = Map.put(attrs, :identity_id, identity_id)
attrs = Map.put(attrs, :account_id, account.id)
{:ok, token} = Domain.Tokens.create_token(attrs)
token
end
def create_service_account_token(attrs \\ %{}) do
attrs = token_attrs(attrs)
{account, attrs} =
pop_assoc_fixture(attrs, :account, fn assoc_attrs ->
Fixtures.Accounts.create_account(assoc_attrs)
end)
{identity_id, attrs} =
pop_assoc_fixture_id(attrs, :identity, fn ->
Fixtures.Auth.create_identity(account: account)
end)
{subject, attrs} =
pop_assoc_fixture(attrs, :subject, fn assoc_attrs ->
assoc_attrs
|> Enum.into(%{account: account, actor: [type: :account_admin_user]})
|> Fixtures.Auth.create_subject()
end)
attrs = Map.put(attrs, :identity_id, identity_id)
{:ok, token} = Domain.Tokens.create_token(attrs, subject)
token
end
def create_token(attrs \\ %{}) do
attrs = token_attrs(attrs)
{account, attrs} =
pop_assoc_fixture(attrs, :account, fn assoc_attrs ->
Fixtures.Accounts.create_account(assoc_attrs)
end)
{identity_id, attrs} =
pop_assoc_fixture_id(attrs, :identity, fn ->
Fixtures.Auth.create_identity(account: account)
end)
attrs = Map.put(attrs, :identity_id, identity_id)
attrs = Map.put(attrs, :account_id, account.id)
{:ok, token} = Domain.Tokens.create_token(attrs)
token
end
def delete_token(token) do
token
|> Tokens.Token.Changeset.delete()
|> Domain.Repo.update!()
end
def expire_token(token) do
one_minute_ago = DateTime.utc_now() |> DateTime.add(-1, :minute)
token
|> Ecto.Changeset.change(expires_at: one_minute_ago)
|> Domain.Repo.update!()
end
end

View File

@@ -4,8 +4,8 @@ defmodule Web.Auth do
# This is the cookie which will store recent account ids
# that the user has signed in to.
@remember_me_cookie_name "fz_recent_account_ids"
@remember_me_cookie_options [
@recent_accounts_cookie_name "fz_recent_account_ids"
@recent_accounts_cookie_options [
sign: true,
max_age: 365 * 24 * 60 * 60,
same_site: "Lax",
@@ -14,106 +14,147 @@ defmodule Web.Auth do
]
@remember_last_account_ids 5
def signed_in_path(%Auth.Subject{actor: %{type: :account_admin_user}} = subject) do
~p"/#{subject.account}/sites"
# Session is stored as a list in a cookie so we want to limit numbers
# of items in the list to avoid hitting cookie size limit.
@remember_last_sessions 10
# Session Management
def put_account_session(%Plug.Conn{} = conn, context_type, account_id, encoded_fragment)
when context_type in [:browser, :client] do
session = {context_type, account_id, encoded_fragment}
sessions =
Plug.Conn.get_session(conn, :sessions, [])
|> Enum.reject(fn {session_context_type, session_account_id, _encoded_fragment} ->
session_context_type == context_type and session_account_id == account_id
end)
sessions = Enum.take(sessions ++ [session], -1 * @remember_last_sessions)
Plug.Conn.put_session(conn, :sessions, sessions)
end
def put_subject_in_session(conn, %Auth.Subject{} = subject) do
{:ok, session_token} = Auth.create_session_token_from_subject(subject)
# Signing In and Out
conn
|> Plug.Conn.put_session(:signed_in_at, DateTime.utc_now())
|> Plug.Conn.put_session(:session_token, session_token)
|> Plug.Conn.put_session(:live_socket_id, "actors_sessions:#{subject.actor.id}")
@doc """
Returns non-empty parameters that should be persisted during sign in flow.
"""
def take_sign_in_params(params) do
params
|> Map.take(["as", "state", "nonce", "redirect_to"])
|> Map.reject(fn {_key, value} -> value in ["", nil] end)
end
@doc """
Redirects the signed in user depending on the actor type.
Takes sign in parameters returned by `take_sign_in_params/1` and
returns the appropriate auth context type for them.
"""
def fetch_auth_context_type!(%{"as" => "client"}), do: :client
def fetch_auth_context_type!(_params), do: :browser
The account admin users are sent to authenticated home or a return path if it's stored in session.
def fetch_token_nonce!(%{"nonce" => nonce}), do: nonce
def fetch_token_nonce!(_params), do: nil
The account users are only expected to authenticate using client apps.
If the platform is known, we direct them to the application through a deep link or an app link;
if not, we guide them to the install instructions accompanied by an error message.
@doc """
Persists the token in the session and redirects the user depending on the
auth context type.
The browser users are sent to authenticated home or a return path if it's stored in params.
The account users are only expected to authenticate using client apps and are redirected
to the deep link.
"""
def signed_in_redirect(
%Plug.Conn{path_params: %{"account_id_or_slug" => account_id_or_slug}} = conn,
%Auth.Subject{account: %Accounts.Account{} = account},
_client_platform,
_client_csrf_token
)
when not is_nil(account_id_or_slug) and account_id_or_slug != account.id and
account_id_or_slug != account.slug do
conn
|> Web.Auth.renew_session()
|> Plug.Conn.delete_session(:user_return_to)
|> Phoenix.Controller.redirect(to: ~p"/#{account_id_or_slug}")
end
def signed_in(
%Plug.Conn{} = conn,
%Auth.Provider{} = provider,
%Auth.Identity{} = identity,
context,
encoded_fragment,
redirect_params
) do
redirect_params = take_sign_in_params(redirect_params)
conn = prepend_recent_account_ids(conn, provider.account_id)
def signed_in_redirect(
conn,
%Auth.Subject{} = subject,
client_platform,
client_csrf_token
)
when not is_nil(client_platform) and client_platform != "" do
platform_redirects =
Domain.Config.fetch_env!(:web, __MODULE__)
|> Keyword.fetch!(:platform_redirects)
if redirects = Map.get(platform_redirects, client_platform) do
{:ok, client_token} = Auth.create_client_token_from_subject(subject)
query =
%{
client_auth_token: client_token,
client_csrf_token: client_csrf_token,
actor_name: subject.actor.name,
account_slug: subject.account.slug,
account_name: subject.account.name,
identity_provider_identifier: subject.identity.provider_identifier
}
|> Enum.reject(&is_nil(elem(&1, 1)))
|> URI.encode_query()
redirect_method = Keyword.fetch!(redirects, :method)
redirect_dest = "#{Keyword.fetch!(redirects, :dest)}?#{query}"
conn
|> Phoenix.Controller.redirect([{redirect_method, redirect_dest}])
else
if is_nil(redirect_params["as"]) and identity.actor.type == :account_user do
conn
|> Phoenix.Controller.put_flash(
:info,
:error,
"Please use a client application to access Firezone."
)
|> Phoenix.Controller.redirect(to: ~p"/#{subject.account}")
|> Phoenix.Controller.redirect(to: ~p"/#{conn.path_params["account_id_or_slug"]}")
|> Plug.Conn.halt()
else
conn
|> put_account_session(context.type, provider.account_id, encoded_fragment)
|> signed_in_redirect(identity, context, encoded_fragment, redirect_params)
end
end
def signed_in_redirect(
conn,
%Auth.Subject{actor: %{type: :account_admin_user}} = subject,
_client_platform,
_client_csrf_token
) do
redirect_to = Plug.Conn.get_session(conn, :user_return_to) || signed_in_path(subject)
defp signed_in_redirect(conn, identity, %Auth.Context{type: :client}, encoded_fragment, %{
"as" => "client",
"nonce" => _nonce,
"state" => state
}) do
query =
%{
fragment: encoded_fragment,
state: state,
actor_name: identity.actor.name,
account_slug: conn.assigns.account.slug,
account_name: conn.assigns.account.name,
identity_provider_identifier: identity.provider_identifier
}
|> Enum.reject(&is_nil(elem(&1, 1)))
|> URI.encode_query()
conn
|> Web.Auth.renew_session()
|> Web.Auth.put_subject_in_session(subject)
|> Plug.Conn.delete_session(:user_return_to)
|> Phoenix.Controller.redirect(to: redirect_to)
Phoenix.Controller.redirect(conn,
external: "firezone-fd0020211111://handle_client_sign_in_callback?#{query}"
)
end
def signed_in_redirect(conn, %Auth.Subject{} = subject, _client_platform, _client_csrf_token) do
defp signed_in_redirect(
conn,
_identity,
%Auth.Context{type: :client},
_encoded_fragment,
_params
) do
conn
|> Phoenix.Controller.put_flash(
:info,
"Please use a client application to access Firezone."
)
|> Phoenix.Controller.redirect(to: ~p"/#{subject.account}")
|> Phoenix.Controller.put_flash(:error, "Please use a client application to access Firezone.")
|> Phoenix.Controller.redirect(to: ~p"/#{conn.path_params["account_id_or_slug"]}")
|> Plug.Conn.halt()
end
defp signed_in_redirect(
conn,
_identity,
%Auth.Context{type: :browser},
_encoded_fragment,
redirect_params
) do
account = conn.assigns.account
redirect_to = signed_in_path(account, redirect_params)
Phoenix.Controller.redirect(conn, to: redirect_to)
end
defp signed_in_path(%Accounts.Account{} = account, %{"redirect_to" => redirect_to})
when is_binary(redirect_to) do
if String.starts_with?(redirect_to, "/#{account.id}") or
String.starts_with?(redirect_to, "/#{account.slug}") do
redirect_to
else
signed_in_path(account)
end
end
defp signed_in_path(%Accounts.Account{} = account, _redirect_params) do
signed_in_path(account)
end
defp signed_in_path(%Accounts.Account{} = account) do
~p"/#{account}/sites"
end
@doc """
@@ -121,29 +162,58 @@ defmodule Web.Auth do
It clears all session data for safety. See `renew_session/1`.
"""
def sign_out(%Plug.Conn{} = conn) do
# token = Plug.Conn.get_session(conn, :session_token)
# subject && Accounts.delete_user_session_token(subject)
if live_socket_id = Plug.Conn.get_session(conn, :live_socket_id) do
conn.private.phoenix_endpoint.broadcast(live_socket_id, "disconnect", %{})
end
def sign_out(%Plug.Conn{} = conn, params) do
account_or_slug = Map.get(conn.assigns, :account) || params["account_id_or_slug"]
conn
|> renew_session()
|> sign_out_redirect(account_or_slug, params)
end
defp sign_out_redirect(
%{assigns: %{subject: %Auth.Subject{} = subject}} = conn,
account_or_slug,
params
) do
post_sign_out_url = post_sign_out_url(account_or_slug, params)
conn.private.phoenix_endpoint.broadcast("sessions:#{subject.token_id}", "disconnect", %{})
{:ok, _identity, redirect_url} = Auth.sign_out(subject, post_sign_out_url)
Phoenix.Controller.redirect(conn, external: redirect_url)
end
defp sign_out_redirect(conn, account_or_slug, params) do
post_sign_out_url = post_sign_out_url(account_or_slug, params)
Phoenix.Controller.redirect(conn, external: post_sign_out_url)
end
defp post_sign_out_url(_account_or_slug, %{"as" => "client", "state" => state}) do
"firezone://handle_client_sign_out_callback?state=#{state}"
end
defp post_sign_out_url(account_or_slug, _params) do
url(~p"/#{account_or_slug}")
end
@doc """
This function renews the session ID and erases the whole
session to avoid fixation attacks.
This function renews the session ID to avoid fixation attacks
and erases the session token from the sessions list.
"""
def renew_session(%Plug.Conn{} = conn) do
preferred_locale = Plug.Conn.get_session(conn, :preferred_locale)
account_id = if Map.get(conn.assigns, :account), do: conn.assigns.account.id
sessions =
Plug.Conn.get_session(conn, :sessions, [])
|> Enum.reject(fn {_, session_account_id, _} ->
session_account_id == account_id
end)
|> Enum.take(-1 * @remember_last_sessions)
conn
|> Plug.Conn.configure_session(renew: true)
|> Plug.Conn.clear_session()
|> Plug.Conn.put_session(:preferred_locale, preferred_locale)
|> Plug.Conn.put_session(:sessions, sessions)
end
###########################
@@ -151,15 +221,21 @@ defmodule Web.Auth do
###########################
def list_recent_account_ids(conn) do
conn = Plug.Conn.fetch_cookies(conn, signed: [@remember_me_cookie_name])
conn = Plug.Conn.fetch_cookies(conn, signed: [@recent_accounts_cookie_name])
if recent_account_ids = Map.get(conn.cookies, @remember_me_cookie_name) do
if recent_account_ids = Map.get(conn.cookies, @recent_accounts_cookie_name) do
{:ok, :erlang.binary_to_term(recent_account_ids, [:safe]), conn}
else
{:ok, [], conn}
end
end
defp prepend_recent_account_ids(conn, account_id) do
update_recent_account_ids(conn, fn recent_account_ids ->
[account_id] ++ recent_account_ids
end)
end
def update_recent_account_ids(conn, callback) when is_function(callback, 1) do
{:ok, recent_account_ids, conn} = list_recent_account_ids(conn)
@@ -171,9 +247,9 @@ defmodule Web.Auth do
Plug.Conn.put_resp_cookie(
conn,
@remember_me_cookie_name,
@recent_accounts_cookie_name,
recent_account_ids,
@remember_me_cookie_options
@recent_accounts_cookie_options
)
end
@@ -191,14 +267,53 @@ defmodule Web.Auth do
end
end
def fetch_account(%Plug.Conn{path_info: [account_id_or_slug | _]} = conn, _opts) do
case Accounts.fetch_account_by_id_or_slug(account_id_or_slug) do
{:ok, account} -> Plug.Conn.assign(conn, :account, account)
_ -> conn
end
end
def fetch_account(%Plug.Conn{} = conn, _opts) do
conn
end
@doc """
Fetches the session token from the session and assigns the subject to the connection.
"""
def fetch_subject_and_account(%Plug.Conn{} = conn, _opts) do
def fetch_subject(%Plug.Conn{} = conn, _opts) do
context = get_auth_context(conn, :browser)
with account when not is_nil(account) <- Map.get(conn.assigns, :account),
sessions <- Plug.Conn.get_session(conn, :sessions, []),
{:ok, encoded_fragment} <- fetch_token(sessions, account.id, context.type),
{:ok, subject} <- Auth.authenticate(encoded_fragment, context),
true <- subject.account.id == account.id do
conn
|> Plug.Conn.put_session(:live_socket_id, "sessions:#{subject.token_id}")
|> Plug.Conn.assign(:subject, subject)
else
{:error, :unauthorized} -> renew_session(conn)
_ -> conn
end
end
defp fetch_token(sessions, account_id, context_type) do
Enum.find(sessions, fn {session_context_type, session_account_id, _encoded_fragment} ->
session_context_type == context_type and session_account_id == account_id
end)
|> case do
{_context_type, _account_id, encoded_fragment} -> {:ok, encoded_fragment}
_ -> :error
end
end
def get_auth_context(%Plug.Conn{} = conn, type) do
{location_region, location_city, {location_lat, location_lon}} =
get_load_balancer_ip_location(conn)
context = %Auth.Context{
%Auth.Context{
type: type,
user_agent: Map.get(conn.assigns, :user_agent),
remote_ip: conn.remote_ip,
remote_ip_location_region: location_region,
@@ -206,21 +321,6 @@ defmodule Web.Auth do
remote_ip_location_lat: location_lat,
remote_ip_location_lon: location_lon
}
with token when not is_nil(token) <- Plug.Conn.get_session(conn, :session_token),
{:ok, subject} <-
Domain.Auth.sign_in(token, context),
{:ok, account} <-
Domain.Accounts.fetch_account_by_id_or_slug(
conn.path_params["account_id_or_slug"],
subject
) do
conn
|> Plug.Conn.assign(:account, account)
|> Plug.Conn.assign(:subject, subject)
else
_ -> conn
end
end
defp get_load_balancer_ip_location(%Plug.Conn{} = conn) do
@@ -306,15 +406,11 @@ defmodule Web.Auth do
Used for routes that require the user to not be authenticated.
"""
def redirect_if_user_is_authenticated(%Plug.Conn{} = conn, _opts) do
if conn.assigns[:subject] do
client_platform =
Plug.Conn.get_session(conn, :client_platform) || conn.query_params["client_platform"]
client_csrf_token =
Plug.Conn.get_session(conn, :client_csrf_token) || conn.query_params["client_csrf_token"]
if subject = conn.assigns[:subject] do
redirect_to = signed_in_path(subject.account)
conn
|> signed_in_redirect(conn.assigns[:subject], client_platform, client_csrf_token)
|> Phoenix.Controller.redirect(to: redirect_to)
|> Plug.Conn.halt()
else
conn
@@ -330,10 +426,13 @@ defmodule Web.Auth do
if conn.assigns[:subject] do
conn
else
redirect_params = maybe_store_return_to(conn)
conn
|> Phoenix.Controller.put_flash(:error, "You must log in to access this page.")
|> maybe_store_return_to()
|> Phoenix.Controller.redirect(to: ~p"/#{conn.path_params["account_id_or_slug"]}")
|> Phoenix.Controller.redirect(
to: ~p"/#{conn.path_params["account_id_or_slug"]}?#{redirect_params}"
)
|> Plug.Conn.halt()
end
end
@@ -354,10 +453,12 @@ defmodule Web.Auth do
end
defp maybe_store_return_to(%{method: "GET"} = conn) do
Plug.Conn.put_session(conn, :user_return_to, Phoenix.Controller.current_path(conn))
%{"redirect_to" => Phoenix.Controller.current_path(conn)}
end
defp maybe_store_return_to(conn), do: conn
defp maybe_store_return_to(_conn) do
%{}
end
###########################
## LiveView
@@ -378,7 +479,7 @@ defmodule Web.Auth do
Redirects to login page if there's no logged user.
* `:redirect_if_user_is_authenticated` - authenticates the user from the session.
Redirects to signed_in_path if there's a logged user.
Redirects to signed in path if there's a logged user.
* `:mount_account` - takes `account_id` from path params and loads the given account
into the socket assigns using the `subject` mounted via `:mount_subject`. This is useful
@@ -404,6 +505,7 @@ defmodule Web.Auth do
end
"""
def on_mount(:mount_subject, params, session, socket) do
socket = mount_account(socket, params, session)
{:cont, mount_subject(socket, params, session)}
end
@@ -412,6 +514,7 @@ defmodule Web.Auth do
end
def on_mount(:ensure_authenticated, params, session, socket) do
socket = mount_account(socket, params, session)
socket = mount_subject(socket, params, session)
if socket.assigns[:subject] do
@@ -427,6 +530,7 @@ defmodule Web.Auth do
end
def on_mount(:ensure_account_admin_user_actor, params, session, socket) do
socket = mount_account(socket, params, session)
socket = mount_subject(socket, params, session)
if socket.assigns[:subject].actor.type == :account_admin_user do
@@ -437,15 +541,19 @@ defmodule Web.Auth do
end
def on_mount(:redirect_if_user_is_authenticated, params, session, socket) do
socket = mount_account(socket, params, session)
socket = mount_subject(socket, params, session)
if socket.assigns[:subject] do
{:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket.assigns[:subject]))}
{:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket.assigns.account))}
else
{:cont, socket}
end
end
# TODO: we need to schedule socket expiration for this subject, so that when it expires
# LiveView socket will be disconnected. Otherwise, you can keep using the system as long as
# socket is active extending the session.
defp mount_subject(socket, _params, session) do
Phoenix.Component.assign_new(socket, :subject, fn ->
user_agent = Phoenix.LiveView.get_connect_info(socket, :user_agent)
@@ -455,7 +563,8 @@ defmodule Web.Auth do
{location_region, location_city, {location_lat, location_lon}} =
get_load_balancer_ip_location(x_headers)
context = %Domain.Auth.Context{
context = %Auth.Context{
type: :browser,
user_agent: user_agent,
remote_ip: real_ip,
remote_ip_location_region: location_region,
@@ -464,8 +573,12 @@ defmodule Web.Auth do
remote_ip_location_lon: location_lon
}
with token when not is_nil(token) <- session["session_token"],
{:ok, subject} <- Domain.Auth.sign_in(token, context) do
sessions = session["sessions"] || []
with account when not is_nil(account) <- Map.get(socket.assigns, :account),
{:ok, encoded_fragment} <- fetch_token(sessions, account.id, context.type),
{:ok, subject} <- Auth.authenticate(encoded_fragment, context),
true <- subject.account.id == account.id do
subject
else
_ -> nil
@@ -473,14 +586,10 @@ defmodule Web.Auth do
end)
end
defp mount_account(
%{assigns: %{subject: subject}} = socket,
%{"account_id_or_slug" => account_id_or_slug},
_session
) do
defp mount_account(socket, %{"account_id_or_slug" => account_id_or_slug}, _session) do
Phoenix.Component.assign_new(socket, :account, fn ->
with {:ok, account} <-
Domain.Accounts.fetch_account_by_id_or_slug(account_id_or_slug, subject) do
Accounts.fetch_account_by_id_or_slug(account_id_or_slug) do
account
else
_ -> nil

View File

@@ -939,7 +939,7 @@ defmodule Web.CoreComponents do
end
def identity_has_email?(identity) do
not is_nil(provider_email(identity)) || identity.provider.adapter == :email
not is_nil(provider_email(identity)) or identity.provider.adapter == :email
end
defp provider_email(identity) do
@@ -992,10 +992,10 @@ defmodule Web.CoreComponents do
def last_seen(assigns) do
~H"""
<code>
<code class="text-xs -mr-1">
<%= @schema.last_seen_remote_ip %>
</code>
<span class="text-neutral-500 inline-block">
<span class="text-neutral-500 inline-block text-xs">
<%= [
@schema.last_seen_remote_ip_location_region,
@schema.last_seen_remote_ip_location_city
@@ -1004,12 +1004,15 @@ defmodule Web.CoreComponents do
|> Enum.join(", ") %>
<a
:if={not is_nil(@schema.last_seen_remote_ip_location_lat)}
class="ml-1 text-accent-800"
:if={
not is_nil(@schema.last_seen_remote_ip_location_lat) and
not is_nil(@schema.last_seen_remote_ip_location_lon)
}
class="text-accent-800"
target="_blank"
href={"http://www.google.com/maps/place/#{@schema.last_seen_remote_ip_location_lat},#{@schema.last_seen_remote_ip_location_lon}"}
>
<.icon name="hero-arrow-top-right-on-square" class="-ml-1 mb-3 w-3 h-3" />
<.icon name="hero-arrow-top-right-on-square" class="mb-3 w-3 h-3" />
</a>
</span>
"""

View File

@@ -61,31 +61,13 @@ defmodule Web.TableComponents do
render = render_slot(action, @mapper.(@row))
not_empty_render?(render)
end) %>
<td :if={@actions != [] and show_actions?} class="px-4 py-3 flex items-center justify-end">
<button
id={"#{@id}-dropdown-button"}
data-dropdown-toggle={"#{@id}-dropdown"}
class={[
"inline-flex items-center p-0.5 text-sm text-center",
"text-neutral-500 hover:text-neutral-800 rounded"
]}
type="button"
>
<.icon name="hero-ellipsis-horizontal" class="w-5 h-5" />
</button>
<div
id={"#{@id}-dropdown"}
class={[
"hidden z-10 w-44 bg-white rounded divide-y divide-neutral-100",
"shadow border border-neutral-300"
]}
>
<ul class="py-1 text-sm text-neutral-700" aria-labelledby={"#{@id}-dropdown-button"}>
<li :for={action <- @actions}>
<%= render_slot(action, @mapper.(@row)) %>
</li>
</ul>
</div>
<td
:if={@actions != [] and show_actions?}
class="px-4 py-3 flex space-x-1 items-center justify-end"
>
<span :for={action <- @actions}>
<%= render_slot(action, @mapper.(@row)) %>
</span>
</td>
</tr>
"""

View File

@@ -4,11 +4,15 @@ defmodule Web.AuthController do
alias Domain.Auth.Adapters.OpenIDConnect
# This is the cookie which will be used to store the
# state and code verifier for OpenID Connect IdP's
# state during redirect to third-party website,
# eg. state and code verifier for OpenID Connect IdP's
@state_cookie_key_prefix "fz_auth_state_"
@state_cookie_options [
sign: true,
max_age: 300,
# encrypt: true,
max_age: 30 * 60,
# If `same_site` is set to `Strict` then the cookie will not be sent on
# IdP callback redirects, which will break the auth flow.
same_site: "Lax",
secure: true,
http_only: true
@@ -30,23 +34,15 @@ defmodule Web.AuthController do
}
} = params
) do
redirect_params = take_non_empty_params(params, ["client_platform", "client_csrf_token"])
redirect_params = Web.Auth.take_sign_in_params(params)
context_type = Web.Auth.fetch_auth_context_type!(redirect_params)
context = Web.Auth.get_auth_context(conn, context_type)
nonce = Web.Auth.fetch_token_nonce!(redirect_params)
with {:ok, provider} <- Domain.Auth.fetch_active_provider_by_id(provider_id),
{:ok, subject} <-
Domain.Auth.sign_in(
provider,
provider_identifier,
secret,
conn.assigns.user_agent,
conn.remote_ip
) do
client_platform = params["client_platform"]
client_csrf_token = params["client_csrf_token"]
conn
|> persist_recent_account(subject.account)
|> Web.Auth.signed_in_redirect(subject, client_platform, client_csrf_token)
{:ok, identity, encoded_fragment} <-
Domain.Auth.sign_in(provider, provider_identifier, nonce, secret, context) do
Web.Auth.signed_in(conn, provider, identity, context, encoded_fragment, redirect_params)
else
{:error, :not_found} ->
conn
@@ -75,48 +71,48 @@ defmodule Web.AuthController do
}
} = params
) do
conn =
with {:ok, provider} <- Domain.Auth.fetch_active_provider_by_id(provider_id),
{:ok, identity} <-
Domain.Auth.fetch_identity_by_provider_and_identifier(provider, provider_identifier,
preload: :account
),
{:ok, identity} <- Domain.Auth.Adapters.Email.request_sign_in_token(identity) do
sign_in_link_params =
take_non_empty_params(params, ["client_platform", "client_csrf_token"])
<<email_secret::binary-size(5), nonce::binary>> =
identity.provider_virtual_state.sign_in_token
{:ok, _} =
Web.Mailer.AuthEmail.sign_in_link_email(
identity,
email_secret,
conn.assigns.user_agent,
conn.remote_ip,
sign_in_link_params
)
|> Web.Mailer.deliver()
put_session(conn, :sign_in_nonce, nonce)
else
_ -> conn
end
redirect_params =
params
|> take_non_empty_params(["client_platform", "client_csrf_token"])
|> Map.put("provider_identifier", provider_identifier)
redirect_params = Web.Auth.take_sign_in_params(params)
conn = maybe_send_magic_link_email(conn, provider_id, provider_identifier, redirect_params)
redirect_params = Map.put(redirect_params, "provider_identifier", provider_identifier)
conn
|> maybe_put_resent_flash(params)
|> put_session(:client_platform, params["client_platform"])
|> put_session(:client_csrf_token, params["client_csrf_token"])
|> redirect(
to: ~p"/#{account_id_or_slug}/sign_in/providers/email/#{provider_id}?#{redirect_params}"
)
end
defp maybe_send_magic_link_email(conn, provider_id, provider_identifier, redirect_params) do
with {:ok, provider} <- Domain.Auth.fetch_active_provider_by_id(provider_id),
{:ok, identity} <-
Domain.Auth.fetch_active_identity_by_provider_and_identifier(
provider,
provider_identifier,
preload: :account
),
{:ok, identity} <- Domain.Auth.Adapters.Email.request_sign_in_token(identity) do
# We split the secret into two components, the first 5 bytes is the code we send to the user
# the rest is the secret we store in the cookie. This is to prevent authorization code injection
# attacks where you can trick user into logging in into a attacker account.
<<email_secret::binary-size(5), nonce::binary>> =
identity.provider_virtual_state.sign_in_token
{:ok, _} =
Web.Mailer.AuthEmail.sign_in_link_email(
identity,
email_secret,
conn.assigns.user_agent,
conn.remote_ip,
redirect_params
)
|> Web.Mailer.deliver()
put_auth_state(conn, provider.id, {nonce, redirect_params})
else
_ -> conn
end
end
defp maybe_put_resent_flash(conn, %{"resend" => "true"}),
do: put_flash(conn, :info, "Email was resent.")
@@ -136,43 +132,40 @@ defmodule Web.AuthController do
"secret" => email_secret
} = params
) do
client_platform = get_session(conn, :client_platform) || params["client_platform"]
client_csrf_token = get_session(conn, :client_csrf_token) || params["client_csrf_token"]
with {:ok, {nonce, redirect_params}, conn} <- fetch_auth_state(conn, provider_id) do
conn = delete_auth_state(conn, provider_id)
secret = String.downcase(email_secret) <> nonce
context_type = Web.Auth.fetch_auth_context_type!(redirect_params)
context = Web.Auth.get_auth_context(conn, context_type)
nonce = Web.Auth.fetch_token_nonce!(redirect_params)
redirect_params =
put_if_not_empty(:client_platform, client_platform)
|> put_if_not_empty(:client_csrf_token, client_csrf_token)
with {:ok, provider} <- Domain.Auth.fetch_active_provider_by_id(provider_id),
{:ok, identity, encoded_fragment} <-
Domain.Auth.sign_in(provider, identity_id, nonce, secret, context) do
Web.Auth.signed_in(conn, provider, identity, context, encoded_fragment, redirect_params)
else
{:error, :not_found} ->
conn
|> put_flash(:error, "You may not use this method to sign in.")
|> redirect(to: ~p"/#{account_id_or_slug}?#{redirect_params}")
with {:ok, provider} <- Domain.Auth.fetch_active_provider_by_id(provider_id),
nonce = get_session(conn, :sign_in_nonce) || "=",
{:ok, subject} <-
Domain.Auth.sign_in(
provider,
identity_id,
String.downcase(email_secret) <> nonce,
conn.assigns.user_agent,
conn.remote_ip
) do
conn
|> delete_session(:client_platform)
|> delete_session(:client_csrf_token)
|> delete_session(:sign_in_nonce)
|> persist_recent_account(subject.account)
|> Web.Auth.signed_in_redirect(subject, client_platform, client_csrf_token)
{:error, _reason} ->
redirect_params = Map.put(redirect_params, "provider_identifier", identity_id)
conn
|> put_flash(:error, "The sign in token is invalid or expired.")
|> redirect(
to:
~p"/#{account_id_or_slug}/sign_in/providers/email/#{provider_id}?#{redirect_params}"
)
end
else
{:error, :not_found} ->
conn
|> put_flash(:error, "You may not use this method to sign in.")
|> redirect(to: ~p"/#{account_id_or_slug}?#{redirect_params}")
{:error, _reason} ->
redirect_params = put_if_not_empty(redirect_params, "provider_identifier", identity_id)
:error ->
params = Web.Auth.take_sign_in_params(params)
conn
|> put_flash(:error, "The sign in token is invalid or expired.")
|> redirect(
to: ~p"/#{account_id_or_slug}/sign_in/providers/email/#{provider_id}?#{redirect_params}"
)
|> put_flash(:error, "The sign in token is expired.")
|> redirect(to: ~p"/#{account_id_or_slug}?#{params}")
end
end
@@ -188,20 +181,16 @@ defmodule Web.AuthController do
} = params
) do
with {:ok, provider} <- Domain.Auth.fetch_active_provider_by_id(provider_id) do
conn = put_session(conn, :client_platform, params["client_platform"])
conn = put_session(conn, :client_csrf_token, params["client_csrf_token"])
redirect_url =
url(~p"/#{provider.account_id}/sign_in/providers/#{provider.id}/handle_callback")
redirect_to_idp(conn, redirect_url, provider)
redirect_params = Web.Auth.take_sign_in_params(params)
redirect_to_idp(conn, redirect_url, provider, %{}, redirect_params)
else
{:error, :not_found} ->
redirect_params = take_non_empty_params(params, ["client_platform", "client_csrf_token"])
conn
|> put_flash(:error, "You may not use this method to sign in.")
|> redirect(to: ~p"/#{account_id_or_slug}?#{redirect_params}")
|> redirect(to: ~p"/#{account_id_or_slug}")
end
end
@@ -209,16 +198,14 @@ defmodule Web.AuthController do
%Plug.Conn{} = conn,
redirect_url,
%Domain.Auth.Provider{} = provider,
params \\ %{}
params \\ %{},
redirect_params \\ %{}
) do
{:ok, authorization_url, {state, code_verifier}} =
OpenIDConnect.authorization_uri(provider, redirect_url, params)
key = state_cookie_key(provider.id)
value = :erlang.term_to_binary({state, code_verifier})
conn
|> put_resp_cookie(key, value, @state_cookie_options)
|> put_auth_state(provider.id, {redirect_params, state, code_verifier})
|> redirect(external: authorization_url)
end
@@ -231,29 +218,22 @@ defmodule Web.AuthController do
"state" => state,
"code" => code
}) do
client_platform = get_session(conn, :client_platform)
client_csrf_token = get_session(conn, :client_csrf_token)
redirect_params =
put_if_not_empty(:client_platform, client_platform)
|> put_if_not_empty(:client_csrf_token, client_csrf_token)
with {:ok, code_verifier, conn} <- verify_state_and_fetch_verifier(conn, provider_id, state) do
with {:ok, redirect_params, code_verifier, conn} <-
verify_idp_state_and_fetch_verifier(conn, provider_id, state) do
payload = {
url(~p"/#{account_id}/sign_in/providers/#{provider_id}/handle_callback"),
code_verifier,
code
}
context_type = Web.Auth.fetch_auth_context_type!(redirect_params)
context = Web.Auth.get_auth_context(conn, context_type)
nonce = Web.Auth.fetch_token_nonce!(redirect_params)
with {:ok, provider} <- Domain.Auth.fetch_active_provider_by_id(provider_id),
{:ok, subject} <-
Domain.Auth.sign_in(provider, payload, conn.assigns.user_agent, conn.remote_ip) do
conn
|> delete_session(:client_platform)
|> delete_session(:client_csrf_token)
|> delete_session(:sign_in_nonce)
|> persist_recent_account(subject.account)
|> Web.Auth.signed_in_redirect(subject, client_platform, client_csrf_token)
{:ok, identity, encoded_fragment} <-
Domain.Auth.sign_in(provider, nonce, payload, context) do
Web.Auth.signed_in(conn, provider, identity, context, encoded_fragment, redirect_params)
else
{:error, :not_found} ->
conn
@@ -269,60 +249,46 @@ defmodule Web.AuthController do
{:error, :invalid_state, conn} ->
conn
|> put_flash(:error, "Your session has expired, please try again.")
|> redirect(to: ~p"/#{account_id}?#{redirect_params}")
|> redirect(to: ~p"/#{account_id}")
end
end
def verify_state_and_fetch_verifier(conn, provider_id, state) do
def verify_idp_state_and_fetch_verifier(conn, provider_id, state) do
with {:ok, {redirect_params, persisted_state, persisted_verifier}, conn} <-
fetch_auth_state(conn, provider_id),
:ok <- OpenIDConnect.ensure_states_equal(state, persisted_state) do
{:ok, redirect_params, persisted_verifier, delete_auth_state(conn, provider_id)}
else
_ -> {:error, :invalid_state, delete_auth_state(conn, provider_id)}
end
end
def sign_out(conn, params) do
Auth.sign_out(conn, params)
end
@doc false
def put_auth_state(conn, provider_id, state) do
key = state_cookie_key(provider_id)
value = :erlang.term_to_binary(state)
put_resp_cookie(conn, key, value, @state_cookie_options)
end
defp fetch_auth_state(conn, provider_id) do
key = state_cookie_key(provider_id)
conn = fetch_cookies(conn, signed: [key])
with {:ok, encoded_state} <- Map.fetch(conn.cookies, key),
{persisted_state, persisted_verifier} <- :erlang.binary_to_term(encoded_state, [:safe]),
:ok <- OpenIDConnect.ensure_states_equal(state, persisted_state) do
{:ok, persisted_verifier, delete_resp_cookie(conn, key, @state_cookie_options)}
else
_ -> {:error, :invalid_state, delete_resp_cookie(conn, key, @state_cookie_options)}
with {:ok, encoded_state} <- Map.fetch(conn.cookies, key) do
{:ok, :erlang.binary_to_term(encoded_state, [:safe]), conn}
end
end
defp delete_auth_state(conn, provider_id) do
key = state_cookie_key(provider_id)
delete_resp_cookie(conn, key, @state_cookie_options)
end
defp state_cookie_key(provider_id) do
@state_cookie_key_prefix <> provider_id
end
def sign_out(%{assigns: %{subject: subject}} = conn, %{
"account_id_or_slug" => account_id_or_slug
}) do
redirect_params = Map.take(conn.params, ["client_platform"])
{:ok, _identity, redirect_url} =
Domain.Auth.sign_out(subject.identity, url(~p"/#{account_id_or_slug}?#{redirect_params}"))
conn
|> Auth.sign_out()
|> redirect(external: redirect_url)
end
def sign_out(conn, %{"account_id_or_slug" => account_id_or_slug}) do
redirect_params = Map.take(conn.params, ["client_platform"])
conn
|> Auth.sign_out()
|> redirect(to: ~p"/#{account_id_or_slug}?#{redirect_params}")
end
defp persist_recent_account(conn, %Domain.Accounts.Account{} = account) do
Auth.update_recent_account_ids(conn, fn recent_account_ids ->
[account.id] ++ recent_account_ids
end)
end
defp take_non_empty_params(map, keys) do
map |> Map.take(keys) |> Map.reject(fn {_key, value} -> value in ["", nil] end)
end
defp put_if_not_empty(map \\ %{}, key, value)
defp put_if_not_empty(map, _key, ""), do: map
defp put_if_not_empty(map, _key, nil), do: map
defp put_if_not_empty(map, key, value), do: Map.put(map, key, value)
end

View File

@@ -3,6 +3,8 @@ defmodule Web.HomeController do
alias Domain.Accounts
def home(conn, params) do
signed_in_account_ids = conn |> get_session("sessions", []) |> Enum.map(&elem(&1, 0))
{accounts, conn} =
with {:ok, recent_account_ids, conn} <- Web.Auth.list_recent_account_ids(conn),
{:ok, accounts} <- Accounts.list_accounts_by_ids(recent_account_ids) do
@@ -16,21 +18,20 @@ defmodule Web.HomeController do
_other -> {[], conn}
end
redirect_params = take_non_empty_params(params, ["client_platform", "client_csrf_token"])
params = Web.Auth.take_sign_in_params(params)
conn
|> put_layout(html: {Web.Layouts, :public})
|> render("home.html", accounts: accounts, redirect_params: redirect_params)
|> render("home.html",
accounts: accounts,
signed_in_account_ids: signed_in_account_ids,
params: params
)
end
def redirect_to_sign_in(conn, %{"account_id_or_slug" => account_id_or_slug} = params) do
account_id_or_slug = String.downcase(account_id_or_slug)
redirect_params = take_non_empty_params(params, ["client_platform", "client_csrf_token"])
redirect(conn, to: ~p"/#{account_id_or_slug}?#{redirect_params}")
end
defp take_non_empty_params(map, keys) do
map |> Map.take(keys) |> Map.reject(fn {_key, value} -> value in ["", nil] end)
params = Web.Auth.take_sign_in_params(params)
redirect(conn, to: ~p"/#{account_id_or_slug}?#{params}")
end
end

View File

@@ -24,18 +24,14 @@ defmodule Web.HomeHTML do
<.account_button
:for={account <- @accounts}
account={account}
redirect_params={@redirect_params}
signed_in?={account.id in @signed_in_account_ids}
params={@params}
/>
</div>
<.separator :if={@accounts != []} />
<.form
:let={f}
for={%{}}
action={~p"/?#{@redirect_params}"}
class="space-y-4 lg:space-y-6"
>
<.form :let={f} for={%{}} action={~p"/?#{@params}"} class="space-y-4 lg:space-y-6">
<.input
field={f[:account_id_or_slug]}
type="text"
@@ -51,7 +47,10 @@ defmodule Web.HomeHTML do
</.button>
</.form>
<p
:if={Domain.Config.sign_up_enabled?() and is_nil(@redirect_params["client_platform"])}
:if={
Domain.Config.sign_up_enabled?() and
Web.Auth.fetch_auth_context_type!(@params) == :browser
}
class="py-2"
>
Don't have an account?
@@ -68,7 +67,7 @@ defmodule Web.HomeHTML do
def account_button(assigns) do
~H"""
<a href={~p"/#{@account}?#{@redirect_params}"} class={~w[
<a href={~p"/#{@account}?#{@params}"} class={~w[
w-full inline-flex items-center justify-center py-2.5 px-5
bg-white rounded
text-sm text-neutral-900
@@ -76,6 +75,10 @@ defmodule Web.HomeHTML do
hover:bg-neutral-100 hover:text-neutral-900
]}>
<%= @account.name %>
<span :if={@signed_in?} class="text-green-400 pl-1">
<.icon name="hero-shield-check" class="w-4 h-4" />
</span>
</a>
"""
end

View File

@@ -1,5 +1,6 @@
defmodule Web.Endpoint do
use Phoenix.Endpoint, otp_app: :web
import Web.Auth
if Application.compile_env(:domain, :sql_sandbox) do
plug Phoenix.Ecto.SQL.Sandbox
@@ -55,6 +56,8 @@ defmodule Web.Endpoint do
pass: ["*/*"],
json_decoder: Phoenix.json_library()
plug :fetch_user_agent
plug Web.Session
plug Web.Router

View File

@@ -12,13 +12,15 @@ defmodule Web.Actors.Edit do
groups = Enum.reject(groups, &Actors.group_synced?/1)
{:ok, socket,
temporary_assigns: [
actor: actor,
groups: groups,
form: to_form(changeset),
page_title: "Edit actor #{actor.name}"
]}
socket =
assign(socket,
actor: actor,
groups: groups,
form: to_form(changeset),
page_title: "Edit actor #{actor.name}"
)
{:ok, socket}
else
_other -> raise Web.LiveErrors.NotFoundError
end

View File

@@ -8,15 +8,16 @@ defmodule Web.Actors.Index do
with {:ok, actors} <-
Actors.list_actors(socket.assigns.subject, preload: [identities: :provider]),
{:ok, actor_groups} <- Actors.peek_actor_groups(actors, 3, socket.assigns.subject),
{:ok, providers} <-
Auth.list_providers_for_account(socket.assigns.account, socket.assigns.subject) do
{:ok, socket,
temporary_assigns: [
actors: actors,
actor_groups: actor_groups,
providers_by_id: Map.new(providers, &{&1.id, &1}),
page_title: "Actors"
]}
{:ok, providers} <- Auth.list_providers(socket.assigns.subject) do
socket =
assign(socket,
actors: actors,
actor_groups: actor_groups,
providers_by_id: Map.new(providers, &{&1.id, &1}),
page_title: "Actors"
)
{:ok, socket}
else
{:error, _reason} -> raise Web.LiveErrors.NotFoundError
end

View File

@@ -17,7 +17,7 @@ defmodule Web.Actors.ServiceAccounts.New do
form: to_form(changeset)
)
{:ok, socket}
{:ok, socket, temporary_assigns: [form: %Phoenix.HTML.Form{}]}
else
{:error, _reason} -> raise Web.LiveErrors.NotFoundError
end

View File

@@ -20,7 +20,7 @@ defmodule Web.Actors.ServiceAccounts.NewIdentity do
form: to_form(changeset)
)
{:ok, socket}
{:ok, socket, temporary_assigns: [form: %Phoenix.HTML.Form{}]}
else
_other -> raise Web.LiveErrors.NotFoundError
end
@@ -103,7 +103,13 @@ defmodule Web.Actors.ServiceAccounts.NewIdentity do
attrs,
socket.assigns.subject
) do
{:ok, encoded_token} = Auth.create_access_token_for_identity(identity)
{:ok, encoded_token} =
Auth.create_service_account_token(
socket.assigns.provider,
identity,
socket.assigns.subject
)
{:noreply, assign(socket, encoded_token: encoded_token)}
else
{:error, changeset} ->

View File

@@ -1,10 +1,10 @@
defmodule Web.Actors.Show do
use Web, :live_view
import Web.Actors.Components
alias Domain.{Auth, Flows, Clients}
alias Domain.{Auth, Tokens, Flows, Clients}
alias Domain.Actors
def mount(%{"id" => id}, _session, socket) do
def mount(%{"id" => id}, _token, socket) do
with {:ok, actor} <-
Actors.fetch_actor_by_id(id, socket.assigns.subject,
preload: [
@@ -13,6 +13,10 @@ defmodule Web.Actors.Show do
clients: []
]
),
{:ok, tokens} <-
Tokens.list_tokens_for(actor, socket.assigns.subject,
preload: [identity: [:provider, created_by_identity: [:actor]]]
),
{:ok, flows} <-
Flows.list_flows_for(actor, socket.assigns.subject,
preload: [gateway: [:group], client: [], policy: [:resource, :actor_group]]
@@ -24,6 +28,7 @@ defmodule Web.Actors.Show do
assign(socket,
actor: actor,
flows: flows,
tokens: tokens,
page_title: actor.name,
flow_activities_enabled?: Domain.Config.flow_activities_enabled?()
)}
@@ -93,6 +98,7 @@ defmodule Web.Actors.Show do
</.section>
<.section>
<!-- TODO: do we need them for service accounts? -->
<:title>Authentication Identities</:title>
<:action :if={is_nil(@actor.deleted_at)}>
<.add_button
@@ -116,7 +122,6 @@ defmodule Web.Actors.Show do
<:col :let={identity} label="IDENTITY" sortable="false">
<.identity_identifier account={@account} identity={identity} />
</:col>
<:col :let={identity} label="CREATED" sortable="false">
<.created_by account={@account} schema={identity} />
</:col>
@@ -124,29 +129,27 @@ defmodule Web.Actors.Show do
<.relative_datetime datetime={identity.last_seen_at} />
</:col>
<:action :let={identity}>
<button
<.button
:if={identity_has_email?(identity)}
icon="hero-envelope"
phx-click="send_welcome_email"
phx-value-id={identity.id}
class={[
"block w-full py-2 px-4 hover:bg-neutral-100"
]}
>
Send Welcome Email
</button>
</.button>
</:action>
<:action :let={identity}>
<button
<.delete_button
:if={identity.created_by != :provider}
phx-click="delete_identity"
data-confirm="Are you sure want to delete this identity?"
data-confirm="Are you sure you want to delete this identity?"
phx-value-id={identity.id}
class={[
"block w-full py-2 px-4 hover:bg-neutral-100"
]}
>
Delete
</button>
</.delete_button>
</:action>
<:empty>
<div class="flex justify-center text-center text-neutral-500 p-4">
@@ -175,6 +178,59 @@ defmodule Web.Actors.Show do
</:content>
</.section>
<.section>
<:title>Authentication Tokens</:title>
<:action :if={is_nil(@actor.deleted_at)}>
<.delete_button
phx-click="revoke_all_tokens"
data-confirm="Are you sure you want to revoke all tokens? This will immediately sign the actor out of all clients."
>
Revoke All
</.delete_button>
</:action>
<:content>
<.table id="tokens" rows={@tokens} row_id={&"tokens-#{&1.id}"}>
<:col :let={token} label="TYPE" sortable="false">
<%= token.type %>
</:col>
<:col :let={token} label="IDENTITY" sortable="false">
<.identity_identifier account={@account} identity={token.identity} />
</:col>
<:col :let={token} label="CREATED">
<.created_by account={@account} schema={token} />
</:col>
<:col :let={token} label="LAST USED (IP)">
<p>
<.relative_datetime datetime={token.last_seen_at} />
</p>
<p :if={not is_nil(token.last_seen_at)}>
<.last_seen schema={token} />
</p>
</:col>
<:col :let={token} label="EXPIRES">
<.relative_datetime datetime={token.expires_at} />
</:col>
<:action :let={token}>
<.delete_button
phx-click="revoke_token"
data-confirm="Are you sure you want to revoke this token?"
phx-value-id={token.id}
class={[
"block w-full py-2 px-4 hover:bg-gray-100"
]}
>
Revoke
</.delete_button>
</:action>
<:empty>
<div class="text-center text-neutral-500 p-4">No authentication tokens to display.</div>
</:empty>
</.table>
</:content>
</.section>
<.section>
<:title>Clients</:title>
@@ -376,6 +432,39 @@ defmodule Web.Actors.Show do
{:noreply, socket}
end
def handle_event("revoke_all_tokens", _params, socket) do
{:ok, deleted_count} = Tokens.delete_tokens_for(socket.assigns.actor, socket.assigns.subject)
{:ok, tokens} =
Tokens.list_tokens_for(socket.assigns.actor, socket.assigns.subject,
preload: [identity: [:provider, created_by_identity: [:actor]]]
)
socket =
socket
|> put_flash(:info, "#{deleted_count} token(s) were revoked.")
|> assign(tokens: tokens)
{:noreply, socket}
end
def handle_event("revoke_token", %{"id" => id}, socket) do
{:ok, token} = Tokens.fetch_token_by_id(id, socket.assigns.subject)
{:ok, _token} = Tokens.delete_token(token, socket.assigns.subject)
{:ok, tokens} =
Tokens.list_tokens_for(socket.assigns.actor, socket.assigns.subject,
preload: [identity: [:provider, created_by_identity: [:actor]]]
)
socket =
socket
|> put_flash(:info, "Token was revoked.")
|> assign(tokens: tokens)
{:noreply, socket}
end
defp last_seen_at(identities) do
identities
|> Enum.reject(&is_nil(&1.last_seen_at))

View File

@@ -13,7 +13,7 @@ defmodule Web.Actors.Users.New do
form: to_form(changeset)
)
{:ok, socket}
{:ok, socket, temporary_assigns: [form: %Phoenix.HTML.Form{}]}
else
{:error, _reason} -> raise Web.LiveErrors.NotFoundError
end

View File

@@ -30,7 +30,7 @@ defmodule Web.Actors.Users.NewIdentity do
form: to_form(changeset)
)
{:ok, socket}
{:ok, socket, temporary_assigns: [form: %Phoenix.HTML.Form{}]}
else
_other -> raise Web.LiveErrors.NotFoundError
end

View File

@@ -6,7 +6,8 @@ defmodule Web.Clients.Edit do
with {:ok, client} <- Clients.fetch_client_by_id(id, socket.assigns.subject),
nil <- client.deleted_at do
changeset = Clients.change_client(client)
{:ok, assign(socket, client: client, form: to_form(changeset))}
socket = assign(socket, client: client, form: to_form(changeset))
{:ok, socket, temporary_assigns: [form: %Phoenix.HTML.Form{}]}
else
_other -> raise Web.LiveErrors.NotFoundError
end

View File

@@ -13,7 +13,7 @@ defmodule Web.Flows.Show do
resource: []
]
) do
{:ok, socket, temporary_assigns: [flow: flow]}
{:ok, assign(socket, flow: flow)}
else
{:error, _reason} -> raise Web.LiveErrors.NotFoundError
end

View File

@@ -8,7 +8,8 @@ defmodule Web.Groups.Edit do
nil <- group.deleted_at,
false <- Actors.group_synced?(group) do
changeset = Actors.change_group(group)
{:ok, assign(socket, group: group, form: to_form(changeset))}
socket = assign(socket, group: group, form: to_form(changeset))
{:ok, socket, temporary_assigns: [form: %Phoenix.HTML.Form{}]}
else
_other -> raise Web.LiveErrors.NotFoundError
end

View File

@@ -9,12 +9,14 @@ defmodule Web.Groups.Index do
preload: [:provider, created_by_identity: [:actor]]
),
{:ok, group_actors} <- Actors.peek_group_actors(groups, 3, socket.assigns.subject) do
{:ok, socket,
temporary_assigns: [
page_title: "Groups",
groups: groups,
group_actors: group_actors
]}
socket =
assign(socket,
page_title: "Groups",
groups: groups,
group_actors: group_actors
)
{:ok, socket}
else
{:error, _reason} -> raise Web.LiveErrors.NotFoundError
end

View File

@@ -4,7 +4,9 @@ defmodule Web.Groups.New do
def mount(_params, _session, socket) do
changeset = Actors.new_group()
{:ok, assign(socket, form: to_form(changeset))}
{:ok, assign(socket, form: to_form(changeset)),
temporary_assigns: [form: %Phoenix.HTML.Form{}]}
end
def render(assigns) do

View File

@@ -7,7 +7,8 @@ defmodule Web.RelayGroups.Edit do
{:ok, group} <- Relays.fetch_group_by_id(id, socket.assigns.subject),
nil <- group.deleted_at do
changeset = Relays.change_group(group)
{:ok, assign(socket, group: group, form: to_form(changeset))}
socket = assign(socket, group: group, form: to_form(changeset))
{:ok, socket, temporary_assigns: [form: %Phoenix.HTML.Form{}]}
else
_other -> raise Web.LiveErrors.NotFoundError
end

View File

@@ -5,7 +5,9 @@ defmodule Web.RelayGroups.New do
def mount(_params, _session, socket) do
with true <- Domain.Config.self_hosted_relays_enabled?() do
changeset = Relays.new_group()
{:ok, assign(socket, form: to_form(changeset))}
{:ok, assign(socket, form: to_form(changeset)),
temporary_assigns: [form: %Phoenix.HTML.Form{}]}
else
_other -> raise Web.LiveErrors.NotFoundError
end

View File

@@ -138,9 +138,6 @@ defmodule Web.Settings.IdentityProviders.Components do
def view_provider(account, %{adapter: :google_workspace} = provider),
do: ~p"/#{account}/settings/identity_providers/google_workspace/#{provider}"
def view_provider(account, %{adapter: :saml} = provider),
do: ~p"/#{account}/settings/identity_providers/saml/#{provider}"
def sync_status(%{provider: %{provisioner: :custom}} = assigns) do
~H"""
<div :if={not is_nil(@provider.last_synced_at)} class="flex items-center">

View File

@@ -9,7 +9,7 @@ defmodule Web.Settings.IdentityProviders.GoogleWorkspace.Connect do
def redirect_to_idp(conn, %{"provider_id" => provider_id}) do
account = conn.assigns.account
with {:ok, provider} <- Domain.Auth.fetch_provider_by_id(provider_id) do
with {:ok, provider} <- Domain.Auth.fetch_provider_by_id(provider_id, conn.assigns.subject) do
redirect_url =
url(
~p"/#{provider.account_id}/settings/identity_providers/google_workspace/#{provider}/handle_callback"
@@ -32,8 +32,8 @@ defmodule Web.Settings.IdentityProviders.GoogleWorkspace.Connect do
account = conn.assigns.account
subject = conn.assigns.subject
with {:ok, code_verifier, conn} <-
Web.AuthController.verify_state_and_fetch_verifier(conn, provider_id, state) do
with {:ok, _redirect_params, code_verifier, conn} <-
Web.AuthController.verify_idp_state_and_fetch_verifier(conn, provider_id, state) do
payload = {
url(
~p"/#{account.id}/settings/identity_providers/google_workspace/#{provider_id}/handle_callback"
@@ -42,7 +42,7 @@ defmodule Web.Settings.IdentityProviders.GoogleWorkspace.Connect do
code
}
with {:ok, provider} <- Domain.Auth.fetch_provider_by_id(provider_id),
with {:ok, provider} <- Domain.Auth.fetch_provider_by_id(provider_id, conn.assigns.subject),
{:ok, identity} <-
GoogleWorkspace.verify_and_upsert_identity(subject.actor, provider, payload),
attrs = %{

View File

@@ -13,7 +13,7 @@ defmodule Web.Settings.IdentityProviders.GoogleWorkspace.Edit do
form: to_form(changeset)
)
{:ok, socket}
{:ok, socket, temporary_assigns: [form: %Phoenix.HTML.Form{}]}
else
{:error, _reason} -> raise Web.LiveErrors.NotFoundError
end

View File

@@ -19,7 +19,7 @@ defmodule Web.Settings.IdentityProviders.GoogleWorkspace.New do
form: to_form(changeset)
)
{:ok, socket}
{:ok, socket, temporary_assigns: [form: %Phoenix.HTML.Form{}]}
end
def render(assigns) do

View File

@@ -4,21 +4,22 @@ defmodule Web.Settings.IdentityProviders.Index do
alias Domain.{Auth, Actors}
def mount(_params, _session, socket) do
account = socket.assigns.account
subject = socket.assigns.subject
with {:ok, providers} <- Auth.list_providers_for_account(account, subject),
with {:ok, providers} <- Auth.list_providers(subject),
{:ok, identities_count_by_provider_id} <-
Auth.fetch_identities_count_grouped_by_provider_id(subject),
{:ok, groups_count_by_provider_id} <-
Actors.fetch_groups_count_grouped_by_provider_id(subject) do
{:ok, socket,
temporary_assigns: [
identities_count_by_provider_id: identities_count_by_provider_id,
groups_count_by_provider_id: groups_count_by_provider_id,
providers: providers,
page_title: "Identity Providers Settings"
]}
socket =
assign(socket,
identities_count_by_provider_id: identities_count_by_provider_id,
groups_count_by_provider_id: groups_count_by_provider_id,
providers: providers,
page_title: "Identity Providers Settings"
)
{:ok, socket}
else
_ -> raise Web.LiveErrors.NotFoundError
end

View File

@@ -4,15 +4,8 @@ defmodule Web.Settings.IdentityProviders.New do
def mount(_params, _session, socket) do
{:ok, adapters} = Auth.list_provider_adapters()
socket =
socket
|> assign(:form, %{})
{:ok, socket,
temporary_assigns: [
adapters: adapters
]}
socket = assign(socket, form: %{}, adapters: adapters)
{:ok, socket}
end
def handle_event("submit", %{"next" => next}, socket) do

View File

@@ -9,7 +9,7 @@ defmodule Web.Settings.IdentityProviders.OpenIDConnect.Connect do
def redirect_to_idp(conn, %{"provider_id" => provider_id}) do
account = conn.assigns.account
with {:ok, provider} <- Domain.Auth.fetch_provider_by_id(provider_id) do
with {:ok, provider} <- Domain.Auth.fetch_provider_by_id(provider_id, conn.assigns.subject) do
redirect_url =
url(
~p"/#{provider.account_id}/settings/identity_providers/openid_connect/#{provider.id}/handle_callback"
@@ -32,8 +32,8 @@ defmodule Web.Settings.IdentityProviders.OpenIDConnect.Connect do
account = conn.assigns.account
subject = conn.assigns.subject
with {:ok, code_verifier, conn} <-
Web.AuthController.verify_state_and_fetch_verifier(conn, provider_id, state) do
with {:ok, _redirect_params, code_verifier, conn} <-
Web.AuthController.verify_idp_state_and_fetch_verifier(conn, provider_id, state) do
payload = {
url(
~p"/#{account.id}/settings/identity_providers/openid_connect/#{provider_id}/handle_callback"
@@ -42,7 +42,7 @@ defmodule Web.Settings.IdentityProviders.OpenIDConnect.Connect do
code
}
with {:ok, provider} <- Domain.Auth.fetch_provider_by_id(provider_id),
with {:ok, provider} <- Domain.Auth.fetch_provider_by_id(provider_id, conn.assigns.subject),
{:ok, _identity} <-
OpenIDConnect.verify_and_upsert_identity(subject.actor, provider, payload),
attrs = %{adapter_state: %{status: :connected}, disabled_at: nil},
@@ -95,8 +95,8 @@ defmodule Web.Settings.IdentityProviders.OpenIDConnect.Connect do
}) do
account = conn.assigns.account
with {:ok, _code_verifier, conn} <-
Web.AuthController.verify_state_and_fetch_verifier(conn, provider_id, state) do
with {:ok, _redirect_params, _code_verifier, conn} <-
Web.AuthController.verify_idp_state_and_fetch_verifier(conn, provider_id, state) do
conn
|> put_flash(:error, "Your IdP returned an error (" <> error <> "): " <> error_description)
|> redirect(to: ~p"/#{account}/settings/identity_providers/openid_connect/#{provider_id}")

View File

@@ -13,7 +13,7 @@ defmodule Web.Settings.IdentityProviders.OpenIDConnect.Edit do
form: to_form(changeset)
)
{:ok, socket}
{:ok, socket, temporary_assigns: [form: %Phoenix.HTML.Form{}]}
else
{:error, _reason} -> raise Web.LiveErrors.NotFoundError
end

View File

@@ -19,7 +19,7 @@ defmodule Web.Settings.IdentityProviders.OpenIDConnect.New do
form: to_form(changeset)
)
{:ok, socket}
{:ok, socket, temporary_assigns: [form: %Phoenix.HTML.Form{}]}
end
def render(assigns) do

View File

@@ -1,220 +0,0 @@
defmodule Web.Settings.IdentityProviders.SAML.Components do
@moduledoc """
Provides components that can be shared across forms.
"""
use Phoenix.Component
use Web, :verified_routes
import Web.CoreComponents
import Web.FormComponents
alias Phoenix.LiveView.JS
@doc """
Conditionally renders form fields corresponding to a given provisioning strategy type.
## Examples
<.provisioning_strategy_form form={%{
provisioning_strategy: "jit",
jit_user_filter_type: "email_allowlist",
jit_user_filter_value: "jamil@foo.dev,andrew@foo.dev",
jit_extract_groups: true
}} />
<.provisioning_strategy_form form={@form} />
"""
attr :form, :map, required: true, doc: "The form to which this component belongs."
def provisioning_strategy_form(assigns) do
~H"""
<h2 class="mb-4 text-xl text-neutral-900">Provisioning strategy</h2>
<ul class="mb-4 w-full sm:flex border border-neutral-200 rounded">
<li class="w-full border-b border-neutral-200 sm:border-b-0 sm:border-r">
<div class="text-lg font-medium p-3">
<.input
id="provisioning_strategy_jit"
label="Just-in-time"
type="radio"
value="jit"
field={@form[:provisioning_strategy]}
checked={@form[:provisioning_strategy].value == "jit"}
required
/>
</div>
<p class="px-4 py-2 text-sm text-neutral-500">
Provision users and groups on the fly when they first sign in.
</p>
</li>
<li class="w-full border-b border-neutral-200 sm:border-b-0 sm:border-r">
<div class="text-lg font-medium p-3">
<.input
id="provisioning_strategy_scim"
label="SCIM 2.0"
type="radio"
value="scim"
field={@form[:provisioning_strategy]}
checked={@form[:provisioning_strategy].value == "scim"}
required
/>
</div>
<p class="px-4 py-2 text-sm text-neutral-500">
Provision users using the SCIM 2.0 protocol. Requires a supported identity provider.
</p>
</li>
<li class="w-full border-b border-neutral-200 sm:border-b-0 sm:border-r">
<div class="text-lg font-medium p-3">
<.input
id="provisioning_strategy_manual"
label="Manual"
type="radio"
value="manual"
field={@form[:provisioning_strategy]}
checked={@form[:provisioning_strategy].value == "manual"}
required
/>
</div>
<p class="px-4 py-2 text-sm text-neutral-500">
Disable automatic provisioning and manually manage users and groups.
</p>
</li>
</ul>
<%= if @form[:provisioning_strategy].value == "jit" do %>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 mb-4">
<div>
<.input
label="User filter"
type="select"
field={@form[:jit_user_filter_type]}
options={[
[value: "email_allowlist", key: "Email allowlist"],
[value: "same_domain", key: "Allow from same email domain"],
[value: "allow_all", key: "Allow any authenticated user"]
]}
>
</.input>
</div>
<div>
<%= if @form[:jit_user_filter_type].value == "email_allowlist" do %>
<.input
label="Email allowlist"
autocomplete="off"
field={@form[:jit_user_filter_value]}
placeholder="Comma-delimited list of email addresses"
required
/>
<% end %>
</div>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 mb-4">
<div class="col-offset-1">
<.input
label="Extract group membership information"
type="checkbox"
field={@form[:jit_extract_groups]}
/>
<p class="ml-8 text-sm text-neutral-500">
<.link
class="text-accent-500 hover:underline"
href="https://www.firezone.dev/kb/authenticate/user-group-sync?utm_source=product"
target="_blank"
>
Read more about group extraction.
<.icon name="hero-arrow-top-right-on-square" class="-ml-1 mb-3 w-3 h-3" />
</.link>
</p>
</div>
</div>
<% end %>
"""
end
def provisioning_status(assigns) do
~H"""
<!-- Provisioning details -->
<.header>
<:title>Provisioning</:title>
</.header>
<div class="bg-white overflow-hidden">
<table class="w-full text-sm text-left text-neutral-500">
<tbody>
<tr class="border-b border-neutral-200">
<th
scope="row"
class="text-right px-6 py-4 font-medium text-neutral-900 whitespace-nowrap bg-neutral-50"
>
Type
</th>
<td class="px-6 py-4">
SCIM 2.0
</td>
</tr>
<tr class="border-b border-neutral-200">
<th
scope="row"
class="text-right px-6 py-4 font-medium text-neutral-900 whitespace-nowrap bg-neutral-50"
>
Endpoint
</th>
<td class="px-6 py-4">
<div class="flex items-center">
<button
phx-click={JS.dispatch("phx:copy", to: "#endpoint-value")}
title="Copy Endpoint"
class="text-accent-500"
>
<.icon name="hero-document-duplicate" class="w-5 h-5 mr-1" />
</button>
<code id="endpoint-value" data-copy={"/#{@account}/scim/v2"}>
<%= "/#{@account}/scim/v2" %>
</code>
</div>
</td>
</tr>
<tr class="border-b border-neutral-200">
<th
scope="row"
class="text-right px-6 py-4 font-medium text-neutral-900 whitespace-nowrap bg-neutral-50"
>
Token
</th>
<td class="px-6 py-4">
<div class="flex items-center">
<button
phx-click={JS.dispatch("phx:copy", to: "#visible-token")}
title="Copy SCIM token"
class="text-accent-500"
>
<.icon name="hero-document-duplicate" class="w-5 h-5 mr-1" />
</button>
<button
phx-click={toggle_scim_token()}
title="Show SCIM token"
class="text-accent-500"
>
<.icon name="hero-eye" class="w-5 h-5 mr-1" />
</button>
<span id="hidden-token">
•••••••••••••••••••••••••••••••••••••••••••••
</span>
<span
id="visible-token"
style="display: none"
data-copy={@identity_provider.scim_token}
>
<code><%= @identity_provider.scim_token %></code>
</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
"""
end
def toggle_scim_token(js \\ %JS{}) do
js
|> JS.toggle(to: "#visible-token")
|> JS.toggle(to: "#hidden-token")
end
end

View File

@@ -1,115 +0,0 @@
defmodule Web.Settings.IdentityProviders.SAML.New do
use Web, :live_view
import Web.Settings.IdentityProviders.SAML.Components
# TODO: Use a changeset for this
@form_initializer %{
"type" => "saml",
"scopes" => "openid profile email offline_access",
"provisioning_strategy" => "scim",
"saml_sign_requests" => true,
"saml_sign_metadata" => true,
"saml_require_signed_assertions" => true,
"saml_require_signed_envelopes" => true
}
def mount(_params, _session, socket) do
{:ok, assign(socket, form: to_form(@form_initializer))}
end
def render(assigns) do
~H"""
<.breadcrumbs account={@account}>
<.breadcrumb path={~p"/#{@account}/settings/identity_providers"}>
Identity Providers Settings
</.breadcrumb>
<.breadcrumb path={~p"/#{@account}/settings/identity_providers/new"}>
Create Identity Provider
</.breadcrumb>
<.breadcrumb path={~p"/#{@account}/settings/identity_providers/saml/new"}>SAML</.breadcrumb>
</.breadcrumbs>
<.section>
<:title>
Add a new SAML Identity Provider
</:title>
<:content>
<div class="max-w-2xl px-4 py-8 mx-auto lg:py-16">
<.form for={@form} id="saml-form" phx-change="change" phx-submit="submit">
<h2 class="mb-4 text-xl font-bold text-neutral-900">SAML configuration</h2>
<div class="grid gap-4 mb-4 sm:grid-cols-1 sm:gap-6 sm:mb-6">
<div>
<.input
label="Name"
autocomplete="off"
field={@form[:name]}
class={[
"bg-neutral-50 border border-neutral-300 text-neutral-900 text-sm rounded",
"block w-full p-2.5"
]}
placeholder="Name this identity provider"
required
/>
<p class="mt-2 text-xs text-neutral-500">
A friendly name for this identity provider. This will be displayed to end-users.
</p>
</div>
<div>
<.input
label="Metadata"
type="textarea"
field={@form[:metadata]}
placeholder="SAML XML Metadata from your identity provider"
required
/>
</div>
<div>
<.input label="Sign requests" type="checkbox" field={@form[:saml_sign_requests]} />
</div>
<div>
<.input label="Sign metadata" type="checkbox" field={@form[:saml_sign_metadata]} />
</div>
<div>
<.input
label="Require signed assertions"
type="checkbox"
field={@form[:saml_require_signed_assertions]}
/>
</div>
<div>
<.input
label="Require signed envelopes"
type="checkbox"
field={@form[:saml_require_signed_envelopes]}
/>
</div>
</div>
<.provisioning_strategy_form form={@form} />
<.submit_button>
Create Identity Provider
</.submit_button>
</.form>
</div>
</:content>
</.section>
"""
end
def handle_event("change", params, socket) do
# TODO: Validations
# changeset = ProvisioningStrategies.changeset(%ProvisioningStrategy{}, params)
{:noreply, assign(socket, form: to_form(params))}
end
def handle_event("submit", _params, socket) do
# TODO: Create identity provider
idp = %{id: "DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}
{:noreply,
push_navigate(socket,
to: ~p"/#{socket.assigns.subject.account}/settings/identity_providers/saml/#{idp.id}"
)}
end
end

View File

@@ -1,168 +0,0 @@
defmodule Web.Settings.IdentityProviders.SAML.Show do
use Web, :live_view
alias Domain.Auth
def mount(%{"provider_id" => provider_id}, _session, socket) do
with {:ok, provider} <-
Auth.fetch_active_provider_by_id(provider_id, socket.assigns.subject,
preload: [created_by_identity: [:actor]]
) do
{:ok, assign(socket, provider: provider)}
else
_ -> raise Web.LiveErrors.NotFoundError
end
end
def render(assigns) do
~H"""
<.breadcrumbs account={@account}>
<.breadcrumb path={~p"/#{@account}/settings/identity_providers"}>
Identity Providers Settings
</.breadcrumb>
<.breadcrumb path={
~p"/#{@account}/settings/identity_providers/saml/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"
}>
<%= @provider.name %>
</.breadcrumb>
</.breadcrumbs>
<.section>
<:title>
Viewing Identity Provider <code><%= @provider.name %></code>
</:title>
<:action>
<.edit_button navigate={
~p"/#{@account}/settings/identity_providers/saml/#{@provider.id}/edit"
}>
Edit Identity Provider
</.edit_button>
</:action>
<:content>
<.header>
<:title>Details</:title>
</.header>
<.flash_group flash={@flash} />
<div class="bg-white overflow-hidden">
<table class="w-full text-sm text-left text-neutral-500">
<tbody>
<tr class="border-b border-neutral-200">
<th
scope="row"
class="text-right px-6 py-4 font-medium text-neutral-900 whitespace-nowrap bg-neutral-50"
>
Name
</th>
<td class="px-6 py-4">
<%= @provider.name %>
</td>
</tr>
<tr class="border-b border-neutral-200">
<th
scope="row"
class="text-right px-6 py-4 font-medium text-neutral-900 whitespace-nowrap bg-neutral-50"
>
Type
</th>
<td class="px-6 py-4">
SAML 2.0
</td>
</tr>
<tr class="border-b border-neutral-200">
<th
scope="row"
class="text-right px-6 py-4 font-medium text-neutral-900 whitespace-nowrap bg-neutral-50"
>
Sign requests
</th>
<td class="px-6 py-4">
Yes
</td>
</tr>
<tr class="border-b border-neutral-200">
<th
scope="row"
class="text-right px-6 py-4 font-medium text-neutral-900 whitespace-nowrap bg-neutral-50"
>
Sign metadata
</th>
<td class="px-6 py-4">
Yes
</td>
</tr>
<tr class="border-b border-neutral-200">
<th
scope="row"
class="text-right px-6 py-4 font-medium text-neutral-900 whitespace-nowrap bg-neutral-50"
>
Require signed assertions
</th>
<td class="px-6 py-4">
Yes
</td>
</tr>
<tr class="border-b border-neutral-200">
<th
scope="row"
class="text-right px-6 py-4 font-medium text-neutral-900 whitespace-nowrap bg-neutral-50"
>
Require signed envelopes
</th>
<td class="px-6 py-4">
Yes
</td>
</tr>
<tr class="border-b border-neutral-200">
<th
scope="row"
class="text-right px-6 py-4 font-medium text-neutral-900 whitespace-nowrap bg-neutral-50"
>
Base URL
</th>
<td class="px-6 py-4">
Yes
</td>
</tr>
<tr class="border-b border-neutral-200">
<th
scope="row"
class="text-right px-6 py-4 font-medium text-neutral-900 whitespace-nowrap bg-neutral-50"
>
Created
</th>
<td class="px-6 py-4">
<.created_by account={@account} schema={@provider} />
</td>
</tr>
</tbody>
</table>
</div>
</:content>
</.section>
<.section>
<:title>
Danger zone
</:title>
<:action>
<.delete_button
data-confirm="Are you sure want to delete this provider along with all related data?"
phx-click="delete"
>
Delete Identity Provider
</.delete_button>
</:action>
<:content></:content>
</.section>
"""
end
def handle_event("delete", _params, socket) do
{:ok, _provider} = Auth.delete_provider(socket.assigns.provider, socket.assigns.subject)
{:noreply,
push_navigate(socket, to: ~p"/#{socket.assigns.account}/settings/identity_providers")}
end
end

View File

@@ -2,40 +2,43 @@ defmodule Web.SignIn do
use Web, {:live_view, layout: {Web.Layouts, :public}}
alias Domain.{Auth, Accounts}
def mount(%{"account_id_or_slug" => account_id_or_slug} = params, session, socket) do
@root_adapters_whitelist [:email, :userpass, :openid_connect]
def mount(%{"account_id_or_slug" => account_id_or_slug} = params, _session, socket) do
with {:ok, account} <- Accounts.fetch_account_by_id_or_slug(account_id_or_slug),
{:ok, [_ | _] = providers} <- Auth.list_active_providers_for_account(account) do
providers_by_adapter =
providers
|> Enum.group_by(fn provider ->
parent_adapter =
provider
|> Auth.fetch_provider_capabilities!()
|> Keyword.get(:parent_adapter)
|> group_providers_by_root_adapter()
|> Map.take(@root_adapters_whitelist)
parent_adapter || provider.adapter
end)
|> Map.drop([:token])
params = Web.Auth.take_sign_in_params(params)
query_params = take_non_empty_params(params, ["client_platform", "client_csrf_token"])
session_params = take_non_empty_params(session, ["client_platform", "client_csrf_token"])
params = Map.merge(session_params, query_params)
socket =
assign(socket,
params: params,
account: account,
providers_by_adapter: providers_by_adapter,
page_title: "Sign in"
)
{:ok, socket,
temporary_assigns: [
params: params,
account: account,
providers_by_adapter: providers_by_adapter,
page_title: "Sign in"
]}
{:ok, socket}
else
_other ->
raise Web.LiveErrors.NotFoundError
end
end
defp take_non_empty_params(map, keys) do
map |> Map.take(keys) |> Map.reject(fn {_key, value} -> value in ["", nil] end)
defp group_providers_by_root_adapter(providers) do
providers
|> Enum.group_by(fn provider ->
parent_adapter =
provider
|> Auth.fetch_provider_capabilities!()
|> Keyword.get(:parent_adapter)
parent_adapter || provider.adapter
end)
end
def render(assigns) do
@@ -99,7 +102,7 @@ defmodule Web.SignIn do
</.intersperse_blocks>
</div>
</div>
<div :if={is_nil(@params["client_platform"])} class="mx-auto p-6 sm:p-8">
<div :if={Web.Auth.fetch_auth_context_type!(@params) == :browser} class="mx-auto p-6 sm:p-8">
<p class="py-2">
Meant to sign in from a client instead?
<a href="https://firezone.dev/kb/user-guides?utm_source=product" class={link_style()}>

View File

@@ -7,26 +7,24 @@ defmodule Web.SignIn.Email do
"provider_id" => provider_id,
"provider_identifier" => provider_identifier
} = params,
session,
_session,
socket
) do
form = to_form(%{"secret" => nil})
query_params = Map.take(params, ["client_platform", "client_csrf_token"])
session_params = Map.take(session, ["client_platform", "client_csrf_token"])
params = Map.merge(session_params, query_params)
params = Web.Auth.take_sign_in_params(params)
{:ok, socket,
temporary_assigns: [
form: form,
provider_identifier: provider_identifier,
account_id_or_slug: account_id_or_slug,
provider_id: provider_id,
resent: params["resent"],
redirect_params: params,
client_platform: params["client_platform"],
client_csrf_token: params["client_csrf_token"]
]}
socket =
assign(socket,
form: form,
provider_identifier: provider_identifier,
account_id_or_slug: account_id_or_slug,
provider_id: provider_id,
resent: params["resent"],
params: params
)
{:ok, socket, temporary_assigns: [form: %Phoenix.HTML.Form{}]}
end
def render(assigns) do
@@ -56,6 +54,7 @@ defmodule Web.SignIn.Email do
method="get"
class="my-4 flex"
>
<.input :for={{key, value} <- @params} type="hidden" name={key} value={value} />
<.input type="hidden" name="identity_id" value={@provider_identifier} />
<input
@@ -88,10 +87,9 @@ defmodule Web.SignIn.Email do
account_id_or_slug={@account_id_or_slug}
provider_id={@provider_id}
provider_identifier={@provider_identifier}
client_platform={@client_platform}
client_csrf_token={@client_csrf_token}
params={@params}
/> or
<.link navigate={~p"/#{@account_id_or_slug}?#{@redirect_params}"} class={[link_style()]}>
<.link navigate={~p"/#{@account_id_or_slug}?#{@params}"} class={link_style()}>
use a different Sign In method
</.link>
.
@@ -135,18 +133,7 @@ defmodule Web.SignIn.Email do
method="post"
>
<.input type="hidden" name="email[provider_identifier]" value={@provider_identifier} />
<.input
:if={not is_nil(@client_platform)}
type="hidden"
name="client_platform"
value={@client_platform}
/>
<.input
:if={not is_nil(@client_csrf_token)}
type="hidden"
name="client_csrf_token"
value={@client_csrf_token}
/>
<.input :for={{key, value} <- @params} type="hidden" name={key} value={value} />
<span>
Did not receive it?
<button type="submit" class="inline text-accent-500 hover:underline">

View File

@@ -56,9 +56,7 @@ defmodule Web.SignUp do
actor_name_changed?: false
)
{:ok, socket}
{:ok, assign(socket, form: to_form(changeset), account: nil)}
{:ok, socket, temporary_assigns: [form: %Phoenix.HTML.Form{}]}
end
def render(assigns) do

View File

@@ -7,7 +7,8 @@ defmodule Web.Sites.Edit do
with {:ok, group} <- Gateways.fetch_group_by_id(id, socket.assigns.subject),
nil <- group.deleted_at do
changeset = Gateways.change_group(group)
{:ok, assign(socket, group: group, form: to_form(changeset))}
socket = assign(socket, group: group, form: to_form(changeset))
{:ok, socket, temporary_assigns: [form: %Phoenix.HTML.Form{}]}
else
_other -> raise Web.LiveErrors.NotFoundError
end

View File

@@ -5,7 +5,9 @@ defmodule Web.Sites.New do
def mount(_params, _session, socket) do
changeset = Gateways.new_group()
{:ok, assign(socket, form: to_form(changeset))}
{:ok, assign(socket, form: to_form(changeset)),
temporary_assigns: [form: %Phoenix.HTML.Form{}]}
end
def render(assigns) do

View File

@@ -8,8 +8,6 @@ defmodule Web.Router do
plug :protect_from_forgery
plug :fetch_live_flash
plug :put_root_layout, {Web.Layouts, :root}
plug :fetch_user_agent
plug :fetch_subject_and_account
end
pipeline :api do
@@ -22,6 +20,11 @@ defmodule Web.Router do
plug :accepts, ["html", "xml"]
end
pipeline :account do
plug :fetch_account
plug :fetch_subject
end
pipeline :home do
plug :accepts, ["html", "xml"]
plug :fetch_session
@@ -68,7 +71,7 @@ defmodule Web.Router do
end
scope "/:account_id_or_slug", Web do
pipe_through [:browser, :redirect_if_user_is_authenticated]
pipe_through [:browser, :account, :redirect_if_user_is_authenticated]
live_session :redirect_if_user_is_authenticated,
on_mount: [
@@ -97,13 +100,13 @@ defmodule Web.Router do
end
scope "/:account_id_or_slug", Web do
pipe_through [:browser]
pipe_through [:browser, :account]
get "/sign_out", AuthController, :sign_out
end
scope "/:account_id_or_slug", Web do
pipe_through [:browser, :ensure_authenticated_admin]
pipe_through [:browser, :account, :ensure_authenticated_admin]
live_session :ensure_authenticated,
on_mount: [
@@ -202,12 +205,6 @@ defmodule Web.Router do
live "/", Index
live "/new", New
scope "/saml", SAML do
live "/new", New
live "/:provider_id", Show
live "/:provider_id/edit", Edit
end
scope "/openid_connect", OpenIDConnect do
live "/new", New
live "/:provider_id", Show

View File

@@ -6,7 +6,7 @@ defmodule Web.Session do
@behaviour Plug
# 4 hours
@max_cookie_age 14_400
@max_cookie_age 4 * 60 * 60
# The session will be stored in the cookie signed and encrypted for 4 hours
@session_options [

View File

@@ -37,6 +37,7 @@ defmodule Web.AcceptanceCase.Auth do
remote_ip = {127, 0, 0, 1}
context = %Domain.Auth.Context{
type: :browser,
user_agent: user_agent,
remote_ip_location_region: "UA",
remote_ip_location_city: "Kyiv",
@@ -45,11 +46,11 @@ defmodule Web.AcceptanceCase.Auth do
remote_ip: remote_ip
}
subject = Domain.Auth.build_subject(identity, nil, context)
authenticate(session, subject)
{:ok, token} = Domain.Auth.create_token(identity, context, "", nil)
authenticate(session, token)
end
def authenticate(session, %Domain.Auth.Subject{} = subject) do
def authenticate(session, %Domain.Tokens.Token{} = token) do
options = Web.Session.options()
key = Keyword.fetch!(options, :key)
@@ -57,42 +58,43 @@ defmodule Web.AcceptanceCase.Auth do
signing_salt = Keyword.fetch!(options, :signing_salt)
secret_key_base = Web.Endpoint.config(:secret_key_base)
with {:ok, token} <- Domain.Auth.create_session_token_from_subject(subject) do
encryption_key = Plug.Crypto.KeyGenerator.generate(secret_key_base, encryption_salt, [])
signing_key = Plug.Crypto.KeyGenerator.generate(secret_key_base, signing_salt, [])
encoded_token = Domain.Tokens.encode_fragment!(token)
encryption_key = Plug.Crypto.KeyGenerator.generate(secret_key_base, encryption_salt, [])
signing_key = Plug.Crypto.KeyGenerator.generate(secret_key_base, signing_salt, [])
cookie =
%{
"session_token" => token,
"signed_in_at" => DateTime.utc_now(),
"live_socket_id" => "actors_sessions:#{subject.actor.id}"
}
|> :erlang.term_to_binary()
cookie =
%{"sessions" => [{:browser, token.account_id, encoded_token}]}
|> :erlang.term_to_binary()
encrypted =
Plug.Crypto.MessageEncryptor.encrypt(
cookie,
encryption_key,
signing_key
)
encrypted =
Plug.Crypto.MessageEncryptor.encrypt(
cookie,
encryption_key,
signing_key
)
Wallaby.Browser.set_cookie(session, key, encrypted)
end
Wallaby.Browser.set_cookie(session, key, encrypted)
end
TODO
def assert_unauthenticated(session) do
with {:ok, cookie} <- fetch_session_cookie(session) do
if token = cookie["session_token"] do
user_agent = fetch_session_user_agent!(session)
remote_ip = {127, 0, 0, 1}
context = %Domain.Auth.Context{user_agent: user_agent, remote_ip: remote_ip}
assert {:ok, subject} = Domain.Auth.sign_in(token, context)
flunk("User is authenticated, identity: #{inspect(subject.identity)}")
:ok
else
session
case cookie["sessions"] do
[{_, _, token} | _] ->
user_agent = fetch_session_user_agent!(session)
remote_ip = {127, 0, 0, 1}
context = %Domain.Auth.Context{
type: :browser,
user_agent: user_agent,
remote_ip: remote_ip
}
assert {:ok, subject} = Domain.Auth.authenticate(token, context)
flunk("User is authenticated, identity: #{inspect(subject.identity)}")
:ok
[] ->
session
end
else
:error -> session
@@ -102,10 +104,13 @@ defmodule Web.AcceptanceCase.Auth do
def assert_authenticated(session, identity) do
with {:ok, cookie} <- fetch_session_cookie(session),
context = %Domain.Auth.Context{
type: :browser,
user_agent: fetch_session_user_agent!(session),
remote_ip: {127, 0, 0, 1}
},
{:ok, subject} <- Domain.Auth.sign_in(cookie["session_token"], context) do
{:browser, _account_id, token} <-
List.keyfind(cookie["sessions"], identity.account_id, 1),
{:ok, subject} <- Domain.Auth.authenticate(token, context) do
assert subject.identity.id == identity.id,
"Expected #{inspect(identity)}, got #{inspect(subject.identity)}"

View File

@@ -1,7 +1,9 @@
defmodule Web.ConnCase do
use ExUnit.CaseTemplate
use Domain.CaseTemplate
use Web, :verified_routes
import Phoenix.LiveViewTest
import Phoenix.ConnTest
using do
quote do
@@ -35,6 +37,8 @@ defmodule Web.ConnCase do
|> Plug.Conn.put_req_header("x-geo-location-city", "Kyiv")
|> Plug.Conn.put_req_header("x-geo-location-coordinates", "50.4333,30.5167")
conn = %{conn | secret_key_base: Web.Endpoint.config(:secret_key_base)}
{:ok, conn: conn, user_agent: user_agent}
end
@@ -51,6 +55,7 @@ defmodule Web.ConnCase do
{"user-agent", user_agent} = List.keyfind(conn.req_headers, "user-agent", 0, "FooBar 1.1")
context = %Domain.Auth.Context{
type: :browser,
user_agent: user_agent,
remote_ip_location_region: "UA",
remote_ip_location_city: "Kyiv",
@@ -59,13 +64,61 @@ defmodule Web.ConnCase do
remote_ip: conn.remote_ip
}
subject = Domain.Auth.build_subject(identity, expires_in, context)
nonce = "nonce"
{:ok, token} = Domain.Auth.create_token(identity, context, nonce, expires_in)
encoded_fragment = Domain.Tokens.encode_fragment!(token)
subject = Domain.Auth.build_subject(token, identity, context)
conn
|> Web.Auth.put_subject_in_session(subject)
|> Web.Auth.put_account_session(context.type, identity.account_id, nonce <> encoded_fragment)
|> Plug.Conn.assign(:account, subject.account)
|> Plug.Conn.assign(:subject, subject)
end
def put_magic_link_auth_state(
conn,
account,
%{adapter: :email} = provider,
identity,
params \\ %{}
) do
params =
Map.merge(%{"email" => %{"provider_identifier" => identity.provider_identifier}}, params)
redirected_conn =
post(conn, ~p"/#{account}/sign_in/providers/#{provider.id}/request_magic_link", params)
assert_received {:email, email}
[_match, secret] = Regex.run(~r/secret=([^&\n]*)/, email.text_body)
cookie_key = "fz_auth_state_#{provider.id}"
%{value: signed_state} = redirected_conn.resp_cookies[cookie_key]
conn_with_cookie =
put_req_cookie(conn, "fz_auth_state_#{provider.id}", signed_state)
{conn_with_cookie, secret}
end
def put_idp_auth_state(conn, account, provider, params \\ %{}) do
redirected_conn =
get(conn, ~p"/#{account.id}/sign_in/providers/#{provider.id}/redirect", params)
cookie_key = "fz_auth_state_#{provider.id}"
redirected_conn = Plug.Conn.fetch_cookies(redirected_conn, signed: [cookie_key])
{_params, state, verifier} =
redirected_conn.cookies[cookie_key]
|> :erlang.binary_to_term([:safe])
%{value: signed_state} = redirected_conn.resp_cookies[cookie_key]
conn_with_cookie =
put_req_cookie(conn, "fz_auth_state_#{provider.id}", signed_state)
{conn_with_cookie, state, verifier}
end
### Helpers to test LiveView forms
def find_inputs(html, selector) do
@@ -172,4 +225,13 @@ defmodule Web.ConnCase do
|> String.replace(~r|[\n\s ]+|, " ")
|> String.trim()
end
def active_buttons(html) do
html
|> Floki.find("main button")
|> Enum.filter(fn button ->
Floki.attribute(button, "disabled") != "disabled"
end)
|> elements_to_text()
end
end

View File

@@ -120,6 +120,7 @@ defmodule Web.Acceptance.Auth.UserPassTest do
session: session
} do
account = Fixtures.Accounts.create_account()
actor = Fixtures.Actors.create_actor(type: :account_user, account: account)
provider = Fixtures.Auth.create_userpass_provider(account: account)
password = "Firezone1234"
@@ -127,13 +128,14 @@ defmodule Web.Acceptance.Auth.UserPassTest do
Fixtures.Auth.create_identity(
account: account,
provider: provider,
actor: [type: :account_user],
actor: actor,
provider_virtual_state: %{"password" => password, "password_confirmation" => password}
)
session
|> password_login_flow(account, identity.provider_identifier, password)
|> assert_path(~p"/#{account}")
|> assert_error_flash("Please use a client application to access Firezone.")
end
defp password_login_flow(session, account, username, password) do

File diff suppressed because it is too large Load Diff

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