Skip to content

Commit db476d4

Browse files
committed
feat: use TOTPs in org onboarding
1 parent 5c18fe1 commit db476d4

File tree

2 files changed

+121
-196
lines changed

2 files changed

+121
-196
lines changed

lib/algora_web/controllers/oauth_callback_controller.ex

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -59,25 +59,6 @@ defmodule AlgoraWeb.OAuthCallbackController do
5959
redirect(conn, to: "/")
6060
end
6161

62-
def new(conn, %{"provider" => "email", "email" => email, "token" => token, "return_to" => "/onboarding/org"}) do
63-
case AlgoraWeb.UserAuth.verify_login_code(token, email) do
64-
{:ok, login_token} ->
65-
conn
66-
|> put_session(:onboarding_email, login_token.email)
67-
|> put_session(:onboarding_domain, login_token.domain)
68-
|> put_session(:onboarding_tech_stack, Enum.join(login_token.tech_stack, ","))
69-
|> put_session(:onboarding_token, token)
70-
|> redirect(to: "/onboarding/org")
71-
72-
{:error, reason} ->
73-
Logger.debug("invalid email auth token #{inspect(reason)}")
74-
75-
conn
76-
|> put_flash(:error, "Invalid token")
77-
|> redirect(to: "/")
78-
end
79-
end
80-
8162
def new(conn, %{"provider" => "email", "email" => email, "token" => token} = params) do
8263
with {:ok, _login_token} <- AlgoraWeb.UserAuth.verify_login_code(token, email),
8364
{:ok, user} <- get_or_register_user(email) do

lib/algora_web/live/onboarding/org.ex

Lines changed: 121 additions & 177 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ defmodule AlgoraWeb.Onboarding.OrgLive do
1111
alias AlgoraWeb.Components.Wordmarks
1212
alias AlgoraWeb.LocalStore
1313
alias Phoenix.LiveView.AsyncResult
14-
alias Swoosh.Email
1514

1615
require Logger
1716

@@ -152,63 +151,6 @@ defmodule AlgoraWeb.Onboarding.OrgLive do
152151

153152
# === LIFECYCLE === #
154153

155-
def mount(
156-
_params,
157-
%{
158-
"onboarding_email" => email,
159-
"onboarding_domain" => domain,
160-
"onboarding_tech_stack" => tech_stack,
161-
"onboarding_token" => login_code
162-
},
163-
socket
164-
) do
165-
if Accounts.get_user_by_email(email) do
166-
# user already exists, so onboarding is complete
167-
# allow user to login with token until expiry
168-
{:ok, redirect(socket, to: AlgoraWeb.UserAuth.login_path(email, login_code))}
169-
else
170-
tech_stack_form =
171-
%TechStackForm{}
172-
|> TechStackForm.changeset(%{tech_stack: String.split(tech_stack, ",")})
173-
|> to_form()
174-
175-
email_form =
176-
%EmailForm{}
177-
|> EmailForm.changeset(%{email: email, domain: domain})
178-
|> to_form()
179-
180-
verificaiton_form =
181-
%VerificationForm{}
182-
|> VerificationForm.changeset(%{code: login_code})
183-
|> to_form()
184-
185-
case AlgoraWeb.UserAuth.verify_login_code(login_code, email) do
186-
{:ok, _login_token} ->
187-
{:ok,
188-
socket
189-
|> assign(:tech_stack_form, tech_stack_form)
190-
|> assign(:email_form, email_form)
191-
|> assign(:verification_form, verificaiton_form)
192-
|> assign(:preferences_form, PreferencesForm.init())
193-
|> assign(:step, :preferences)
194-
|> assign(:steps, @steps)
195-
|> assign(:code_sent?, true)
196-
|> assign(:code_valid?, true)
197-
|> assign(:timezone, nil)
198-
|> assign(:user_metadata, AsyncResult.loading())
199-
|> assign_matching_devs()
200-
|> start_async(:fetch_metadata, fn -> Algora.Crawler.fetch_user_metadata(email) end)
201-
|> assign(:user_metadata, AsyncResult.loading())}
202-
203-
{:error, _invalid} ->
204-
{:ok,
205-
socket
206-
|> put_flash(:error, "Invalid auth token")
207-
|> redirect(to: "/")}
208-
end
209-
end
210-
end
211-
212154
def mount(_params, _session, socket) do
213155
{:ok,
214156
socket
@@ -221,6 +163,7 @@ defmodule AlgoraWeb.Onboarding.OrgLive do
221163
|> assign(:code_sent?, false)
222164
|> assign(:code_valid?, nil)
223165
|> assign(:timezone, nil)
166+
|> assign(:secret, nil)
224167
|> assign(:user_metadata, AsyncResult.loading())
225168
|> assign_matching_devs()}
226169
end
@@ -284,22 +227,13 @@ defmodule AlgoraWeb.Onboarding.OrgLive do
284227
case changeset do
285228
%{valid?: true} = changeset ->
286229
email = get_field(changeset, :email)
287-
domain = get_field(changeset, :domain)
288-
tech_stack = get_field(socket.assigns.tech_stack_form.source, :tech_stack)
289-
login_code = AlgoraWeb.UserAuth.generate_login_code(email, domain, tech_stack)
290-
291-
name = email |> String.split("@") |> List.first() |> String.capitalize()
230+
{secret, code} = AlgoraWeb.UserAuth.generate_totp()
292231

293-
{:ok, _} =
294-
Email.new()
295-
|> Email.to({name, email})
296-
|> Email.from({"Algora", "[email protected]"})
297-
|> Email.subject("Algora sign-in verification code")
298-
|> Email.text_body(AlgoraWeb.UserAuth.login_email(email, name, login_code))
299-
|> Algora.Mailer.deliver()
232+
{:ok, _} = Accounts.deliver_totp_signup_email(email, code)
300233

301234
{:noreply,
302235
socket
236+
|> LocalStore.assign_cached(:secret, secret)
303237
|> LocalStore.assign_cached(:email_form, to_form(changeset))
304238
|> LocalStore.assign_cached(:code_sent?, true)
305239
|> assign_matching_devs()
@@ -323,102 +257,110 @@ defmodule AlgoraWeb.Onboarding.OrgLive do
323257
email = get_field(socket.assigns.email_form.source, :email)
324258
domain = get_field(socket.assigns.email_form.source, :domain)
325259
tech_stack = get_field(socket.assigns.tech_stack_form.source, :tech_stack)
326-
login_code = get_field(socket.assigns.verification_form.source, :code)
327260
preferences = changeset.changes
328261

329-
metadata =
330-
case socket.assigns.user_metadata do
331-
%AsyncResult{ok?: true, result: metadata} -> metadata
332-
_ -> %{}
333-
end
334-
335-
org_name =
336-
case get_in(metadata, [:org, :display_name]) do
337-
nil ->
338-
domain
339-
|> String.split(".")
340-
|> List.first()
341-
|> String.capitalize()
342-
343-
name ->
344-
name
345-
end
346-
347-
org_handle =
348-
case get_in(metadata, [:org, :handle]) do
349-
nil ->
350-
domain
351-
|> String.split(".")
352-
|> List.first()
353-
|> String.downcase()
354-
355-
handle ->
356-
handle
357-
end
358-
359-
user_handle = Organizations.generate_handle_from_email(email)
360-
361-
org_params =
362-
%{
363-
display_name: org_name,
364-
bio:
365-
get_in(metadata, [:org, :bio]) ||
366-
get_in(metadata, [:org, :og_description]) ||
367-
get_in(metadata, [:org, :og_title]),
368-
avatar_url: get_in(metadata, [:org, :avatar_url]) || get_in(metadata, [:org, :favicon_url]),
369-
handle: org_handle,
370-
domain: domain,
371-
og_title: get_in(metadata, [:org, :og_title]),
372-
og_image_url: get_in(metadata, [:org, :og_image_url]),
373-
tech_stack: tech_stack,
374-
hiring: get_in(preferences, [:hiring]),
375-
categories: get_in(preferences, [:categories]),
376-
website_url: get_in(metadata, [:org, :website_url]),
377-
twitter_url: get_in(metadata, [:org, :socials, :twitter]),
378-
github_url: get_in(metadata, [:org, :socials, :github]),
379-
youtube_url: get_in(metadata, [:org, :socials, :youtube]),
380-
twitch_url: get_in(metadata, [:org, :socials, :twitch]),
381-
discord_url: get_in(metadata, [:org, :socials, :discord]),
382-
slack_url: get_in(metadata, [:org, :socials, :slack]),
383-
linkedin_url: get_in(metadata, [:org, :socials, :linkedin])
384-
}
385-
386-
user_params =
387-
%{
388-
email: email,
389-
display_name: user_handle,
390-
avatar_url: get_in(metadata, [:avatar_url]),
391-
handle: user_handle,
392-
tech_stack: tech_stack,
393-
timezone: socket.assigns.timezone
394-
}
395-
396-
member_params =
397-
%{
398-
role: :admin
399-
}
400-
401-
params =
402-
%{
403-
organization: org_params,
404-
user: user_params,
405-
member: member_params
406-
}
407-
408-
socket =
409-
case Algora.Organizations.onboard_organization(params) do
410-
{:ok, _} ->
411-
redirect(socket, to: AlgoraWeb.UserAuth.login_path(email, login_code))
412-
413-
{:error, name, changeset, _created} ->
414-
Logger.error("error onboarding organization: #{inspect(name)} #{inspect(changeset)}")
415-
416-
socket
417-
|> put_flash(:error, "Something went wrong. Please try again.")
418-
|> redirect(to: "/")
419-
end
420-
421-
{:noreply, socket}
262+
if socket.assigns.code_valid? do
263+
metadata =
264+
case socket.assigns.user_metadata do
265+
%AsyncResult{ok?: true, result: metadata} -> metadata
266+
_ -> %{}
267+
end
268+
269+
org_name =
270+
case get_in(metadata, [:org, :display_name]) do
271+
nil ->
272+
domain
273+
|> String.split(".")
274+
|> List.first()
275+
|> String.capitalize()
276+
277+
name ->
278+
name
279+
end
280+
281+
org_handle =
282+
case get_in(metadata, [:org, :handle]) do
283+
nil ->
284+
domain
285+
|> String.split(".")
286+
|> List.first()
287+
|> String.downcase()
288+
289+
handle ->
290+
handle
291+
end
292+
293+
user_handle = Organizations.generate_handle_from_email(email)
294+
295+
org_params =
296+
%{
297+
display_name: org_name,
298+
bio:
299+
get_in(metadata, [:org, :bio]) ||
300+
get_in(metadata, [:org, :og_description]) ||
301+
get_in(metadata, [:org, :og_title]),
302+
avatar_url: get_in(metadata, [:org, :avatar_url]) || get_in(metadata, [:org, :favicon_url]),
303+
handle: org_handle,
304+
domain: domain,
305+
og_title: get_in(metadata, [:org, :og_title]),
306+
og_image_url: get_in(metadata, [:org, :og_image_url]),
307+
tech_stack: tech_stack,
308+
hiring: get_in(preferences, [:hiring]),
309+
categories: get_in(preferences, [:categories]),
310+
website_url: get_in(metadata, [:org, :website_url]),
311+
twitter_url: get_in(metadata, [:org, :socials, :twitter]),
312+
github_url: get_in(metadata, [:org, :socials, :github]),
313+
youtube_url: get_in(metadata, [:org, :socials, :youtube]),
314+
twitch_url: get_in(metadata, [:org, :socials, :twitch]),
315+
discord_url: get_in(metadata, [:org, :socials, :discord]),
316+
slack_url: get_in(metadata, [:org, :socials, :slack]),
317+
linkedin_url: get_in(metadata, [:org, :socials, :linkedin])
318+
}
319+
320+
user_params =
321+
%{
322+
email: email,
323+
display_name: user_handle,
324+
avatar_url: get_in(metadata, [:avatar_url]),
325+
handle: user_handle,
326+
tech_stack: tech_stack,
327+
timezone: socket.assigns.timezone
328+
}
329+
330+
member_params =
331+
%{
332+
role: :admin
333+
}
334+
335+
params =
336+
%{
337+
organization: org_params,
338+
user: user_params,
339+
member: member_params
340+
}
341+
342+
socket =
343+
case Algora.Organizations.onboard_organization(params) do
344+
{:ok, _} ->
345+
redirect(socket, to: AlgoraWeb.UserAuth.generate_login_path(email))
346+
347+
{:error, name, changeset, _created} ->
348+
Logger.error("error onboarding organization: #{inspect(name)} #{inspect(changeset)}")
349+
350+
socket
351+
|> put_flash(:error, "Something went wrong. Please try again.")
352+
|> redirect(to: "/")
353+
end
354+
355+
{:noreply, socket}
356+
else
357+
throttle()
358+
359+
{:noreply,
360+
socket
361+
|> put_flash(:error, "Invalid verification code")
362+
|> LocalStore.assign_cached(:step, :email)}
363+
end
422364

423365
%{valid?: false} ->
424366
{:noreply, LocalStore.assign_cached(socket, :preferences_form, to_form(changeset))}
@@ -434,20 +376,20 @@ defmodule AlgoraWeb.Onboarding.OrgLive do
434376
case changeset do
435377
%{valid?: true} = changeset ->
436378
code = get_field(changeset, :code)
437-
email = get_field(socket.assigns.email_form.source, :email)
438379

439-
case AlgoraWeb.UserAuth.verify_login_code(code, email) do
440-
{:ok, _login_token} ->
441-
{:noreply,
442-
socket
443-
|> LocalStore.assign_cached(:verification_form, to_form(changeset))
444-
|> LocalStore.assign_cached(:step, :preferences)}
445-
446-
{:error, _reason} ->
447-
{:noreply,
448-
socket
449-
|> LocalStore.assign_cached(:verification_form, to_form(changeset))
450-
|> LocalStore.assign_cached(:code_valid?, false)}
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)}
451393
end
452394

453395
%{valid?: false} = changeset ->
@@ -864,4 +806,6 @@ defmodule AlgoraWeb.Onboarding.OrgLive do
864806
def handle_async(:fetch_metadata, {:exit, reason}, socket) do
865807
{:noreply, assign(socket, :user_metadata, AsyncResult.failed(socket.assigns.user_metadata, reason))}
866808
end
809+
810+
defp throttle, do: :timer.sleep(1000)
867811
end

0 commit comments

Comments
 (0)