MishkaCmsCore.Accounts.AuthRateLimit (Mishka CMS Core v0.0.1)

Copy Markdown View Source

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:

actionper IP addressper accountsetting
sign_in_with_password30 per 5 minutes10 per 15 minutessecurity.sign_in_per_*
request_password_reset_token10 per hour3 per hoursecurity.reset_request_per_*
request_magic_link10 per hour3 per hoursecurity.magic_link_per_*
register_with_password10 per hour—security.register_per_ip
register_with_provider, every sign-in provider's30 per 5 minutes—security.sign_in_per_ip
change_password—10 per 15 minutessecurity.change_password_per_account
verify_with_totp, confirm_setup_with_totp, verify_with_recovery_code—5 per 5 minutes, togethersecurity.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

limited()

@type limited() :: %{
  action:
    :sign_in
    | :reset_request
    | :magic_link
    | :register
    | :change_password
    | :totp,
  key: :ip | :account,
  retry_after: pos_integer()
}

Functions

by_account(subject, context)

@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>".

by_address(subject, context)

@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.

by_user(changeset, context)

@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.

describe(map)

@spec describe(limited()) :: %{title: String.t(), text: String.t()}

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."}

hit(key, per, limit)

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.

limited(errors)

@spec limited(term()) :: limited() | nil

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}

remote_ip_options()

@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.

validate_trusted_proxies!()

@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.