How often someone may sign in with a password, ask for a password reset, ask for a sign-in link
and try their current password to change it: the backend and the keys
MishkaCmsCore.Accounts.User gives ash_rate_limiter for those four actions, and what to tell
whoever was refused.
The first three are counted twice, per IP address and per account; a registration once, per IP address; a password change once, per signed-in account. Every attempt counts, right or wrong. The DSL numbers are the defaults:
| action | per IP address | per account | setting |
|---|---|---|---|
sign_in_with_password | 30 per 5 minutes | 10 per 15 minutes | security.sign_in_per_* |
request_password_reset_token | 10 per hour | 3 per hour | security.reset_request_per_* |
request_magic_link | 10 per hour | 3 per hour | security.magic_link_per_* |
register_with_password | 10 per hour | — | security.register_per_ip |
register_with_provider, every sign-in provider's | 30 per 5 minutes | — | security.sign_in_per_ip |
change_password | — | 10 per 15 minutes | security.change_password_per_account |
verify_with_totp, confirm_setup_with_totp, verify_with_recovery_code | — | 5 per 5 minutes, together | security.totp_per_account |
A master changes the numbers on the Settings page (MishkaCmsCore.Settings); the windows stay.
hit/3 reads the setting from the settings cache on every attempt, so a change takes effect on
the next one. config :mishka_cms_core, MishkaCmsCore.Accounts.AuthRateLimit, limit: n holds every action to
n instead, for a test suite that signs in from one address.
Every site is counted apart, masters together as one more. An account is its email, or its id
once signed in; the address is the one RemoteIp reads with remote_ip_options/0, from the
ash_authentication_request context: AshAuthentication writes it on the browser's /api/auth
routes, MishkaCmsCore.Runtime.Plugs.AuthRateLimitPlug on the JSON:API and the sign-in pages'
forms from MishkaCmsWeb.RuntimeLive's @client_request. An IPv6 client is counted by its /64,
and an IPv4 address written as IPv6 (::ffff:192.0.2.7) as that IPv4 address. An action run
without it is counted under the address "unknown".
Summary
Functions
The key an attempt is counted under by the account it names, within its site:
"auth:<action>:account:<site id | master>:<digest of the email>".
The key an attempt is counted under by the address it came from, within its site:
"auth:<action>:ip:<site id | master>:<address>". The address of an IPv6 client is its /64
("2001:db8:1:2::/64"), and an IPv4 address written as IPv6 is that IPv4 address.
The key a signed-in account's attempt on itself is counted under, within its site:
"auth:<action>:account:<site id | master>:<user id>". An action input names its account in the
:user argument, as verify_with_totp does.
What to tell whoever was refused: a title naming what they asked for too often, and how long to wait.
Counts one hit on key in MishkaCmsCore.Runtime.States.RateLimitState, held to the setting
"security.<action>_per_<ip|account>" the key names, or limit while that holds no positive
integer.
The refusal anywhere inside reason, or nil: an error or error class, a form's raw_errors,
an AshAuthentication.Errors.AuthenticationFailed, or the JSON:API errors document written for
one.
The options RemoteIp reads the client's address with: x-forwarded-for, right to left, passing
over private addresses and config :mishka_cms_core, :trusted_proxies (IPs or CIDR ranges), but
never over Cloudflare Workers' address for a request to another zone, 2a06:98c0:3600::103. The
endpoint's plug RemoteIp and the /auth socket take the same ones.
Reads config :mishka_cms_core, :trusted_proxies the way every request does, and raises
ArgumentError naming the first entry that is no IP or CIDR range. Call it before the endpoint
starts, so a mistyped TRUSTED_PROXY_RANGES stops the boot.
Types
@type limited() :: %{ action: :sign_in | :reset_request | :magic_link | :register | :change_password | :totp, key: :ip | :account, retry_after: pos_integer() }
Functions
@spec by_account(Ash.Query.t() | Ash.ActionInput.t(), map()) :: String.t()
The key an attempt is counted under by the account it names, within its site:
"auth:<action>:account:<site id | master>:<digest of the email>".
@spec by_address(Ash.Query.t() | Ash.ActionInput.t() | Ash.Changeset.t(), map()) :: String.t()
The key an attempt is counted under by the address it came from, within its site:
"auth:<action>:ip:<site id | master>:<address>". The address of an IPv6 client is its /64
("2001:db8:1:2::/64"), and an IPv4 address written as IPv6 is that IPv4 address.
@spec by_user(Ash.Changeset.t() | Ash.ActionInput.t(), map()) :: String.t()
The key a signed-in account's attempt on itself is counted under, within its site:
"auth:<action>:account:<site id | master>:<user id>". An action input names its account in the
:user argument, as verify_with_totp does.
What to tell whoever was refused: a title naming what they asked for too often, and how long to wait.
AuthRateLimit.describe(%{action: :sign_in, key: :account, retry_after: 90})
#=> %{title: "Too many sign-in attempts", text: "Try again in 2 minutes."}
Counts one hit on key in MishkaCmsCore.Runtime.States.RateLimitState, held to the setting
"security.<action>_per_<ip|account>" the key names, or limit while that holds no positive
integer.
The refusal anywhere inside reason, or nil: an error or error class, a form's raw_errors,
an AshAuthentication.Errors.AuthenticationFailed, or the JSON:API errors document written for
one.
AuthRateLimit.limited(Form.raw_errors(form, for_path: :all))
#=> %{action: :sign_in, key: :account, retry_after: 812}
@spec remote_ip_options() :: keyword()
The options RemoteIp reads the client's address with: x-forwarded-for, right to left, passing
over private addresses and config :mishka_cms_core, :trusted_proxies (IPs or CIDR ranges), but
never over Cloudflare Workers' address for a request to another zone, 2a06:98c0:3600::103. The
endpoint's plug RemoteIp and the /auth socket take the same ones.
@spec validate_trusted_proxies!() :: :ok
Reads config :mishka_cms_core, :trusted_proxies the way every request does, and raises
ArgumentError naming the first entry that is no IP or CIDR range. Call it before the endpoint
starts, so a mistyped TRUSTED_PROXY_RANGES stops the boot.