pow-auth / pow

Robust, modular, and extendable user authentication system
https://powauth.com
MIT License
1.6k stars 154 forks source link

Changing confirmation email and reset password URLs with external frontend #553

Closed vadimshvetsov closed 4 years ago

vadimshvetsov commented 4 years ago

Is there any way to change confirmation or reset password URL in emails without reimplementing it like this in GraphQL resolvers?

  def sign_up(_, %{input: input}, _) do
    with {:ok, user, conn} <- Pow.Plug.create_user(conn(), input) do
      send_email_confirmation_email(conn, user)

      {:ok,
       %{
         session: %{
           access_token: conn.private[:api_access_token],
           renewal_token: conn.private[:api_renewal_token]
         },
         user: user
       }}
    else
      {:error, changeset, _conn} -> {:error, changeset}
    end
  end

  def send_reset_password_url(_, %{input: %{email: email}}, _) do
    send_reset_password_email(conn(), email)

    {:ok, %{email: email}}
  end

  defp send_email_confirmation_email(conn, user) do
    token = PowEmailConfirmation.Plug.sign_confirmation_token(conn, user)
    url = "#{System.get_env("FRONTEND_URL")}/auth/confirm-email/#{token}"
    unconfirmed_user = %{user | email: user.unconfirmed_email || user.email}
    email = PowEmailConfirmation.Phoenix.Mailer.email_confirmation(conn, unconfirmed_user, url)

    Pow.Phoenix.Mailer.deliver(conn, email)
  end

  defp send_reset_password_email(conn, email) do
    with {:ok, %{token: token, user: user}, conn} <-
           PowResetPassword.Plug.create_reset_token(conn, %{"email" => email}) do
      url = "#{System.get_env("FRONTEND_URL")}/auth/reset-password/#{token}"
      email = PowResetPassword.Phoenix.Mailer.reset_password(conn, user, url)

      Pow.Phoenix.Mailer.deliver(conn, email)
    end
  end

  defp conn(socket \\ %{}) do
    endpoint = Map.get(socket, :endpoint, ProlingWeb.Endpoint)

    %Plug.Conn{
      private: %{
        phoenix_endpoint: endpoint,
        phoenix_router: ProlingWeb.Router,
        pow_mailer_layout: {ProlingWeb.LayoutView, :email}
      },
      secret_key_base: endpoint.config(:secret_key_base)
    }
    |> Pow.Plug.put_config(config())
  end

  defp config(params \\ []) do
    [plug: ProlingWeb.Plug.APIAuth, otp_app: :proling_web] ++ params
  end

With this code I've got a correct link but user gets authenticated while still has unconfirmed email. UPD: I've got that returning uncofirmedEmail => nil with create is totally ok.

And my schema

defmodule Proling.Identity.User do
  use Ecto.Schema
  use Arc.Ecto.Schema

  use Pow.Ecto.Schema, password_min_length: 6
  use Pow.Extension.Ecto.Schema, extensions: [PowResetPassword, PowEmailConfirmation]

  import Ecto.Changeset
  import Pow.Ecto.Schema.Changeset, only: [new_password_changeset: 3]

  alias Proling.Uploader.Avatar

  @roles ~w(user employee admin)

  @required_fields ~w(phone email)a

  schema "users" do
    field :avatar, Avatar.Type
    field :first_name, :string
    field :last_name, :string
    field :phone, :string
    field :role, :string, default: "user"

    pow_user_fields()

    timestamps()
  end

  def changeset(user, attrs) do
    user
    |> cast(attrs, @required_fields)
    |> validate_required(@required_fields)
    |> common_changeset(attrs)
  end

  def update_changeset(user, attrs) do
    user
    |> cast(attrs, @required_fields)
    |> validate_required(@required_fields)
    |> common_changeset(attrs)
  end

  def update_role_changeset(user, attrs) do
    user
    |> cast(attrs, [:role])
    |> validate_required([:role])
    |> validate_inclusion(:role, @roles)
  end

  def reset_password_changeset(changeset, attrs) do
    changeset
    |> cast(attrs, [:password])
    |> validate_required([:password])
    |> new_password_changeset(attrs, @pow_config)
  end

  defp common_changeset(changeset, attrs) do
    changeset
    |> validate_phone()
    |> unique_constraint(:phone)
    |> unique_constraint(:email)
    |> maybe_pow_changeset(attrs)
    |> pow_extension_changeset(attrs)
  end

  defp maybe_pow_changeset(changeset, attrs) do
    pow_changeset =
      changeset
      |> pow_user_id_field_changeset(attrs)
      |> maybe_pow_current_password_changeset(attrs)
      |> new_password_changeset(attrs, @pow_config)

    case pow_changeset.changes == %{} do
      true -> changeset
      false -> pow_changeset
    end
  end

  defp maybe_pow_current_password_changeset(%{changes: %{password: _}} = changeset, attrs) do
    changeset |> pow_current_password_changeset(attrs)
  end

  defp maybe_pow_current_password_changeset(changeset, _attrs) do
    changeset
  end

  defp validate_phone(%{valid?: true, changes: %{phone: phone}} = changeset) do
    with {:ok, parsed_phone} <- ExPhoneNumber.parse("+#{phone}", "RU"),
         true <- ExPhoneNumber.is_valid_number?(parsed_phone) do
      changeset
    else
      {:error, _message} -> add_error(changeset, :phone, "has invalid format")
      false -> add_error(changeset, :phone, "is invalid")
    end
  end

  defp validate_phone(changeset), do: changeset
end
danschultzer commented 4 years ago

You have to block authentication after sign up if the e-mail is unconfirmed, so you need to set it up like this:

  def sign_up(_, %{input: input}, _) do
    case {:ok, user, conn} <- Pow.Plug.create_user(conn(), input) do
       {:ok, _user, conn}         -> maybe_require_confirmation(conn)
       {:error, changeset,  conn} -> {:error, changeset}
     end
   end

  defp maybe_require_confirmation(conn) do
    case PowEmailConfirmation.Plug.email_unconfirmed?(conn) do
      true ->
        {:ok,
         %{
           session: %{
             access_token: conn.private[:api_access_token],
             renewal_token: conn.private[:api_renewal_token]
           },
           user: Pow.Plug.current_user(conn)
         }}
      false ->

      {:error, changeset, _conn} ->
        {:error, changeset}

      false ->
        send_email_confirmation_email(conn)

        Pow.Plug.delete(conn)

        {:error, "requires email confirmation"}
    end
  end

Though if you already know that all users will need email confirmation then you can just bypass the pow auth logic entirely and just create the user directly instead of using Pow.Plug.create_user/2:

  def sign_up(_, %{input: input}, _) do
    with {:ok, user} <- Pow.Ecto.Context.create(input, otp_app: proling_web) do
        send_email_confirmation_email(conn)

       {:ok, %{user: user}}
    else
      {:error, changeset, _conn} -> {:error, changeset}
     end
   end
  end
vadimshvetsov commented 4 years ago

@danschultzer Thanks a lot for this great explanation. I definitely agreed but the drawback of requiring email confirmation for login like double current password and secure password rules originates a bad UX. Not all systems need to be super secure for UX sake I mean. So I still can't find a way to just change URL for emails if I have external frontend, is it possible with little changes without reimplementing sending email on my own?

vadimshvetsov commented 4 years ago

I think there is no better way right now to change only change url without building email from scratch so it might be closed right now.

danschultzer commented 4 years ago

Sorry @vadimshvetsov, got away from this issue.

I definitely agreed but the drawback of requiring email confirmation for login like double current password and secure password rules originates a bad UX. Not all systems need to be super secure for UX sake I mean.

Oh I understand what you were asking for now. Your setup is fine then 😄

So I still can't find a way to just change URL for emails if I have external frontend, is it possible with little changes without reimplementing sending email on my own?

So first, you can change the domain by using the Phoenix URL generator options:

config :my_app, MyAppWeb.Endpoint,
  # ..,
  url: [scheme: "https", host: "example.com", port: 443]

Pow uses the router helpers so with that set up you can instead call e.g. Routes.pow_email_confirmation_confirmation_url(conn, :show, token) rather than building it manually.

You would still need to generate and send the e-mail, as the mail delivery are private methods inside the Pow controllers (except for PowEmailConfirmation.Phoenix.ControllerCallbacks.send_confirmation_email/2). I'm planning to refactor some of the mailer logic with https://github.com/danschultzer/pow/issues/469, so might find a way to make this easier.

vadimshvetsov commented 4 years ago

@danschultzer Thanks again, Dan! It looks like changing Phoenix.Endpoint will change Phoenix url instead pointing my React Frontend app

danschultzer commented 4 years ago

It looks like changing Phoenix.Endpoint will change Phoenix url instead pointing my React Frontend app

Oh right, I incorrectly assumed you just wanted to replicate the URL with the front-end domain. In that case your setup is probably the best that can be done currently. If you find any opportunities for refactoring Pow to make this easier, please let me know! I'm not sure the refactoring of the e-mail logic I'm planning would help much here.

I would like to move away from requiring conn for the mailer if possible, so maybe it would be good to do both that and a slight change to the logic so you only have to call one method to generate and deliver the email?

vadimshvetsov commented 4 years ago

@danschultzer I love what Pow offers for Phoenix users but it's not easy to use in GraphQL API which doesn't rely on request as well as using Phoenix. I would happy to help. By the way, when Phoenix will do their auth based on conn Pow might offer auth without conn and it could be killer feature for Absinthe users

danschultzer commented 4 years ago

By the way, when Phoenix will do their auth based on conn Pow might offer auth without conn and it could be killer feature for Absinthe users

Agreed! I'm planning to let Pow v1.1.0 be the final version of Pow, with hard deprecations for everything I've learned to make all of this much simpler. I've realized that I'm depending too much on conn and probably should do a lot more with just using the Endpoint module (as Phoenix does).