diff --git a/lib/who_need_help/accounts.ex b/lib/who_need_help/accounts.ex index a5bf87d..0eaf618 100644 --- a/lib/who_need_help/accounts.ex +++ b/lib/who_need_help/accounts.ex @@ -32,6 +32,7 @@ defmodule WhoNeedHelp.Accounts do "display_name" => :display_name, "email" => :email, "email_verified" => :email_verified, + "hosted_domain" => :hosted_domain, "locale" => :locale, "provider_uid" => :provider_uid, "terms_accepted" => :terms_accepted @@ -423,6 +424,21 @@ defmodule WhoNeedHelp.Accounts do end end + def change_google_registration(identity_attrs, registration_attrs \\ %{}) + when is_map(registration_attrs) do + case normalize_google_identity(identity_attrs) do + {:ok, identity_attrs} -> + %User{} + |> User.registration_changeset( + google_registration_attrs(identity_attrs, registration_attrs), + validate_unique: false + ) + + {:error, _reason} -> + User.registration_changeset(%User{}, %{}, validate_unique: false) + end + end + def register_user_by_google(identity_attrs, registration_attrs) when is_map(registration_attrs) do with {:ok, identity_attrs} <- normalize_google_identity(identity_attrs) do @@ -451,6 +467,36 @@ defmodule WhoNeedHelp.Accounts do def register_user_by_google(_identity_attrs, _registration_attrs), do: {:error, :invalid_registration} + def link_google_identity_by_authoritative_email(%User{id: user_id}, identity_attrs) do + with {:ok, identity_attrs} <- normalize_google_identity(identity_attrs), + true <- WhoNeedHelp.GoogleAuth.authoritative_email?(identity_attrs) do + Repo.transact(fn -> + user = + User + |> where([user], user.id == ^user_id) + |> lock("FOR UPDATE") + |> Repo.one() + + cond do + is_nil(user) or user.moderation_status == :suspended -> + {:error, :not_found} + + normalized_email(user.email) != identity_attrs.email -> + {:error, :email_mismatch} + + true -> + with {:ok, user} <- confirm_google_email_owner(user), + {:ok, identity} <- upsert_google_identity_for_user(user, identity_attrs) do + {:ok, {user, identity}} + end + end + end) + else + false -> {:error, :email_not_authoritative} + {:error, _reason} = error -> error + end + end + def link_google_identity(%User{id: user_id}, identity_attrs) do with {:ok, identity_attrs} <- normalize_google_identity(identity_attrs) do Repo.transact(fn -> @@ -499,12 +545,7 @@ defmodule WhoNeedHelp.Accounts do if existing_user do {:error, :email_already_registered} else - attrs = %{ - "email" => identity_attrs.email, - "display_name" => identity_attrs.display_name, - "locale" => registration_value(registration_attrs, "locale", "en"), - "terms_accepted" => registration_value(registration_attrs, "terms_accepted", false) - } + attrs = google_registration_attrs(identity_attrs, registration_attrs) with {:ok, user} <- register_user(attrs), {:ok, user} <- user |> User.confirm_changeset() |> Repo.update(), @@ -585,11 +626,30 @@ defmodule WhoNeedHelp.Accounts do end end + defp confirm_google_email_owner(%User{confirmed_at: nil} = user) do + user + |> User.confirm_changeset() + |> Repo.update() + end + + defp confirm_google_email_owner(%User{} = user), do: {:ok, user} + + defp google_registration_attrs(identity_attrs, registration_attrs) do + %{ + "email" => identity_attrs.email, + "display_name" => + registration_value(registration_attrs, "display_name", identity_attrs.display_name), + "locale" => registration_value(registration_attrs, "locale", "en"), + "terms_accepted" => registration_value(registration_attrs, "terms_accepted", false) + } + end + defp normalize_google_identity(identity_attrs) when is_map(identity_attrs) do provider_uid = registration_value(identity_attrs, "provider_uid", nil) email = registration_value(identity_attrs, "email", nil) email_verified = registration_value(identity_attrs, "email_verified", false) display_name = registration_value(identity_attrs, "display_name", nil) + hosted_domain = registration_value(identity_attrs, "hosted_domain", nil) cond do email_verified != true -> @@ -610,7 +670,8 @@ defmodule WhoNeedHelp.Accounts do provider_uid: provider_uid, email: email |> String.trim() |> String.downcase(), email_verified: true, - display_name: display_name |> String.trim() |> String.slice(0, 80) + display_name: display_name |> String.trim() |> String.slice(0, 80), + hosted_domain: normalize_hosted_domain(hosted_domain) }} end end @@ -618,6 +679,18 @@ defmodule WhoNeedHelp.Accounts do defp normalize_google_identity(_identity_attrs), do: {:error, :invalid_provider_identity} + defp normalize_hosted_domain(domain) when is_binary(domain) do + case domain |> String.trim() |> String.downcase() do + "" -> nil + normalized -> String.slice(normalized, 0, 255) + end + end + + defp normalize_hosted_domain(_domain), do: nil + + defp normalized_email(email) when is_binary(email), + do: email |> String.trim() |> String.downcase() + defp registration_value(attrs, key, default) do case Map.fetch(attrs, key) do {:ok, value} -> diff --git a/lib/who_need_help/google_auth.ex b/lib/who_need_help/google_auth.ex index 76fc4d0..6d2554e 100644 --- a/lib/who_need_help/google_auth.ex +++ b/lib/who_need_help/google_auth.ex @@ -10,7 +10,8 @@ defmodule WhoNeedHelp.GoogleAuth do provider_uid: String.t(), email: String.t(), email_verified: true, - display_name: String.t() + display_name: String.t(), + hosted_domain: String.t() | nil } @callback enabled?() :: boolean() @@ -33,6 +34,36 @@ defmodule WhoNeedHelp.GoogleAuth do def verify_id_token(id_token, nonce), do: adapter().verify_id_token(id_token, nonce) + @doc """ + Returns whether Google is authoritative for the identity's current email. + + Gmail addresses are hosted by Google. A non-empty hosted-domain claim marks + a verified Google Workspace identity. Other third-party email addresses still + require the user to prove access to the existing local account before linking. + """ + def authoritative_email?( + %{ + email: email, + email_verified: true, + hosted_domain: hosted_domain + } = identity + ) + when is_binary(email) and is_binary(hosted_domain) do + String.trim(hosted_domain) != "" or authoritative_email?(Map.delete(identity, :hosted_domain)) + end + + def authoritative_email?(%{ + email: email, + email_verified: true + }) + when is_binary(email) do + normalized_email = email |> String.trim() |> String.downcase() + + String.ends_with?(normalized_email, "@gmail.com") + end + + def authoritative_email?(_identity), do: false + defp adapter do Application.get_env( :who_need_help, diff --git a/lib/who_need_help/google_auth/assent_adapter.ex b/lib/who_need_help/google_auth/assent_adapter.ex index bc4e411..a96a7d4 100644 --- a/lib/who_need_help/google_auth/assent_adapter.ex +++ b/lib/who_need_help/google_auth/assent_adapter.ex @@ -94,7 +94,8 @@ defmodule WhoNeedHelp.GoogleAuth.AssentAdapter do provider_uid: provider_uid, email: normalized_email, email_verified: true, - display_name: display_name(normalized_email, Map.get(claims, "name")) + display_name: display_name(normalized_email, Map.get(claims, "name")), + hosted_domain: hosted_domain(Map.get(claims, "hd")) }} end @@ -132,6 +133,15 @@ defmodule WhoNeedHelp.GoogleAuth.AssentAdapter do else: "Google user" end + defp hosted_domain(domain) when is_binary(domain) do + case domain |> String.trim() |> String.downcase() do + "" -> nil + normalized -> String.slice(normalized, 0, 255) + end + end + + defp hosted_domain(_domain), do: nil + defp random_url_token(length) do length |> :crypto.strong_rand_bytes() diff --git a/lib/who_need_help_web/controllers/google_auth_controller.ex b/lib/who_need_help_web/controllers/google_auth_controller.ex index e88a552..47c81cd 100644 --- a/lib/who_need_help_web/controllers/google_auth_controller.ex +++ b/lib/who_need_help_web/controllers/google_auth_controller.ex @@ -71,12 +71,15 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do |> GoogleAuthPending.delete() |> google_account_unavailable(~p"/users/log-in") - existing_account -> - render(conn, :complete, - pending: pending, - existing_account: not is_nil(existing_account), - page_title: gettext("Continue with Google") - ) + %Accounts.User{} = existing_account -> + if GoogleAuth.authoritative_email?(identity_attrs(pending)) do + connect_authoritative_google_and_log_in(conn, existing_account, pending) + else + render_google_completion(conn, pending, true) + end + + nil -> + render_google_completion(conn, pending, false) end {:error, _reason} -> @@ -88,17 +91,14 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do end def complete_registration(conn, %{"google_registration" => params}) when is_map(params) do - terms_accepted = params["terms_accepted"] in [true, "true", "on", "1"] - - with true <- terms_accepted, - {:ok, pending} <- GoogleAuthPending.fetch(conn), + with {:ok, pending} <- GoogleAuthPending.fetch(conn), nil <- Accounts.get_user_by_email(pending.email), {:ok, _limit} <- RateLimiter.check(:registration_email, pending.email), {:ok, {user, _identity}} <- - Accounts.register_user_by_google(identity_attrs(pending), %{ - "locale" => normalize_locale(params["locale"] || pending.locale), - "terms_accepted" => true - }) do + Accounts.register_user_by_google( + identity_attrs(pending), + normalize_google_registration_params(params, pending) + ) do _ = ProductAnalytics.increment_for_user(user, "account.registered", "google") conn @@ -106,13 +106,10 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do |> put_flash(:info, gettext("Your account was created with Google.")) |> UserAuth.log_in_user(user) else - false -> + {:error, %Ecto.Changeset{} = changeset} -> conn - |> put_flash( - :error, - gettext("Confirm that you are 18 or older and accept the safety rules first.") - ) - |> redirect(to: ~p"/auth/google/complete") + |> put_status(:unprocessable_entity) + |> render_google_completion_from_session(changeset) %Accounts.User{} -> conn @@ -354,10 +351,7 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do |> UserAuth.log_in_user(user) {:error, :not_linked} -> - case GoogleAuthPending.put(conn, identity_attrs, flow_locale(flow)) do - {:ok, conn} -> redirect(conn, to: ~p"/auth/google/complete") - {:error, _reason} -> google_account_unavailable(conn, ~p"/users/log-in") - end + continue_unlinked_google(conn, flow, identity_attrs, ~p"/users/log-in") {:error, _reason} -> google_account_unavailable(conn, ~p"/users/log-in") @@ -365,13 +359,7 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do end defp finish_flow(conn, "register", flow, identity_attrs) do - case GoogleAuthPending.put(conn, identity_attrs, flow_locale(flow)) do - {:ok, conn} -> - redirect(conn, to: ~p"/auth/google/complete") - - {:error, _reason} -> - google_account_unavailable(conn, ~p"/users/register") - end + continue_unlinked_google(conn, flow, identity_attrs, ~p"/users/register") end defp finish_flow(conn, "link", flow, identity_attrs) do @@ -421,6 +409,90 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do end) end + defp connect_authoritative_google_and_log_in(conn, existing_account, pending) do + result = + Repo.transact(fn -> + with {:ok, {user, identity}} <- + Accounts.link_google_identity_by_authoritative_email( + existing_account, + identity_attrs(pending) + ), + {:ok, _audit} <- + Trust.audit( + user.id, + "auth_identity.connected", + "auth_identity", + identity.id, + %{"provider" => "google", "method" => "authoritative_google_email"} + ) do + {:ok, user} + end + end) + + case result do + {:ok, user} -> + conn + |> GoogleAuthPending.delete() + |> put_flash(:info, gettext("Welcome back!")) + |> UserAuth.log_in_user(user) + + {:error, reason} -> + Logger.warning("Unable to connect authoritative Google identity (#{error_name(reason)})") + + conn + |> GoogleAuthPending.delete() + |> google_account_unavailable(~p"/users/log-in") + end + end + + defp continue_unlinked_google(conn, flow, identity_attrs, failure_path) do + case Accounts.get_user_by_email(identity_attrs.email) do + %Accounts.User{moderation_status: :suspended} -> + google_account_unavailable(conn, failure_path) + + %Accounts.User{} = existing_account -> + if GoogleAuth.authoritative_email?(identity_attrs) do + pending = Map.put(identity_attrs, :locale, flow_locale(flow)) + connect_authoritative_google_and_log_in(conn, existing_account, pending) + else + store_pending_google(conn, flow, identity_attrs, failure_path) + end + + nil -> + store_pending_google(conn, flow, identity_attrs, failure_path) + end + end + + defp store_pending_google(conn, flow, identity_attrs, failure_path) do + case GoogleAuthPending.put(conn, identity_attrs, flow_locale(flow)) do + {:ok, conn} -> redirect(conn, to: ~p"/auth/google/complete") + {:error, _reason} -> google_account_unavailable(conn, failure_path) + end + end + + defp render_google_completion_from_session(conn, changeset) do + case GoogleAuthPending.fetch(conn) do + {:ok, pending} -> render_google_completion(conn, pending, false, changeset) + {:error, _reason} -> expired_pending_redirect(conn) + end + end + + defp render_google_completion(conn, pending, existing_account, changeset \\ nil) do + changeset = + changeset || + Accounts.change_google_registration(identity_attrs(pending), %{ + "display_name" => pending.display_name, + "locale" => pending.locale + }) + + render(conn, :complete, + pending: pending, + existing_account: existing_account, + registration_form: Phoenix.Component.to_form(changeset, as: :google_registration), + page_title: gettext("Continue with Google") + ) + end + defp unlink_google_identity_and_audit(current_user) do Repo.transact(fn -> with {:ok, identity} <- Accounts.unlink_google_identity(current_user), @@ -474,10 +546,27 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do defp normalize_locale(locale) when locale in @supported_locales, do: locale defp normalize_locale(_locale), do: "en" + defp normalize_google_registration_params(params, pending) do + %{ + "display_name" => normalize_display_name(params["display_name"]), + "locale" => normalize_locale(params["locale"] || pending.locale), + "terms_accepted" => params["terms_accepted"] in [true, "true", "on", "1"] + } + end + + defp normalize_display_name(name) when is_binary(name), do: String.trim(name) + defp normalize_display_name(_name), do: "" + defp flow_locale(flow), do: normalize_locale(flow["locale"]) defp identity_attrs(pending) do - Map.take(pending, [:provider_uid, :email, :email_verified, :display_name]) + Map.take(pending, [ + :provider_uid, + :email, + :email_verified, + :display_name, + :hosted_domain + ]) end defp error_name(error) when is_atom(error), do: Atom.to_string(error) diff --git a/lib/who_need_help_web/controllers/google_auth_html/complete.html.heex b/lib/who_need_help_web/controllers/google_auth_html/complete.html.heex index 77ae194..da657b6 100644 --- a/lib/who_need_help_web/controllers/google_auth_html/complete.html.heex +++ b/lib/who_need_help_web/controllers/google_auth_html/complete.html.heex @@ -67,17 +67,37 @@ <.form - for={%{}} - as={:google_registration} + for={@registration_form} action={~p"/auth/google/complete-registration"} - class="space-y-3" + class="space-y-4" > - +
+ {gettext("Other people will see this name on requests, activities, and reviews.")} +
+