mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-28 02:18:50 +00:00
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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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: []
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -32,6 +32,7 @@ defmodule Domain.Application do
|
||||
Domain.Cluster,
|
||||
|
||||
# Application
|
||||
Domain.Tokens,
|
||||
Domain.Auth,
|
||||
Domain.Relays,
|
||||
Domain.Gateways,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
215
elixir/apps/domain/lib/domain/tokens.ex
Normal file
215
elixir/apps/domain/lib/domain/tokens.ex
Normal 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
|
||||
38
elixir/apps/domain/lib/domain/tokens/authorizer.ex
Normal file
38
elixir/apps/domain/lib/domain/tokens/authorizer.ex
Normal 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
|
||||
10
elixir/apps/domain/lib/domain/tokens/jobs.ex
Normal file
10
elixir/apps/domain/lib/domain/tokens/jobs.ex
Normal 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
|
||||
38
elixir/apps/domain/lib/domain/tokens/token.ex
Normal file
38
elixir/apps/domain/lib/domain/tokens/token.ex
Normal 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
|
||||
97
elixir/apps/domain/lib/domain/tokens/token/changeset.ex
Normal file
97
elixir/apps/domain/lib/domain/tokens/token/changeset.ex
Normal 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
|
||||
50
elixir/apps/domain/lib/domain/tokens/token/query.ex
Normal file
50
elixir/apps/domain/lib/domain/tokens/token/query.ex
Normal 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
|
||||
@@ -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]}}]
|
||||
|
||||
@@ -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
|
||||
@@ -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} =
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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!()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
558
elixir/apps/domain/test/domain/tokens_test.exs
Normal file
558
elixir/apps/domain/test/domain/tokens_test.exs
Normal 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
|
||||
@@ -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
|
||||
|
||||
105
elixir/apps/domain/test/support/fixtures/tokens.ex
Normal file
105
elixir/apps/domain/test/support/fixtures/tokens.ex
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
"""
|
||||
|
||||
@@ -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>
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} ->
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 = %{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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)}"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user