feat(auth): streamline verified Google onboarding
This commit is contained in:
parent
51d55fa73f
commit
bcbe417a79
|
|
@ -32,6 +32,7 @@ defmodule WhoNeedHelp.Accounts do
|
||||||
"display_name" => :display_name,
|
"display_name" => :display_name,
|
||||||
"email" => :email,
|
"email" => :email,
|
||||||
"email_verified" => :email_verified,
|
"email_verified" => :email_verified,
|
||||||
|
"hosted_domain" => :hosted_domain,
|
||||||
"locale" => :locale,
|
"locale" => :locale,
|
||||||
"provider_uid" => :provider_uid,
|
"provider_uid" => :provider_uid,
|
||||||
"terms_accepted" => :terms_accepted
|
"terms_accepted" => :terms_accepted
|
||||||
|
|
@ -423,6 +424,21 @@ defmodule WhoNeedHelp.Accounts do
|
||||||
end
|
end
|
||||||
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)
|
def register_user_by_google(identity_attrs, registration_attrs)
|
||||||
when is_map(registration_attrs) do
|
when is_map(registration_attrs) do
|
||||||
with {:ok, identity_attrs} <- normalize_google_identity(identity_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),
|
def register_user_by_google(_identity_attrs, _registration_attrs),
|
||||||
do: {:error, :invalid_registration}
|
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
|
def link_google_identity(%User{id: user_id}, identity_attrs) do
|
||||||
with {:ok, identity_attrs} <- normalize_google_identity(identity_attrs) do
|
with {:ok, identity_attrs} <- normalize_google_identity(identity_attrs) do
|
||||||
Repo.transact(fn ->
|
Repo.transact(fn ->
|
||||||
|
|
@ -499,12 +545,7 @@ defmodule WhoNeedHelp.Accounts do
|
||||||
if existing_user do
|
if existing_user do
|
||||||
{:error, :email_already_registered}
|
{:error, :email_already_registered}
|
||||||
else
|
else
|
||||||
attrs = %{
|
attrs = google_registration_attrs(identity_attrs, registration_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)
|
|
||||||
}
|
|
||||||
|
|
||||||
with {:ok, user} <- register_user(attrs),
|
with {:ok, user} <- register_user(attrs),
|
||||||
{:ok, user} <- user |> User.confirm_changeset() |> Repo.update(),
|
{:ok, user} <- user |> User.confirm_changeset() |> Repo.update(),
|
||||||
|
|
@ -585,11 +626,30 @@ defmodule WhoNeedHelp.Accounts do
|
||||||
end
|
end
|
||||||
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
|
defp normalize_google_identity(identity_attrs) when is_map(identity_attrs) do
|
||||||
provider_uid = registration_value(identity_attrs, "provider_uid", nil)
|
provider_uid = registration_value(identity_attrs, "provider_uid", nil)
|
||||||
email = registration_value(identity_attrs, "email", nil)
|
email = registration_value(identity_attrs, "email", nil)
|
||||||
email_verified = registration_value(identity_attrs, "email_verified", false)
|
email_verified = registration_value(identity_attrs, "email_verified", false)
|
||||||
display_name = registration_value(identity_attrs, "display_name", nil)
|
display_name = registration_value(identity_attrs, "display_name", nil)
|
||||||
|
hosted_domain = registration_value(identity_attrs, "hosted_domain", nil)
|
||||||
|
|
||||||
cond do
|
cond do
|
||||||
email_verified != true ->
|
email_verified != true ->
|
||||||
|
|
@ -610,7 +670,8 @@ defmodule WhoNeedHelp.Accounts do
|
||||||
provider_uid: provider_uid,
|
provider_uid: provider_uid,
|
||||||
email: email |> String.trim() |> String.downcase(),
|
email: email |> String.trim() |> String.downcase(),
|
||||||
email_verified: true,
|
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
|
||||||
end
|
end
|
||||||
|
|
@ -618,6 +679,18 @@ defmodule WhoNeedHelp.Accounts do
|
||||||
defp normalize_google_identity(_identity_attrs),
|
defp normalize_google_identity(_identity_attrs),
|
||||||
do: {:error, :invalid_provider_identity}
|
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
|
defp registration_value(attrs, key, default) do
|
||||||
case Map.fetch(attrs, key) do
|
case Map.fetch(attrs, key) do
|
||||||
{:ok, value} ->
|
{:ok, value} ->
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,8 @@ defmodule WhoNeedHelp.GoogleAuth do
|
||||||
provider_uid: String.t(),
|
provider_uid: String.t(),
|
||||||
email: String.t(),
|
email: String.t(),
|
||||||
email_verified: true,
|
email_verified: true,
|
||||||
display_name: String.t()
|
display_name: String.t(),
|
||||||
|
hosted_domain: String.t() | nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@callback enabled?() :: boolean()
|
@callback enabled?() :: boolean()
|
||||||
|
|
@ -33,6 +34,36 @@ defmodule WhoNeedHelp.GoogleAuth do
|
||||||
def verify_id_token(id_token, nonce),
|
def verify_id_token(id_token, nonce),
|
||||||
do: adapter().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
|
defp adapter do
|
||||||
Application.get_env(
|
Application.get_env(
|
||||||
:who_need_help,
|
:who_need_help,
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,8 @@ defmodule WhoNeedHelp.GoogleAuth.AssentAdapter do
|
||||||
provider_uid: provider_uid,
|
provider_uid: provider_uid,
|
||||||
email: normalized_email,
|
email: normalized_email,
|
||||||
email_verified: true,
|
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
|
end
|
||||||
|
|
||||||
|
|
@ -132,6 +133,15 @@ defmodule WhoNeedHelp.GoogleAuth.AssentAdapter do
|
||||||
else: "Google user"
|
else: "Google user"
|
||||||
end
|
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
|
defp random_url_token(length) do
|
||||||
length
|
length
|
||||||
|> :crypto.strong_rand_bytes()
|
|> :crypto.strong_rand_bytes()
|
||||||
|
|
|
||||||
|
|
@ -71,12 +71,15 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do
|
||||||
|> GoogleAuthPending.delete()
|
|> GoogleAuthPending.delete()
|
||||||
|> google_account_unavailable(~p"/users/log-in")
|
|> google_account_unavailable(~p"/users/log-in")
|
||||||
|
|
||||||
existing_account ->
|
%Accounts.User{} = existing_account ->
|
||||||
render(conn, :complete,
|
if GoogleAuth.authoritative_email?(identity_attrs(pending)) do
|
||||||
pending: pending,
|
connect_authoritative_google_and_log_in(conn, existing_account, pending)
|
||||||
existing_account: not is_nil(existing_account),
|
else
|
||||||
page_title: gettext("Continue with Google")
|
render_google_completion(conn, pending, true)
|
||||||
)
|
end
|
||||||
|
|
||||||
|
nil ->
|
||||||
|
render_google_completion(conn, pending, false)
|
||||||
end
|
end
|
||||||
|
|
||||||
{:error, _reason} ->
|
{:error, _reason} ->
|
||||||
|
|
@ -88,17 +91,14 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do
|
||||||
end
|
end
|
||||||
|
|
||||||
def complete_registration(conn, %{"google_registration" => params}) when is_map(params) do
|
def complete_registration(conn, %{"google_registration" => params}) when is_map(params) do
|
||||||
terms_accepted = params["terms_accepted"] in [true, "true", "on", "1"]
|
with {:ok, pending} <- GoogleAuthPending.fetch(conn),
|
||||||
|
|
||||||
with true <- terms_accepted,
|
|
||||||
{:ok, pending} <- GoogleAuthPending.fetch(conn),
|
|
||||||
nil <- Accounts.get_user_by_email(pending.email),
|
nil <- Accounts.get_user_by_email(pending.email),
|
||||||
{:ok, _limit} <- RateLimiter.check(:registration_email, pending.email),
|
{:ok, _limit} <- RateLimiter.check(:registration_email, pending.email),
|
||||||
{:ok, {user, _identity}} <-
|
{:ok, {user, _identity}} <-
|
||||||
Accounts.register_user_by_google(identity_attrs(pending), %{
|
Accounts.register_user_by_google(
|
||||||
"locale" => normalize_locale(params["locale"] || pending.locale),
|
identity_attrs(pending),
|
||||||
"terms_accepted" => true
|
normalize_google_registration_params(params, pending)
|
||||||
}) do
|
) do
|
||||||
_ = ProductAnalytics.increment_for_user(user, "account.registered", "google")
|
_ = ProductAnalytics.increment_for_user(user, "account.registered", "google")
|
||||||
|
|
||||||
conn
|
conn
|
||||||
|
|
@ -106,13 +106,10 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do
|
||||||
|> put_flash(:info, gettext("Your account was created with Google."))
|
|> put_flash(:info, gettext("Your account was created with Google."))
|
||||||
|> UserAuth.log_in_user(user)
|
|> UserAuth.log_in_user(user)
|
||||||
else
|
else
|
||||||
false ->
|
{:error, %Ecto.Changeset{} = changeset} ->
|
||||||
conn
|
conn
|
||||||
|> put_flash(
|
|> put_status(:unprocessable_entity)
|
||||||
:error,
|
|> render_google_completion_from_session(changeset)
|
||||||
gettext("Confirm that you are 18 or older and accept the safety rules first.")
|
|
||||||
)
|
|
||||||
|> redirect(to: ~p"/auth/google/complete")
|
|
||||||
|
|
||||||
%Accounts.User{} ->
|
%Accounts.User{} ->
|
||||||
conn
|
conn
|
||||||
|
|
@ -354,10 +351,7 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do
|
||||||
|> UserAuth.log_in_user(user)
|
|> UserAuth.log_in_user(user)
|
||||||
|
|
||||||
{:error, :not_linked} ->
|
{:error, :not_linked} ->
|
||||||
case GoogleAuthPending.put(conn, identity_attrs, flow_locale(flow)) do
|
continue_unlinked_google(conn, flow, identity_attrs, ~p"/users/log-in")
|
||||||
{:ok, conn} -> redirect(conn, to: ~p"/auth/google/complete")
|
|
||||||
{:error, _reason} -> google_account_unavailable(conn, ~p"/users/log-in")
|
|
||||||
end
|
|
||||||
|
|
||||||
{:error, _reason} ->
|
{:error, _reason} ->
|
||||||
google_account_unavailable(conn, ~p"/users/log-in")
|
google_account_unavailable(conn, ~p"/users/log-in")
|
||||||
|
|
@ -365,13 +359,7 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp finish_flow(conn, "register", flow, identity_attrs) do
|
defp finish_flow(conn, "register", flow, identity_attrs) do
|
||||||
case GoogleAuthPending.put(conn, identity_attrs, flow_locale(flow)) do
|
continue_unlinked_google(conn, flow, identity_attrs, ~p"/users/register")
|
||||||
{:ok, conn} ->
|
|
||||||
redirect(conn, to: ~p"/auth/google/complete")
|
|
||||||
|
|
||||||
{:error, _reason} ->
|
|
||||||
google_account_unavailable(conn, ~p"/users/register")
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp finish_flow(conn, "link", flow, identity_attrs) do
|
defp finish_flow(conn, "link", flow, identity_attrs) do
|
||||||
|
|
@ -421,6 +409,90 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do
|
||||||
end)
|
end)
|
||||||
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
|
defp unlink_google_identity_and_audit(current_user) do
|
||||||
Repo.transact(fn ->
|
Repo.transact(fn ->
|
||||||
with {:ok, identity} <- Accounts.unlink_google_identity(current_user),
|
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) when locale in @supported_locales, do: locale
|
||||||
defp normalize_locale(_locale), do: "en"
|
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 flow_locale(flow), do: normalize_locale(flow["locale"])
|
||||||
|
|
||||||
defp identity_attrs(pending) do
|
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
|
end
|
||||||
|
|
||||||
defp error_name(error) when is_atom(error), do: Atom.to_string(error)
|
defp error_name(error) when is_atom(error), do: Atom.to_string(error)
|
||||||
|
|
|
||||||
|
|
@ -67,17 +67,37 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<.form
|
<.form
|
||||||
for={%{}}
|
for={@registration_form}
|
||||||
as={:google_registration}
|
|
||||||
action={~p"/auth/google/complete-registration"}
|
action={~p"/auth/google/complete-registration"}
|
||||||
class="space-y-3"
|
class="space-y-4"
|
||||||
>
|
>
|
||||||
<input type="hidden" name="google_registration[locale]" value={@pending.locale} />
|
<div>
|
||||||
|
<.input
|
||||||
|
field={@registration_form[:display_name]}
|
||||||
|
type="text"
|
||||||
|
label={gettext("Display name")}
|
||||||
|
autocomplete="name"
|
||||||
|
minlength="2"
|
||||||
|
maxlength="80"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-base-content/60">
|
||||||
|
{gettext("Other people will see this name on requests, activities, and reviews.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<.input
|
<.input
|
||||||
|
field={@registration_form[:locale]}
|
||||||
|
type="select"
|
||||||
|
label={gettext("Interface language")}
|
||||||
|
options={[{"English", "en"}, {"Українська", "uk"}, {"Русский", "ru"}]}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<.input
|
||||||
|
field={@registration_form[:terms_accepted]}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="google_completion_terms"
|
id="google_completion_terms"
|
||||||
name="google_registration[terms_accepted]"
|
|
||||||
value="false"
|
|
||||||
label={gettext("I am 18 or older and accept the Terms and Safety Rules")}
|
label={gettext("I am 18 or older and accept the Terms and Safety Rules")}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,7 @@ defmodule WhoNeedHelpWeb.GoogleAuthPending do
|
||||||
email = value(attrs, :email)
|
email = value(attrs, :email)
|
||||||
email_verified = value(attrs, :email_verified)
|
email_verified = value(attrs, :email_verified)
|
||||||
display_name = value(attrs, :display_name)
|
display_name = value(attrs, :display_name)
|
||||||
|
hosted_domain = normalize_hosted_domain(value(attrs, :hosted_domain))
|
||||||
|
|
||||||
cond do
|
cond do
|
||||||
email_verified != true ->
|
email_verified != true ->
|
||||||
|
|
@ -108,6 +109,7 @@ defmodule WhoNeedHelpWeb.GoogleAuthPending do
|
||||||
email: normalized_email(email),
|
email: normalized_email(email),
|
||||||
email_verified: true,
|
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: hosted_domain,
|
||||||
locale: normalize_locale(locale)
|
locale: normalize_locale(locale)
|
||||||
}}
|
}}
|
||||||
end
|
end
|
||||||
|
|
@ -116,13 +118,28 @@ defmodule WhoNeedHelpWeb.GoogleAuthPending do
|
||||||
defp normalize(_attrs, _locale), do: {:error, :invalid}
|
defp normalize(_attrs, _locale), do: {:error, :invalid}
|
||||||
|
|
||||||
defp identity_attrs(pending) do
|
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
|
end
|
||||||
|
|
||||||
defp value(attrs, key), do: Map.get(attrs, key) || Map.get(attrs, Atom.to_string(key))
|
defp value(attrs, key), do: Map.get(attrs, key) || Map.get(attrs, Atom.to_string(key))
|
||||||
|
|
||||||
defp normalized_email(email), do: email |> String.trim() |> String.downcase()
|
defp normalized_email(email), do: email |> String.trim() |> String.downcase()
|
||||||
|
|
||||||
|
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 normalize_locale(locale) when locale in @supported_locales, do: locale
|
defp normalize_locale(locale) when locale in @supported_locales, do: locale
|
||||||
defp normalize_locale(_locale), do: "en"
|
defp normalize_locale(_locale), do: "en"
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,8 @@ defmodule WhoNeedHelp.GoogleAuthFake do
|
||||||
provider_uid: params["uid"] || "google-user-42",
|
provider_uid: params["uid"] || "google-user-42",
|
||||||
email: email,
|
email: email,
|
||||||
email_verified: true,
|
email_verified: true,
|
||||||
display_name: params["name"] || "Google Neighbor"
|
display_name: params["name"] || "Google Neighbor",
|
||||||
|
hosted_domain: normalize_hosted_domain(params["hd"])
|
||||||
}}
|
}}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -60,10 +61,20 @@ defmodule WhoNeedHelp.GoogleAuthFake do
|
||||||
provider_uid: provider_uid,
|
provider_uid: provider_uid,
|
||||||
email: String.downcase(email),
|
email: String.downcase(email),
|
||||||
email_verified: true,
|
email_verified: true,
|
||||||
display_name: name
|
display_name: name,
|
||||||
|
hosted_domain: nil
|
||||||
}}
|
}}
|
||||||
else
|
else
|
||||||
_invalid_or_replayed -> {:error, :invalid_id_token}
|
_invalid_or_replayed -> {:error, :invalid_id_token}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp normalize_hosted_domain(domain) when is_binary(domain) do
|
||||||
|
case domain |> String.trim() |> String.downcase() do
|
||||||
|
"" -> nil
|
||||||
|
normalized -> normalized
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_hosted_domain(_domain), do: nil
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
defmodule WhoNeedHelp.GoogleAuthTest do
|
defmodule WhoNeedHelp.GoogleAuthTest do
|
||||||
use ExUnit.Case, async: false
|
use ExUnit.Case, async: false
|
||||||
|
|
||||||
|
alias WhoNeedHelp.GoogleAuth
|
||||||
alias WhoNeedHelp.GoogleAuth.AssentAdapter
|
alias WhoNeedHelp.GoogleAuth.AssentAdapter
|
||||||
|
|
||||||
defmodule GoogleJwksHTTPAdapter do
|
defmodule GoogleJwksHTTPAdapter do
|
||||||
|
|
@ -66,13 +67,46 @@ defmodule WhoNeedHelp.GoogleAuthTest do
|
||||||
provider_uid: "google-subject-123",
|
provider_uid: "google-subject-123",
|
||||||
email: "alice@example.com",
|
email: "alice@example.com",
|
||||||
email_verified: true,
|
email_verified: true,
|
||||||
display_name: "Alice Neighbor"
|
display_name: "Alice Neighbor",
|
||||||
|
hosted_domain: nil
|
||||||
}
|
}
|
||||||
|
|
||||||
refute Map.has_key?(identity, :access_token)
|
refute Map.has_key?(identity, :access_token)
|
||||||
refute Map.has_key?(identity, :id_token)
|
refute Map.has_key?(identity, :id_token)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "normalizes the hosted-domain claim and applies Google's authoritative-email rules" do
|
||||||
|
assert {:ok, workspace_identity} =
|
||||||
|
AssentAdapter.normalize_identity(%{
|
||||||
|
"sub" => "workspace-subject",
|
||||||
|
"email" => "Owner@Example.ORG",
|
||||||
|
"email_verified" => true,
|
||||||
|
"name" => "Workspace Owner",
|
||||||
|
"hd" => " Example.ORG "
|
||||||
|
})
|
||||||
|
|
||||||
|
assert workspace_identity.hosted_domain == "example.org"
|
||||||
|
assert GoogleAuth.authoritative_email?(workspace_identity)
|
||||||
|
|
||||||
|
assert GoogleAuth.authoritative_email?(%{
|
||||||
|
email: "OWNER@GMAIL.COM",
|
||||||
|
email_verified: true,
|
||||||
|
hosted_domain: nil
|
||||||
|
})
|
||||||
|
|
||||||
|
refute GoogleAuth.authoritative_email?(%{
|
||||||
|
email: "owner@example.org",
|
||||||
|
email_verified: true,
|
||||||
|
hosted_domain: nil
|
||||||
|
})
|
||||||
|
|
||||||
|
refute GoogleAuth.authoritative_email?(%{
|
||||||
|
email: "owner@gmail.com",
|
||||||
|
email_verified: false,
|
||||||
|
hosted_domain: "gmail.com"
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
test "rejects an unverified or incomplete Google email" do
|
test "rejects an unverified or incomplete Google email" do
|
||||||
assert {:error, :email_not_verified} =
|
assert {:error, :email_not_verified} =
|
||||||
AssentAdapter.normalize_identity(%{
|
AssentAdapter.normalize_identity(%{
|
||||||
|
|
@ -130,7 +164,8 @@ defmodule WhoNeedHelp.GoogleAuthTest do
|
||||||
provider_uid: "native-subject",
|
provider_uid: "native-subject",
|
||||||
email: "native@example.com",
|
email: "native@example.com",
|
||||||
email_verified: true,
|
email_verified: true,
|
||||||
display_name: "Native Neighbor"
|
display_name: "Native Neighbor",
|
||||||
|
hosted_domain: nil
|
||||||
}} = AssentAdapter.verify_id_token(token, nonce)
|
}} = AssentAdapter.verify_id_token(token, nonce)
|
||||||
|
|
||||||
assert {:error, _reason} = AssentAdapter.verify_id_token(token, "different-nonce")
|
assert {:error, _reason} = AssentAdapter.verify_id_token(token, "different-nonce")
|
||||||
|
|
@ -203,7 +238,8 @@ defmodule WhoNeedHelp.GoogleAuthTest do
|
||||||
provider_uid: "cross-client-subject",
|
provider_uid: "cross-client-subject",
|
||||||
email: "crossclient@example.com",
|
email: "crossclient@example.com",
|
||||||
email_verified: true,
|
email_verified: true,
|
||||||
display_name: "Cross Client"
|
display_name: "Cross Client",
|
||||||
|
hosted_domain: nil
|
||||||
}} = AssentAdapter.verify_id_token(token, nonce)
|
}} = AssentAdapter.verify_id_token(token, nonce)
|
||||||
|
|
||||||
unauthorized_token =
|
unauthorized_token =
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,11 @@ defmodule WhoNeedHelpWeb.GoogleAuthControllerTest do
|
||||||
|
|
||||||
assert html =~ "Create your Who Need Help account"
|
assert html =~ "Create your Who Need Help account"
|
||||||
assert html =~ email
|
assert html =~ email
|
||||||
|
assert html =~ ~s(value="Native Neighbor")
|
||||||
|
|
||||||
|
assert Enum.count(
|
||||||
|
LazyHTML.query(LazyHTML.from_document(html), "option[value='ru'][selected]")
|
||||||
|
) == 1
|
||||||
end
|
end
|
||||||
|
|
||||||
test "native Google account linking still requires the signed-in sudo owner", %{conn: conn} do
|
test "native Google account linking still requires the signed-in sudo owner", %{conn: conn} do
|
||||||
|
|
@ -213,7 +218,11 @@ defmodule WhoNeedHelpWeb.GoogleAuthControllerTest do
|
||||||
)
|
)
|
||||||
|> recycle()
|
|> recycle()
|
||||||
|> post(~p"/auth/google/complete-registration", %{
|
|> post(~p"/auth/google/complete-registration", %{
|
||||||
"google_registration" => %{"locale" => "ru", "terms_accepted" => "true"}
|
"google_registration" => %{
|
||||||
|
"display_name" => "Chosen Helper",
|
||||||
|
"locale" => "ru",
|
||||||
|
"terms_accepted" => "true"
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
assert redirected_to(conn) == ~p"/"
|
assert redirected_to(conn) == ~p"/"
|
||||||
|
|
@ -224,7 +233,7 @@ defmodule WhoNeedHelpWeb.GoogleAuthControllerTest do
|
||||||
assert user.confirmed_at
|
assert user.confirmed_at
|
||||||
assert user.accepted_terms_at
|
assert user.accepted_terms_at
|
||||||
assert user.locale == "ru"
|
assert user.locale == "ru"
|
||||||
assert user.display_name == "Google Helper"
|
assert user.display_name == "Chosen Helper"
|
||||||
assert is_nil(user.hashed_password)
|
assert is_nil(user.hashed_password)
|
||||||
|
|
||||||
assert %AuthIdentity{
|
assert %AuthIdentity{
|
||||||
|
|
@ -259,6 +268,98 @@ defmodule WhoNeedHelpWeb.GoogleAuthControllerTest do
|
||||||
assert html =~ "Email me a verification link"
|
assert html =~ "Email me a verification link"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "auto-links a verified Gmail owner and keeps the existing public name", %{conn: conn} do
|
||||||
|
email = "existing#{System.unique_integer([:positive])}@gmail.com"
|
||||||
|
user = unconfirmed_user_fixture(%{email: email, display_name: "Local Public Name"})
|
||||||
|
|
||||||
|
refute user.confirmed_at
|
||||||
|
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> post(~p"/auth/google/login")
|
||||||
|
|> recycle()
|
||||||
|
|> get(
|
||||||
|
~p"/auth/google/callback?code=code&state=google-test-state&uid=gmail-owner&email=#{email}&name=Google%20Profile%20Name"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert redirected_to(conn) == ~p"/"
|
||||||
|
assert get_session(conn, :user_token)
|
||||||
|
assert is_nil(get_session(conn, "pending_google_identity"))
|
||||||
|
|
||||||
|
reloaded_user = Accounts.get_user_by_email(email)
|
||||||
|
assert reloaded_user.confirmed_at
|
||||||
|
assert reloaded_user.display_name == "Local Public Name"
|
||||||
|
|
||||||
|
identity =
|
||||||
|
Repo.get_by!(AuthIdentity, provider: :google, provider_uid: "gmail-owner")
|
||||||
|
|
||||||
|
assert identity.user_id == user.id
|
||||||
|
|
||||||
|
assert %AuditEvent{actor_id: actor_id, metadata: metadata} =
|
||||||
|
Repo.get_by!(AuditEvent,
|
||||||
|
action: "auth_identity.connected",
|
||||||
|
target_id: identity.id
|
||||||
|
)
|
||||||
|
|
||||||
|
assert actor_id == user.id
|
||||||
|
assert metadata["method"] == "authoritative_google_email"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "auto-links a verified Google Workspace owner carrying the hosted-domain claim", %{
|
||||||
|
conn: conn
|
||||||
|
} do
|
||||||
|
email = "owner#{System.unique_integer([:positive])}@example.org"
|
||||||
|
user = user_fixture(%{email: email, display_name: "Workspace Member"})
|
||||||
|
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> post(~p"/auth/google/register", %{
|
||||||
|
"google_registration" => %{"locale" => "uk"}
|
||||||
|
})
|
||||||
|
|> recycle()
|
||||||
|
|> get(
|
||||||
|
~p"/auth/google/callback?code=code&state=google-test-state&uid=workspace-owner&email=#{email}&hd=example.org"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert Phoenix.Flash.get(conn.assigns.flash, :error) == nil
|
||||||
|
assert redirected_to(conn) == ~p"/"
|
||||||
|
assert get_session(conn, :user_token)
|
||||||
|
|
||||||
|
assert %AuthIdentity{user_id: user_id} =
|
||||||
|
Repo.get_by!(AuthIdentity, provider: :google, provider_uid: "workspace-owner")
|
||||||
|
|
||||||
|
assert user_id == user.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not replace a different Google identity already connected to the Gmail account", %{
|
||||||
|
conn: conn
|
||||||
|
} do
|
||||||
|
email = "protected#{System.unique_integer([:positive])}@gmail.com"
|
||||||
|
user = user_fixture(%{email: email})
|
||||||
|
|
||||||
|
assert {:ok, original_identity} =
|
||||||
|
Accounts.link_google_identity(user, %{
|
||||||
|
provider_uid: "original-google-owner",
|
||||||
|
email: email,
|
||||||
|
email_verified: true,
|
||||||
|
display_name: user.display_name
|
||||||
|
})
|
||||||
|
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> post(~p"/auth/google/login")
|
||||||
|
|> recycle()
|
||||||
|
|> get(
|
||||||
|
~p"/auth/google/callback?code=code&state=google-test-state&uid=different-google-owner&email=#{email}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert redirected_to(conn) == ~p"/users/log-in"
|
||||||
|
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "cannot be used"
|
||||||
|
refute get_session(conn, :user_token)
|
||||||
|
refute Repo.get_by(AuthIdentity, provider: :google, provider_uid: "different-google-owner")
|
||||||
|
assert Repo.get!(AuthIdentity, original_identity.id).provider_uid == "original-google-owner"
|
||||||
|
end
|
||||||
|
|
||||||
test "logs in only through an identity already linked to the local account", %{conn: conn} do
|
test "logs in only through an identity already linked to the local account", %{conn: conn} do
|
||||||
user = user_fixture()
|
user = user_fixture()
|
||||||
|
|
||||||
|
|
@ -314,7 +415,11 @@ defmodule WhoNeedHelpWeb.GoogleAuthControllerTest do
|
||||||
)
|
)
|
||||||
|> recycle()
|
|> recycle()
|
||||||
|> post(~p"/auth/google/complete-registration", %{
|
|> post(~p"/auth/google/complete-registration", %{
|
||||||
"google_registration" => %{"locale" => "uk", "terms_accepted" => "true"}
|
"google_registration" => %{
|
||||||
|
"display_name" => "Chosen Continued Helper",
|
||||||
|
"locale" => "uk",
|
||||||
|
"terms_accepted" => "true"
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
assert redirected_to(conn) == ~p"/"
|
assert redirected_to(conn) == ~p"/"
|
||||||
|
|
@ -324,7 +429,7 @@ defmodule WhoNeedHelpWeb.GoogleAuthControllerTest do
|
||||||
assert user.confirmed_at
|
assert user.confirmed_at
|
||||||
assert user.accepted_terms_at
|
assert user.accepted_terms_at
|
||||||
assert user.locale == "uk"
|
assert user.locale == "uk"
|
||||||
assert user.display_name == "Continued Helper"
|
assert user.display_name == "Chosen Continued Helper"
|
||||||
|
|
||||||
assert %AuthIdentity{user_id: user_id} =
|
assert %AuthIdentity{user_id: user_id} =
|
||||||
Repo.get_by!(AuthIdentity, provider: :google, provider_uid: "continue-google")
|
Repo.get_by!(AuthIdentity, provider: :google, provider_uid: "continue-google")
|
||||||
|
|
@ -344,15 +449,45 @@ defmodule WhoNeedHelpWeb.GoogleAuthControllerTest do
|
||||||
)
|
)
|
||||||
|> recycle()
|
|> recycle()
|
||||||
|> post(~p"/auth/google/complete-registration", %{
|
|> post(~p"/auth/google/complete-registration", %{
|
||||||
"google_registration" => %{"locale" => "en", "terms_accepted" => "false"}
|
"google_registration" => %{
|
||||||
|
"display_name" => "No Terms Helper",
|
||||||
|
"locale" => "en",
|
||||||
|
"terms_accepted" => "false"
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
assert redirected_to(conn) == ~p"/auth/google/complete"
|
html = html_response(conn, 422)
|
||||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "18 or older"
|
assert html =~ "you must confirm that you are 18+ and accept the rules"
|
||||||
refute Accounts.get_user_by_email(email)
|
refute Accounts.get_user_by_email(email)
|
||||||
refute Repo.get_by(AuthIdentity, provider: :google, provider_uid: "no-terms-google")
|
refute Repo.get_by(AuthIdentity, provider: :google, provider_uid: "no-terms-google")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "keeps the Google completion form open when the public name is invalid", %{conn: conn} do
|
||||||
|
email = unique_user_email()
|
||||||
|
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> post(~p"/auth/google/login")
|
||||||
|
|> recycle()
|
||||||
|
|> get(
|
||||||
|
~p"/auth/google/callback?code=code&state=google-test-state&uid=invalid-name-google&email=#{email}&name=Initial%20Name"
|
||||||
|
)
|
||||||
|
|> recycle()
|
||||||
|
|> post(~p"/auth/google/complete-registration", %{
|
||||||
|
"google_registration" => %{
|
||||||
|
"display_name" => " ",
|
||||||
|
"locale" => "en",
|
||||||
|
"terms_accepted" => "true"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
html = html_response(conn, 422)
|
||||||
|
assert html =~ "Create your Who Need Help account"
|
||||||
|
assert html =~ "can't be blank"
|
||||||
|
refute Accounts.get_user_by_email(email)
|
||||||
|
refute Repo.get_by(AuthIdentity, provider: :google, provider_uid: "invalid-name-google")
|
||||||
|
end
|
||||||
|
|
||||||
test "links an existing account after one magic-link verification", %{conn: conn} do
|
test "links an existing account after one magic-link verification", %{conn: conn} do
|
||||||
user = user_fixture()
|
user = user_fixture()
|
||||||
assert_email_sent()
|
assert_email_sent()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user