Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Guide on password-less auth #8

Open
danschultzer opened this issue Sep 18, 2019 · 7 comments
Open

Guide on password-less auth #8

danschultzer opened this issue Sep 18, 2019 · 7 comments
Labels
enhancement New feature or request

Comments

@danschultzer
Copy link
Collaborator

danschultzer commented Sep 18, 2019

A guide could be added that details password-less auth strategies, and how to get it working with Pow. PowAssent is an obvious one, but magic links would be very easy to set up. It could also show how to set up WebAuthn.

This would require custom controllers with the current version of Pow. I have a planned feature to Pow that would make it easy to set up custom auth flow, but it'll still be a good amount of time before I get to that.

A post I've been looking at recently: https://biarity.gitlab.io/2018/02/23/passwordless/

@danschultzer danschultzer added the enhancement New feature or request label Sep 18, 2019
@morgz
Copy link

morgz commented Nov 22, 2019

Magic links would be a very desirable feature for us! Are core changes required for this, or could it be done in an extension?

@danschultzer
Copy link
Collaborator Author

It can be done in an extension (PowAssent is an example of password-less auth extension) or just as custom controllers and custom changeset. No core changes needed.

@morgz
Copy link

morgz commented Nov 23, 2019

Thanks @danschultzer. If we come up with a decent implementation we’ll be sure to share.

@morgz
Copy link

morgz commented Dec 6, 2019

I've just finished implementing magic links using POW. Would welcome feedback on how to improve.

My approach is:

  1. Ask for email >
  2. Create a JWT with guardian which includes email and possible a redirect_url captured pre-auth challenge. No user record is created.
  3. {login link}/JWT send via email.
  4. Following the URL decodes the token; email and possibly the redirect_url are in the claims.
  5. If more account information is needed (name?) then challenge the user via changeset errors.
  6. If all goes OK. User is created or fetched and we login via POW.

Here's my magic_link_controller.ex

https://gist.github.com/morgz/4976c75ad6cafe62ef8eee0d37bca598

defmodule WildeWeb.MagicLinkController do
  use WildeWeb, :controller
  alias Pow.Plug
  require Logger

  plug :put_layout, "auth.html"
  plug :put_layout, "simple.html" when action in [:setup]

  def send(conn, params) do
    Wilde.Auth.MagicLinks.deliver_magic_link(params["email"], params["request_path"])
    conn
      |> put_flash(:info, "Check your inbox for your login link")
      |> redirect(to: Routes.page_path(conn, :landing))
  end

  @doc """
  Called from a login link. Attempts to recreate the email address & an optional request_path
  from the JWT token. The request path to redirect the user to the path they were trying to visit
  before the auth challenge.
  """
  def callback(conn, %{"magic_token" => magic_token}) do
    config = Plug.fetch_config(conn)

    case Wilde.Auth.MagicLinks.email_and_request_path_from_token("#{magic_token}") do
      {:ok, result} ->
        conn
        |> renew_existing_session_or_authenticate_or_create_user(result, config)
      {:error, reason} ->
        conn
        |> put_flash(:error, "Invalid magic link. #{reason}")
        |> redirect(to: Routes.pow_session_path(conn, :new))
    end
  end


  # If there's already a Plug user we fetch it.
  # Otherwise, we get or create a user via the email address from tne token
  # We then try to auth the user
  # Finally we maybe redirect to the request_path if it exists.
  defp renew_existing_session_or_authenticate_or_create_user(conn, %{"email" => email, "request_path" => request_path}, config) do
    case Pow.Plug.current_user(conn) do
      # If we don't have a session then either authenticate an existing user or create a new one
      nil  -> get_or_create_user(%{email: email}, config)
      # if we have a current user session then renew it. Currently won't switch users
      user -> {:ok, user}
    end
      |> maybe_create_auth(conn, config)
      |> maybe_redirect(request_path)
  end

  def maybe_redirect({:ok, _user, conn}, nil) do
    conn
      |> put_flash(:info, "Successfully Logged in")
      |> redirect(to: Routes.page_path(conn, :landing))
  end

  def maybe_redirect({:ok, _user, conn}, request_path) do
    conn
      |> put_flash(:info, "Successfully Logged in")
      |> redirect(to: request_path)
  end

  @doc """
  If we've got changeset errors, then render a specific form: setup.html
  """
  def maybe_redirect({:error, changeset, conn}, request_path) do
    conn
      |> assign(:changeset, changeset)
      |> assign(:request_path, request_path)
      |> render("setup.html")
  end

  def setup(conn, %{"user" => user_params, "request_path" => ""}), do: setup(conn, %{"user" => user_params, "request_path" => nil})
  def setup(conn, %{"user" => user_params, "request_path" => request_path}) do
    config = Plug.fetch_config(conn)
    create_user(user_params, config)
      |> maybe_create_auth(conn, config)
      |> maybe_redirect(request_path)
  end

  defp get_or_create_user(%{email: email} = user_params, config) do
    case Pow.Ecto.Context.get_by([email: email], config) do
      nil   ->  create_user(user_params, config)
      user  ->  {:ok, user}
    end
  end

  defp create_user(params, _config) do
    Wilde.Accounts.create_user_passwordless(params)
  end

  defp maybe_create_auth({:ok, user}, conn, config) do
    {:ok, user, Plug.get_plug(config).do_create(conn, user, config)}
  end

  defp maybe_create_auth({:error, changeset}, conn, _config) do
    {:error, changeset, conn}
  end
end

@danschultzer
Copy link
Collaborator Author

Looks great, thanks!

Quick comments:

  1. You can use the backend cache in Pow if you would like to contain everything there, instead of relying on stateless tokens.
  2. You should validate the email address before sending the email.
  3. You can just deny any authenticated users from accessing the routes in the first place.

I've made a quick rewrite of this so it's closer to how Pow works. It's from the top of my head and untested. When I have some time available I'll go through this properly, test it, add unit tests, etc, and get it ready for a guide. Thanks for getting this rolling 😄

I would also look to make this logic generic if possible, so it's not magic link based, but you could use it for WebAuthn/TOTP/Hardware auth.

Some caveats:

  1. If registration is necessary, the token could expire before submitting. This would be true for JWT too. The magic link token should definitely be short lived though!
  2. I added load_by_changeset to the users context since I prefer the controller to be unaware of the data, and just passes it along. I would probably just use load_by_email for pure magic links, maybe even just calling Repo.get_by/2 directly in the controller.
  3. Doesn't support request_path

I'll update this post as I refactor.

defmodule MyAppWeb.MagicLinkCache do
  @moduledoc false
  use Pow.Store.Base,
    ttl: :timer.minutes(5),
    namespace: "magic_link"
end
defmodule MyApp.Users.User do
  use Ecto.Schema
  use Pow.Ecto.Schema
  
  alias Ecto.Changeset
  alias Pow.Ecto.Changeset
  
  # ...

  @spec passwordless_changeset(Ecto.Schema.t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
  def passwordless_changeset(user_or_changeset, attrs \\ %{}) do
    user_or_changeset
    |> Changeset.cast(attrs, [:email])
    |> Changeset.validate_change(:email, fn :email, email ->
      case Pow.Ecto.Schema.Changeset.validate_email(email) do
        :ok              -> []
        :error           -> [email: {"has invalid format"}]
        {:error, reason} -> [email: {"has invalid format", reason: reason}]
      end
    end)
  end
end
defmodule MyApp.Users do
  alias MyApp.{Repo, Users.User}
  
  # ...
  
  @spec passwordless_create(map()) :: {:ok, map()} | {:error, map()}
  def passwordless_create(attrs) do
    User
    |> User.passwordless_changeset(attrs)
    |> Repo.insert()
  end
  
  @spec load_by_changeset(Ecto.Changset.t()) :: map() | nil
  def load_by_changeset(changeset) do
    case Ecto.Changeset.get_field(changeset, :email) do
      nil   -> nil
      email -> Repo.get_by(User, [email: email])
    end
  end
end
defmodule MyAppWeb.Router do
  use MyAppWeb, :router

  # ... pipelines

  pipeline :not_authenticated do
    plug Pow.Plug.RequireNotAuthenticated,
      error_handler: Pow.Phoenix.PlugErrorHandler
  end

  scope "/", MyAppWeb do
    pipe_through [:browser, :not_authenticated]

    resources "/magic-link", MagicLinkController, only: [:new, :create, :edit, :update]
    get "/magic-link/check-inbox", MagicLinkController, :inbox
  end

  # ... routes
end
defmodule MyAppWeb.MagicLinkController do
  use MyAppWeb, :controller
  
  alias Plug.Conn
  alias Ecto.Changeset
  alias MyApp.{Users, Users.User}
  alias MyAppWeb.MagicLinkCache
  alias Pow.{Config, Ecto.Context, Phoenix.Mailer, Phoenix.Mailer.Mail, Plug, Store.Backend.EtsCache, UUID}

  plug :load_changeset_from_token, only: [:edit, :update]
  plug :auth_user_from_changeset, only: [:edit, :update]

  @doc false
  @spec new(Conn.t(), map()) :: Conn.t() do
  def new(conn, _params) do
    changeset = User.magic_link_changeset(User, %{})

    conn
    |> assign(:changeset, changeset)
    |> render("new.html", changeset: changeset)
  end
  
  @doc false
  @spec create(Conn.t(), map()) :: Conn.t()
  def create(conn, %{"user" => user_params}) do
    config = Plug.fetch_config(conn)

    User
    |> User.magic_link_changeset(user_params)
    |> create_magic_link_token(config)
    |> case do
      {:ok, changeset, url} ->
        deliver_email(conn, changeset, url)

        redirect(conn, to: Routes.magic_link_path(conn, :inbox)

      {:error, changeset} ->
        conn
        |> assign(:changeset, changeset)
        |> render("new.html", changeset: changeset)
    end
  end
  
  defp create_magic_link_token(%{valid?: true} = changeset, config) do
    backend_store = Config.get(config, :cache_store_backend, EtsCache)
    token         = UUID.generate()
    url           = Routes.magic_link_path(conn, :edit, token)

    MagicLinkCache.put([backend: backend_store], token, changeset)

    {:ok, changeset, url}
  end
  defp create_magic_link_token(changset, _config), do: {:error, %{changeset | action: :insert}}
  
  defp deliver_email(conn, changeset, url) do
    user    = Ecto.Changeset.apply_changes(changeset)
    subject = # ...
    text    = # ...
    html    = # ...
    email   = struct(Mail, user: user, subject: subject, text: text, html: html, assigns: [])

    Mailer.deliver(conn, email)
  end

  @doc false
  @spec inbox(Conn.t(), map()) :: Conn.t()
  def inbox(conn, _params) do
    render(conn, "inbox.html", changeset: changeset)
  end

  @doc false
  @spec edit(Conn.t(), map()) :: Conn.t()
  def edit(conn, _params) do
    changeset = conn.assigns[:changeset]
    
    update(conn, changeset.changes)
  end

  @doc false
  @spec update(Conn.t(), map()) :: Conn.t()
  def update(conn, params) do
    conn
    |> Users.passwordless_create(params)
    |> case do
      {:ok, user} -> 
        conn
        |> expire_token()
        |> Plug.get_plug(config).do_create(user, config)
        |> redirect(to: after_registration_path(conn))
      
      {:error, changeset} ->
        conn
        |> assign(:changeset, changeset)
        |> render("edit.html")
    end
  end

  defp expire_token(%{params: %{"id" => token}} = conn) do
    config        = Plug.fetch_config(conn)
    backend_store = Config.get(config, :cache_store_backend, EtsCache)

    MagicLinkCache.delete([backend: backend_store], token)

    conn
  end

  defp load_changeset_from_token(%{params: %{"id" => token}} = conn, _opts) do
    config        = Plug.fetch_config(conn)
    backend_store = Config.get(config, :cache_store_backend, EtsCache)

    case MagicLinkCache.get([backend: backend_store], token) do
      :not_found ->
        conn
        |> put_flash(:error, "The link has expired. Please try again")
        |> redirect(to: Routes.magic_link_path(conn, :new))
        |> halt()

      changeset ->
        assign(conn, :changeset, changeset)
    end
  end
  
  defp auth_user_from_changeset(%{assigns: %{changeset: changeset}} = conn, _opts) do
    case Users.load_by_changeset(changeset) do
      nil ->
        conn

      user ->
        conn
        |> expire_token()
        |> Plug.get_plug(config).do_create(user, config)
        |> redirect(to: after_sign_in_path(conn))
        |> halt()
    end
  end
  
  defp after_sign_in_path(conn), do: Routes.page_path(conn, :index)
  
  defp after_registration_path(conn), do: Routes.page_path(conn, :index)
end

@morgz
Copy link

morgz commented Dec 16, 2019

Thanks @danschultzer. I'll work on these improvements. Just another note, will this play well with PowPersistentSession? Would the callbacks somehow need to be triggered? (Also, as an aside, does pow_assent work with PowPersistentSession?)

@danschultzer
Copy link
Collaborator Author

You have to add that logic yourself since it's only triggered on the Pow.Phoenix.SessionController.create/2 action. It's simple, just call PowPersistentSession.Plug.create/2:

  @doc false
  @spec update(Conn.t(), map()) :: Conn.t()
  def update(conn, params) do
    conn
    |> Users.passwordless_create(params)
    |> case do
      {:ok, user} -> 
        conn
        |> expire_token()
        |> Plug.get_plug(config).do_create(user, config)
        |> PowPersistentSession.Plug.create(user)
        |> redirect(to: after_registration_path(conn))
      
      {:error, changeset} ->
        conn
        |> assign(:changeset, changeset)
        |> render("edit.html")
    end
  end

PowAssent doesn't support PowPersistentSession. My thinking is that the provider authorizes access, and it only really makes sense that the authorization is only valid for the current session. You usually want to verify that the access token still works after a while or refresh token can be used to re-auth. This could be made easier though by having something similar to PowPersistentSession in PowAssent that will remember the current user and redirect the user to re-auth when seen again without an active session. That would be seamless with most providers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

2 participants