diff --git a/lib/mix/tasks/tempest.account.reset_password.ex b/lib/mix/tasks/tempest.account.reset_password.ex new file mode 100644 index 0000000..2372555 --- /dev/null +++ b/lib/mix/tasks/tempest.account.reset_password.ex @@ -0,0 +1,79 @@ +defmodule Mix.Tasks.Tempest.Account.ResetPassword do + @shortdoc "Resets a local account password without email" + + @moduledoc """ + Resets a local Tempest account password from an operator shell. + + mix tempest.account.reset_password --identifier did:plc:... --password-env NEW_TEMPEST_PASSWORD + + Options: + + * `--identifier` - required account DID, handle, or email. + * `--password-env` - environment variable containing the new password. + Defaults to `TEMPEST_PASSWORD`. + + The task revokes existing sessions after updating the password hash. + """ + + use Mix.Task + + import Ecto.Query + + alias Tempest.Accounts.{Account, Password, Session} + alias Tempest.Repo + + @requirements ["app.start"] + + @impl true + def run(args) do + {opts, _rest, invalid} = + OptionParser.parse(args, + strict: [identifier: :string, password_env: :string], + aliases: [i: :identifier] + ) + + if invalid != [] do + Mix.raise("invalid options: #{inspect(invalid)}") + end + + identifier = required_option(opts, :identifier) + password_env = Keyword.get(opts, :password_env, "TEMPEST_PASSWORD") + password = System.get_env(password_env) || Mix.raise("#{password_env} is not set") + + with :ok <- Password.validate(password), + %Account{} = account <- account_by_identifier(identifier) do + reset_password!(account, password) + Mix.shell().info("reset password for #{account.did} handle=#{account.handle}; revoked active sessions") + else + nil -> Mix.raise("account not found for identifier #{identifier}") + {:error, reason} when is_binary(reason) -> Mix.raise(reason) + end + end + + defp required_option(opts, key) do + Keyword.get(opts, key) || Mix.raise("--#{String.replace(Atom.to_string(key), "_", "-")} is required") + end + + defp account_by_identifier(identifier) do + normalized = identifier |> String.trim() |> String.downcase() + + Repo.one( + from account in Account, + where: account.did == ^identifier or account.handle == ^normalized or account.email == ^normalized + ) + end + + defp reset_password!(%Account{} = account, password) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + + Repo.transaction(fn -> + account + |> Ecto.Changeset.change(%{password_hash: Password.hash(password)}) + |> Repo.update!() + + Session + |> where([session], session.account_id == ^account.id and is_nil(session.revoked_at)) + |> Repo.update_all(set: [revoked_at: now]) + end) + end +end diff --git a/lib/tempest/accounts.ex b/lib/tempest/accounts.ex index 7b97c14..de42376 100644 --- a/lib/tempest/accounts.ex +++ b/lib/tempest/accounts.ex @@ -30,22 +30,22 @@ defmodule Tempest.Accounts do |> Map.put("active", not migrated?) |> Map.put("status", if(migrated?, do: "deactivated", else: "active")) - refresh_token = Tokens.new_refresh_token() - Repo.transaction(fn -> with {:ok, account} <- Repo.insert(Account.create_changeset(%Account{}, account_attrs)), {:ok, signing_key} <- Identity.create_initial_signing_key(account), {:ok, _repo_path} <- RepoStorage.initialize_empty_repo(account, signing_key), + family_id = Ecto.UUID.generate(), + refresh_token = Tokens.sign_refresh_token(account, family_id), {:ok, session} <- - Repo.insert(new_session_changeset(account, refresh_token, Ecto.UUID.generate())) do - {account, session} + Repo.insert(new_session_changeset(account, refresh_token, family_id)) do + {account, session, refresh_token} else {:error, %Ecto.Changeset{} = changeset} -> Repo.rollback({:validation, changeset}) {:error, reason} -> Repo.rollback({:repo_initialization, reason}) end end) |> case do - {:ok, {account, session}} -> + {:ok, {account, session, refresh_token}} -> with :ok <- maybe_publish_plc_operation(account), {:ok, _events} <- emit_account_creation_events(account) do {:ok, session_response(account, session, refresh_token)} @@ -119,7 +119,7 @@ defmodule Tempest.Accounts do Repo.rollback(:expired_refresh_token) true -> - refresh_token = Tokens.new_refresh_token() + refresh_token = Tokens.sign_refresh_token(account, fresh_session.family_id) fresh_session |> Session.rotate_changeset(%{revoked_at: now, rotated_at: now}) @@ -422,10 +422,11 @@ defmodule Tempest.Accounts do end defp create_session_for_account(%Account{} = account) do - refresh_token = Tokens.new_refresh_token() + family_id = Ecto.UUID.generate() + refresh_token = Tokens.sign_refresh_token(account, family_id) account - |> new_session_changeset(refresh_token, Ecto.UUID.generate()) + |> new_session_changeset(refresh_token, family_id) |> Repo.insert() |> case do {:ok, session} -> {:ok, session_response(account, session, refresh_token)} diff --git a/lib/tempest/accounts/tokens.ex b/lib/tempest/accounts/tokens.ex index 34b8ec2..4bef4f3 100644 --- a/lib/tempest/accounts/tokens.ex +++ b/lib/tempest/accounts/tokens.ex @@ -16,6 +16,28 @@ defmodule Tempest.Accounts.Tokens do @refresh_prefix "tempest-refresh-v1." def sign_access_token(%Account{} = account, %Session{} = session) do + sign_session_jwt!(account, %{ + "typ" => "access", + "scope" => "com.atproto.access", + "sub" => account.did, + "aud" => service_did(), + "account_id" => account.id, + "session_id" => session.id, + "jti" => Integer.to_string(session.id) + }) + end + + def sign_refresh_token(%Account{} = account, family_id) when is_binary(family_id) do + sign_session_jwt!(account, %{ + "typ" => "refresh", + "scope" => "com.atproto.refresh", + "sub" => account.did, + "aud" => service_did(), + "jti" => family_id + }) + end + + def sign_legacy_access_token(%Account{} = account, %Session{} = session) do Phoenix.Token.sign(Endpoint, @access_salt, %{ "typ" => "access", "account_id" => account.id, @@ -25,7 +47,10 @@ defmodule Tempest.Accounts.Tokens do end def verify_access_token(token) when is_binary(token) do - Phoenix.Token.verify(Endpoint, @access_salt, token, max_age: @access_max_age_seconds) + case verify_session_jwt(token, "access", @access_max_age_seconds) do + {:ok, claims} -> {:ok, claims} + {:error, _reason} -> Phoenix.Token.verify(Endpoint, @access_salt, token, max_age: @access_max_age_seconds) + end end def verify_access_token(_token), do: {:error, :invalid} @@ -200,6 +225,94 @@ defmodule Tempest.Accounts.Tokens do defp base64url(value), do: Base.url_encode64(value, padding: false) + defp sign_session_jwt!(%Account{} = account, claims) do + now = DateTime.utc_now() |> DateTime.to_unix() + key = KeyStore.active_key_for_account(account) + jwk = service_auth_private_jwk!(key) + typ = if claims["typ"] == "refresh", do: "refresh+jwt", else: "at+jwt" + max_age = if claims["typ"] == "refresh", do: @refresh_lifetime_seconds, else: @access_max_age_seconds + + headers = %{"typ" => typ, "alg" => "ES256K", "kid" => account.did <> key.kid} + + claims = + claims + |> Map.put("iss", account.did) + |> Map.put("iat", now) + |> Map.put("exp", now + max_age) + + {_jws, compact} = JOSE.JWT.sign(jwk, headers, claims) |> JOSE.JWS.compact() + compact + end + + defp verify_session_jwt(token, expected_typ, max_age_seconds) do + with {:ok, header} <- peek_service_auth_header(token), + :ok <- validate_session_jwt_header(header, expected_typ), + {:ok, unverified_claims} <- peek_service_auth_claims(token), + :ok <- validate_session_claim_shape(unverified_claims, expected_typ), + {:ok, public_jwk} <- service_auth_public_jwk(unverified_claims["iss"], header["kid"]), + {:ok, claims} <- verify_service_auth_signature(token, public_jwk), + :ok <- validate_session_claims(claims, expected_typ, max_age_seconds) do + {:ok, claims} + else + {:error, reason} -> {:error, reason} + end + end + + defp validate_session_jwt_header(%{"alg" => "ES256K", "typ" => typ}, "access") + when typ in ["at+jwt", "JWT"], + do: :ok + + defp validate_session_jwt_header(%{"alg" => "ES256K", "typ" => typ}, "refresh") + when typ in ["refresh+jwt", "JWT"], + do: :ok + + defp validate_session_jwt_header(_header, _expected_typ), do: {:error, :invalid} + + defp validate_session_claim_shape(%{"typ" => "access", "scope" => "com.atproto.access"} = claims, "access") do + with did when is_binary(did) and did != "" <- Map.get(claims, "sub"), + ^did <- Map.get(claims, "iss"), + account_id when is_integer(account_id) <- Map.get(claims, "account_id"), + session_id when is_integer(session_id) <- Map.get(claims, "session_id") do + :ok + else + _other -> {:error, :invalid} + end + end + + defp validate_session_claim_shape(%{"typ" => "refresh", "scope" => "com.atproto.refresh"} = claims, "refresh") do + with did when is_binary(did) and did != "" <- Map.get(claims, "sub"), + ^did <- Map.get(claims, "iss") do + :ok + else + _other -> {:error, :invalid} + end + end + + defp validate_session_claim_shape(_claims, _expected_typ), do: {:error, :invalid} + + defp validate_session_claims( + %{"typ" => expected_typ, "sub" => did, "iss" => did, "aud" => aud, "iat" => iat, "exp" => exp}, + expected_typ, + max_age_seconds + ) + when is_integer(iat) and is_integer(exp) do + now = DateTime.utc_now() |> DateTime.to_unix() + + cond do + aud != service_did() -> {:error, :invalid} + iat > now + 60 -> {:error, :invalid} + exp <= now -> {:error, :expired_token} + exp - iat > max_age_seconds -> {:error, :invalid} + true -> :ok + end + end + + defp validate_session_claims(_claims, _expected_typ, _max_age_seconds), do: {:error, :invalid} + + defp service_did do + "did:web:" <> Tempest.Config.load!().hostname + end + defp random_url_token(bytes) do bytes |> :crypto.strong_rand_bytes() diff --git a/lib/tempest_web/controllers/well_known_controller.ex b/lib/tempest_web/controllers/well_known_controller.ex index bd7de85..a965b94 100644 --- a/lib/tempest_web/controllers/well_known_controller.ex +++ b/lib/tempest_web/controllers/well_known_controller.ex @@ -3,6 +3,22 @@ defmodule TempestWeb.WellKnownController do alias Tempest.Identity + def did_json(conn, _params) do + config = Tempest.Config.load!() + + json(conn, %{ + "@context" => ["https://www.w3.org/ns/did/v1"], + "id" => "did:web:#{config.hostname}", + "service" => [ + %{ + "id" => "#atproto_pds", + "type" => "AtprotoPersonalDataServer", + "serviceEndpoint" => service_endpoint(config) + } + ] + }) + end + def atproto_did(conn, _params) do case Identity.hosted_did_for_handle(conn.host) do {:ok, did} -> @@ -14,4 +30,11 @@ defmodule TempestWeb.WellKnownController do send_resp(conn, 404, "handle not found") end end + + defp service_endpoint(config) do + %{scheme: scheme, host: host, port: port} = URI.parse(config.public_url) + default_port? = (scheme == "http" and port in [nil, 80]) or (scheme == "https" and port in [nil, 443]) + + if default_port?, do: "#{scheme}://#{host}", else: "#{scheme}://#{host}:#{port}" + end end diff --git a/lib/tempest_web/router.ex b/lib/tempest_web/router.ex index bb4ce48..ee10090 100644 --- a/lib/tempest_web/router.ex +++ b/lib/tempest_web/router.ex @@ -15,7 +15,7 @@ defmodule TempestWeb.Router do end pipeline :well_known do - plug :accepts, ["text"] + plug :accepts, ["text", "json"] end pipeline :oauth_metadata do @@ -72,6 +72,7 @@ defmodule TempestWeb.Router do pipe_through :well_known get "/.well-known/atproto-did", WellKnownController, :atproto_did + get "/.well-known/did.json", WellKnownController, :did_json end scope "/oauth", TempestWeb do diff --git a/test/tempest_web/controllers/xrpc_controller_test.exs b/test/tempest_web/controllers/xrpc_controller_test.exs index b63d6ae..244c61b 100644 --- a/test/tempest_web/controllers/xrpc_controller_test.exs +++ b/test/tempest_web/controllers/xrpc_controller_test.exs @@ -39,6 +39,21 @@ defmodule TempestWeb.XrpcControllerTest do assert allow_headers =~ "x-atproto-accept-labelers" end + test "GET did.json returns service DID document", %{conn: conn} do + conn = get(conn, ~p"/.well-known/did.json") + response = json_response(conn, 200) + + assert response["id"] == "did:web:localhost" + + assert [ + %{ + "id" => "#atproto_pds", + "type" => "AtprotoPersonalDataServer", + "serviceEndpoint" => "http://localhost:4002" + } + ] = response["service"] + end + test "unknown XRPC method returns JSON error", %{conn: conn} do conn = get(conn, ~p"/xrpc/com.atproto.unknown.method") response = json_response(conn, 404) diff --git a/test/tempest_web/xrpc/accounts_sessions_test.exs b/test/tempest_web/xrpc/accounts_sessions_test.exs index 5e847b3..6488ef7 100644 --- a/test/tempest_web/xrpc/accounts_sessions_test.exs +++ b/test/tempest_web/xrpc/accounts_sessions_test.exs @@ -56,6 +56,14 @@ defmodule TempestWeb.Xrpc.AccountsSessionsTest do assert is_binary(login["accessJwt"]) assert is_binary(login["refreshJwt"]) + assert compact_jwt?(login["accessJwt"]) + assert compact_jwt?(login["refreshJwt"]) + + assert %{"typ" => "access", "scope" => "com.atproto.access", "aud" => "did:web:localhost"} = + jwt_payload(login["accessJwt"]) + + assert %{"typ" => "refresh", "scope" => "com.atproto.refresh", "aud" => "did:web:localhost"} = + jwt_payload(login["refreshJwt"]) session_conn = conn @@ -607,6 +615,16 @@ defmodule TempestWeb.Xrpc.AccountsSessionsTest do encode_base58_value(div(value, 58), alphabet, [Enum.at(alphabet, rem(value, 58)) | acc]) end + defp compact_jwt?(token), do: token |> String.split(".") |> length() == 3 + + defp jwt_payload(token) do + token + |> String.split(".") + |> Enum.at(1) + |> Base.url_decode64!(padding: false) + |> Jason.decode!() + end + defp maybe_put(map, true, key, value), do: Map.put(map, key, value) defp maybe_put(map, false, _key, _value), do: map end