Fresh repository history for elektrine/tarakan hosted at https://git.elektrine.com/elektrine/tarakan.
274 lines
8.6 KiB
Elixir
274 lines
8.6 KiB
Elixir
defmodule Tarakan.Accounts.ClientAuthorizations do
|
|
@moduledoc """
|
|
Short-lived device authorization for browser-based Tarakan Client login.
|
|
|
|
A client holds a high-entropy device code while a signed-in browser approves
|
|
the displayed user code. Approval never places an API credential in a URL;
|
|
the credential is minted once, during the device-code exchange.
|
|
"""
|
|
|
|
import Ecto.Query, warn: false
|
|
|
|
alias Ecto.Changeset
|
|
alias Tarakan.Accounts.{Account, AccountToken, ApiCredentials, ClientAuthorization}
|
|
alias Tarakan.PromptSafety
|
|
alias Tarakan.RateLimiter
|
|
alias Tarakan.Repo
|
|
|
|
@validity_minutes 10
|
|
@poll_interval_seconds 2
|
|
# Least privilege for agent participation in the public work queue.
|
|
# Independent verification (reviews:verify / reviews:read) is opt-in via
|
|
# settings-minted credentials so a stolen device token cannot flood verdicts.
|
|
@client_scopes ~w(
|
|
tasks:read tasks:claim contributions:write
|
|
reviews:submit
|
|
)
|
|
@credential_validity_days 7
|
|
@max_user_code_attempts 5
|
|
|
|
def validity_seconds, do: @validity_minutes * 60
|
|
def poll_interval_seconds, do: @poll_interval_seconds
|
|
def client_scopes, do: @client_scopes
|
|
def credential_validity_days, do: @credential_validity_days
|
|
|
|
def start(attrs \\ %{}), do: start(attrs, @max_user_code_attempts)
|
|
|
|
defp start(_attrs, 0), do: {:error, :user_code_unavailable}
|
|
|
|
defp start(attrs, attempts_left) do
|
|
_ = prune_expired()
|
|
|
|
# Strip control, ANSI and bidirectional codes before the changeset's format
|
|
# check, so a client that merely pads its name with invisible characters is
|
|
# cleaned up rather than rejected outright.
|
|
client_name =
|
|
attrs
|
|
|> Map.get("client_name", "Tarakan Client")
|
|
|> PromptSafety.sanitize_line(max_bytes: 40)
|
|
|> case do
|
|
"" -> "Tarakan Client"
|
|
name -> name
|
|
end
|
|
|
|
device_code = "trkd_" <> random_url_token(32)
|
|
user_code = random_user_code()
|
|
expires_at = DateTime.add(DateTime.utc_now(), @validity_minutes, :minute)
|
|
|
|
changeset =
|
|
%ClientAuthorization{}
|
|
|> ClientAuthorization.creation_changeset(%{
|
|
"client_name" => client_name,
|
|
"scopes" => @client_scopes,
|
|
"expires_at" => expires_at
|
|
})
|
|
|> Changeset.put_change(:device_code_hash, token_hash(device_code))
|
|
|> Changeset.put_change(:user_code, normalize_user_code(user_code))
|
|
|
|
case Repo.insert(changeset) do
|
|
{:ok, authorization} ->
|
|
{:ok, device_code, display_user_code(user_code), authorization}
|
|
|
|
# A user-code collision is astronomically unlikely, but retrying without a
|
|
# bound would turn one into unbounded recursion on an unauthenticated
|
|
# endpoint.
|
|
{:error, %Changeset{errors: errors}} = error ->
|
|
if Keyword.has_key?(errors, :user_code),
|
|
do: start(attrs, attempts_left - 1),
|
|
else: error
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Looks up a pending authorization for the approving browser.
|
|
|
|
`actor_key` identifies who is doing the looking (the approving account), so
|
|
attempts can be budgeted. A user code is only 40 bits and lives for ten
|
|
minutes, which puts brute force far out of reach, but the endpoint is
|
|
otherwise an unmetered oracle for "does this code exist right now", and
|
|
RFC 8628 asks authorization servers to rate limit it. The budget is generous
|
|
because approving a login is a handful of page loads, never dozens.
|
|
"""
|
|
def get_for_browser(user_code, actor_key \\ nil)
|
|
|
|
def get_for_browser(user_code, actor_key) when is_binary(user_code) do
|
|
if lookup_allowed?(actor_key) do
|
|
now = DateTime.utc_now()
|
|
|
|
case Repo.one(
|
|
from authorization in ClientAuthorization,
|
|
where:
|
|
authorization.user_code == ^normalize_user_code(user_code) and
|
|
authorization.status in ["pending", "approved", "denied"] and
|
|
authorization.expires_at > ^now
|
|
) do
|
|
%ClientAuthorization{} = authorization -> {:ok, authorization}
|
|
nil -> {:error, :not_found}
|
|
end
|
|
else
|
|
{:error, :throttled}
|
|
end
|
|
end
|
|
|
|
def get_for_browser(_user_code, _actor_key), do: {:error, :not_found}
|
|
|
|
defp lookup_allowed?(nil), do: true
|
|
|
|
defp lookup_allowed?(actor_key) do
|
|
RateLimiter.check({:client_authorization_lookup, actor_key}, 30, 300) == :ok
|
|
end
|
|
|
|
def approve(%ClientAuthorization{id: id}, %Account{} = account) do
|
|
transition(id, fn authorization ->
|
|
now = DateTime.utc_now()
|
|
|
|
cond do
|
|
DateTime.compare(authorization.expires_at, now) != :gt ->
|
|
{:error, :expired}
|
|
|
|
authorization.status == "pending" ->
|
|
authorization
|
|
|> Changeset.change(status: "approved", account_id: account.id, approved_at: now)
|
|
|> Repo.update()
|
|
|
|
authorization.status == "approved" and authorization.account_id == account.id ->
|
|
{:ok, authorization}
|
|
|
|
true ->
|
|
{:error, :not_found}
|
|
end
|
|
end)
|
|
end
|
|
|
|
def deny(%ClientAuthorization{id: id}, %Account{} = account) do
|
|
transition(id, fn authorization ->
|
|
cond do
|
|
authorization.status == "pending" ->
|
|
authorization
|
|
|> Changeset.change(status: "denied", account_id: account.id)
|
|
|> Repo.update()
|
|
|
|
authorization.status == "denied" and authorization.account_id == account.id ->
|
|
{:ok, authorization}
|
|
|
|
true ->
|
|
{:error, :not_found}
|
|
end
|
|
end)
|
|
end
|
|
|
|
def exchange(device_code) when is_binary(device_code) do
|
|
if valid_device_code?(device_code) do
|
|
Repo.transaction(fn ->
|
|
authorization =
|
|
Repo.one(
|
|
from candidate in ClientAuthorization,
|
|
where: candidate.device_code_hash == ^token_hash(device_code),
|
|
lock: "FOR UPDATE"
|
|
) || Repo.rollback(:invalid_device_code)
|
|
|
|
exchange_locked(authorization)
|
|
end)
|
|
else
|
|
{:error, :invalid_device_code}
|
|
end
|
|
end
|
|
|
|
def exchange(_device_code), do: {:error, :invalid_device_code}
|
|
|
|
defp exchange_locked(authorization) do
|
|
now = DateTime.utc_now()
|
|
|
|
cond do
|
|
DateTime.compare(authorization.expires_at, now) != :gt ->
|
|
Repo.rollback(:expired)
|
|
|
|
authorization.status == "pending" ->
|
|
Repo.rollback(:authorization_pending)
|
|
|
|
authorization.status == "denied" ->
|
|
Repo.rollback(:access_denied)
|
|
|
|
authorization.status != "approved" or is_nil(authorization.account_id) ->
|
|
Repo.rollback(:invalid_device_code)
|
|
|
|
true ->
|
|
account = Repo.get!(Account, authorization.account_id)
|
|
|
|
if not Account.access_allowed?(account) do
|
|
Repo.rollback(:account_locked)
|
|
end
|
|
|
|
case ApiCredentials.create(account, %{
|
|
"name" => authorization.client_name,
|
|
"scopes" => authorization.scopes,
|
|
"validity_days" => @credential_validity_days
|
|
}) do
|
|
{:ok, token, credential} ->
|
|
authorization
|
|
|> Changeset.change(status: "consumed", consumed_at: now)
|
|
|> Repo.update!()
|
|
|
|
{token, credential}
|
|
|
|
{:error, reason} ->
|
|
Repo.rollback(reason)
|
|
end
|
|
end
|
|
end
|
|
|
|
defp prune_expired do
|
|
now = DateTime.utc_now()
|
|
|
|
# Delete by the indexed expires_at only, so this stays a cheap index range
|
|
# scan on the unauthenticated start path. Consumed/denied rows are short-
|
|
# lived and get swept once their (unchanged) expiry passes.
|
|
from(authorization in ClientAuthorization, where: authorization.expires_at <= ^now)
|
|
|> Repo.delete_all()
|
|
|
|
:ok
|
|
rescue
|
|
_error -> :ok
|
|
end
|
|
|
|
defp transition(id, callback) do
|
|
Repo.transaction(fn ->
|
|
authorization =
|
|
Repo.one(
|
|
from candidate in ClientAuthorization,
|
|
where: candidate.id == ^id,
|
|
lock: "FOR UPDATE"
|
|
) || Repo.rollback(:not_found)
|
|
|
|
case callback.(authorization) do
|
|
{:ok, updated} -> updated
|
|
{:error, reason} -> Repo.rollback(reason)
|
|
end
|
|
end)
|
|
end
|
|
|
|
defp valid_device_code?(<<"trkd_", encoded::binary-size(43)>>),
|
|
do: encoded =~ ~r/^[A-Za-z0-9_-]+$/
|
|
|
|
defp valid_device_code?(_device_code), do: false
|
|
|
|
defp normalize_user_code(code) do
|
|
code
|
|
|> to_string()
|
|
|> String.upcase()
|
|
|> String.replace(~r/[^A-Z2-7]/, "")
|
|
end
|
|
|
|
@doc "Formats a user code as `XXXX-YYYY` for display in the terminal and browser."
|
|
def display_user_code(code) do
|
|
normalized = normalize_user_code(code)
|
|
String.slice(normalized, 0, 4) <> "-" <> String.slice(normalized, 4, 4)
|
|
end
|
|
|
|
defp random_user_code, do: :crypto.strong_rand_bytes(5) |> Base.encode32(padding: false)
|
|
|
|
defp random_url_token(bytes),
|
|
do: bytes |> :crypto.strong_rand_bytes() |> Base.url_encode64(padding: false)
|
|
|
|
defp token_hash(token), do: AccountToken.hash_token(token)
|
|
end
|