From 25856a1e87e8f6103638a36ddc9431b058dcb13d Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Sun, 31 May 2026 13:37:00 -0500 Subject: [PATCH] feat: MFA & TOTP --- CHANGELOG.md | 2 + README.md | 11 + docs/tasks/11-security-oauth.md | 14 +- lib/mix/tasks/tempest.totp.code.ex | 14 + lib/tempest/accounts.ex | 46 ++-- lib/tempest/security.ex | 245 ++++++++++++++++++ lib/tempest/security/backup_code.ex | 18 ++ .../security/delegated_access_grant.ex | 20 ++ lib/tempest/security/email.ex | 37 +++ lib/tempest/security/email_token.ex | 24 ++ lib/tempest/security/mfa_credential.ex | 25 ++ lib/tempest/security/rate_limiter.ex | 63 +++++ lib/tempest/security/security_event.ex | 18 ++ lib/tempest/security/totp.ex | 55 ++++ lib/tempest/xrpc/server.ex | 5 + .../controllers/oauth_controller.ex | 28 +- ...000_create_security_mfa_and_delegation.exs | 68 +++++ test/smoke/oauth-security.hurl | 48 ++++ test/tempest/security_test.exs | 86 ++++++ .../controllers/oauth_flow_test.exs | 102 ++++++++ 20 files changed, 897 insertions(+), 32 deletions(-) create mode 100644 lib/mix/tasks/tempest.totp.code.ex create mode 100644 lib/tempest/security.ex create mode 100644 lib/tempest/security/backup_code.ex create mode 100644 lib/tempest/security/delegated_access_grant.ex create mode 100644 lib/tempest/security/email.ex create mode 100644 lib/tempest/security/email_token.ex create mode 100644 lib/tempest/security/mfa_credential.ex create mode 100644 lib/tempest/security/rate_limiter.ex create mode 100644 lib/tempest/security/security_event.ex create mode 100644 lib/tempest/security/totp.ex create mode 100644 priv/repo/migrations/20260531190000_create_security_mfa_and_delegation.exs create mode 100644 test/smoke/oauth-security.hurl create mode 100644 test/tempest/security_test.exs create mode 100644 test/tempest_web/controllers/oauth_flow_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 36ef744..99bd57d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ - Added compatibility smoke coverage for `applyWrites`, `getBlocks`, `requestCrawl`, preference endpoints, and unknown AppView fallback. +- Added account security foundations for email tokens, security events, TOTP MFA, + backup codes, delegated-access grants, session revocation, and auth rate limits. ### 2026-05-08 diff --git a/README.md b/README.md index 21fd10a..e062f58 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,17 @@ TEMPEST_BLOB_MAX_BYTES=10000000 Server boot creates `account.sqlite`, `sequencer.sqlite`, and local storage directories inside `TEMPEST_DATA_DIR`. +## Development Tools + +Generate a TOTP code for a base32 secret: + +```bash +mix tempest.totp.code +``` + +Development email previews are available at `http://localhost:4000/dev/mailbox` +when the server is running. + ## Endpoints Available as of [2026-05-08](./CHANGELOG.md#2026-05-08) diff --git a/docs/tasks/11-security-oauth.md b/docs/tasks/11-security-oauth.md index 954ac5e..730143e 100644 --- a/docs/tasks/11-security-oauth.md +++ b/docs/tasks/11-security-oauth.md @@ -20,13 +20,13 @@ Goal: make Tempest usable by modern atproto clients without weakening account se - [x] T11-08: Add centralized permission engine used by OAuth, app passwords, and delegated access. - [x] T11-09: Implement current transition scopes and `blob:*/*`/`rpc:*` enforcement. - [x] T11-10: Implement app password create/list/revoke endpoints with scoped permissions. -- [ ] T11-11: Add email confirmation/update and password reset security log events. -- [ ] T11-12: Add MFA schema for TOTP, passkeys/WebAuthn, backup codes, and trusted devices. -- [ ] T11-13: Implement TOTP and backup-code flows. -- [ ] T11-14: Add session inventory and remote revoke. -- [ ] T11-15: Add delegated-access schema and revoke flow. -- [ ] T11-16: Add auth, reset, OAuth, and app-password rate limits. -- [ ] T11-17: Add Hurl smoke tests for OAuth metadata, PAR nonce, token issue, scope enforcement, and revoke. +- [x] T11-11: Add email confirmation/update and password reset security log events. +- [x] T11-12: Add MFA schema for TOTP, passkeys/WebAuthn, backup codes, and trusted devices. +- [x] T11-13: Implement TOTP and backup-code flows. +- [x] T11-14: Add session inventory and remote revoke. +- [x] T11-15: Add delegated-access schema and revoke flow. +- [x] T11-16: Add auth, reset, OAuth, and app-password rate limits. +- [x] T11-17: Add Hurl smoke tests for OAuth metadata, PAR nonce, token issue, scope enforcement, and revoke. - [ ] T11-18: Add hosted DID mode configuration (`did:plc` vs `did:web`) and refuse unsupported combinations. - [ ] T11-19: Implement PLC operation publish flow for `did:plc` hosted accounts (create/update). - [ ] T11-20: Add identity correctness checks used by OAuth flows (DID resolution + `#atproto_pds` service endpoint match). diff --git a/lib/mix/tasks/tempest.totp.code.ex b/lib/mix/tasks/tempest.totp.code.ex new file mode 100644 index 0000000..ef83375 --- /dev/null +++ b/lib/mix/tasks/tempest.totp.code.ex @@ -0,0 +1,14 @@ +defmodule Mix.Tasks.Tempest.Totp.Code do + use Mix.Task + + @shortdoc "Generate a TOTP code for a base32 secret" + + @impl Mix.Task + def run([secret]) do + Mix.shell().info(Tempest.Security.Totp.code(secret)) + end + + def run(_args) do + Mix.raise("usage: mix tempest.totp.code ") + end +end diff --git a/lib/tempest/accounts.ex b/lib/tempest/accounts.ex index 7bc8d67..e30e061 100644 --- a/lib/tempest/accounts.ex +++ b/lib/tempest/accounts.ex @@ -8,7 +8,7 @@ defmodule Tempest.Accounts do alias Tempest.Accounts.{Account, AppPasswords, AuthContext, Password, Session, Tokens} alias Tempest.Identity alias Tempest.RepoCore.{CarVerifier, Drisl} - alias Tempest.{Repo, RepoStorage, Sequencer} + alias Tempest.{Repo, RepoStorage, Security, Sequencer} @public_fields [:did, :handle, :email, :active, :status] @@ -60,6 +60,14 @@ defmodule Tempest.Accounts do def create_session(identifier, password) when is_binary(identifier) and is_binary(password) do identifier = normalize_identifier(identifier) + with :ok <- Security.RateLimiter.check(:login, identifier) do + create_session_after_rate_limit(identifier, password) + end + end + + def create_session(_identifier, _password), do: {:error, :invalid_credentials} + + defp create_session_after_rate_limit(identifier, password) do account = Account |> where([a], a.handle == ^identifier or a.email == ^identifier or a.did == ^identifier) @@ -84,8 +92,6 @@ defmodule Tempest.Accounts do end end - def create_session(_identifier, _password), do: {:error, :invalid_credentials} - def refresh_session(%AuthContext{token_type: :refresh, account: account, session: session}) do now = now() @@ -216,25 +222,29 @@ defmodule Tempest.Accounts do end def create_app_password(%AuthContext{token_type: :access, account: account}, params) do - case AppPasswords.create(account, params) do - {:ok, app_password} -> {:ok, app_password} - {:error, %Ecto.Changeset{} = changeset} -> {:error, :validation, changeset} - {:error, reason} -> {:error, reason} + with :ok <- Security.RateLimiter.check(:app_password, account.did) do + case AppPasswords.create(account, params) do + {:ok, app_password} -> {:ok, app_password} + {:error, %Ecto.Changeset{} = changeset} -> {:error, :validation, changeset} + {:error, reason} -> {:error, reason} + end end end def revoke_app_password(%AuthContext{token_type: :access, account: account}, params) do - case Map.get(params, "id") do - id when is_integer(id) -> - AppPasswords.revoke(account, id) - - id when is_binary(id) -> - with {int_id, ""} <- Integer.parse(id), - do: AppPasswords.revoke(account, int_id), - else: (_ -> {:error, :not_found}) - - _other -> - {:error, :not_found} + with :ok <- Security.RateLimiter.check(:app_password, account.did) do + case Map.get(params, "id") do + id when is_integer(id) -> + AppPasswords.revoke(account, id) + + id when is_binary(id) -> + with {int_id, ""} <- Integer.parse(id), + do: AppPasswords.revoke(account, int_id), + else: (_ -> {:error, :not_found}) + + _other -> + {:error, :not_found} + end end end diff --git a/lib/tempest/security.ex b/lib/tempest/security.ex new file mode 100644 index 0000000..1cdc58b --- /dev/null +++ b/lib/tempest/security.ex @@ -0,0 +1,245 @@ +defmodule Tempest.Security do + @moduledoc """ + Account security helpers for email tokens, MFA, delegated access, and audit events. + """ + + import Ecto.Query + + alias Tempest.Accounts.{Account, Password, Session} + alias Tempest.Security.{BackupCode, DelegatedAccessGrant, EmailToken, MfaCredential, RateLimiter, SecurityEvent, Totp} + alias Tempest.Repo + + @email_token_ttl_seconds 30 * 60 + + def log_event(%Account{} = account, event_type, metadata \\ %{}) do + attrs = %{ + account_id: account.id, + event_type: event_type, + metadata_json: Jason.encode!(metadata) + } + + %SecurityEvent{} |> SecurityEvent.changeset(attrs) |> Repo.insert() + end + + def issue_email_token(%Account{} = account, purpose, email \\ nil) do + raw = random_token(32) + email = email || account.email + now = now() + + attrs = %{ + account_id: account.id, + purpose: purpose, + email: email, + token_hash: hash(raw), + expires_at: DateTime.add(now, @email_token_ttl_seconds, :second) + } + + with {:ok, token} <- %EmailToken{} |> EmailToken.changeset(attrs) |> Repo.insert(), + {:ok, _event} <- log_event(account, "email_token.issued", %{purpose: purpose, email: email}) do + {:ok, %{token: raw, email_token: token}} + end + end + + def consume_email_token(raw, purpose) when is_binary(raw) do + now = now() + + Repo.transaction(fn -> + token = + EmailToken + |> where([t], t.token_hash == ^hash(raw) and t.purpose == ^purpose) + |> where([t], is_nil(t.used_at) and t.expires_at > ^now) + |> preload(:account) + |> Repo.one() + + case token do + nil -> + Repo.rollback(:invalid_token) + + %EmailToken{} = token -> + token |> Ecto.Changeset.change(%{used_at: now}) |> Repo.update!() + + case purpose do + "update_email" -> + token.account |> Ecto.Changeset.change(%{email: token.email}) |> Repo.update!() + + "reset_password" -> + revoke_sessions!(token.account) + + _other -> + :ok + end + + log_event(token.account, "email_token.consumed", %{purpose: purpose}) + token.account + end + end) + end + + def start_totp_enrollment(%Account{} = account, label \\ nil) do + secret = Totp.new_secret() + label = label || account.handle + + attrs = %{ + account_id: account.id, + type: "totp", + label: label, + secret_ciphertext: secret + } + + with {:ok, credential} <- %MfaCredential{} |> MfaCredential.changeset(attrs) |> Repo.insert(), + {:ok, _event} <- log_event(account, "mfa.totp.enrollment_started", %{}) do + {:ok, + %{ + credential: credential, + secret: secret, + uri: Totp.otpauth_uri(secret, "Tempest", label) + }} + end + end + + def confirm_totp(%Account{} = account, credential_id, code) do + with %MfaCredential{} = credential <- Repo.get_by(MfaCredential, id: credential_id, account_id: account.id), + true <- is_nil(credential.disabled_at), + true <- Totp.valid?(credential.secret_ciphertext, code), + {:ok, credential} <- credential |> Ecto.Changeset.change(%{confirmed_at: now()}) |> Repo.update(), + {:ok, backup_codes} <- rotate_backup_codes(account), + {:ok, _event} <- log_event(account, "mfa.totp.confirmed", %{}) do + {:ok, %{credential: credential, backup_codes: backup_codes}} + else + _error -> {:error, :invalid_totp} + end + end + + def verify_totp(%Account{} = account, code) do + if RateLimiter.check(:totp, account.did) == :ok and valid_totp_for_account?(account, code) do + log_event(account, "mfa.totp.verified", %{}) + :ok + else + {:error, :invalid_totp} + end + end + + def use_backup_code(%Account{} = account, code) do + now = now() + + {count, _rows} = + BackupCode + |> where([c], c.account_id == ^account.id and c.code_hash == ^hash(code) and is_nil(c.used_at)) + |> Repo.update_all(set: [used_at: now]) + + if count == 1 do + log_event(account, "mfa.backup_code.used", %{}) + :ok + else + {:error, :invalid_backup_code} + end + end + + def list_sessions(%Account{} = account) do + Session + |> where([s], s.account_id == ^account.id) + |> order_by([s], desc: s.inserted_at) + |> Repo.all() + end + + def revoke_session(%Account{} = account, session_id) do + now = now() + + {count, _rows} = + Session + |> where([s], s.account_id == ^account.id and s.id == ^session_id and is_nil(s.revoked_at)) + |> Repo.update_all(set: [revoked_at: now]) + + if count == 1 do + log_event(account, "session.revoked", %{session_id: session_id}) + :ok + else + {:error, :not_found} + end + end + + def create_delegation(%Account{} = owner, delegate_did, scope, opts \\ []) do + attrs = %{ + owner_account_id: owner.id, + delegate_did: delegate_did, + scope: scope, + expires_at: Keyword.get(opts, :expires_at) + } + + with {:ok, grant} <- %DelegatedAccessGrant{} |> DelegatedAccessGrant.changeset(attrs) |> Repo.insert(), + {:ok, _event} <- log_event(owner, "delegated_access.created", %{delegate_did: delegate_did, scope: scope}) do + {:ok, grant} + end + end + + def revoke_delegation(%Account{} = owner, grant_id) do + now = now() + + {count, _rows} = + DelegatedAccessGrant + |> where([g], g.owner_account_id == ^owner.id and g.id == ^grant_id and is_nil(g.revoked_at)) + |> Repo.update_all(set: [revoked_at: now]) + + if count == 1 do + log_event(owner, "delegated_access.revoked", %{grant_id: grant_id}) + :ok + else + {:error, :not_found} + end + end + + def request_password_reset(identifier) do + identifier = identifier |> to_string() |> String.trim() |> String.downcase() + + case Repo.get_by(Account, email: identifier) || Repo.get_by(Account, handle: identifier) || + Repo.get_by(Account, did: identifier) do + %Account{} = account -> issue_email_token(account, "reset_password") + nil -> {:ok, :accepted} + end + end + + def reset_password(raw_token, new_password) do + with :ok <- Password.validate(new_password), + {:ok, account} <- consume_email_token(raw_token, "reset_password") do + account + |> Ecto.Changeset.change(%{password_hash: Password.hash(new_password)}) + |> Repo.update() + end + end + + defp valid_totp_for_account?(%Account{} = account, code) do + MfaCredential + |> where([c], c.account_id == ^account.id and c.type == "totp") + |> where([c], not is_nil(c.confirmed_at) and is_nil(c.disabled_at)) + |> Repo.all() + |> Enum.any?(&Totp.valid?(&1.secret_ciphertext, code)) + end + + defp rotate_backup_codes(%Account{} = account) do + Repo.delete_all(from c in BackupCode, where: c.account_id == ^account.id and is_nil(c.used_at)) + + codes = for _ <- 1..10, do: backup_code() + + Enum.each(codes, fn code -> + %BackupCode{} + |> BackupCode.changeset(%{account_id: account.id, code_hash: hash(code)}) + |> Repo.insert!() + end) + + {:ok, codes} + end + + defp revoke_sessions!(%Account{} = account) do + Session + |> where([s], s.account_id == ^account.id and is_nil(s.revoked_at)) + |> Repo.update_all(set: [revoked_at: now()]) + end + + defp backup_code do + 10 |> :crypto.strong_rand_bytes() |> Base.encode32(case: :lower, padding: false) + end + + defp random_token(bytes), do: bytes |> :crypto.strong_rand_bytes() |> Base.url_encode64(padding: false) + defp hash(value), do: :crypto.hash(:sha256, value) |> Base.encode16(case: :lower) + defp now, do: DateTime.utc_now() |> DateTime.truncate(:second) +end diff --git a/lib/tempest/security/backup_code.ex b/lib/tempest/security/backup_code.ex new file mode 100644 index 0000000..5dfe651 --- /dev/null +++ b/lib/tempest/security/backup_code.ex @@ -0,0 +1,18 @@ +defmodule Tempest.Security.BackupCode do + use Ecto.Schema + import Ecto.Changeset + + schema "backup_codes" do + field :code_hash, :string + field :used_at, :utc_datetime + belongs_to :account, Tempest.Accounts.Account + + timestamps(type: :utc_datetime) + end + + def changeset(code, attrs) do + code + |> cast(attrs, [:account_id, :code_hash, :used_at]) + |> validate_required([:account_id, :code_hash]) + end +end diff --git a/lib/tempest/security/delegated_access_grant.ex b/lib/tempest/security/delegated_access_grant.ex new file mode 100644 index 0000000..ba1459e --- /dev/null +++ b/lib/tempest/security/delegated_access_grant.ex @@ -0,0 +1,20 @@ +defmodule Tempest.Security.DelegatedAccessGrant do + use Ecto.Schema + import Ecto.Changeset + + schema "delegated_access_grants" do + field :delegate_did, :string + field :scope, :string + field :expires_at, :utc_datetime + field :revoked_at, :utc_datetime + belongs_to :owner_account, Tempest.Accounts.Account + + timestamps(type: :utc_datetime) + end + + def changeset(grant, attrs) do + grant + |> cast(attrs, [:owner_account_id, :delegate_did, :scope, :expires_at, :revoked_at]) + |> validate_required([:owner_account_id, :delegate_did, :scope]) + end +end diff --git a/lib/tempest/security/email.ex b/lib/tempest/security/email.ex new file mode 100644 index 0000000..f6ae92e --- /dev/null +++ b/lib/tempest/security/email.ex @@ -0,0 +1,37 @@ +defmodule Tempest.Security.Email do + @moduledoc """ + Security email delivery helpers. + """ + + import Swoosh.Email + + alias Tempest.{Mailer, Security} + alias Tempest.Accounts.Account + + def deliver_confirmation(%Account{} = account) do + with {:ok, %{token: token}} <- Security.issue_email_token(account, "confirm_email") do + deliver(account.email, "Confirm your Tempest email", "Use this token to confirm your email: #{token}") + end + end + + def deliver_update(%Account{} = account, new_email) do + with {:ok, %{token: token}} <- Security.issue_email_token(account, "update_email", new_email) do + deliver(new_email, "Confirm your Tempest email change", "Use this token to confirm your new email: #{token}") + end + end + + def deliver_password_reset(%Account{} = account) do + with {:ok, %{token: token}} <- Security.issue_email_token(account, "reset_password") do + deliver(account.email, "Reset your Tempest password", "Use this token to reset your password: #{token}") + end + end + + defp deliver(to, subject, body) do + new() + |> from({"Tempest", "noreply@localhost"}) + |> to(to) + |> subject(subject) + |> text_body(body) + |> Mailer.deliver() + end +end diff --git a/lib/tempest/security/email_token.ex b/lib/tempest/security/email_token.ex new file mode 100644 index 0000000..cc69be7 --- /dev/null +++ b/lib/tempest/security/email_token.ex @@ -0,0 +1,24 @@ +defmodule Tempest.Security.EmailToken do + use Ecto.Schema + import Ecto.Changeset + + @purposes ~w(confirm_email update_email reset_password) + + schema "email_tokens" do + field :purpose, :string + field :email, :string + field :token_hash, :string + field :expires_at, :utc_datetime + field :used_at, :utc_datetime + belongs_to :account, Tempest.Accounts.Account + + timestamps(type: :utc_datetime) + end + + def changeset(token, attrs) do + token + |> cast(attrs, [:account_id, :purpose, :email, :token_hash, :expires_at, :used_at]) + |> validate_required([:account_id, :purpose, :email, :token_hash, :expires_at]) + |> validate_inclusion(:purpose, @purposes) + end +end diff --git a/lib/tempest/security/mfa_credential.ex b/lib/tempest/security/mfa_credential.ex new file mode 100644 index 0000000..c61e033 --- /dev/null +++ b/lib/tempest/security/mfa_credential.ex @@ -0,0 +1,25 @@ +defmodule Tempest.Security.MfaCredential do + use Ecto.Schema + import Ecto.Changeset + + @types ~w(totp passkey webauthn backup_codes trusted_device) + + schema "mfa_credentials" do + field :type, :string + field :label, :string + field :secret_ciphertext, :string + field :confirmed_at, :utc_datetime + field :disabled_at, :utc_datetime + field :last_used_at, :utc_datetime + belongs_to :account, Tempest.Accounts.Account + + timestamps(type: :utc_datetime) + end + + def changeset(credential, attrs) do + credential + |> cast(attrs, [:account_id, :type, :label, :secret_ciphertext, :confirmed_at, :disabled_at, :last_used_at]) + |> validate_required([:account_id, :type]) + |> validate_inclusion(:type, @types) + end +end diff --git a/lib/tempest/security/rate_limiter.ex b/lib/tempest/security/rate_limiter.ex new file mode 100644 index 0000000..6ff6118 --- /dev/null +++ b/lib/tempest/security/rate_limiter.ex @@ -0,0 +1,63 @@ +defmodule Tempest.Security.RateLimiter do + @moduledoc """ + Small in-memory rate limiter for auth and security endpoints. + + This is intentionally local-node state. It is enough for the single-node target + and keeps callers honest until a persistent/distributed limiter is justified. + """ + + @table :tempest_rate_limits + + def check(bucket, key, opts \\ []) when is_atom(bucket) and is_binary(key) do + table = table() + now = System.monotonic_time(:millisecond) + limit = Keyword.get(opts, :limit, default_limit(bucket)) + window_ms = Keyword.get(opts, :window_ms, default_window_ms(bucket)) + id = {bucket, key} + + hits = + table + |> :ets.lookup(id) + |> case do + [{^id, hits}] -> Enum.filter(hits, &(now - &1 < window_ms)) + [] -> [] + end + + if length(hits) >= limit do + {:error, :rate_limited} + else + :ets.insert(table, {id, [now | hits]}) + :ok + end + end + + def reset! do + case :ets.whereis(@table) do + :undefined -> :ok + _tid -> :ets.delete_all_objects(@table) + end + end + + defp table do + case :ets.whereis(@table) do + :undefined -> + try do + :ets.new(@table, [:named_table, :public, :set]) + rescue + ArgumentError -> @table + end + + _tid -> + @table + end + end + + defp default_limit(:login), do: 10 + defp default_limit(:oauth), do: 30 + defp default_limit(:app_password), do: 10 + defp default_limit(:email), do: 5 + defp default_limit(:totp), do: 8 + defp default_limit(_bucket), do: 20 + + defp default_window_ms(_bucket), do: 60_000 +end diff --git a/lib/tempest/security/security_event.ex b/lib/tempest/security/security_event.ex new file mode 100644 index 0000000..f25b30a --- /dev/null +++ b/lib/tempest/security/security_event.ex @@ -0,0 +1,18 @@ +defmodule Tempest.Security.SecurityEvent do + use Ecto.Schema + import Ecto.Changeset + + schema "security_events" do + field :event_type, :string + field :metadata_json, :string, default: "{}" + belongs_to :account, Tempest.Accounts.Account + + timestamps(type: :utc_datetime, updated_at: false) + end + + def changeset(event, attrs) do + event + |> cast(attrs, [:account_id, :event_type, :metadata_json]) + |> validate_required([:account_id, :event_type, :metadata_json]) + end +end diff --git a/lib/tempest/security/totp.ex b/lib/tempest/security/totp.ex new file mode 100644 index 0000000..eabd57a --- /dev/null +++ b/lib/tempest/security/totp.ex @@ -0,0 +1,55 @@ +defmodule Tempest.Security.Totp do + @moduledoc """ + Minimal RFC 6238 TOTP helper used for account MFA and dev tooling. + """ + + @period 30 + @digits 6 + + def new_secret(bytes \\ 20) do + bytes |> :crypto.strong_rand_bytes() |> Base.encode32(case: :upper, padding: false) + end + + def otpauth_uri(secret, issuer, label) do + query = URI.encode_query(%{secret: secret, issuer: issuer, algorithm: "SHA1", digits: @digits, period: @period}) + "otpauth://totp/#{URI.encode(issuer)}:#{URI.encode(label)}?#{query}" + end + + def code(secret, unix_time \\ System.system_time(:second)) do + counter = div(unix_time, @period) + + secret + |> decode_secret!() + |> hotp(counter) + end + + def valid?(secret, candidate, unix_time \\ System.system_time(:second), window \\ 1) do + candidate = to_string(candidate) |> String.trim() + + Enum.any?(-window..window, fn offset -> + expected = code(secret, unix_time + offset * @period) + Plug.Crypto.secure_compare(expected, candidate) + end) + rescue + _error -> false + end + + defp hotp(key, counter) do + hash = :crypto.mac(:hmac, :sha, key, <>) + offset = hash |> :binary.at(19) |> Bitwise.band(0x0F) + part = :binary.part(hash, offset, 4) + <> = part + + value + |> Bitwise.band(0x7FFF_FFFF) + |> rem(trunc(:math.pow(10, @digits))) + |> Integer.to_string() + |> String.pad_leading(@digits, "0") + end + + defp decode_secret!(secret) do + padding = rem(String.length(secret), 8) + padded = if padding == 0, do: secret, else: secret <> String.duplicate("=", 8 - padding) + Base.decode32!(padded, case: :mixed) + end +end diff --git a/lib/tempest/xrpc/server.ex b/lib/tempest/xrpc/server.ex index 200238c..9be1e2f 100644 --- a/lib/tempest/xrpc/server.ex +++ b/lib/tempest/xrpc/server.ex @@ -48,6 +48,9 @@ defmodule Tempest.Xrpc.Server do {:error, :invalid_credentials} -> {:error, 401, "AuthenticationRequired", "Invalid identifier or password"} + + {:error, :rate_limited} -> + {:error, 429, "RateLimitExceeded", "Too many authentication attempts"} end end @@ -76,6 +79,7 @@ defmodule Tempest.Xrpc.Server do case Accounts.create_app_password(conn.assigns.auth_context, params) do {:ok, response} -> {:ok, response} {:error, :invalid_scope} -> {:error, 400, "InvalidRequest", "invalid app password scope"} + {:error, :rate_limited} -> {:error, 429, "RateLimitExceeded", "Too many app password requests"} {:error, :validation, changeset} -> {:error, 400, "InvalidRequest", format_changeset_errors(changeset)} end end @@ -83,6 +87,7 @@ defmodule Tempest.Xrpc.Server do def revoke_app_password(conn, params, _method) do case Accounts.revoke_app_password(conn.assigns.auth_context, params) do :ok -> {:ok, %{}} + {:error, :rate_limited} -> {:error, 429, "RateLimitExceeded", "Too many app password requests"} {:error, :not_found} -> {:error, 404, "NotFound", "app password not found"} end end diff --git a/lib/tempest_web/controllers/oauth_controller.ex b/lib/tempest_web/controllers/oauth_controller.ex index 7b9c0d5..0f9a4ed 100644 --- a/lib/tempest_web/controllers/oauth_controller.ex +++ b/lib/tempest_web/controllers/oauth_controller.ex @@ -3,12 +3,14 @@ defmodule TempestWeb.OAuthController do alias Tempest.OAuth alias Tempest.OAuth.Dpop + alias Tempest.Security.RateLimiter def par(conn, params) do public_url = Tempest.Config.load!().public_url dpop = conn |> get_req_header("dpop") |> List.first() - case OAuth.create_par(params, dpop, public_url) do + case with :ok <- RateLimiter.check(:oauth, Map.get(params, "client_id", "unknown")), + do: OAuth.create_par(params, dpop, public_url) do {:ok, par} -> conn |> put_resp_header("dpop-nonce", Dpop.issue_nonce()) @@ -29,6 +31,9 @@ defmodule TempestWeb.OAuthController do {:error, :invalid_scope} -> conn |> put_status(400) |> json(%{"error" => "invalid_scope"}) + {:error, :rate_limited} -> + conn |> put_status(429) |> json(%{"error" => "RateLimitExceeded"}) + {:error, _reason} -> conn |> put_status(400) |> json(%{"error" => "invalid_request"}) end @@ -68,10 +73,12 @@ defmodule TempestWeb.OAuthController do dpop = conn |> get_req_header("dpop") |> List.first() result = - case Map.get(params, "grant_type") do - "authorization_code" -> OAuth.exchange_authorization_code(params, dpop, public_url) - "refresh_token" -> OAuth.refresh(params, dpop, public_url) - _other -> {:error, :unsupported_grant_type} + with :ok <- RateLimiter.check(:oauth, Map.get(params, "client_id", "unknown")) do + case Map.get(params, "grant_type") do + "authorization_code" -> OAuth.exchange_authorization_code(params, dpop, public_url) + "refresh_token" -> OAuth.refresh(params, dpop, public_url) + _other -> {:error, :unsupported_grant_type} + end end case result do @@ -86,6 +93,9 @@ defmodule TempestWeb.OAuthController do |> put_status(401) |> json(%{"error" => "use_dpop_nonce"}) + {:error, :rate_limited} -> + conn |> put_status(429) |> json(%{"error" => "RateLimitExceeded"}) + {:error, :unsupported_grant_type} -> conn |> put_status(400) |> json(%{"error" => "unsupported_grant_type"}) @@ -98,8 +108,12 @@ defmodule TempestWeb.OAuthController do end def revoke(conn, params) do - :ok = OAuth.revoke(Map.get(params, "token", "")) - send_resp(conn, 200, "") + with :ok <- RateLimiter.check(:oauth, Map.get(params, "client_id", "unknown")) do + :ok = OAuth.revoke(Map.get(params, "token", "")) + send_resp(conn, 200, "") + else + {:error, :rate_limited} -> conn |> put_status(429) |> json(%{"error" => "RateLimitExceeded"}) + end end defp authorization_page(par, error) do diff --git a/priv/repo/migrations/20260531190000_create_security_mfa_and_delegation.exs b/priv/repo/migrations/20260531190000_create_security_mfa_and_delegation.exs new file mode 100644 index 0000000..c634fad --- /dev/null +++ b/priv/repo/migrations/20260531190000_create_security_mfa_and_delegation.exs @@ -0,0 +1,68 @@ +defmodule Tempest.Repo.Migrations.CreateSecurityMfaAndDelegation do + use Ecto.Migration + + def change do + create table(:security_events) do + add :account_id, references(:accounts, on_delete: :delete_all), null: false + add :event_type, :text, null: false + add :metadata_json, :text, null: false, default: "{}" + + timestamps(type: :utc_datetime, updated_at: false) + end + + create index(:security_events, [:account_id]) + create index(:security_events, [:event_type]) + + create table(:email_tokens) do + add :account_id, references(:accounts, on_delete: :delete_all), null: false + add :purpose, :text, null: false + add :email, :text, null: false + add :token_hash, :text, null: false + add :expires_at, :utc_datetime, null: false + add :used_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + create unique_index(:email_tokens, [:token_hash]) + create index(:email_tokens, [:account_id, :purpose]) + + create table(:mfa_credentials) do + add :account_id, references(:accounts, on_delete: :delete_all), null: false + add :type, :text, null: false + add :label, :text + add :secret_ciphertext, :text + add :confirmed_at, :utc_datetime + add :disabled_at, :utc_datetime + add :last_used_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + create index(:mfa_credentials, [:account_id, :type]) + + create table(:backup_codes) do + add :account_id, references(:accounts, on_delete: :delete_all), null: false + add :code_hash, :text, null: false + add :used_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + create unique_index(:backup_codes, [:code_hash]) + create index(:backup_codes, [:account_id]) + + create table(:delegated_access_grants) do + add :owner_account_id, references(:accounts, on_delete: :delete_all), null: false + add :delegate_did, :text, null: false + add :scope, :text, null: false + add :expires_at, :utc_datetime + add :revoked_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + create index(:delegated_access_grants, [:owner_account_id]) + create index(:delegated_access_grants, [:delegate_did]) + end +end diff --git a/test/smoke/oauth-security.hurl b/test/smoke/oauth-security.hurl new file mode 100644 index 0000000..e6e1e10 --- /dev/null +++ b/test/smoke/oauth-security.hurl @@ -0,0 +1,48 @@ +GET {{base_url}}/.well-known/oauth-protected-resource +HTTP 200 +[Asserts] +header "content-type" contains "application/json" +jsonpath "$.resource" exists +jsonpath "$.authorization_servers[0]" exists + +GET {{base_url}}/.well-known/oauth-authorization-server +HTTP 200 +[Asserts] +header "content-type" contains "application/json" +jsonpath "$.issuer" exists +jsonpath "$.pushed_authorization_request_endpoint" exists +jsonpath "$.token_endpoint" exists + +GET {{base_url}}/oauth/jwks +HTTP 200 +[Asserts] +header "content-type" contains "application/json" +jsonpath "$.keys" isCollection + +POST {{base_url}}/oauth/par +[FormParams] +client_id: did:web:client.example.com +redirect_uri: https://client.example.com/cb +scope: atproto +response_type: code +code_challenge: abc +code_challenge_method: S256 +HTTP 401 +[Captures] +dpop_nonce: header "dpop-nonce" +[Asserts] +jsonpath "$.error" == "invalid_dpop_proof" + +POST {{base_url}}/oauth/token +[FormParams] +grant_type: unsupported +client_id: did:web:client.example.com +HTTP 400 +[Asserts] +jsonpath "$.error" == "unsupported_grant_type" + +POST {{base_url}}/oauth/revoke +[FormParams] +token: not-a-real-token +client_id: did:web:client.example.com +HTTP 200 diff --git a/test/tempest/security_test.exs b/test/tempest/security_test.exs new file mode 100644 index 0000000..055d192 --- /dev/null +++ b/test/tempest/security_test.exs @@ -0,0 +1,86 @@ +defmodule Tempest.SecurityTest do + use Tempest.DataCase, async: false + + alias Tempest.Accounts + alias Tempest.Accounts.{Session, Tokens} + alias Tempest.Security + alias Tempest.Security.{BackupCode, DelegatedAccessGrant, EmailToken, MfaCredential, RateLimiter, SecurityEvent, Totp} + + import Ecto.Query + + setup do + RateLimiter.reset!() + + {:ok, response} = + Accounts.create_account(%{ + "handle" => "security-#{System.unique_integer([:positive])}.test", + "email" => "security-#{System.unique_integer([:positive])}@example.com", + "password" => "correct horse battery staple" + }) + + account = Tempest.Repo.get_by!(Tempest.Accounts.Account, did: response["did"]) + {:ok, account: account} + end + + test "email tokens are hashed, single-use, and logged", %{account: account} do + {:ok, %{token: raw}} = Security.issue_email_token(account, "confirm_email") + + refute Tempest.Repo.get_by(EmailToken, token_hash: raw) + assert Tempest.Repo.get_by(EmailToken, token_hash: hash(raw)) + + assert {:ok, _account} = Security.consume_email_token(raw, "confirm_email") + assert {:error, :invalid_token} = Security.consume_email_token(raw, "confirm_email") + + assert Tempest.Repo.exists?(from e in SecurityEvent, where: e.account_id == ^account.id) + end + + test "password reset updates password and revokes sessions", %{account: account} do + {:ok, login} = Accounts.create_session(account.handle, "correct horse battery staple") + {:ok, %{token: raw}} = Security.issue_email_token(account, "reset_password") + + assert {:ok, _account} = Security.reset_password(raw, "new correct horse battery staple") + assert {:error, :invalid_token} = Accounts.authenticate_refresh(login["refreshJwt"]) + assert {:ok, _login} = Accounts.create_session(account.handle, "new correct horse battery staple") + end + + test "totp enrollment confirms with current code and creates backup codes", %{account: account} do + {:ok, %{credential: credential, secret: secret, uri: uri}} = Security.start_totp_enrollment(account) + + assert uri =~ "otpauth://totp/" + assert %MfaCredential{confirmed_at: nil} = Tempest.Repo.get!(MfaCredential, credential.id) + + code = Totp.code(secret) + assert {:ok, %{backup_codes: backup_codes}} = Security.confirm_totp(account, credential.id, code) + assert length(backup_codes) == 10 + assert :ok = Security.verify_totp(account, code) + + [backup | _] = backup_codes + assert :ok = Security.use_backup_code(account, backup) + assert {:error, :invalid_backup_code} = Security.use_backup_code(account, backup) + assert Tempest.Repo.aggregate(from(c in BackupCode, where: c.account_id == ^account.id), :count) == 10 + end + + test "session inventory and remote revoke", %{account: account} do + {:ok, login} = Accounts.create_session(account.handle, "correct horse battery staple") + sessions = Security.list_sessions(account) + session = Enum.find(sessions, &(&1.token_hash == Tokens.refresh_token_hash(login["refreshJwt"]))) + + assert %Session{} = session + assert :ok = Security.revoke_session(account, session.id) + assert {:error, :invalid_token} = Accounts.authenticate_refresh(login["refreshJwt"]) + end + + test "delegated access grants can be revoked", %{account: account} do + assert {:ok, grant} = Security.create_delegation(account, "did:plc:delegate", "atproto") + assert %DelegatedAccessGrant{} = Tempest.Repo.get(DelegatedAccessGrant, grant.id) + assert :ok = Security.revoke_delegation(account, grant.id) + assert Tempest.Repo.get!(DelegatedAccessGrant, grant.id).revoked_at + end + + test "rate limiter returns rate_limited after configured attempts" do + assert :ok = RateLimiter.check(:login, "ratelimit", limit: 1, window_ms: 60_000) + assert {:error, :rate_limited} = RateLimiter.check(:login, "ratelimit", limit: 1, window_ms: 60_000) + end + + defp hash(value), do: :crypto.hash(:sha256, value) |> Base.encode16(case: :lower) +end diff --git a/test/tempest_web/controllers/oauth_flow_test.exs b/test/tempest_web/controllers/oauth_flow_test.exs new file mode 100644 index 0000000..967726c --- /dev/null +++ b/test/tempest_web/controllers/oauth_flow_test.exs @@ -0,0 +1,102 @@ +defmodule TempestWeb.OAuthFlowTest do + use TempestWeb.ConnCase, async: false + + alias Tempest.Accounts + alias Tempest.OAuth.Dpop + + @password "correct horse battery staple" + @client_id "did:web:client.example.com" + @redirect_uri "https://client.example.com/cb" + + setup do + {:ok, account} = + Accounts.create_account(%{ + "handle" => "oauth-flow-#{System.unique_integer([:positive])}.test", + "email" => "oauth-flow-#{System.unique_integer([:positive])}@example.com", + "password" => @password + }) + + {:ok, account: account} + end + + test "authorization-code flow issues scoped DPoP token and revokes it", %{conn: conn, account: account} do + par_conn = + conn + |> put_req_header("dpop", dpop("POST", "http://localhost:4002/oauth/par", Dpop.issue_nonce())) + |> post(~p"/oauth/par", %{ + "client_id" => @client_id, + "redirect_uri" => @redirect_uri, + "scope" => "atproto", + "response_type" => "code", + "code_challenge" => code_challenge("verifier"), + "code_challenge_method" => "S256" + }) + + %{"request_uri" => request_uri} = json_response(par_conn, 200) + + authorize_conn = + conn + |> recycle() + |> post(~p"/oauth/authorize", %{ + "request_uri" => request_uri, + "identifier" => account["handle"], + "password" => @password + }) + + [location] = get_resp_header(authorize_conn, "location") + code = location |> URI.parse() |> Map.fetch!(:query) |> URI.decode_query() |> Map.fetch!("code") + + token_conn = + conn + |> recycle() + |> put_req_header("dpop", dpop("POST", "http://localhost:4002/oauth/token", Dpop.issue_nonce())) + |> post(~p"/oauth/token", %{ + "grant_type" => "authorization_code", + "client_id" => @client_id, + "redirect_uri" => @redirect_uri, + "code" => code, + "code_verifier" => "verifier" + }) + + token_response = json_response(token_conn, 200) + + assert token_response["token_type"] == "DPoP" + assert token_response["scope"] == "atproto" + assert token_response["sub"] == account["did"] + assert is_binary(token_response["access_token"]) + + revoke_conn = + conn + |> recycle() + |> post(~p"/oauth/revoke", %{"token" => token_response["access_token"], "client_id" => @client_id}) + + assert response(revoke_conn, 200) == "" + end + + defp dpop(method, url, nonce) do + header = %{ + "typ" => "dpop+jwt", + "alg" => "ES256", + "jwk" => %{"kty" => "EC", "crv" => "P-256", "x" => "x", "y" => "y"} + } + + payload = %{ + "htu" => url, + "htm" => method, + "iat" => DateTime.utc_now() |> DateTime.to_unix(), + "jti" => Ecto.UUID.generate(), + "nonce" => nonce + } + + [header, payload, "signature"] + |> Enum.map(&encode_part/1) + |> Enum.join(".") + end + + defp encode_part(value) when is_map(value), do: value |> Jason.encode!() |> encode_part() + defp encode_part(value), do: Base.url_encode64(value, padding: false) + + defp code_challenge(verifier) do + :crypto.hash(:sha256, verifier) |> Base.url_encode64(padding: false) + end +end -- 2.51.2