-
-
Notifications
You must be signed in to change notification settings - Fork 2
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
Comments
Magic links would be a very desirable feature for us! Are core changes required for this, or could it be done in an extension? |
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. |
Thanks @danschultzer. If we come up with a decent implementation we’ll be sure to share. |
I've just finished implementing magic links using POW. Would welcome feedback on how to improve. My approach is:
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 |
Looks great, thanks! Quick comments:
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:
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
|
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?) |
You have to add that logic yourself since it's only triggered on the @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
|
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/
The text was updated successfully, but these errors were encountered: