Skip to content

Commit fbb7683

Browse files
committed
feat: rate limit TOTP submissions
1 parent 4c1e17c commit fbb7683

File tree

9 files changed

+132
-89
lines changed

9 files changed

+132
-89
lines changed

lib/algora/application.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ defmodule Algora.Application do
2222
Algora.Github.TokenPool,
2323
Algora.Github.Poller.RootSupervisor,
2424
Algora.ScreenshotQueue,
25+
Algora.RateLimit,
2526
# Start to serve requests, typically the last entry
2627
AlgoraWeb.Endpoint,
2728
Algora.Stargazer,

lib/algora/rate_limit.ex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
defmodule Algora.RateLimit do
2+
@moduledoc false
3+
use Hammer, backend: :ets
4+
end

lib/algora_web/controllers/user_auth.ex

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,4 +382,18 @@ defmodule AlgoraWeb.UserAuth do
382382
(NimbleTOTP.valid?(secret, code, period: totp_period(), time: time) or
383383
NimbleTOTP.valid?(secret, code, period: totp_period(), time: time - totp_period()))
384384
end
385+
386+
def verify_totp(rate_limit_key, secret, code) do
387+
case Algora.RateLimit.hit(rate_limit_key, :timer.minutes(1), 5) do
388+
{:allow, _} ->
389+
if valid_totp?(secret, code) do
390+
:ok
391+
else
392+
{:error, :invalid_totp}
393+
end
394+
395+
{:deny, _} ->
396+
{:error, :rate_limit_exceeded}
397+
end
398+
end
385399
end

lib/algora_web/live/onboarding/dev.ex

Lines changed: 48 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -181,48 +181,54 @@ defmodule AlgoraWeb.Onboarding.DevLive do
181181

182182
@impl true
183183
def handle_event("send_signup_code", %{"user" => %{"signup_code" => code}}, socket) do
184-
if AlgoraWeb.UserAuth.valid_totp?(socket.assigns.secret, String.trim(code)) do
185-
user_handle =
186-
socket.assigns.email
187-
|> String.replace(~r/[^a-zA-Z0-9]/, "-")
188-
|> String.downcase()
189-
190-
email = socket.assigns.email
191-
192-
tech_stack = get_field(socket.assigns.info_form.source, :tech_stack) || []
193-
intentions = get_field(socket.assigns.info_form.source, :intentions) || []
194-
195-
opts = [
196-
tech_stack: tech_stack,
197-
seeking_bounties: "bounties" in intentions,
198-
seeking_contracts: "contracts" in intentions,
199-
seeking_jobs: "jobs" in intentions
200-
]
201-
202-
{:ok, user} =
203-
case Repo.get_by(User, email: email) do
204-
nil ->
205-
%User{
206-
type: :individual,
207-
last_context: "personal",
208-
handle: Organizations.ensure_unique_handle(user_handle),
209-
avatar_url: Algora.Util.get_gravatar_url(email)
210-
}
211-
|> User.signup_changeset(%{email: email})
212-
|> User.generate_id()
213-
|> change(opts)
214-
|> Repo.insert()
215-
216-
existing_user ->
217-
existing_user
218-
|> change(opts)
219-
|> Repo.update()
220-
end
221-
222-
{:noreply, redirect(socket, to: AlgoraWeb.UserAuth.generate_login_path(user.email, socket.assigns[:return_to]))}
223-
else
224-
throttle()
225-
{:noreply, put_flash(socket, :error, "Invalid signup code")}
184+
case AlgoraWeb.UserAuth.verify_totp(socket.assigns.email, socket.assigns.secret, String.trim(code)) do
185+
:ok ->
186+
user_handle =
187+
socket.assigns.email
188+
|> String.replace(~r/[^a-zA-Z0-9]/, "-")
189+
|> String.downcase()
190+
191+
email = socket.assigns.email
192+
193+
tech_stack = get_field(socket.assigns.info_form.source, :tech_stack) || []
194+
intentions = get_field(socket.assigns.info_form.source, :intentions) || []
195+
196+
opts = [
197+
tech_stack: tech_stack,
198+
seeking_bounties: "bounties" in intentions,
199+
seeking_contracts: "contracts" in intentions,
200+
seeking_jobs: "jobs" in intentions
201+
]
202+
203+
{:ok, user} =
204+
case Repo.get_by(User, email: email) do
205+
nil ->
206+
%User{
207+
type: :individual,
208+
last_context: "personal",
209+
handle: Organizations.ensure_unique_handle(user_handle),
210+
avatar_url: Algora.Util.get_gravatar_url(email)
211+
}
212+
|> User.signup_changeset(%{email: email})
213+
|> User.generate_id()
214+
|> change(opts)
215+
|> Repo.insert()
216+
217+
existing_user ->
218+
existing_user
219+
|> change(opts)
220+
|> Repo.update()
221+
end
222+
223+
{:noreply, redirect(socket, to: AlgoraWeb.UserAuth.generate_login_path(user.email, socket.assigns[:return_to]))}
224+
225+
{:error, :rate_limit_exceeded} ->
226+
throttle()
227+
{:noreply, put_flash(socket, :error, "Too many attempts. Please try again later.")}
228+
229+
{:error, :invalid_totp} ->
230+
throttle()
231+
{:noreply, put_flash(socket, :error, "Invalid signup code")}
226232
end
227233
end
228234

lib/algora_web/live/onboarding/org.ex

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -376,20 +376,23 @@ defmodule AlgoraWeb.Onboarding.OrgLive do
376376
case changeset do
377377
%{valid?: true} = changeset ->
378378
code = get_field(changeset, :code)
379+
email = get_field(socket.assigns.email_form.source, :email)
379380

380-
if AlgoraWeb.UserAuth.valid_totp?(socket.assigns.secret, String.trim(code)) do
381-
{:noreply,
382-
socket
383-
|> LocalStore.assign_cached(:verification_form, to_form(changeset))
384-
|> LocalStore.assign_cached(:code_valid?, true)
385-
|> LocalStore.assign_cached(:step, :preferences)}
386-
else
387-
throttle()
388-
389-
{:noreply,
390-
socket
391-
|> LocalStore.assign_cached(:verification_form, to_form(changeset))
392-
|> LocalStore.assign_cached(:code_valid?, false)}
381+
case AlgoraWeb.UserAuth.verify_totp(email, socket.assigns.secret, String.trim(code)) do
382+
:ok ->
383+
{:noreply,
384+
socket
385+
|> LocalStore.assign_cached(:verification_form, to_form(changeset))
386+
|> LocalStore.assign_cached(:code_valid?, true)
387+
|> LocalStore.assign_cached(:step, :preferences)}
388+
389+
{:error, :rate_limit_exceeded} ->
390+
throttle()
391+
{:noreply, put_flash(socket, :error, "Too many attempts. Please try again later.")}
392+
393+
{:error, :invalid_totp} ->
394+
throttle()
395+
{:noreply, put_flash(socket, :error, "Invalid verification code")}
393396
end
394397

395398
%{valid?: false} = changeset ->

lib/algora_web/live/org/dashboard_live.ex

Lines changed: 31 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ defmodule AlgoraWeb.Org.DashboardLive do
2828
alias AlgoraWeb.Forms.BountyForm
2929
alias AlgoraWeb.Forms.ContractForm
3030
alias AlgoraWeb.Forms.TipForm
31-
alias Swoosh.Email
3231

3332
require Logger
3433

@@ -827,31 +826,37 @@ defmodule AlgoraWeb.Org.DashboardLive do
827826

828827
@impl true
829828
def handle_event("send_login_code", %{"user" => %{"login_code" => code}}, socket) do
830-
if AlgoraWeb.UserAuth.valid_totp?(socket.assigns.secret, String.trim(code)) do
831-
handle =
832-
socket.assigns.email
833-
|> Organizations.generate_handle_from_email()
834-
|> Organizations.ensure_unique_handle()
835-
836-
case Repo.get_by(User, email: socket.assigns.email) do
837-
nil ->
838-
{:ok, user} =
839-
socket.assigns.current_user
840-
|> Ecto.Changeset.change(handle: handle, email: socket.assigns.email)
841-
|> Repo.update()
842-
843-
{:noreply,
844-
socket
845-
|> assign(:current_user, user)
846-
|> assign_achievements()}
847-
848-
user ->
849-
socket = switch_from_preview(socket, user)
850-
{:noreply, socket}
851-
end
852-
else
853-
throttle()
854-
{:noreply, put_flash(socket, :error, "Invalid login code")}
829+
case AlgoraWeb.UserAuth.verify_totp(socket.assigns.email, socket.assigns.secret, String.trim(code)) do
830+
:ok ->
831+
handle =
832+
socket.assigns.email
833+
|> Organizations.generate_handle_from_email()
834+
|> Organizations.ensure_unique_handle()
835+
836+
case Repo.get_by(User, email: socket.assigns.email) do
837+
nil ->
838+
{:ok, user} =
839+
socket.assigns.current_user
840+
|> Ecto.Changeset.change(handle: handle, email: socket.assigns.email)
841+
|> Repo.update()
842+
843+
{:noreply,
844+
socket
845+
|> assign(:current_user, user)
846+
|> assign_achievements()}
847+
848+
user ->
849+
socket = switch_from_preview(socket, user)
850+
{:noreply, socket}
851+
end
852+
853+
{:error, :rate_limit_exceeded} ->
854+
throttle()
855+
{:noreply, put_flash(socket, :error, "Too many attempts. Please try again later.")}
856+
857+
{:error, :invalid_totp} ->
858+
throttle()
859+
{:noreply, put_flash(socket, :error, "Invalid login code")}
855860
end
856861
end
857862

lib/algora_web/live/sign_in_live.ex

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -289,14 +289,22 @@ defmodule AlgoraWeb.SignInLive do
289289

290290
@impl true
291291
def handle_event("send_login_code", %{"user" => %{"login_code" => code}}, socket) do
292-
if AlgoraWeb.UserAuth.valid_totp?(socket.assigns.secret, String.trim(code)) do
293-
Accounts.ensure_org_context(socket.assigns.user)
294-
295-
{:noreply,
296-
redirect(socket, to: AlgoraWeb.UserAuth.generate_login_path(socket.assigns.user.email, socket.assigns[:return_to]))}
297-
else
298-
throttle()
299-
{:noreply, put_flash(socket, :error, "Invalid login code")}
292+
case AlgoraWeb.UserAuth.verify_totp(socket.assigns.user.email, socket.assigns.secret, String.trim(code)) do
293+
:ok ->
294+
Accounts.ensure_org_context(socket.assigns.user)
295+
296+
{:noreply,
297+
redirect(socket,
298+
to: AlgoraWeb.UserAuth.generate_login_path(socket.assigns.user.email, socket.assigns[:return_to])
299+
)}
300+
301+
{:error, :rate_limit_exceeded} ->
302+
throttle()
303+
{:noreply, put_flash(socket, :error, "Too many attempts. Please try again later.")}
304+
305+
{:error, :invalid_totp} ->
306+
throttle()
307+
{:noreply, put_flash(socket, :error, "Invalid login code")}
300308
end
301309
end
302310

mix.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ defmodule Algora.MixProject do
9696
{:plug_canonical_host, "~> 2.0"},
9797
{:timex, "~> 3.7"},
9898
{:yaml_elixir, "~> 2.9"},
99+
{:hammer, "~> 7.0"},
99100
# ex_aws
100101
{:ex_aws, "~> 2.1"},
101102
{:ex_aws_s3, "~> 2.0"},

mix.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"floki": {:hex, :floki, "0.37.1", "d7aaee758c8a5b4a7495799a4260754fec5530d95b9c383c03b27359dea117cf", [:mix], [], "hexpm", "673d040cb594d31318d514590246b6dd587ed341d3b67e17c1c0eb8ce7ca6f04"},
4040
"gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
4141
"hackney": {:hex, :hackney, "1.23.0", "55cc09077112bcb4a69e54be46ed9bc55537763a96cd4a80a221663a7eafd767", [:rebar3], [{:certifi, "~> 2.14.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "6cd1c04cd15c81e5a493f167b226a15f0938a84fc8f0736ebe4ddcab65c0b44e"},
42+
"hammer": {:hex, :hammer, "7.0.1", "136edcd81af44becbe6b73a958c109e2364ab0dc026d7b19892037dc2632078c", [:mix], [], "hexpm", "796edf14ab2aa80df72080210fcf944ee5e8868d8ece7a7511264d802f58cc2d"},
4243
"hpax": {:hex, :hpax, "1.0.2", "762df951b0c399ff67cc57c3995ec3cf46d696e41f0bba17da0518d94acd4aac", [:mix], [], "hexpm", "2f09b4c1074e0abd846747329eaa26d535be0eb3d189fa69d812bfb8bfefd32f"},
4344
"httpoison": {:hex, :httpoison, "2.2.1", "87b7ed6d95db0389f7df02779644171d7319d319178f6680438167d7b69b1f3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "51364e6d2f429d80e14fe4b5f8e39719cacd03eb3f9a9286e61e216feac2d2df"},
4445
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},

0 commit comments

Comments
 (0)