166 lines
6.0 KiB
Elixir
166 lines
6.0 KiB
Elixir
defmodule Farmbot.Bootstrap.Authorization do
|
|
@moduledoc "Functionality responsible for getting a JWT."
|
|
|
|
@typedoc "Email used to configure this bot."
|
|
@type email :: binary
|
|
|
|
@typedoc "Password used to configure this bot."
|
|
@type password :: binary
|
|
|
|
@typedoc "Server used to configure this bot."
|
|
@type server :: binary
|
|
|
|
@typedoc "Token that was fetched with the credentials."
|
|
@type token :: binary
|
|
|
|
use Farmbot.Logger
|
|
alias Farmbot.System.ConfigStorage
|
|
import ConfigStorage, only: [update_config_value: 4, get_config_value: 3]
|
|
|
|
@version Farmbot.Project.version()
|
|
@target Farmbot.Project.target()
|
|
@data_path Application.get_env(:farmbot, :data_path)
|
|
|
|
@doc """
|
|
Callback for an authorization implementation.
|
|
Should return {:ok, token} | {:error, term}
|
|
"""
|
|
@callback authorize(email, password, server) :: {:ok, token} | {:error, term}
|
|
|
|
# this is the default authorize implementation.
|
|
# It gets overwrote in the Test Environment.
|
|
@doc "Authorizes with the farmbot api."
|
|
def authorize(email, pw_or_secret, server) do
|
|
case get_config_value(:bool, "settings", "first_boot") do
|
|
false -> authorize_with_secret(email, pw_or_secret, server)
|
|
true -> authorize_with_password(email, pw_or_secret, server)
|
|
end
|
|
|> case do
|
|
{:ok, token} -> {:ok, token}
|
|
err ->
|
|
Logger.error 1, "Authorization failed: #{inspect err}"
|
|
err
|
|
end
|
|
end
|
|
|
|
def authorize_with_secret(email, secret, server, state \\ %{backoff: 5000, logged_once: false})
|
|
|
|
def authorize_with_secret(email, secret, server, state) do
|
|
with {:ok, payload} <- build_payload(secret),
|
|
{:ok, resp} <- request_token(server, payload),
|
|
{:ok, body} <- Poison.decode(resp),
|
|
{:ok, map} <- Map.fetch(body, "token") do
|
|
last_reset_reason_file = Path.join(@data_path, "last_shutdown_reason")
|
|
File.rm(last_reset_reason_file)
|
|
Map.fetch(map, "encoded")
|
|
else
|
|
:error -> {:error, "unknown error."}
|
|
{:error, :invalid, _} -> authorize_with_secret(email, secret, server, state)
|
|
# If we got maintance mode, a 5xx error etc,
|
|
# just sleep for a few seconds
|
|
# and try again.
|
|
# There is some state data here to allow for a backoff timer.
|
|
# This means in cases of the api serving 5xx's because it is overloaded,
|
|
# We are not going to be adding way more to the load.
|
|
# We also only log this as an error once, to ensure the database doesn't
|
|
# get full of logs.
|
|
{:error, {:http_error, code}} ->
|
|
msg = "Failed to authorize due to server error: #{code}. Trying again in #{state.backoff / 1000} seconds."
|
|
if state.logged_once, do: Logger.debug(3, msg), else: Logger.error(1, msg)
|
|
Process.sleep(state.backoff)
|
|
new_state = %{state | backoff: state.backoff + 1000, logged_once: true}
|
|
authorize_with_secret(email, secret, server, new_state)
|
|
{:error, %HTTPoison.Error{reason: reason}} -> {:error, reason}
|
|
err -> err
|
|
end
|
|
end
|
|
|
|
def authorize_with_password(email, password, server) do
|
|
with {:ok, {:RSAPublicKey, _, _} = rsa_key} <- fetch_rsa_key(server),
|
|
{:ok, payload} <- build_payload(email, password, rsa_key),
|
|
{:ok, resp} <- request_token(server, payload),
|
|
{:ok, body} <- Poison.decode(resp),
|
|
{:ok, map} <- Map.fetch(body, "token") do
|
|
update_config_value(:bool, "settings", "first_boot", false)
|
|
last_reset_reason_file = Path.join(@data_path, "last_shutdown_reason")
|
|
File.rm(last_reset_reason_file)
|
|
Map.fetch(map, "encoded")
|
|
else
|
|
:error -> {:error, "unknown error."}
|
|
{:error, :invalid, _} -> authorize(email, password, server)
|
|
# If we got maintance mode, a 5xx error etc,
|
|
# just sleep for a few seconds
|
|
# and try again.
|
|
{:error, {:http_error, code}} ->
|
|
Logger.error 1, "Failed to authorize due to server error: #{code}"
|
|
Process.sleep(5000)
|
|
authorize(email, password, server)
|
|
{:error, %HTTPoison.Error{reason: reason}} -> {:error, reason}
|
|
err -> err
|
|
end
|
|
end
|
|
|
|
def fetch_rsa_key(server) do
|
|
url = "#{server}/api/public_key"
|
|
case HTTPoison.get(url) do
|
|
{:ok, %{status_code: 200, body: body}} ->
|
|
r = body |> to_string() |> RSA.decode_key()
|
|
{:ok, r}
|
|
{:ok, %{status_code: code, body: body}} ->
|
|
msg = """
|
|
Failed to fetch public key.
|
|
status_code: #{code}
|
|
body: #{inspect body}
|
|
"""
|
|
{:error, msg}
|
|
{:error, reason} -> {:error, reason}
|
|
end
|
|
end
|
|
|
|
def build_payload(email, password, rsa_key) do
|
|
secret =
|
|
%{email: email, password: password, id: UUID.uuid1(), version: 1}
|
|
|> Poison.encode!()
|
|
|> RSA.encrypt({:public, rsa_key})
|
|
update_config_value(:string, "authorization", "password", secret)
|
|
%{user: %{credentials: secret |> Base.encode64()}} |> Poison.encode()
|
|
end
|
|
|
|
defp build_payload(secret) do
|
|
user = %{credentials: secret |> :base64.encode_to_string |> to_string}
|
|
Poison.encode(%{user: user})
|
|
end
|
|
|
|
def request_token(server, payload) do
|
|
headers = [
|
|
{"User-Agent", "FarmbotOS/#{@version} (#{@target}) #{@target} ()"},
|
|
{"Content-Type", "application/json"}
|
|
]
|
|
case HTTPoison.post("#{server}/api/tokens", payload, headers) do
|
|
{:ok, %{status_code: 200, body: body}} -> {:ok, body}
|
|
|
|
# if the error is a 4xx code, it was a failed auth.
|
|
{:ok, %{status_code: code, body: body}} when code > 399 and code < 500 ->
|
|
reason = get_body(body)
|
|
msg = """
|
|
Failed to authorize with the Farmbot web application at: #{server}
|
|
with code: #{code}
|
|
body: #{reason}
|
|
"""
|
|
{:error, msg}
|
|
|
|
# if the error is not 2xx and not 4xx, probably maintance mode.
|
|
{:ok, %{status_code: code}} -> {:error, {:http_error, code}}
|
|
{:error, error} -> {:error, error}
|
|
end
|
|
end
|
|
|
|
defp get_body(body) do
|
|
case Poison.decode(body) do
|
|
{:ok, %{"auth" => reason}} -> reason
|
|
{:ok, reason} -> inspect reason
|
|
_ -> inspect body
|
|
end
|
|
end
|
|
end
|