defmodule Mix.Tasks.Phx.Gen.Auth do @shortdoc "Generates authentication logic for a resource" @moduledoc """ Generates authentication logic for a resource. mix phx.gen.auth Accounts User users The first argument is the context module followed by the schema module and its plural name (used as the schema table name). For more information about the generated authentication system, see the [Overview](overview.html). ## Password hashing The password hashing mechanism defaults to `bcrypt` for Unix systems and `pbkdf2` for Windows systems. Both systems use [the Comeonin interface](https://hexdocs.pm/comeonin/). The password hashing mechanism can be overriden with the `--hashing-lib` option. The following values are supported: * `bcrypt` - [bcrypt_elixir](https://hex.pm/packages/bcrypt_elixir) * `pbkdf2` - [pbkdf2_elixir](https://hex.pm/packages/pbkdf2_elixir) * `argon2` - [argon2_elixir](https://hex.pm/packages/argon2_elixir) For more information about choosing these libraries, see the [Comeonin project](https://github.com/riverrun/comeonin). ## Web namespace By default, the controllers and view will be namespaced by the schema name. You can customize the web module namespace by passing the `--web` flag with a module name, for example: mix phx.gen.auth Accounts User users --web Warehouse Which would generate the controllers, views, templates and associated tests in nested in the `MyAppWeb.Warehouse` namespace: * `lib/my_app_web/controllers/warehouse/user_auth.ex` * `lib/my_app_web/controllers/warehouse/user_confirmation_controller.ex` * `lib/my_app_web/views/warehouse/user_confirmation_view.ex` * `lib/my_app_web/templates/warehouse/user_confirmation/new.html.eex` * `test/my_app_web/controllers/warehouse/user_auth_test.exs` * `test/my_app_web/controllers/warehouse/user_confirmation_controller_test.exs` * and so on... ## Binary ids The `--binary-id` option causes the generated migration to use `binary_id` for its primary key and foreign keys. ## Default options This generator uses default options provided in the `:generators` configuration of your application. These are the defaults: config :your_app, :generators, binary_id: false, sample_binary_id: "11111111-1111-1111-1111-111111111111" You can override those options per invocation by providing corresponding switches, e.g. `--no-binary-id` to use normal ids despite the default configuration. ## Custom table names By default, the table name for the migration and schema will be the plural name provided for the resource. To customize this value, a `--table` option may be provided. For example: mix phx.gen.auth Accounts User users --table accounts_users This will cause the generated tables to be named `"accounts_users"` and `"accounts_users_tokens"`. """ use Mix.Task alias Mix.Phoenix.{Context, Schema} alias Mix.Tasks.Phx.Gen alias Mix.Phx.Gen.Auth.{HashingLibrary, Injector, Migration} @switches [web: :string, binary_id: :boolean, hashing_lib: :string, table: :string] @doc false def run(args) do if Mix.Project.umbrella?() do Mix.raise("mix phx.gen.auth can only be run inside an application directory") end {opts, parsed} = OptionParser.parse!(args, strict: @switches) validate_args!(parsed) hashing_library = build_hashing_library!(opts) context_args = OptionParser.to_argv(opts, switches: @switches) ++ parsed {context, schema} = Gen.Context.build(context_args, __MODULE__) Gen.Context.prompt_for_code_injection(context) # Needed so we can get the ecto adapter and ensure other # libraries are loaded. # # As far as I can tell, everything after this must be tested with an # integration test. Mix.Task.run("compile") validate_required_dependencies!() ecto_adapter = get_ecto_adapter!(schema) migration = Migration.build(ecto_adapter) binding = [ context: context, schema: schema, migration: migration, hashing_library: hashing_library, endpoint_module: Module.concat([context.web_module, Endpoint]), auth_module: Module.concat([context.web_module, schema.web_namespace, "#{inspect(schema.alias)}Auth"]), router_scope: router_scope(context), web_path_prefix: web_path_prefix(schema), test_case_options: test_case_options(ecto_adapter) ] paths = generator_paths() prompt_for_conflicts(context) context |> copy_new_files(binding, paths) |> inject_conn_case_helpers(paths, binding) |> inject_config(hashing_library) |> maybe_inject_mix_dependency(hashing_library) |> inject_routes(paths, binding) |> maybe_inject_router_import(binding) |> maybe_inject_router_plug() |> maybe_inject_app_layout_menu(binding) |> print_shell_instructions() end defp validate_args!([_, _, _]), do: :ok defp validate_args!(_) do raise_with_help("Invalid arguments") end defp validate_required_dependencies! do unless Code.ensure_loaded?(Ecto.Adapters.SQL) do raise_with_help("mix phx.gen.auth requires ecto_sql", :phx_generator_args) end if generated_with_no_html?() do raise_with_help("mix phx.gen.auth requires phoenix_html", :phx_generator_args) end end defp generated_with_no_html? do Mix.Project.config() |> Keyword.get(:deps, []) |> Enum.any?(fn {:phoenix_html, _} -> true {:phoenix_html, _, _} -> true _ -> false end) |> Kernel.not() end defp build_hashing_library!(opts) do opts |> Keyword.get_lazy(:hashing_lib, &default_hashing_library_option/0) |> HashingLibrary.build() |> case do {:ok, hashing_library} -> hashing_library {:error, {:unknown_library, unknown_library}} -> raise_with_help("Unknown value for --hashing-lib #{inspect(unknown_library)}", :hashing_lib) end end defp default_hashing_library_option do case :os.type() do {:unix, _} -> "bcrypt" {:win32, _} -> "pbkdf2" end end defp prompt_for_conflicts(context) do context |> files_to_be_generated() |> Mix.Phoenix.prompt_for_conflicts() end defp files_to_be_generated(%Context{schema: schema, context_app: context_app} = context) do web_prefix = Mix.Phoenix.web_path(context_app) web_test_prefix = Mix.Phoenix.web_test_path(context_app) migrations_prefix = Mix.Phoenix.context_app_path(context_app, "priv/repo/migrations") web_path = to_string(schema.web_path) context_app_prefix = Mix.Phoenix.context_app_path(context_app, "") [ {:eex, "context_fixtures.ex", Path.join([context_app_prefix, "test", "support", "fixtures", "#{context.basename}_fixtures.ex"])}, {:eex, "migration.ex", Path.join([migrations_prefix, "#{timestamp()}_create_#{schema.table}_auth_tables.exs"])}, {:eex, "notifier.ex", Path.join([context.dir, "#{schema.singular}_notifier.ex"])}, {:eex, "schema.ex", Path.join([context.dir, "#{schema.singular}.ex"])}, {:eex, "schema_token.ex", Path.join([context.dir, "#{schema.singular}_token.ex"])}, {:eex, "auth.ex", Path.join([web_prefix, "controllers", web_path, "#{schema.singular}_auth.ex"])}, {:eex, "auth_test.exs", Path.join([web_test_prefix, "controllers", web_path, "#{schema.singular}_auth_test.exs"])}, {:eex, "confirmation_view.ex", Path.join([web_prefix, "views", web_path, "#{schema.singular}_confirmation_view.ex"])}, {:eex, "confirmation_new.html.eex", Path.join([web_prefix, "templates", web_path, "#{schema.singular}_confirmation", "new.html.eex"])}, {:eex, "confirmation_controller.ex", Path.join([web_prefix, "controllers", web_path, "#{schema.singular}_confirmation_controller.ex"])}, {:eex, "confirmation_controller_test.exs", Path.join([web_test_prefix, "controllers", web_path, "#{schema.singular}_confirmation_controller_test.exs"])}, {:eex, "_menu.html.eex", Path.join([web_prefix, "templates", "layout", "_#{schema.singular}_menu.html.eex"])}, {:eex, "registration_new.html.eex", Path.join([web_prefix, "templates", web_path, "#{schema.singular}_registration", "new.html.eex"])}, {:eex, "registration_controller.ex", Path.join([web_prefix, "controllers", web_path, "#{schema.singular}_registration_controller.ex"])}, {:eex, "registration_controller_test.exs", Path.join([web_test_prefix, "controllers", web_path, "#{schema.singular}_registration_controller_test.exs"])}, {:eex, "registration_view.ex", Path.join([web_prefix, "views", web_path, "#{schema.singular}_registration_view.ex"])}, {:eex, "reset_password_view.ex", Path.join([web_prefix, "views", web_path, "#{schema.singular}_reset_password_view.ex"])}, {:eex, "reset_password_controller.ex", Path.join([web_prefix, "controllers", web_path, "#{schema.singular}_reset_password_controller.ex"])}, {:eex, "reset_password_controller_test.exs", Path.join([web_test_prefix, "controllers", web_path, "#{schema.singular}_reset_password_controller_test.exs"])}, {:eex, "reset_password_edit.html.eex", Path.join([web_prefix, "templates", web_path, "#{schema.singular}_reset_password", "edit.html.eex"])}, {:eex, "reset_password_new.html.eex", Path.join([web_prefix, "templates", web_path, "#{schema.singular}_reset_password", "new.html.eex"])}, {:eex, "session_view.ex", Path.join([web_prefix, "views", web_path, "#{schema.singular}_session_view.ex"])}, {:eex, "session_controller.ex", Path.join([web_prefix, "controllers", web_path, "#{schema.singular}_session_controller.ex"])}, {:eex, "session_controller_test.exs", Path.join([web_test_prefix, "controllers", web_path, "#{schema.singular}_session_controller_test.exs"])}, {:eex, "session_new.html.eex", Path.join([web_prefix, "templates", web_path, "#{schema.singular}_session", "new.html.eex"])}, {:eex, "settings_view.ex", Path.join([web_prefix, "views", web_path, "#{schema.singular}_settings_view.ex"])}, {:eex, "settings_edit.html.eex", Path.join([web_prefix, "templates", web_path, "#{schema.singular}_settings", "edit.html.eex"])}, {:eex, "settings_controller.ex", Path.join([web_prefix, "controllers", web_path, "#{schema.singular}_settings_controller.ex"])}, {:eex, "settings_controller_test.exs", Path.join([web_test_prefix, "controllers", web_path, "#{schema.singular}_settings_controller_test.exs"])} ] end defp copy_new_files(%Context{} = context, binding, paths) do files = files_to_be_generated(context) Mix.Phoenix.copy_from(paths, "priv/templates/phx.gen.auth", binding, files) inject_context_functions(context, paths, binding) inject_tests(context, paths, binding) context end defp inject_context_functions(%Context{file: file} = context, paths, binding) do unless Context.pre_existing?(context) do Mix.Generator.create_file(file, Mix.Phoenix.eval_from(paths, "priv/templates/phx.gen.context/context.ex", binding)) end paths |> Mix.Phoenix.eval_from("priv/templates/phx.gen.auth/context_functions.ex", binding) |> inject_before_final_end(file) end defp inject_tests(%Context{test_file: test_file} = context, paths, binding) do unless Context.pre_existing_tests?(context) do Mix.Generator.create_file(test_file, Mix.Phoenix.eval_from(paths, "priv/templates/phx.gen.context/context_test.exs", binding)) end paths |> Mix.Phoenix.eval_from("priv/templates/phx.gen.auth/test_cases.exs", binding) |> inject_before_final_end(test_file) end defp inject_conn_case_helpers(%Context{} = context, paths, binding) do test_file = "test/support/conn_case.ex" paths |> Mix.Phoenix.eval_from("priv/templates/phx.gen.auth/conn_case.exs", binding) |> inject_before_final_end(test_file) context end defp inject_routes(%Context{context_app: ctx_app} = context, paths, binding) do web_prefix = Mix.Phoenix.web_path(ctx_app) file_path = Path.join(web_prefix, "router.ex") paths |> Mix.Phoenix.eval_from("priv/templates/phx.gen.auth/routes.ex", binding) |> inject_before_final_end(file_path) context end defp maybe_inject_mix_dependency(%Context{context_app: ctx_app} = context, %HashingLibrary{mix_dependency: mix_dependency}) do file_path = Mix.Phoenix.context_app_path(ctx_app, "mix.exs") file = File.read!(file_path) case Injector.inject_mix_dependency(file, mix_dependency) do {:ok, new_file} -> print_injecting(file_path) File.write!(file_path, new_file) :already_injected -> :ok {:error, :unable_to_inject} -> Mix.shell().info(""" Add your #{inspect(mix_dependency)} dependency to #{file_path}: defp deps do [ #{mix_dependency}, ... ] end """) end context end defp maybe_inject_router_import(%Context{context_app: ctx_app} = context, binding) do web_prefix = Mix.Phoenix.web_path(ctx_app) file_path = Path.join(web_prefix, "router.ex") auth_module = Keyword.fetch!(binding, :auth_module) inject = "import #{inspect(auth_module)}" use_line = "use #{inspect(context.web_module)}, :router" help_text = """ Add your #{inspect(auth_module)} import to #{Path.relative_to_cwd(file_path)}: defmodule #{inspect(context.web_module)}.Router do #{use_line} # Import authentication plugs #{inject} ... end """ with {:ok, file} <- read_file(file_path), {:ok, new_file} <- Injector.inject_unless_contains(file, inject, &String.replace(&1, use_line, "#{use_line}\n\n #{&2}")) do print_injecting(file_path, " - imports") File.write!(file_path, new_file) else :already_injected -> :ok {:error, :unable_to_inject} -> Mix.shell().info(""" #{help_text} """) {:error, {:file_read_error, _}} -> print_injecting(file_path) print_unable_to_read_file_error(file_path, help_text) end context end defp maybe_inject_router_plug(%Context{context_app: ctx_app, schema: schema} = context) do web_prefix = Mix.Phoenix.web_path(ctx_app) file_path = Path.join(web_prefix, "router.ex") plug_name = ":fetch_current_#{schema.singular}" inject = "plug #{plug_name}" anchor_line = "plug :put_secure_browser_headers" help_text = """ Add the #{plug_name} plug to the browser pipeline in #{Path.relative_to_cwd(file_path)}: pipeline :browser do ... #{anchor_line} #{inject} end """ with {:ok, file} <- read_file(file_path), {:ok, new_file} <- Injector.inject_unless_contains(file, inject, &String.replace(&1, anchor_line, "#{anchor_line}\n #{&2}")) do print_injecting(file_path, " - plug") File.write!(file_path, new_file) else :already_injected -> :ok {:error, :unable_to_inject} -> Mix.shell().info(""" #{help_text} """) {:error, {:file_read_error, _}} -> print_injecting(file_path) print_unable_to_read_file_error(file_path, help_text) end context end defp maybe_inject_app_layout_menu(%Context{} = context, binding) do schema = Keyword.fetch!(binding, :schema) menu_name = "_#{schema.singular}_menu.html" inject = "<%= render \"#{menu_name}\", assigns %>" if file_path = get_layout_html_path(context) do file = File.read!(file_path) case Injector.inject_unless_contains(file, inject, &String.replace(&1, "
", "\n #{&2}")) do {:ok, new_file} -> print_injecting(file_path) File.write!(file_path, new_file) :already_injected -> :ok {:error, :unable_to_inject} -> Mix.shell().info(""" Add a render call for #{inspect(menu_name)} to #{file_path}: """) end else Mix.shell().error(""" Unable to find an application layout file to inject a render call for #{inspect(menu_name)}. Missing files: #{ context |> potential_layout_file_paths() |> Enum.map(&" * #{&1}") |> Enum.join("\n") } Please ensure this phoenix app was not generated with --no-html. If you have changed the name of your application layout file, please add the following code to it where you'd like #{inspect(menu_name)} to be rendered. #{inject} """) end context end defp get_layout_html_path(%Context{} = context) do context |> potential_layout_file_paths() |> Enum.find(&File.exists?/1) end defp potential_layout_file_paths(%Context{context_app: ctx_app}) do web_prefix = Mix.Phoenix.web_path(ctx_app) for file_name <- ~w(root.html.leex app.html.eex) do Path.join([web_prefix, "templates", "layout", file_name]) end end defp inject_config(context, %HashingLibrary{test_config: test_config}) do project_path = if Mix.Phoenix.in_umbrella?(File.cwd!()) do Path.expand("../../") else File.cwd!() end config_inject(project_path, "config/test.exs", """ # Only in tests, remove the complexity from the password hashing algorithm #{test_config} """) context end defp config_inject(path, file, to_inject) do file = Path.join(path, file) print_injecting(file) contents = case File.read(file) do {:ok, bin} -> bin {:error, _} -> "use Mix.Config\n" end with :error <- split_with_self(contents, "use Mix.Config\n"), :error <- split_with_self(contents, "import Config\n") do Mix.raise(~s[Could not find "use Mix.Config" or "import Config" in #{inspect(file)}]) else [left, middle, right] -> File.write!(file, [left, middle, ?\n, String.trim(to_inject), ?\n, right]) end end defp split_with_self(contents, text) do case :binary.split(contents, text) do [left, right] -> [left, text, right] [_] -> :error end end defp print_shell_instructions(%Context{} = context) do Mix.shell().info(""" Please re-fetch your dependencies with the following command: mix deps.get """) Mix.shell().info(""" Remember to update your repository by running migrations: $ mix ecto.migrate """) context end defp router_scope(%Context{schema: schema} = context) do prefix = Module.concat(context.web_module, schema.web_namespace) if schema.web_namespace do ~s|"/#{schema.web_path}", #{inspect(prefix)}, as: :#{schema.web_path}| else ~s|"/", #{inspect(context.web_module)}| end end defp web_path_prefix(%Schema{web_path: nil}), do: "" defp web_path_prefix(%Schema{web_path: web_path}), do: "/" <> web_path # The paths to look for template files for generators. # # Defaults to checking the current app's `priv` directory, # and falls back to phx_gen_auth's `priv` directory. defp generator_paths do [".", :phx_gen_auth, :phoenix] end defp inject_before_final_end(content_to_inject, file_path) do with {:ok, file} <- read_file(file_path), {:ok, new_file} <- Injector.inject_before_final_end(file, content_to_inject) do print_injecting(file_path) File.write!(file_path, new_file) else :already_injected -> :ok {:error, {:file_read_error, _}} -> print_injecting(file_path) print_unable_to_read_file_error( file_path, """ Please add the following to the end of your equivalent #{Path.relative_to_cwd(file_path)} module: #{indent_spaces(content_to_inject, 2)} """ ) end end defp read_file(file_path) do case File.read(file_path) do {:ok, file} -> {:ok, file} {:error, reason} -> {:error, {:file_read_error, reason}} end end defp indent_spaces(string, number_of_spaces) when is_binary(string) and is_integer(number_of_spaces) do indent = String.duplicate(" ", number_of_spaces) string |> String.split("\n") |> Enum.map(&(indent <> &1)) |> Enum.join("\n") end defp timestamp do {{y, m, d}, {hh, mm, ss}} = :calendar.universal_time() "#{y}#{pad(m)}#{pad(d)}#{pad(hh)}#{pad(mm)}#{pad(ss)}" end defp pad(i) when i < 10, do: <0, ?0 + i>> defp pad(i), do: to_string(i) defp get_ecto_adapter!(%Schema{repo: repo}) do if Code.ensure_loaded?(repo) do repo.__adapter__() else Mix.raise("Unable to find #{inspect(repo)}") end end defp print_injecting(file_path, suffix \\ []) do Mix.shell().info([:green, "* injecting ", :reset, Path.relative_to_cwd(file_path), suffix]) end defp print_unable_to_read_file_error(file_path, help_text) do Mix.shell().error( """ Unable to read file #{Path.relative_to_cwd(file_path)}. #{help_text} """ |> indent_spaces(2) ) end def raise_with_help(msg) do raise_with_help(msg, :general) end defp raise_with_help(msg, :general) do Mix.raise(""" #{msg} mix phx.gen.auth expects a context module name, followed by the schema module and its plural name (used as the schema table name). For example: mix phx.gen.auth Accounts User users The context serves as the API boundary for the given resource. Multiple resources may belong to a context and a resource may be split over distinct contexts (such as Accounts.User and Payments.User). """) end defp raise_with_help(msg, :phx_generator_args) do Mix.raise(""" #{msg} mix phx.gen.auth must be installed into a Phoenix 1.5 app that contains ecto and html templates. mix phx.new my_app mix phx.new my_app --umbrella mix phx.new my_app --database mysql Apps generated with --no-ecto and --no-html are not supported. """) end defp raise_with_help(msg, :hashing_lib) do Mix.raise(""" #{msg} mix phx.gen.auth supports the following values for --hashing-lib * bcrypt * pbkdf2 * argon2 Visit https://github.com/riverrun/comeonin for more information on choosing a library. """) end defp test_case_options(Ecto.Adapters.Postgres), do: ", async: true" defp test_case_options(adapter) when is_atom(adapter), do: "" end