diff --git a/.travis.yml b/.travis.yml index 70ff90f8..c8cdea00 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,5 @@ language: elixir -elixir: 1.9 +elixir: 1.10 otp_release: 22.0 services: - postgresql diff --git a/CHANGELOG.md b/CHANGELOG.md index edfbcfbd..3246c8f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,118 @@ # Changelog +## v1.0.19 (TBA) + +**Warning:** This release will now sign and verify all tokens, causing previous tokens to no longer work. Any sessions and persistent sessions will be invalidated. + +### Enhancements + +* [`Pow.Plug.Session`] Now sets a global lock when renewing the session +* [`PowPersistentSession.Plug.Cookie`] Now sets a global lock when authenticating the user +* [`PowEmailConfirmation.Plug`] Added `PowEmailConfirmation.Plug.sign_confirmation_token/2` to sign the `email_confirmation_token` to prevent timing attacks +* [`PowEmailConfirmation.Plug`] Added `PowEmailConfirmation.Plug.confirm_email_by_token/2` to verify the signed `email_confirmation_token` to prevent timing attacks +* [`PowInvitation.Plug`] Added `PowInvitation.Plug.sign_invitation_token/2` to sign the `invitation_token` +* [`PowInvitation.Plug`] Added `PowInvitation.Plug.load_invited_user_by_token/2` to verify the signed `invitation_token` to prevent timing attacks +* [`PowResetPassword.Plug`] Changed `PowResetPassword.Plug.create_reset_token/2` to sign the `:token` +* [`PowResetPassword.Plug`] Added `PowResetPassword.Plug.load_user_by_token/2` to verify the signed token to prevent timing attacks +* [`PowResetPassword.Plug`] Changed `PowResetPassword.Plug.update_user_password/2` so it decodes the signed token +* [`PowPersistentSession.Plug.Cookie`] Now uses signed tokens to prevent timing attacks +* [`Pow.Plug.Session`] Now uses signed session ID's to prevent timing attacks +* [`Pow.Plug`] Added `Pow.Plug.sign_token/4` to sign tokens +* [`Pow.Plug`] Added `Pow.Plug.verify_token/4` to decode and verify signed tokens +* [`Pow.Plug.MessageVerifier`] Added `Pow.Plug.MessageVerifier` module to sign and verify messages + +### Deprecations + +* [`PowEmailConfirmation.Plug`] `PowEmailConfirmation.Plug.confirm_email/2` has been deprecated in favor of `PowEmailConfirmation.Plug.confirm_email_by_token/2` +* [`PowInvitation.Plug`] `PowInvitation.Plug.invited_user_from_token/2` has been deprecated in favor of `PowInvitation.Plug.load_invited_user_by_token/2` +* [`PowInvitation.Plug`] `PowInvitation.Plug.assign_invited_user/2` has been deprecated +* [`PowResetPassword.Plug`] `PowResetPassword.Plug.user_from_token/2` has been deprecated in favor of `PowResetPassword.Plug.load_user_by_token/2` +* [`PowResetPassword.Plug`] `PowResetPassword.Plug.assign_reset_password_user/2` has been deprecated + +### Documentation + +* Updated the [API guide](guides/api.md) with signed tokens + +## v1.0.18 (2020-02-14) + +### Bug fixes + +* [`Pow.Phoenix.Routes`] Fixed bug where callback route methods is not using the overridden method +* [`PowPersistentSession.Plug.Cookie`] `PowPersistentSession.Plug.Cookie.delete/2` now correctly pulls token during `:before_send` callback +* [`Pow.Plug.Session`] `Pow.Plug.Session.delete/2` now correctly pulls session id during `:before_send` callback so `PowEmailConfirmation` will remove set session + +## v1.0.17 (2020-02-04) + +### Enhancements + +* [`Pow.Ecto.Context`] Calls to `Pow.Ecto.Context.get_by/2` replaced with `Pow.Operations.get_by/2` so custom users context module can be used. The following methods has been updated: + * `Pow.Ecto.Context.authenticate/2` + * `PowEmailConfirmation.Ecto.Context.get_by_confirmation_token/2` + * `PowInvitation.Ecto.Context.get_by_invitation_token/2` + * `PowResetPassword.Ecto.Context.get_by_email/2` +* [`Pow.Ecto.Schema.Changeset`] `Pow.Ecto.Schema.Changeset.confirm_password_changeset/3` now adds the default `Ecto.Changeset.validate_confirmation/3` error instead of the previous `not same as password` error +* [`Pow.Ecto.Schema.Changeset`] `Pow.Ecto.Schema.Changeset.confirm_password_changeset/3` now uses the `Ecto.Changeset.validate_confirmation/3` for validation and expects `:password_confirmation` instead of `:confirm_password` in params +* [`Pow.Ecto.Schema.Changeset`] `Pow.Ecto.Schema.Changeset.new_password_changeset/3` now only requires the `:password_hash` if there have been no previous errors set in the changeset +* [`Pow.Ecto.Schema`] No longer adds `:confirm_password` virtual field +* [`Pow.Ecto.Schema`] Now has an `@after_compile` callback that ensures all required fields has been defined +* [`PowInvitation.Phoenix.InvitationView`] Now renders `:password_confirmation` field instead of `:confirm_password` +* [`PowResetPassword.Phoenix.ResetPasswordView`] Now renders `:password_confirmation` field instead of `:confirm_password` +* [`Pow.Phoenix.RegistrationView`] Now renders `:password_confirmation` field instead of `:confirm_password` +* [`PowEmailConfirmation.Ecto.Schema`] No longer validates if `:email` has been taken before setting `:unconfirmed_email` +* [`PowEmailConfirmation.Phoenix.ControllerCallbacks`] Now prevents user enumeration for `PowInvitation.Phoenix.InvitationController.create/2` +* [`PowPersistentSession.Plug.Cookie`] Changed default cookie name to `persistent_session` +* [`PowPersistentSession.Plug.Cookie`] Removed renewal of cookie as the token will always expire +* [`PowPersistentSession.Plug.Cookie`] No longer expires invalid cookies +* [`Pow.Operations`] Added `Pow.Operations.fetch_primary_key_values/2` +* [`PowPersistentSession.Plug.Base`] Now registers `:before_send` callbacks +* [`PowPersistentSession.Plug.Cookie`] Now updates cookie and backend store in `:before_send` callback +* [`Pow.Plug.Base`] Now registers `:before_send` callbacks +* [`Pow.Plug.Session`] Now updates plug session and backend store in `:before_send` callback +* [`Pow.Plug`] Added `Pow.Plug.create/3` +* [`Pow.Plug`] Added `Pow.Plug.delete/2` + +### Removed + +* [`PowResetPassword.Phoenix.ResetPasswordController`] Will no longer prevent information leak by checking if `PowEmailConfirmation` or registration routes are enabled; instead it'll by default prevent user enumeration, but can be disabled if `pow_prevent_user_enumeration: false` is set in `conn.private` + +### Bug fixes + +* [`PowPersistentSession.Plug.Base`] With custom `:persistent_session_store` now falls back to `:cache_store_backend` configuration option +* [`PowResetPassword.Plug`] With custom `:reset_password_token_store` now falls back to `:cache_store_backend` configuration option +* [`Pow.Plug.Base`] With custom `:credentials_cache_store` now falls back to `:cache_store_backend` configuration option + +### Deprecations + +* [`Pow.Ecto.Changeset`] `Pow.Ecto.Schema.Changeset.confirm_password_changeset/3` has deprecated use of `:confirm_password` in params in favor of `:password_confirmation` +* [`Pow.Plug.Session`] `:session_store` option has been renamed to `:credentials_cache_store` +* [`Pow.Plug`] `Pow.Plug.clear_authenticated_user/1` deprecated in favor of `Pow.Plug.delete/1` + +## v1.0.16 (2020-01-07) + +**Note:** This release contains an important security fix. + +### Enhancements + +* [`PowPersistentSession.Plug.Cookie`] Now supports `:persistent_session_cookie_opts` to customize any options that will be passed on to `Plug.Conn.put_resp_cookie/4` +* [`PowResetPassword.Phoenix.ResetPasswordController`] Now uses `PowResetPassword.Phoenix.Messages.maybe_email_has_been_sent/1` with a generic response that tells the user the email has been sent only if an account was found +* [`PowResetPassword.Phoenix.ResetPasswordController`] When a user doesn't exist will now return success message if `PowEmailConfirmation` extension is enabled +* [`PowResetPassword.Phoenix.Messages`] Added `PowResetPassword.Phoenix.Messages.maybe_email_has_been_sent/1` and let `PowResetPassword.Phoenix.Messages.email_has_been_sent/1` fall back to it +* [`PowEmailConfirmation.Phoenix.ControllerCallbacks`] When a user tries to sign up and the email has already been taken the default e-mail confirmation required message will be shown +* [`Pow.Plug.Session`] Now renews the Plug session each time the Pow session is created or rolled + +### Bug fixes + +* [`Pow.Ecto.Schema.Changeset`] Fixed bug where `Pow.Ecto.Schema.Changeset.user_id_field_changeset/3` update with `nil` value caused an exception to be raised +* [`PowPersistentSession.Plug.Cookie`] Now expires the cookie 10 seconds after the last request when authenticating to prevent multiple simultaneous requests deletes the cookie immediately + +### Documentation + +* Added mailer rate limitation section to [production checklist guide](guides/production_checklist.md) +* [`Pow.Plug.Session`] Added section on session expiration to the docs +* Updated instructions in [umbrella project guide](guides/umbrella_project.md) to Elixir 1.9 +* [`Pow.Store.Backend.Base`] Updated usage example with Cachex +* Added [security practices page](guides/security_practices.md) + ## v1.0.15 (2019-11-20) ### Enhancements diff --git a/README.md b/README.md index 4273b64c..0b62dbb5 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Add Pow to your list of dependencies in `mix.exs`: defp deps do [ # ... - {:pow, "~> 1.0.15"} + {:pow, "~> 1.0.18"} ] end ``` @@ -54,9 +54,15 @@ PRIV_PATH/repo/migrations/TIMESTAMP_create_user.ex Add the following to `config/config.exs`: ```elixir +use Mix.Config + +# ... existing config + config :my_app, :pow, user: MyApp.Users.User, repo: MyApp.Repo + +# ... import_config ``` Set up `WEB_PATH/endpoint.ex` to enable session based authentication (`Pow.Plug.Session` is added after `Plug.Session`): @@ -67,13 +73,8 @@ defmodule MyAppWeb.Endpoint do # ... - plug Plug.Session, - store: :cookie, - key: "_my_app_key", - signing_salt: "secret" - + plug Plug.Session, @session_options plug Pow.Plug.Session, otp_app: :my_app - plug MyAppWeb.Phoenix.Router end ``` @@ -522,7 +523,7 @@ By default `Pow.Store.Backend.EtsCache` is started automatically and can be used For a production environment, you should use a distributed, persistent cache store. Pow makes this easy with `Pow.Store.Backend.MnesiaCache`. To start MnesiaCache in your Phoenix app, add it to your `application.ex` supervisor: ```elixir -defmodule MyAppWeb.Application do +defmodule MyApp.Application do use Application def start(_type, _args) do @@ -545,7 +546,7 @@ end Update the config `cache_store_backend: Pow.Store.Backend.MnesiaCache`. -Remember to add `:mnesia` to your `:extra_applications` so it'll be available for your release build. +Remember to add `:mnesia` to your `:extra_applications` so it'll be available for your release build. Mnesia will write files to the current working directory. The path can be changed with `config :mnesia, dir: '/path/to/dir'`. The MnesiaCache requires write access. If you've a read-only file system you should take a look at the [Redis cache backend store guide](guides/redis_cache_store_backend.md). @@ -563,17 +564,7 @@ If you're currently using Coherence, you can migrate your app to use Pow instead ## Pow security practices -* The `user_id_field` value is always treated as case insensitive -* If the `user_id_field` is `:email`, it'll be validated based on RFC 5322 (excluding IP validation) -* The `:password` has a minimum length of 8 characters -* The `:password` has a maximum length of 4096 bytes [to prevent DOS attacks against Pbkdf2](https://github.com/riverrun/pbkdf2_elixir/blob/master/lib/pbkdf2.ex#L21) -* The `:password_hash` is generated with `PBKDF2-SHA512` with 100,000 iterations -* The session value contains a UUID token that is used to pull credentials through a GenServer -* The credentials are stored in a key-value cache with TTL of 30 minutes -* The credentials and session are renewed after 15 minutes if any activity is detected -* The credentials and session are renewed when user updates - -Some of the above is based on [OWASP](https://www.owasp.org/) or [NIST SP800-63b](https://pages.nist.gov/800-63-3/sp800-63b.html) recommendations. +See [security practices](guides/security_practices.md). ## Other libraries diff --git a/config/test.exs b/config/test.exs index cbcf1ce3..c14d091e 100644 --- a/config/test.exs +++ b/config/test.exs @@ -18,7 +18,7 @@ config :pow, Pow.Ecto.Schema.Password, iterations: 1 config :phoenix, :json_library, Jason -extension_test_modules = [PowEmailConfirmation, PowInvitation, PowEmailConfirmation.PowInvitation, PowPersistentSession, PowResetPassword, PowResetPassword.NoRegistration] +extension_test_modules = [PowEmailConfirmation, PowInvitation, PowEmailConfirmation.PowInvitation, PowPersistentSession, PowResetPassword] for extension <- extension_test_modules do endpoint_module = Module.concat([extension, TestWeb.Phoenix.Endpoint]) diff --git a/guides/api.md b/guides/api.md index 7e349911..72e4f5bc 100644 --- a/guides/api.md +++ b/guides/api.md @@ -69,14 +69,19 @@ defmodule MyAppWeb.APIAuthPlug do use Pow.Plug.Base alias Plug.Conn - alias Pow.{Config, Store.CredentialsCache} + alias Pow.{Config, Plug, Store.CredentialsCache} alias PowPersistentSession.Store.PersistentSessionCache @impl true @spec fetch(Conn.t(), Config.t()) :: {Conn.t(), map() | nil} def fetch(conn, config) do - token = fetch_auth_token(conn) + conn + |> fetch_auth_token(config) + |> fetch_from_store(conn, config) + end + defp fetch_from_store(nil, conn, _config), do: {conn, nil} + defp fetch_from_store(token, conn, config) do config |> store_config() |> CredentialsCache.get(token) @@ -94,35 +99,39 @@ defmodule MyAppWeb.APIAuthPlug do renew_token = Pow.UUID.generate() conn = conn - |> Conn.put_private(:api_auth_token, token) - |> Conn.put_private(:api_renew_token, renew_token) + |> Conn.put_private(:api_auth_token, sign_token(conn, token, config)) + |> Conn.put_private(:api_renew_token, sign_token(conn, renew_token, config)) CredentialsCache.put(store_config, token, {user, []}) PersistentSessionCache.put(store_config, renew_token, {[id: user.id], []}) {conn, user} end - + @impl true @spec delete(Conn.t(), Config.t()) :: Conn.t() def delete(conn, config) do - token = fetch_auth_token(conn) - - config - |> store_config() - |> CredentialsCache.delete(token) + case fetch_auth_token(conn, config) do + nil -> + :ok + + token -> + config + |> store_config() + |> CredentialsCache.delete(token) + end conn end - + @doc """ Create a new token with the provided authorization token. - + The renewal authorization token will be deleted from the store after the user id has been fetched. """ @spec renew(Conn.t(), Config.t()) :: {Conn.t(), map() | nil} def renew(conn, config) do - renew_token = fetch_auth_token(conn) + renew_token = fetch_auth_token(conn, config) store_config = store_config(config) res = PersistentSessionCache.get(store_config, renew_token) @@ -133,7 +142,7 @@ defmodule MyAppWeb.APIAuthPlug do res -> load_and_create_session(conn, res, config) end end - + defp load_and_create_session(conn, {clauses, _metadata}, config) do case Pow.Operations.get_by(clauses, config) do nil -> {conn, nil} @@ -141,12 +150,21 @@ defmodule MyAppWeb.APIAuthPlug do end end - defp fetch_auth_token(conn) do - conn - |> Plug.Conn.get_req_header("authorization") - |> List.first() + defp sign_token(conn, token, config) do + Plug.sign_token(conn, signing_salt(), token, config) + end + + defp signing_salt(), do: Atom.to_string(__MODULE__) + + defp fetch_auth_token(conn, config) do + with [token | _rest] <- Conn.get_req_header(conn, "authorization"), + {:ok, token} <- Plug.verify_token(conn, signing_salt(), token, config) do + token + else + _any -> nil + end end - + defp store_config(config) do backend = Config.get(config, :cache_store_backend, Pow.Store.Backend.EtsCache) @@ -251,9 +269,9 @@ defmodule MyAppWeb.API.V1.SessionController do @spec delete(Conn.t(), map()) :: Conn.t() def delete(conn, _params) do - {:ok, conn} = Pow.Plug.clear_authenticated_user(conn) - - json(conn, %{data: %{}}) + conn + |> Pow.Plug.delete() + |> json(%{data: %{}}) end end ``` @@ -265,7 +283,7 @@ You can now set up your client to connect to your API and generate session token You can run the following curl methods to test it out: ```bash -$ curl -X POST -d "user[email]=test@example.com&user[password]=secret1234&user[confirm_password]=secret1234" http://localhost:4000/api/v1/registration +$ curl -X POST -d "user[email]=test@example.com&user[password]=secret1234&user[password_confirmation]=secret1234" http://localhost:4000/api/v1/registration {"data":{"renew_token":"RENEW_TOKEN","token":"AUTH_TOKEN"}} $ curl -X POST -d "user[email]=test@example.com&user[password]=secret1234" http://localhost:4000/api/v1/session @@ -294,8 +312,10 @@ defmodule MyAppWeb.APIAuthPlugTest do alias MyApp.{Repo, Users.User} @pow_config [otp_app: :my_app] + @secret_key_base String.duplicate("abcdefghijklmnopqrstuvxyz0123456789", 2) test "can fetch, create, delete, and renew session for user", %{conn: conn} do + conn = %{conn | secret_key_base: @secret_key_base} user = Repo.insert!(%User{id: 1, email: "test@example.com"}) assert {_conn, nil} = APIAuthPlug.fetch(conn, @pow_config) @@ -333,8 +353,8 @@ defmodule MyAppWeb.API.V1.RegistrationControllerTest do use MyAppWeb.ConnCase describe "create/2" do - @valid_params %{"user" => %{"email" => "test@example.com", "password" => "secret1234", "confirm_password" => "secret1234"}} - @invalid_params %{"user" => %{"email" => "invalid", "password" => "secret1234", "confirm_password" => ""}} + @valid_params %{"user" => %{"email" => "test@example.com", "password" => "secret1234", "password_confirmation" => "secret1234"}} + @invalid_params %{"user" => %{"email" => "invalid", "password" => "secret1234", "password_confirmation" => ""}} test "with valid params", %{conn: conn} do conn = post conn, Routes.api_v1_registration_path(conn, :create, @valid_params) @@ -351,7 +371,7 @@ defmodule MyAppWeb.API.V1.RegistrationControllerTest do assert json["error"]["message"] == "Couldn't create user" assert json["error"]["status"] == 500 - assert json["error"]["errors"]["confirm_password"] == ["not same as password"] + assert json["error"]["errors"]["password_confirmation"] == ["does not match confirmation"] assert json["error"]["errors"]["email"] == ["has invalid format"] end end @@ -364,15 +384,16 @@ defmodule MyAppWeb.API.V1.SessionControllerTest do use MyAppWeb.ConnCase alias MyApp.{Repo, Users.User} - alias MyAppWeb.APIAuthPlug + alias MyAppWeb.{APIAuthPlug, Endpoint} alias Pow.Ecto.Schema.Password @pow_config [otp_app: :my_app] setup %{conn: conn} do + conn = %{conn | secret_key_base: Application.get_env(@pow_config[:otp_app], Endpoint)[:secret_key_base]} user = Repo.insert!(%User{email: "test@example.com", password_hash: Password.pbkdf2_hash("secret1234")}) - {:ok, conn: conn, user: user} + {:ok, user: user, conn: conn} end describe "create/2" do @@ -403,7 +424,7 @@ defmodule MyAppWeb.API.V1.SessionControllerTest do :timer.sleep(100) - {:ok, conn: conn, renew_token: authed_conn.private[:api_renew_token]} + {:ok, renew_token: authed_conn.private[:api_renew_token]} end test "with valid authorization header", %{conn: conn, renew_token: token} do @@ -436,7 +457,7 @@ defmodule MyAppWeb.API.V1.SessionControllerTest do :timer.sleep(100) - {:ok, conn: conn, auth_token: authed_conn.private[:api_auth_token]} + {:ok, auth_token: authed_conn.private[:api_auth_token]} end test "invalidates", %{conn: conn, auth_token: token} do diff --git a/guides/coherence_migration.md b/guides/coherence_migration.md index f65bcaac..73731fd4 100644 --- a/guides/coherence_migration.md +++ b/guides/coherence_migration.md @@ -78,10 +78,10 @@ Set up `user.ex` to use Pow: Coherence uses bcrypt, so you'll have to switch to bcrypt in Pow: - 1. Install comeonin for bcrypt in `mix.exs`: + 1. Install bcrypt in `mix.exs`: ```elixir - {:comeonin, "~> 3.0"} + {:bcrypt_elixir, "~> 2.0"} ``` 2. Set up `user.ex` to use bcrypt for password hashing: @@ -90,8 +90,8 @@ Coherence uses bcrypt, so you'll have to switch to bcrypt in Pow: defmodule MyApp.User do use Ecto.Schema use Pow.Ecto.Schema, - password_hash_methods: {&Comeonin.Bcrypt.hashpwsalt/1, - &Comeonin.Bcrypt.checkpw/2} + password_hash_methods: {&Bcrypt.hash_pwd_salt/1, + &Bcrypt.verify_pass/2} # ... end diff --git a/guides/configuring_mailer.md b/guides/configuring_mailer.md index 9226c37e..f34a7f25 100644 --- a/guides/configuring_mailer.md +++ b/guides/configuring_mailer.md @@ -24,6 +24,7 @@ defmodule MyAppWeb.PowMailer do require Logger + @impl true def cast(%{user: user, subject: subject, text: text, html: html}) do %Swoosh.Email{} |> to({"", user.email}) @@ -33,6 +34,7 @@ defmodule MyAppWeb.PowMailer do |> text_body(text) end + @impl true def process(email) do email |> deliver() @@ -73,6 +75,7 @@ defmodule MyAppWeb.PowMailer do import Bamboo.Email + @impl true def cast(%{user: user, subject: subject, text: text, html: html}) do new_email( to: user.email, @@ -80,9 +83,10 @@ defmodule MyAppWeb.PowMailer do subject: subject, html_body: html, text_body: text - ) + ) end + @impl true def process(email) do deliver_now(email) end diff --git a/guides/custom_controllers.md b/guides/custom_controllers.md index 8f6d9c9e..c2a26568 100644 --- a/guides/custom_controllers.md +++ b/guides/custom_controllers.md @@ -142,9 +142,9 @@ defmodule MyAppWeb.SessionController do end def delete(conn, _params) do - {:ok, conn} = Pow.Plug.clear_authenticated_user(conn) - - redirect(conn, to: Routes.page_path(conn, :index)) + conn + |> Pow.Plug.delete() + |> redirect(to: Routes.page_path(conn, :index)) end end ``` @@ -155,17 +155,17 @@ Create `lib/my_app_web/templates/registration/new.html.eex`: ```elixir <%= form_for @changeset, Routes.signup_path(@conn, :create), fn f -> %> - <%= label f, :email, "email" %> + <%= label f, :email %> <%= email_input f, :email %> <%= error_tag f, :email %> - <%= label f, :password, "password" %> + <%= label f, :password %> <%= password_input f, :password %> <%= error_tag f, :password %> - <%= label f, :confirm_password, "confirm_password" %> - <%= password_input f, :confirm_password %> - <%= error_tag f, :confirm_password %> + <%= label f, :password_confirmation %> + <%= password_input f, :password_confirmation %> + <%= error_tag f, :password_confirmation %> <%= submit "Register" %> <% end %> @@ -213,7 +213,7 @@ defmodule MyAppWeb.SessionController do false -> conn - |> Pow.Plug.clear_authenticated_user() + |> Pow.Plug.delete() |> put_flash(:info, "Your e-mail address has not been confirmed.") |> redirect(to: Routes.login_path(conn, :new)) end diff --git a/guides/lock_users.md b/guides/lock_users.md index 47707552..75d0b5fe 100644 --- a/guides/lock_users.md +++ b/guides/lock_users.md @@ -70,7 +70,7 @@ defmodule MyAppWeb.Admin.UserController do defp load_user(%{params: %{"id" => user_id}} = conn, _opts) do config = Pow.Plug.fetch_config(conn) - case Pow.Ecto.Context.get_by([id: user_id], config) do + case Pow.Operations.get_by([id: user_id], config) do nil -> # Invalid user id user -> Plug.Conn.assign(conn, :user, user) end @@ -115,9 +115,8 @@ defmodule MyAppWeb.EnsureUserNotLockedPlug do defp locked?(_user), do: false defp maybe_halt(true, conn) do - {:ok, conn} = Plug.clear_authenticated_user(conn) - conn + |> Plug.delete() |> Controller.put_flash(:error, "Sorry, your account is locked.") |> Controller.redirect(to: Routes.pow_session_path(conn, :new)) end diff --git a/guides/multitenancy.md b/guides/multitenancy.md index fd25594d..bde49f01 100644 --- a/guides/multitenancy.md +++ b/guides/multitenancy.md @@ -41,13 +41,8 @@ defmodule MyAppWeb.Endpoint do # ... - plug Plug.Session, - store: :cookie, - key: "_my_app_key", - signing_salt: "secret" - + plug Plug.Session, @session_options plug MyAppWeb.PowTriplexSessionPlug, otp_app: :my_app - # ... end ``` diff --git a/guides/production_checklist.md b/guides/production_checklist.md index 81999cd6..3cdc73ca 100644 --- a/guides/production_checklist.md +++ b/guides/production_checklist.md @@ -13,7 +13,7 @@ You should use a persistent (and possibly distributed) cache store like the `Pow To enable the Mnesia cache you should add it to your `application.ex` supervisor: ```elixir -defmodule MyAppWeb.Application do +defmodule MyApp.Application do use Application def start(_type, _args) do @@ -39,9 +39,11 @@ Update the config with `cache_store_backend: Pow.Store.Backend.MnesiaCache`. Mnesia will store the database files in the directory `./Mnesia.NODE` in the current working directory where `NODE` is the node name. By default this is `./Mnesia.nonode@nohost`. You may wish to change the location to a shared directory so you can roll deploys: ```elixir -config :mensia, :dir, '/path/to/dir' +config :mnesia, :dir, '/path/to/dir' ``` +`:mnesia` should be added to `:extra_applications` in `mix.exs` for it to be included in releases. + ## OPTIONAL: Validate that strong passwords are used [NIST 800-63b](https://pages.nist.gov/800-63-3/sp800-63b.html#-5112-memorized-secret-verifiers) recommends that you reject passwords that are commonly-used, expected, or compromised. The guidelines explicitly mentions the following methods to ensure strong passwords are used: @@ -75,3 +77,33 @@ end You should rate limit authentication attempts according to [NIST 800-63b](https://pages.nist.gov/800-63-3/sp800-63b.html#-5112-memorized-secret-verifiers). Pow doesn't include a rate limiter since this is better dealt with at the proxy or gateway side rather than application side. The minimum requirement would be to rate limit to a maximum of 100 failed authentication attempts per IP. You may also wish to [lock accounts](../guides/lock_users.md) that has had too many failed authentication attempts, or require a CAPTCHA to be solved before allowing new attempts. + +## OPTIONAL: Rate limit e-mail delivery + +There are no rate limits for any e-mails sent out with Pow, including `PowEmailConfirmation`, `PowInvitation` and `PowResetPassword` extensions. If you use a transactional e-mail service you have to make careful considerations to prevent resource usage attacks. + +Rate limitation should either be handled at the service, or you may be able to set up rate limitation in the Pow mailer. For the latter, here's a simple example using [Hammer](https://github.com/ExHammer/hammer): + +```elixir +defmodule MyAppWeb.PowMailer do + use Pow.Phoenix.Mailer + + # .... + + require Logger + + @impl true + def process(email) do + case check_rate(email) do + {:allow, _count} -> deliver(email) + {:deny, _count} -> Logger.warn("Mailer backend failed due to rate limitation: #{inspect(email)}") + end + end + + defp check_rate(%{to: email}) do + Hammer.check_rate_inc("email:#{email}", :timer.minutes(1), 2, 1) + end +end +``` + +In the above the e-mail delivery will be limited to two e-mails per minute for a single recipient, but you can use different criterias, e.g. limit for e-mails that has same receipient and subject. It's strongly recommended to add tests where appropriate to ensure abuse is not possible. diff --git a/guides/security_practices.md b/guides/security_practices.md new file mode 100644 index 00000000..c4c22122 --- /dev/null +++ b/guides/security_practices.md @@ -0,0 +1,49 @@ +# Security practices + +Some of the below is based on [OWASP](https://www.owasp.org/) or [NIST SP800-63b](https://pages.nist.gov/800-63-3/sp800-63b.html) recommendations. + +## User ID + +* The `user_id_field` value is always treated as case insensitive +* If the `user_id_field` is `:email`, it'll be validated based on RFC 5322 (sections 3.2.3 and 3.4.1) and RFC 5321 with unicode characters permitted in local and domain part + +## Password + +* The `:password` has a minimum length of 8 characters +* The `:password` has a maximum length of 4096 bytes [to prevent DOS attacks against Pbkdf2](https://github.com/riverrun/pbkdf2_elixir/blob/master/lib/pbkdf2.ex#L21) +* The `:password_hash` is generated with `PBKDF2-SHA512` with 100,000 iterations + +## Session management + +* The session value contains a UUID token that is used to pull credentials through a GenServer +* The credentials are stored in a key-value cache with TTL of 30 minutes +* The credentials and session are renewed after 15 minutes if any activity is detected +* The credentials and session are renewed when user updates + +## Timing attacks + +* If a user couldn't be found or the `:password_hash` is `nil` a blank password is used +* A UUID is always generated during reset password flow +* Tokens are signed for public consumption and verified before lookup: + * Session ID in `Pow.Plug.Session` + * Persistent session token in `PowPersistentSession.Plug.Cookie` + * Reset password token in `PowResetPassword.Plug` + * E-mail confirmation token in `PowEmailConfirmation.Plug` + * Invitation token in `PowInvitation.Plug` + +## User enumeration attacks + +* If authentication fails, a generic `The provided login details did not work. Please verify your credentials, and try again.` message is returned +* When password reset is requested with `PowResetPassword` for an e-mail that doesn't exist, the generic `If an account for the provided email exists, an email with reset instructions will be send to you. Please check your inbox.` message is returned +* When attempting to invite a user with `PowInvitation` using an already taken e-mail, the success message `An e-mail with invitation link has been sent.` is returned + +Enabling `PowEmailConfirmation` extension will add additional protection: + +* User is redirected with message to confirm their e-mail when they attempt to create a user with already taken e-mail +* Updating e-mail requires the user to confirm the e-mail address by clicking a link send to them + +You can disable the protection by setting `pow_prevent_user_enumeration: false` in `conn.private`. + +## Browser cache + +* The sign in, registration and invitation acceptance page won't be cached by the browser diff --git a/guides/sync_user.md b/guides/sync_user.md index f1e43d17..56044b86 100644 --- a/guides/sync_user.md +++ b/guides/sync_user.md @@ -59,15 +59,9 @@ defmodule MyAppWeb.Endpoint do # ... - plug Plug.Session, - store: :cookie, - key: "_my_app_key", - signing_salt: "secret" - + plug Plug.Session, @session_options plug Pow.Plug.Session, otp_app: :my_app - plug MyAppWeb.ReloadUserPlug - # ... end ``` @@ -76,19 +70,14 @@ end Let's say that you want to show the user `plan` on most pages. In this case we can safely rely on the cached credentials since we don't need to know the actual value in the database. The worst case is that a different plan may be shown if you haven't ensured that all plan update actions uses the below method. -We can use `do_create/3` defined in the `Pow.Plug.Base` macro to update the cached credentials. +We can use `Pow.Plug.create/2` to call the plug and update the cached credentials. First we'll make a helper and import it to our controllers: ```elixir defmodule MyAppWeb.PowHelper do @spec sync_user(Plug.Conn.t(), map()) :: Plug.Conn.t() - def sync_user(conn, user) do - config = Pow.Plug.fetch_config(conn) - plug = Pow.Plug.get_plug(config) - - plug.do_create(conn, user, config) - end + def sync_user(conn, user), do: Pow.Plug.create(conn, user) end ``` diff --git a/guides/umbrella_project.md b/guides/umbrella_project.md index 626c852b..9bdab8ae 100644 --- a/guides/umbrella_project.md +++ b/guides/umbrella_project.md @@ -9,4 +9,3 @@ You can follow the rest of the [README](../README.md#phoenix-app) instructions w - Run all mix ecto tasks inside the ecto app - Run all mix phoenix tasks inside the phoenix app(s) - For a project generated with `mix phx.new --umbrella` use `:my_app_web` instead of `:my_app` -- `config/config.ex` is inside the Phoenix app(s) \ No newline at end of file diff --git a/guides/user_roles.md b/guides/user_roles.md index 0dc7c4f5..2eff353a 100644 --- a/guides/user_roles.md +++ b/guides/user_roles.md @@ -178,7 +178,7 @@ defmodule MyApp.UsersTest do alias MyApp.{Repo, Users, Users.User} - @valid_params %{email: "test@example.com", password: "secret1234", confirm_password: "secret1234"} + @valid_params %{email: "test@example.com", password: "secret1234", password_confirmation: "secret1234"} test "create_admin/2" do assert {:ok, user} = Users.create_admin(@valid_params) diff --git a/lib/extensions/email_confirmation/README.md b/lib/extensions/email_confirmation/README.md index 41b77d18..b52966fa 100644 --- a/lib/extensions/email_confirmation/README.md +++ b/lib/extensions/email_confirmation/README.md @@ -4,7 +4,9 @@ This extension will send an e-mail confirmation link when the user registers, an Users won't be signed in when they register, and can't sign in until the e-mail has been confirmed. The confirmation e-mail will be sent every time the sign in fails. The user will be redirected to `after_registration_path/1` and `after_sign_in_path/1` accordingly. -When users updates their e-mail, it won't be changed until the user has confirmed the new e-mail by clicking the e-mail confirmation link. +To prevent user enumeration, the user will see the same confirmation required message if the account couldn't be created due to unique constraint error on `:email`. No e-mail will be sent. + +When users updates their e-mail, it won't be changed until the user has confirmed the new e-mail by clicking the e-mail confirmation link. The confirmation will fail if the `:email` is already in use for another account. If `PowInvitation` is used then the same logic applies when a user accepts an invitation changing their e-mail address in the process. ## Installation diff --git a/lib/extensions/email_confirmation/ecto/context.ex b/lib/extensions/email_confirmation/ecto/context.ex index 9cc68272..24ebe41e 100644 --- a/lib/extensions/email_confirmation/ecto/context.ex +++ b/lib/extensions/email_confirmation/ecto/context.ex @@ -2,7 +2,7 @@ defmodule PowEmailConfirmation.Ecto.Context do @moduledoc """ Handles e-mail confirmation context for user. """ - alias Pow.{Config, Ecto.Context} + alias Pow.{Config, Ecto.Context, Operations} alias PowEmailConfirmation.Ecto.Schema @doc """ @@ -10,7 +10,7 @@ defmodule PowEmailConfirmation.Ecto.Context do """ @spec get_by_confirmation_token(binary(), Config.t()) :: Context.user() | nil def get_by_confirmation_token(token, config), - do: Context.get_by([email_confirmation_token: token], config) + do: Operations.get_by([email_confirmation_token: token], config) @doc """ Checks if the users current e-mail is unconfirmed. diff --git a/lib/extensions/email_confirmation/ecto/schema.ex b/lib/extensions/email_confirmation/ecto/schema.ex index 6274cab2..a2424b6a 100644 --- a/lib/extensions/email_confirmation/ecto/schema.ex +++ b/lib/extensions/email_confirmation/ecto/schema.ex @@ -121,21 +121,6 @@ defmodule PowEmailConfirmation.Ecto.Schema do changeset |> Changeset.put_change(:email, current_email) |> Changeset.put_change(:unconfirmed_email, new_email) - |> Changeset.prepare_changes(&validate_unique_email/1) - end - - defp validate_unique_email(changeset) do - opts = Keyword.take(changeset.repo_opts, [:prefix]) - unconfirmed_email = Changeset.get_change(changeset, :unconfirmed_email) - unique_email_changeset = - changeset - |> Changeset.put_change(:email, unconfirmed_email) - |> Changeset.unsafe_validate_unique(:email, changeset.repo, opts) - - case unique_email_changeset.valid? do - true -> changeset - false -> unique_email_changeset - end end @doc """ diff --git a/lib/extensions/email_confirmation/phoenix/controllers/confirmation_controller.ex b/lib/extensions/email_confirmation/phoenix/controllers/confirmation_controller.ex index 0a14248c..130be448 100644 --- a/lib/extensions/email_confirmation/phoenix/controllers/confirmation_controller.ex +++ b/lib/extensions/email_confirmation/phoenix/controllers/confirmation_controller.ex @@ -6,7 +6,7 @@ defmodule PowEmailConfirmation.Phoenix.ConfirmationController do alias PowEmailConfirmation.Plug @spec process_show(Conn.t(), map()) :: {:ok | :error, map(), Conn.t()} - def process_show(conn, %{"id" => token}), do: Plug.confirm_email(conn, token) + def process_show(conn, %{"id" => token}), do: Plug.confirm_email_by_token(conn, token) @spec respond_show({:ok | :error, map(), Conn.t()}) :: Conn.t() def respond_show({:ok, _user, conn}) do diff --git a/lib/extensions/email_confirmation/phoenix/controllers/controller_callbacks.ex b/lib/extensions/email_confirmation/phoenix/controllers/controller_callbacks.ex index b713a733..0f6e980d 100644 --- a/lib/extensions/email_confirmation/phoenix/controllers/controller_callbacks.ex +++ b/lib/extensions/email_confirmation/phoenix/controllers/controller_callbacks.ex @@ -4,13 +4,14 @@ defmodule PowEmailConfirmation.Phoenix.ControllerCallbacks do ### User hasn't confirmed e-mail - Triggers on `Pow.Phoenix.RegistrationController.create/2` or + Triggers on `Pow.Phoenix.RegistrationController.create/2` and `Pow.Phoenix.SessionController.create/2`. When a user is created or authenticated, and the current e-mail hasn't been - confirmed, a confirmation e-mail is sent, the session will be cleared, and the - user redirected back to `Pow.Phoenix.Routes.after_registration_path/1` or - `Pow.Phoenix.Routes.after_registration_path/1` respectively. + confirmed, a confirmation e-mail is sent, the session will be cleared, an + error flash is set for the `conn` and the user redirected back to + `Pow.Phoenix.Routes.after_registration_path/1` or + `Pow.Phoenix.Routes.after_sign_in_path/1` respectively. ### User updates e-mail @@ -18,20 +19,29 @@ defmodule PowEmailConfirmation.Phoenix.ControllerCallbacks do `PowInvitation.Phoenix.InvitationController.update/2` When a user changes their e-mail, a confirmation e-mail is send to the new - e-mail, and an error flash is set for the conn. The same happens if the + e-mail, and an error flash is set for the `conn`. The same happens if the `PowInvitation` extension is enabled, and a user updates their e-mail when accepting their invitation. It's assumed that the current e-mail for the invited user has already been confirmed, see `PowInvitation.Ecto.Schema.invite_changeset/3` for more. See `PowEmailConfirmation.Ecto.Schema` for more. + + ### Unique constraint error on `:email` + + Triggers on `Pow.Phoenix.RegistrationController.create/2`. + + When a user can't be created and the changeset has a unique constraint error + for the `:email` field, the user will experience the same success flow as if + the user could be created, but no e-mail is sent out. This prevents + user enumeration. """ use Pow.Extension.Phoenix.ControllerCallbacks.Base alias Plug.Conn - alias Pow.Plug + alias Pow.Plug, as: PowPlug alias PowEmailConfirmation.Phoenix.{ConfirmationController, Mailer} - alias PowEmailConfirmation.Plug, as: PowEmailConfirmationPlug + alias PowEmailConfirmation.Plug @doc false @impl true @@ -40,40 +50,58 @@ defmodule PowEmailConfirmation.Phoenix.ControllerCallbacks do halt_unconfirmed(conn, {:ok, user, conn}, return_path) end + def before_respond(Pow.Phoenix.RegistrationController, :create, {:error, changeset, conn}, _config) do + case PowPlug.__prevent_user_enumeration__(conn, changeset) do + true -> + return_path = routes(conn).after_registration_path(conn) + conn = redirect_with_email_confirmation_required(conn, return_path) + + {:halt, conn} + + false -> + {:error, changeset, conn} + end + end + def before_respond(Pow.Phoenix.RegistrationController, :update, {:ok, user, conn}, _config) do + warn_unconfirmed(conn, user) + end def before_respond(Pow.Phoenix.SessionController, :create, {:ok, conn}, _config) do return_path = routes(conn).after_sign_in_path(conn) halt_unconfirmed(conn, {:ok, conn}, return_path) end - def before_respond(Pow.Phoenix.RegistrationController, :update, {:ok, user, conn}, _config), - do: warn_unconfirmed(conn, user) - def before_respond(PowInvitation.Phoenix.InvitationController, :update, {:ok, user, conn}, _config), - do: warn_unconfirmed(conn, user) + def before_respond(PowInvitation.Phoenix.InvitationController, :update, {:ok, user, conn}, _config) do + warn_unconfirmed(conn, user) + end defp halt_unconfirmed(conn, success_response, return_path) do - case PowEmailConfirmationPlug.email_unconfirmed?(conn) do + case Plug.email_unconfirmed?(conn) do true -> halt_and_send_confirmation_email(conn, return_path) false -> success_response end end defp halt_and_send_confirmation_email(conn, return_path) do - user = Plug.current_user(conn) - {:ok, conn} = Plug.clear_authenticated_user(conn) - error = extension_messages(conn).email_confirmation_required(conn) - - send_confirmation_email(user, conn) + send_confirmation_email(PowPlug.current_user(conn), conn) conn = conn - |> Phoenix.Controller.put_flash(:error, error) - |> Phoenix.Controller.redirect(to: return_path) + |> PowPlug.delete() + |> redirect_with_email_confirmation_required(return_path) {:halt, conn} end + defp redirect_with_email_confirmation_required(conn, return_path) do + error = extension_messages(conn).email_confirmation_required(conn) + + conn + |> Phoenix.Controller.put_flash(:error, error) + |> Phoenix.Controller.redirect(to: return_path) + end + defp warn_unconfirmed(%{params: %{"user" => %{"email" => email}}} = conn, %{unconfirmed_email: email} = user) do - case PowEmailConfirmationPlug.pending_email_change?(conn) do + case Plug.pending_email_change?(conn) do true -> warn_and_send_confirmation_email(conn) false -> {:ok, user, conn} end @@ -81,7 +109,7 @@ defmodule PowEmailConfirmation.Phoenix.ControllerCallbacks do defp warn_unconfirmed(conn, user), do: {:ok, user, conn} defp warn_and_send_confirmation_email(conn) do - user = Plug.current_user(conn) + user = PowPlug.current_user(conn) error = extension_messages(conn).email_confirmation_required_for_update(conn) conn = Phoenix.Controller.put_flash(conn, :error, error) @@ -98,14 +126,16 @@ defmodule PowEmailConfirmation.Phoenix.ControllerCallbacks do """ @spec send_confirmation_email(map(), Conn.t()) :: any() def send_confirmation_email(user, conn) do - url = confirmation_url(conn, user.email_confirmation_token) + url = confirmation_url(conn, user) unconfirmed_user = %{user | email: user.unconfirmed_email || user.email} email = Mailer.email_confirmation(conn, unconfirmed_user, url) Pow.Phoenix.Mailer.deliver(conn, email) end - defp confirmation_url(conn, token) do + defp confirmation_url(conn, user) do + token = Plug.sign_confirmation_token(conn, user) + routes(conn).url_for(conn, ConfirmationController, :show, [token]) end end diff --git a/lib/extensions/email_confirmation/phoenix/messages.ex b/lib/extensions/email_confirmation/phoenix/messages.ex index d84a54d5..bd1145b5 100644 --- a/lib/extensions/email_confirmation/phoenix/messages.ex +++ b/lib/extensions/email_confirmation/phoenix/messages.ex @@ -1,5 +1,9 @@ defmodule PowEmailConfirmation.Phoenix.Messages do - @moduledoc false + @moduledoc """ + Module that handles messages for PowEmailConfirmation. + + See `Pow.Extension.Phoenix.Messages` for more. + """ @doc """ Flash message to show when email has been confirmed. diff --git a/lib/extensions/email_confirmation/plug.ex b/lib/extensions/email_confirmation/plug.ex index fe06be60..dd80fc50 100644 --- a/lib/extensions/email_confirmation/plug.ex +++ b/lib/extensions/email_confirmation/plug.ex @@ -3,7 +3,7 @@ defmodule PowEmailConfirmation.Plug do Plug helper methods. """ alias Plug.Conn - alias Pow.Plug + alias Pow.{Operations, Plug} alias PowEmailConfirmation.Ecto.Context @doc """ @@ -31,22 +31,41 @@ defmodule PowEmailConfirmation.Plug do end @doc """ - Confirms the e-mail for the user found by the provided confirmation token. + Signs the e-mail confirmation token for public consumption. + + The token will be signed using `Pow.Plug.sign_token/4`. + """ + @spec sign_confirmation_token(Conn.t(), map()) :: binary() + def sign_confirmation_token(conn, %{email_confirmation_token: token}), + do: Plug.sign_token(conn, signing_salt(), token) + + defp signing_salt(), do: Atom.to_string(__MODULE__) + + @doc """ + Verifies the token and confirms the e-mail for the user found with it. If successful, and a session exists, the session will be regenerated. + + The token should have been signed with `sign_confirmation_token/2`. The token + will be decoded and verified with `Pow.Plug.verify_token/4`. """ - @spec confirm_email(Conn.t(), binary()) :: {:ok, map(), Conn.t()} | {:error, map(), Conn.t()} - def confirm_email(conn, token) do + @spec confirm_email_by_token(Conn.t(), binary()) :: {:ok, map(), Conn.t()} | {:error, map(), Conn.t()} + def confirm_email_by_token(conn, signed_token) do config = Plug.fetch_config(conn) - token - |> Context.get_by_confirmation_token(config) + conn + |> verify_and_get_by_token(signed_token, config) |> maybe_confirm_email(conn, config) end - defp maybe_confirm_email(nil, conn, _config) do - {:error, nil, conn} + defp verify_and_get_by_token(conn, signed_token, config) do + case Plug.verify_token(conn, signing_salt(), signed_token, config) do + :error -> nil + {:ok, token} -> Context.get_by_confirmation_token(token, config) + end end + + defp maybe_confirm_email(nil, conn, _config), do: {:error, nil, conn} defp maybe_confirm_email(user, conn, config) do user |> Context.confirm_email(config) @@ -56,10 +75,29 @@ defmodule PowEmailConfirmation.Plug do end end - defp maybe_renew_conn(conn, %{id: user_id} = user, config) do - case Plug.current_user(conn, config) do - %{id: ^user_id} -> Plug.get_plug(config).do_create(conn, user, config) - _any -> conn + defp maybe_renew_conn(conn, user, config) do + case equal_user?(user, Plug.current_user(conn, config), config) do + true -> Plug.create(conn, user, config) + false -> conn end end + + defp equal_user?(_user, nil, _config), do: false + defp equal_user?(user, current_user, config) do + {:ok, clauses1} = Operations.fetch_primary_key_values(user, config) + {:ok, clauses2} = Operations.fetch_primary_key_values(current_user, config) + + Keyword.equal?(clauses1, clauses2) + end + + # TODO: Remove by 1.1.0 + @doc false + @deprecated "Use `confirm_email_by_token/2`" + def confirm_email(conn, token) do + config = Plug.fetch_config(conn) + + token + |> Context.get_by_confirmation_token(config) + |> maybe_confirm_email(conn, config) + end end diff --git a/lib/extensions/invitation/README.md b/lib/extensions/invitation/README.md index b3d28788..d1b9bd47 100644 --- a/lib/extensions/invitation/README.md +++ b/lib/extensions/invitation/README.md @@ -4,6 +4,10 @@ This extension will set up a basic invitation system where users can invite othe Invited users are persisted in the database without a password. Only the user id will be validated when the user is invited, but `changeset/2` on your user schema will be used for when the user accepts the invitation. +To prevent user enumeration, the invited user will, to the inviter, appear as succesfully invited even if the user couldn't be created due to unique constraint error on `:email`. No e-mail will be sent out. If `pow_prevent_user_enumeration: false` is set in `conn.private` the form with error will be shown instead. + +An invited user can change their e-mail when accepting the invitation. To prevent user enumeration `PowEmailConfirmation` extension can be enabled. + ## Installation Follow the instructions for extensions in [README.md](../../../README.md#add-extensions-support), and set `PowInvitation` in the `:extensions` list. diff --git a/lib/extensions/invitation/ecto/context.ex b/lib/extensions/invitation/ecto/context.ex index c5d025c1..04af59d2 100644 --- a/lib/extensions/invitation/ecto/context.ex +++ b/lib/extensions/invitation/ecto/context.ex @@ -2,7 +2,7 @@ defmodule PowInvitation.Ecto.Context do @moduledoc """ Handles invitation context for user. """ - alias Pow.{Config, Ecto.Context} + alias Pow.{Config, Ecto.Context, Operations} alias PowInvitation.Ecto.Schema @doc """ @@ -40,7 +40,7 @@ defmodule PowInvitation.Ecto.Context do @spec get_by_invitation_token(binary(), Config.t()) :: Context.user() | nil def get_by_invitation_token(token, config) do [invitation_token: token] - |> Context.get_by(config) + |> Operations.get_by(config) |> case do %{invitation_accepted_at: nil} = user -> user _ -> nil diff --git a/lib/extensions/invitation/phoenix/controllers/invitation_controller.ex b/lib/extensions/invitation/phoenix/controllers/invitation_controller.ex index 42a86dd3..d4cc7b21 100644 --- a/lib/extensions/invitation/phoenix/controllers/invitation_controller.ex +++ b/lib/extensions/invitation/phoenix/controllers/invitation_controller.ex @@ -4,6 +4,7 @@ defmodule PowInvitation.Phoenix.InvitationController do alias Plug.Conn alias Pow.Phoenix.SessionController + alias Pow.Plug, as: PowPlug alias PowInvitation.{Phoenix.Mailer, Plug} plug :require_authenticated when action in [:new, :create, :show] @@ -34,29 +35,41 @@ defmodule PowInvitation.Phoenix.InvitationController do def respond_create({:ok, %{email: email} = user, conn}) when is_binary(email) do deliver_email(conn, user) - conn - |> put_flash(:info, extension_messages(conn).invitation_email_sent(conn)) - |> redirect(to: routes(conn).path_for(conn, __MODULE__, :new)) + invitation_sent_redirect(conn) end def respond_create({:ok, user, conn}) do redirect(conn, to: routes(conn).path_for(conn, __MODULE__, :show, [user.invitation_token])) end def respond_create({:error, changeset, conn}) do - conn - |> assign(:changeset, changeset) - |> render("new.html") + case PowPlug.__prevent_user_enumeration__(conn, changeset) do + true -> + invitation_sent_redirect(conn) + + false -> + conn + |> assign(:changeset, changeset) + |> render("new.html") + end end defp deliver_email(conn, user) do url = invitation_url(conn, user) - invited_by = Pow.Plug.current_user(conn) + invited_by = PowPlug.current_user(conn) email = Mailer.invitation(conn, user, invited_by, url) Pow.Phoenix.Mailer.deliver(conn, email) end defp invitation_url(conn, user) do - routes(conn).url_for(conn, __MODULE__, :edit, [user.invitation_token]) + token = Plug.sign_invitation_token(conn, user) + + routes(conn).url_for(conn, __MODULE__, :edit, [token]) + end + + defp invitation_sent_redirect(conn) do + conn + |> put_flash(:info, extension_messages(conn).invitation_email_sent(conn)) + |> redirect(to: routes(conn).path_for(conn, __MODULE__, :new)) end @spec process_show(Conn.t(), map()) :: {:ok, Conn.t()} @@ -99,15 +112,15 @@ defmodule PowInvitation.Phoenix.InvitationController do end defp load_user_from_invitation_token(%{params: %{"id" => token}} = conn, _opts) do - case Plug.invited_user_from_token(conn, token) do - nil -> + case Plug.load_invited_user_by_token(conn, token) do + {:error, conn} -> conn |> put_flash(:error, extension_messages(conn).invalid_invitation(conn)) |> redirect(to: routes(conn).path_for(conn, SessionController, :new)) |> halt() - user -> - Plug.assign_invited_user(conn, user) + {:ok, conn} -> + conn end end diff --git a/lib/extensions/invitation/phoenix/messages.ex b/lib/extensions/invitation/phoenix/messages.ex index 29d70fb9..280f3573 100644 --- a/lib/extensions/invitation/phoenix/messages.ex +++ b/lib/extensions/invitation/phoenix/messages.ex @@ -1,5 +1,9 @@ defmodule PowInvitation.Phoenix.Messages do - @moduledoc false + @moduledoc """ + Module that handles messages for PowInvitation. + + See `Pow.Extension.Phoenix.Messages` for more. + """ @doc """ Flash message to show when an invalid or expired invitation url is used. diff --git a/lib/extensions/invitation/phoenix/templates/invitation_template.ex b/lib/extensions/invitation/phoenix/templates/invitation_template.ex index e7bae81d..1b7d69af 100644 --- a/lib/extensions/invitation/phoenix/templates/invitation_template.ex +++ b/lib/extensions/invitation/phoenix/templates/invitation_template.ex @@ -18,7 +18,7 @@ defmodule PowInvitation.Phoenix.InvitationTemplate do <%= Pow.Phoenix.HTML.FormTemplate.render([ {:text, {:changeset, :pow_user_id_field}}, {:password, :password}, - {:password, :confirm_password} + {:password, :password_confirmation} ]) %> <%%= link "Sign in", to: Routes.<%= Pow.Phoenix.Controller.route_helper(Pow.Phoenix.SessionController) %>_path(@conn, :new) %> diff --git a/lib/extensions/invitation/plug.ex b/lib/extensions/invitation/plug.ex index 2eef776b..11443e9f 100644 --- a/lib/extensions/invitation/plug.ex +++ b/lib/extensions/invitation/plug.ex @@ -54,7 +54,7 @@ defmodule PowInvitation.Plug do |> invited_user() |> InvitationContext.update(params, config) |> case do - {:ok, user} -> {:ok, user, Plug.get_plug(config).do_create(conn, user, config)} + {:ok, user} -> {:ok, user, Plug.create(conn, user, config)} {:error, changeset} -> {:error, changeset, conn} end end @@ -62,19 +62,49 @@ defmodule PowInvitation.Plug do defp invited_user(conn), do: conn.assigns[:invited_user] @doc """ - Fetches invited user by the invitation token. + Signs the invitation token for public consumption. + + The token will be signed using `Pow.Plug.sign_token/4`. + """ + @spec sign_invitation_token(Conn.t(), map()) :: binary() + def sign_invitation_token(conn, %{invitation_token: token}), + do: Plug.sign_token(conn, signing_salt(), token) + + defp signing_salt(), do: Atom.to_string(__MODULE__) + + @doc """ + Verifies the token and fetches invited user by the invitation token. + + If a user is found, it'll be assigned to `conn.assign` for key + `:invited_user`. + + The token should have been signed with `sign_invitation_token/2`. The token + will be decoded and verified with `Pow.Plug.verify_token/4`. """ - @spec invited_user_from_token(Conn.t(), binary()) :: map() | nil + @spec load_invited_user_by_token(Conn.t(), binary()) :: {:ok, Conn.t()} | {:error, Conn.t()} + def load_invited_user_by_token(conn, signed_token) do + config = Plug.fetch_config(conn) + + with {:ok, token} <- Plug.verify_token(conn, signing_salt(), signed_token, config), + user when not is_nil(user) <- InvitationContext.get_by_invitation_token(token, config) do + {:ok, Conn.assign(conn, :invited_user, user)} + else + _any -> {:error, conn} + end + end + + # TODO: Remove by 1.1.0 + @doc false + @deprecated "Use `load_invited_user_by_token/2` instead" def invited_user_from_token(conn, token) do config = Plug.fetch_config(conn) InvitationContext.get_by_invitation_token(token, config) end - @doc """ - Assigns a `:invited_user` key with the user in the connection. - """ - @spec assign_invited_user(Conn.t(), map()) :: Conn.t() + # TODO: Remove by 1.1.0 + @doc false + @deprecated "No longer used" def assign_invited_user(conn, user) do Conn.assign(conn, :invited_user, user) end diff --git a/lib/extensions/persistent_session/README.md b/lib/extensions/persistent_session/README.md index ee0c4cfe..75d6f2e6 100644 --- a/lib/extensions/persistent_session/README.md +++ b/lib/extensions/persistent_session/README.md @@ -1,6 +1,6 @@ # PowPersistentSession -This extension will permit reissuing sessions, by setting a cookie with a 30-day expiration (renewed on every request). The token in this cookie can be used exactly once to create the session. When the session has been created, the cookie will be updated with a new id. +This extension can reissue sessions by setting a cookie with a token that may be used exactly once to issue the session. The cookie and token will expire after 30 days. Once the session has been issued, a new cookie and token will be set that expires after another 30 days. ## Installation @@ -13,9 +13,7 @@ defmodule MyAppWeb.Endpoint do # ... plug Pow.Plug.Session, otp_app: :my_app - plug PowPersistentSession.Plug.Cookie - #... end ``` diff --git a/lib/extensions/persistent_session/phoenix/controllers/controller_callbacks.ex b/lib/extensions/persistent_session/phoenix/controllers/controller_callbacks.ex index 255e97eb..1ef655e1 100644 --- a/lib/extensions/persistent_session/phoenix/controllers/controller_callbacks.ex +++ b/lib/extensions/persistent_session/phoenix/controllers/controller_callbacks.ex @@ -6,20 +6,18 @@ defmodule PowPersistentSession.Phoenix.ControllerCallbacks do alias PowPersistentSession.Plug @impl true + def before_process(Pow.Phoenix.SessionController, :create, %{params: %{"user" => %{"persistent_session" => "false"}}} = conn, _config) do + Conn.put_private(conn, :pow_persistent_session_store, false) + end def before_process(Pow.Phoenix.SessionController, :create, conn, _config) do - store = Map.get(conn.params["user"], "persistent_session", "true") - - Conn.put_private(conn, :store_persistent_session?, store) + Conn.put_private(conn, :pow_persistent_session_store, true) end @impl true - def before_respond(Pow.Phoenix.SessionController, :create, {:ok, conn}, _config) do + def before_respond(Pow.Phoenix.SessionController, :create, {:ok, %{private: %{pow_persistent_session_store: true}} = conn}, _config) do user = Pow.Plug.current_user(conn) - case conn.private[:store_persistent_session?] do - "true" -> {:ok, Plug.create(conn, user)} - _any -> {:ok, conn} - end + {:ok, Plug.create(conn, user)} end @impl true diff --git a/lib/extensions/persistent_session/plug/base.ex b/lib/extensions/persistent_session/plug/base.ex index b433ccdc..d91ddc82 100644 --- a/lib/extensions/persistent_session/plug/base.ex +++ b/lib/extensions/persistent_session/plug/base.ex @@ -2,6 +2,11 @@ defmodule PowPersistentSession.Plug.Base do @moduledoc """ Base module for setting up persistent session plugs. + Any writes to backend store or client should occur in `:before_send` callback + as defined in `Plug.Conn`. To ensure that the callbacks are called in the + order they were set, a `register_before_send/2` method is used to set + callbacks instead of `Plug.Conn.register_before_send/2`. + See `PowPersistentSession.Plug.Cookie` for an implementation example. ## Configuration options @@ -24,6 +29,8 @@ defmodule PowPersistentSession.Plug.Base do alias Pow.{Config, Plug, Store.Backend.EtsCache} alias PowPersistentSession.Store.PersistentSessionCache + @callback init(Config.t()) :: Config.t() + @callback call(Conn.t(), Config.t()) :: Conn.t() @callback authenticate(Conn.t(), Config.t()) :: Conn.t() @callback create(Conn.t(), map(), Config.t()) :: Conn.t() @@ -32,38 +39,54 @@ defmodule PowPersistentSession.Plug.Base do quote do @behaviour unquote(__MODULE__) + @before_send_private_key String.to_atom(Macro.underscore(__MODULE__) <> "/before_send") + import unquote(__MODULE__) - @spec init(Config.t()) :: Config.t() + @impl true def init(config), do: config - @spec call(Conn.t(), Config.t()) :: Conn.t() + @impl true def call(conn, config) do config = conn |> Plug.fetch_config() |> Config.merge(config) + conn = Conn.put_private(conn, :pow_persistent_session, {__MODULE__, config}) conn - |> Conn.put_private(:pow_persistent_session, {__MODULE__, config}) - |> authenticate(config) + |> Plug.current_user(config) + |> maybe_authenticate(conn, config) + |> Conn.register_before_send(fn conn -> + conn.private + |> Map.get(@before_send_private_key, []) + |> Enum.reduce(conn, & &1.(&2)) + end) + end + + defp maybe_authenticate(nil, conn, config), do: authenticate(conn, config) + defp maybe_authenticate(_user, conn, _config), do: conn + + defp register_before_send(conn, callback) do + callbacks = Map.get(conn.private, @before_send_private_key, []) ++ [callback] + + Conn.put_private(conn, @before_send_private_key, callbacks) end end end @spec store(Config.t()) :: {module(), Config.t()} def store(config) do - case Config.get(config, :persistent_session_store, default_store(config)) do - {store, store_config} -> {store, store_config} - store -> {store, []} + case Config.get(config, :persistent_session_store) do + {store, store_config} -> {store, store_opts(config, store_config)} + nil -> {PersistentSessionCache, store_opts(config)} end end - defp default_store(config) do - backend = Config.get(config, :cache_store_backend, EtsCache) - ttl = ttl(config) - - {PersistentSessionCache, [backend: backend, ttl: ttl]} + defp store_opts(config, store_config \\ []) do + store_config + |> Keyword.put_new(:backend, Config.get(config, :cache_store_backend, EtsCache)) + |> Keyword.put_new(:ttl, ttl(config)) end @ttl :timer.hours(24) * 30 diff --git a/lib/extensions/persistent_session/plug/cookie.ex b/lib/extensions/persistent_session/plug/cookie.ex index 8f748dce..3bd9f94f 100644 --- a/lib/extensions/persistent_session/plug/cookie.ex +++ b/lib/extensions/persistent_session/plug/cookie.ex @@ -2,24 +2,24 @@ defmodule PowPersistentSession.Plug.Cookie do @moduledoc """ This plug will handle persistent user sessions with cookies. - By default, the cookie will expire after 30 days. The cookie expiration will - be renewed on every request. The token in the cookie can only be used once to - create a session. + The cookie and token will expire after 30 days. The token in the cookie can + only be used once to create a session. If an assigned private `:pow_session_metadata` key exists in the conn with a keyword list containing a `:fingerprint` key, that fingerprint value will be set along with the user clause as the persistent session value as `{[id: user_id], session_metadata: [fingerprint: fingerprint]}`. + The token used in the client is signed using `Pow.Plug.sign_token/4` to + prevent timing attacks. + ## Example defmodule MyAppWeb.Endpoint do # ... plug Pow.Plug.Session, otp_app: :my_app - plug PowPersistentSession.Plug.Cookie - #... end @@ -30,12 +30,17 @@ defmodule PowPersistentSession.Plug.Cookie do * `:cache_store_backend` - see `PowPersistentSession.Plug.Base` * `:persistent_session_cookie_key` - session key name. This defaults to - "persistent_session_cookie". If `:otp_app` is used it'll automatically - prepend the key with the `:otp_app` value. + "persistent_session". If `:otp_app` is used it'll automatically prepend + the key with the `:otp_app` value. * `:persistent_session_ttl` - used for both backend store and max age for cookie. See `PowPersistentSession.Plug.Base` for more. + * `:persistent_session_cookie_opts` - keyword list of cookie options, see + `Plug.Conn.put_resp_cookie/4` for options. The default options are + `[max_age: max_age, path: "/"]` where `:max_age` is the value defined in + `:persistent_session_ttl`. + ## Custom metadata You can assign a private `:pow_persistent_session_metadata` key in the conn @@ -63,12 +68,12 @@ defmodule PowPersistentSession.Plug.Cookie do use PowPersistentSession.Plug.Base alias Plug.Conn - alias Pow.{Config, Plug, UUID} + alias Pow.{Config, Operations, Plug, UUID} - @cookie_key "persistent_session_cookie" + @cookie_key "persistent_session" @doc """ - Sets a persistent session cookie with an auto generated token. + Sets a persistent session cookie with a randomly generated unique token. The token is set as a key in the persistent session cache with the id fetched from the struct. Any existing persistent session will be deleted first with @@ -80,26 +85,32 @@ defmodule PowPersistentSession.Plug.Cookie do value will look like: `{[id: user_id], session_metadata: [fingerprint: fingerprint]}` - The unique cookie id will be prepended by the `:otp_app` configuration - value, if present. + The unique token will be prepended by the `:otp_app` configuration value, if + present. + + The token will be signed for public consumption with `Pow.Plug.sign_token/4`. """ @spec create(Conn.t(), map(), Config.t()) :: Conn.t() def create(conn, user, config) do + conn + |> delete(config) + |> before_send_create(user, config) + end + + defp before_send_create(conn, user, config) do {store, store_config} = store(config) - cookie_key = cookie_key(config) - key = cookie_id(config) - value = persistent_session_value(conn, user) - opts = session_opts(config) + token = gen_token(config) + value = persistent_session_value(conn, user, config) - store.put(store_config, key, value) + register_before_send(conn, fn conn -> + store.put(store_config, token, value) - conn - |> delete(config) - |> Conn.put_resp_cookie(cookie_key, key, opts) + client_store_put(conn, token, config) + end) end - defp persistent_session_value(conn, user) do - clauses = user_to_get_by_clauses(user) + defp persistent_session_value(conn, user, config) do + clauses = user_to_get_by_clauses!(user, config) metadata = conn.private |> Map.get(:pow_persistent_session_metadata, []) @@ -108,7 +119,12 @@ defmodule PowPersistentSession.Plug.Cookie do {clauses, metadata} end - defp user_to_get_by_clauses(%{id: id}), do: [id: id] + defp user_to_get_by_clauses!(user, config) do + case Operations.fetch_primary_key_values(user, config) do + {:ok, clauses} -> clauses + {:error, error} -> raise error + end + end defp maybe_put_fingerprint_in_session_metadata(metadata, conn) do conn.private @@ -129,99 +145,125 @@ defmodule PowPersistentSession.Plug.Cookie do end @doc """ - Expires the persistent session cookie. + Expires the persistent session. - If a persistent session cookie exists it'll be expired, and the token in - the persistent session cache will be deleted. + If a persistent session cookie exists the token in the persistent session + cache will be deleted, and cookie deleted with + `Plug.Conn.delete_resp_cookie/3. """ @spec delete(Conn.t(), Config.t()) :: Conn.t() - def delete(conn, config) do - cookie_key = cookie_key(config) + def delete(conn, config), do: before_send_delete(conn, config) - case conn.req_cookies[cookie_key] do - nil -> conn - key_id -> do_delete(conn, cookie_key, key_id, config) - end + defp before_send_delete(conn, config) do + register_before_send(conn, fn conn -> + case client_store_fetch(conn, config) do + {nil, conn} -> + conn + + {token, conn} -> + expire_token_in_store(token, config) + + client_store_delete(conn, config) + end + end) end - defp do_delete(conn, cookie_key, key_id, config) do + defp expire_token_in_store(token, config) do {store, store_config} = store(config) - value = "" - opts = [max_age: -1, path: "/"] - store.delete(store_config, key_id) - Conn.put_resp_cookie(conn, cookie_key, value, opts) + store.delete(store_config, token) end @doc """ Authenticates a user with the persistent session cookie. If a persistent session cookie exists, it'll fetch the credentials from the - persistent session cache, and create a new session and persistent session - cookie. The old persistent session cookie and session cache credentials will - be removed. + persistent session cache. + + If credentials was fetched successfully, a global lock is set and the token + in the cache is deleted, a new session is created, and `create/2` is called + to create a new persistent session cookie. If setting the lock failed, the + user will fetched will be set for the `conn` with + `Pow.Plug.assign_current_user/3`. If a `:session_metadata` keyword list is fetched from the persistent session metadata, all the values will be merged into the private `:pow_session_metadata` key in the conn. - The cookie expiration will automatically be renewed on every request. + The persistent session token will be decoded and verified with + `Pow.Plug.verify_token/4`. """ @spec authenticate(Conn.t(), Config.t()) :: Conn.t() def authenticate(conn, config) do - user = Plug.current_user(conn, config) - - conn - |> Conn.fetch_cookies() - |> maybe_authenticate(user, config) - |> maybe_renew(config) - end - - defp maybe_authenticate(conn, nil, config) do - cookie_key = cookie_key(config) - - case conn.req_cookies[cookie_key] do - nil -> conn - key_id -> do_authenticate(conn, key_id, config) + case client_store_fetch(conn, config) do + {nil, conn} -> conn + {token, conn} -> do_authenticate(conn, token, config) end end - defp maybe_authenticate(conn, _user, _config), do: conn - defp do_authenticate(conn, key_id, config) do + defp do_authenticate(conn, token, config) do {store, store_config} = store(config) - res = store.get(store_config, key_id) - plug = Plug.get_plug(config) - conn = delete(conn, config) - case res do - :not_found -> conn - res -> fetch_and_auth_user(conn, res, plug, config) + {token, store.get(store_config, token)} + |> fetch_user(config) + |> case do + :error -> + conn + + {token, nil} -> + expire_token_in_store(token, config) + + conn + + {token, {user, metadata}} -> + lock_auth_user(conn, token, user, metadata, config) end end - defp fetch_and_auth_user(conn, {clauses, metadata}, plug, config) do + defp fetch_user({_token, :not_found}, _config), do: :error + defp fetch_user({token, {clauses, metadata}}, config) do clauses |> filter_invalid!() - |> Pow.Operations.get_by(config) + |> Operations.get_by(config) |> case do - nil -> - conn - - user -> - conn - |> update_persistent_session_metadata(metadata) - |> update_session_metadata(metadata) - |> create(user, config) - |> plug.do_create(user, config) + nil -> {token, nil} + user -> {token, {user, metadata}} end end # TODO: Remove by 1.1.0 - defp fetch_and_auth_user(conn, user_id, plug, config), - do: fetch_and_auth_user(conn, {user_id, []}, plug, config) + defp fetch_user({token, user_id}, config), + do: fetch_user({token, {user_id, []}}, config) defp filter_invalid!([id: _value] = clauses), do: clauses defp filter_invalid!(clauses), do: raise "Invalid get_by clauses stored: #{inspect clauses}" + defp lock_auth_user(conn, token, user, metadata, config) do + id = {[__MODULE__, token], self()} + nodes = Node.list() ++ [node()] + + case :global.set_lock(id, nodes, 0) do + true -> + conn + |> auth_user(user, metadata, config) + |> register_before_send(fn conn -> + :global.del_lock(id, nodes) + + conn + end) + + false -> + Plug.assign_current_user(conn, user, config) + end + end + + defp auth_user(conn, user, metadata, config) do + conn + |> update_persistent_session_metadata(metadata) + |> update_session_metadata(metadata) + |> create(user, config) + |> Plug.create(user, config) + end + defp update_persistent_session_metadata(conn, metadata) do case Keyword.get(metadata, :session_metadata) do nil -> @@ -270,28 +312,37 @@ defmodule PowPersistentSession.Plug.Cookie do end end - defp maybe_renew(conn, config) do - cookie_key = cookie_key(config) + defp gen_token(config) do + uuid = UUID.generate() - case conn.resp_cookies[cookie_key] do - nil -> renew(conn, cookie_key, config) - _any -> conn - end + Plug.prepend_with_namespace(config, uuid) end - defp renew(conn, cookie_key, config) do - opts = session_opts(config) + defp client_store_fetch(conn, config) do + conn = Conn.fetch_cookies(conn) - case conn.req_cookies[cookie_key] do - nil -> conn - value -> Conn.put_resp_cookie(conn, cookie_key, value, opts) + with token when is_binary(token) <- conn.req_cookies[cookie_key(config)], + {:ok, token} <- Plug.verify_token(conn, signing_salt(), token, config) do + {token, conn} + else + _any -> {nil, conn} end end - defp cookie_id(config) do - uuid = UUID.generate() + defp signing_salt(), do: Atom.to_string(__MODULE__) - Plug.prepend_with_namespace(config, uuid) + defp client_store_put(conn, token, config) do + signed_token = Plug.sign_token(conn, signing_salt(), token, config) + + conn + |> Conn.fetch_cookies() + |> Conn.put_resp_cookie(cookie_key(config), signed_token, cookie_opts(config)) + end + + defp client_store_delete(conn, config) do + conn + |> Conn.fetch_cookies() + |> Conn.delete_resp_cookie(cookie_key(config), cookie_opts(config)) end defp cookie_key(config) do @@ -302,8 +353,11 @@ defmodule PowPersistentSession.Plug.Cookie do Plug.prepend_with_namespace(config, @cookie_key) end - defp session_opts(config) do - [max_age: max_age(config), path: "/"] + defp cookie_opts(config) do + config + |> Config.get(:persistent_session_cookie_opts, []) + |> Keyword.put_new(:max_age, max_age(config)) + |> Keyword.put_new(:path, "/") end defp max_age(config) do diff --git a/lib/extensions/reset_password/README.md b/lib/extensions/reset_password/README.md index fb9a0f56..58de9d26 100644 --- a/lib/extensions/reset_password/README.md +++ b/lib/extensions/reset_password/README.md @@ -2,7 +2,7 @@ This extension will allow users to reset the password by sending an e-mail with a reset password link. It requires that the user schema has an `:email` field. -A success message will always be returned during reset request if registration routes has been disabled to prevent information leak. +To prevent user enumeration attacks, the generic `PowResetPassword.Phoenix.Messages.maybe_email_has_been_sent/1` message is always shown when requesting password reset. If `pow_prevent_user_enumeration: false` is set in `conn.private` the form will be shown instead with the `PowResetPassword.Phoenix.Messages.user_not_found/1` message. ## Installation diff --git a/lib/extensions/reset_password/ecto/context.ex b/lib/extensions/reset_password/ecto/context.ex index 4e6e6eaa..6178cbfc 100644 --- a/lib/extensions/reset_password/ecto/context.ex +++ b/lib/extensions/reset_password/ecto/context.ex @@ -1,10 +1,10 @@ defmodule PowResetPassword.Ecto.Context do @moduledoc false - alias Pow.{Config, Ecto.Context} + alias Pow.{Config, Ecto.Context, Operations} alias PowResetPassword.Ecto.Schema @spec get_by_email(binary(), Config.t()) :: Context.user() | nil - def get_by_email(email, config), do: Context.get_by([email: email], config) + def get_by_email(email, config), do: Operations.get_by([email: email], config) @spec update_password(Context.user(), map(), Config.t()) :: {:ok, Context.user()} | {:error, Context.changeset()} def update_password(user, params, config) do diff --git a/lib/extensions/reset_password/phoenix/controllers/reset_password_controller.ex b/lib/extensions/reset_password/phoenix/controllers/reset_password_controller.ex index 2419d068..24bf4bcb 100644 --- a/lib/extensions/reset_password/phoenix/controllers/reset_password_controller.ex +++ b/lib/extensions/reset_password/phoenix/controllers/reset_password_controller.ex @@ -3,6 +3,7 @@ defmodule PowResetPassword.Phoenix.ResetPasswordController do use Pow.Extension.Phoenix.Controller.Base alias Plug.Conn + alias Pow.Plug, as: PowPlug alias PowResetPassword.{Phoenix.Mailer, Plug} plug :require_not_authenticated @@ -32,27 +33,25 @@ defmodule PowResetPassword.Phoenix.ResetPasswordController do url = routes(conn).url_for(conn, __MODULE__, :edit, [token]) deliver_email(conn, user, url) - default_respond_create(conn) + conn + |> put_flash(:info, extension_messages(conn).email_has_been_sent(conn)) + |> redirect(to: routes(conn).session_path(conn, :new)) end - def respond_create({:error, _any, conn}) do - case registration_path?(conn) do + def respond_create({:error, changeset, conn}) do + case PowPlug.__prevent_user_enumeration__(conn, nil) do true -> conn - |> assign(:changeset, Plug.change_user(conn, conn.params["user"])) - |> put_flash(:error, extension_messages(conn).user_not_found(conn)) - |> render("new.html") + |> put_flash(:info, extension_messages(conn).maybe_email_has_been_sent(conn)) + |> redirect(to: routes(conn).session_path(conn, :new)) false -> - default_respond_create(conn) + conn + |> assign(:changeset, changeset) + |> put_flash(:error, extension_messages(conn).user_not_found(conn)) + |> render("new.html") end end - defp default_respond_create(conn) do - conn - |> put_flash(:info, extension_messages(conn).email_has_been_sent(conn)) - |> redirect(to: routes(conn).session_path(conn, :new)) - end - @spec process_edit(Conn.t(), map()) :: {:ok, map(), Conn.t()} def process_edit(conn, _params) do {:ok, Plug.change_user(conn), conn} @@ -83,15 +82,15 @@ defmodule PowResetPassword.Phoenix.ResetPasswordController do end defp load_user_from_reset_token(%{params: %{"id" => token}} = conn, _opts) do - case Plug.user_from_token(conn, token) do - nil -> + case Plug.load_user_by_token(conn, token) do + {:error, conn} -> conn |> put_flash(:error, extension_messages(conn).invalid_token(conn)) |> redirect(to: routes(conn).path_for(conn, __MODULE__, :new)) |> halt() - user -> - Plug.assign_reset_password_user(conn, user) + {:ok, conn} -> + conn end end @@ -101,12 +100,6 @@ defmodule PowResetPassword.Phoenix.ResetPasswordController do Pow.Phoenix.Mailer.deliver(conn, email) end - defp registration_path?(conn) do - [conn.private.phoenix_router, Helpers] - |> Module.concat() - |> function_exported?(:pow_registration_path, 3) - end - defp assign_create_path(conn, _opts) do path = routes(conn).path_for(conn, __MODULE__, :create) Conn.assign(conn, :action, path) diff --git a/lib/extensions/reset_password/phoenix/messages.ex b/lib/extensions/reset_password/phoenix/messages.ex index 86dbb188..542c0f84 100644 --- a/lib/extensions/reset_password/phoenix/messages.ex +++ b/lib/extensions/reset_password/phoenix/messages.ex @@ -1,10 +1,20 @@ defmodule PowResetPassword.Phoenix.Messages do - @moduledoc false + @moduledoc """ + Module that handles messages for PowResetPassword. + + See `Pow.Extension.Phoenix.Messages` for more. + """ + + @doc """ + Flash message to show generic response for reset password request. + """ + def maybe_email_has_been_sent(_conn), do: "If an account for the provided email exists, an email with reset instructions will be sent to you. Please check your inbox." @doc """ - Flash message to show when a reset password e-mail has been sent. + Flash message to show when a reset password e-mail has been sent. Falls back + to `maybe_email_has_been_sent/1` """ - def email_has_been_sent(_conn), do: "An email with reset instructions has been sent to you. Please check your inbox." + def email_has_been_sent(conn), do: maybe_email_has_been_sent(conn) @doc """ Flash message to show when no user exists for the provided e-mail. diff --git a/lib/extensions/reset_password/phoenix/templates/reset_password_template.ex b/lib/extensions/reset_password/phoenix/templates/reset_password_template.ex index 35af749d..a21bc6b7 100644 --- a/lib/extensions/reset_password/phoenix/templates/reset_password_template.ex +++ b/lib/extensions/reset_password/phoenix/templates/reset_password_template.ex @@ -17,7 +17,7 @@ defmodule PowResetPassword.Phoenix.ResetPasswordTemplate do <%= Pow.Phoenix.HTML.FormTemplate.render([ {:password, :password}, - {:password, :confirm_password} + {:password, :password_confirmation} ]) %> <%%= link "Sign in", to: Routes.<%= Pow.Phoenix.Controller.route_helper(Pow.Phoenix.SessionController) %>_path(@conn, :new) %> diff --git a/lib/extensions/reset_password/plug.ex b/lib/extensions/reset_password/plug.ex index 99fb8e33..65efbb8b 100644 --- a/lib/extensions/reset_password/plug.ex +++ b/lib/extensions/reset_password/plug.ex @@ -24,10 +24,9 @@ defmodule PowResetPassword.Plug do |> struct() end - @doc """ - Assigns a `:reset_password_user` key with the user in the connection. - """ - @spec assign_reset_password_user(Conn.t(), map()) :: Conn.t() + # TODO: Remove by 1.1.0 + @doc false + @deprecated "No longer used" def assign_reset_password_user(conn, user) do Conn.assign(conn, :reset_password_user, user) end @@ -36,8 +35,9 @@ defmodule PowResetPassword.Plug do Finds a user for the provided params, creates a token, and stores the user for the token. - To prevent timing attacks, `Pow.UUID.generate/0` is called whether the user - exists or not. + The returned `:token` is signed for public consumption using + `Pow.Plug.sign_token/4`. Additionally `Pow.UUID.generate/0` is called whether + the user exists or not to prevent timing attacks. `:reset_password_token_store` can be passed in the config for the conn. This value defaults to @@ -49,33 +49,64 @@ defmodule PowResetPassword.Plug do def create_reset_token(conn, params) do config = Plug.fetch_config(conn) token = UUID.generate() - user = - params - |> Map.get("email") - |> ResetPasswordContext.get_by_email(config) - maybe_store_reset_token(conn, user, token, config) - end + params + |> Map.get("email") + |> ResetPasswordContext.get_by_email(config) + |> case do + nil -> + {:error, change_user(conn, params), conn} - defp maybe_store_reset_token(conn, nil, _token, _config) do - changeset = change_user(conn) - {:error, %{changeset | action: :update}, conn} - end - defp maybe_store_reset_token(conn, user, token, config) do - {store, store_config} = store(config) + user -> + {store, store_config} = store(config) - store.put(store_config, token, user) + store.put(store_config, token, user) - {:ok, %{token: token, user: user}, conn} + signed = Plug.sign_token(conn, signing_salt(), token, config) + + {:ok, %{token: signed, user: user}, conn} + end end + defp signing_salt(), do: Atom.to_string(__MODULE__) + @doc """ - Fetches user from the store by the provided token. + Verifies the signed token and fetches invited user from store. + + If a user is found, it'll be assigned to `conn.assign` for key + `:reset_password_user`. + + The token will be decoded and verified with `Pow.Plug.verify_token/4`. See `create_reset_token/2` for more on `:reset_password_token_store` config option. """ - @spec user_from_token(Conn.t(), binary()) :: map() | nil + @spec load_user_by_token(Conn.t(), binary()) :: {:ok, Conn.t()} | {:error, Conn.t()} + def load_user_by_token(conn, signed_token) do + config = Plug.fetch_config(conn) + + with {:ok, token} <- Plug.verify_token(conn, signing_salt(), signed_token, config), + user when not is_nil(user) <- fetch_user_from_token(token, config) do + {:ok, Conn.assign(conn, :reset_password_user, user)} + else + _any -> {:error, conn} + end + end + + defp fetch_user_from_token(token, config) do + {store, store_config} = store(config) + + store_config + |> store.get(token) + |> case do + :not_found -> nil + user -> user + end + end + + # TODO: Remove by 1.1.0 + @doc false + @deprecated "Use `load_user_by_token/2` instead" def user_from_token(conn, token) do {store, store_config} = conn @@ -95,6 +126,8 @@ defmodule PowResetPassword.Plug do See `create_reset_token/2` for more on `:reset_password_token_store` config option. + + The token will be decoded and verified with `Pow.Plug.verify_token/4`. """ @spec update_user_password(Conn.t(), map()) :: {:ok, map(), Conn.t()} | {:error, map(), Conn.t()} def update_user_password(conn, params) do @@ -108,7 +141,10 @@ defmodule PowResetPassword.Plug do end defp maybe_expire_token({:ok, user}, conn, token, config) do - expire_token(token, config) + case Plug.verify_token(conn, signing_salt(), token, config) do + :error -> :ok + {:ok, token} -> expire_token(token, config) + end {:ok, user, conn} end @@ -126,15 +162,13 @@ defmodule PowResetPassword.Plug do end defp store(config) do - case Config.get(config, :reset_password_token_store, default_store(config)) do - {store, store_config} -> {store, store_config} - store -> {store, []} + case Config.get(config, :reset_password_token_store) do + {store, store_config} -> {store, store_opts(config, store_config)} + nil -> {ResetTokenCache, store_opts(config)} end end - defp default_store(config) do - backend = Config.get(config, :cache_store_backend, EtsCache) - - {ResetTokenCache, [backend: backend]} + defp store_opts(config, store_config \\ []) do + Keyword.put_new(store_config, :backend, Config.get(config, :cache_store_backend, EtsCache)) end end diff --git a/lib/mix/tasks/phoenix/pow.phoenix.install.ex b/lib/mix/tasks/phoenix/pow.phoenix.install.ex index 303733d5..15741f20 100644 --- a/lib/mix/tasks/phoenix/pow.phoenix.install.ex +++ b/lib/mix/tasks/phoenix/pow.phoenix.install.ex @@ -91,13 +91,8 @@ defmodule Mix.Tasks.Pow.Phoenix.Install do # ... - plug Plug.Session, - store: :cookie, - key: "_#{web_app}_key", - signing_salt: "secret" - + plug Plug.Session, @session_options plug Pow.Plug.Session, otp_app: #{inspect(web_app)} - plug #{inspect(web_base)}.Router end diff --git a/lib/pow/ecto/context.ex b/lib/pow/ecto/context.ex index 4b7b2d78..19faa647 100644 --- a/lib/pow/ecto/context.ex +++ b/lib/pow/ecto/context.ex @@ -35,8 +35,7 @@ defmodule Pow.Ecto.Context do * `:user` - the user schema module (required) * `:repo_opts` - keyword list options for the repo, `:prefix` can be set here """ - alias Pow.Config - alias Pow.Ecto.Schema + alias Pow.{Config, Ecto.Schema, Operations} @type user :: map() @type changeset :: map() @@ -110,7 +109,7 @@ defmodule Pow.Ecto.Context do defp do_authenticate(user_id_field, user_id_value, password, config) do [{user_id_field, user_id_value}] - |> get_by(config) + |> Operations.get_by(config) |> verify_password(password, config) end @@ -236,12 +235,20 @@ defmodule Pow.Ecto.Context do defp reload_after_write({:ok, struct}, config) do # When ecto updates/inserts, has_many :through associations are set to nil. # So we'll just reload when writes happen. - opts = repo_opts(config, [:prefix]) - struct = Config.repo!(config).get!(struct.__struct__, struct.id, opts) + opts = repo_opts(config, [:prefix]) + clauses = fetch_primary_key_values!(struct, config) + struct = Config.repo!(config).get_by!(struct.__struct__, clauses, opts) {:ok, struct} end + defp fetch_primary_key_values!(struct, config) do + case Operations.fetch_primary_key_values(struct, config) do + {:error, error} -> raise error + {:ok, clauses} -> clauses + end + end + # TODO: Remove by 1.1.0 @deprecated "Use `Pow.Config.repo!/1` instead" defdelegate repo(config), to: Config, as: :repo! diff --git a/lib/pow/ecto/schema.ex b/lib/pow/ecto/schema.ex index ce4a8797..0edc51d7 100644 --- a/lib/pow/ecto/schema.ex +++ b/lib/pow/ecto/schema.ex @@ -94,6 +94,25 @@ defmodule Pow.Ecto.Schema do end end + An `@after_compile` callback will raise an error if there are missing fields + or associations, so you can forego the `pow_user_fields/0` call, and write + out the whole schema instead: + + defmodule MyApp.Users.User do + use Ecto.Schema + use Pow.Ecto.Schema, user_id_field: :email + + schema "users" do + field :email, :string, null: false + field :password_hash, :string + field :current_password, :string, virtual: true + field :password, :string, virtual: true + field :confirm_password, :string, virtual: true + + timestamps() + end + end + ## Customize Pow changeset You can extract individual changeset methods to modify the changeset flow @@ -124,6 +143,11 @@ defmodule Pow.Ecto.Schema do alias Ecto.Changeset alias Pow.Config + defmodule SchemaError do + @moduledoc false + defexception [:message] + end + @callback changeset(Ecto.Schema.t() | Changeset.t(), map()) :: Changeset.t() @callback verify_password(Ecto.Schema.t(), binary()) :: boolean() @@ -145,6 +169,7 @@ defmodule Pow.Ecto.Schema do unquote(__MODULE__).__register_fields__() unquote(__MODULE__).__register_assocs__() unquote(__MODULE__).__register_user_id_field__() + unquote(__MODULE__).__register_after_compile_validation__() end end @@ -195,7 +220,6 @@ defmodule Pow.Ecto.Schema do * `:password_hash` * `:current_password` (virtual) * `:password` (virtual) - * `:confirm_password` (virtual) """ defmacro pow_user_fields do quote do @@ -295,6 +319,87 @@ defmodule Pow.Ecto.Schema do def user_id_field(config) when is_list(config), do: Config.get(config, :user_id_field, @default_user_id_field) def user_id_field(_any), do: @default_user_id_field + @doc false + defmacro __register_after_compile_validation__ do + quote do + def pow_validate_after_compilation!(env, _bytecode) do + unquote(__MODULE__).__require_assocs__(__MODULE__) + unquote(__MODULE__).__require_fields__(__MODULE__) + end + + @after_compile {__MODULE__, :pow_validate_after_compilation!} + end + end + + @doc false + def __require_assocs__(module) do + ecto_assocs = Module.get_attribute(module, :ecto_assocs) + + module + |> Module.get_attribute(:pow_assocs) + |> Enum.reverse() + |> Enum.filter(fn assoc -> + not Enum.any?(ecto_assocs, &assocs_match?(elem(assoc, 0), elem(assoc, 1), &1)) + end) + |> Enum.map(fn + {type, name, queryable} -> "#{type} #{inspect name}, #{inspect queryable}" + {type, name, queryable, defaults} -> "#{type} #{inspect name}, #{inspect queryable}, #{inspect defaults}" + end) + |> case do + [] -> :ok + assoc_defs -> raise_missing_assocs_error(module, assoc_defs) + end + end + + defp raise_missing_assocs_error(module, assoc_defs) do + raise SchemaError, message: + """ + Please define the following association(s) in the schema for #{inspect module}: + + #{Enum.join(assoc_defs, "\n")} + """ + end + + @doc false + def __require_fields__(module) do + ecto_fields = Module.get_attribute(module, :ecto_fields) + changeset_fields = Module.get_attribute(module, :changeset_fields) + + module + |> Module.get_attribute(:pow_fields) + |> Enum.reverse() + |> Enum.filter(&missing_field?(&1, ecto_fields, changeset_fields)) + |> Enum.map(fn + {name, type} -> "field #{inspect name}, #{inspect type}" + {name, type, defaults} -> "field #{inspect name}, #{inspect type}, #{inspect defaults}" + end) + |> case do + [] -> :ok + field_defs -> raise_missing_fields_error(module, field_defs) + end + end + + defp missing_field?({name, type, defaults}, ecto_fields, changeset_fields) do + case defaults[:virtual] do + true -> missing_field?(name, type, changeset_fields) + _any -> missing_field?(name, type, ecto_fields) + end + end + defp missing_field?({name, type}, ecto_fields, _changeset_fields), + do: missing_field?(name, type, ecto_fields) + defp missing_field?(name, type, existing_fields) do + not Enum.member?(existing_fields, {name, type}) + end + + defp raise_missing_fields_error(module, field_defs) do + raise SchemaError, message: + """ + Please define the following field(s) in the schema for #{inspect module}: + + #{Enum.join(field_defs, "\n")} + """ + end + @doc """ Normalizes the user id field. diff --git a/lib/pow/ecto/schema/changeset.ex b/lib/pow/ecto/schema/changeset.ex index 6a3b5938..ba051cf0 100644 --- a/lib/pow/ecto/schema/changeset.ex +++ b/lib/pow/ecto/schema/changeset.ex @@ -45,12 +45,15 @@ defmodule Pow.Ecto.Schema.Changeset do user_or_changeset |> Changeset.cast(params, [user_id_field]) - |> Changeset.update_change(user_id_field, &Schema.normalize_user_id_field_value/1) + |> Changeset.update_change(user_id_field, &maybe_normalize_user_id_field_value/1) |> maybe_validate_email_format(user_id_field, config) |> Changeset.validate_required([user_id_field]) |> Changeset.unique_constraint(user_id_field) end + defp maybe_normalize_user_id_field_value(value) when is_binary(value), do: Schema.normalize_user_id_field_value(value) + defp maybe_normalize_user_id_field_value(any), do: any + @doc """ Validates the password field. @@ -67,7 +70,7 @@ defmodule Pow.Ecto.Schema.Changeset do Validates the password field. A password hash is generated by using `:password_hash_methods` in the - configuration. The password is always required if the password hash is nil, + configuration. The password is always required if the password hash is `nil`, and it's required to be between `:password_min_length` to `:password_max_length` characters long. @@ -81,19 +84,59 @@ defmodule Pow.Ecto.Schema.Changeset do |> maybe_require_password() |> maybe_validate_password(config) |> maybe_put_password_hash(config) - |> Changeset.validate_required([:password_hash]) + |> maybe_validate_password_hash() end + # TODO: Remove `confirm_password` support by 1.1.0 @doc """ Validates the confirm password field. - Requires `password` and `confirm_password` params to be equal. + Requires `password` and `confirm_password` params to be equal. Validation is + only performed if a change for `:password` exists and the change is not + `nil`. """ @spec confirm_password_changeset(Ecto.Schema.t() | Changeset.t(), map(), Config.t()) :: Changeset.t() - def confirm_password_changeset(user_or_changeset, params, _config) do - user_or_changeset - |> Changeset.cast(params, [:password, :confirm_password]) - |> maybe_validate_confirm_password() + def confirm_password_changeset(user_or_changeset, %{confirm_password: password_confirmation} = params, _config) do + params = + params + |> Map.delete(:confirm_password) + |> Map.put(:password_confirmation, password_confirmation) + + do_confirm_password_changeset(user_or_changeset, params) + end + def confirm_password_changeset(user_or_changeset, %{"confirm_password" => password_confirmation} = params, _config) do + params = + params + |> Map.delete("confirm_password") + |> Map.put("password_confirmation", password_confirmation) + + convert_confirm_password_param(user_or_changeset, params) + end + def confirm_password_changeset(user_or_changeset, params, _config), + do: do_confirm_password_changeset(user_or_changeset, params) + + # TODO: Remove by 1.1.0 + defp convert_confirm_password_param(user_or_changeset, params) do + IO.warn("warning: passing `confirm_password` value to `#{inspect unquote(__MODULE__)}.confirm_password_changeset/3` has been deprecated, please use `password_confirmation` instead") + + changeset = do_confirm_password_changeset(user_or_changeset, params) + errors = Enum.map(changeset.errors, fn + {:password_confirmation, error} -> {:confirm_password, error} + error -> error + end) + + %{changeset | errors: errors} + end + + defp do_confirm_password_changeset(user_or_changeset, params) do + changeset = Changeset.cast(user_or_changeset, params, [:password]) + + changeset + |> Changeset.get_change(:password) + |> case do + nil -> changeset + _password -> Changeset.validate_confirmation(changeset, :password, required: true) + end end @doc """ @@ -120,11 +163,11 @@ defmodule Pow.Ecto.Schema.Changeset do defp maybe_validate_email_format(changeset, :email, config) do validate_method = email_validator(config) - Changeset.validate_change(changeset, :email, fn :email, email -> + Changeset.validate_change(changeset, :email, {:email_format, validate_method}, fn :email, email -> case validate_method.(email) do :ok -> [] - :error -> [email: {"has invalid format", validator: validate_method}] - {:error, reason} -> [email: {"has invalid format", validator: validate_method, reason: reason}] + :error -> [email: {"has invalid format", validation: :email_format}] + {:error, reason} -> [email: {"has invalid format", validation: :email_format, reason: reason}] end end) end @@ -145,8 +188,13 @@ defmodule Pow.Ecto.Schema.Changeset do user |> verify_password(password, config) |> case do - true -> changeset - _ -> Changeset.add_error(changeset, :current_password, "is invalid") + true -> + changeset + + _ -> + changeset = %{changeset | validations: [{:current_password, {:verify_password, []}} | changeset.validations]} + + Changeset.add_error(changeset, :current_password, "is invalid", validation: :verify_password) end end @@ -158,7 +206,7 @@ defmodule Pow.Ecto.Schema.Changeset do To prevent timing attacks, a blank password will be passed to the hash method in the `:password_hash_methods` configuration option if the `:password_hash` - is nil. + is `nil`. """ @spec verify_password(Ecto.Schema.t(), binary(), Config.t()) :: boolean() def verify_password(%{password_hash: nil}, _password, config) do @@ -195,29 +243,16 @@ defmodule Pow.Ecto.Schema.Changeset do Changeset.validate_length(changeset, :password, min: password_min_length, max: password_max_length) end - defp maybe_validate_confirm_password(changeset) do - changeset - |> Changeset.get_change(:password) - |> case do - nil -> changeset - password -> validate_confirm_password(changeset, password) - end - end - - defp validate_confirm_password(changeset, password) do - confirm_password = Changeset.get_change(changeset, :confirm_password) - - case password do - ^confirm_password -> changeset - _ -> Changeset.add_error(changeset, :confirm_password, "not same as password") - end - end - defp maybe_put_password_hash(%Changeset{valid?: true, changes: %{password: password}} = changeset, config) do Changeset.put_change(changeset, :password_hash, hash_password(password, config)) end defp maybe_put_password_hash(changeset, _config), do: changeset + defp maybe_validate_password_hash(%Changeset{valid?: true} = changeset) do + Changeset.validate_required(changeset, [:password_hash]) + end + defp maybe_validate_password_hash(changeset), do: changeset + defp hash_password(password, config) do config |> password_hash_method() diff --git a/lib/pow/ecto/schema/fields.ex b/lib/pow/ecto/schema/fields.ex index 7596fd87..6bfabeaa 100644 --- a/lib/pow/ecto/schema/fields.ex +++ b/lib/pow/ecto/schema/fields.ex @@ -7,8 +7,7 @@ defmodule Pow.Ecto.Schema.Fields do @attrs [ {:password_hash, :string}, {:current_password, :string, virtual: true}, - {:password, :string, virtual: true}, - {:confirm_password, :string, virtual: true} + {:password, :string, virtual: true} ] @doc """ diff --git a/lib/pow/extension/ecto/schema.ex b/lib/pow/extension/ecto/schema.ex index 528ba880..19bb9b4a 100644 --- a/lib/pow/extension/ecto/schema.ex +++ b/lib/pow/extension/ecto/schema.ex @@ -99,11 +99,11 @@ defmodule Pow.Extension.Ecto.Schema do @doc false defmacro __register_after_compile_validation__ do quote do - def validate_after_compilation!(env, _bytecode) do + def pow_extension_validate_after_compilation!(env, _bytecode) do unquote(__MODULE__).validate!(@pow_extension_config, __MODULE__) end - @after_compile {__MODULE__, :validate_after_compilation!} + @after_compile {__MODULE__, :pow_extension_validate_after_compilation!} end end diff --git a/lib/pow/extension/phoenix/messages.ex b/lib/pow/extension/phoenix/messages.ex index 425ee9de..f279beef 100644 --- a/lib/pow/extension/phoenix/messages.ex +++ b/lib/pow/extension/phoenix/messages.ex @@ -2,6 +2,10 @@ defmodule Pow.Extension.Phoenix.Messages do @moduledoc """ Module that handles messages for extensions. + To override messages from extensions, the method name has to start with the + snake cased extension name. So the `a_message/1` method from + `PowExtensionOne`, should be written as `pow_extension_one_a_message/1`. + ## Usage defmodule MyAppWeb.Pow.Messages do @@ -12,6 +16,8 @@ defmodule Pow.Extension.Phoenix.Messages do import MyAppWeb.Gettext def pow_extension_one_a_message(_conn), do: gettext("A message.") + + def pow_extension_two_a_message(_conn), do: gettext("A message.") end Remember to update configuration with `messages_backend: MyAppWeb.Pow.Messages`. diff --git a/lib/pow/extension/phoenix/router.ex b/lib/pow/extension/phoenix/router.ex index ab4042b6..6cbca064 100644 --- a/lib/pow/extension/phoenix/router.ex +++ b/lib/pow/extension/phoenix/router.ex @@ -45,7 +45,7 @@ defmodule Pow.Extension.Phoenix.Router do scope "/", PowExtensionOne.Phoenix, as: "pow_extension_one" do pipe_through [:browser] - resources "/pow_extension_on", SomeController, only: [:new, :create] + resources "/pow_extension_one", SomeController, only: [:new, :create] end scope "/" do diff --git a/lib/pow/operations.ex b/lib/pow/operations.ex index c740fe3d..6322ca08 100644 --- a/lib/pow/operations.ex +++ b/lib/pow/operations.ex @@ -104,4 +104,31 @@ defmodule Pow.Operations do defp context_module(config) do Config.get(config, :users_context, Context) end + + @doc """ + Retrieve a keyword list of primary key value(s) from the provided struct. + + The keys will be fetched from the `__schema__/1` method in the struct module. + If no `__schema__/1` method exists, then it's expected that the struct has + `:id` as its only primary key. + """ + @spec fetch_primary_key_values(struct(), Config.t()) :: {:ok, keyword()} | {:error, term()} + def fetch_primary_key_values(%mod{} = struct, _config) do + mod + |> function_exported?(:__schema__, 1) + |> case do + true -> mod.__schema__(:primary_key) + false -> [:id] + end + |> map_primary_key_values(struct, []) + end + + defp map_primary_key_values([], %mod{}, []), do: {:error, "No primary keys found for #{inspect mod}"} + defp map_primary_key_values([key | rest], %mod{} = struct, acc) do + case Map.get(struct, key) do + nil -> {:error, "Primary key value for key `#{inspect key}` in #{inspect mod} can't be `nil`"} + value -> map_primary_key_values(rest, struct, acc ++ [{key, value}]) + end + end + defp map_primary_key_values([], _struct, acc), do: {:ok, acc} end diff --git a/lib/pow/phoenix/controllers/session_controller.ex b/lib/pow/phoenix/controllers/session_controller.ex index 05c2794f..4c2c2edb 100644 --- a/lib/pow/phoenix/controllers/session_controller.ex +++ b/lib/pow/phoenix/controllers/session_controller.ex @@ -52,7 +52,7 @@ defmodule Pow.Phoenix.SessionController do @doc false @spec process_delete(Conn.t(), map()) :: {:ok, Conn.t()} - def process_delete(conn, _params), do: Plug.clear_authenticated_user(conn) + def process_delete(conn, _params), do: {:ok, Plug.delete(conn)} @doc false @spec respond_delete({:ok, Conn.t()}) :: Conn.t() diff --git a/lib/pow/phoenix/routes.ex b/lib/pow/phoenix/routes.ex index 979fcb55..c2932b17 100644 --- a/lib/pow/phoenix/routes.ex +++ b/lib/pow/phoenix/routes.ex @@ -49,31 +49,31 @@ defmodule Pow.Phoenix.Routes do @behaviour unquote(__MODULE__) def user_not_authenticated_path(conn), - do: unquote(__MODULE__).user_not_authenticated_path(conn) + do: unquote(__MODULE__).user_not_authenticated_path(conn, __MODULE__) def user_already_authenticated_path(conn), - do: unquote(__MODULE__).user_already_authenticated_path(conn) + do: unquote(__MODULE__).user_already_authenticated_path(conn, __MODULE__) def after_sign_out_path(conn), - do: unquote(__MODULE__).after_sign_out_path(conn) + do: unquote(__MODULE__).after_sign_out_path(conn, __MODULE__) def after_sign_in_path(conn), - do: unquote(__MODULE__).after_sign_in_path(conn) + do: unquote(__MODULE__).after_sign_in_path(conn, __MODULE__) def after_registration_path(conn), - do: unquote(__MODULE__).after_registration_path(conn) + do: unquote(__MODULE__).after_registration_path(conn, __MODULE__) def after_user_updated_path(conn), - do: unquote(__MODULE__).after_user_updated_path(conn) + do: unquote(__MODULE__).after_user_updated_path(conn, __MODULE__) def after_user_deleted_path(conn), - do: unquote(__MODULE__).after_user_deleted_path(conn) + do: unquote(__MODULE__).after_user_deleted_path(conn, __MODULE__) def session_path(conn, verb, query_params \\ []), - do: unquote(__MODULE__).session_path(conn, verb, query_params) + do: unquote(__MODULE__).session_path(conn, verb, query_params, __MODULE__) def registration_path(conn, verb), - do: unquote(__MODULE__).registration_path(conn, verb) + do: unquote(__MODULE__).registration_path(conn, verb, __MODULE__) def path_for(conn, plug, verb, vars \\ [], query_params \\ []), do: unquote(__MODULE__).path_for(conn, plug, verb, vars, query_params) @@ -96,10 +96,10 @@ defmodule Pow.Phoenix.Routes do See `Pow.Phoenix.SessionController` for more on how this value is handled. """ - def user_not_authenticated_path(conn) do + def user_not_authenticated_path(conn, routes_module \\ __MODULE__) do case conn.method do - "GET" -> session_path(conn, :new, request_path: Phoenix.Controller.current_path(conn)) - _method -> session_path(conn, :new) + "GET" -> routes_module.session_path(conn, :new, request_path: Phoenix.Controller.current_path(conn)) + _method -> routes_module.session_path(conn, :new) end end @@ -108,12 +108,12 @@ defmodule Pow.Phoenix.Routes do By default this is the same as `after_sign_in_path/1`. """ - def user_already_authenticated_path(conn), do: routes(conn).after_sign_in_path(conn) + def user_already_authenticated_path(conn, routes_module \\ __MODULE__), do: routes_module.after_sign_in_path(conn) @doc """ Path to redirect user to when user has signed out. """ - def after_sign_out_path(conn), do: session_path(conn, :new) + def after_sign_out_path(conn, routes_module \\ __MODULE__), do: routes_module.session_path(conn, :new) @doc """ Path to redirect user to when user has signed in. @@ -121,35 +121,36 @@ defmodule Pow.Phoenix.Routes do This will look for a `:request_path` assigns key, and redirect to this value if it exists. """ - def after_sign_in_path(%{assigns: %{request_path: request_path}}) when is_binary(request_path), + def after_sign_in_path(params, routes_module \\ __MODULE__) + def after_sign_in_path(%{assigns: %{request_path: request_path}}, _routes_module) when is_binary(request_path), do: request_path - def after_sign_in_path(_params), do: "/" + def after_sign_in_path(_params, _routes_module), do: "/" @doc """ Path to redirect user to when user has signed up. By default this is the same as `after_sign_in_path/1`. """ - def after_registration_path(conn), do: routes(conn).after_sign_in_path(conn) + def after_registration_path(conn, routes_module \\ __MODULE__), do: routes_module.after_sign_in_path(conn) @doc """ Path to redirect user to when user has updated their account. """ - def after_user_updated_path(conn), do: registration_path(conn, :edit) + def after_user_updated_path(conn, routes_module \\ __MODULE__), do: routes_module.registration_path(conn, :edit) @doc """ Path to redirect user to when user has deleted their account. By default this is the same as `after_sign_out_path/1`. """ - def after_user_deleted_path(conn), do: routes(conn).after_sign_out_path(conn) + def after_user_deleted_path(conn, routes_module \\ __MODULE__), do: routes_module.after_sign_out_path(conn) @doc false - def session_path(conn, verb, query_params \\ []), do: path_for(conn, SessionController, verb, [], query_params) + def session_path(conn, verb, query_params \\ [], routes_module \\ __MODULE__), do: routes_module.path_for(conn, SessionController, verb, [], query_params) @doc false - def registration_path(conn, verb), do: path_for(conn, RegistrationController, verb) + def registration_path(conn, verb, routes_module \\ __MODULE__), do: routes_module.path_for(conn, RegistrationController, verb) @doc """ Generates a path route. @@ -175,6 +176,4 @@ defmodule Pow.Phoenix.Routes do apply(router, helper, args) end - - defp routes(conn), do: Controller.routes(conn, __MODULE__) end diff --git a/lib/pow/phoenix/templates/registration_template.ex b/lib/pow/phoenix/templates/registration_template.ex index 5a3c8e4b..f279c7ea 100644 --- a/lib/pow/phoenix/templates/registration_template.ex +++ b/lib/pow/phoenix/templates/registration_template.ex @@ -9,7 +9,7 @@ defmodule Pow.Phoenix.RegistrationTemplate do <%= Pow.Phoenix.HTML.FormTemplate.render([ {:text, {:changeset, :pow_user_id_field}}, {:password, :password}, - {:password, :confirm_password} + {:password, :password_confirmation} ], button_label: "Register") %> @@ -24,7 +24,7 @@ defmodule Pow.Phoenix.RegistrationTemplate do {:password, :current_password}, {:text, {:changeset, :pow_user_id_field}}, {:password, :password}, - {:password, :confirm_password} + {:password, :password_confirmation} ], button_label: "Update") %> """ diff --git a/lib/pow/plug.ex b/lib/pow/plug.ex index 9d64958a..ffc432c3 100644 --- a/lib/pow/plug.ex +++ b/lib/pow/plug.ex @@ -3,11 +3,10 @@ defmodule Pow.Plug do Plug helper methods. """ alias Plug.Conn - alias Pow.{Config, Operations} + alias Pow.{Config, Operations, Plug.MessageVerifier} @private_config_key :pow_config - @doc """ Get the current user assigned to the conn. @@ -96,19 +95,15 @@ defmodule Pow.Plug do |> Operations.authenticate(config) |> case do nil -> {:error, conn} - user -> {:ok, get_plug(config).do_create(conn, user, config)} + user -> {:ok, create(conn, user, config)} end end - @doc """ - Clears the user authentication from the session. - """ + # TODO: Remove by 1.1.0 + @doc false + @deprecated "Use `delete/1` instead" @spec clear_authenticated_user(Conn.t()) :: {:ok, Conn.t()} - def clear_authenticated_user(conn) do - config = fetch_config(conn) - - {:ok, get_plug(config).do_delete(conn, config)} - end + def clear_authenticated_user(conn), do: {:ok, delete(conn)} @doc """ Creates a changeset from the current authenticated user. @@ -165,13 +160,13 @@ defmodule Pow.Plug do |> current_user(config) |> Operations.delete(config) |> case do - {:ok, user} -> {:ok, user, get_plug(config).do_delete(conn, config)} + {:ok, user} -> {:ok, user, delete(conn, config)} {:error, changeset} -> {:error, changeset, conn} end end defp maybe_create_auth({:ok, user}, conn, config) do - {:ok, user, get_plug(config).do_create(conn, user, config)} + {:ok, user, create(conn, user, config)} end defp maybe_create_auth({:error, changeset}, conn, _config) do {:error, changeset, conn} @@ -188,6 +183,24 @@ defmodule Pow.Plug do config[:plug] || no_plug_error() end + @doc """ + Call `create/3` for the Pow plug set for the `conn`. + """ + @spec create(Conn.t(), map()) :: Conn.t() + def create(conn, user), do: create(conn, user, fetch_config(conn)) + + @spec create(Conn.t(), map(), Config.t()) :: Conn.t() + def create(conn, user, config), do: get_plug(config).do_create(conn, user, config) + + @doc """ + Call `delete/2` for the Pow plug set for the `conn`. + """ + @spec delete(Conn.t()) :: Conn.t() + def delete(conn), do: delete(conn, fetch_config(conn)) + + @spec delete(Conn.t(), Config.t()) :: Conn.t() + def delete(conn, config), do: get_plug(config).do_delete(conn, config) + @spec no_config_error :: no_return defp no_config_error do Config.raise_error("Pow configuration not found in connection. Please use a Pow plug that puts the Pow configuration in the plug connection.") @@ -197,4 +210,61 @@ defmodule Pow.Plug do defp no_plug_error do Config.raise_error("Pow plug was not found in config. Please use a Pow plug that puts the `:plug` in the Pow configuration.") end + + @doc false + @spec __prevent_user_enumeration__(Conn.t(), any()) :: boolean() + def __prevent_user_enumeration__(%{private: %{pow_prevent_user_enumeration: false}}, _changeset), do: false + def __prevent_user_enumeration__(_conn, %{errors: errors}), do: unique_constraint_error?(errors, :email) + def __prevent_user_enumeration__(_conn, _any), do: true + + defp unique_constraint_error?(errors, field) do + Enum.find_value(errors, false, fn + {^field, {_msg, [constraint: :unique, constraint_name: _name]}} -> true + _any -> false + end) + end + + @doc """ + Signs a token for public consumption. + + Used to prevent timing attacks with token lookup. + + This uses `Pow.Plug.MessageVerifier` by default, but can be changed if the + Pow configuration is set with `:message_verifier`. `Pow.Plug.MessageVerifier` + can also be configured in this way if `:message_verifier` is set to + `{Pow.Plug.MessageVerifier, key_generator_opts: [length: 64]}` + """ + + @spec sign_token(Conn.t(), binary(), binary(), Config.t() | nil) :: binary() + def sign_token(conn, salt, token, config \\ nil) do + config = config || fetch_config(conn) + {module, config} = message_verifier_module(config) + + module.sign(conn, salt, token, config) + end + + @doc """ + Decodes and verifies a token. + + Used to prevent timing attacks with token lookup. + + This uses `Pow.Plug.MessageVerifier` by default, but can be changed if the + Pow configuration is set with `:message_verifier`. `Pow.Plug.MessageVerifier` + can also be configured in this way if `:message_verifier` is set to + `{Pow.Plug.MessageVerifier, key_generator_opts: [length: 64]}` + """ + @spec verify_token(Conn.t(), binary(), binary(), Config.t() | nil) :: {:ok, binary()} | :error + def verify_token(conn, salt, token, config \\ nil) do + config = config || fetch_config(conn) + {module, config} = message_verifier_module(config) + + module.verify(conn, salt, token, config) + end + + defp message_verifier_module(config) do + case Config.get(config, :message_verifier, MessageVerifier) do + {module, config} -> {module, config} + module -> {module, []} + end + end end diff --git a/lib/pow/plug/base.ex b/lib/pow/plug/base.ex index f949099f..a42ab570 100644 --- a/lib/pow/plug/base.ex +++ b/lib/pow/plug/base.ex @@ -4,6 +4,21 @@ defmodule Pow.Plug.Base do assign a user in the connection if it has not already been assigned. The user will be assigned automatically in any of the operations. + Any writes to backend store or client should occur in `:before_send` callback + as defined in `Plug.Conn`. To ensure that the callbacks are called in the + order they were set, a `register_before_send/2` method is used to set + callbacks instead of `Plug.Conn.register_before_send/2`. + + ## Configuration options + + * `:credentials_cache_store` - the credentials cache store. This value defaults to + `{Pow.Store.CredentialsCache, backend: Pow.Store.Backend.EtsCache}`. The + `Pow.Store.Backend.EtsCache` backend store can be changed with the + `:cache_store_backend` option. + + * `:cache_store_backend` - the backend cache store. This value defaults to + `Pow.Store.Backend.EtsCache`. + ## Example defmodule MyAppWeb.Pow.CustomPlug do @@ -30,7 +45,7 @@ defmodule Pow.Plug.Base do end """ alias Plug.Conn - alias Pow.{Config, Plug} + alias Pow.{Config, Plug, Store.Backend.EtsCache, Store.CredentialsCache} @callback init(Config.t()) :: Config.t() @callback call(Conn.t(), Config.t()) :: Conn.t() @@ -43,7 +58,12 @@ defmodule Pow.Plug.Base do quote do @behaviour unquote(__MODULE__) + @before_send_private_key String.to_atom(Macro.underscore(__MODULE__) <> "/before_send") + + import unquote(__MODULE__) + @doc false + @impl true def init(config), do: config @doc """ @@ -57,6 +77,7 @@ defmodule Pow.Plug.Base do If a user can't be fetched with `Pow.Plug.current_user/2`, `do_fetch/2` will be called. """ + @impl true def call(conn, config) do config = put_plug(config) conn = Plug.put_config(conn, config) @@ -64,6 +85,17 @@ defmodule Pow.Plug.Base do conn |> Plug.current_user(config) |> maybe_fetch_user(conn, config) + |> Conn.register_before_send(fn conn -> + conn.private + |> Map.get(@before_send_private_key, []) + |> Enum.reduce(conn, & &1.(&2)) + end) + end + + defp register_before_send(conn, callback) do + callbacks = Map.get(conn.private, @before_send_private_key, []) ++ [callback] + + Conn.put_private(conn, @before_send_private_key, callbacks) end defp put_plug(config) do @@ -119,4 +151,31 @@ defmodule Pow.Plug.Base do defoverridable unquote(__MODULE__) end end + + @spec store(Config.t()) :: {module(), Config.t()} + def store(config) do + config + |> Config.get(:credentials_cache_store) + |> Kernel.||(fallback_store(config)) + |> case do + {store, store_config} -> {store, store_opts(config, store_config)} + nil -> {CredentialsCache, store_opts(config)} + end + end + + # TODO: Remove by 1.1.0 + defp fallback_store(config) do + case Config.get(config, :session_store) do + nil -> + nil + + value -> + IO.warn("use of `:session_store` config value is deprecated, use `:credentials_cache_store` instead") + value + end + end + + defp store_opts(config, store_config \\ []) do + Keyword.put_new(store_config, :backend, Config.get(config, :cache_store_backend, EtsCache)) + end end diff --git a/lib/pow/plug/message_verifier.ex b/lib/pow/plug/message_verifier.ex new file mode 100644 index 00000000..d8c9f936 --- /dev/null +++ b/lib/pow/plug/message_verifier.ex @@ -0,0 +1,55 @@ +defmodule Pow.Plug.MessageVerifier do + @moduledoc """ + This module can sign and verify messages. + + Based on `Phoenix.Token`. + """ + alias Plug.{Conn, Crypto.KeyGenerator, Crypto.MessageVerifier} + + @callback sign(Conn.t(), binary(), binary(), keyword()) :: binary() + @callback verify(Conn.t(), binary(), binary(), keyword()) :: {:ok, binary()} | :error + + @doc """ + Signs a message. + + `Plug.Crypto.MessageVerifier.sign/2` is used. The secret is derived from the + `salt` and `conn.secret_key_base` using + `Plug.Crypto.KeyGenerator.generate/3`. If `:key_generator_opts` is set in the + config, this will be passed on to `Plug.Crypto.KeyGenerator`. + """ + @spec sign(Conn.t(), binary(), binary(), keyword()) :: binary() + def sign(conn, salt, message, config) do + secret = derive(conn, salt, key_opts(config)) + + MessageVerifier.sign(message, secret) + end + + @doc """ + Verifies a message. + + `Plug.Crypto.MessageVerifier.sign/2` is used. The secret is derived from the + `salt` and `conn.secret_key_base` using + `Plug.Crypto.KeyGenerator.generate/3`. If `:key_generator_opts` is set in the + config, this will be passed on to `Plug.Crypto.KeyGenerator`. + """ + @spec verify(Conn.t(), binary(), binary(), keyword()) :: {:ok, binary()} | :error + def verify(conn, salt, message, config) do + secret = derive(conn, salt, key_opts(config)) + + MessageVerifier.verify(message, secret) + end + + defp derive(conn, key, key_opts) do + conn.secret_key_base + |> validate_secret_key_base() + |> KeyGenerator.generate(key, key_opts) + end + + defp validate_secret_key_base(nil), + do: raise ArgumentError, "No conn.secret_key_base set" + defp validate_secret_key_base(secret_key_base) when byte_size(secret_key_base) < 64, + do: raise ArgumentError, "conn.secret_key_base has to be at least 64 bytes" + defp validate_secret_key_base(secret_key_base), do: secret_key_base + + defp key_opts(config), do: Keyword.get(config, :key_generator_opts, []) +end diff --git a/lib/pow/plug/session.ex b/lib/pow/plug/session.ex index 746ab21c..365d1f29 100644 --- a/lib/pow/plug/session.ex +++ b/lib/pow/plug/session.ex @@ -14,37 +14,37 @@ defmodule Pow.Plug.Session do assigned private `:pow_session_metadata` key in the conn. The value has to be a keyword list. - ## Example + The session id used in the client is signed using `Pow.Plug.sign_token/4` to + prevent timing attacks. - plug Plug.Session, - store: :cookie, - key: "_my_app_demo_key", - signing_salt: "secret" + ## Example - plug Pow.Plug.Session, + @pow_config [ repo: MyApp.Repo, user: MyApp.User, current_user_assigns_key: :current_user, session_key: "auth", - session_store: {Pow.Store.CredentialsCache, - ttl: :timer.minutes(30), - namespace: "credentials"}, + credentials_cache_store: {Pow.Store.CredentialsCache, + ttl: :timer.minutes(30), + namespace: "credentials"}, session_ttl_renewal: :timer.minutes(15), cache_store_backend: Pow.Store.Backend.EtsCache, users_context: Pow.Ecto.Users + ] + + # ... + + plug Plug.Session, @session_options + plug Pow.Plug.Session, @pow_config ## Configuration options - * `:session_key` - session key name, defaults to "auth". If `:otp_app` is - used it'll automatically prepend the key with the `:otp_app` value. + * `:credentials_cache_store` - see `Pow.Plug.Base`. - * `:session_store` - the credentials cache store. This value defaults to - `{Pow.Store.CredentialsCache, backend: Pow.Store.Backend.EtsCache}`. The - `Pow.Store.Backend.EtsCache` backend store can be changed with the - `:cache_store_backend` option. + * `:cache_store_backend` - see `Pow.Plug.Base`. - * `:cache_store_backend` - the backend cache store. This value defaults to - `Pow.Store.Backend.EtsCache`. + * `:session_key` - session key name, defaults to "auth". If `:otp_app` is + used it'll automatically prepend the key with the `:otp_app` value. * `:session_ttl_renewal` - the ttl in milliseconds to trigger renewal of sessions. Defaults to 15 minutes in miliseconds. @@ -78,11 +78,37 @@ defmodule Pow.Plug.Session do The method should be called after `Pow.Plug.Session.call/2` has been called to ensure that the metadata, if any, has been fetched. + + ## Session expiration + + `Pow.Store.CredentialsCache` will, by default, invalidate any session token + 30 minutes after it has been generated. To keep sessions alive the + `:session_ttl_renewal` option is used to determine when a session token + becomes stale and a new session ID has to be generated for the user (deleting + the previous one in the process). + + If `:session_ttl_renewal` is set to zero, a new session token will be + generated on every request. + + To change the amount of time a session can be alive, both the TTL for + `Pow.Store.CredentialsCache` and `:session_ttl_renewal` option should be + changed: + + plug Pow.Plug.Session, otp_app: :my_app, + session_ttl_renewal: :timer.minutes(1), + credentials_cache_store: {Pow.Store.CredentialsCache, ttl: :timer.minutes(15)} + + In the above, a new session token will be generated when a request occurs + more than a minute after the current session token was generated. The + session is invalidated if there is no request for the next 14 minutes. + + There are no absolute session timeout; sessions can be kept alive + indefinitely. """ use Pow.Plug.Base alias Plug.Conn - alias Pow.{Config, Plug, Store.Backend.EtsCache, Store.CredentialsCache, UUID} + alias Pow.{Config, Plug, UUID} @session_key "auth" @session_ttl_renewal :timer.minutes(15) @@ -92,22 +118,30 @@ defmodule Pow.Plug.Session do This will fetch a session from the credentials cache with the session id fetched through `Plug.Conn.get_session/2` session. If the credentials are - stale (timestamp is older than the `:session_ttl_renewal` value), the session - will be regenerated with `create/3`. + stale (timestamp is older than the `:session_ttl_renewal` value), a global + lock will be set, and the session will be regenerated with `create/3`. + Nothing happens if setting the lock failed. The metadata of the session will be assigned as a private `:pow_session_metadata` key in the conn so it may be used in `create/3`. + The session id will be decoded and verified with `Pow.Plug.verify_token/4`. + See `do_fetch/2` for more. """ @impl true @spec fetch(Conn.t(), Config.t()) :: {Conn.t(), map() | nil} def fetch(conn, config) do + case client_store_fetch(conn, config) do + {nil, conn} -> {conn, nil} + {session_id, conn} -> fetch(conn, session_id, config) + end + end + + defp fetch(conn, session_id, config) do {store, store_config} = store(config) - conn = Conn.fetch_session(conn) - key = Conn.get_session(conn, session_key(config)) - {key, store.get(store_config, key)} + {session_id, store.get(store_config, session_id)} |> convert_old_session_value() |> handle_fetched_session_value(conn, config) end @@ -128,25 +162,22 @@ defmodule Pow.Plug.Session do will always be overridden. If no `:fingerprint` exists in the metadata a random UUID value will be generated as its value. + The session id will be signed for public consumption with + `Pow.Plug.sign_token/4`. + See `do_create/3` for more. """ @impl true @spec create(Conn.t(), map(), Config.t()) :: {Conn.t(), map()} def create(conn, user, config) do - conn = Conn.fetch_session(conn) - {store, store_config} = store(config) - metadata = Map.get(conn.private, :pow_session_metadata, []) - {user, metadata} = session_value(user, metadata) - key = session_id(config) - session_key = session_key(config) - - store.put(store_config, key, {user, metadata}) + metadata = Map.get(conn.private, :pow_session_metadata, []) + {user, metadata} = session_value(user, metadata) conn = conn |> delete(config) + |> before_send_create({user, metadata}, config) |> Conn.put_private(:pow_session_metadata, metadata) - |> Conn.put_session(session_key, key) {conn, user} end @@ -154,12 +185,25 @@ defmodule Pow.Plug.Session do defp session_value(user, metadata) do metadata = metadata - |> Keyword.put_new(:fingerprint, UUID.generate()) + |> Keyword.put_new(:fingerprint, gen_fingerprint()) |> Keyword.put(:inserted_at, timestamp()) {user, metadata} end + defp gen_fingerprint(), do: UUID.generate() + + defp before_send_create(conn, value, config) do + {store, store_config} = store(config) + session_id = gen_session_id(config) + + register_before_send(conn, fn conn -> + store.put(store_config, session_id, value) + + client_store_put(conn, session_id, config) + end) + end + @doc """ Delete an existing session in the credentials cache. @@ -171,38 +215,66 @@ defmodule Pow.Plug.Session do """ @impl true @spec delete(Conn.t(), Config.t()) :: Conn.t() - def delete(conn, config) do - conn = Conn.fetch_session(conn) - key = Conn.get_session(conn, session_key(config)) + def delete(conn, config), do: before_send_delete(conn, config) + + defp before_send_delete(conn, config) do {store, store_config} = store(config) - session_key = session_key(config) - store.delete(store_config, key) + register_before_send(conn, fn conn -> + case client_store_fetch(conn, config) do + {nil, conn} -> + conn + + {session_id, conn} -> + store.delete(store_config, session_id) - Conn.delete_session(conn, session_key) + client_store_delete(conn, config) + end + end) end # TODO: Remove by 1.1.0 - defp convert_old_session_value({key, {user, timestamp}}) when is_number(timestamp), do: {key, {user, inserted_at: timestamp}} + defp convert_old_session_value({session_id, {user, timestamp}}) when is_number(timestamp), do: {session_id, {user, inserted_at: timestamp}} defp convert_old_session_value(any), do: any - defp handle_fetched_session_value({_key, :not_found}, conn, _config), do: {conn, nil} - defp handle_fetched_session_value({_key, {user, metadata}}, conn, config) when is_list(metadata) do + defp handle_fetched_session_value({_session_id, :not_found}, conn, _config), do: {conn, nil} + defp handle_fetched_session_value({session_id, {user, metadata}}, conn, config) when is_list(metadata) do conn |> Conn.put_private(:pow_session_metadata, metadata) - |> renew_stale_session(user, metadata, config) + |> renew_stale_session(session_id, user, metadata, config) end - defp renew_stale_session(conn, user, metadata, config) do + defp renew_stale_session(conn, session_id, user, metadata, config) do metadata |> Keyword.get(:inserted_at) |> session_stale?(config) |> case do - true -> create(conn, user, config) + true -> lock_create(conn, session_id, user, config) false -> {conn, user} end end + defp lock_create(conn, session_id, user, config) do + id = {[__MODULE__, session_id], self()} + nodes = Node.list() ++ [node()] + + case :global.set_lock(id, nodes, 0) do + true -> + {conn, user} = create(conn, user, config) + + conn = register_before_send(conn, fn conn -> + :global.del_lock(id, nodes) + + conn + end) + + {conn, user} + + false -> + {conn, user} + end + end + defp session_stale?(inserted_at, config) do ttl = Config.get(config, :session_ttl_renewal, @session_ttl_renewal) session_stale?(inserted_at, config, ttl) @@ -212,7 +284,7 @@ defmodule Pow.Plug.Session do inserted_at + ttl < timestamp() end - defp session_id(config) do + defp gen_session_id(config) do uuid = UUID.generate() Plug.prepend_with_namespace(config, uuid) @@ -226,18 +298,33 @@ defmodule Pow.Plug.Session do Plug.prepend_with_namespace(config, @session_key) end - defp store(config) do - case Config.get(config, :session_store, default_store(config)) do - {store, store_config} -> {store, store_config} - store -> {store, []} + defp timestamp, do: :os.system_time(:millisecond) + + defp client_store_fetch(conn, config) do + conn = Conn.fetch_session(conn) + + with session_id when is_binary(session_id) <- Conn.get_session(conn, session_key(config)), + {:ok, session_id} <- Plug.verify_token(conn, signing_salt(), session_id) do + {session_id, conn} + else + _any -> {nil, conn} end end - defp default_store(config) do - backend = Config.get(config, :cache_store_backend, EtsCache) + defp signing_salt(), do: Atom.to_string(__MODULE__) - {CredentialsCache, [backend: backend]} + defp client_store_put(conn, session_id, config) do + signed_session_id = Plug.sign_token(conn, signing_salt(), session_id, config) + + conn + |> Conn.fetch_session() + |> Conn.put_session(session_key(config), signed_session_id) + |> Conn.configure_session(renew: true) end - defp timestamp, do: :os.system_time(:millisecond) + defp client_store_delete(conn, config) do + conn + |> Conn.fetch_session() + |> Conn.delete_session(session_key(config)) + end end diff --git a/lib/pow/store/backend/base.ex b/lib/pow/store/backend/base.ex index 0afffd62..1cdff3e1 100644 --- a/lib/pow/store/backend/base.ex +++ b/lib/pow/store/backend/base.ex @@ -4,10 +4,61 @@ defmodule Pow.Store.Backend.Base do ## Usage - defmodule MyApp.RedisCache do - @behaviour Base + This is an example using [Cachex](https://hex.pm/packages/cachex): - # ... + defmodule MyApp.CachexCache do + @behaviour Pow.Store.Backend.Base + + alias Pow.Config + + @cachex_tab __MODULE__ + + @impl true + def put(config, record_or_records) do + records = + record_or_records + |> List.wrap() + |> Enum.map(fn {key, value} -> + {wrap_namespace(config, key), value} + end) + + Cachex.put_many(@cachex_tab, records, ttl: Config.get(config, :ttl)) + end + + @impl true + def delete(config, key) do + key = wrap_namespace(config, key) + + Cachex.del(@cachex_tab, key) + end + + @impl true + def get(config, key) do + key = wrap_namespace(config, key) + + case Cachex.get(@cachex_tab, key) do + {:ok, nil} -> :not_found + {:ok, value} -> value + end + end + + @impl true + def all(config, match_spec) do + query = Cachex.Query.create(match_spec, :key) + + @cachex_tab + |> Cachex.stream!(query) + |> Enum.map(fn {key, value} -> {unwrap_namespace(key), value} end) + end + + defp wrap_namespace(config, key) do + namespace = Config.get(config, :namespace, "cache") + + [namespace | List.wrap(key)] + end + + defp unwrap_namespace([_namespace, key]), do: key + defp unwrap_namespace([_namespace | key]), do: key end """ alias Pow.Config diff --git a/lib/pow/store/backend/mnesia_cache.ex b/lib/pow/store/backend/mnesia_cache.ex index 88af92f3..93533c03 100644 --- a/lib/pow/store/backend/mnesia_cache.ex +++ b/lib/pow/store/backend/mnesia_cache.ex @@ -14,6 +14,9 @@ defmodule Pow.Store.Backend.MnesiaCache do The directory path should be accessible, otherwise MnesiaCache will crash on startup. + `:mnesia` should be added to `:extra_applications` in `mix.exs` for it to be + included in releases. + ## Distribution The MnesiaCache is built to handle multi-node setup. @@ -45,7 +48,7 @@ defmodule Pow.Store.Backend.MnesiaCache do To start the GenServer, add it to your application `start/2` method: - defmodule MyAppWeb.Application do + defmodule MyApp.Application do use Application def start(_type, _args) do @@ -65,6 +68,8 @@ defmodule Pow.Store.Backend.MnesiaCache do # ... end + Update configuration with `cache_store_backend: Pow.Store.Backend.MnesiaCache`. + ## Initialization options * `:extra_db_nodes` - list of nodes in cluster to connect to. diff --git a/lib/pow/store/backend/mnesia_cache/unsplit.ex b/lib/pow/store/backend/mnesia_cache/unsplit.ex index ed53c22a..3a767f2a 100644 --- a/lib/pow/store/backend/mnesia_cache/unsplit.ex +++ b/lib/pow/store/backend/mnesia_cache/unsplit.ex @@ -21,7 +21,7 @@ defmodule Pow.Store.Backend.MnesiaCache.Unsplit do To start the GenServer, add it to your application `start/2` method: - defmodule MyAppWeb.Application do + defmodule MyApp.Application do use Application def start(_type, _args) do diff --git a/lib/pow/store/base.ex b/lib/pow/store/base.ex index d19b17d6..e9f2a028 100644 --- a/lib/pow/store/base.ex +++ b/lib/pow/store/base.ex @@ -10,8 +10,8 @@ defmodule Pow.Store.Base do namespace: "credentials" @impl true - def put(config, backend_config, key, value) do - Pow.Store.Base.put(config, backend_config, {key, value}) + def put(config, key, value) do + Pow.Store.Base.put(config, backend_config(config), {key, value}) end end """ diff --git a/lib/pow/store/credentials_cache.ex b/lib/pow/store/credentials_cache.ex index b27342e4..933c3c10 100644 --- a/lib/pow/store/credentials_cache.ex +++ b/lib/pow/store/credentials_cache.ex @@ -2,19 +2,17 @@ defmodule Pow.Store.CredentialsCache do @moduledoc """ Default module for credentials session storage. - A key (session id), is used to store, fetch or delete credentials. When - credentials are stored or deleted, a credentials key will be generated. - The value of that key will be all current keys (session ids), and the - most recent credentials. - - When a key is updated, all expired keys will be pruned from the credentials - key. - - The credentials are expected to take the form of + A key (session id) is used to store, fetch, or delete credentials. The + credentials are expected to take the form of `{credentials, session_metadata}`, where session metadata is data exclusive to the session id. + + This module also adds two utility methods: + + * `users/2` - to list all current users + * `sessions/2` - to list all current sessions """ - alias Pow.{Config, Store.Base} + alias Pow.{Config, Operations, Store.Base} use Base, ttl: :timer.minutes(30), @@ -42,7 +40,7 @@ defmodule Pow.Store.CredentialsCache do # TODO: Refactor by 1.1.0 defp fetch_sessions(config, backend_config, user) do - {struct, id} = user_to_struct_id(user) + {struct, id} = user_to_struct_id!(user, []) config |> Base.all(backend_config, [struct, :user, id, :session, :_]) @@ -68,7 +66,7 @@ defmodule Pow.Store.CredentialsCache do """ @impl true def put(config, session_id, {user, metadata}) do - {struct, id} = user_to_struct_id(user) + {struct, id} = user_to_struct_id!(user, []) user_key = [struct, :user, id] session_key = [struct, :user, id, :session, session_id] records = [ @@ -131,33 +129,26 @@ defmodule Pow.Store.CredentialsCache do end end - defp user_to_struct_id(%struct{} = user) do - key_value = case function_exported?(struct, :__schema__, 1) do - true -> key_value_from_primary_keys(user) - false -> primary_keys_to_keyword_list!([:id], user) - end - - {struct, key_value} - end - defp user_to_struct_id(_user), do: raise "Only structs can be stored as credentials" - - defp key_value_from_primary_keys(%struct{} = user) do - :primary_key - |> struct.__schema__() - |> Enum.sort() - |> primary_keys_to_keyword_list!(user) - end + defp user_to_struct_id!(%mod{} = user, config) do + key_values = + user + |> fetch_primary_key_values!(config) + |> Enum.sort(&elem(&1, 0) < elem(&2, 0)) + |> case do + [id: id] -> id + clauses -> clauses + end - defp primary_keys_to_keyword_list!([], %struct{}), do: raise "No primary keys found for #{inspect struct}" - defp primary_keys_to_keyword_list!([key], user), do: get_primary_key_value!(user, key) - defp primary_keys_to_keyword_list!(keys, user) do - Enum.map(keys, &{&1, get_primary_key_value!(user, &1)}) + {mod, key_values} end - - defp get_primary_key_value!(%struct{} = user, key) do - case Map.get(user, key) do - nil -> raise "Primary key value for key `#{inspect key}` in #{inspect struct} can't be `nil`" - val -> val + defp user_to_struct_id!(_user, _config), do: raise_error "Only structs can be stored as credentials" + + defp fetch_primary_key_values!(user, config) do + user + |> Operations.fetch_primary_key_values(config) + |> case do + {:error, error} -> raise_error error + {:ok, clauses} -> clauses end end @@ -181,6 +172,9 @@ defmodule Pow.Store.CredentialsCache do end) end + @spec raise_error(binary()) :: no_return() + defp raise_error(message), do: raise message + # TODO: Remove by 1.1.0 @doc false @deprecated "Use `users/2` or `sessions/2` instead" diff --git a/mix.exs b/mix.exs index ec263d67..e0226e0a 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Pow.MixProject do use Mix.Project - @version "1.0.15" + @version "1.0.18" def project do [ @@ -40,15 +40,15 @@ defmodule Pow.MixProject do {:phoenix_html, ">= 2.0.0 and <= 3.0.0"}, {:plug, ">= 1.5.0 and < 2.0.0", optional: true}, - {:phoenix_ecto, "~> 4.0.0", only: [:dev, :test]}, - {:credo, "~> 1.1.0", only: [:dev, :test]}, + {:phoenix_ecto, "~> 4.1.0", only: [:dev, :test]}, + {:credo, "~> 1.2.0", only: [:dev, :test]}, {:jason, "~> 1.0", only: [:dev, :test]}, # Credo requires jason to exist also in :dev {:ex_doc, "~> 0.21.0", only: :dev}, - {:ecto_sql, "~> 3.1", only: [:test]}, - {:plug_cowboy, "~> 2.0", only: [:test]}, - {:postgrex, "~> 0.15.0", only: [:test]} + {:ecto_sql, "~> 3.3", only: [:test]}, + {:plug_cowboy, "~> 2.1", only: [:test]}, + {:postgrex, "~> 0.15.3", only: [:test]} ] end @@ -79,6 +79,7 @@ defmodule Pow.MixProject do "CHANGELOG.md": [filename: "CHANGELOG"], "guides/why_pow.md": [], "guides/production_checklist.md": [], + "guides/security_practices.md": [], "guides/coherence_migration.md": [], "guides/configuring_mailer.md": [], "guides/user_roles.md": [], diff --git a/mix.lock b/mix.lock index f5890f91..d5186a8c 100644 --- a/mix.lock +++ b/mix.lock @@ -1,29 +1,28 @@ %{ - "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"}, - "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"}, - "cowboy": {:hex, :cowboy, "2.7.0", "91ed100138a764355f43316b1d23d7ff6bdb0de4ea618cb5d8677c93a7a2f115", [:rebar3], [{:cowlib, "~> 2.8.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"}, - "cowlib": {:hex, :cowlib, "2.8.0", "fd0ff1787db84ac415b8211573e9a30a3ebe71b5cbff7f720089972b2319c8a4", [:rebar3], [], "hexpm"}, - "credo": {:hex, :credo, "1.1.5", "caec7a3cadd2e58609d7ee25b3931b129e739e070539ad1a0cd7efeeb47014f4", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, - "db_connection": {:hex, :db_connection, "2.1.1", "a51e8a2ee54ef2ae6ec41a668c85787ed40cb8944928c191280fe34c15b76ae5", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm"}, - "decimal": {:hex, :decimal, "1.8.0", "ca462e0d885f09a1c5a342dbd7c1dcf27ea63548c65a65e67334f4b61803822e", [:mix], [], "hexpm"}, - "earmark": {:hex, :earmark, "1.4.2", "3aa0bd23bc4c61cf2f1e5d752d1bb470560a6f8539974f767a38923bb20e1d7f", [:mix], [], "hexpm"}, - "ecto": {:hex, :ecto, "3.2.3", "51274df79862845b388733fddcf6f107d0c8c86e27abe7131fa98f8d30761bda", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"}, - "ecto_sql": {:hex, :ecto_sql, "3.2.0", "751cea597e8deb616084894dd75cbabfdbe7255ff01e8c058ca13f0353a3921b", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.2.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.2.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"}, - "ex_doc": {:hex, :ex_doc, "0.21.2", "caca5bc28ed7b3bdc0b662f8afe2bee1eedb5c3cf7b322feeeb7c6ebbde089d6", [:mix], [{:earmark, "~> 1.3.3 or ~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, - "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, - "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, - "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, - "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"}, - "nimble_parsec": {:hex, :nimble_parsec, "0.5.1", "c90796ecee0289dbb5ad16d3ad06f957b0cd1199769641c961cfe0b97db190e0", [:mix], [], "hexpm"}, - "phoenix": {:hex, :phoenix, "1.4.10", "619e4a545505f562cd294df52294372d012823f4fd9d34a6657a8b242898c255", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.1 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"}, - "phoenix_ecto": {:hex, :phoenix_ecto, "4.0.0", "c43117a136e7399ea04ecaac73f8f23ee0ffe3e07acfcb8062fe5f4c9f0f6531", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, - "phoenix_html": {:hex, :phoenix_html, "2.13.3", "850e292ff6e204257f5f9c4c54a8cb1f6fbc16ed53d360c2b780a3d0ba333867", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, - "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.2", "496c303bdf1b2e98a9d26e89af5bba3ab487ba3a3735f74bf1f4064d2a845a3e", [:mix], [], "hexpm"}, - "plug": {:hex, :plug, "1.8.3", "12d5f9796dc72e8ac9614e94bda5e51c4c028d0d428e9297650d09e15a684478", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm"}, - "plug_cowboy": {:hex, :plug_cowboy, "2.1.0", "b75768153c3a8a9e8039d4b25bb9b14efbc58e9c4a6e6a270abff1cd30cbe320", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, - "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"}, - "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, - "postgrex": {:hex, :postgrex, "0.15.1", "23ce3417de70f4c0e9e7419ad85bdabcc6860a6925fe2c6f3b1b5b1e8e47bf2f", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"}, - "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"}, - "telemetry": {:hex, :telemetry, "0.4.0", "8339bee3fa8b91cb84d14c2935f8ecf399ccd87301ad6da6b71c09553834b2ab", [:rebar3], [], "hexpm"}, + "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, + "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm", "4a0850c9be22a43af9920a71ab17c051f5f7d45c209e40269a1938832510e4d9"}, + "cowboy": {:hex, :cowboy, "2.7.0", "91ed100138a764355f43316b1d23d7ff6bdb0de4ea618cb5d8677c93a7a2f115", [:rebar3], [{:cowlib, "~> 2.8.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "04fd8c6a39edc6aaa9c26123009200fc61f92a3a94f3178c527b70b767c6e605"}, + "cowlib": {:hex, :cowlib, "2.8.0", "fd0ff1787db84ac415b8211573e9a30a3ebe71b5cbff7f720089972b2319c8a4", [:rebar3], [], "hexpm", "79f954a7021b302186a950a32869dbc185523d99d3e44ce430cd1f3289f41ed4"}, + "credo": {:hex, :credo, "1.2.2", "f57faf60e0a12b0ba9fd4bad07966057fde162b33496c509b95b027993494aab", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8f2623cd8c895a6f4a55ef10f3fdf6a55a9ca7bef09676bd835551687bf8a740"}, + "db_connection": {:hex, :db_connection, "2.2.1", "caee17725495f5129cb7faebde001dc4406796f12a62b8949f4ac69315080566", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "2b02ece62d9f983fcd40954e443b7d9e6589664380e5546b2b9b523cd0fb59e1"}, + "decimal": {:hex, :decimal, "1.8.1", "a4ef3f5f3428bdbc0d35374029ffcf4ede8533536fa79896dd450168d9acdf3c", [:mix], [], "hexpm", "3cb154b00225ac687f6cbd4acc4b7960027c757a5152b369923ead9ddbca7aec"}, + "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"}, + "ecto": {:hex, :ecto, "3.3.2", "002aa428c752a8ee4bb65baa9d1041f0514d7435d2e21d58cb6aa69a1076721d", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "c651e3ea0919241da314f518404b84449c8569525c4d4cf0f3d16f1d5d0a560c"}, + "ecto_sql": {:hex, :ecto_sql, "3.3.3", "7d8962d39f16181c1df1bbd0f64aa392bd9ce0b9f8ff5ff21d43dca3d624eee7", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4 or ~> 3.3.2", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3d5b8e14836d930448d79ab6ac325501c3c62caed83c34791d5af77718766795"}, + "ex_doc": {:hex, :ex_doc, "0.21.3", "857ec876b35a587c5d9148a2512e952e24c24345552259464b98bfbb883c7b42", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "0db1ee8d1547ab4877c5b5dffc6604ef9454e189928d5ba8967d4a58a801f161"}, + "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fdf843bca858203ae1de16da2ee206f53416bbda5dc8c9e78f43243de4bc3afe"}, + "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "a10c6eb62cca416019663129699769f0c2ccf39428b3bb3c0cb38c718a0c186d"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d4b316c7222a85bbaa2fd7c6e90e37e953257ad196dc229505137c5e505e9eff"}, + "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm", "6cbe761d6a0ca5a31a0931bf4c63204bceb64538e664a8ecf784a9a6f3b875f1"}, + "nimble_parsec": {:hex, :nimble_parsec, "0.5.3", "def21c10a9ed70ce22754fdeea0810dafd53c2db3219a0cd54cf5526377af1c6", [:mix], [], "hexpm", "589b5af56f4afca65217a1f3eb3fee7e79b09c40c742fddc1c312b3ac0b3399f"}, + "phoenix": {:hex, :phoenix, "1.4.13", "67271ad69b51f3719354604f4a3f968f83aa61c19199343656c9caee057ff3b8", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.1 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ab765a0feddb81fc62e2116c827b5f068df85159c162bee760745276ad7ddc1b"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.1.0", "a044d0756d0464c5a541b4a0bf4bcaf89bffcaf92468862408290682c73ae50d", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "c5e666a341ff104d0399d8f0e4ff094559b2fde13a5985d4cb5023b2c2ac558b"}, + "phoenix_html": {:hex, :phoenix_html, "2.14.0", "d8c6bc28acc8e65f8ea0080ee05aa13d912c8758699283b8d3427b655aabe284", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "b0bb30eda478a06dbfbe96728061a93833db3861a49ccb516f839ecb08493fbb"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.2", "496c303bdf1b2e98a9d26e89af5bba3ab487ba3a3735f74bf1f4064d2a845a3e", [:mix], [], "hexpm", "1f13f9f0f3e769a667a6b6828d29dec37497a082d195cc52dbef401a9b69bf38"}, + "plug": {:hex, :plug, "1.9.0", "8d7c4e26962283ff9f8f3347bd73838e2413fbc38b7bb5467d5924f68f3a5a4a", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "9902eda2c52ada2a096434682e99a2493f5d06a94d6ac6bcfff9805f952350f1"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.1.2", "8b0addb5908c5238fac38e442e81b6fcd32788eaa03246b4d55d147c47c5805e", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "7d722581ce865a237e14da6d946f92704101740a256bd13ec91e63c0b122fc70"}, + "plug_crypto": {:hex, :plug_crypto, "1.1.0", "854843d59062bf104ffe48fd92ad25a26fa3cc47558a13dd14c3025dc987542e", [:mix], [], "hexpm", "aef0a3aef2e4cda1c50b33cb25a56575117c9b25caaec8a7eb2dd7ce6ce13de5"}, + "postgrex": {:hex, :postgrex, "0.15.3", "5806baa8a19a68c4d07c7a624ccdb9b57e89cbc573f1b98099e3741214746ae4", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "4737ce62a31747b4c63c12b20c62307e51bb4fcd730ca0c32c280991e0606c90"}, + "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"}, + "telemetry": {:hex, :telemetry, "0.4.1", "ae2718484892448a24470e6aa341bc847c3277bfb8d4e9289f7474d752c09c7f", [:rebar3], [], "hexpm", "4738382e36a0a9a2b6e25d67c960e40e1a2c95560b9f936d8e29de8cd858480f"}, } diff --git a/test/extensions/email_confirmation/ecto/context_test.exs b/test/extensions/email_confirmation/ecto/context_test.exs index 1c84495c..d08044bf 100644 --- a/test/extensions/email_confirmation/ecto/context_test.exs +++ b/test/extensions/email_confirmation/ecto/context_test.exs @@ -9,6 +9,16 @@ defmodule PowEmailConfirmation.Ecto.ContextTest do @config [repo: RepoMock, user: User] @user %User{id: 1, email: "test@example.com"} + defmodule CustomUsers do + def get_by([email_confirmation_token: :test]), do: %User{email: :ok} + end + + describe "get_by_confirmation_token/2" do + test "with `:users_context`" do + assert %User{email: :ok} = Context.get_by_confirmation_token(:test, @config ++ [users_context: CustomUsers]) + end + end + describe "confirm_email/2" do test "confirms with no :unconfirmed_email" do assert {:ok, user} = Context.confirm_email(@user, @config) @@ -36,11 +46,11 @@ defmodule PowEmailConfirmation.Ecto.ContextTest do user = %{@user | unconfirmed_email: "taken@example.com"} assert {:error, changeset} = Context.confirm_email(user, @config) - assert changeset.errors[:email] == {"has already been taken", []} + assert changeset.errors[:email] == {"has already been taken", constraint: :unique, constraint_name: "users_email_index"} end end - @valid_params %{email: "test@example.com", password: "secret1234", confirm_password: "secret1234"} + @valid_params %{email: "test@example.com", password: "secret1234", password_confirmation: "secret1234"} test "current_email_unconfirmed?/2" do new_user = diff --git a/test/extensions/email_confirmation/ecto/schema_test.exs b/test/extensions/email_confirmation/ecto/schema_test.exs index 1159a56d..5afea530 100644 --- a/test/extensions/email_confirmation/ecto/schema_test.exs +++ b/test/extensions/email_confirmation/ecto/schema_test.exs @@ -6,7 +6,7 @@ defmodule PowEmailConfirmation.Ecto.SchemaTest do alias PowEmailConfirmation.Test.{RepoMock, Users.User} @password "secret1234" - @valid_params %{email: "test@example.com", password: @password, confirm_password: @password, current_password: @password} + @valid_params %{email: "test@example.com", password: @password, password_confirmation: @password, current_password: @password} test "user_schema/1" do user = %User{} @@ -93,7 +93,8 @@ defmodule PowEmailConfirmation.Ecto.SchemaTest do changeset = User.changeset(user, Map.put(@valid_params, :email, "invalid")) refute changeset.valid? - assert changeset.errors[:email] == {"has invalid format", [validator: &Pow.Ecto.Schema.Changeset.validate_email/1, reason: "invalid format"]} + assert changeset.errors[:email] == {"has invalid format", [validation: :email_format, reason: "invalid format"]} + assert changeset.validations[:email] == {:email_format, &Pow.Ecto.Schema.Changeset.validate_email/1} refute Ecto.Changeset.get_change(changeset, :email_confirmation_token) refute Ecto.Changeset.get_change(changeset, :unconfirmed_email) @@ -105,21 +106,11 @@ defmodule PowEmailConfirmation.Ecto.SchemaTest do changeset = User.changeset(user, Map.put(@valid_params, :email, "invalid")) refute changeset.valid? - assert changeset.errors[:email] == {"has invalid format", [validator: &Pow.Ecto.Schema.Changeset.validate_email/1, reason: "invalid format"]} + assert changeset.errors[:email] == {"has invalid format", [validation: :email_format, reason: "invalid format"]} + assert changeset.validations[:email] == {:email_format, &Pow.Ecto.Schema.Changeset.validate_email/1} assert Ecto.Changeset.get_field(changeset, :email_confirmation_token) == user.email_confirmation_token assert Ecto.Changeset.get_field(changeset, :unconfirmed_email) == user.unconfirmed_email end - - test "doesn't update when :email already taken by another user", %{user: user} do - {:error, changeset} = - user - |> User.changeset(Map.put(@valid_params, :email, "taken@example.com")) - |> RepoMock.update([]) - - assert changeset.errors[:email] == {"has already been taken", [validation: :unsafe_unique, fields: [:email]]} - assert Ecto.Changeset.get_change(changeset, :email) == "taken@example.com" - assert Ecto.Changeset.get_change(changeset, :unconfirmed_email) == "taken@example.com" - end end describe "confirm_email_changeset/1" do diff --git a/test/extensions/email_confirmation/phoenix/controllers/confirmation_controller_test.exs b/test/extensions/email_confirmation/phoenix/controllers/confirmation_controller_test.exs index 96ae4088..536508e6 100644 --- a/test/extensions/email_confirmation/phoenix/controllers/confirmation_controller_test.exs +++ b/test/extensions/email_confirmation/phoenix/controllers/confirmation_controller_test.exs @@ -1,11 +1,16 @@ defmodule PowEmailConfirmation.Phoenix.ConfirmationControllerTest do use PowEmailConfirmation.TestWeb.Phoenix.ConnCase + alias Plug.Conn + alias Pow.Plug, as: PowPlug + alias PowEmailConfirmation.Plug + alias PowEmailConfirmation.{Test, Test.Users.User} + @session_key "auth" describe "show/2" do test "confirms with valid token", %{conn: conn} do - conn = get conn, Routes.pow_email_confirmation_confirmation_path(conn, :show, "valid") + conn = get conn, Routes.pow_email_confirmation_confirmation_path(conn, :show, sign_token("valid")) assert redirected_to(conn) == Routes.pow_session_path(conn, :new) assert get_flash(conn, :info) == "The email address has been confirmed." @@ -18,7 +23,8 @@ defmodule PowEmailConfirmation.Phoenix.ConfirmationControllerTest do end test "confirms with valid token and :unconfirmed_email", %{conn: conn} do - conn = get conn, Routes.pow_email_confirmation_confirmation_path(conn, :show, "valid-with-unconfirmed-changed-email") + conn = get conn, Routes.pow_email_confirmation_confirmation_path(conn, :show, sign_token("valid-with-unconfirmed-changed-email")) + assert redirected_to(conn) == Routes.pow_session_path(conn, :new) assert get_flash(conn, :info) == "The email address has been confirmed." @@ -28,8 +34,26 @@ defmodule PowEmailConfirmation.Phoenix.ConfirmationControllerTest do refute user.unconfirmed_email end + test "fails with unique constraint", %{conn: conn} do + conn = get conn, Routes.pow_email_confirmation_confirmation_path(conn, :show, sign_token("valid-with-taken-email")) + + assert redirected_to(conn) == Routes.pow_session_path(conn, :new) + assert get_flash(conn, :error) == "The email address couldn't be confirmed." + + refute Process.get({:user, 1}) + end + test "fails with invalid token", %{conn: conn} do - conn = get conn, Routes.pow_email_confirmation_confirmation_path(conn, :show, "invalid") + conn = get conn, Routes.pow_email_confirmation_confirmation_path(conn, :show, sign_token("invalid")) + + assert redirected_to(conn) == Routes.pow_session_path(conn, :new) + assert get_flash(conn, :error) == "The email address couldn't be confirmed." + + refute Process.get({:user, 1}) + end + + test "fails with unsigned token", %{conn: conn} do + conn = get conn, Routes.pow_email_confirmation_confirmation_path(conn, :show, "valid") assert redirected_to(conn) == Routes.pow_session_path(conn, :new) assert get_flash(conn, :error) == "The email address couldn't be confirmed." @@ -41,8 +65,8 @@ defmodule PowEmailConfirmation.Phoenix.ConfirmationControllerTest do session_id = conn.private[:plug_session][@session_key] conn = conn - |> Pow.Plug.assign_current_user(%{id: 1}, []) - |> get(Routes.pow_email_confirmation_confirmation_path(conn, :show, "valid")) + |> Pow.Plug.assign_current_user(%User{id: 1}, []) + |> get(Routes.pow_email_confirmation_confirmation_path(conn, :show, sign_token("valid"))) assert redirected_to(conn) == Routes.pow_registration_path(conn, :edit) assert Pow.Plug.current_user(conn) @@ -53,12 +77,18 @@ defmodule PowEmailConfirmation.Phoenix.ConfirmationControllerTest do session_id = conn.private[:plug_session][@session_key] conn = conn - |> Pow.Plug.assign_current_user(%{id: 2}, []) - |> get(Routes.pow_email_confirmation_confirmation_path(conn, :show, "valid")) + |> Pow.Plug.assign_current_user(%User{id: 2}, []) + |> get(Routes.pow_email_confirmation_confirmation_path(conn, :show, sign_token("valid"))) assert redirected_to(conn) == Routes.pow_registration_path(conn, :edit) assert Pow.Plug.current_user(conn) assert conn.private[:plug_session][@session_key] == session_id end end + + defp sign_token(token) do + %Conn{} + |> PowPlug.put_config(Test.pow_config()) + |> Plug.sign_confirmation_token(%{email_confirmation_token: token}) + end end diff --git a/test/extensions/email_confirmation/phoenix/controllers/controller_callbacks_test.exs b/test/extensions/email_confirmation/phoenix/controllers/controller_callbacks_test.exs index 11c6bb47..3a2e857d 100644 --- a/test/extensions/email_confirmation/phoenix/controllers/controller_callbacks_test.exs +++ b/test/extensions/email_confirmation/phoenix/controllers/controller_callbacks_test.exs @@ -1,8 +1,11 @@ defmodule PowEmailConfirmation.Phoenix.ControllerCallbacksTest do use PowEmailConfirmation.TestWeb.Phoenix.ConnCase - alias Pow.{Ecto.Schema.Password, Plug} - alias PowEmailConfirmation.Test.Users.User + alias Plug.Conn + alias PowEmailConfirmation.Plug + alias Pow.Ecto.Schema.Password + alias Pow.Plug, as: PowPlug + alias PowEmailConfirmation.{Test, Test.Users.User} @password "secret1234" @@ -15,18 +18,21 @@ defmodule PowEmailConfirmation.Phoenix.ControllerCallbacksTest do assert get_flash(conn, :error) == "You'll need to confirm your e-mail before you can sign in. An e-mail confirmation link has been sent to you." assert redirected_to(conn) == "/after_signed_in" - refute Plug.current_user(conn) + refute PowPlug.current_user(conn) + refute conn.private[:plug_session]["auth"] assert_received {:mail_mock, mail} assert token = mail.user.email_confirmation_token refute mail.user.email_confirmed_at - assert mail.html =~ "" + assert mail.html =~ "" assert mail.user.email == "test@example.com" end test "when current email has been confirmed", %{conn: conn} do conn = post conn, Routes.pow_session_path(conn, :create, %{"user" => Map.put(@valid_params, "email", "confirmed-email@example.com")}) + assert PowPlug.current_user(conn) + assert conn.private[:plug_session]["auth"] assert get_flash(conn, :info) == "signed_in" assert redirected_to(conn) == "/after_signed_in" end @@ -34,14 +40,17 @@ defmodule PowEmailConfirmation.Phoenix.ControllerCallbacksTest do test "when current email confirmed and has unconfirmed changed email", %{conn: conn} do conn = post conn, Routes.pow_session_path(conn, :create, %{"user" => Map.put(@valid_params, "email", "with-unconfirmed-changed-email@example.com")}) - assert %{id: 1} = Plug.current_user(conn) + assert %{id: 1} = PowPlug.current_user(conn) + assert conn.private[:plug_session]["auth"] refute_received {:mail_mock, _mail} end end describe "Pow.Phoenix.RegistrationController.create/2" do - @valid_params %{"user" => %{"email" => "test@example.com", "password" => @password, "confirm_password" => @password}} + @valid_params %{"user" => %{"email" => "test@example.com", "password" => @password, "password_confirmation" => @password}} + @invalid_params_email_taken %{"user" => %{"email" => "taken@example.com", "password" => @password, "password_confirmation" => "s"}} + @valid_params_email_taken %{"user" => %{"email" => "taken@example.com", "password" => @password, "password_confirmation" => @password}} test "with valid params", %{conn: conn} do conn = post conn, Routes.pow_registration_path(conn, :create, @valid_params) @@ -49,14 +58,45 @@ defmodule PowEmailConfirmation.Phoenix.ControllerCallbacksTest do assert get_flash(conn, :error) == "You'll need to confirm your e-mail before you can sign in. An e-mail confirmation link has been sent to you." assert redirected_to(conn) == "/after_registration" - refute Plug.current_user(conn) + refute PowPlug.current_user(conn) + refute conn.private[:plug_session]["auth"] assert_received {:mail_mock, mail} assert token = mail.user.email_confirmation_token refute mail.user.email_confirmed_at - assert mail.html =~ "" + assert mail.html =~ "" assert mail.user.email == "test@example.com" end + + test "with invalid params and email taken", %{conn: conn} do + conn = post conn, Routes.pow_registration_path(conn, :create, @invalid_params_email_taken) + + assert html = html_response(conn, 200) + refute html =~ "has already been taken" + assert html =~ "does not match confirmation" + end + + test "with valid params and email taken", %{conn: conn} do + conn = post conn, Routes.pow_registration_path(conn, :create, @valid_params_email_taken) + + assert get_flash(conn, :error) == "You'll need to confirm your e-mail before you can sign in. An e-mail confirmation link has been sent to you." + assert redirected_to(conn) == "/after_registration" + + refute PowPlug.current_user(conn) + refute conn.private[:plug_session]["auth"] + + refute_received {:mail_mock, _mail} + end + + test "with valid params and email taken with pow_prevent_user_enumeration: false", %{conn: conn} do + conn = + conn + |> Conn.put_private(:pow_prevent_user_enumeration, false) + |> post(Routes.pow_registration_path(conn, :create, @valid_params_email_taken)) + + assert html = html_response(conn, 200) + assert html =~ "has already been taken" + end end describe "Pow.Phoenix.RegistrationController.update/2" do @@ -67,14 +107,15 @@ defmodule PowEmailConfirmation.Phoenix.ControllerCallbacksTest do setup %{conn: conn} do user = Ecto.put_meta(@user, state: :loaded) - conn = Plug.assign_current_user(conn, user, []) + conn = PowPlug.assign_current_user(conn, user, []) {:ok, conn: conn} end test "when email changes", %{conn: conn} do conn = put conn, Routes.pow_registration_path(conn, :update, %{"user" => @change_email_params}) - assert %{id: 1, email: "test@example.com", email_confirmation_token: new_token} = Plug.current_user(conn) + + assert %{id: 1, email: "test@example.com", email_confirmation_token: new_token} = PowPlug.current_user(conn) assert get_flash(conn, :error) == "You'll need to confirm the e-mail before it's updated. An e-mail confirmation link has been sent to you." assert get_flash(conn, :info) == "Your account has been updated." @@ -82,8 +123,8 @@ defmodule PowEmailConfirmation.Phoenix.ControllerCallbacksTest do assert_received {:mail_mock, mail} assert mail.subject == "Confirm your email address" - assert mail.text =~ "\nhttp://localhost/confirm-email/#{new_token}\n" - assert mail.html =~ "" + assert mail.text =~ "\nhttp://localhost/confirm-email/#{sign_token(conn, new_token)}\n" + assert mail.html =~ "" assert mail.user.email == "new@example.com" end @@ -91,7 +132,7 @@ defmodule PowEmailConfirmation.Phoenix.ControllerCallbacksTest do conn = put conn, Routes.pow_registration_path(conn, :update, %{"user" => @params}) assert get_flash(conn, :info) == "Your account has been updated." - assert %{id: 1, unconfirmed_email: nil, email_confirmation_token: nil} = Plug.current_user(conn) + assert %{id: 1, unconfirmed_email: nil, email_confirmation_token: nil} = PowPlug.current_user(conn) refute_received {:mail_mock, _mail} end @@ -99,31 +140,41 @@ defmodule PowEmailConfirmation.Phoenix.ControllerCallbacksTest do alias PowEmailConfirmation.PowInvitation.TestWeb.Phoenix.Endpoint, as: PowInvitationEndpoint alias PowEmailConfirmation.PowInvitation.TestWeb.Phoenix.Router.Helpers, as: PowInvitationRoutes - - defp put_invitation(conn, path) do - Phoenix.ConnTest.dispatch(conn, PowInvitationEndpoint, :put, path) - end + alias PowInvitation.Plug, as: PowInvitationPlug describe "PowInvitation.Phoenix.InvitationController.update/2" do @token "token" - @params %{"email" => "test@example.com", "password" => @password, "confirm_password" => @password} - @change_email_params %{"email" => "new@example.com", "password" => @password, "confirm_password" => @password} + @params %{"email" => "test@example.com", "password" => @password, "password_confirmation" => @password} + @change_email_params %{"email" => "new@example.com", "password" => @password, "password_confirmation" => @password} - test "when email changes", %{conn: conn} do - conn = put_invitation conn, PowInvitationRoutes.pow_invitation_invitation_path(conn, :update, @token, %{"user" => @change_email_params}) + setup do + token = + %Conn{} + |> PowPlug.put_config(Test.pow_config()) + |> PowInvitationPlug.sign_invitation_token(%{invitation_token: @token}) - assert %{id: 1, email_confirmation_token: new_token} = Plug.current_user(conn) + {:ok, token: token} + end + + test "when email changes", %{conn: conn, token: token} do + conn = Phoenix.ConnTest.dispatch(conn, PowInvitationEndpoint, :put, PowInvitationRoutes.pow_invitation_invitation_path(conn, :update, token, %{"user" => @change_email_params})) + + assert get_flash(conn, :error) == "You'll need to confirm the e-mail before it's updated. An e-mail confirmation link has been sent to you." + assert %{id: 1, email_confirmation_token: new_token} = PowPlug.current_user(conn) refute is_nil(new_token) assert_received {:mail_mock, _mail} end - test "when email hasn't changed", %{conn: conn} do - conn = put_invitation conn, PowInvitationRoutes.pow_invitation_invitation_path(conn, :update, @token, %{"user" => @params}) + test "when email hasn't changed", %{conn: conn, token: token} do + conn = Phoenix.ConnTest.dispatch(conn, PowInvitationEndpoint, :put, PowInvitationRoutes.pow_invitation_invitation_path(conn, :update, token, %{"user" => @params})) - assert %{id: 1, email_confirmation_token: nil} = Plug.current_user(conn) + refute get_flash(conn, :error) + assert %{id: 1, email_confirmation_token: nil} = PowPlug.current_user(conn) refute_received {:mail_mock, _mail} end end + + defp sign_token(conn, token), do: Plug.sign_confirmation_token(conn, %{email_confirmation_token: token}) end diff --git a/test/extensions/invitation/ecto/context_test.exs b/test/extensions/invitation/ecto/context_test.exs index dc8a19f4..1e907556 100644 --- a/test/extensions/invitation/ecto/context_test.exs +++ b/test/extensions/invitation/ecto/context_test.exs @@ -7,6 +7,10 @@ defmodule PowInvitation.Ecto.ContextTest do @config [repo: RepoMock, user: User] + defmodule CustomUsers do + def get_by([invitation_token: :test]), do: %User{email: :ok} + end + describe "get_by_invitation_token/2" do test "gets when :invitation_accepted_at is nil" do assert Context.get_by_invitation_token("valid", @config) @@ -15,5 +19,9 @@ defmodule PowInvitation.Ecto.ContextTest do test "nil when :invitation_accepted_at is not nil" do refute Context.get_by_invitation_token("valid_but_accepted", @config) end + + test "with `:users_context`" do + assert %User{email: :ok} = Context.get_by_invitation_token(:test, @config ++ [users_context: CustomUsers]) + end end end diff --git a/test/extensions/invitation/ecto/schema_test.exs b/test/extensions/invitation/ecto/schema_test.exs index d52a0c37..a3054b46 100644 --- a/test/extensions/invitation/ecto/schema_test.exs +++ b/test/extensions/invitation/ecto/schema_test.exs @@ -68,7 +68,7 @@ defmodule PowInvitation.Ecto.SchemaTest do describe "accept_invitation_changeset/2" do @password "password12" - @valid_params %{email: "test@example.com", password: @password, confirm_password: @password} + @valid_params %{email: "test@example.com", password: @password, password_confirmation: @password} @invalid_params %{email: "foo"} test "with valid params" do diff --git a/test/extensions/invitation/phoenix/controllers/invitation_controller_test.exs b/test/extensions/invitation/phoenix/controllers/invitation_controller_test.exs index 057e25dd..5ff8af36 100644 --- a/test/extensions/invitation/phoenix/controllers/invitation_controller_test.exs +++ b/test/extensions/invitation/phoenix/controllers/invitation_controller_test.exs @@ -2,10 +2,12 @@ defmodule PowInvitation.Phoenix.InvitationControllerTest do use PowInvitation.TestWeb.Phoenix.ConnCase alias Plug.Conn - alias PowInvitation.Test.Users.{User, UsernameUser} + alias Pow.Plug, as: PowPlug + alias PowInvitation.Plug + alias PowInvitation.{Test, Test.Users.User, Test.Users.UsernameUser} @user %User{id: 1, email: "test@example.com"} - @url_regex ~r/http:\/\/localhost\/invitations\/[a-z0-9\-]*\/edit/ + @url_regex ~r/http:\/\/localhost\/invitations\/[a-zA-Z0-9\-\_\.]*\/edit/ describe "new/2" do test "not signed in", %{conn: conn} do @@ -41,6 +43,7 @@ defmodule PowInvitation.Phoenix.InvitationControllerTest do describe "create/2" do @valid_params %{"user" => %{"email" => "test@example.com"}} @invalid_params %{"user" => %{"email" => "invalid"}} + @valid_params_email_taken %{"user" => %{"email" => "taken@example.com"}} @valid_params_no_email %{"user" => %{"email" => :no_email}} test "not signed in", %{conn: conn} do @@ -79,6 +82,31 @@ defmodule PowInvitation.Phoenix.InvitationControllerTest do assert html =~ "has invalid format" end + test "with valid params and email taken", %{conn: conn} do + conn = + conn + |> Pow.Plug.assign_current_user(@user, []) + |> post(Routes.pow_invitation_invitation_path(conn, :create, @valid_params_email_taken)) + + refute_received {:mail_mock, _mail} + + assert redirected_to(conn) == Routes.pow_invitation_invitation_path(conn, :new) + assert get_flash(conn, :info) == "An e-mail with invitation link has been sent." + end + + test "with valid params and email taken with pow_prevent_user_enumeration: false", %{conn: conn} do + conn = + conn + |> Conn.put_private(:pow_prevent_user_enumeration, false) + |> Pow.Plug.assign_current_user(@user, []) + |> post(Routes.pow_invitation_invitation_path(conn, :create, @valid_params_email_taken)) + + assert html = html_response(conn, 200) + assert html =~ "" + assert html =~ "" + assert html =~ "has already been taken" + end + test "user with no email", %{conn: conn} do conn = conn @@ -93,7 +121,7 @@ defmodule PowInvitation.Phoenix.InvitationControllerTest do describe "show/2" do test "not signed in", %{conn: conn} do - conn = get(conn, Routes.pow_invitation_invitation_path(conn, :show, "valid")) + conn = get(conn, Routes.pow_invitation_invitation_path(conn, :show, sign_token("valid"))) assert_not_authenticated_redirect(conn) end @@ -102,7 +130,7 @@ defmodule PowInvitation.Phoenix.InvitationControllerTest do conn = conn |> Pow.Plug.assign_current_user(@user, []) - |> get(Routes.pow_invitation_invitation_path(conn, :show, "valid")) + |> get(Routes.pow_invitation_invitation_path(conn, :show, sign_token("valid"))) assert html = html_response(conn, 200) assert html =~ @url_regex @@ -114,21 +142,28 @@ defmodule PowInvitation.Phoenix.InvitationControllerTest do conn = conn |> Pow.Plug.assign_current_user(@user, []) - |> get(Routes.pow_invitation_invitation_path(conn, :edit, "valid")) + |> get(Routes.pow_invitation_invitation_path(conn, :edit, sign_token("valid"))) assert_authenticated_redirect(conn) end - test "invalid invitation token", %{conn: conn} do - conn = get conn, Routes.pow_invitation_invitation_path(conn, :edit, "invalid") + test "with invalid invitation token", %{conn: conn} do + conn = get conn, Routes.pow_invitation_invitation_path(conn, :edit, sign_token("invalid")) assert redirected_to(conn) == Routes.pow_session_path(conn, :new) assert get_flash(conn, :error) == "The invitation doesn't exist." end - test "shows", %{conn: conn} do + test "with unsigned invitation token", %{conn: conn} do conn = get conn, Routes.pow_invitation_invitation_path(conn, :edit, "valid") + assert redirected_to(conn) == Routes.pow_session_path(conn, :new) + assert get_flash(conn, :error) == "The invitation doesn't exist." + end + + test "shows", %{conn: conn} do + conn = get conn, Routes.pow_invitation_invitation_path(conn, :edit, sign_token("valid")) + assert Conn.get_resp_header(conn, "cache-control") == ["no-cache, no-store, must-revalidate"] assert html = html_response(conn, 200) @@ -136,43 +171,60 @@ defmodule PowInvitation.Phoenix.InvitationControllerTest do assert html =~ "" assert html =~ "" assert html =~ "" - assert html =~ "" - assert html =~ "" + assert html =~ "" + assert html =~ "" assert html =~ "" end end describe "update/2" do @password "password1234" - @valid_params %{"user" => %{"email" => "test@example.com", "password" => @password, "confirm_password" => @password}} - @invalid_params %{"user" => %{"email" => "invalid", "password" => @password, "confirm_password" => "invalid"}} + @valid_params %{"user" => %{"email" => "test@example.com", "password" => @password, "password_confirmation" => @password}} + @valid_params_email_taken %{"user" => %{"email" => "taken@example.com", "password" => @password, "password_confirmation" => @password}} + @invalid_params %{"user" => %{"email" => "invalid", "password" => @password, "password_confirmation" => "invalid"}} test "already signed in", %{conn: conn} do conn = conn |> Pow.Plug.assign_current_user(@user, []) - |> put(Routes.pow_invitation_invitation_path(conn, :update, "valid", @valid_params)) + |> put(Routes.pow_invitation_invitation_path(conn, :update, sign_token("valid"), @valid_params)) assert_authenticated_redirect(conn) end - test "invalid invitation", %{conn: conn} do - conn = put conn, Routes.pow_invitation_invitation_path(conn, :update, "invalid", @valid_params) + test "with invalid invitation", %{conn: conn} do + conn = put conn, Routes.pow_invitation_invitation_path(conn, :update, sign_token("invalid"), @valid_params) assert redirected_to(conn) == Routes.pow_session_path(conn, :new) assert get_flash(conn, :error) == "The invitation doesn't exist." end - test "with valid params", %{conn: conn} do + test "with unsigned invitation token", %{conn: conn} do conn = put conn, Routes.pow_invitation_invitation_path(conn, :update, "valid", @valid_params) + assert redirected_to(conn) == Routes.pow_session_path(conn, :new) + assert get_flash(conn, :error) == "The invitation doesn't exist." + end + + test "with valid params", %{conn: conn} do + conn = put conn, Routes.pow_invitation_invitation_path(conn, :update, sign_token("valid"), @valid_params) + assert redirected_to(conn) == "/after_registration" assert get_flash(conn, :info) == "user_created" assert conn.private[:plug_session]["auth"] end + test "with valid params and email taken", %{conn: conn} do + conn = put conn, Routes.pow_invitation_invitation_path(conn, :update, sign_token("valid"), @valid_params_email_taken) + + assert html = html_response(conn, 200) + assert html =~ "" + assert html =~ "" + assert html =~ "has already been taken" + end + test "with invalid params", %{conn: conn} do - conn = put conn, Routes.pow_invitation_invitation_path(conn, :update, "valid", @invalid_params) + conn = put conn, Routes.pow_invitation_invitation_path(conn, :update, sign_token("valid"), @invalid_params) assert html = html_response(conn, 200) assert html =~ "" @@ -180,8 +232,14 @@ defmodule PowInvitation.Phoenix.InvitationControllerTest do assert html =~ "has invalid format" assert html =~ "" assert html =~ "" - assert html =~ "not same as password" + assert html =~ "does not match confirmation" refute conn.private[:plug_session]["auth"] end end + + defp sign_token(token) do + %Conn{} + |> PowPlug.put_config(Test.pow_config()) + |> Plug.sign_invitation_token(%{invitation_token: token}) + end end diff --git a/test/extensions/persistent_session/phoenix/controllers/controller_callbacks_test.exs b/test/extensions/persistent_session/phoenix/controllers/controller_callbacks_test.exs index 4e8e33fd..4bc207eb 100644 --- a/test/extensions/persistent_session/phoenix/controllers/controller_callbacks_test.exs +++ b/test/extensions/persistent_session/phoenix/controllers/controller_callbacks_test.exs @@ -1,19 +1,20 @@ defmodule PowPersistentSession.Phoenix.ControllerCallbacksTest do use PowPersistentSession.TestWeb.Phoenix.ConnCase - alias PowPersistentSession.Store.PersistentSessionCache + alias Pow.Plug + alias PowPersistentSession.{Plug.Cookie, Store.PersistentSessionCache} @valid_params %{"email" => "test@example.com", "password" => "secret1234"} @max_age Integer.floor_div(:timer.hours(30) * 24, 1000) - @cookie_key "persistent_session_cookie" + @cookie_key "persistent_session" describe "Pow.Phoenix.SessionController.create/2" do test "generates cookie", %{conn: conn, ets: ets} do conn = post conn, Routes.pow_session_path(conn, :create, %{"user" => @valid_params}) - assert session_fingerprint = conn.private[:pow_session_metadata][:fingerprint] + assert session_fingerprint = conn.private[:pow_session_metadata][:fingerprint] assert %{max_age: @max_age, path: "/", value: id} = conn.resp_cookies[@cookie_key] - assert PersistentSessionCache.get([backend: ets], id) == {[id: 1], session_metadata: [fingerprint: session_fingerprint]} + assert get_from_cache(conn, id, backend: ets) == {[id: 1], session_metadata: [fingerprint: session_fingerprint]} end test "with persistent_session param set to false", %{conn: conn} do @@ -32,13 +33,21 @@ defmodule PowPersistentSession.Phoenix.ControllerCallbacksTest do end describe "Pow.Phoenix.SessionController.delete/2" do - test "generates cookie", %{conn: conn, ets: ets} do + test "expires cookie", %{conn: conn, ets: ets} do conn = post conn, Routes.pow_session_path(conn, :create, %{"user" => @valid_params}) - %{value: id} = conn.resp_cookies[@cookie_key] + + assert %{value: id} = conn.resp_cookies[@cookie_key] + conn = delete conn, Routes.pow_session_path(conn, :delete) - assert %{max_age: -1, path: "/", value: ""} = conn.resp_cookies[@cookie_key] - assert PersistentSessionCache.get([backend: ets], id) == :not_found + assert conn.resp_cookies[@cookie_key] == %{max_age: 0, path: "/", universal_time: {{1970, 1, 1}, {0, 0, 0}}} + assert get_from_cache(conn, id, backend: ets) == :not_found end end + + defp get_from_cache(conn, token, config) do + assert {:ok, token} = Plug.verify_token(conn, Atom.to_string(Cookie), token) + + PersistentSessionCache.get(config, token) + end end diff --git a/test/extensions/persistent_session/plug/cookie_test.exs b/test/extensions/persistent_session/plug/cookie_test.exs index 30129c08..68727be8 100644 --- a/test/extensions/persistent_session/plug/cookie_test.exs +++ b/test/extensions/persistent_session/plug/cookie_test.exs @@ -2,215 +2,315 @@ defmodule PowPersistentSession.Plug.CookieTest do use ExUnit.Case doctest PowPersistentSession.Plug.Cookie - alias Plug.Conn + alias Plug.{Conn, ProcessStore, Test} + alias Plug.Session, as: PlugSession alias Pow.{Plug, Plug.Session} - alias Pow.Test.ConnHelpers alias PowPersistentSession.{Plug.Cookie, Store.PersistentSessionCache} alias PowPersistentSession.Test.Users.User + alias Pow.Test.EtsCacheMock + @cookie_key "persistent_session" @max_age Integer.floor_div(:timer.hours(24) * 30, 1000) + @custom_cookie_opts [domain: "domain.com", max_age: 1, path: "/path", http_only: false, secure: true, extra: "SameSite=Lax"] setup do - config = PowPersistentSession.Test.pow_config() - ets = Pow.Config.get(config, :cache_store_backend, nil) - - ets.init() - - conn = - :get - |> ConnHelpers.conn("/") - |> ConnHelpers.init_session() - |> Session.call(config) - - {:ok, %{conn: conn, config: config, ets: ets}} - end + EtsCacheMock.init() - defp store_persistent(conn, ets, id, value, cookie_key \\ "persistent_session_cookie") do - PersistentSessionCache.put([backend: ets], id, value) - persistent_cookie(conn, cookie_key, id) - end + config = PowPersistentSession.Test.pow_config() + conn = conn_with_session_plug(config) - defp persistent_cookie(conn, cookie_key, id) do - cookies = Map.new([{cookie_key, id}]) - %{conn | req_cookies: cookies, cookies: cookies} + {:ok, conn: conn, config: config, ets: EtsCacheMock} end test "call/2 sets pow_persistent_session plug in conn", %{conn: conn, config: config} do - conn = Cookie.call(conn, Cookie.init([])) + conn = run_plug(conn) expected_config = [mod: Session, plug: Session] ++ config assert {Cookie, ^expected_config} = conn.private[:pow_persistent_session] - refute conn.resp_cookies["persistent_session_cookie"] + refute conn.resp_cookies[@cookie_key] end test "call/2 assigns user from cookie", %{conn: conn, ets: ets} do user = %User{id: 1} - id = "test" + id = store_in_cache(conn, "test", {[id: user.id], []}, backend: ets) conn = conn - |> store_persistent(ets, id, {[id: user.id], []}) - |> Cookie.call(Cookie.init([])) + |> persistent_cookie(@cookie_key, id) + |> run_plug() assert Plug.current_user(conn) == user - assert %{value: new_id, max_age: @max_age, path: "/"} = conn.resp_cookies["persistent_session_cookie"] + assert %{value: new_id, max_age: @max_age, path: "/"} = conn.resp_cookies[@cookie_key] refute new_id == id - assert PersistentSessionCache.get([backend: ets], id) == :not_found - assert PersistentSessionCache.get([backend: ets], new_id) == {[id: 1], []} + assert get_from_cache(conn, id, backend: ets) == :not_found + assert get_from_cache(conn, new_id, backend: ets) == {[id: 1], []} + end + + defmodule PersistentSessionCacheWaitDelete do + alias PowPersistentSession.Store.PersistentSessionCache + + @timeout :timer.seconds(5) + + defdelegate put(config, session_id, record_or_records), to: PersistentSessionCache + + defdelegate get(config, session_id), to: PersistentSessionCache + + def delete(config, session_id)do + send(self(), {__MODULE__, :wait}) + + receive do + {__MODULE__, :commit} -> :ok + after + @timeout -> raise "Timeout reached" + end + + PersistentSessionCache.delete(config, session_id) + end + end + + test "call/2 assigns user from cookie and doesn't expire with simultanous request", %{conn: conn, ets: ets} do + :ets.delete(EtsCacheMock) + :ets.new(EtsCacheMock, [:ordered_set, :public, :named_table]) + + user = %User{id: 1} + id = store_in_cache(conn, "test", {[id: user.id], []}, backend: ets) + conn = persistent_cookie(conn, @cookie_key, id) + config = [persistent_session_store: {PersistentSessionCacheWaitDelete, ttl: :timer.hours(24) * 30, namespace: "persistent_session"}] + + task_1 = + fn -> run_plug(conn, config) end + |> Task.async() + |> wait_till_ready() + + conn_2 = + fn -> run_plug(conn, config) end + |> Task.async() + |> continue_work() + |> Task.await() + + conn_1 = + task_1 + |> continue_work() + |> Task.await() + + assert Plug.current_user(conn_1) == user + assert %{value: _id, max_age: @max_age, path: "/"} = conn_1.resp_cookies[@cookie_key] + + assert Plug.current_user(conn_2) == user + refute conn_2.resp_cookies[@cookie_key] + end + + defp wait_till_ready(%{pid: tracking_pid} = task) do + :erlang.trace(tracking_pid, true, [:receive]) + assert_receive {:trace, ^tracking_pid, :receive, {PersistentSessionCacheWaitDelete, :wait}} + + task + end + + defp continue_work(%{pid: tracking_pid} = task) do + send(tracking_pid, {PersistentSessionCacheWaitDelete, :commit}) + + task end test "call/2 assigns user from cookie passing fingerprint to the session metadata", %{conn: conn, ets: ets} do user = %User{id: 1} - id = "test" + id = store_in_cache(conn, "test", {[id: user.id], session_metadata: [fingerprint: "fingerprint"]}, backend: ets) conn = conn - |> store_persistent(ets, id, {[id: user.id], session_metadata: [fingerprint: "fingerprint"]}) - |> Cookie.call(Cookie.init([])) + |> persistent_cookie(@cookie_key, id) + |> run_plug() assert Plug.current_user(conn) == user - assert %{value: new_id, max_age: @max_age, path: "/"} = conn.resp_cookies["persistent_session_cookie"] + assert %{value: new_id, max_age: @max_age, path: "/"} = conn.resp_cookies[@cookie_key] refute new_id == id - assert PersistentSessionCache.get([backend: ets], id) == :not_found - assert PersistentSessionCache.get([backend: ets], new_id) == {[id: 1], session_metadata: [fingerprint: "fingerprint"]} + assert get_from_cache(conn, id, backend: ets) == :not_found + assert get_from_cache(conn, new_id, backend: ets) == {[id: 1], session_metadata: [fingerprint: "fingerprint"]} assert conn.private[:pow_session_metadata][:fingerprint] == "fingerprint" end test "call/2 assigns user from cookie passing custom metadata to session metadata", %{conn: conn, ets: ets} do user = %User{id: 1} + id = store_in_cache(conn, "test", {[id: user.id], session_metadata: [a: 1, b: 2, fingerprint: "fingerprint"]}, backend: ets) conn = conn - |> store_persistent(ets, "test", {[id: user.id], session_metadata: [a: 1, b: 2, fingerprint: "fingerprint"]}) + |> persistent_cookie(@cookie_key, id) |> Conn.put_private(:pow_persistent_session_metadata, session_metadata: [a: 2]) |> Conn.put_private(:pow_session_metadata, [a: 3, fingerprint: "new_fingerprint"]) - |> Cookie.call(Cookie.init([])) + |> run_plug() assert Plug.current_user(conn) == user - assert %{value: id, max_age: @max_age, path: "/"} = conn.resp_cookies["persistent_session_cookie"] - assert PersistentSessionCache.get([backend: ets], id) == {[id: 1], session_metadata: [fingerprint: "new_fingerprint", b: 2, a: 2]} + assert %{value: id, max_age: @max_age, path: "/"} = conn.resp_cookies[@cookie_key] + assert get_from_cache(conn, id, backend: ets) == {[id: 1], session_metadata: [fingerprint: "new_fingerprint", b: 2, a: 2]} assert [inserted_at: _, b: 2, a: 3, fingerprint: "new_fingerprint"] = conn.private[:pow_session_metadata] end test "call/2 assigns user from cookie with prepended `:otp_app`", %{config: config, ets: ets} do user = %User{id: 1} + conn = conn_with_session_plug(config ++ [otp_app: :test_app]) + id = store_in_cache(conn, "test_app_test", {[id: user.id], []}, backend: ets) conn = - :get - |> ConnHelpers.conn("/") - |> ConnHelpers.init_session() - |> Session.call(config ++ [otp_app: :test_app]) - |> store_persistent(ets, "test_app_test", {[id: user.id], []}, "test_app_persistent_session_cookie") - |> Cookie.call(Cookie.init(config)) + conn + |> persistent_cookie("test_app_" <> @cookie_key, id) + |> run_plug(config) assert Plug.current_user(conn) == user - assert %{value: new_id, max_age: @max_age, path: "/"} = conn.resp_cookies["test_app_persistent_session_cookie"] - assert String.starts_with?(new_id, "test_app") - assert PersistentSessionCache.get([backend: ets], new_id) == {[id: 1], []} + assert %{value: new_id, max_age: @max_age, path: "/"} = conn.resp_cookies["test_app_" <> @cookie_key] + assert {:ok, decoded_id} = Plug.verify_token(conn, Atom.to_string(Cookie), new_id) + assert String.starts_with?(decoded_id, "test_app") + assert get_from_cache(conn, new_id, backend: ets) == {[id: 1], []} end test "call/2 when user already assigned", %{conn: conn, ets: ets} do user = %User{id: 1} - id = "test" + id = store_in_cache(conn, "test", {[id: user.id], []}, backend: ets) conn = conn - |> store_persistent(ets, id, {[id: user.id], []}) + |> persistent_cookie(@cookie_key, id) |> Plug.assign_current_user(:user, []) - |> Cookie.call(Cookie.init([])) + |> run_plug() - assert %{value: new_id, max_age: @max_age, path: "/"} = conn.resp_cookies["persistent_session_cookie"] - assert new_id == id - assert PersistentSessionCache.get([backend: ets], id) == {[id: 1], []} + refute conn.resp_cookies[@cookie_key] + assert get_from_cache(conn, id, backend: ets) == {[id: 1], []} end test "call/2 when user doesn't exist in database", %{conn: conn, ets: ets} do user = %User{id: -1} - id = "test" + id = store_in_cache(conn, "test", {[id: user.id], []}, backend: ets) + conn = + conn + |> persistent_cookie(@cookie_key, id) + |> run_plug() + + refute Plug.current_user(conn) + refute conn.resp_cookies[@cookie_key] + assert get_from_cache(conn, id, backend: ets) == :not_found + end + + test "call/2 with unsigned token", %{conn: conn, ets: ets} do + user = %User{id: 1} + id = "test" + store_in_cache(conn, id, {[id: user.id], []}, backend: ets) conn = conn - |> store_persistent(ets, id, {[id: user.id], []}) - |> Cookie.call(Cookie.init([])) + |> persistent_cookie(@cookie_key, id) + |> run_plug() refute Plug.current_user(conn) - assert conn.resp_cookies["persistent_session_cookie"] == %{max_age: -1, path: "/", value: ""} - assert PersistentSessionCache.get([backend: ets], id) == :not_found + refute conn.resp_cookies[@cookie_key] + assert PersistentSessionCache.get([backend: ets], id) == {[id: user.id], []} end test "call/2 when persistent session cache doesn't have credentials", %{conn: conn} do conn = conn - |> persistent_cookie("persistent_session_cookie", "test") - |> Cookie.call(Cookie.init([])) + |> persistent_cookie(@cookie_key, "test") + |> run_plug() refute Plug.current_user(conn) - assert conn.resp_cookies["persistent_session_cookie"] == %{max_age: -1, path: "/", value: ""} + refute conn.resp_cookies[@cookie_key] end test "call/2 with invalid stored clauses", %{conn: conn, ets: ets} do user = %User{id: 1} - id = "test" + id = store_in_cache(conn, "test", {[id: user.id, uid: 2], []}, backend: ets) assert_raise RuntimeError, "Invalid get_by clauses stored: [id: 1, uid: 2]", fn -> conn - |> store_persistent(ets, id, {[id: user.id, uid: 2], []}) - |> Cookie.call(Cookie.init([])) + |> persistent_cookie(@cookie_key, id) + |> run_plug() end end # TODO: Remove by 1.1.0 test "call/2 is backwards-compatible with just user fetch clause", %{conn: conn, ets: ets} do user = %User{id: 1} - id = "test" + id = store_in_cache(conn, "test", [id: user.id], backend: ets) conn = conn - |> store_persistent(ets, id, id: user.id) - |> Cookie.call(Cookie.init([])) + |> persistent_cookie(@cookie_key, id) + |> run_plug() assert Plug.current_user(conn) == user - assert %{value: new_id, max_age: @max_age, path: "/"} = conn.resp_cookies["persistent_session_cookie"] + assert %{value: new_id, max_age: @max_age, path: "/"} = conn.resp_cookies[@cookie_key] refute new_id == id - assert PersistentSessionCache.get([backend: ets], id) == :not_found - assert PersistentSessionCache.get([backend: ets], new_id) == {[id: 1], []} + assert get_from_cache(conn, id, backend: ets) == :not_found + assert get_from_cache(conn, new_id, backend: ets) == {[id: 1], []} end # TODO: Remove by 1.1.0 test "call/2 is backwards-compatible with `:session_fingerprint` metadata", %{conn: conn, ets: ets} do user = %User{id: 1} - id = "test" + id = store_in_cache(conn, "test", {[id: user.id], session_fingerprint: "fingerprint"}, backend: ets) conn = conn - |> store_persistent(ets, id, {[id: user.id], session_fingerprint: "fingerprint"}) - |> Cookie.call(Cookie.init([])) + |> persistent_cookie(@cookie_key, id) + |> run_plug() assert Plug.current_user(conn) == user - assert %{value: new_id, max_age: @max_age, path: "/"} = conn.resp_cookies["persistent_session_cookie"] + assert %{value: new_id, max_age: @max_age, path: "/"} = conn.resp_cookies[@cookie_key] refute new_id == id - assert PersistentSessionCache.get([backend: ets], id) == :not_found - assert PersistentSessionCache.get([backend: ets], new_id) == {[id: 1], session_metadata: [fingerprint: "fingerprint"]} + assert get_from_cache(conn, id, backend: ets) == :not_found + assert get_from_cache(conn, new_id, backend: ets) == {[id: 1], session_metadata: [fingerprint: "fingerprint"]} assert conn.private[:pow_session_metadata][:fingerprint] == "fingerprint" end test "create/3 with custom TTL", %{conn: conn, config: config} do config = Keyword.put(config, :persistent_session_ttl, 1000) - conn = Cookie.create(conn, %User{id: 1}, config) + conn = + conn + |> init_plug(config) + |> run_create(%User{id: 1}, config) assert_received {:ets, :put, [{_key, _value} | _rest], config} assert config[:ttl] == 1000 - assert %{max_age: 1, path: "/"} = conn.resp_cookies["persistent_session_cookie"] + assert %{max_age: 1, path: "/"} = conn.resp_cookies[@cookie_key] + end + + test "create/3 handles clause error", %{conn: conn, config: config} do + assert_raise RuntimeError, "Primary key value for key `:id` in #{inspect User} can't be `nil`", fn -> + conn + |> init_plug(config) + |> run_create(%User{id: nil}, config) + end + end + + test "create/3 with custom cookie options", %{conn: conn, config: config} do + config = Keyword.put(config, :persistent_session_cookie_opts, @custom_cookie_opts) + conn = + conn + |> init_plug(config) + |> run_create(%User{id: 1}, config) + + assert %{ + domain: "domain.com", + extra: "SameSite=Lax", + http_only: false, + max_age: 1, + path: "/path", + secure: true + } = conn.resp_cookies[@cookie_key] end test "create/3 deletes previous persistent session", %{conn: conn, config: config, ets: ets} do - conn = store_persistent(conn, ets, "previous_persistent_session", {[id: 1], []}) + id = store_in_cache(conn, "previous_persistent_session", {[id: 1], []}, backend: ets) + conn = persistent_cookie(conn, @cookie_key, id) - assert PersistentSessionCache.get([backend: ets], "previous_persistent_session") == {[id: 1], []} + assert get_from_cache(conn, id, backend: ets) == {[id: 1], []} - Cookie.create(conn, %User{id: 1}, config) + conn + |> init_plug(config) + |> run_create(%User{id: 1}, config) assert_received {:ets, :put, [{_key, {[id: 1], []}}], _config} - assert PersistentSessionCache.get([backend: ets], "previous_persistent_session") == :not_found + assert get_from_cache(conn, id, backend: ets) == :not_found end test "create/3 with `[:pow_session_metadata][:fingerprint]` defined in conn.private", %{conn: conn, config: config} do conn |> Conn.put_private(:pow_session_metadata, fingerprint: "fingerprint") - |> Cookie.create(%User{id: 1}, config) + |> init_plug(config) + |> run_create(%User{id: 1}, config) assert_received {:ets, :put, [{_key, {[id: 1], session_metadata: [fingerprint: "fingerprint"]}}], _config} end @@ -218,8 +318,80 @@ defmodule PowPersistentSession.Plug.CookieTest do test "create/3 with custom metadata", %{conn: conn, config: config} do conn |> Conn.put_private(:pow_persistent_session_metadata, session_metadata: [a: 1]) - |> Cookie.create(%User{id: 1}, config) + |> init_plug(config) + |> run_create(%User{id: 1}, config) assert_received {:ets, :put, [{_key, {[id: 1], session_metadata: [a: 1]}}], _config} end + + test "delete/3", %{conn: conn, ets: ets, config: config} do + id = store_in_cache(conn, "test", {[id: 1], []}, backend: ets) + conn = + conn + |> persistent_cookie(@cookie_key, id) + |> init_plug(config) + |> run_delete(config) + + assert conn.resp_cookies[@cookie_key] == %{max_age: 0, path: "/", universal_time: {{1970, 1, 1}, {0, 0, 0}}} + assert get_from_cache(conn, id, backend: ets) == :not_found + end + + test "delete/3 with custom cookie options", %{conn: conn, ets: ets, config: config} do + id = store_in_cache(conn, "test", {[id: 1], []}, backend: ets) + config = Keyword.put(config, :persistent_session_cookie_opts, @custom_cookie_opts) + conn = + conn + |> persistent_cookie(@cookie_key, id) + |> init_plug(config) + |> run_delete(config) + + assert conn.resp_cookies[@cookie_key] == %{max_age: 0, universal_time: {{1970, 1, 1}, {0, 0, 0}}, path: "/path", domain: "domain.com", extra: "SameSite=Lax", http_only: false, secure: true} + assert get_from_cache(conn, id, backend: ets) == :not_found + end + + defp conn_with_session_plug(config) do + :get + |> Test.conn("/") + |> PlugSession.call(PlugSession.init(store: ProcessStore, key: "foobar")) + |> Session.call(Session.init(config)) + end + + defp persistent_cookie(conn, cookie_key, id) do + cookies = Map.new([{cookie_key, id}]) + %{conn | req_cookies: cookies, cookies: cookies} + end + + defp run_plug(conn, config \\ []) do + conn + |> init_plug(config) + |> Conn.send_resp(200, "") + end + + defp init_plug(conn, config) do + Cookie.call(conn, Cookie.init(config)) + end + + defp run_create(conn, user, config) do + conn + |> Cookie.create(user, config) + |> Conn.send_resp(200, "") + end + + defp run_delete(conn, config) do + conn + |> Cookie.delete(config) + |> Conn.send_resp(200, "") + end + + defp store_in_cache(conn, token, value, config) do + PersistentSessionCache.put(config, token, value) + + Plug.sign_token(conn, Atom.to_string(Cookie), token) + end + + defp get_from_cache(conn, token, config) do + assert {:ok, token} = Plug.verify_token(conn, Atom.to_string(Cookie), token) + + PersistentSessionCache.get(config, token) + end end diff --git a/test/extensions/reset_password/ecto/context_test.exs b/test/extensions/reset_password/ecto/context_test.exs index aa304f18..76e5c5a6 100644 --- a/test/extensions/reset_password/ecto/context_test.exs +++ b/test/extensions/reset_password/ecto/context_test.exs @@ -10,6 +10,10 @@ defmodule PowResetPassword.Ecto.ContextTest do @password "secret1234" @user %User{id: 1, password_hash: :set} + defmodule CustomUsers do + def get_by([email: :test]), do: %User{email: :ok} + end + describe "get_by_email/2" do test "email is case insensitive when it's the user id field" do assert Context.get_by_email("test@example.com", @config) @@ -19,11 +23,15 @@ defmodule PowResetPassword.Ecto.ContextTest do test "email is trimmed when it's the user id field" do assert Context.get_by_email(" test@example.com ", @config) end + + test "with `:users_context`" do + assert %User{email: :ok} = Context.get_by_email(:test, @config ++ [users_context: CustomUsers]) + end end describe "update_password/2" do test "updates with compiled password hash methods" do - assert {:ok, user} = Context.update_password(@user, %{password: @password, confirm_password: @password}, @config) + assert {:ok, user} = Context.update_password(@user, %{password: @password, password_confirmation: @password}, @config) assert Password.pbkdf2_verify(@password, user.password_hash) end @@ -31,7 +39,7 @@ defmodule PowResetPassword.Ecto.ContextTest do assert {:error, changeset} = Context.update_password(@user, %{}, @config) assert changeset.errors[:password] == {"can't be blank", [validation: :required]} - assert {:error, changeset} = Context.update_password(@user, %{password: "", confirm_password: ""}, @config) + assert {:error, changeset} = Context.update_password(@user, %{password: "", password_confirmation: ""}, @config) assert changeset.errors[:password] == {"can't be blank", [validation: :required]} end end diff --git a/test/extensions/reset_password/phoenix/controllers/reset_password_controller_test.exs b/test/extensions/reset_password/phoenix/controllers/reset_password_controller_test.exs index 9a7248db..c1d38ffe 100644 --- a/test/extensions/reset_password/phoenix/controllers/reset_password_controller_test.exs +++ b/test/extensions/reset_password/phoenix/controllers/reset_password_controller_test.exs @@ -1,8 +1,10 @@ defmodule PowResetPassword.Phoenix.ResetPasswordControllerTest do use PowResetPassword.TestWeb.Phoenix.ConnCase - alias PowResetPassword.Store.ResetTokenCache - alias PowResetPassword.Test.Users.User + alias Plug.Conn + alias Pow.Plug, as: PowPlug + alias PowResetPassword.{Plug, Store.ResetTokenCache} + alias PowResetPassword.{Test, Test.Users.User} @user %User{id: 1} @password "secret1234" @@ -19,7 +21,7 @@ defmodule PowResetPassword.Phoenix.ResetPasswordControllerTest do test "already signed in", %{conn: conn} do conn = conn - |> Pow.Plug.assign_current_user(@user, []) + |> PowPlug.assign_current_user(@user, []) |> get(Routes.pow_reset_password_reset_password_path(conn, :new)) assert_authenticated_redirect(conn) @@ -33,143 +35,172 @@ defmodule PowResetPassword.Phoenix.ResetPasswordControllerTest do test "already signed in", %{conn: conn} do conn = conn - |> Pow.Plug.assign_current_user(@user, []) + |> PowPlug.assign_current_user(@user, []) |> get(Routes.pow_reset_password_reset_password_path(conn, :new)) assert_authenticated_redirect(conn) end test "with valid params", %{conn: conn, ets: ets} do - conn = post conn, Routes.pow_reset_password_reset_password_path(conn, :create, @valid_params) - [{token, _}] = ResetTokenCache.all([backend: ets], [:_]) + conn = post conn, Routes.pow_reset_password_reset_password_path(conn, :create, @valid_params) + [{[token], _}] = ResetTokenCache.all([backend: ets], [:_]) assert_received {:mail_mock, mail} assert mail.subject == "Reset password link" - assert mail.text =~ "\nhttp://localhost/reset-password/#{token}\n" - assert mail.html =~ "" + assert mail.text =~ "\nhttp://localhost/reset-password/#{sign_token(conn, token)}\n" + assert mail.html =~ "" assert redirected_to(conn) == Routes.pow_session_path(conn, :new) - assert get_flash(conn, :info) == "An email with reset instructions has been sent to you. Please check your inbox." + assert get_flash(conn, :info) == "If an account for the provided email exists, an email with reset instructions will be sent to you. Please check your inbox." end test "with invalid params", %{conn: conn} do conn = post conn, Routes.pow_reset_password_reset_password_path(conn, :create, @invalid_params) - assert html = html_response(conn, 200) - assert get_flash(conn, :error) == "No account exists for the provided email. Please try again." - assert html =~ "" + assert redirected_to(conn) == Routes.pow_session_path(conn, :new) + assert get_flash(conn, :info) == "If an account for the provided email exists, an email with reset instructions will be sent to you. Please check your inbox." end - end - alias PowResetPassword.NoRegistration.TestWeb.Phoenix.Endpoint, as: NoRegistrationEndpoint - describe "create/2 with no registration routes" do - test "with invalid params", %{conn: conn} do - conn = Phoenix.ConnTest.dispatch(conn, NoRegistrationEndpoint, :post, Routes.pow_reset_password_reset_password_path(conn, :create, @invalid_params)) + test "with invalid params and pow_prevent_user_enumeration: false", %{conn: conn} do + conn = + conn + |> Conn.put_private(:pow_prevent_user_enumeration, false) + |> post(Routes.pow_reset_password_reset_password_path(conn, :create, @invalid_params)) - assert redirected_to(conn) == Routes.pow_session_path(conn, :new) - assert get_flash(conn, :info) == "An email with reset instructions has been sent to you. Please check your inbox." + assert html = html_response(conn, 200) + assert get_flash(conn, :error) == "No account exists for the provided email. Please try again." + assert html =~ "" end end describe "edit/2" do - @valid_token "valid" - @invalid_token "invalid" + setup %{conn: conn} do + token = create_reset_token(conn, "test@example.com") - setup %{ets: ets} do - ResetTokenCache.put([backend: ets], @valid_token, @user) - - :ok + {:ok, conn: conn, token: token} end - test "already signed in", %{conn: conn} do + test "already signed in", %{conn: conn, token: token} do conn = conn - |> Pow.Plug.assign_current_user(@user, []) - |> get(Routes.pow_reset_password_reset_password_path(conn, :edit, @valid_token)) + |> PowPlug.assign_current_user(@user, []) + |> get(Routes.pow_reset_password_reset_password_path(conn, :edit, token)) assert_authenticated_redirect(conn) end - test "invalid token", %{conn: conn} do - conn = get conn, Routes.pow_reset_password_reset_password_path(conn, :edit, @invalid_token) + test "with invalid token", %{conn: conn} do + conn = get conn, Routes.pow_reset_password_reset_password_path(conn, :edit, "invalid") assert redirected_to(conn) == Routes.pow_reset_password_reset_password_path(conn, :new) assert get_flash(conn, :error) == "The reset token has expired." end - test "valid token", %{conn: conn} do - conn = get conn, Routes.pow_reset_password_reset_password_path(conn, :edit, @valid_token) + test "with unsigned token", %{conn: conn, token: token} do + token = decode_token(conn, token) + conn = get conn, Routes.pow_reset_password_reset_password_path(conn, :edit, token) + + assert redirected_to(conn) == Routes.pow_reset_password_reset_password_path(conn, :new) + assert get_flash(conn, :error) == "The reset token has expired." + end + + test "valid token", %{conn: conn, token: token} do + conn = get conn, Routes.pow_reset_password_reset_password_path(conn, :edit, token) assert html = html_response(conn, 200) assert html =~ "" assert html =~ "" - assert html =~ "" - assert html =~ "" + assert html =~ "" + assert html =~ "" assert html =~ "Sign in" end end describe "update/2" do - @valid_token "valid" - @invalid_token "invalid" - - @valid_params %{"user" => %{"password" => @password, "confirm_password" => @password}} - @invalid_params %{"user" => %{"password" => @password, "confirm_password" => "invalid"}} + @valid_params %{"user" => %{"password" => @password, "password_confirmation" => @password}} + @invalid_params %{"user" => %{"password" => @password, "password_confirmation" => "invalid"}} - setup %{ets: ets} do - ResetTokenCache.put([backend: ets], @valid_token, @user) + setup %{conn: conn} do + token = create_reset_token(conn, "test@example.com") - :ok + {:ok, conn: conn, token: token} end - test "already signed in", %{conn: conn} do + test "already signed in", %{conn: conn, token: token} do conn = conn - |> Pow.Plug.assign_current_user(@user, []) - |> put(Routes.pow_reset_password_reset_password_path(conn, :update, @valid_token, @valid_params)) + |> PowPlug.assign_current_user(@user, []) + |> put(Routes.pow_reset_password_reset_password_path(conn, :update, token, @valid_params)) assert_authenticated_redirect(conn) end - test "invalid token", %{conn: conn} do - conn = put conn, Routes.pow_reset_password_reset_password_path(conn, :update, @invalid_token, @valid_params) + test "with invalid token", %{conn: conn} do + conn = put conn, Routes.pow_reset_password_reset_password_path(conn, :update, "invalid", @valid_params) assert redirected_to(conn) == Routes.pow_reset_password_reset_password_path(conn, :new) assert get_flash(conn, :error) == "The reset token has expired." end - test "with valid params", %{conn: conn, ets: ets} do - conn = put conn, Routes.pow_reset_password_reset_password_path(conn, :update, @valid_token, @valid_params) + test "with unsigned token", %{conn: conn, token: token} do + token = decode_token(conn, token) + conn = put conn, Routes.pow_reset_password_reset_password_path(conn, :update, token, @valid_params) + + assert redirected_to(conn) == Routes.pow_reset_password_reset_password_path(conn, :new) + assert get_flash(conn, :error) == "The reset token has expired." + end + + test "with valid params", %{conn: conn, token: token} do + conn = put conn, Routes.pow_reset_password_reset_password_path(conn, :update, token, @valid_params) assert redirected_to(conn) == Routes.pow_session_path(conn, :new) assert get_flash(conn, :info) == "The password has been updated." - assert ResetTokenCache.get([backend: ets], @valid_token) == :not_found + assert {:error, _conn} = Plug.load_user_by_token(conn, token) end - test "with invalid params", %{conn: conn, ets: ets} do - conn = put conn, Routes.pow_reset_password_reset_password_path(conn, :update, @valid_token, @invalid_params) + test "with invalid params", %{conn: conn, token: token} do + conn = put conn, Routes.pow_reset_password_reset_password_path(conn, :update, token, @invalid_params) assert html = html_response(conn, 200) assert html =~ "" assert html =~ "" - assert html =~ "not same as password" + assert html =~ "does not match confirmation" changeset = conn.assigns[:changeset] - assert changeset.errors[:confirm_password] + assert changeset.errors[:password_confirmation] assert changeset.action == :update - assert ResetTokenCache.get([backend: ets], @valid_token) == @user + assert {:ok, _conn} = Plug.load_user_by_token(conn, token) end - test "with missing user", %{conn: conn, ets: ets} do - ResetTokenCache.put([backend: ets], @valid_token, %User{id: :missing}) - - conn = put conn, Routes.pow_reset_password_reset_password_path(conn, :update, @valid_token, @valid_params) + test "with missing user", %{conn: conn} do + token = create_reset_token(conn, "missing@example.com") + conn = put conn, Routes.pow_reset_password_reset_password_path(conn, :update, token, @valid_params) assert redirected_to(conn) == Routes.pow_session_path(conn, :new) assert get_flash(conn, :info) == "The password has been updated." end end + + defp create_reset_token(conn, email) do + {:ok, %{token: token}, _conn} = + conn + |> PowPlug.put_config(Test.pow_config()) + |> Plug.create_reset_token(%{"email" => email}) + + token + end + + defp decode_token(conn, token) do + {:ok, token} = + conn + |> PowPlug.put_config(Test.pow_config()) + |> PowPlug.verify_token(Atom.to_string(Plug), token) + + token + end + + defp sign_token(conn, token), do: PowPlug.sign_token(conn, Atom.to_string(Plug), token) end diff --git a/test/pow/ecto/context_test.exs b/test/pow/ecto/context_test.exs index 35b1b588..1485b9a1 100644 --- a/test/pow/ecto/context_test.exs +++ b/test/pow/ecto/context_test.exs @@ -33,6 +33,10 @@ defmodule Pow.Ecto.ContextTest do @config [repo: Repo, user: User] @username_config [repo: Repo, user: UsernameUser] + defmodule CustomUsers do + def get_by([email: :test]), do: %User{email: :ok, password_hash: Password.pbkdf2_hash("secret1234")} + end + describe "authenticate/2" do @password "secret1234" @valid_params %{"email" => "test@example.com", "password" => @password} @@ -96,6 +100,12 @@ defmodule Pow.Ecto.ContextTest do assert Users.authenticate(:test_macro) == :ok end + test "with `:users_context`" do + params = Map.put(@valid_params, "email", :test) + + assert %User{email: :ok} = Context.authenticate(params, @config ++ [users_context: CustomUsers]) + end + test "prevents timing attack" do config = [repo: Repo, user: TimingAttackUser] @@ -113,15 +123,14 @@ defmodule Pow.Ecto.ContextTest do "email" => "test@example.com", "custom" => "custom", "password" => @password, - "confirm_password" => @password + "password_confirmation" => @password } test "creates" do - assert {:error, _changeset} = Context.create(Map.delete(@valid_params, "confirm_password"), @config) + assert {:error, _changeset} = Context.create(Map.delete(@valid_params, "password_confirmation"), @config) assert {:ok, user} = Context.create(@valid_params, @config) assert user.custom == "custom" refute user.password - refute user.confirm_password end test "as `use Pow.Ecto.Context`" do @@ -138,7 +147,7 @@ defmodule Pow.Ecto.ContextTest do "email" => "new@example.com", "custom" => "custom", "password" => "new_#{@password}", - "confirm_password" => "new_#{@password}", + "password_confirmation" => "new_#{@password}", "current_password" => @password } @@ -155,7 +164,6 @@ defmodule Pow.Ecto.ContextTest do assert Context.authenticate(@valid_params, @config) == updated_user assert updated_user.custom == "custom" refute updated_user.password - refute updated_user.confirm_password refute updated_user.current_password end diff --git a/test/pow/ecto/schema/changeset_test.exs b/test/pow/ecto/schema/changeset_test.exs index 61ecc234..13c88a62 100644 --- a/test/pow/ecto/schema/changeset_test.exs +++ b/test/pow/ecto/schema/changeset_test.exs @@ -9,13 +9,13 @@ defmodule Pow.Ecto.Schema.ChangesetTest do @valid_params %{ "email" => "john.doe@example.com", "password" => "secret1234", - "confirm_password" => "secret1234", + "password_confirmation" => "secret1234", "custom" => "custom" } @valid_params_username %{ "username" => "john.doe", "password" => "secret1234", - "confirm_password" => "secret1234" + "password_confirmation" => "secret1234" } test "requires user id" do @@ -26,6 +26,10 @@ defmodule Pow.Ecto.Schema.ChangesetTest do refute changeset.valid? assert changeset.errors[:email] == {"can't be blank", [validation: :required]} + changeset = User.changeset(%User{email: "john.doe@example.com"}, %{email: nil}) + refute changeset.valid? + assert changeset.errors[:email] == {"can't be blank", [validation: :required]} + changeset = UsernameUser.changeset(%UsernameUser{}, Map.delete(@valid_params_username, "username")) refute changeset.valid? assert changeset.errors[:username] == {"can't be blank", [validation: :required]} @@ -37,7 +41,8 @@ defmodule Pow.Ecto.Schema.ChangesetTest do test "validates user id as email" do changeset = User.changeset(%User{}, Map.put(@valid_params, "email", "invalid")) refute changeset.valid? - assert changeset.errors[:email] == {"has invalid format", [validator: &Pow.Ecto.Schema.Changeset.validate_email/1, reason: "invalid format"]} + assert changeset.errors[:email] == {"has invalid format", [validation: :email_format, reason: "invalid format"]} + assert changeset.validations[:email] == {:email_format, &Pow.Ecto.Schema.Changeset.validate_email/1} changeset = User.changeset(%User{}, @valid_params) assert changeset.valid? @@ -48,7 +53,8 @@ defmodule Pow.Ecto.Schema.ChangesetTest do changeset = Changeset.user_id_field_changeset(%User{}, @valid_params, config) refute changeset.valid? - assert changeset.errors[:email] == {"has invalid format", [validator: config[:email_validator], reason: "custom message john.doe@example.com"]} + assert changeset.errors[:email] == {"has invalid format", [validation: :email_format, reason: "custom message john.doe@example.com"]} + assert changeset.validations[:email] == {:email_format, config[:email_validator]} config = [email_validator: fn _email -> :ok end] changeset = Changeset.user_id_field_changeset(%User{}, @valid_params, config) @@ -86,7 +92,7 @@ defmodule Pow.Ecto.Schema.ChangesetTest do %User{} |> User.changeset(@valid_params) |> Repo.insert() - assert changeset.errors[:email] == {"has already been taken", [constraint: :unique, constraint_name: "users_email_index"]} + assert changeset.errors[:email] == {"has already been taken", constraint: :unique, constraint_name: "users_email_index"} {:ok, _user} = %UsernameUser{} @@ -97,7 +103,7 @@ defmodule Pow.Ecto.Schema.ChangesetTest do %UsernameUser{} |> UsernameUser.changeset(@valid_params_username) |> Repo.insert() - assert changeset.errors[:username] == {"has already been taken", [constraint: :unique, constraint_name: "users_username_index"]} + assert changeset.errors[:username] == {"has already been taken", constraint: :unique, constraint_name: "users_username_index"} end test "requires password when password_hash is nil" do @@ -139,10 +145,10 @@ defmodule Pow.Ecto.Schema.ChangesetTest do end test "can confirm and hash password" do - changeset = User.changeset(%User{}, Map.put(@valid_params, "confirm_password", "invalid")) + changeset = User.changeset(%User{}, Map.put(@valid_params, "password_confirmation", "invalid")) refute changeset.valid? - assert changeset.errors[:confirm_password] == {"not same as password", []} + assert changeset.errors[:password_confirmation] == {"does not match confirmation", [validation: :confirmation]} refute changeset.changes[:password_hash] changeset = User.changeset(%User{}, @valid_params) @@ -152,6 +158,50 @@ defmodule Pow.Ecto.Schema.ChangesetTest do assert Password.pbkdf2_verify("secret1234", changeset.changes[:password_hash]) end + test "only validates password hash when no previous errors" do + params = Map.drop(@valid_params, ["email"]) + changeset = User.changeset(%User{}, params) + + refute changeset.valid? + refute changeset.errors[:password_hash] + + params = Map.drop(@valid_params, ["password"]) + changeset = User.changeset(%User{}, params) + + refute changeset.valid? + refute changeset.errors[:password_hash] + + params = Map.drop(@valid_params, ["password"]) + changeset = User.changeset(%User{}, params) + + refute changeset.valid? + refute changeset.errors[:password_hash] + + config = [password_hash_methods: {fn _ -> nil end, & &1}] + changeset = Changeset.password_changeset(%User{}, @valid_params, config) + + refute changeset.valid? + assert changeset.errors[:password_hash] == {"can't be blank", [validation: :required]} + end + + # TODO: Remove by 1.1.0 + test "handle `confirm_password` conversion" do + params = + @valid_params + |> Map.delete("password_confirmation") + |> Map.put("confirm_password", "secret1234") + changeset = User.changeset(%User{}, params) + + assert changeset.valid? + + params = Map.put(params, "confirm_password", "invalid") + changeset = User.changeset(%User{}, params) + + refute changeset.valid? + assert changeset.errors[:confirm_password] == {"does not match confirmation", [validation: :confirmation]} + refute changeset.errors[:password_confirmation] + end + test "can use custom password hash methods" do password_hash = &(&1 <> "123") password_verify = &(&1 == &2 <> "123") @@ -179,7 +229,8 @@ defmodule Pow.Ecto.Schema.ChangesetTest do changeset = User.changeset(user, Map.put(@valid_params, "current_password", "invalid")) refute changeset.valid? - assert changeset.errors[:current_password] == {"is invalid", []} + assert changeset.errors[:current_password] == {"is invalid", [validation: :verify_password]} + assert changeset.validations[:current_password] == {:verify_password, []} changeset = User.changeset(user, Map.put(@valid_params, "current_password", "secret1234")) assert changeset.valid? diff --git a/test/pow/ecto/schema_test.exs b/test/pow/ecto/schema_test.exs index c93e73eb..80f15a6e 100644 --- a/test/pow/ecto/schema_test.exs +++ b/test/pow/ecto/schema_test.exs @@ -68,4 +68,57 @@ defmodule Pow.Ecto.SchemaTest do assert %{on_replace: :mark_as_invalid} = OverrideAssocUser.__schema__(:association, :parent) assert %{on_delete: :delete_all} = OverrideAssocUser.__schema__(:association, :children) end + + module_raised_with = + try do + defmodule MissingAssocsUser do + use Ecto.Schema + use Pow.Ecto.Schema + + @pow_assocs {:belongs_to, :invited_by, __MODULE__, foreign_key: :user_id} + @pow_assocs {:has_many, :invited, __MODULE__} + + schema "users" do + timestamps() + end + end + rescue + e in Pow.Ecto.Schema.SchemaError -> e.message + end + + test "requires assocs defined" do + assert unquote(module_raised_with) == + """ + Please define the following association(s) in the schema for Pow.Ecto.SchemaTest.MissingAssocsUser: + + belongs_to :invited_by, Pow.Ecto.SchemaTest.MissingAssocsUser, [foreign_key: :user_id] + has_many :invited, Pow.Ecto.SchemaTest.MissingAssocsUser + """ + end + + module_raised_with = + try do + defmodule MissingFieldsUser do + use Ecto.Schema + use Pow.Ecto.Schema + + schema "users" do + timestamps() + end + end + rescue + e in Pow.Ecto.Schema.SchemaError -> e.message + end + + test "requires fields defined" do + assert unquote(module_raised_with) == + """ + Please define the following field(s) in the schema for Pow.Ecto.SchemaTest.MissingFieldsUser: + + field :email, :string, [null: false] + field :password_hash, :string + field :current_password, :string, [virtual: true] + field :password, :string, [virtual: true] + """ + end end diff --git a/test/pow/extension/ecto/schema_test.exs b/test/pow/extension/ecto/schema_test.exs index 425ad0d9..425aa344 100644 --- a/test/pow/extension/ecto/schema_test.exs +++ b/test/pow/extension/ecto/schema_test.exs @@ -110,7 +110,7 @@ defmodule Pow.Extension.Ecto.SchemaTest do @valid_params %{ "email" => "john.doe@example.com", "password" => @password, - "confirm_password" => @password, + "password_confirmation" => @password, "custom" => "valid" } diff --git a/test/pow/operations_test.exs b/test/pow/operations_test.exs index 3cc51fff..37c2c68b 100644 --- a/test/pow/operations_test.exs +++ b/test/pow/operations_test.exs @@ -1,4 +1,59 @@ defmodule Pow.OperationsTest do use ExUnit.Case doctest Pow.Operations + + alias Pow.Operations + + defmodule PrimaryFieldUser do + use Ecto.Schema + + schema "users" do + timestamps() + end + end + + defmodule NoPrimaryFieldUser do + use Ecto.Schema + + @primary_key false + schema "users" do + timestamps() + end + end + + defmodule CompositePrimaryFieldsUser do + use Ecto.Schema + + @primary_key false + schema "users" do + field :some_id, :integer, primary_key: true + field :another_id, :integer, primary_key: true + + timestamps() + end + end + + defmodule NonEctoUser do + defstruct [:id] + end + + @config [] + + describe "fetch_primary_key_values/2" do + test "handles nil primary key value" do + assert Operations.fetch_primary_key_values(%PrimaryFieldUser{id: nil}, @config) == {:error, "Primary key value for key `:id` in #{inspect PrimaryFieldUser} can't be `nil`"} + assert Operations.fetch_primary_key_values(%CompositePrimaryFieldsUser{}, @config) == {:error, "Primary key value for key `:some_id` in #{inspect CompositePrimaryFieldsUser} can't be `nil`"} + assert Operations.fetch_primary_key_values(%NonEctoUser{}, @config) == {:error, "Primary key value for key `:id` in #{inspect NonEctoUser} can't be `nil`"} + end + + test "requires primary key" do + assert Operations.fetch_primary_key_values(%NoPrimaryFieldUser{}, @config) == {:error, "No primary keys found for #{inspect NoPrimaryFieldUser}"} + end + + test "returns keyword list" do + assert Operations.fetch_primary_key_values(%PrimaryFieldUser{id: 1}, @config) == {:ok, id: 1} + assert Operations.fetch_primary_key_values(%CompositePrimaryFieldsUser{some_id: 1, another_id: 2}, @config) == {:ok, some_id: 1, another_id: 2} + assert Operations.fetch_primary_key_values(%NonEctoUser{id: 1}, @config) == {:ok, id: 1} + end + end end diff --git a/test/pow/phoenix/controllers/registration_controller_test.exs b/test/pow/phoenix/controllers/registration_controller_test.exs index 50b2c19f..cac5980e 100644 --- a/test/pow/phoenix/controllers/registration_controller_test.exs +++ b/test/pow/phoenix/controllers/registration_controller_test.exs @@ -16,8 +16,8 @@ defmodule Pow.Phoenix.RegistrationControllerTest do assert html =~ "" assert html =~ "" assert html =~ "" - assert html =~ "" - assert html =~ "" + assert html =~ "" + assert html =~ "" assert html =~ "Sign in" end diff --git a/test/pow/phoenix/html/form_template_test.exs b/test/pow/phoenix/html/form_template_test.exs index 3222b193..d69e9f1c 100644 --- a/test/pow/phoenix/html/form_template_test.exs +++ b/test/pow/phoenix/html/form_template_test.exs @@ -8,7 +8,7 @@ defmodule Pow.Phoenix.HTML.FormTemplateTest do html = FormTemplate.render([ {:text, {:changeset, :pow_user_id_field}}, {:password, :password}, - {:password, :confirm_password} + {:password, :password_confirmation} ]) refute html =~ "
" @@ -24,7 +24,7 @@ defmodule Pow.Phoenix.HTML.FormTemplateTest do html = FormTemplate.render([ {:text, {:changeset, :pow_user_id_field}}, {:password, :password}, - {:password, :confirm_password} + {:password, :password_confirmation} ], bootstrap: true) assert html =~ "
" diff --git a/test/pow/phoenix/mailer_test.exs b/test/pow/phoenix/mailer_test.exs index 9078f9c0..8410eddf 100644 --- a/test/pow/phoenix/mailer_test.exs +++ b/test/pow/phoenix/mailer_test.exs @@ -19,7 +19,7 @@ defmodule Pow.Phoenix.MailerTest do |> Plug.Conn.put_private(:pow_config, []) |> Mailer.Mail.new(%{email: "test@example.com"}, {MailerView, :mail_test}, value: "test") - {:ok, conn: conn, email: email} + {:ok, email: email} end test "deliver/2", %{conn: conn, email: email} do diff --git a/test/pow/phoenix/views/view_helpers_test.exs b/test/pow/phoenix/views/view_helpers_test.exs index 82419278..88bbc717 100644 --- a/test/pow/phoenix/views/view_helpers_test.exs +++ b/test/pow/phoenix/views/view_helpers_test.exs @@ -26,7 +26,7 @@ defmodule Pow.Phoenix.ViewHelpersTest do |> Conn.assign(:changeset, User.changeset(%User{}, %{})) |> Conn.assign(:action, "/") - {:ok, %{conn: conn}} + {:ok, conn: conn} end test "layout/1", %{conn: conn} do diff --git a/test/pow/plug/require_authenticated_test.exs b/test/pow/plug/require_authenticated_test.exs index 4f645102..40dae82c 100644 --- a/test/pow/plug/require_authenticated_test.exs +++ b/test/pow/plug/require_authenticated_test.exs @@ -2,14 +2,13 @@ defmodule Pow.Plug.RequireAuthenticatedTest do use ExUnit.Case doctest Pow.Plug.RequireAuthenticated - alias Plug.Conn + alias Plug.{Conn, Test} alias Pow.{Config.ConfigError, Plug, Plug.RequireAuthenticated} - alias Pow.Test.ConnHelpers setup do conn = :get - |> ConnHelpers.conn("/") + |> Test.conn("/") |> Plug.put_config(current_user_assigns_key: :current_user) {:ok, %{conn: conn}} diff --git a/test/pow/plug/require_not_authenticated_test.exs b/test/pow/plug/require_not_authenticated_test.exs index fb8dfdf1..50852abb 100644 --- a/test/pow/plug/require_not_authenticated_test.exs +++ b/test/pow/plug/require_not_authenticated_test.exs @@ -2,14 +2,13 @@ defmodule Pow.Plug.RequireNotAuthenticatedTest do use ExUnit.Case doctest Pow.Plug.RequireNotAuthenticated - alias Plug.Conn + alias Plug.{Conn, Test} alias Pow.{Config.ConfigError, Plug, Plug.RequireNotAuthenticated} - alias Pow.Test.ConnHelpers setup do conn = :get - |> ConnHelpers.conn("/") + |> Test.conn("/") |> Plug.put_config(current_user_assigns_key: :current_user) {:ok, %{conn: conn}} diff --git a/test/pow/plug/session_test.exs b/test/pow/plug/session_test.exs index 6129fbc3..d0b89cea 100644 --- a/test/pow/plug/session_test.exs +++ b/test/pow/plug/session_test.exs @@ -2,14 +2,16 @@ defmodule Pow.Plug.SessionTest do use ExUnit.Case doctest Pow.Plug.Session - alias Plug.Conn + alias Plug.{Conn, ProcessStore, Test} + alias Plug.Session, as: PlugSession alias Pow.{Plug, Plug.Session, Store.Backend.EtsCache, Store.CredentialsCache} - alias Pow.Test.{ConnHelpers, Ecto.Users.User, EtsCacheMock} + alias Pow.Test.{Ecto.Users.User, EtsCacheMock, MessageVerifier} @default_opts [ current_user_assigns_key: :current_user, session_key: "auth", - cache_store_backend: EtsCacheMock + cache_store_backend: EtsCacheMock, + message_verifier: MessageVerifier ] @store_config [backend: EtsCacheMock] @user %User{id: 1} @@ -17,17 +19,13 @@ defmodule Pow.Plug.SessionTest do setup do EtsCacheMock.init() - conn = - :get - |> ConnHelpers.conn("/") - |> ConnHelpers.init_session() + conn = conn_with_plug_session() - {:ok, %{conn: conn}} + {:ok, conn: conn} end test "call/2 sets plug in :pow_config", %{conn: conn} do - opts = Session.init(@default_opts) - conn = Session.call(conn, opts) + conn = run_plug(conn) expected_config = [mod: Session, plug: Session] ++ @default_opts assert is_nil(conn.assigns[:current_user]) @@ -35,24 +33,21 @@ defmodule Pow.Plug.SessionTest do end test "call/2 with assigned current_user", %{conn: conn} do - opts = Session.init(@default_opts) conn = conn |> Plug.assign_current_user("assigned", @default_opts) - |> Session.call(opts) + |> run_plug() assert conn.assigns[:current_user] == "assigned" end test "call/2 with stored current_user", %{conn: conn} do - CredentialsCache.put(@store_config, "token", {@user, inserted_at: :os.system_time(:millisecond), fingerprint: "fingerprint"}) - - opts = Session.init(@default_opts) - conn = + session_id = store_in_cache("token", {@user, inserted_at: :os.system_time(:millisecond), fingerprint: "fingerprint"}) + conn = conn |> Conn.fetch_session() - |> Conn.put_session(@default_opts[:session_key], "token") - |> Session.call(opts) + |> Conn.put_session(@default_opts[:session_key], session_id) + |> run_plug() assert conn.assigns[:current_user] == @user assert conn.private[:pow_session_metadata][:fingerprint] == "fingerprint" @@ -60,15 +55,14 @@ defmodule Pow.Plug.SessionTest do test "call/2 with stored session and custom metadata", %{conn: conn} do inserted_at = :os.system_time(:millisecond) - CredentialsCache.put(@store_config, "token", {@user, inserted_at: inserted_at, a: 1}) + session_id = store_in_cache("token", {@user, inserted_at: inserted_at, a: 1}) - opts = Session.init(@default_opts) conn = conn |> Conn.put_private(:pow_session_metadata, b: 2) |> Conn.fetch_session() - |> Conn.put_session(@default_opts[:session_key], "token") - |> Session.call(opts) + |> Conn.put_session(@default_opts[:session_key], session_id) + |> run_plug() assert conn.assigns[:current_user] == @user assert conn.private[:pow_session_metadata][:inserted_at] == inserted_at @@ -76,63 +70,162 @@ defmodule Pow.Plug.SessionTest do end test "call/2 with non existing cached key", %{conn: conn} do - CredentialsCache.put(@store_config, "token", {@user, inserted_at: :os.system_time(:millisecond)}) + _session_id = store_in_cache("token", {@user, inserted_at: :os.system_time(:millisecond)}) + invalid_id = sign_token("invalid") - opts = Session.init(@default_opts) conn = conn |> Conn.fetch_session() - |> Conn.put_session(@default_opts[:session_key], "invalid") - |> Session.call(opts) + |> Conn.put_session(@default_opts[:session_key], invalid_id) + |> run_plug() assert is_nil(conn.assigns[:current_user]) end + test "call/2 with unsigned session id", %{conn: conn} do + session_id = "token" + store_in_cache(session_id, {@user, inserted_at: :os.system_time(:millisecond), fingerprint: "fingerprint"}) + conn = + conn + |> Conn.fetch_session() + |> Conn.put_session(@default_opts[:session_key], session_id) + |> run_plug() + + assert is_nil(conn.assigns[:current_user]) + assert {@user, _metadata} = CredentialsCache.get(@store_config, session_id) + end + test "call/2 creates new session when :session_renewal_ttl reached", %{conn: conn} do ttl = 100 config = Keyword.put(@default_opts, :session_ttl_renewal, ttl) timestamp = :os.system_time(:millisecond) stale_timestamp = timestamp - ttl - 1 + session_id = store_in_cache("token", {@user, inserted_at: timestamp, fingerprint: "fingerprint"}) init_conn = conn |> Conn.fetch_session() - |> Conn.put_session(config[:session_key], "token") - - CredentialsCache.put(@store_config, "token", {@user, inserted_at: timestamp, fingerprint: "fingerprint"}) + |> Conn.put_session(config[:session_key], session_id) - opts = Session.init(config) - conn = Session.call(init_conn, opts) - session_id = get_session_id(conn) + conn = run_plug(init_conn, config) + assert session_id = get_session_id(conn) assert conn.assigns[:current_user] == @user - CredentialsCache.put(@store_config, "token", {@user, inserted_at: stale_timestamp, fingerprint: "fingerprint"}) - CredentialsCache.put(@store_config, "newer_token", {@user, inserted_at: timestamp, fingerprint: "new_fingerprint"}) + store_in_cache("token", {@user, inserted_at: stale_timestamp, fingerprint: "fingerprint"}) + store_in_cache("newer_token", {@user, inserted_at: timestamp, fingerprint: "new_fingerprint"}) - conn = Session.call(init_conn, opts) + conn = run_plug(init_conn, config) assert conn.assigns[:current_user] == @user assert new_session_id = get_session_id(conn) assert new_session_id != session_id - assert {_user, metadata} = CredentialsCache.get(@store_config, new_session_id) + assert {_user, metadata} = get_from_cache(new_session_id) assert metadata[:inserted_at] != stale_timestamp assert metadata[:fingerprint] == "fingerprint" assert conn.private[:pow_session_metadata][:fingerprint] == "fingerprint" end + defmodule CredentialsCacheWaitDelete do + alias Pow.Store.CredentialsCache + + @timeout :timer.seconds(5) + + defdelegate put(config, session_id, record_or_records), to: CredentialsCache + + defdelegate get(config, session_id), to: CredentialsCache + + def delete(config, session_id)do + send(self(), {__MODULE__, :wait}) + + receive do + {__MODULE__, :commit} -> :ok + after + @timeout -> raise "Timeout reached" + end + + CredentialsCache.delete(config, session_id) + end + end + + test "call/2 creates new session when :session_renewal_ttl reached and doesn't delete with simultanous request", %{conn: conn} do + :ets.delete(EtsCacheMock) + :ets.new(EtsCacheMock, [:ordered_set, :public, :named_table]) + + ttl = 100 + config = Keyword.merge(@default_opts, session_ttl_renewal: ttl, credentials_cache_store: {CredentialsCacheWaitDelete, [ttl: :timer.minutes(30), namespace: "credentials"]}) + stale_timestamp = :os.system_time(:millisecond) - ttl - 1 + session_id = store_in_cache("token", {@user, inserted_at: stale_timestamp}) + + conn = + conn + |> Conn.fetch_session() + |> Conn.put_session(config[:session_key], session_id) + |> Conn.send_resp(200, "") + |> recycle_session_conn() + + sid = Conn.fetch_cookies(conn).cookies["foobar"] + session_data = Process.get({:session, sid}) + + CredentialsCache.put(@store_config, session_id, {@user, inserted_at: stale_timestamp}) + + task_1 = + fn -> + Process.put({:session, sid}, session_data) + run_plug(conn, config) + end + |> Task.async() + |> wait_till_ready() + + conn_2 = + fn -> + Process.put({:session, sid}, session_data) + run_plug(conn, config) + end + |> Task.async() + |> continue_work() + |> Task.await() + + conn_1 = + task_1 + |> continue_work() + |> Task.await() + + assert Plug.current_user(conn_1) == @user + assert conn_1.resp_cookies["foobar"] + refute get_session_id(conn_1) == session_id + assert {@user, _metadata} = get_from_cache(get_session_id(conn_1)) + + assert Plug.current_user(conn_2) == @user + refute conn_2.resp_cookies["foobar"] + assert get_session_id(conn_2) == session_id + assert get_from_cache(get_session_id(conn_2)) == :not_found + end + + defp wait_till_ready(%{pid: tracking_pid} = task) do + :erlang.trace(tracking_pid, true, [:receive]) + assert_receive {:trace, ^tracking_pid, :receive, {CredentialsCacheWaitDelete, :wait}} + + task + end + + defp continue_work(%{pid: tracking_pid} = task) do + send(tracking_pid, {CredentialsCacheWaitDelete, :commit}) + + task + end + test "call/2 with prepended `:otp_app` session key", %{conn: conn} do - CredentialsCache.put(@store_config, "token", {@user, inserted_at: :os.system_time(:millisecond)}) + id = store_in_cache("token", {@user, inserted_at: :os.system_time(:millisecond)}) - opts = + config = @default_opts |> Keyword.delete(:session_key) |> Keyword.put(:otp_app, :test_app) - |> Session.init() conn = conn |> Conn.fetch_session() - |> Conn.put_session("test_app_auth", "token") - |> Session.call(opts) + |> Conn.put_session("test_app_auth", id) + |> run_plug(config) assert conn.assigns[:current_user] == @user end @@ -142,59 +235,79 @@ defmodule Pow.Plug.SessionTest do ttl = 100 config = Keyword.put(@default_opts, :session_ttl_renewal, ttl) stale_timestamp = :os.system_time(:millisecond) - ttl - 1 + session_id = sign_token("token") @store_config |> Keyword.put(:namespace, "credentials") |> EtsCacheMock.put({"token", {@user, stale_timestamp}}) - opts = Session.init(config) conn = conn |> Conn.fetch_session() - |> Conn.put_session(config[:session_key], "token") - |> Session.call(opts) + |> Conn.put_session(config[:session_key], session_id) + |> run_plug(config) assert new_session_id = get_session_id(conn) - assert new_session_id != "token" + assert new_session_id != session_id assert conn.assigns[:current_user] == @user end describe "create/2" do - test "creates new session id", %{conn: conn} do - opts = Session.init(@default_opts) + test "deletes existing and creates new session id", %{conn: new_conn} do conn = - conn - |> Session.call(opts) - |> Session.do_create(@user, opts) - - session_id = get_session_id(conn) + new_conn + |> init_plug() + |> run_do_create(@user) - assert {@user, metadata} = CredentialsCache.get(@store_config, session_id) + assert session_id = get_session_id(conn) + assert {@user, metadata} = get_from_cache(session_id) assert is_binary(session_id) assert Plug.current_user(conn) == @user assert metadata[:inserted_at] assert metadata[:fingerprint] - conn = Session.do_create(conn, @user, opts) - new_session_id = get_session_id(conn) + conn = + conn + |> recycle_session_conn() + |> init_plug() + |> run_do_create(@user) - assert {@user, new_metadata} = CredentialsCache.get(@store_config, new_session_id) + assert new_session_id = get_session_id(conn) + assert {@user, new_metadata} = get_from_cache(new_session_id) assert is_binary(session_id) assert new_session_id != session_id - assert CredentialsCache.get(@store_config, session_id) == :not_found + assert get_from_cache(session_id) == :not_found assert Plug.current_user(conn) == @user assert metadata[:fingerprint] == new_metadata[:fingerprint] end + test "renews plug session", %{conn: new_conn} do + conn = + new_conn + |> init_plug() + |> run_do_create(@user) + + assert %{"foobar" => %{value: plug_session_id}} = conn.resp_cookies + + conn = + conn + |> recycle_session_conn() + |> init_plug() + |> run_do_create(@user) + + assert %{"foobar" => %{value: new_plug_session_id}} = conn.resp_cookies + + refute plug_session_id == new_plug_session_id + end + test "creates with custom metadata", %{conn: conn} do inserted_at = :os.system_time(:millisecond) - 10 - opts = Session.init(@default_opts) conn = conn |> Conn.put_private(:pow_session_metadata, inserted_at: inserted_at, a: 1) - |> Session.call(opts) - |> Session.do_create(@user, opts) + |> init_plug() + |> run_do_create(@user) assert conn.assigns[:current_user] == @user assert conn.private[:pow_session_metadata][:inserted_at] != inserted_at @@ -203,41 +316,54 @@ defmodule Pow.Plug.SessionTest do end test "creates new session id with `:otp_app` prepended", %{conn: conn} do - opts = + config = @default_opts |> Keyword.delete(:session_key) |> Keyword.put(:otp_app, :test_app) - |> Session.init() conn = conn - |> Session.call(opts) - |> Session.do_create(@user, opts) + |> init_plug(config) + |> run_do_create(@user) refute get_session_id(conn) session_id = conn.private[:plug_session]["test_app_auth"] - assert String.starts_with?(session_id, "test_app_") + assert {:ok, decoded_session_id} = Plug.verify_token(conn, Atom.to_string(Session), session_id) + assert String.starts_with?(decoded_session_id, "test_app_") end end - test "delete/1 removes session id", %{conn: conn} do - opts = Session.init(@default_opts) + test "delete/2 removes session id", %{conn: new_conn} do conn = - conn - |> Session.call(opts) - |> Session.do_create(@user, opts) + new_conn + |> init_plug() + |> run_do_create(@user) - session_id = get_session_id(conn) - - assert {@user, _metadata} = CredentialsCache.get(@store_config, session_id) + assert session_id = get_session_id(conn) + assert {@user, _metadata} = get_from_cache(session_id) assert is_binary(session_id) assert Plug.current_user(conn) == @user - conn = Session.do_delete(conn, opts) + conn = + conn + |> recycle_session_conn() + |> init_plug() + |> run_do_delete() refute new_session_id = get_session_id(conn) assert is_nil(new_session_id) - assert CredentialsCache.get(@store_config, session_id) == :not_found + assert get_from_cache(session_id) == :not_found + assert is_nil(Plug.current_user(conn)) + end + + test "delete/2 deletes when call create in sequence", %{conn: conn} do + conn = + conn + |> init_plug(@default_opts) + |> Session.do_create(@user, @default_opts) + |> run_do_delete() + + refute get_session_id(conn) assert is_nil(Plug.current_user(conn)) end @@ -249,26 +375,79 @@ defmodule Pow.Plug.SessionTest do end test "call/2", %{conn: conn} do - sesion_key = "auth" - config = [session_key: sesion_key] - token = "credentials_cache_test" timestamp = :os.system_time(:millisecond) - CredentialsCache.put(config, token, {@user, inserted_at: timestamp}) + session_id = store_in_cache("credentials_cache_test", {@user, inserted_at: timestamp}, []) :timer.sleep(100) - opts = Session.init(session_key: "auth") conn = conn |> Conn.fetch_session() - |> Conn.put_session("auth", token) - |> Session.call(opts) + |> Conn.put_session("auth", session_id) + |> run_plug(session_key: "auth", message_verifier: MessageVerifier) assert conn.assigns[:current_user] == @user end end + defp conn_with_plug_session(conn \\ nil) do + conn + |> Kernel.||(Test.conn(:get, "/")) + |> PlugSession.call(PlugSession.init(store: ProcessStore, key: "foobar")) + end + + defp recycle_session_conn(old_conn) do + :get + |> Test.conn("/") + |> Test.recycle_cookies(old_conn) + |> conn_with_plug_session() + end + + defp init_plug(conn, config \\ @default_opts) do + opts = Session.init(config) + + Session.call(conn, opts) + end + + defp run_plug(conn, config \\ @default_opts) do + conn + |> init_plug(config) + |> Conn.send_resp(200, "") + end + def get_session_id(conn) do conn.private[:plug_session][@default_opts[:session_key]] end + + defp run_do_create(conn, user) do + config = Plug.fetch_config(conn) + + conn + |> Session.do_create(user, config) + |> Conn.send_resp(200, "") + end + + defp run_do_delete(conn) do + config = Plug.fetch_config(conn) + + conn + |> Session.do_delete(config) + |> Conn.send_resp(200, "") + end + + defp store_in_cache(token, value, store_config \\ @store_config) do + CredentialsCache.put(store_config, token, value) + + sign_token(token) + end + + defp sign_token(token) do + Plug.sign_token(%Conn{}, Atom.to_string(Session), token, @default_opts) + end + + defp get_from_cache(token) do + assert {:ok, token} = Plug.verify_token(%Conn{}, Atom.to_string(Session), token, @default_opts) + + CredentialsCache.get(@store_config, token) + end end diff --git a/test/pow/plug_test.exs b/test/pow/plug_test.exs index 92f42692..06b81f09 100644 --- a/test/pow/plug_test.exs +++ b/test/pow/plug_test.exs @@ -2,15 +2,17 @@ defmodule Pow.PlugTest do use ExUnit.Case doctest Pow.Plug - alias Plug.Conn + alias Plug.{Conn, ProcessStore, Test} + alias Plug.Session, as: PlugSession alias Pow.{Config, Config.ConfigError, Plug, Plug.Session} - alias Pow.Test.{ConnHelpers, ContextMock, Ecto.Users.User, EtsCacheMock} + alias Pow.Test.{ContextMock, Ecto.Users.User, EtsCacheMock, MessageVerifier} @default_config [ current_user_assigns_key: :current_user, users_context: ContextMock, cache_store_backend: EtsCacheMock, - user: User + user: User, + message_verifier: MessageVerifier ] @admin_config Config.put(@default_config, :current_user_assigns_key, :current_admin_user) @@ -48,19 +50,15 @@ defmodule Pow.PlugTest do test "authenticate_user/2" do EtsCacheMock.init() - opts = Session.init(@default_config) - conn = - conn() - |> ConnHelpers.init_session() - |> Session.call(opts) + conn = conn_with_session_plug() - refute conn.private[:plug_session]["auth"] + refute fetch_session_id(conn) refute Plug.current_user(conn) assert {:ok, loaded_conn} = Plug.authenticate_user(conn, %{"email" => "test@example.com", "password" => "secret"}) assert user = Plug.current_user(loaded_conn) assert user.id == 1 - assert loaded_conn.private[:plug_session]["auth"] + assert fetch_session_id(loaded_conn) assert {:error, conn} = Plug.authenticate_user(conn, %{}) refute Plug.current_user(conn) @@ -87,27 +85,6 @@ defmodule Pow.PlugTest do end end - test "clear_authenticated_user/1" do - EtsCacheMock.init() - - opts = Session.init(@default_config) - conn = - conn() - |> ConnHelpers.init_session() - |> Session.call(opts) - - assert {:ok, conn} = Plug.authenticate_user(conn, %{"email" => "test@example.com", "password" => "secret"}) - assert user = Plug.current_user(conn) - assert session_id = conn.private[:plug_session]["auth"] - assert {key, _metadata} = EtsCacheMock.get([namespace: "credentials"], session_id) - assert EtsCacheMock.get([namespace: "credentials"], key) == user - - {:ok, conn} = Plug.clear_authenticated_user(conn) - refute Plug.current_user(conn) - refute conn.private[:plug_session]["auth"] - assert EtsCacheMock.get([namespace: "credentials"], session_id) == :not_found - end - test "change_user/2" do conn = conn() assert %Ecto.Changeset{} = Plug.change_user(conn) @@ -120,63 +97,87 @@ defmodule Pow.PlugTest do test "create_user/2" do EtsCacheMock.init() - opts = Session.init(@default_config) - conn = - conn() - |> ConnHelpers.init_session() - |> Session.call(opts) + conn = conn_with_session_plug() assert {:error, _changeset, conn} = Plug.create_user(conn, %{}) refute Plug.current_user(conn) - refute conn.private[:plug_session]["auth"] + refute fetch_session_id(conn) assert {:ok, user, conn} = Plug.create_user(conn, %{"email" => "test@example.com", "password" => "secret"}) assert Plug.current_user(conn) == user - assert conn.private[:plug_session]["auth"] + assert fetch_session_id(conn) end test "update_user/2" do EtsCacheMock.init() - opts = Session.init(@default_config) - conn = - conn() - |> ConnHelpers.init_session() - |> Session.call(opts) - - {:ok, conn} = Plug.authenticate_user(conn, %{"email" => "test@example.com", "password" => "secret"}) - user = Plug.current_user(conn) - session_id = conn.private[:plug_session]["auth"] + conn = auth_user_conn() + assert user = Plug.current_user(conn) + assert session_id = fetch_session_id(conn) assert {:error, _changeset, conn} = Plug.update_user(conn, %{}) assert Plug.current_user(conn) == user - assert conn.private[:plug_session]["auth"] == session_id + assert fetch_session_id(conn) == session_id assert {:ok, updated_user, conn} = Plug.update_user(conn, %{"email" => "test@example.com", "password" => "secret"}) assert updated_user.id == :updated assert Plug.current_user(conn) == updated_user refute updated_user == user - refute conn.private[:plug_session]["auth"] == session_id + refute fetch_session_id(conn) == session_id end test "delete_user/2" do EtsCacheMock.init() - opts = Session.init(@default_config) - conn = - conn() - |> ConnHelpers.init_session() - |> Session.call(opts) - - {:ok, conn} = Plug.authenticate_user(conn, %{"email" => "test@example.com", "password" => "secret"}) + conn = auth_user_conn() assert {:ok, user, conn} = Plug.delete_user(conn) assert user.id == :deleted refute Plug.current_user(conn) - refute conn.private[:plug_session]["auth"] + refute fetch_session_id(conn) + end + + test "verify_token/4" do + conn = + @default_config + |> Keyword.put(:message_verifier, Pow.Plug.MessageVerifier) + |> conn() + |> Map.put(:secret_key_base, String.duplicate("abcdefghijklmnopqrstuvxyz0123456789", 2)) + + signed_token = Plug.sign_token(conn, "salt", "token") + + assert Plug.verify_token(%{conn | secret_key_base: conn.secret_key_base <> "invalid"}, "salt", signed_token) == :error + assert Plug.verify_token(conn, "invalid", signed_token) == :error + assert Plug.verify_token(conn, "salt", "invalid") == :error + assert Plug.verify_token(conn, "salt", signed_token) == {:ok, "token"} + end + + defp auth_user_conn() do + conn = conn_with_session_plug() + {:ok, conn} = Plug.authenticate_user(conn, %{"email" => "test@example.com", "password" => "secret"}) + conn = Conn.send_resp(conn, 200, "") + + conn() + |> Test.recycle_cookies(conn) + |> conn_with_session_plug() + end + + defp conn_with_session_plug(conn \\ nil) do + conn + |> Kernel.||(conn()) + |> PlugSession.call(PlugSession.init(store: ProcessStore, key: "foobar")) + |> Session.call(Session.init(@default_config)) + end + + defp fetch_session_id(conn) do + conn = Conn.send_resp(conn, 200, "") + + Map.get(conn.private[:plug_session], "auth") end defp conn(config \\ @default_config) do - %Conn{private: %{pow_config: config}} + :get + |> Test.conn("/") + |> Conn.put_private(:pow_config, config) end end diff --git a/test/pow/store/credentials_cache_test.exs b/test/pow/store/credentials_cache_test.exs index 9d3bea8b..58cab840 100644 --- a/test/pow/store/credentials_cache_test.exs +++ b/test/pow/store/credentials_cache_test.exs @@ -9,13 +9,13 @@ defmodule Pow.Store.CredentialsCacheTest do @config [backend: EtsCacheMock] @backend_config [namespace: "credentials"] - setup context do + setup do EtsCacheMock.init() - {:ok, context} + {:ok, ets: EtsCacheMock} end - test "stores sessions" do + test "stores sessions", %{ets: ets} do user_1 = %User{id: 1} user_2 = %User{id: 2} user_3 = %UsernameUser{id: 1} @@ -37,9 +37,9 @@ defmodule Pow.Store.CredentialsCacheTest do assert CredentialsCache.sessions(@config, user_2) == ["key_3"] assert CredentialsCache.sessions(@config, user_3) == ["key_4"] - assert EtsCacheMock.get(@backend_config, "key_1") == {[User, :user, 1], a: 1} - assert EtsCacheMock.get(@backend_config, [User, :user, 1]) == user_1 - assert EtsCacheMock.get(@backend_config, [User, :user, 1, :session, "key_1"]) + assert ets.get(@backend_config, "key_1") == {[User, :user, 1], a: 1} + assert ets.get(@backend_config, [User, :user, 1]) == user_1 + assert ets.get(@backend_config, [User, :user, 1, :session, "key_1"]) CredentialsCache.put(@config, "key_2", {%{user_1 | email: :updated}, a: 5}) assert CredentialsCache.get(@config, "key_1") == {%{user_1 | email: :updated}, a: 1} @@ -48,16 +48,16 @@ defmodule Pow.Store.CredentialsCacheTest do assert CredentialsCache.get(@config, "key_1") == :not_found assert CredentialsCache.sessions(@config, user_1) == ["key_2"] - assert EtsCacheMock.get(@backend_config, "key_1") == :not_found - assert EtsCacheMock.get(@backend_config, [User, :user, 1]) == %{user_1 | email: :updated} - assert EtsCacheMock.get(@backend_config, [User, :user, 1, :session, "key_1"]) == :not_found + assert ets.get(@backend_config, "key_1") == :not_found + assert ets.get(@backend_config, [User, :user, 1]) == %{user_1 | email: :updated} + assert ets.get(@backend_config, [User, :user, 1, :session, "key_1"]) == :not_found assert CredentialsCache.delete(@config, "key_2") == :ok assert CredentialsCache.sessions(@config, user_1) == [] - assert EtsCacheMock.get(@backend_config, "key_1") == :not_found - assert EtsCacheMock.get(@backend_config, [User, :user, 1]) == %{user_1 | email: :updated} - assert EtsCacheMock.get(@backend_config, [User, :user, 1, :session, "key_1"]) == :not_found + assert ets.get(@backend_config, "key_1") == :not_found + assert ets.get(@backend_config, [User, :user, 1]) == %{user_1 | email: :updated} + assert ets.get(@backend_config, [User, :user, 1, :session, "key_1"]) == :not_found end test "put/3 invalidates sessions with identical fingerprint" do @@ -75,23 +75,6 @@ defmodule Pow.Store.CredentialsCacheTest do assert CredentialsCache.get(@config, "key_3") == {user, fingerprint: 1} end - test "raises for nil primary key value" do - user_1 = %User{id: nil} - - assert_raise RuntimeError, "Primary key value for key `:id` in Pow.Test.Ecto.Users.User can't be `nil`", fn -> - CredentialsCache.put(@config, "key_1", {user_1, a: 1}) - end - end - - defmodule NoPrimaryFieldUser do - use Ecto.Schema - - @primary_key false - schema "users" do - timestamps() - end - end - defmodule CompositePrimaryFieldsUser do use Ecto.Schema @@ -104,44 +87,21 @@ defmodule Pow.Store.CredentialsCacheTest do end end - test "handles custom primary fields" do - assert_raise RuntimeError, "No primary keys found for Pow.Store.CredentialsCacheTest.NoPrimaryFieldUser", fn -> - CredentialsCache.put(@config, "key_1", {%NoPrimaryFieldUser{}, a: 1}) - end - - assert_raise RuntimeError, "Primary key value for key `:another_id` in Pow.Store.CredentialsCacheTest.CompositePrimaryFieldsUser can't be `nil`", fn -> - CredentialsCache.put(@config, "key_1", {%CompositePrimaryFieldsUser{}, a: 1}) - end - + test "sorts composite primary keys", %{ets: ets} do user = %CompositePrimaryFieldsUser{some_id: 1, another_id: 2} CredentialsCache.put(@config, "key_1", {user, a: 1}) assert CredentialsCache.users(@config, CompositePrimaryFieldsUser) == [user] - end - - defmodule NonEctoUser do - defstruct [:id] - end - - test "handles non-ecto user struct" do - assert_raise RuntimeError, "Primary key value for key `:id` in Pow.Store.CredentialsCacheTest.NonEctoUser can't be `nil`", fn -> - CredentialsCache.put(@config, "key_1", {%NonEctoUser{}, a: 1}) - end - - user = %NonEctoUser{id: 1} - - assert CredentialsCache.put(@config, "key_1", {user, a: 1}) - - assert CredentialsCache.users(@config, NonEctoUser) == [user] + assert ets.get(@backend_config, [CompositePrimaryFieldsUser, :user, [another_id: 2, some_id: 1]]) == user end # TODO: Remove by 1.1.0 - test "backwards compatible" do + test "backwards compatible", %{ets: ets} do user_1 = %User{id: 1} timestamp = :os.system_time(:millisecond) - EtsCacheMock.put(@backend_config, {"key_1", {user_1, inserted_at: timestamp}}) + ets.put(@backend_config, {"key_1", {user_1, inserted_at: timestamp}}) assert CredentialsCache.get(@config, @backend_config, "key_1") == {user_1, inserted_at: timestamp} assert CredentialsCache.delete(@config, @backend_config, "key_1") == :ok diff --git a/test/support/conn_helpers.ex b/test/support/conn_helpers.ex deleted file mode 100644 index 395b00b2..00000000 --- a/test/support/conn_helpers.ex +++ /dev/null @@ -1,16 +0,0 @@ -defmodule Pow.Test.ConnHelpers do - @moduledoc false - alias Plug.{Conn, ProcessStore, Session, Test} - - @spec conn(String.Chars.t(), binary(), Test.params()) :: Conn.t() - def conn(method, path, params_or_body \\ nil) do - Test.conn(method, path, params_or_body) - end - - @spec init_session(Conn.t()) :: Conn.t() - def init_session(conn) do - opts = Session.init(store: ProcessStore, key: "foobar") - - Session.call(conn, opts) - end -end diff --git a/test/support/context_mock.ex b/test/support/context_mock.ex index 349a2186..fe5e5e3c 100644 --- a/test/support/context_mock.ex +++ b/test/support/context_mock.ex @@ -10,7 +10,7 @@ defmodule Pow.Test.ContextMock do def authenticate(_params), do: nil def create(@valid_params), do: {:ok, @user} - def create(params), do: {:error, %{User.changeset(%User{}, params) | action: :create}} + def create(params), do: {:error, %{User.changeset(%User{}, params) | action: :insert}} def update(%{id: 1}, @valid_params), do: {:ok, %{@user | id: :updated}} def update(user, params), do: {:error, %{User.changeset(user, params) | action: :update}} diff --git a/test/support/extensions/email_confirmation/repo_mock.ex b/test/support/extensions/email_confirmation/repo_mock.ex index c4219353..bb67b772 100644 --- a/test/support/extensions/email_confirmation/repo_mock.ex +++ b/test/support/extensions/email_confirmation/repo_mock.ex @@ -12,13 +12,6 @@ defmodule PowEmailConfirmation.Test.RepoMock do }, state: :loaded) end - def one(query) do - case inspect(query) =~ "where: u0.email == ^\"taken@example.com\"" do - true -> user() - false -> false - end - end - def get_by(User, [email: "test@example.com"], _opts), do: user() def get_by(User, [email: "with-unconfirmed-changed-email@example.com"], _opts) do %{user() | unconfirmed_email: "new@example.com", email_confirmed_at: DateTime.utc_now()} @@ -31,11 +24,14 @@ defmodule PowEmailConfirmation.Test.RepoMock do def get_by(User, [email_confirmation_token: "valid-with-unconfirmed-changed-email"], _opts) do %{user() | unconfirmed_email: "new@example.com", email_confirmed_at: DateTime.utc_now()} end + def get_by(User, [email_confirmation_token: "valid-with-taken-email"], _opts) do + %{user() | unconfirmed_email: "taken@example.com", email_confirmed_at: DateTime.utc_now()} + end - def update(%{changes: %{email: "taken@example.com"}} = changeset, _opts) do - changeset = Ecto.Changeset.add_error(changeset, :email, "has already been taken") + def update(%{changes: %{email: "taken@example.com"}, valid?: true} = changeset, _opts) do + changeset = Ecto.Changeset.add_error(changeset, :email, "has already been taken", constraint: :unique, constraint_name: "users_email_index") - {:error, changeset} + {:error, %{changeset | action: :update}} end def update(%{valid?: true} = changeset, _opts) do %{changeset | repo: __MODULE__} @@ -57,6 +53,12 @@ defmodule PowEmailConfirmation.Test.RepoMock do |> Enum.reduce(changeset, & &1.(&2)) end + def insert(%{valid?: false} = changeset, _opts), do: {:error, %{changeset | action: :insert}} + def insert(%{changes: %{email: "taken@example.com"}, valid?: true} = changeset, _opts) do + changeset = Ecto.Changeset.add_error(changeset, :email, "has already been taken", constraint: :unique, constraint_name: "users_email_index") + + {:error, %{changeset | action: :insert}} + end def insert(%{valid?: true} = changeset, _opts) do user = changeset @@ -69,7 +71,7 @@ defmodule PowEmailConfirmation.Test.RepoMock do {:ok, user} end - def get!(User, 1, _opts), do: Process.get({:user, 1}) + def get_by!(User, [id: 1], _opts), do: Process.get({:user, 1}) defmodule Invitation do @moduledoc false @@ -80,6 +82,6 @@ defmodule PowEmailConfirmation.Test.RepoMock do def update(changeset, opts), do: RepoMock.update(changeset, opts) - def get!(User, 1, _opts), do: Process.get({:user, 1}) + def get_by!(User, [id: 1], _opts), do: Process.get({:user, 1}) end end diff --git a/test/support/extensions/invitation/repo_mock.ex b/test/support/extensions/invitation/repo_mock.ex index 4320c456..b515a6ce 100644 --- a/test/support/extensions/invitation/repo_mock.ex +++ b/test/support/extensions/invitation/repo_mock.ex @@ -5,6 +5,11 @@ defmodule PowInvitation.Test.RepoMock do @user %User{id: 1, email: "test@example.com"} + def insert(%{changes: %{email: "taken@example.com"}, valid?: true} = changeset, _opts) do + changeset = Ecto.Changeset.add_error(changeset, :email, "has already been taken", constraint: :unique, constraint_name: "users_email_index") + + {:error, %{changeset | action: :insert}} + end def insert(%{valid?: true} = changeset, _opts) do user = %{Ecto.Changeset.apply_changes(changeset) | id: 1} @@ -22,12 +27,17 @@ defmodule PowInvitation.Test.RepoMock do end def insert(%{valid?: false} = changeset, _opts), do: {:error, %{changeset | action: :insert}} - def get!(User, 1, _opts), do: Process.get({:user, 1}) + def get_by!(User, [id: 1], _opts), do: Process.get({:user, 1}) def get_by(User, [invitation_token: "valid"], _opts), do: %{@user | invitation_token: "valid"} def get_by(User, [invitation_token: "valid_but_accepted"], _opts), do: %{@user | invitation_accepted_at: :now} def get_by(User, [invitation_token: "invalid"], _opts), do: nil + def update(%{changes: %{email: "taken@example.com"}, valid?: true} = changeset, _opts) do + changeset = Ecto.Changeset.add_error(changeset, :email, "has already been taken", constraint: :unique, constraint_name: "users_email_index") + + {:error, %{changeset | action: :update}} + end def update(%{valid?: true} = changeset, _opts) do user = Ecto.Changeset.apply_changes(changeset) diff --git a/test/support/extensions/mocks.ex b/test/support/extensions/mocks.ex index 3d338689..a1955d9a 100644 --- a/test/support/extensions/mocks.ex +++ b/test/support/extensions/mocks.ex @@ -42,7 +42,9 @@ defmodule Pow.Test.ExtensionMocks do messages_backend: Module.concat([web_module, Phoenix.Messages]), routes_backend: Module.concat([web_module, Phoenix.Routes]), extensions: extensions, - controller_callbacks: Pow.Extension.Phoenix.ControllerCallbacks] + controller_callbacks: Pow.Extension.Phoenix.ControllerCallbacks, + message_verifier: Pow.Test.MessageVerifier + ] __user_schema__(context_module, extensions) __phoenix_endpoint__(web_module, config, opts) @@ -122,6 +124,14 @@ defmodule Pow.Test.ExtensionMocks do use Phoenix.Endpoint, otp_app: :pow + @session_options [ + store: :cookie, + key: "_binaryid_key", + signing_salt: "secret" + ] + + @pow_config unquote(config) + plug Plug.RequestId plug Plug.Logger @@ -133,17 +143,12 @@ defmodule Pow.Test.ExtensionMocks do plug Plug.MethodOverride plug Plug.Head - plug Plug.Session, - store: :cookie, - key: "_binaryid_key", - signing_salt: "secret" - - plug SessionPlugHelper, unquote(config) - - if Code.ensure_compiled?(unquote(opts[:plug])) do + plug Plug.Session, @session_options + plug SessionPlugHelper, @pow_config + if unquote(opts[:plug]) do + Code.ensure_compiled(unquote(opts[:plug])) plug unquote(opts[:plug]) end - plug unquote(web_module).Phoenix.Router end @@ -168,7 +173,7 @@ defmodule Pow.Test.ExtensionMocks do end end - setup _tags do + setup do unquote(cache_backend).init() {:ok, conn: Phoenix.ConnTest.build_conn(), ets: unquote(cache_backend)} end diff --git a/test/support/extensions/reset_password/no_registration/endpoint.ex b/test/support/extensions/reset_password/no_registration/endpoint.ex deleted file mode 100644 index 90802dcd..00000000 --- a/test/support/extensions/reset_password/no_registration/endpoint.ex +++ /dev/null @@ -1,24 +0,0 @@ -defmodule PowResetPassword.NoRegistration.TestWeb.Phoenix.Endpoint do - @moduledoc false - use Phoenix.Endpoint, otp_app: :pow - - plug Plug.RequestId - plug Plug.Logger - - plug Plug.Parsers, - parsers: [:urlencoded, :multipart, :json], - pass: ["*/*"], - json_decoder: Phoenix.json_library() - - plug Plug.MethodOverride - plug Plug.Head - - plug Plug.Session, - store: :cookie, - key: "_binaryid_key", - signing_salt: "secret" - - plug Pow.Plug.Session, PowResetPassword.Test.pow_config() - - plug PowResetPassword.NoRegistration.TestWeb.Phoenix.Router -end diff --git a/test/support/extensions/reset_password/no_registration/router.ex b/test/support/extensions/reset_password/no_registration/router.ex deleted file mode 100644 index 400a9fa5..00000000 --- a/test/support/extensions/reset_password/no_registration/router.ex +++ /dev/null @@ -1,22 +0,0 @@ -defmodule PowResetPassword.NoRegistration.TestWeb.Phoenix.Router do - @moduledoc false - use Phoenix.Router - use Pow.Phoenix.Router - use Pow.Extension.Phoenix.Router, - extensions: [PowResetPassword] - - pipeline :browser do - plug :accepts, ["html"] - plug :fetch_session - plug :fetch_flash - plug :protect_from_forgery - plug :put_secure_browser_headers - end - - scope "/" do - pipe_through :browser - - pow_session_routes() - pow_extension_routes() - end -end diff --git a/test/support/extensions/reset_password/repo_mock.ex b/test/support/extensions/reset_password/repo_mock.ex index c02d13ef..147d5153 100644 --- a/test/support/extensions/reset_password/repo_mock.ex +++ b/test/support/extensions/reset_password/repo_mock.ex @@ -6,17 +6,18 @@ defmodule PowResetPassword.Test.RepoMock do def get_by(User, [email: "test@example.com"], _opts), do: @user def get_by(User, [email: "invalid@example.com"], _opts), do: nil + def get_by(User, [email: "missing@example.com"], _opts), do: %User{id: :missing} def update(%{valid?: true} = changeset, _opts) do user = Ecto.Changeset.apply_changes(changeset) # We store the user in the process because the user is force reloaded with `get!/2` - Process.put({:user, 1}, user) + Process.put({:user, user.id}, user) {:ok, user} end def update(%{valid?: false} = changeset, _opts), do: {:error, %{changeset | action: :update}} - def get!(User, 1, _opts), do: Process.get({:user, 1}) - def get!(User, :missing, _opts), do: nil + def get_by!(User, [id: 1], _opts), do: Process.get({:user, 1}) + def get_by!(User, [id: :missing], _opts), do: nil end diff --git a/test/support/message_verifier.ex b/test/support/message_verifier.ex new file mode 100644 index 00000000..0f2a272a --- /dev/null +++ b/test/support/message_verifier.ex @@ -0,0 +1,19 @@ +defmodule Pow.Test.MessageVerifier do + @moduledoc false + + @behaviour Pow.Plug.MessageVerifier + + @impl true + def sign(_conn, salt, message, _config), + do: "signed.#{salt}.#{message}" + + @impl true + def verify(_conn, salt, message, _config) do + prepend = "signed." <> salt <> "." + + case String.replace(message, prepend, "") do + ^message -> :error + message -> {:ok, message} + end + end +end diff --git a/test/support/mix/test_case.ex b/test/support/mix/test_case.ex index cba0fc67..ac13c1c2 100644 --- a/test/support/mix/test_case.ex +++ b/test/support/mix/test_case.ex @@ -8,7 +8,7 @@ defmodule Pow.Test.Mix.TestCase do :ok end - setup _context do + setup do current_shell = Mix.shell() on_exit fn -> diff --git a/test/support/phoenix/conn_case.ex b/test/support/phoenix/conn_case.ex index 3bf73fbd..28d04f73 100644 --- a/test/support/phoenix/conn_case.ex +++ b/test/support/phoenix/conn_case.ex @@ -16,7 +16,7 @@ defmodule Pow.Test.Phoenix.ConnCase do end end - setup _tags do + setup do EtsCacheMock.init() {:ok, conn: Phoenix.ConnTest.build_conn(), ets: EtsCacheMock} diff --git a/test/support/phoenix/endpoint.ex b/test/support/phoenix/endpoint.ex index e84415a4..c22c76a0 100644 --- a/test/support/phoenix/endpoint.ex +++ b/test/support/phoenix/endpoint.ex @@ -20,6 +20,20 @@ defmodule Pow.Test.Phoenix.Endpoint do @moduledoc false use Phoenix.Endpoint, otp_app: :pow + @session_options [ + store: :cookie, + key: "_binaryid_key", + signing_salt: "secret" + ] + + @pow_config [ + current_user_assigns_key: :current_user, + session_key: "auth", + cache_store_backend: Pow.Test.EtsCacheMock, + messages_backend: Pow.Test.Phoenix.Messages, + routes_backend: Pow.Test.Phoenix.Routes + ] + plug Plug.RequestId plug Plug.Logger @@ -31,17 +45,7 @@ defmodule Pow.Test.Phoenix.Endpoint do plug Plug.MethodOverride plug Plug.Head - plug Plug.Session, - store: :cookie, - key: "_binaryid_key", - signing_salt: "secret" - - plug SessionPlugHelper, - current_user_assigns_key: :current_user, - session_key: "auth", - cache_store_backend: Pow.Test.EtsCacheMock, - messages_backend: Pow.Test.Phoenix.Messages, - routes_backend: Pow.Test.Phoenix.Routes - + plug Plug.Session, @session_options + plug SessionPlugHelper, @pow_config plug Pow.Test.Phoenix.Router end diff --git a/test/support/process_store.ex b/test/support/process_store.ex index 997cb1aa..2add4941 100644 --- a/test/support/process_store.ex +++ b/test/support/process_store.ex @@ -1,6 +1,6 @@ defmodule Plug.ProcessStore do @moduledoc """ - Used with `Plug.Conn` sessions in tests. See `Pow.Test.ConnHelpers` for more. + Used with `Plug.Conn` sessions in tests. """ @behaviour Plug.Session.Store