├── .formatter.exs ├── .gitignore ├── .tool-versions ├── .travis.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── config ├── config.exs ├── dev.exs ├── prod.exs └── test.exs ├── lib ├── coherence.ex ├── coherence │ ├── config.ex │ ├── controllers │ │ ├── confirmation_controller.ex │ │ ├── confirmation_controller_base.ex │ │ ├── controller.ex │ │ ├── invitation_controller.ex │ │ ├── invitation_controller_base.ex │ │ ├── password_controller.ex │ │ ├── password_controller_base.ex │ │ ├── registration_controller.ex │ │ ├── registration_controller_base.ex │ │ ├── session_controller.ex │ │ ├── session_controller_base.ex │ │ ├── unlock_controller.ex │ │ └── unlock_controller_base.ex │ ├── messages.ex │ ├── plugs │ │ ├── authentication │ │ │ ├── basic.ex │ │ │ ├── credential_store.ex │ │ │ ├── credential_store │ │ │ │ ├── server.ex │ │ │ │ ├── session.ex │ │ │ │ └── types.ex │ │ │ ├── db_store.ex │ │ │ ├── ip_address.ex │ │ │ ├── session.ex │ │ │ ├── token.ex │ │ │ └── utils.ex │ │ ├── require_login.ex │ │ └── validate_option.ex │ ├── redirects.ex │ ├── rememberable_server.ex │ ├── responders.ex │ ├── responders │ │ ├── html.ex │ │ └── json.ex │ ├── router.ex │ ├── schema.ex │ ├── schemas.ex │ ├── schemas │ │ └── rememberable.ex │ ├── services │ │ ├── confirmable_service.ex │ │ ├── lockable_service.ex │ │ ├── password_service.ex │ │ ├── rememberable_service.ex │ │ ├── session_service.ex │ │ └── trackable_service.ex │ ├── supervisor.ex │ └── web.ex └── mix │ ├── mix_utils.ex │ └── tasks │ ├── coh.clean.ex │ ├── coh.gen.controllers.ex │ └── coh.install.ex ├── mix.exs ├── mix.lock ├── priv ├── gettext │ └── coherence.pot └── templates │ ├── coh.gen.controllers │ └── controllers │ │ └── coherence │ │ ├── confirmation_controller.ex │ │ ├── invitation_controller.ex │ │ ├── password_controller.ex │ │ ├── registration_controller.ex │ │ ├── session_controller.ex │ │ └── unlock_controller.ex │ └── coh.install │ ├── coherence_messages.ex │ ├── coherence_web.ex │ ├── controllers │ └── coherence │ │ ├── redirects.ex │ │ └── responders │ │ ├── html.ex │ │ └── json.ex │ ├── emails │ └── coherence │ │ ├── coherence_mailer.ex │ │ └── user_email.ex │ ├── models │ └── coherence │ │ ├── invitation.ex │ │ ├── rememberable.ex │ │ ├── schemas.ex │ │ ├── trackable.ex │ │ └── user.ex │ ├── templates │ └── coherence │ │ ├── confirmation │ │ └── new.html.eex │ │ ├── email │ │ ├── confirmation.html.eex │ │ ├── invitation.html.eex │ │ ├── password.html.eex │ │ ├── reconfirmation.html.eex │ │ └── unlock.html.eex │ │ ├── invitation │ │ ├── edit.html.eex │ │ └── new.html.eex │ │ ├── layout │ │ ├── app.html.eex │ │ └── email.html.eex │ │ ├── password │ │ ├── edit.html.eex │ │ └── new.html.eex │ │ ├── registration │ │ ├── edit.html.eex │ │ ├── form.html.eex │ │ ├── new.html.eex │ │ └── show.html.eex │ │ ├── session │ │ └── new.html.eex │ │ └── unlock │ │ └── new.html.eex │ └── views │ └── coherence │ ├── coherence_view.ex │ ├── coherence_view_helpers.ex │ ├── confirmation_view.ex │ ├── email_view.ex │ ├── invitation_view.ex │ ├── layout_view.ex │ ├── password_view.ex │ ├── registration_view.ex │ ├── session_view.ex │ └── unlock_view.ex └── test ├── coherence_test.exs ├── config_test.exs ├── controllers ├── confirmation_controller.exs ├── controller_helpers_test.exs ├── invitation_controller_test.exs ├── password_controller_test.exs ├── registration_controller_test.exs ├── rememberable_test.exs ├── session_controller_test.exs └── unlock_controller_test.exs ├── mix └── tasks │ ├── coh.clean_test.exs │ ├── coh.gen.controllers_test.exs │ └── coh.install_test.exs ├── mix_helpers.exs ├── models └── rememberable_test.exs ├── plugs └── authentication │ ├── basic_test.exs │ ├── credential_store │ └── server_test.exs │ ├── ip_address_test.exs │ ├── session_test.exs │ └── token_test.exs ├── schema_test.exs ├── services ├── lockable_service_test.exs ├── password_service_test.exs └── trackable_service_test.exs ├── support ├── conn_case.ex ├── dummy_controller.ex ├── email.ex ├── endpoint.ex ├── gettext.ex ├── messages.ex ├── migrations.ex ├── model_case.ex ├── redirect.ex ├── repo.ex ├── responders │ ├── html.ex │ └── json.ex ├── router.ex ├── schema.ex ├── schemas.ex ├── templates │ ├── invitation │ │ ├── edit.html.eex │ │ └── new.html.eex │ └── layout │ │ └── app.html.eex ├── test_conn.ex ├── test_helpers.ex ├── view_helpers.ex ├── views.ex └── web.ex ├── test_helper.exs └── view_helpers_test.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | locals_without_parens = [ 2 | # Phoenix.Channel 3 | intercept: 1, 4 | 5 | # Phoenix.Router 6 | connect: 3, 7 | connect: 4, 8 | delete: 3, 9 | delete: 4, 10 | forward: 2, 11 | forward: 3, 12 | forward: 4, 13 | get: 3, 14 | get: 4, 15 | head: 3, 16 | head: 4, 17 | match: 4, 18 | match: 5, 19 | options: 3, 20 | options: 4, 21 | patch: 3, 22 | patch: 4, 23 | pipeline: 2, 24 | pipe_through: 1, 25 | post: 3, 26 | post: 4, 27 | put: 3, 28 | put: 4, 29 | resources: 2, 30 | resources: 3, 31 | resources: 4, 32 | trace: 4, 33 | 34 | # Phoenix.Controller 35 | action_fallback: 1, 36 | 37 | # Phoenix.Endpoint 38 | plug: 1, 39 | plug: 2, 40 | socket: 2, 41 | socket: 3, 42 | 43 | # Phoenix.Socket 44 | channel: 2, 45 | channel: 3, 46 | 47 | # Phoenix.ChannelTest 48 | assert_broadcast: 2, 49 | assert_broadcast: 3, 50 | assert_push: 2, 51 | assert_push: 3, 52 | assert_reply: 2, 53 | assert_reply: 3, 54 | assert_reply: 4, 55 | refute_broadcast: 2, 56 | refute_broadcast: 3, 57 | refute_push: 2, 58 | refute_push: 3, 59 | refute_reply: 2, 60 | refute_reply: 3, 61 | refute_reply: 4, 62 | 63 | # Phoenix.ConnTest 64 | assert_error_sent: 2 65 | ] 66 | 67 | [ 68 | locals_without_parens: locals_without_parens, 69 | export: [locals_without_parens: locals_without_parens], 70 | inputs: [ 71 | "*.{ex,exs}", 72 | "{config,lib,test}/**/*.{ex,exs}" 73 | ] 74 | ] 75 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /doc 3 | /cover 4 | /deps 5 | erl_crash.dump 6 | *.ez 7 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 25.3.2.5 2 | elixir 1.14.5-otp-25 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | services: 2 | - postgresql 3 | before_script: 4 | - psql -c 'create database coherence_test;' -U postgres 5 | language: elixir 6 | elixir: 7 | - 1.6 8 | - 1.5 9 | - 1.4 10 | otp_release: 11 | - 20.0 12 | matrix: 13 | sudo: false 14 | notification: 15 | recipients: 16 | - smpallen99@gmail.com 17 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, and in the interest of fostering an open and welcoming community, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, or nationality. 6 | 7 | Examples of unacceptable behavior by participants include: 8 | 9 | * The use of sexualized language or imagery 10 | * Personal attacks 11 | * Trolling or insulting/derogatory comments 12 | * Public or private harassment 13 | * Publishing other's private information, such as physical or electronic addresses, without explicit permission 14 | * Other unethical or unprofessional conduct. 15 | 16 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. By adopting this Code of Conduct, project maintainers commit themselves to fairly and consistently applying these principles to every aspect of managing this project. Project maintainers who do not follow or enforce the Code of Conduct may be permanently removed from the project team. 17 | 18 | This code of conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. 19 | 20 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 21 | 22 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.2.0, available at [http://contributor-covenant.org/version/1/2/0/](http://contributor-covenant.org/version/1/2/0/) 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-2023 E-MetroTel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | import Config 4 | 5 | config :coherence, Coherence.Mailer, 6 | adapter: Swoosh.Adapters.Sendgrid, 7 | api_key: "" 8 | 9 | config :phoenix, :json_library, Jason 10 | 11 | import_config "#{Mix.env()}.exs" 12 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # config :coherence, ecto_repos: [TestCoherence.Repo] 4 | 5 | config :logger, level: :error 6 | 7 | config :coherence, TestCoherenceWeb.Endpoint, 8 | http: [port: 4001], 9 | secret_key_base: "HL0pikQMxNSA58Dv4mf26O/eh1e4vaJDmX0qLgqBcnS94gbKu9Xn3x114D+mHYcX", 10 | server: false 11 | 12 | config :coherence, ecto_repos: [TestCoherence.Repo] 13 | 14 | config :coherence, TestCoherence.Repo, 15 | adapter: Ecto.Adapters.Postgres, 16 | username: System.get_env("DB_USERNAME") || "postgres", 17 | password: System.get_env("DB_PASSWORD") || "postgres", 18 | database: "coherence_test", 19 | hostname: System.get_env("DB_HOSTNAME") || "localhost", 20 | pool: Ecto.Adapters.SQL.Sandbox 21 | 22 | config :coherence, 23 | user_schema: TestCoherence.User, 24 | password_hashing_alg: Comeonin.Bcrypt, 25 | repo: TestCoherence.Repo, 26 | router: TestCoherenceWeb.Router, 27 | module: TestCoherence, 28 | web_module: TestCoherenceWeb, 29 | layout: {Coherence.LayoutView, :app}, 30 | messages_backend: TestCoherenceWeb.Coherence.Messages, 31 | logged_out_url: "/", 32 | email_from_name: "Your Name", 33 | email_from_email: "yourname@example.com", 34 | opts: [ 35 | :confirmable, 36 | :authenticatable, 37 | :recoverable, 38 | :lockable, 39 | :trackable, 40 | :unlockable_with_token, 41 | :invitable, 42 | :registerable, 43 | :rememberable 44 | ], 45 | registration_permitted_attributes: [ 46 | "email", 47 | "name", 48 | "password", 49 | "password_confirmation", 50 | "current_password" 51 | ], 52 | invitation_permitted_attributes: ["name", "email"], 53 | password_reset_permitted_attributes: [ 54 | "reset_password_token", 55 | "password", 56 | "password_confirmation" 57 | ], 58 | session_permitted_attributes: ["remember", "email", "password"], 59 | confirm_email_updates: true 60 | 61 | config :bcrypt_elixir, log_rounds: 4 62 | -------------------------------------------------------------------------------- /lib/coherence/controllers/confirmation_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule Coherence.ConfirmationController do 2 | @moduledoc """ 3 | Handle confirmation actions. 4 | 5 | A single action, `edit`, is required for the confirmation module. 6 | 7 | """ 8 | use CoherenceWeb, :controller 9 | use Coherence.ConfirmationControllerBase, schemas: Coherence.Schemas 10 | 11 | plug Coherence.ValidateOption, :confirmable 12 | plug :layout_view, view: Coherence.ConfirmationView, caller: __MODULE__ 13 | plug :redirect_logged_in when action in [:new] 14 | end 15 | -------------------------------------------------------------------------------- /lib/coherence/controllers/confirmation_controller_base.ex: -------------------------------------------------------------------------------- 1 | defmodule Coherence.ConfirmationControllerBase do 2 | @moduledoc """ 3 | Handle confirmation actions. 4 | 5 | A single action, `edit`, is required for the confirmation module. 6 | 7 | """ 8 | defmacro __using__(opts) do 9 | quote location: :keep do 10 | use Timex 11 | 12 | alias Coherence.{ConfirmableService, Messages, Controller, Schema} 13 | alias Coherence.Schemas 14 | 15 | require Coherence.Config, as: Config 16 | require Logger 17 | 18 | @schemas unquote(opts)[:schemas] || raise("Schemas option required") 19 | 20 | def schema(which), do: Coherence.Schemas.schema(which) 21 | 22 | @doc """ 23 | Handle resending a confirmation email. 24 | 25 | Request the user's email, reset the confirmation token and resend the email. 26 | """ 27 | @spec new(Plug.Conn.t(), map()) :: Plug.Conn.t() 28 | def new(conn, _params) do 29 | user_schema = Config.user_schema() 30 | cs = Controller.changeset(:confirmation, user_schema, user_schema.__struct__) 31 | 32 | conn 33 | |> render(:new, email: "", changeset: cs) 34 | end 35 | 36 | @doc """ 37 | Create a new confirmation token and resend the email. 38 | """ 39 | @spec create(Plug.Conn.t(), map()) :: Plug.Conn.t() 40 | def create(conn, %{"confirmation" => password_params} = params) do 41 | user_schema = Config.user_schema() 42 | email = password_params["email"] 43 | user = @schemas.get_user_by_email(email) 44 | 45 | changeset = Controller.changeset(:confirmation, user_schema, user_schema.__struct__) 46 | 47 | case user do 48 | nil -> 49 | conn 50 | |> respond_with( 51 | :confirmation_create_error, 52 | %{ 53 | changeset: changeset, 54 | error: Messages.backend().could_not_find_that_email_address() 55 | } 56 | ) 57 | 58 | user -> 59 | if user_schema.confirmed?(user) do 60 | conn 61 | |> respond_with( 62 | :confirmation_create_error, 63 | %{ 64 | changeset: changeset, 65 | email: "", 66 | error: Messages.backend().account_already_confirmed() 67 | } 68 | ) 69 | else 70 | conn 71 | |> send_confirmation(user, user_schema) 72 | |> respond_with(:confirmation_create_success, %{params: params}) 73 | end 74 | end 75 | end 76 | 77 | @doc """ 78 | Handle the user's click on the confirm link in the confirmation email. 79 | 80 | Validate that the confirmation token has not expired and sets `confirmation_sent_at` 81 | field to nil, marking the user as confirmed. 82 | """ 83 | @spec edit(Plug.Conn.t(), map()) :: Plug.Conn.t() 84 | def edit(conn, params) do 85 | user_schema = Config.user_schema() 86 | token = params["id"] 87 | 88 | user = @schemas.get_by_user(confirmation_token: token) 89 | 90 | case user do 91 | nil -> 92 | changeset = Controller.changeset(:confirmation, user_schema, user_schema.__struct__) 93 | 94 | conn 95 | |> respond_with( 96 | :confirmation_update_invalid, 97 | %{ 98 | params: params, 99 | error: Messages.backend().invalid_confirmation_token() 100 | } 101 | ) 102 | 103 | user -> 104 | if ConfirmableService.expired?(user) do 105 | conn 106 | |> respond_with( 107 | :confirmation_update_expired, 108 | %{ 109 | params: params, 110 | error: Messages.backend().confirmation_token_expired() 111 | } 112 | ) 113 | else 114 | attrs = 115 | if Config.get(:confirm_email_updates) do 116 | %{ 117 | email: user.unconfirmed_email, 118 | unconfirmed_email: nil 119 | } 120 | else 121 | %{} 122 | end 123 | |> Map.merge(%{ 124 | confirmation_token: nil, 125 | confirmed_at: NaiveDateTime.utc_now() 126 | }) 127 | 128 | changeset = Controller.changeset(:confirmation, user_schema, user, attrs) 129 | 130 | case Config.repo().update(changeset) do 131 | {:ok, _user} -> 132 | conn 133 | |> respond_with( 134 | :confirmation_update_success, 135 | %{ 136 | params: params, 137 | info: Messages.backend().user_account_confirmed_successfully() 138 | } 139 | ) 140 | 141 | {:error, _changeset} -> 142 | conn 143 | |> respond_with( 144 | :confirmation_update_error, 145 | %{ 146 | params: params, 147 | error: Messages.backend().problem_confirming_user_account() 148 | } 149 | ) 150 | end 151 | end 152 | end 153 | end 154 | 155 | defoverridable(new: 2, create: 2, edit: 2) 156 | end 157 | end 158 | end 159 | -------------------------------------------------------------------------------- /lib/coherence/controllers/invitation_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule Coherence.InvitationController do 2 | @moduledoc """ 3 | Handle invitation actions. 4 | 5 | Handle the following actions: 6 | 7 | * new - render the send invitation form. 8 | * create - generate and send the invitation token. 9 | * edit - render the form after user clicks the invitation email link. 10 | * create_user - create a new user database record 11 | * resend - resend an invitation token email 12 | """ 13 | use CoherenceWeb, :controller 14 | use Coherence.InvitationControllerBase, schemas: Coherence.Schemas 15 | 16 | plug Coherence.ValidateOption, :invitable 17 | plug :scrub_params, "user" when action in [:create_user] 18 | plug :layout_view, view: Coherence.InvitationView, caller: __MODULE__ 19 | end 20 | -------------------------------------------------------------------------------- /lib/coherence/controllers/password_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule Coherence.PasswordController do 2 | @moduledoc """ 3 | Handle password recovery actions. 4 | 5 | Controller that handles the recover password feature. 6 | 7 | Actions: 8 | 9 | * new - render the recover password form 10 | * create - verify user's email address, generate a token, and send the email 11 | * edit - render the reset password form 12 | * update - verify password, password confirmation, and update the database 13 | """ 14 | use CoherenceWeb, :controller 15 | use Coherence.PasswordControllerBase, schemas: Coherence.Schemas 16 | 17 | plug :layout_view, view: Coherence.PasswordView, caller: __MODULE__ 18 | plug :redirect_logged_in when action in [:new, :create, :edit, :update] 19 | end 20 | -------------------------------------------------------------------------------- /lib/coherence/controllers/registration_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule Coherence.RegistrationController do 2 | @moduledoc """ 3 | Handle account registration actions. 4 | 5 | Actions: 6 | 7 | * new - render the register form 8 | * create - create a new user account 9 | * edit - edit the user account 10 | * update - update the user account 11 | * delete - delete the user account 12 | """ 13 | use CoherenceWeb, :controller 14 | use Coherence.RegistrationControllerBase, schemas: Coherence.Schemas 15 | 16 | plug Coherence.RequireLogin when action in ~w(show edit update delete)a 17 | plug Coherence.ValidateOption, :registerable 18 | plug :scrub_params, "registration" when action in [:create, :update] 19 | 20 | plug :layout_view, view: Coherence.RegistrationView, caller: __MODULE__ 21 | plug :redirect_logged_in when action in [:new, :create] 22 | end 23 | -------------------------------------------------------------------------------- /lib/coherence/controllers/session_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule Coherence.SessionController do 2 | @moduledoc """ 3 | Handle the authentication actions. 4 | 5 | Module used for the session controller when the parent project does not 6 | generate controllers. Most of the work is done by the 7 | `Coherence.SessionControllerBase` inclusion. 8 | """ 9 | use CoherenceWeb, :controller 10 | use Coherence.SessionControllerBase, schemas: Coherence.Schemas 11 | 12 | plug :layout_view, view: Coherence.SessionView, caller: __MODULE__ 13 | plug :redirect_logged_in when action in [:new, :create] 14 | end 15 | -------------------------------------------------------------------------------- /lib/coherence/controllers/unlock_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule Coherence.UnlockController do 2 | @moduledoc """ 3 | Handle unlock_with_token actions. 4 | 5 | This controller provides the ability generate an unlock token, send 6 | the user an email and unlocking the account with a valid token. 7 | 8 | Basic locking and unlocking does not use this controller. 9 | """ 10 | use CoherenceWeb, :controller 11 | use Coherence.UnlockControllerBase, schemas: Coherence.Schemas 12 | 13 | plug Coherence.ValidateOption, :unlockable_with_token 14 | plug :layout_view, view: Coherence.UnlockView, caller: __MODULE__ 15 | plug :redirect_logged_in when action in [:new, :create, :edit] 16 | end 17 | -------------------------------------------------------------------------------- /lib/coherence/controllers/unlock_controller_base.ex: -------------------------------------------------------------------------------- 1 | defmodule Coherence.UnlockControllerBase do 2 | @moduledoc """ 3 | Handle unlock_with_token actions. 4 | 5 | This controller provides the ability generate an unlock token, send 6 | the user an email and unlocking the account with a valid token. 7 | 8 | Basic locking and unlocking does not use this controller. 9 | """ 10 | defmacro __using__(opts) do 11 | quote location: :keep do 12 | use Timex 13 | use Coherence.Config 14 | 15 | alias Coherence.{TrackableService, LockableService, Messages, Schema, Controller} 16 | 17 | require Coherence.Config, as: Config 18 | require Logger 19 | 20 | @schemas unquote(opts)[:schemas] || raise("Schemas option required") 21 | 22 | @type schema :: Ecto.Schema.t() 23 | @type conn :: Plug.Conn.t() 24 | @type params :: map() 25 | 26 | def schema(which), do: Coherence.Schemas.schema(which) 27 | 28 | @doc """ 29 | Render the send reset link form. 30 | """ 31 | @spec new(conn, params) :: conn 32 | def new(conn, _params) do 33 | user_schema = Config.user_schema() 34 | changeset = Controller.changeset(:unlock, user_schema, user_schema.__struct__) 35 | render(conn, "new.html", changeset: changeset) 36 | end 37 | 38 | @doc """ 39 | Create and send the unlock token. 40 | """ 41 | @spec create(conn, params) :: conn 42 | def create(conn, %{"unlock" => unlock_params} = params) do 43 | user_schema = Config.user_schema() 44 | email = unlock_params["email"] 45 | password = unlock_params["password"] 46 | 47 | user = @schemas.get_user_by_email(email) 48 | 49 | if user != nil and user_schema.checkpw(password, Map.get(user, Config.password_hash())) do 50 | case LockableService.unlock_token(user) do 51 | {:ok, user} -> 52 | if user_schema.locked?(user) do 53 | info = Messages.backend().unlock_instructions_sent() 54 | 55 | send_function = fn -> 56 | send_user_email( 57 | :unlock, 58 | user, 59 | router_helpers().unlock_url(conn, :edit, user.unlock_token) 60 | ) 61 | end 62 | 63 | conn 64 | |> send_email_if_mailer(info, send_function) 65 | |> respond_with(:unlock_create_success, %{params: params, user: user}) 66 | else 67 | error = Messages.backend().your_account_is_not_locked() 68 | 69 | conn 70 | |> respond_with(:unlock_create_error_not_locked, %{params: params, error: error}) 71 | end 72 | 73 | {:error, changeset} -> 74 | respond_with(conn, :unlock_create_error, %{changeset: changeset}) 75 | end 76 | else 77 | respond_with( 78 | conn, 79 | :unlock_create_error, 80 | %{params: params, error: Messages.backend().invalid_email_or_password()} 81 | ) 82 | end 83 | end 84 | 85 | @doc """ 86 | Handle the unlock link click. 87 | """ 88 | @spec edit(conn, params) :: conn 89 | def edit(conn, params) do 90 | user_schema = Config.user_schema() 91 | token = params["id"] 92 | 93 | case @schemas.get_by_user(unlock_token: token) do 94 | nil -> 95 | respond_with(conn, :unlock_update_error, %{ 96 | params: params, 97 | error: Messages.backend().invalid_unlock_token() 98 | }) 99 | 100 | user -> 101 | if user_schema.locked?(user) do 102 | Controller.unlock!(user) 103 | 104 | conn 105 | |> TrackableService.track_unlock_token(user, user_schema.trackable_table?) 106 | |> respond_with(:unlock_update_success, %{ 107 | params: params, 108 | info: Messages.backend().your_account_has_been_unlocked() 109 | }) 110 | else 111 | clear_unlock_values(user, user_schema) 112 | 113 | respond_with( 114 | conn, 115 | :unlock_update_error_not_locked, 116 | %{error: Messages.backend().account_is_not_locked()} 117 | ) 118 | end 119 | end 120 | end 121 | 122 | @doc false 123 | @spec clear_unlock_values(schema, module) :: nil | :ok | String.t() 124 | def clear_unlock_values(user, user_schema) do 125 | if user.unlock_token or user.locked_at do 126 | schema = 127 | :unlock 128 | |> Controller.changeset(user_schema, user, %{unlock_token: nil, locked_at: nil}) 129 | |> @schemas.update 130 | 131 | case schema do 132 | {:error, changeset} -> 133 | lockable_failure(changeset) 134 | 135 | _ -> 136 | :ok 137 | end 138 | end 139 | end 140 | 141 | defoverridable(clear_unlock_values: 2, new: 2, create: 2, edit: 2) 142 | end 143 | end 144 | end 145 | -------------------------------------------------------------------------------- /lib/coherence/messages.ex: -------------------------------------------------------------------------------- 1 | defmodule Coherence.Messages do 2 | @moduledoc """ 3 | Interface for handling localization of build in Coherence messages. 4 | 5 | The following module defines the behaviour for rendering internal 6 | coherence messages. 7 | 8 | The coherence mix tasks generate a messages file in the user's app 9 | that uses this behaviour to ensure the user has implement all the 10 | required messages. 11 | """ 12 | 13 | @callback cant_be_blank() :: binary 14 | @callback invalid_current_password() :: binary 15 | @callback account_already_confirmed() :: binary 16 | @callback account_is_not_locked() :: binary 17 | @callback account_updated_successfully() :: binary 18 | @callback account_created_successfully() :: binary 19 | @callback already_logged_in() :: binary 20 | @callback cant_find_that_token() :: binary 21 | @callback confirmation_token_expired() :: binary 22 | @callback could_not_find_that_email_address() :: binary 23 | @callback forgot_your_password() :: binary 24 | @callback http_authentication_required() :: binary 25 | @callback incorrect_login_or_password([{atom, any}]) :: binary 26 | @callback invalid_invitation() :: binary 27 | @callback invalid_request() :: binary 28 | @callback invalid_confirmation_token() :: binary 29 | @callback invalid_email_or_password() :: binary 30 | @callback invalid_invitation_token() :: binary 31 | @callback invalid_reset_token() :: binary 32 | @callback invalid_unlock_token() :: binary 33 | @callback invitation_already_sent() :: binary 34 | @callback invitation_sent() :: binary 35 | @callback invite_someone() :: binary 36 | @callback maximum_login_attempts_exceeded() :: binary 37 | @callback need_an_account() :: binary 38 | @callback password_reset_token_expired() :: binary 39 | @callback problem_confirming_user_account() :: binary 40 | @callback registration_created_successfully() :: binary 41 | @callback resend_confirmation_email() :: binary 42 | @callback reset_email_sent() :: binary 43 | @callback restricted_area() :: binary 44 | @callback send_an_unlock_email() :: binary 45 | @callback sign_in() :: binary 46 | @callback sign_out() :: binary 47 | @callback signed_in_successfully() :: binary 48 | @callback too_many_failed_login_attempts() :: binary 49 | @callback unauthorized_ip_address() :: binary 50 | @callback unlock_instructions_sent() :: binary 51 | @callback user_account_confirmed_successfully() :: binary 52 | @callback user_already_has_an_account() :: binary 53 | @callback you_are_using_an_invalid_security_token() :: binary 54 | @callback you_must_confirm_your_account() :: binary 55 | @callback your_account_has_been_unlocked() :: binary 56 | @callback your_account_is_not_locked() :: binary 57 | @callback already_confirmed() :: binary 58 | @callback not_locked() :: binary 59 | @callback required() :: binary 60 | @callback verify_user_token([{atom, any}]) :: binary 61 | @callback mailer_required() :: binary 62 | @callback account_is_inactive() :: binary 63 | 64 | @doc """ 65 | Returns the Messages module from the users app's configuration 66 | """ 67 | def backend do 68 | Coherence.Config.messages_backend() 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/coherence/plugs/authentication/basic.ex: -------------------------------------------------------------------------------- 1 | defmodule Coherence.Authentication.Basic do 2 | @moduledoc """ 3 | Implements basic HTTP authentication. To use add: 4 | 5 | plug Coherence.Authentication.Basic, realm: "Secret world" 6 | 7 | to your pipeline. 8 | 9 | This module is derived from https://github.com/bitgamma/plug_auth which is derived from https://github.com/lexmag/blaguth 10 | """ 11 | @type t :: Ecto.Schema.t() | map() 12 | @type conn :: Plug.Conn.t() 13 | 14 | @behaviour Plug 15 | import Plug.Conn 16 | import Coherence.Authentication.Utils 17 | 18 | alias Coherence.Messages 19 | alias Coherence.CredentialStore.Types, as: T 20 | 21 | @doc """ 22 | Returns the encoded form for the given `user` and `password` combination. 23 | """ 24 | @spec encode_credentials(atom | String.t(), String.t() | nil) :: T.credentials() 25 | def encode_credentials(user, password), do: Base.encode64("#{user}:#{password}") 26 | 27 | @spec create_login(String.t(), String.t(), t, keyword()) :: t 28 | def create_login(email, password, user_data, _opts \\ []) do 29 | creds = encode_credentials(email, password) 30 | store = get_credential_store() 31 | store.put_credentials(creds, user_data) 32 | end 33 | 34 | @doc """ 35 | Update login store for a user. `user_data` can be any term but must not be `nil`. 36 | """ 37 | @spec update_login(String.t(), String.t(), t, keyword()) :: t 38 | def update_login(email, password, user_data, opts \\ []) do 39 | create_login(email, password, user_data, opts) 40 | end 41 | 42 | @spec init(Keyword.t) :: map 43 | def init(opts) do 44 | %{ 45 | realm: Keyword.get(opts, :realm, Messages.backend().restricted_area()), 46 | error: Keyword.get(opts, :error, Messages.backend().http_authentication_required()), 47 | store: Keyword.get(opts, :store, Coherence.CredentialStore.Server), 48 | assigns_key: Keyword.get(opts, :assigns_key, :current_user) 49 | } 50 | end 51 | 52 | @spec call(conn, Keyword.t) :: conn 53 | def call(conn, opts) do 54 | conn 55 | |> get_auth_header 56 | |> verify_creds(opts[:store]) 57 | |> assert_creds(opts[:realm], opts[:error], opts[:assigns_key]) 58 | end 59 | 60 | defp get_auth_header(conn), do: {conn, get_first_req_header(conn, "authorization")} 61 | 62 | defp verify_creds({conn, <<"Basic ", creds::binary>>}, store), 63 | do: {conn, store.get_user_data(creds)} 64 | 65 | defp verify_creds({conn, _}, _), do: {conn, nil} 66 | 67 | defp assert_creds({conn, nil}, realm, error, _), do: halt_with_login(conn, realm, error) 68 | defp assert_creds({conn, user_data}, _, _, key), do: assign_user_data(conn, user_data, key) 69 | 70 | defp halt_with_login(conn, realm, error) do 71 | conn 72 | |> put_resp_header("www-authenticate", ~s{Basic realm="#{realm}"}) 73 | |> halt_with_error(error) 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/coherence/plugs/authentication/credential_store.ex: -------------------------------------------------------------------------------- 1 | defmodule Coherence.CredentialStore do 2 | @moduledoc false 3 | 4 | @callback get_user_data(String.t() | {String.t(), any, any}) :: any 5 | end 6 | -------------------------------------------------------------------------------- /lib/coherence/plugs/authentication/credential_store/server.ex: -------------------------------------------------------------------------------- 1 | defmodule Coherence.CredentialStore.Server do 2 | alias Coherence.CredentialStore.Types, as: T 3 | 4 | @name __MODULE__ 5 | 6 | @behaviour Coherence.CredentialStore 7 | 8 | # Server State 9 | # ------------ 10 | # The state of the server is a record containing a store and an index. 11 | # defstruct store: %{}, index: %{} 12 | 13 | ################### 14 | # Public API 15 | 16 | @doc false 17 | def child_spec(opts) do 18 | %{ 19 | id: @name, 20 | start: {__MODULE__, :start_link, [opts]}, 21 | type: :worker, 22 | restart: :permanent, 23 | shutdown: 500 24 | } 25 | end 26 | 27 | @spec start_link(any) :: {:ok, pid} 28 | def start_link(args) do 29 | GenServer.start_link(__MODULE__, args, name: @name) 30 | end 31 | 32 | @spec update_user_logins(T.user_data()) :: :ok 33 | def update_user_logins(%{id: _} = user_data) do 34 | GenServer.cast(@name, {:update_user_logins, user_data}) 35 | end 36 | 37 | # If the user_data doesn't contain an ID, there are no sessions belonging to the user 38 | # There is no need to update anything and we just return an empty list 39 | def update_user_logins(_), do: [] 40 | 41 | @spec delete_user_logins(T.user_data()) :: :ok 42 | def delete_user_logins(%{id: _} = user_data) do 43 | GenServer.cast(@name, {:delete_user_logins, user_data}) 44 | end 45 | 46 | @spec get_user_data(T.credentials()) :: T.user_data() | nil 47 | def get_user_data(credentials) do 48 | GenServer.call(@name, {:get_user_data, credentials}) 49 | end 50 | 51 | @spec put_credentials(T.credentials(), T.user_data()) :: :ok 52 | def put_credentials(credentials, user_data) do 53 | GenServer.cast(@name, {:put_credentials, credentials, user_data}) 54 | end 55 | 56 | @spec delete_credentials(T.credentials()) :: :ok 57 | def delete_credentials(credentials) do 58 | GenServer.cast(@name, {:delete_credentials, credentials}) 59 | end 60 | 61 | @spec stop() :: :ok 62 | def stop do 63 | GenServer.cast(@name, :stop) 64 | end 65 | 66 | ################### 67 | # Callbacks 68 | 69 | @doc false 70 | def init(_) do 71 | {:ok, initial_state()} 72 | end 73 | 74 | @doc false 75 | def handle_call({:get_user_data, credentials}, _from, state) do 76 | id = state.store[credentials] 77 | 78 | user_data = 79 | case state.user_data[id] do 80 | nil -> nil 81 | {user_data, _} -> user_data 82 | end 83 | 84 | {:reply, user_data, state} 85 | end 86 | 87 | @doc false 88 | def handle_cast({:put_credentials, credentials, %{id: id} = user_data}, state) do 89 | state = 90 | update_in(state, [:user_data, id], fn 91 | nil -> {user_data, 1} 92 | {_, cnt} -> {user_data, cnt + 1} 93 | end) 94 | |> put_in([:store, credentials], id) 95 | 96 | {:noreply, state} 97 | end 98 | 99 | def handle_cast({:put_credentials, _credentials, _user_data}, state) do 100 | {:noreply, state} 101 | end 102 | 103 | @doc false 104 | def handle_cast({:update_user_logins, %{id: id} = user_data}, state) do 105 | # TODO: 106 | # Maybe support updating ths user's ID. 107 | state = 108 | if state.user_data[id] do 109 | update_in(state, [:user_data, id], fn {_, inx} -> 110 | {user_data, inx} 111 | end) 112 | else 113 | state 114 | end 115 | 116 | {:noreply, state} 117 | end 118 | 119 | @doc false 120 | def handle_cast({:delete_user_logins, %{id: id}}, state) do 121 | state = 122 | state 123 | |> remove_all_users_from_store(id) 124 | |> update_in([:user_data], &Map.delete(&1, id)) 125 | 126 | {:noreply, state} 127 | end 128 | 129 | @doc false 130 | def handle_cast({:delete_credentials, credentials}, state) do 131 | id = state.store[credentials] 132 | 133 | state = 134 | state 135 | |> update_in([:store], &Map.delete(&1, credentials)) 136 | |> remove_user_data(id, credentials) 137 | 138 | {:noreply, state} 139 | end 140 | 141 | @doc false 142 | def handle_cast(:stop, state) do 143 | {:stop, :normal, state} 144 | end 145 | 146 | ################## 147 | # Private 148 | 149 | defp initial_state, do: %{store: %{}, user_data: %{}} 150 | 151 | defp remove_all_users_from_store(state, id) do 152 | update_in(state, [:store], fn store -> 153 | for val = {_, v} <- store, v != id, into: %{}, do: val 154 | end) 155 | end 156 | 157 | defp remove_user_data(state, id, _credentials) do 158 | not_exists? = is_nil(Enum.find(state.store, fn {_, user_id} -> user_id == id end)) 159 | 160 | update_in(state, [:user_data], fn user_data -> 161 | case user_data[id] do 162 | _ when not_exists? -> 163 | Map.delete(user_data, id) 164 | 165 | {data, inx} -> 166 | Map.put(user_data, id, {data, inx - 1}) 167 | end 168 | end) 169 | end 170 | end 171 | -------------------------------------------------------------------------------- /lib/coherence/plugs/authentication/credential_store/session.ex: -------------------------------------------------------------------------------- 1 | defmodule Coherence.CredentialStore.Session do 2 | @moduledoc """ 3 | Stores current credential information. 4 | 5 | Uses a Server to save logged in credentials. 6 | 7 | Note: If you restart the phoenix server, this information 8 | is lost, requiring the user to log in again. 9 | 10 | If you would like to preserve login status across server restart, you 11 | can enable the Rememberable option, or configure the Database 12 | cache on the Session plug. 13 | """ 14 | 15 | require Logger 16 | alias Coherence.DbStore 17 | alias Coherence.CredentialStore.Server 18 | alias Coherence.CredentialStore.Types, as: T 19 | 20 | @behaviour Coherence.CredentialStore 21 | 22 | @type t :: Ecto.Schema.t() | map() 23 | 24 | @doc false 25 | def child_spec(opts) do 26 | %{ 27 | id: __MODULE__, 28 | start: {__MODULE__, :start_link, [opts]}, 29 | type: :worker, 30 | restart: :permanent, 31 | shutdown: 500 32 | } 33 | end 34 | 35 | @doc """ 36 | Starts a new credentials store. 37 | """ 38 | @spec start_link(any) :: {:ok, pid} | {:error, atom} 39 | def start_link(args) do 40 | Server.start_link(args) 41 | end 42 | 43 | @doc """ 44 | Gets the user data for the given credentials 45 | """ 46 | @dialyzer [{:no_match, get_user_data: 1}] 47 | @spec get_user_data({T.credentials(), nil | struct, integer | nil}) :: T.user_data() | nil 48 | def get_user_data({credentials, nil, _}) do 49 | get_data(credentials) 50 | end 51 | 52 | def get_user_data({credentials, db_model, id_key}) do 53 | case get_data(credentials) do 54 | nil -> 55 | case DbStore.get_user_data(db_model.__struct__, credentials, id_key) do 56 | nil -> 57 | nil 58 | 59 | user_data -> 60 | Server.put_credentials(credentials, user_data) 61 | user_data 62 | end 63 | 64 | other -> 65 | other 66 | end 67 | end 68 | 69 | @spec get_data(T.credentials()) :: any 70 | defp get_data(credentials), do: Server.get_user_data(credentials) 71 | 72 | @doc """ 73 | Puts the `user_data` for the given `credentials`. 74 | """ 75 | @spec put_credentials({T.credentials(), any, atom}) :: :ok 76 | def put_credentials({credentials, user_data, id_key}) do 77 | Server.put_credentials(credentials, user_data) 78 | DbStore.put_credentials(user_data, credentials, id_key) 79 | end 80 | 81 | @doc """ 82 | Deletes `credentials` from the store. 83 | 84 | Returns the current value of `credentials`, if `credentials` exists. 85 | """ 86 | @spec delete_credentials(T.credentials()) :: :ok 87 | def delete_credentials(credentials) do 88 | case get_data(credentials) do 89 | nil -> 90 | nil 91 | 92 | user_data -> 93 | DbStore.delete_credentials(user_data, credentials) 94 | Server.delete_credentials(credentials) 95 | end 96 | end 97 | 98 | @doc """ 99 | Deletes the sessions for all logged in users. 100 | """ 101 | @spec delete_user_logins(any) :: :ok 102 | def delete_user_logins(user_data) do 103 | Server.delete_user_logins(user_data) 104 | DbStore.delete_user_logins(user_data) 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /lib/coherence/plugs/authentication/credential_store/types.ex: -------------------------------------------------------------------------------- 1 | defmodule Coherence.CredentialStore.Types do 2 | @type credentials :: String.t() 3 | @type user_data :: Ecto.Schema.t() | map() 4 | @type user_id :: any() 5 | end 6 | -------------------------------------------------------------------------------- /lib/coherence/plugs/authentication/db_store.ex: -------------------------------------------------------------------------------- 1 | defprotocol Coherence.DbStore do 2 | @moduledoc """ 3 | Database persistence of current_user data. 4 | 5 | Implement this protocol to add database storage, allowing session 6 | data to survive application restarts. 7 | """ 8 | @fallback_to_any true 9 | 10 | @type schema :: Ecto.Schema.t() | map() | nil 11 | 12 | @doc """ 13 | Get authenticated user data. 14 | """ 15 | @spec get_user_data(schema, String.t(), atom) :: schema 16 | def get_user_data(resource, credentials, id_key) 17 | 18 | @doc """ 19 | Save authenticated user data in the database. 20 | """ 21 | @spec put_credentials(schema, String.t(), atom) :: :ok 22 | def put_credentials(resource, credentials, id_key) 23 | 24 | @doc """ 25 | Delete current user credentials. 26 | """ 27 | @spec delete_credentials(schema, String.t()) :: :ok 28 | def delete_credentials(resource, credentials) 29 | 30 | @doc """ 31 | Delete all logged in users. 32 | """ 33 | @spec delete_user_logins(schema) :: :ok 34 | def delete_user_logins(resource) 35 | end 36 | 37 | defimpl Coherence.DbStore, for: Any do 38 | require Logger 39 | def get_user_data(_, _, _), do: nil 40 | def put_credentials(_, _, _), do: :ok 41 | def delete_credentials(_, _), do: :ok 42 | def delete_user_logins(_), do: :ok 43 | end 44 | -------------------------------------------------------------------------------- /lib/coherence/plugs/authentication/ip_address.ex: -------------------------------------------------------------------------------- 1 | defmodule Coherence.Authentication.IpAddress do 2 | @moduledoc """ 3 | Implements ip address based authentication. To use add 4 | 5 | plug Coherence.Authentication.IpAddress, allow: ~w(127.0.0.1 192.168.1.200) 6 | 7 | to your pipeline. 8 | 9 | IP addresses can be specified in a list as either IP or IP/subnet_mask, where subnet_mask 10 | can be an integer or dot format. 11 | 12 | If you would like access to the current user you must set each authorized IP address like: 13 | 14 | Coherence.CredentialStore.Server.put_credentials({127.0.0.1}, %{role: :admin}) 15 | 16 | or use a custom store like: 17 | 18 | defmodule MyProject.Store do 19 | def get_user_data(ip) do 20 | Repo.one from u in User, where: u.ip_address == ^id 21 | end 22 | end 23 | 24 | plug Coherence.Authentication.IpAddress, allow: ~w(127.0.0.1 192.168.1.0/24), store: &MyProject.Store/1 25 | 26 | ## IP Format Examples: 27 | 28 | allow: ~w(127.0.0.1 192.169.1.0/255.255.255.0) 29 | allow: ~w(127.0.0.1 192.169.1.0/24) 30 | deny: ~w(10.10.0.0/16) 31 | 32 | ## Options 33 | 34 | * `:allow` - list of allowed IPs 35 | * `:deny` - list of denied IPs 36 | * `:error` - error to be displayed if the IP is not allowed 37 | * `:store` - the user_data store 38 | * `:assign_key` - the assigns key to store the user_data 39 | """ 40 | 41 | @behaviour Plug 42 | import Bitwise 43 | import Plug.Conn 44 | import Coherence.Authentication.Utils 45 | 46 | alias Coherence.Authentication.Utils 47 | alias Coherence.Messages 48 | 49 | require Logger 50 | 51 | @dialyzer [ {:nowarn_function, call: 2} ] 52 | 53 | @type t :: Ecto.Schema.t() | map() 54 | @type conn :: Plug.Conn.t() 55 | 56 | @doc """ 57 | Add the credentials for a `token`. `user_data` can be any term but must not be `nil`. 58 | """ 59 | @spec add_credentials(String.t(), t, module) :: t 60 | def add_credentials(ip, user_data, store \\ Coherence.CredentialStore.Server) do 61 | store.put_credentials(ip, user_data) 62 | end 63 | 64 | @doc """ 65 | Remove the credentials for a `token`. 66 | """ 67 | @spec remove_credentials(String.t(), module) :: t 68 | def remove_credentials(ip, store \\ Coherence.CredentialStore.Server) do 69 | store.delete_credentials(ip) 70 | end 71 | 72 | @spec init(keyword()) :: map() 73 | def init(opts) do 74 | %{ 75 | allow: Keyword.get(opts, :allow, []), 76 | deny: Keyword.get(opts, :deny, []), 77 | error: Keyword.get(opts, :error, Messages.backend().unauthorized_ip_address()), 78 | store: Keyword.get(opts, :store, Coherence.CredentialStore.Server), 79 | assign_key: Keyword.get(opts, :assign_key, :current_user) 80 | } 81 | end 82 | 83 | @spec call(conn, keyword()) :: conn 84 | def call(conn, opts) do 85 | ip = conn |> Plug.Conn.get_peer_data() |> Map.get(:address) 86 | 87 | conn 88 | |> verify_ip(ip, opts) 89 | |> fetch_user_data(opts) 90 | |> assert_ip(opts) 91 | end 92 | 93 | defp verify_ip(conn, ip, %{allow: allow, deny: deny}), 94 | do: {conn, ip, in?(ip, allow) && !in?(ip, deny)} 95 | 96 | defp fetch_user_data({conn, ip, true}, %{store: store}), 97 | do: {conn, true, store.get_user_data(ip)} 98 | 99 | defp fetch_user_data({conn, _ip, valid?}, _), do: {conn, valid?, nil} 100 | 101 | defp assert_ip({conn, true, nil}, _), do: conn 102 | 103 | defp assert_ip({conn, true, user_data}, %{assign_key: assign_key}), 104 | do: assign(conn, assign_key, user_data) 105 | 106 | defp assert_ip({conn, _, _}, %{error: error}), do: halt_with_error(conn, error) 107 | 108 | defp in?(ip, list) do 109 | Enum.any?(list, &matches?(String.split(&1, "/"), ip)) 110 | end 111 | 112 | defp matches?([item], ip), do: Utils.to_string(ip) == item 113 | defp matches?([item, subnet], ip), do: in_subnet?(to_tuple(item), ip, subnet) 114 | 115 | defp subnet(string) when is_binary(string) do 116 | if String.contains?(string, ".") do 117 | string |> to_tuple |> subnet 118 | else 119 | string |> String.to_integer() |> subnet 120 | end 121 | end 122 | 123 | defp subnet(num) when is_integer(num) do 124 | Enum.reduce(0..31, 0, &if(&1 < num, do: (&2 ||| 1) <<< 1, else: &2 <<< 1)) >>> 1 125 | end 126 | 127 | defp subnet(tuple) when is_tuple(tuple), do: to_integer(tuple) 128 | 129 | defp in_subnet?(source_ip, target_ip, subnet) do 130 | to_integer(source_ip) == (to_integer(target_ip) &&& subnet(subnet)) 131 | end 132 | 133 | defp to_integer({a, b, c, d}) do 134 | a <<< 24 ||| b <<< 16 ||| c <<< 8 ||| d 135 | end 136 | 137 | defp to_tuple(string) when is_binary(string) do 138 | string 139 | |> String.split(".") 140 | |> Enum.map(&String.to_integer/1) 141 | |> List.to_tuple() 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /lib/coherence/plugs/authentication/token.ex: -------------------------------------------------------------------------------- 1 | defmodule Coherence.Authentication.Token do 2 | @moduledoc """ 3 | Implements token based authentication. To use add 4 | 5 | plug Coherence.Authentication.Token, source: :params, param: "auth_token" 6 | 7 | or 8 | 9 | plug Coherence.Authentication.Token, source: :session, param: "auth_token" 10 | 11 | or 12 | 13 | plug Coherence.Authentication.Token, source: :header, param: "x-auth-token" 14 | 15 | or 16 | 17 | plug Coherence.Authentication.Token, source: { module, function, ["my_param"]} end 18 | 19 | or 20 | 21 | plug Coherence.Authentication.Token, source: :params_session, param: "auth_token" 22 | 23 | to your pipeline. 24 | 25 | ## Options 26 | 27 | * `source` - where to locate the token 28 | * `error` - The error message if not authenticated 29 | * `assigns_key` - The key to user in assigns (:current_uer) 30 | * `store` - Where to store the token data 31 | """ 32 | 33 | import Plug.Conn 34 | import Coherence.Authentication.Utils 35 | 36 | require Logger 37 | 38 | @type t :: Ecto.Schema.t() | map() 39 | @type conn :: Plug.Conn.t() 40 | 41 | @behaviour Plug 42 | 43 | @doc """ 44 | Add the credentials for a `token`. `user_data` can be any term but must not be `nil`. 45 | """ 46 | @spec add_credentials(String.t(), t, module) :: :ok 47 | def add_credentials(token, user_data, store \\ Coherence.CredentialStore.Server) do 48 | store.put_credentials(token, user_data) 49 | end 50 | 51 | @doc """ 52 | Remove the credentials for a `token`. 53 | """ 54 | @spec remove_credentials(String.t(), module) :: :ok 55 | def remove_credentials(token, store \\ Coherence.CredentialStore.Server) do 56 | store.delete_credentials(token) 57 | end 58 | 59 | @doc """ 60 | Utility function to generate a random authentication token. 61 | """ 62 | @spec generate_token() :: String.t() 63 | def generate_token() do 64 | 16 65 | |> :crypto.strong_rand_bytes() 66 | |> Base.url_encode64() 67 | end 68 | 69 | @spec init(keyword()) :: map() 70 | def init(opts) do 71 | param = Keyword.get(opts, :param) 72 | 73 | %{ 74 | source: opts |> Keyword.fetch!(:source) |> convert_source(param), 75 | error: Keyword.get(opts, :error, "HTTP Authentication Required"), 76 | assigns_key: Keyword.get(opts, :assigns_key, :current_user), 77 | store: Keyword.get(opts, :store, Coherence.CredentialStore.Server) 78 | } 79 | end 80 | 81 | defp convert_source(:params_session, param), 82 | do: {__MODULE__, :get_token_from_params_session, [param]} 83 | 84 | defp convert_source(:params, param), 85 | do: {__MODULE__, :get_token_from_params, [param]} 86 | 87 | defp convert_source(:header, param), 88 | do: {__MODULE__, :get_token_from_header, [param]} 89 | 90 | defp convert_source(:session, param), 91 | do: {__MODULE__, :get_token_from_session, [param]} 92 | 93 | defp convert_source(source = {module, fun, args}, _param) 94 | when is_atom(module) and is_atom(fun) and is_list(args), 95 | do: source 96 | 97 | @spec get_token_from_params(conn, atom() | binary()) :: {conn, any()} 98 | def get_token_from_params(conn, param), 99 | do: {conn, conn.params[param]} 100 | 101 | @spec get_token_from_header(conn, binary()) :: {conn, String.t} 102 | def get_token_from_header(conn, param), 103 | do: {conn, get_first_req_header(conn, param)} 104 | 105 | @spec get_token_from_session(conn, atom() | binary()) :: {conn, nil | String.t()} 106 | def get_token_from_session(conn, param), 107 | do: {conn, get_session(conn, param)} 108 | 109 | @spec get_token_from_params_session(conn, atom() | binary()) :: {conn, nil | String.t()} 110 | def get_token_from_params_session(conn, param) do 111 | conn 112 | |> get_token_from_params(param) 113 | |> check_token_from_session(param) 114 | |> save_token_in_session(param) 115 | end 116 | 117 | @spec check_token_from_session({conn, nil | String.t()}, atom() | binary()) :: {conn, nil | String.t()} 118 | def check_token_from_session({conn, nil}, param), do: get_token_from_session(conn, param) 119 | def check_token_from_session({conn, creds}, _param), do: {conn, creds} 120 | 121 | @spec save_token_in_session({conn, nil | String.t()}, atom() | binary()) :: {conn, nil | String.t()} 122 | def save_token_in_session({conn, nil}, _), do: {conn, nil} 123 | 124 | def save_token_in_session({conn, creds}, param) do 125 | conn = 126 | conn 127 | |> put_session(param, creds) 128 | |> put_session(param_key(), param) 129 | 130 | {conn, creds} 131 | end 132 | 133 | @spec call(conn, keyword()) :: conn 134 | def call(conn, opts) do 135 | if get_authenticated_user(conn) do 136 | conn 137 | else 138 | {module, fun, args} = opts[:source] 139 | 140 | module 141 | |> apply(fun, [conn | args]) 142 | |> verify_creds(opts[:store]) 143 | |> assert_creds(opts[:error]) 144 | end 145 | end 146 | 147 | defp verify_creds({conn, creds}, store), do: {conn, store.get_user_data(creds)} 148 | 149 | defp assert_creds({conn, nil}, nil), do: conn 150 | defp assert_creds({conn, nil}, error), do: halt_with_error(conn, error) 151 | defp assert_creds({conn, user_data}, _), do: assign_user_data(conn, user_data) 152 | end 153 | -------------------------------------------------------------------------------- /lib/coherence/plugs/authentication/utils.ex: -------------------------------------------------------------------------------- 1 | defmodule Coherence.Authentication.Utils do 2 | @moduledoc false 3 | import Plug.Conn 4 | alias Coherence.Config 5 | 6 | @type conn :: Plug.Conn.t() 7 | @type t :: map() 8 | 9 | @param_key Application.compile_env(:coherence, :token_param_key, "param_key") 10 | 11 | @spec param_key() :: String.t() 12 | def param_key, do: @param_key 13 | 14 | @spec assign_user_data(conn, t, atom) :: conn 15 | def assign_user_data(conn, user_data, key \\ :current_user) do 16 | assign(conn, key, user_data) 17 | end 18 | 19 | @spec get_authenticated_user(conn, atom) :: any 20 | def get_authenticated_user(conn, key \\ :current_user) do 21 | conn.assigns[key] 22 | end 23 | 24 | @spec halt_with_error(conn, String.t() | function) :: conn 25 | def halt_with_error(conn, error \\ "unauthorized") 26 | 27 | def halt_with_error(conn, error) when is_function(error) do 28 | conn 29 | |> error.() 30 | |> halt 31 | end 32 | 33 | def halt_with_error(conn, error) do 34 | conn 35 | |> send_resp(401, error) 36 | |> halt 37 | end 38 | 39 | @spec get_first_req_header(conn, String.t()) :: nil | String.t() 40 | def get_first_req_header(conn, header), do: conn |> get_req_header(header) |> header_hd 41 | 42 | @spec delete_token_session(conn) :: conn 43 | def delete_token_session(conn) do 44 | case get_session(conn, param_key()) do 45 | nil -> conn 46 | param -> put_session(conn, param, nil) 47 | end 48 | end 49 | 50 | @spec get_credential_store() :: module 51 | def get_credential_store do 52 | if store = Config.credential_store() do 53 | store 54 | else 55 | case Config.auth_module() do 56 | Coherence.Authentication.Session -> 57 | Coherence.CredentialStore.Session 58 | 59 | Coherence.Authentication.Basic -> 60 | Coherence.CredentialStore.Server 61 | end 62 | end 63 | end 64 | 65 | defp header_hd([]), do: nil 66 | defp header_hd([head | _]), do: head 67 | 68 | @type si :: String.t() | integer 69 | @spec to_string({si, si, si, si} | String.t()) :: String.t() 70 | def to_string({a, b, c, d}), do: "#{a}.#{b}.#{c}.#{d}" 71 | def to_string(string) when is_binary(string), do: string 72 | 73 | def delete_user_token(conn) do 74 | if Config.user_token() do 75 | assign(conn, Config.token_assigns_key(), nil) 76 | else 77 | conn 78 | end 79 | end 80 | 81 | def create_user_token(conn, _, nil_or_false, _) when nil_or_false in [nil, false], do: conn 82 | 83 | def create_user_token(conn, user, _, assign_key) do 84 | if conn.assigns[assign_key] do 85 | token = 86 | case Config.token_generator() do 87 | {mod, fun, args} -> apply(mod, fun, [conn, user | args]) 88 | fun when is_function(fun) -> fun.(conn, user) 89 | other -> raise "Invalid Config.token_generator option, other: #{inspect(other)}" 90 | end 91 | 92 | assign(conn, Config.token_assigns_key(), token) 93 | else 94 | conn 95 | end 96 | end 97 | 98 | def new_session_path(conn) do 99 | Module.concat(Config.web_module(), Router.Helpers).session_path(conn, :new) 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /lib/coherence/plugs/require_login.ex: -------------------------------------------------------------------------------- 1 | defmodule Coherence.RequireLogin do 2 | @moduledoc """ 3 | Plug to protect controllers that require login. 4 | """ 5 | 6 | import Coherence.Controller, only: [logged_out_url: 1] 7 | import Plug.Conn 8 | import Phoenix.Controller, only: [put_flash: 3, redirect: 2] 9 | 10 | alias Coherence.Messages 11 | 12 | @behaviour Plug 13 | 14 | @spec init(keyword()) :: map() 15 | def init(options) do 16 | %{option: options} 17 | end 18 | 19 | @spec call(Plug.Conn.t(), any) :: Plug.Conn.t() 20 | def call(conn, _opts) do 21 | if Coherence.current_user(conn) do 22 | conn 23 | else 24 | conn 25 | |> put_flash(:error, Messages.backend().invalid_request()) 26 | |> redirect(to: logged_out_url(conn)) 27 | |> halt 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/coherence/plugs/validate_option.ex: -------------------------------------------------------------------------------- 1 | defmodule Coherence.ValidateOption do 2 | @moduledoc """ 3 | Plug to validate the given option is enabled in the project's configuration. 4 | """ 5 | 6 | import Coherence.Controller, only: [logged_out_url: 1] 7 | import Plug.Conn 8 | import Phoenix.Controller, only: [put_flash: 3, redirect: 2] 9 | 10 | alias Coherence.Messages 11 | 12 | @behaviour Plug 13 | 14 | @spec init(keyword() | atom) :: map() 15 | def init(options) do 16 | %{option: options} 17 | end 18 | 19 | @spec call(Plug.Conn.t(), map()) :: Plug.Conn.t() 20 | def call(conn, opts) do 21 | if Coherence.Config.has_option(opts[:option]) do 22 | conn 23 | else 24 | conn 25 | |> put_flash(:error, Messages.backend().invalid_request()) 26 | |> redirect(to: logged_out_url(conn)) 27 | |> halt 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/coherence/rememberable_server.ex: -------------------------------------------------------------------------------- 1 | defmodule Coherence.RememberableServer do 2 | @moduledoc false 3 | use GenServer 4 | use Coherence.Config 5 | 6 | @name __MODULE__ 7 | 8 | @doc false 9 | def child_spec(args), 10 | do: %{ 11 | id: @name, 12 | start: {__MODULE__, :start_link, args}, 13 | restart: :permanent, 14 | shutdown: 500, 15 | type: :worker 16 | } 17 | 18 | @doc false 19 | def start_link, do: GenServer.start_link(__MODULE__, [], name: @name) 20 | 21 | @doc false 22 | def callback(callback) do 23 | GenServer.call(@name, {:callback, callback}) 24 | end 25 | 26 | @doc false 27 | def init(_) do 28 | # the item below can be used to schedule expired tokens, but the 29 | # session controller needs to be refactored to ignore expired tokens 30 | 31 | # schedule_daily_work() 32 | {:ok, nil} 33 | end 34 | 35 | @doc false 36 | def handle_call({:callback, callback}, _, state) do 37 | {:reply, callback.(), state} 38 | end 39 | 40 | # @doc false 41 | # def handle_info(:daily_work, state) do 42 | # # start the timer again before doing the work 43 | # # this will reduce drift if the work takes a while 44 | # schedule_daily_work() 45 | # # do the work 46 | # repo = Config.repo 47 | # repo.delete_all Coherence.Rememberable.delete_expired_tokens 48 | # {:noreply, state} 49 | # end 50 | 51 | # defp schedule_daily_work do 52 | # Process.send_after(self(), :daily_work, 24 * 60 * 60 * 1000) # 1 day 53 | # end 54 | end 55 | -------------------------------------------------------------------------------- /lib/coherence/responders.ex: -------------------------------------------------------------------------------- 1 | defmodule Coherence.Responders do 2 | @callback session_create_success(conn :: term, opts :: term) :: term 3 | @callback session_create_error(conn :: term, opts :: term) :: term 4 | @callback session_create_error_locked(conn :: term, opts :: term) :: term 5 | @callback session_delete_success(conn :: term, opts :: term) :: term 6 | @callback session_already_logged_in(conn :: term, opts :: term) :: term 7 | 8 | @callback registration_create_success(conn :: term, opts :: term) :: term 9 | @callback registration_create_error(conn :: term, opts :: term) :: term 10 | @callback registration_update_success(conn :: term, opts :: term) :: term 11 | @callback registration_update_error(conn :: term, opts :: term) :: term 12 | @callback registration_delete_success(conn :: term, opts :: term) :: term 13 | 14 | @callback unlock_create_success(conn :: term, opts :: term) :: term 15 | @callback unlock_create_error(conn :: term, opts :: term) :: term 16 | @callback unlock_create_error_not_locked(conn :: term, opts :: term) :: term 17 | @callback unlock_update_success(conn :: term, opts :: term) :: term 18 | @callback unlock_update_error(conn :: term, opts :: term) :: term 19 | @callback unlock_update_error_not_locked(conn :: term, opts :: term) :: term 20 | 21 | @callback confirmation_create_success(conn :: term, opts :: term) :: term 22 | @callback confirmation_create_error(conn :: term, opts :: term) :: term 23 | @callback confirmation_update_success(conn :: term, opts :: term) :: term 24 | @callback confirmation_update_error(conn :: term, opts :: term) :: term 25 | 26 | @callback password_create_success(conn :: term, opts :: term) :: term 27 | @callback password_create_error(conn :: term, opts :: term) :: term 28 | @callback password_update_success(conn :: term, opts :: term) :: term 29 | @callback password_update_error(conn :: term, opts :: term) :: term 30 | 31 | @callback invitation_create_success(conn :: term, opts :: term) :: term 32 | @callback invitation_create_error(conn :: term, opts :: term) :: term 33 | @callback invitation_resend_success(conn :: term, opts :: term) :: term 34 | @callback invitation_resend_error(conn :: term, opts :: term) :: term 35 | @callback invitation_create_user_success(conn :: term, opts :: term) :: term 36 | @callback invitation_create_user_error(conn :: term, opts :: term) :: term 37 | end 38 | -------------------------------------------------------------------------------- /lib/coherence/schemas.ex: -------------------------------------------------------------------------------- 1 | defmodule Coherence.Schemas do 2 | use Coherence.Config 3 | 4 | import Ecto.Query 5 | 6 | def schema(schema) do 7 | Module.concat([Config.module(), Coherence, schema]) 8 | end 9 | 10 | def list_user do 11 | Config.repo().all(Config.user_schema()) 12 | end 13 | 14 | def list_by_user(opts) do 15 | Config.repo().all(query_by(Config.user_schema(), opts)) 16 | end 17 | 18 | def get_by_user(opts) do 19 | Config.repo().get_by(Config.user_schema(), opts) 20 | end 21 | 22 | def get_user(id) do 23 | Config.repo().get(Config.user_schema(), id) 24 | end 25 | 26 | def get_user!(id) do 27 | Config.repo().get!(Config.user_schema(), id) 28 | end 29 | 30 | def get_user_by_email(email) do 31 | Config.repo().get_by(Config.user_schema(), email: email) 32 | end 33 | 34 | def change_user(struct, params) do 35 | Config.user_schema().changeset(struct, params) 36 | end 37 | 38 | def change_user(params) do 39 | Config.user_schema().changeset(Config.user_schema().__struct__, params) 40 | end 41 | 42 | def change_user do 43 | Config.user_schema().changeset(Config.user_schema().__struct__, %{}) 44 | end 45 | 46 | def create_user(params) do 47 | user_schema = Config.user_schema() 48 | Config.repo().insert(user_schema.new_changeset(params)) 49 | end 50 | 51 | def create_user!(params) do 52 | user_schema = Config.user_schema() 53 | Config.repo().insert!(user_schema.new_changeset(params)) 54 | end 55 | 56 | def update_user(user, params) do 57 | Config.repo().update(user.__struct__.changeset(user, params)) 58 | end 59 | 60 | def update_user!(user, params) do 61 | Config.repo().update!(user.__struct__.changeset(user, params)) 62 | end 63 | 64 | Enum.each([Invitation, Rememberable, Trackable], fn module -> 65 | name = module |> inspect |> String.downcase() 66 | 67 | def unquote(String.to_atom("list_#{name}"))() do 68 | Config.repo().all(schema(unquote(module))) 69 | end 70 | 71 | def unquote(String.to_atom("list_by_#{name}"))(opts) do 72 | Config.repo().all(query_by(schema(unquote(module)), opts)) 73 | end 74 | 75 | def unquote(String.to_atom("list_#{name}"))(%Ecto.Query{} = query) do 76 | Config.repo().all(query) 77 | end 78 | 79 | def unquote(String.to_atom("get_#{name}"))(id) do 80 | Config.repo().get(schema(unquote(module)), id) 81 | end 82 | 83 | def unquote(String.to_atom("get_#{name}!"))(id) do 84 | Config.repo().get!(schema(unquote(module)), id) 85 | end 86 | 87 | def unquote(String.to_atom("get_by_#{name}"))(opts) do 88 | Config.repo().get_by(schema(unquote(module)), opts) 89 | end 90 | 91 | def unquote(String.to_atom("change_#{name}"))(struct, params) do 92 | schema(unquote(module)).changeset(struct, params) 93 | end 94 | 95 | def unquote(String.to_atom("change_#{name}"))(params) do 96 | schema(unquote(module)).new_changeset(params) 97 | end 98 | 99 | def unquote(String.to_atom("change_#{name}"))() do 100 | schema(unquote(module)).new_changeset(%{}) 101 | end 102 | 103 | def unquote(String.to_atom("create_#{name}"))(params) do 104 | Config.repo().insert(schema(unquote(module)).new_changeset(params)) 105 | end 106 | 107 | def unquote(String.to_atom("create_#{name}!"))(params) do 108 | Config.repo().insert!(schema(unquote(module)).new_changeset(params)) 109 | end 110 | 111 | def unquote(String.to_atom("update_#{name}"))(struct, params) do 112 | Config.repo().update(schema(unquote(module)).changeset(struct, params)) 113 | end 114 | 115 | def unquote(String.to_atom("update_#{name}!"))(struct, params) do 116 | Config.repo().update!(schema(unquote(module)).changeset(struct, params)) 117 | end 118 | 119 | def unquote(String.to_atom("delete_#{name}"))(struct) do 120 | Config.repo().delete(struct) 121 | end 122 | 123 | def unquote(String.to_atom("delete_#{name}!"))(struct) do 124 | Config.repo().delete!(struct) 125 | end 126 | end) 127 | 128 | def last_trackable(user_id) do 129 | schema = 130 | Config.repo().one( 131 | Trackable 132 | |> schema 133 | |> where([t], t.user_id == ^user_id) 134 | |> order_by(desc: :id) 135 | |> limit(1) 136 | ) 137 | 138 | case schema do 139 | nil -> schema(Trackable).__struct__ 140 | trackable -> trackable 141 | end 142 | end 143 | 144 | def query_by(schema, opts) do 145 | Enum.reduce(opts, schema(schema), fn {k, v}, query -> 146 | where(query, [b], field(b, ^k) == ^v) 147 | end) 148 | end 149 | 150 | def delete_all(%Ecto.Query{} = query) do 151 | Config.repo().delete_all(query) 152 | end 153 | 154 | def delete_all(module) when is_atom(module) do 155 | Config.repo().delete_all(module) 156 | end 157 | 158 | def create(%Ecto.Changeset{} = changeset) do 159 | Config.repo().insert(changeset) 160 | end 161 | 162 | def create!(%Ecto.Changeset{} = changeset) do 163 | Config.repo().insert!(changeset) 164 | end 165 | 166 | def update(%Ecto.Changeset{} = changeset) do 167 | Config.repo().update(changeset) 168 | end 169 | 170 | def update!(%Ecto.Changeset{} = changeset) do 171 | Config.repo().update!(changeset) 172 | end 173 | 174 | def delete(schema) do 175 | Config.repo().delete(schema) 176 | end 177 | 178 | def delete!(schema) do 179 | Config.repo().delete!(schema) 180 | end 181 | end 182 | -------------------------------------------------------------------------------- /lib/coherence/schemas/rememberable.ex: -------------------------------------------------------------------------------- 1 | defmodule Coherence.Rememberable do 2 | @moduledoc false 3 | 4 | defmacro __using__(_) do 5 | quote do 6 | use Timex 7 | 8 | alias Coherence.Config 9 | alias __MODULE__ 10 | 11 | require Logger 12 | 13 | @spec create_login(Ecto.Schema.t()) :: {Ecto.Changeset.t(), String.t(), String.t()} 14 | def create_login(user) do 15 | series = gen_series() 16 | token = gen_token() 17 | 18 | changeset = 19 | changeset(%Rememberable{}, %{ 20 | token_created_at: created_at(), 21 | user_id: user.id, 22 | series_hash: hash(series), 23 | token_hash: hash(token) 24 | }) 25 | 26 | {changeset, series, token} 27 | end 28 | 29 | @spec update_login(Ecto.Changeset.t()) :: {Ecto.Changeset.t(), String.t()} 30 | def update_login(rememberable) do 31 | token = gen_token() 32 | {changeset(rememberable, %{token_hash: hash(token)}), token} 33 | end 34 | 35 | @spec get_valid_login(integer, String.t(), String.t()) :: Ecto.Queryable.t() 36 | def get_valid_login(user_id, series, token) do 37 | from( 38 | p in Rememberable, 39 | where: p.user_id == ^user_id and p.series_hash == ^series and p.token_hash == ^token 40 | ) 41 | end 42 | 43 | @spec get_invalid_login(integer, String.t(), String.t()) :: Ecto.Queryable.t() 44 | def get_invalid_login(user_id, series, token) do 45 | from( 46 | p in Rememberable, 47 | where: p.user_id == ^user_id and p.series_hash == ^series and p.token_hash != ^token, 48 | select: count(p.id) 49 | ) 50 | end 51 | 52 | @spec delete_all(integer) :: Ecto.Queryable.t() 53 | def delete_all(user_id) do 54 | from(p in Rememberable, where: p.user_id == ^user_id) 55 | end 56 | 57 | @spec delete_expired_tokens() :: Ecto.Queryable.all() 58 | def delete_expired_tokens do 59 | expire_datetime = 60 | Timex.shift(Timex.now(), hours: -Config.rememberable_cookie_expire_hours()) 61 | 62 | from(p in Rememberable, where: p.token_created_at < ^expire_datetime) 63 | end 64 | 65 | @spec gen_cookie(integer, String.t(), String.t()) :: String.t() 66 | def gen_cookie(user_id, series, token), do: "#{user_id} #{series} #{token}" 67 | 68 | @spec hash(String.t()) :: String.t() 69 | def hash(string) do 70 | :sha 71 | |> :crypto.hash(String.to_charlist(string)) 72 | |> Base.url_encode64() 73 | end 74 | 75 | @spec log_cookie(String.t()) :: String.t() 76 | def log_cookie(cookie) do 77 | [_id, series, token] = String.split(cookie, " ") 78 | cookie <> " : #{hash(series)} #{hash(token)}" 79 | end 80 | 81 | def created_at, do: Timex.now() 82 | 83 | def gen_token do 84 | Coherence.Controller.random_string(24) 85 | end 86 | 87 | def gen_series do 88 | Coherence.Controller.random_string(10) 89 | end 90 | 91 | defoverridable create_login: 1, 92 | update_login: 1, 93 | get_valid_login: 3, 94 | delete_all: 1, 95 | delete_expired_tokens: 0, 96 | gen_cookie: 3, 97 | hash: 1, 98 | log_cookie: 1, 99 | created_at: 0, 100 | gen_series: 0, 101 | gen_token: 0 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /lib/coherence/services/lockable_service.ex: -------------------------------------------------------------------------------- 1 | defmodule Coherence.LockableService do 2 | @moduledoc """ 3 | Lockable disables an account after too many failed login attempts. 4 | 5 | Enabled with the `--lockable` installation option, after 5 failed login 6 | attempts, the user is locked out of their account for 5 minutes. 7 | 8 | This option adds the following fields to the user schema: 9 | 10 | * :failed_attempts, :integer - The number of failed login attempts. 11 | * :locked_at, :datetime - The time and date when the account was locked. 12 | 13 | The following configuration is used to customize lockable behavior: 14 | 15 | * :unlock_timeout_minutes (20) - The number of minutes to wait before unlocking the account. 16 | * :max_failed_login_attempts (5) - The number of failed login attempts before locking the account. 17 | 18 | By default, a locked account will be unlocked after the `:unlock_timeout_minutes` expires or the 19 | is unlocked using the `unlock` API. 20 | 21 | In addition, the `--unlock-with-token` option can be given to the installer to allow 22 | a user to unlock their own account by requesting an email be sent with an link containing an 23 | unlock token. 24 | 25 | With this option installed, the following field is added to the user schema: 26 | 27 | * :unlock_token, :string 28 | 29 | """ 30 | use Coherence.Config 31 | alias Coherence.Controller 32 | 33 | def unlock_token(user) do 34 | token = Controller.random_string(48) 35 | 36 | [Config.module(), Coherence, Schemas] 37 | |> Module.concat() 38 | |> apply(:update_user, [user, %{unlock_token: token}]) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/coherence/services/password_service.ex: -------------------------------------------------------------------------------- 1 | defmodule Coherence.PasswordService do 2 | @moduledoc """ 3 | This service handles reseting of passwords. 4 | 5 | Installed with the `--recoverable` installation option, this service handles 6 | the creation of the `reset_password_token`. With this installation option, the 7 | following fields are added to the user's schema: 8 | 9 | * :reset_password_token - A random string token generated and sent to the user 10 | * :reset_password_sent_at - the date and time the token was created 11 | 12 | The following configuration can be used to customize the behavior of the 13 | recoverable option: 14 | 15 | * :reset_token_expire_days (2) - the expiry time of the reset token in days. 16 | 17 | """ 18 | use Coherence.Config 19 | 20 | alias Coherence.Controller 21 | alias Coherence.Schemas 22 | 23 | @doc """ 24 | Create and save a reset password token. 25 | 26 | Creates a random password reset token and saves the token in the 27 | user schema along with setting the `reset_password_sent_at` to the 28 | current time and date. 29 | """ 30 | def reset_password_token(user) do 31 | token = Controller.random_string(48) 32 | dt = NaiveDateTime.utc_now() 33 | 34 | :password 35 | |> Controller.changeset(user.__struct__, user, %{ 36 | reset_password_token: token, 37 | reset_password_sent_at: dt 38 | }) 39 | |> Schemas.update() 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/coherence/services/rememberable_service.ex: -------------------------------------------------------------------------------- 1 | defmodule Coherence.RememberableService do 2 | @moduledoc false 3 | 4 | use Coherence.Config 5 | 6 | import Plug.Conn 7 | 8 | alias Coherence.Schemas 9 | 10 | require Logger 11 | @doc """ 12 | Delete a rememberable token. 13 | """ 14 | @spec delete_rememberable(Plug.Conn.t(), map()) :: Plug.Conn.t() 15 | def delete_rememberable(conn, %{id: id}) do 16 | if Config.has_option(:rememberable) do 17 | Rememberable 18 | |> Schemas.query_by(user_id: id) 19 | |> Schemas.delete_all() 20 | 21 | delete_resp_cookie(conn, Config.login_cookie()) 22 | else 23 | conn 24 | end 25 | end 26 | 27 | def delete_rememberable(conn, user) do 28 | Logger.warning("user has no id #{inspect(user)}") 29 | conn 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/coherence/services/session_service.ex: -------------------------------------------------------------------------------- 1 | defmodule Coherence.SessionService do 2 | @moduledoc """ 3 | Support functions for Coherence sessions. 4 | """ 5 | require Coherence.Config, as: Config 6 | 7 | @doc """ 8 | Create a signed Phoenix token for a given user 9 | """ 10 | def sign_user_token(context, user, opts \\ []) do 11 | Phoenix.Token.sign(context, Config.token_salt(), user.id, opts) 12 | end 13 | 14 | @doc """ 15 | Verify a signed Phoenix Token. 16 | """ 17 | def verify_user_token(context, token, opts \\ []) do 18 | Phoenix.Token.verify( 19 | context, 20 | Config.token_salt(), 21 | token, 22 | Keyword.put_new(opts, :max_age, Config.token_max_age()) 23 | ) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/coherence/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Coherence.Supervisor do 2 | @moduledoc """ 3 | Supervisor to start Coherence services. 4 | 5 | Starts the configured credential store server. Also starts 6 | the RememberableServer if this option is configured. 7 | """ 8 | use Supervisor 9 | 10 | import Coherence.Authentication.Utils, only: [get_credential_store: 0] 11 | 12 | @doc false 13 | def child_spec(args), 14 | do: %{ 15 | id: __MODULE__, 16 | start: {__MODULE__, :start_link, args}, 17 | restart: :permanent, 18 | shutdown: 500, 19 | type: :supervisor 20 | } 21 | 22 | @doc false 23 | def start_link() do 24 | Supervisor.start_link(__MODULE__, :ok, name: __MODULE__) 25 | end 26 | 27 | @doc false 28 | def init(_) do 29 | use Coherence.Config 30 | 31 | [{get_credential_store(), []}] 32 | |> build_children(Config.has_option(:rememberable)) 33 | |> Supervisor.init(strategy: :one_for_one) 34 | end 35 | 36 | defp build_children(children, true), do: [{Coherence.RememberableServer, []} | children] 37 | defp build_children(children, _), do: children 38 | end 39 | -------------------------------------------------------------------------------- /lib/coherence/web.ex: -------------------------------------------------------------------------------- 1 | defmodule CoherenceWeb do 2 | @moduledoc """ 3 | Coherence setting for web resources. 4 | 5 | Similar to a project's Web module 6 | """ 7 | 8 | @doc false 9 | def model do 10 | quote do 11 | use Ecto.Schema 12 | 13 | import Ecto 14 | import Ecto.Changeset 15 | import Ecto.Query, only: [from: 1, from: 2] 16 | 17 | if Coherence.Config.use_binary_id?() do 18 | @primary_key {:id, :binary_id, autogenerate: true} 19 | @foreign_key_type :binary_id 20 | end 21 | end 22 | end 23 | 24 | @doc false 25 | def controller do 26 | quote do 27 | use Phoenix.Controller 28 | import Coherence.Controller 29 | import Ecto 30 | import Ecto.Query 31 | 32 | alias Coherence.Config 33 | alias Coherence.Controller 34 | 35 | require Redirects 36 | end 37 | end 38 | 39 | @doc false 40 | def router do 41 | quote do 42 | use Phoenix.Router 43 | end 44 | end 45 | 46 | def service do 47 | quote do 48 | end 49 | end 50 | 51 | @doc """ 52 | When used, dispatch to the appropriate controller/view/etc. 53 | """ 54 | defmacro __using__(which) when is_atom(which) do 55 | apply(__MODULE__, which, []) 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/mix/mix_utils.ex: -------------------------------------------------------------------------------- 1 | defmodule Coherence.Mix.Utils do 2 | @moduledoc false 3 | 4 | @dialyzer [ 5 | {:nowarn_function, raise_option_errors: 1} 6 | ] 7 | 8 | @spec rm_dir!(String.t()) :: any 9 | def rm_dir!(dir) do 10 | if File.dir?(dir) do 11 | File.rm_rf(dir) 12 | end 13 | end 14 | 15 | @spec rm!(String.t()) :: any 16 | def rm!(file) do 17 | if File.exists?(file) do 18 | File.rm!(file) 19 | end 20 | end 21 | 22 | @spec raise_option_errors([:atom]) :: String.t() 23 | def raise_option_errors(list) do 24 | list = 25 | Enum.map(list, fn option -> 26 | ("--" <> Atom.to_string(option)) |> String.replace("_", "-") 27 | end) 28 | 29 | list = Enum.join(list, ", ") 30 | 31 | Mix.raise(""" 32 | The following option(s) are not supported: 33 | #{inspect(list)} 34 | """) 35 | end 36 | 37 | @spec verify_args!([String.t()] | [], [String.t()] | []) :: String.t() | nil 38 | def verify_args!(parsed, unknown) do 39 | unless parsed == [] do 40 | opts = Enum.join(parsed, ", ") 41 | 42 | Mix.raise(""" 43 | Invalid argument(s) #{opts} 44 | """) 45 | end 46 | 47 | unless unknown == [] do 48 | opts = 49 | unknown 50 | |> Enum.map(&elem(&1, 0)) 51 | |> Enum.join(", ") 52 | 53 | Mix.raise(""" 54 | Invalid argument(s) #{opts} 55 | """) 56 | end 57 | end 58 | 59 | @doc """ 60 | Get list of migration schema fields for each option. 61 | 62 | Helper function to return a keyword list of the migration fields for each 63 | of the supported options. 64 | 65 | TODO: Does this really belong here? Should it not be in a migration support 66 | module? 67 | """ 68 | 69 | def schema_fields(config) do 70 | active_field = 71 | if config.user_active_field? do 72 | ["add :active, :boolean, null: false, default: true"] 73 | else 74 | [] 75 | end 76 | 77 | [ 78 | authenticatable: 79 | [ 80 | "# authenticatable", 81 | "add :password_hash, :string" 82 | ] ++ active_field, 83 | recoverable: [ 84 | "# recoverable", 85 | "add :reset_password_token, :string", 86 | "add :reset_password_sent_at, :utc_datetime" 87 | ], 88 | rememberable: [ 89 | "# rememberable", 90 | "add :remember_created_at, :utc_datetime" 91 | ], 92 | trackable: [ 93 | "# trackable", 94 | "add :sign_in_count, :integer, default: 0", 95 | "add :current_sign_in_at, :utc_datetime", 96 | "add :last_sign_in_at, :utc_datetime", 97 | "add :current_sign_in_ip, :string", 98 | "add :last_sign_in_ip, :string" 99 | ], 100 | lockable: [ 101 | "# lockable", 102 | "add :failed_attempts, :integer, default: 0", 103 | "add :locked_at, :utc_datetime" 104 | ], 105 | unlockable_with_token: [ 106 | "# unlockable_with_token", 107 | "add :unlock_token, :string" 108 | ], 109 | confirmable: [ 110 | "# confirmable", 111 | "add :confirmation_token, :string", 112 | "add :confirmed_at, :utc_datetime", 113 | "add :confirmation_sent_at, :utc_datetime", 114 | "add :unconfirmed_email, :string" 115 | ] 116 | ] 117 | end 118 | 119 | def controller_files, 120 | do: [ 121 | confirmable: "confirmation_controller.ex", 122 | invitable: "invitation_controller.ex", 123 | recoverable: "password_controller.ex", 124 | registerable: "registration_controller.ex", 125 | authenticatable: "session_controller.ex", 126 | unlockable_with_token: "unlock_controller.ex" 127 | ] 128 | 129 | def format_string!(string) do 130 | if function_exported?(Code, :format_string!, 1) do 131 | Code.format_string!(string) 132 | else 133 | string 134 | end 135 | end 136 | 137 | defdelegate migrations_path(repo), to: Ecto.Migrator 138 | end 139 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Coherence.Mixfile do 2 | use Mix.Project 3 | 4 | @version "0.8.0" 5 | 6 | def project do 7 | [ 8 | app: :coherence, 9 | version: @version, 10 | elixir: "~> 1.11", 11 | elixirc_paths: elixirc_paths(Mix.env()), 12 | compilers: [:phoenix] ++ Mix.compilers(), 13 | build_embedded: Mix.env() == :prod, 14 | start_permanent: Mix.env() == :prod, 15 | docs: [extras: ["README.md", "CODE_OF_CONDUCT.md", "CONTRIBUTING.md", "LICENSE"], main: "Coherence"], 16 | deps: deps(), 17 | package: package(), 18 | dialyzer: [plt_add_apps: [:mix]], 19 | name: "Coherence", 20 | description: """ 21 | A full featured, configurable authentication and user management system for Phoenix. 22 | """ 23 | ] 24 | end 25 | 26 | # Configuration for the OTP application 27 | def application do 28 | [ 29 | mod: {Coherence, []}, 30 | extra_applications: [ 31 | :logger, 32 | :ecto, 33 | :tzdata, 34 | :crypto, 35 | :eex 36 | ] 37 | ] 38 | end 39 | 40 | defp elixirc_paths(:test), do: ["lib", "test/support"] 41 | defp elixirc_paths(_), do: ["lib"] 42 | 43 | defp deps do 44 | [ 45 | {:ecto_sql, "~> 3.4"}, 46 | {:comeonin, "~> 4.0"}, 47 | {:bcrypt_elixir, "~> 1.1"}, 48 | {:phoenix, "~> 1.3"}, 49 | {:phoenix_html, "~> 2.10"}, 50 | {:gettext, "~> 0.14"}, 51 | {:elixir_uuid, "~> 1.2"}, 52 | {:phoenix_swoosh, "~> 0.2"}, 53 | {:timex, "~> 3.6"}, 54 | {:floki, "~> 0.19", only: :test}, 55 | {:ex_doc, "~> 0.30.0", only: :dev}, 56 | {:earmark, "~> 1.2", only: :dev, override: true}, 57 | {:postgrex, ">= 0.0.0", only: :test}, 58 | {:dialyxir, "~> 1.1", only: [:dev], runtime: false}, 59 | {:credo, "~> 1.5", only: [:dev, :test], runtime: false}, 60 | {:plug, "~> 1.11"}, 61 | {:jason, "~> 1.0"} 62 | ] 63 | end 64 | 65 | defp package do 66 | [ 67 | maintainers: ["Stephen Pallen"], 68 | licenses: ["MIT"], 69 | links: %{"Github" => "https://github.com/smpallen99/coherence"}, 70 | files: ~w(lib priv README.md mix.exs LICENSE) 71 | ] 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /priv/templates/coh.gen.controllers/controllers/coherence/confirmation_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule <%= web_base %>.Coherence.ConfirmationController do 2 | @moduledoc """ 3 | Handle confirmation actions. 4 | 5 | A single action, `edit`, is required for the confirmation module. 6 | 7 | """ 8 | use CoherenceWeb, :controller 9 | use Coherence.ConfirmationControllerBase, schemas: <%= base %>.Coherence.Schemas 10 | 11 | plug(Coherence.ValidateOption, :confirmable) 12 | plug(:layout_view, view: Coherence.ConfirmationView, caller: __MODULE__) 13 | plug(:redirect_logged_in when action in [:new]) 14 | end 15 | -------------------------------------------------------------------------------- /priv/templates/coh.gen.controllers/controllers/coherence/invitation_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule <%= web_base %>.Coherence.InvitationController do 2 | @moduledoc """ 3 | Handle invitation actions. 4 | 5 | Handle the following actions: 6 | 7 | * new - render the send invitation form. 8 | * create - generate and send the invitation token. 9 | * edit - render the form after user clicks the invitation email link. 10 | * create_user - create a new user database record 11 | * resend - resend an invitation token email 12 | """ 13 | use CoherenceWeb, :controller 14 | use Coherence.InvitationControllerBase, schemas: <%= base %>.Coherence.Schemas 15 | 16 | plug(Coherence.ValidateOption, :invitable) 17 | plug(:scrub_params, "user" when action in [:create_user]) 18 | plug(:layout_view, view: Coherence.InvitationView, caller: __MODULE__) 19 | end 20 | -------------------------------------------------------------------------------- /priv/templates/coh.gen.controllers/controllers/coherence/password_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule <%= web_base %>.Coherence.PasswordController do 2 | @moduledoc """ 3 | Handle password recovery actions. 4 | 5 | Controller that handles the recover password feature. 6 | 7 | Actions: 8 | 9 | * new - render the recover password form 10 | * create - verify user's email address, generate a token, and send the email 11 | * edit - render the reset password form 12 | * update - verify password, password confirmation, and update the database 13 | """ 14 | use CoherenceWeb, :controller 15 | use Coherence.PasswordControllerBase, schemas: <%= base %>.Coherence.Schemas 16 | 17 | plug(:layout_view, view: Coherence.PasswordView, caller: __MODULE__) 18 | plug(:redirect_logged_in when action in [:new, :create, :edit, :update]) 19 | end 20 | -------------------------------------------------------------------------------- /priv/templates/coh.gen.controllers/controllers/coherence/registration_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule <%= web_base %>.Coherence.RegistrationController do 2 | @moduledoc """ 3 | Handle account registration actions. 4 | 5 | Actions: 6 | 7 | * new - render the register form 8 | * create - create a new user account 9 | * edit - edit the user account 10 | * update - update the user account 11 | * delete - delete the user account 12 | """ 13 | use CoherenceWeb, :controller 14 | use Coherence.RegistrationControllerBase, schemas: <%= base %>.Coherence.Schemas 15 | 16 | plug(Coherence.RequireLogin when action in ~w(show edit update delete)a) 17 | plug(Coherence.ValidateOption, :registerable) 18 | plug(:scrub_params, "registration" when action in [:create, :update]) 19 | 20 | plug(:layout_view, view: Coherence.RegistrationView, caller: __MODULE__) 21 | plug(:redirect_logged_in when action in [:new, :create]) 22 | end 23 | -------------------------------------------------------------------------------- /priv/templates/coh.gen.controllers/controllers/coherence/session_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule <%= web_base %>.Coherence.SessionController do 2 | @moduledoc """ 3 | Handle the authentication actions. 4 | 5 | Module used for the session controller when the parent project does not 6 | generate controllers. Most of the work is done by the 7 | `Coherence.SessionControllerBase` inclusion. 8 | """ 9 | use CoherenceWeb, :controller 10 | use Coherence.SessionControllerBase, schemas: <%= base %>.Coherence.Schemas 11 | 12 | plug(:layout_view, view: Coherence.SessionView, caller: __MODULE__) 13 | plug(:redirect_logged_in when action in [:new, :create]) 14 | end 15 | -------------------------------------------------------------------------------- /priv/templates/coh.gen.controllers/controllers/coherence/unlock_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule <%= web_base %>.Coherence.UnlockController do 2 | @moduledoc """ 3 | Handle unlock_with_token actions. 4 | 5 | This controller provides the ability generate an unlock token, send 6 | the user an email and unlocking the account with a valid token. 7 | 8 | Basic locking and unlocking does not use this controller. 9 | """ 10 | use CoherenceWeb, :controller 11 | use Coherence.UnlockControllerBase, schemas: <%= base %>.Coherence.Schemas 12 | 13 | plug(Coherence.ValidateOption, :unlockable_with_token) 14 | plug(:layout_view, view: Coherence.UnlockView, caller: __MODULE__) 15 | plug(:redirect_logged_in when action in [:new, :create, :edit]) 16 | end 17 | -------------------------------------------------------------------------------- /priv/templates/coh.install/coherence_web.ex: -------------------------------------------------------------------------------- 1 | defmodule <%= web_module %> do 2 | @moduledoc false 3 | 4 | def view do 5 | quote do 6 | use Phoenix.View, root: "<%= Path.join(web_path, "templates") %>" 7 | # Import convenience functions from controllers 8 | 9 | import Phoenix.Controller, only: [get_csrf_token: 0, get_flash: 2, view_module: 1] 10 | 11 | # Use all HTML functionality (forms, tags, etc) 12 | use Phoenix.HTML 13 | 14 | import <%= web_base %>.Router.Helpers 15 | import <%= web_base %>.ErrorHelpers 16 | import <%= web_base %>.Gettext 17 | import <%= web_base %>.Coherence.ViewHelpers 18 | end 19 | end 20 | 21 | def controller do 22 | quote do 23 | use Phoenix.Controller, except: [layout_view: 2] 24 | use Coherence.Config 25 | use Timex 26 | 27 | import Ecto 28 | import Ecto.Query 29 | import Plug.Conn 30 | import <%= web_base %>.Router.Helpers 31 | import <%= web_base %>.Gettext 32 | import Coherence.Controller 33 | 34 | alias Coherence.Config 35 | alias Coherence.Controller 36 | 37 | require Redirects 38 | end 39 | end 40 | 41 | @doc """ 42 | When used, dispatch to the appropriate controller/view/etc. 43 | """ 44 | defmacro __using__(which) when is_atom(which) do 45 | apply(__MODULE__, which, []) 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /priv/templates/coh.install/controllers/coherence/redirects.ex: -------------------------------------------------------------------------------- 1 | defmodule Coherence.Redirects do 2 | @moduledoc """ 3 | Define controller action redirection functions. 4 | 5 | This module contains default redirect functions for each of the controller 6 | actions that perform redirects. By using this Module you get the following 7 | functions: 8 | 9 | * session_create/2 10 | * session_delete/2 11 | * password_create/2 12 | * password_update/2, 13 | * unlock_create_not_locked/2 14 | * unlock_create_invalid/2 15 | * unlock_create/2 16 | * unlock_edit_not_locked/2 17 | * unlock_edit/2 18 | * unlock_edit_invalid/2 19 | * registration_create/2 20 | * invitation_create/2 21 | * confirmation_create/2 22 | * confirmation_edit_invalid/2 23 | * confirmation_edit_expired/2 24 | * confirmation_edit/2 25 | * confirmation_edit_error/2 26 | 27 | You can override any of the functions to customize the redirect path. Each 28 | function is passed the `conn` and `params` arguments from the controller. 29 | 30 | ## Examples 31 | 32 | import <%= web_base %>.Router.Helpers 33 | 34 | # override the log out action back to the log in page 35 | def session_delete(conn, _), do: redirect(conn, to: session_path(conn, :new)) 36 | 37 | # redirect the user to the login page after registering 38 | def registration_create(conn, _), do: redirect(conn, to: session_path(conn, :new)) 39 | 40 | # disable the user_return_to feature on login 41 | def session_create(conn, _), do: redirect(conn, to: landing_path(conn, :index)) 42 | 43 | """ 44 | use Redirects 45 | # Uncomment the import below if adding overrides 46 | # import <%= web_base %>.Router.Helpers 47 | 48 | # Add function overrides below 49 | 50 | # Example usage 51 | # Uncomment the following line to return the user to the login form after logging out 52 | # def session_delete(conn, _), do: redirect(conn, to: session_path(conn, :new)) 53 | end 54 | -------------------------------------------------------------------------------- /priv/templates/coh.install/controllers/coherence/responders/html.ex: -------------------------------------------------------------------------------- 1 | defmodule Coherence.Responders.Html do 2 | use Responders.Html 3 | end 4 | -------------------------------------------------------------------------------- /priv/templates/coh.install/controllers/coherence/responders/json.ex: -------------------------------------------------------------------------------- 1 | defmodule Coherence.Responders.Json do 2 | use Responders.Json 3 | end 4 | -------------------------------------------------------------------------------- /priv/templates/coh.install/emails/coherence/coherence_mailer.ex: -------------------------------------------------------------------------------- 1 | defmodule <%= web_base %>.Coherence.Mailer do 2 | @moduledoc false 3 | if Coherence.Config.mailer?() do 4 | use Swoosh.Mailer, otp_app: :coherence 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /priv/templates/coh.install/emails/coherence/user_email.ex: -------------------------------------------------------------------------------- 1 | Code.ensure_loaded(Phoenix.Swoosh) 2 | 3 | defmodule <%= web_base %>.Coherence.UserEmail do 4 | @moduledoc """ 5 | Generate Coherence emails. 6 | 7 | Renders all the Coherence generated emails including: 8 | 9 | * Reset password 10 | * New account confirmation 11 | * Account Invitation 12 | * Account Unlock 13 | 14 | To assist in trouble shooting the mailer, or to retrieve email links like the 15 | confirmation or invitation emails, logging can be enabled. This feature is 16 | enabled by setting `log_emails: true` in the coherence's configuration. 17 | """ 18 | use Phoenix.Swoosh, 19 | view: <%= web_base %>.Coherence.EmailView, 20 | layout: {<%= web_base %>.Coherence.LayoutView, :email} 21 | 22 | alias Swoosh.Email 23 | require Logger 24 | alias Coherence.Config 25 | import <%= web_base %>.Gettext 26 | 27 | defp site_name, do: Config.site_name(inspect(Config.module())) 28 | 29 | @doc """ 30 | Render the reset password email. 31 | 32 | Renders the email sent when someone clicks the Forgot password link on the new 33 | pages. The email includes a link to reset the user's password. 34 | """ 35 | def password(user, url) do 36 | %Email{} 37 | |> from(from_email()) 38 | |> to(user_email(user)) 39 | |> add_reply_to() 40 | |> subject( 41 | dgettext("coherence", "%{site_name} - Reset password instructions", site_name: site_name()) 42 | ) 43 | |> render_body("password.html", %{url: url, name: first_name(user.name)}) 44 | |> log() 45 | end 46 | 47 | @doc """ 48 | Renders the account confirmation email. 49 | 50 | Renders the email sent when someone registers for an account with the 51 | confirmable option enabled. The email contains a link to confirm the account. 52 | """ 53 | def confirmation(user, url) do 54 | {template, subject, email} = 55 | if Config.get(:confirm_email_updates) && user.unconfirmed_email do 56 | { 57 | "reconfirmation.html", 58 | dgettext("coherence", "%{site_name} - Confirm your new email", site_name: site_name()), 59 | unconfirmed_email(user) 60 | } 61 | else 62 | { 63 | "confirmation.html", 64 | dgettext( 65 | "coherence", 66 | "%{site_name} - Confirm your new account", 67 | site_name: site_name() 68 | ), 69 | user_email(user) 70 | } 71 | end 72 | 73 | %Email{} 74 | |> from(from_email()) 75 | |> to(email) 76 | |> add_reply_to() 77 | |> subject(subject) 78 | |> render_body(template, %{url: url, name: first_name(user.name)}) 79 | |> log() 80 | end 81 | 82 | @doc """ 83 | Renders the invitation email. 84 | 85 | Renders the email when someone is invited. The email contains a link to 86 | register for an account. 87 | """ 88 | def invitation(invitation, url) do 89 | %Email{} 90 | |> from(from_email()) 91 | |> to(user_email(invitation)) 92 | |> add_reply_to() 93 | |> subject( 94 | dgettext( 95 | "coherence", 96 | "%{site_name} - Invitation to create a new account", 97 | site_name: site_name() 98 | ) 99 | ) 100 | |> render_body("invitation.html", %{url: url, name: first_name(invitation.name)}) 101 | |> log() 102 | end 103 | 104 | @doc """ 105 | Renders the unlock account email. 106 | 107 | Renders the email sent when a user requests to unlock their account. The email 108 | contains a link with an unlock token. 109 | """ 110 | def unlock(user, url) do 111 | %Email{} 112 | |> from(from_email()) 113 | |> to(user_email(user)) 114 | |> add_reply_to() 115 | |> subject( 116 | dgettext("coherence", "%{site_name} - Unlock Instructions", site_name: site_name()) 117 | ) 118 | |> render_body("unlock.html", %{url: url, name: first_name(user.name)}) 119 | |> log() 120 | end 121 | 122 | defp add_reply_to(mail) do 123 | case Coherence.Config.email_reply_to() do 124 | nil -> mail 125 | true -> reply_to(mail, from_email()) 126 | address -> reply_to(mail, address) 127 | end 128 | end 129 | 130 | defp first_name(name) do 131 | case String.split(name, " ") do 132 | [first_name | _] -> first_name 133 | _ -> name 134 | end 135 | end 136 | 137 | defp user_email(user) do 138 | {user.name, user.email} 139 | end 140 | 141 | @doc false 142 | def unconfirmed_email(user) do 143 | {user.name, user.unconfirmed_email} 144 | end 145 | 146 | defp from_email do 147 | case Coherence.Config.email_from() do 148 | nil -> 149 | Logger.error( 150 | ~s|Need to configure :coherence, :email_from_name, "Name", and :email_from_email, "me@example.com"| 151 | ) 152 | 153 | nil 154 | 155 | {name, email} = email_tuple -> 156 | if is_nil(name) or is_nil(email) do 157 | Logger.error( 158 | ~s|Need to configure :coherence, :email_from_name, "Name", and :email_from_email, "me@example.com"| 159 | ) 160 | 161 | nil 162 | else 163 | email_tuple 164 | end 165 | end 166 | end 167 | 168 | @doc """ 169 | Log a rendered email. 170 | """ 171 | def log(%{html_body: body, subject: subject, from: from, to: to} = email) do 172 | if Application.get_env(:coherence, :log_emails) do 173 | Logger.info("Email to: #{inspect(to)}, from: #{inspect(from)}") 174 | Logger.info("Subject: #{subject}") 175 | Logger.info(body) 176 | end 177 | 178 | email 179 | end 180 | 181 | def log(email) do 182 | email 183 | end 184 | end 185 | -------------------------------------------------------------------------------- /priv/templates/coh.install/models/coherence/invitation.ex: -------------------------------------------------------------------------------- 1 | defmodule <%= base %>.Coherence.Invitation do 2 | @moduledoc """ 3 | Schema to support inviting a someone to create an account. 4 | """ 5 | use Ecto.Schema 6 | import Ecto.Changeset 7 | <%= if use_binary_id? do %> 8 | @primary_key {:id, :binary_id, autogenerate: true} 9 | @foreign_key_type :binary_id 10 | <% else %><% end %> 11 | schema "invitations" do 12 | field(:name, :string) 13 | field(:email, :string) 14 | field(:token, :string) 15 | 16 | timestamps() 17 | end 18 | 19 | @doc """ 20 | Creates a changeset based on the `model` and `params`. 21 | 22 | If no params are provided, an invalid changeset is returned 23 | with no validation performed. 24 | """ 25 | @spec changeset(Ecto.Schema.t(), map()) :: Ecto.Changeset.t() 26 | def changeset(model, params \\ %{}) do 27 | model 28 | |> cast(params, ~w(name email token)) 29 | |> validate_required([:name, :email]) 30 | |> unique_constraint(:email) 31 | |> validate_format(:email, ~r/@/) 32 | end 33 | 34 | @doc """ 35 | Creates a changeset for a new schema 36 | """ 37 | @spec new_changeset(map()) :: Ecto.Changeset.t() 38 | def new_changeset(params \\ %{}) do 39 | changeset(%__MODULE__{}, params) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /priv/templates/coh.install/models/coherence/rememberable.ex: -------------------------------------------------------------------------------- 1 | defmodule <%= base %>.Coherence.Rememberable do 2 | @moduledoc false 3 | use Ecto.Schema 4 | 5 | import Ecto.Changeset 6 | import Ecto.Query 7 | 8 | alias Coherence.Config 9 | <%= if use_binary_id? do %> 10 | @primary_key {:id, :binary_id, autogenerate: true} 11 | @foreign_key_type :binary_id 12 | <% else %><% end %> 13 | schema "rememberables" do 14 | field(:series_hash, :string) 15 | field(:token_hash, :string) 16 | field(:token_created_at, :naive_datetime) 17 | belongs_to(:user, Config.user_schema()<%= if use_binary_id?, do: ", type: :binary_id", else: "" %>) 18 | 19 | timestamps() 20 | end 21 | 22 | use Coherence.Rememberable 23 | 24 | @doc """ 25 | Creates a changeset based on the `model` and `params`. 26 | 27 | If no params are provided, an invalid changeset is returned 28 | with no validation performed. 29 | """ 30 | @spec changeset(Ecto.Schema.t(), map()) :: Ecto.Changeset.t() 31 | def changeset(model, params \\ %{}) do 32 | model 33 | |> cast(params, ~w(series_hash token_hash token_created_at user_id)) 34 | |> validate_required(~w(series_hash token_hash token_created_at user_id)a) 35 | end 36 | 37 | @doc """ 38 | Creates a changeset for a new schema 39 | """ 40 | @spec new_changeset(map()) :: Ecto.Changeset.t() 41 | def new_changeset(params \\ %{}) do 42 | changeset(%Rememberable{}, params) 43 | end 44 | 45 | end 46 | -------------------------------------------------------------------------------- /priv/templates/coh.install/models/coherence/schemas.ex: -------------------------------------------------------------------------------- 1 | defmodule <%= base %>.Coherence.Schemas do 2 | use Coherence.Config 3 | 4 | import Ecto.Query 5 | 6 | @user_schema Config.user_schema() 7 | @repo Config.repo() 8 | 9 | def list_user do 10 | @repo.all(@user_schema) 11 | end 12 | 13 | def list_by_user(opts) do 14 | @repo.all(query_by(@user_schema, opts)) 15 | end 16 | 17 | def get_by_user(opts) do 18 | @repo.get_by(@user_schema, opts) 19 | end 20 | 21 | def get_user(id) do 22 | @repo.get(@user_schema, id) 23 | end 24 | 25 | def get_user!(id) do 26 | @repo.get!(@user_schema, id) 27 | end 28 | 29 | def get_user_by_email(email) do 30 | @repo.get_by(@user_schema, email: email) 31 | end 32 | 33 | def change_user(struct, params) do 34 | @user_schema.changeset(struct, params) 35 | end 36 | 37 | def change_user(params) do 38 | @user_schema.changeset(@user_schema.__struct__, params) 39 | end 40 | 41 | def change_user do 42 | @user_schema.changeset(@user_schema.__struct__, %{}) 43 | end 44 | 45 | def create_user(params) do 46 | @repo.insert(change_user(params)) 47 | end 48 | 49 | def create_user!(params) do 50 | @repo.insert!(change_user(params)) 51 | end 52 | 53 | def update_user(user, params) do 54 | @repo.update(change_user(user, params)) 55 | end 56 | 57 | def update_user!(user, params) do 58 | @repo.update!(change_user(user, params)) 59 | end 60 | 61 | Enum.each( 62 | <%= schema_list %>, 63 | fn module -> 64 | name = 65 | module 66 | |> Module.split() 67 | |> List.last() 68 | |> String.downcase() 69 | 70 | def unquote(String.to_atom("list_#{name}"))() do 71 | @repo.all(unquote(module)) 72 | end 73 | 74 | def unquote(String.to_atom("list_#{name}"))(%Ecto.Query{} = query) do 75 | @repo.all(query) 76 | end 77 | 78 | def unquote(String.to_atom("list_by_#{name}"))(opts) do 79 | @repo.all(query_by(unquote(module), opts)) 80 | end 81 | 82 | def unquote(String.to_atom("get_#{name}"))(id) do 83 | @repo.get(unquote(module), id) 84 | end 85 | 86 | def unquote(String.to_atom("get_#{name}!"))(id) do 87 | @repo.get!(unquote(module), id) 88 | end 89 | 90 | def unquote(String.to_atom("get_by_#{name}"))(opts) do 91 | @repo.get_by(unquote(module), opts) 92 | end 93 | 94 | def unquote(String.to_atom("change_#{name}"))(struct, params) do 95 | unquote(module).changeset(struct, params) 96 | end 97 | 98 | def unquote(String.to_atom("change_#{name}"))(params) do 99 | unquote(module).new_changeset(params) 100 | end 101 | 102 | def unquote(String.to_atom("change_#{name}"))() do 103 | unquote(module).new_changeset(%{}) 104 | end 105 | 106 | def unquote(String.to_atom("create_#{name}"))(params) do 107 | @repo.insert(unquote(module).new_changeset(params)) 108 | end 109 | 110 | def unquote(String.to_atom("create_#{name}!"))(params) do 111 | @repo.insert!(unquote(module).new_changeset(params)) 112 | end 113 | 114 | def unquote(String.to_atom("update_#{name}"))(struct, params) do 115 | @repo.update(unquote(module).changeset(struct, params)) 116 | end 117 | 118 | def unquote(String.to_atom("update_#{name}!"))(struct, params) do 119 | @repo.update!(unquote(module).changeset(struct, params)) 120 | end 121 | 122 | def unquote(String.to_atom("delete_#{name}"))(struct) do 123 | @repo.delete(struct) 124 | end 125 | end 126 | ) 127 | 128 | <%= if trackable? do %> 129 | def last_trackable(user_id) do 130 | schema = 131 | @repo.one( 132 | <%= base %>.Coherence.Trackable 133 | |> where([t], t.user_id == ^user_id) 134 | |> order_by(desc: :id) 135 | |> limit(1) 136 | ) 137 | 138 | case schema do 139 | nil -> <%= base %>.Coherence.Trackable.__struct__ 140 | trackable -> trackable 141 | end 142 | end 143 | <% end %> 144 | 145 | def query_by(schema, opts) do 146 | Enum.reduce(opts, schema, fn {k, v}, query -> 147 | where(query, [b], field(b, ^k) == ^v) 148 | end) 149 | end 150 | 151 | def delete_all(%Ecto.Query{} = query) do 152 | @repo.delete_all(query) 153 | end 154 | 155 | def delete_all(module) when is_atom(module) do 156 | @repo.delete_all(module) 157 | end 158 | 159 | def create(%Ecto.Changeset{} = changeset) do 160 | @repo.insert(changeset) 161 | end 162 | 163 | def create!(%Ecto.Changeset{} = changeset) do 164 | @repo.insert!(changeset) 165 | end 166 | 167 | def update(%Ecto.Changeset{} = changeset) do 168 | @repo.update(changeset) 169 | end 170 | 171 | def update!(%Ecto.Changeset{} = changeset) do 172 | @repo.update!(changeset) 173 | end 174 | 175 | def delete(schema) do 176 | @repo.delete(schema) 177 | end 178 | 179 | def delete!(schema) do 180 | @repo.delete!(schema) 181 | end 182 | end 183 | -------------------------------------------------------------------------------- /priv/templates/coh.install/models/coherence/trackable.ex: -------------------------------------------------------------------------------- 1 | defmodule <%= base %>.Coherence.Trackable do 2 | @moduledoc """ 3 | Schema responsible for saving user tracking data for the --trackable-table option. 4 | """ 5 | use Ecto.Schema 6 | import Ecto.Changeset 7 | 8 | alias Coherence.Config 9 | 10 | @fields ~w(action sign_in_count current_sign_in_ip current_sign_in_at last_sign_in_ip last_sign_in_at user_id)a 11 | <%= if use_binary_id? do %> 12 | @primary_key {:id, :binary_id, autogenerate: true} 13 | @foreign_key_type :binary_id 14 | <% else %><% end %> 15 | schema "trackables" do 16 | field(:action, :string, null: false) 17 | field(:sign_in_count, :integer, default: 0) 18 | field(:current_sign_in_at, :naive_datetime) 19 | field(:last_sign_in_at, :naive_datetime) 20 | field(:current_sign_in_ip, :string) 21 | field(:last_sign_in_ip, :string) 22 | belongs_to(:user, Config.user_schema()<%= if use_binary_id?, do: ", type: :binary_id", else: "" %>) 23 | 24 | timestamps() 25 | end 26 | 27 | @doc """ 28 | Creates a changeset based on the `model` and `params`. 29 | 30 | If no params are provided, an invalid changeset is returned 31 | with no validation performed. 32 | """ 33 | @spec changeset(Ecto.Schema.t(), map()) :: Ecto.Changeset.t() 34 | def changeset(model, params \\ %{}) do 35 | model 36 | |> cast(params, @fields) 37 | |> validate_required([:action, :user_id]) 38 | end 39 | 40 | @doc """ 41 | Creates a changeset for a new schema 42 | """ 43 | @spec new_changeset(map()) :: Ecto.Changeset.t() 44 | def new_changeset(params \\ %{}) do 45 | changeset(%__MODULE__{}, params) 46 | end 47 | 48 | end 49 | -------------------------------------------------------------------------------- /priv/templates/coh.install/models/coherence/user.ex: -------------------------------------------------------------------------------- 1 | defmodule <%= user_schema %> do 2 | @moduledoc false 3 | use Ecto.Schema 4 | use Coherence.Schema 5 | <%= if use_binary_id? do %> 6 | @primary_key {:id, :binary_id, autogenerate: true} 7 | @foreign_key_type :binary_id 8 | <% else %><% end %> 9 | schema "<%= user_table_name %>" do 10 | field(:name, :string) 11 | field(:email, :string) 12 | coherence_schema() 13 | 14 | timestamps() 15 | end 16 | 17 | @doc false 18 | @spec changeset(Ecto.Schema.t(), map()) :: Ecto.Changeset.t() 19 | def changeset(model, params \\ %{}) do 20 | model 21 | |> cast(params, [:name, :email] ++ coherence_fields()) 22 | |> validate_required([:name, :email]) 23 | |> validate_format(:email, ~r/@/) 24 | |> unique_constraint(:email) 25 | |> validate_coherence(params) 26 | end 27 | 28 | @doc false 29 | @spec changeset(Ecto.Schema.t(), map(), atom) :: Ecto.Changeset.t() 30 | def changeset(model, params, :password) do 31 | model 32 | |> cast( 33 | params, 34 | ~w(password password_confirmation reset_password_token reset_password_sent_at)a 35 | ) 36 | |> validate_coherence_password_reset(params) 37 | end 38 | 39 | def changeset(model, params, :registration) do 40 | changeset = changeset(model, params) 41 | 42 | if Config.get(:confirm_email_updates) && Map.get(params, "email", false) && model.id do 43 | changeset 44 | |> put_change(:unconfirmed_email, get_change(changeset, :email)) 45 | |> delete_change(:email) 46 | else 47 | changeset 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /priv/templates/coh.install/templates/coherence/confirmation/new.html.eex: -------------------------------------------------------------------------------- 1 |
2 | 3 |

<%%= dgettext "coherence", "Resend Confirmation Instructions" %>

4 | 5 | <%%= form_for @changeset, confirmation_path(@conn, :create), [as: :confirmation], fn f -> %> 6 | 7 |
8 | <%%= required_label f, dgettext("coherence", "Email"), class: "control-label" %> 9 | <%%= text_input f, :email, class: "form-control", required: "" %> 10 | <%%= error_tag f, :email %> 11 |
12 | 13 |
14 | <%%= submit dgettext("coherence", "Resend Email"), class: "btn btn-primary" %> 15 | <%%= link dgettext("coherence", "Cancel"), to: Coherence.Config.logged_out_url("/"), class: "btn" %> 16 |
17 | <%% end %> 18 | -------------------------------------------------------------------------------- /priv/templates/coh.install/templates/coherence/email/confirmation.html.eex: -------------------------------------------------------------------------------- 1 |
2 |

<%%= dgettext "coherence", "Hello %{name}!", name: @name %>

3 |

4 | <%%= dgettext "coherence", "Your new account is almost ready. Click the link below to confirm your new account." %> 5 |

6 |

7 | <%%= dgettext "coherence", "Confirm my Account" %> 8 |

9 |

<%%= dgettext "coherence", "Thank you!" %>

10 |
11 | 12 | -------------------------------------------------------------------------------- /priv/templates/coh.install/templates/coherence/email/invitation.html.eex: -------------------------------------------------------------------------------- 1 |
2 |

<%%= dgettext "coherence", "Hello %{name}!", name: @name %>

3 |

4 | <%%= dgettext "coherence", "You have been invited to create an Account. Use the link below to create an account." %> 5 |

6 |

7 | <%%= dgettext "coherence", "Create my Account" %> 8 |

9 |

<%%= dgettext "coherence", "Thank you!" %>

10 |
11 | 12 | -------------------------------------------------------------------------------- /priv/templates/coh.install/templates/coherence/email/password.html.eex: -------------------------------------------------------------------------------- 1 |
2 |

<%%= dgettext "coherence", "Hello %{name}!", name: @name %>

3 |

4 | <%%= dgettext "coherence", "Someone has requested a link to change your password, and you can do this through the link below." %> 5 |

6 |

7 | <%%= dgettext "coherence", "Change my password" %> 8 |

9 |

10 | <%%= dgettext "coherence", "If you didn't request this, please ignore this email." %> 11 |

12 |

13 | <%%= dgettext "coherence", "Your password won't change until you access the link above and create a new one." %> 14 |

15 |
16 | 17 | -------------------------------------------------------------------------------- /priv/templates/coh.install/templates/coherence/email/reconfirmation.html.eex: -------------------------------------------------------------------------------- 1 |
2 |

<%%= dgettext "coherence", "Hello %{name}!", name: @name %>

3 |

4 | <%%= dgettext "coherence", "Your new email has been correctly saved. Click the link below to confirm your new email." %> 5 |

6 |

7 | <%%= dgettext "coherence", "Confirm my Email" %> 8 |

9 |

<%%= dgettext "coherence", "Thank you!" %>

10 |
11 | 12 | -------------------------------------------------------------------------------- /priv/templates/coh.install/templates/coherence/email/unlock.html.eex: -------------------------------------------------------------------------------- 1 |
2 |

<%%= dgettext "coherence", "Hello %{name}!", name: @name %>

3 |

4 | <%%= dgettext "coherence", "You requested unlock instructions for your locked account. Please click the link below to unlock your account." %> 5 |

6 |

7 | <%%= dgettext "coherence", "Unlock my Account" %> 8 |

9 |

<%%= dgettext "coherence", "Thank you!" %>

10 |
11 | 12 | -------------------------------------------------------------------------------- /priv/templates/coh.install/templates/coherence/invitation/edit.html.eex: -------------------------------------------------------------------------------- 1 |
2 | 3 | <%%= form_for @changeset, invitation_path(@conn, :create_user), fn f -> %> 4 | 5 | <%%= if @changeset.action do %> 6 |
7 |

<%%= dgettext "coherence", "Oops, something went wrong! Please check the errors below." %>

8 |
9 | <%% end %> 10 | 11 | 12 | 13 |
14 | <%%= required_label f, dgettext("coherence", "Email"), class: "control-label" %> 15 | <%%= text_input f, :name, class: "form-control", required: "" %> 16 | <%%= error_tag f, :name %> 17 |
18 | 19 | <%%= unless (login_field = Coherence.Config.login_field) == :email do %> 20 |
21 | <%%= required_label f, login_field, class: "control-label" %> 22 | <%%= text_input f, login_field, class: "form-control", required: "" %> 23 | <%%= error_tag f, login_field %> 24 |
25 | <%% end %> 26 | 27 |
28 | <%%= required_label f, dgettext("coherence", "Email"), class: "control-label" %> 29 | <%%= text_input f, :email, class: "form-control", required: "" %> 30 | <%%= error_tag f, :email %> 31 |
32 | 33 |
34 | <%%= required_label f, dgettext("coherence", "Password"), class: "control-label" %> 35 | <%%= password_input f, :password, class: "form-control", required: "" %> 36 | <%%= error_tag f, :password %> 37 |
38 | 39 |
40 | <%%= required_label f, dgettext("coherence", "Password Confirmation"), class: "control-label" %> 41 | <%%= password_input f, :password_confirmation, class: "form-control", required: "" %> 42 | <%%= error_tag f, :password_confirmation %> 43 |
44 | 45 |
46 | <%%= submit dgettext("coherence", "Create"), class: "btn btn-primary" %> 47 | <%%= link dgettext("coherence", "Cancel"), to: Coherence.Config.logged_out_url("/"), class: "btn" %> 48 |
49 | <%% end %> 50 | -------------------------------------------------------------------------------- /priv/templates/coh.install/templates/coherence/invitation/new.html.eex: -------------------------------------------------------------------------------- 1 | <%%= form_for @changeset, invitation_path(@conn, :create), [as: :invitation], fn f -> %> 2 | <%%= if @changeset.action do %> 3 |
4 |

<%%= dgettext "coherence", "Oops, something went wrong! Please check the errors below." %>

5 |
6 | <%% end %> 7 |
8 | <%%= required_label f, dgettext("coherence", "Name"), class: "control-label" %> 9 | <%%= text_input f, :name, class: "form-control", required: "" %> 10 | <%%= error_tag f, :name %> 11 |
12 | 13 |
14 | <%%= required_label f, dgettext("coherence", "Email"), class: "control-label" %> 15 | <%%= text_input f, :email, class: "form-control", required: "" %> 16 | <%%= error_tag f, :email %> 17 |
18 | 19 |
20 | <%%= submit dgettext("coherence", "Send Invitation"), class: "btn btn-primary" %> 21 | <%%= link dgettext("coherence", "Cancel"), to: Coherence.Config.logged_out_url("/"), class: "btn" %> 22 | <%%= if invitation = @conn.assigns[:invitation] do %> 23 | <%%= link dgettext("coherence", "Resend Invitation!"), to: invitation_path(@conn, :resend, invitation.id), class: "btn" %> 24 | <%% end %> 25 |
26 | <%% end %> 27 | -------------------------------------------------------------------------------- /priv/templates/coh.install/templates/coherence/layout/app.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | <%%= Coherence.Config.title() %> 11 | "> 12 | 13 | 14 | 15 |
16 |
17 | 19 | 20 |
21 | 22 | 23 | 24 | 25 |
26 | <%%= @inner_content %> 27 |
28 | 29 |
30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /priv/templates/coh.install/templates/coherence/layout/email.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | <%%= @email.subject %> 4 | 5 | 6 | <%%= @inner_content %> 7 | 8 | 9 | -------------------------------------------------------------------------------- /priv/templates/coh.install/templates/coherence/password/edit.html.eex: -------------------------------------------------------------------------------- 1 |
2 | 3 |

<%%= dgettext "coherence", "Create a New Password" %>

4 | 5 | <%%= form_for @changeset, password_path(@conn, :update, @changeset.data), [as: :password], fn f -> %> 6 | 7 | <%%= hidden_input f, :reset_password_token %> 8 | 9 |
10 | <%%= required_label f, dgettext("coherence", "Password"), class: "control-label" %> 11 | <%%= password_input f, :password, class: "form-control", required: "" %> 12 | <%%= error_tag f, :password %> 13 |
14 | 15 |
16 | <%%= required_label f, dgettext("coherence", "Password Confirmation"), class: "control-label" %> 17 | <%%= password_input f, :password_confirmation, class: "form-control", required: "" %> 18 | <%%= error_tag f, :password_confirmation %> 19 |
20 | 21 |
22 | <%%= submit dgettext("coherence", "Update Password"), class: "btn btn-primary" %> 23 | <%%= link dgettext("coherence", "Cancel"), to: Coherence.Config.logged_out_url("/"), class: "btn" %> 24 |
25 | <%% end %> 26 | -------------------------------------------------------------------------------- /priv/templates/coh.install/templates/coherence/password/new.html.eex: -------------------------------------------------------------------------------- 1 |
2 | 3 |

<%%= dgettext "coherence", "Send Reset Password Instructions" %>

4 | 5 | <%%= form_for @changeset, password_path(@conn, :create), [as: :password], fn f -> %> 6 | 7 |
8 | <%%= required_label f, :email, class: "control-label" %> 9 | <%%= text_input f, :email, class: "form-control", required: "" %> 10 | <%%= error_tag f, :email %> 11 |
12 | 13 |
14 | <%%= submit dgettext("coherence", "Reset Password"), class: "btn btn-primary" %> 15 | <%%= link dgettext("coherence", "Cancel"), to: Coherence.Config.logged_out_url("/"), class: "btn" %> 16 |
17 | <%% end %> 18 | -------------------------------------------------------------------------------- /priv/templates/coh.install/templates/coherence/registration/edit.html.eex: -------------------------------------------------------------------------------- 1 |

<%%= dgettext "coherence", "Edit Account" %>

2 | 3 | <%%= render "form.html", changeset: @changeset, 4 | label: dgettext("coherence", "Update"), required: [], 5 | action: registration_path(@conn, :update) %> 6 | -------------------------------------------------------------------------------- /priv/templates/coh.install/templates/coherence/registration/form.html.eex: -------------------------------------------------------------------------------- 1 | <%%= form_for @changeset, @action, [as: :registration], fn f -> %> 2 | 3 | <%%= if @changeset.action do %> 4 |
5 |

<%%= dgettext "coherence", "Oops, something went wrong! Please check the errors below." %>

6 |
7 | <%% end %> 8 | 9 |
10 | <%%= required_label f, dgettext("coherence", "name"), class: "control-label" %> 11 | <%%= text_input f, :name, class: "form-control", required: "" %> 12 | <%%= error_tag f, :name %> 13 |
14 | 15 | <%%= unless (login_field = Coherence.Config.login_field) == :email do %> 16 |
17 | <%%= required_label f, login_field, class: "control-label" %> 18 | <%%= text_input f, login_field, class: "form-control", required: "" %> 19 | <%%= error_tag f, login_field %> 20 |
21 | <%% end %> 22 | 23 |
24 | <%%= required_label f, dgettext("coherence", "email"), class: "control-label" %> 25 | <%%= text_input f, :email, class: "form-control", required: "" %> 26 | <%%= error_tag f, :email %> 27 |
28 | 29 | <%%= if Coherence.Config.require_current_password and not is_nil(@changeset.data.id) do %> 30 |
31 | <%%= required_label f, :current_password, class: "control-label" %> 32 | <%%= password_input f, :current_password, [class: "form-control"] ++ @required %> 33 | <%%= error_tag f, :current_password %> 34 |
35 | <%% end %> 36 | 37 |
38 | <%%= required_label f, dgettext("coherence", "password"), class: "control-label" %> 39 | <%%= password_input f, :password, [class: "form-control"] ++ @required %> 40 | <%%= error_tag f, :password %> 41 |
42 | 43 |
44 | <%%= required_label f, dgettext("coherence", "password confirmation"), class: "control-label" %> 45 | <%%= password_input f, :password_confirmation, [class: "form-control"] ++ @required %> 46 | <%%= error_tag f, :password_confirmation %> 47 |
48 | 49 |
50 | <%%= submit @label, class: "btn btn-primary" %> 51 | <%%= link dgettext("coherence", "Cancel"), to: Coherence.Config.logged_out_url("/"), class: "btn" %> 52 |
53 | <%% end %> 54 | -------------------------------------------------------------------------------- /priv/templates/coh.install/templates/coherence/registration/new.html.eex: -------------------------------------------------------------------------------- 1 |

<%%= dgettext "coherence", "Register Account" %>

2 | 3 | <%%= render "form.html", changeset: @changeset, 4 | label: dgettext("coherence", "Register"), required: [required: ""], 5 | action: registration_path(@conn, :create) %> 6 | -------------------------------------------------------------------------------- /priv/templates/coh.install/templates/coherence/registration/show.html.eex: -------------------------------------------------------------------------------- 1 |

<%%= dgettext "coherence", "Show account" %>

2 | 20 | 21 | <%%= link dgettext("coherence", "Edit"), to: registration_path(@conn, :edit) %> | 22 | <%%= link dgettext("coherence", "Delete"), 23 | to: registration_path(@conn, :delete), 24 | method: :delete, 25 | data: [confirm: dgettext("coherence", "Are you sure?")] %> 26 | -------------------------------------------------------------------------------- /priv/templates/coh.install/templates/coherence/session/new.html.eex: -------------------------------------------------------------------------------- 1 |
2 | 3 | <%%= form_for @conn, session_path(@conn, :create), [as: :session], fn f -> %> 4 | 5 | <%% login_field = Coherence.Config.login_field %> 6 |
7 | <%%= required_label f, login_field, class: "control-label" %> 8 | <%%= text_input f, login_field, class: "form-control", required: "" %> 9 | <%%= error_tag f, login_field %> 10 |
11 | 12 |
13 | <%%= required_label f, dgettext("coherence", "Password"), class: "control-label" %> 14 | <%%= password_input f, :password, class: "form-control", required: "" %> 15 | <%%= error_tag f, :password %> 16 |
17 | 18 | <%%= if @remember do %> 19 |
20 | 21 | 22 |
23 |
24 | <%% end %> 25 | 26 |
27 | <%%= submit dgettext("coherence", "Sign In"), class: "btn btn-primary" %> 28 | <%%= link dgettext("coherence", "Cancel"), to: Coherence.Config.logged_out_url("/"), class: "btn" %> 29 |
30 | 31 |
32 | <%%= coherence_links(@conn, :new_session) %> 33 |
34 | 35 | <%% end %> 36 | -------------------------------------------------------------------------------- /priv/templates/coh.install/templates/coherence/unlock/new.html.eex: -------------------------------------------------------------------------------- 1 |
2 | 3 | <%%= form_for @conn, unlock_path(@conn, :create), [as: :unlock], fn f -> %> 4 | 5 |
6 | <%%= required_label f, dgettext("coherence", "Email"), class: "control-label" %> 7 | <%%= text_input f, :email, class: "form-control", required: "" %> 8 | <%%= error_tag f, :email %> 9 |
10 | 11 |
12 | <%%= required_label f, dgettext("coherence", "Password"), class: "control-label" %> 13 | <%%= password_input f, :password, class: "form-control", required: "" %> 14 | <%%= error_tag f, :password %> 15 |
16 | 17 |
18 | <%%= submit dgettext("coherence", "Send Instructions"), class: "btn btn-primary" %> 19 | <%%= link dgettext("coherence", "Cancel"), to: Coherence.Config.logged_out_url("/"), class: "btn" %> 20 |
21 | 22 | <%% end %> 23 | -------------------------------------------------------------------------------- /priv/templates/coh.install/views/coherence/coherence_view.ex: -------------------------------------------------------------------------------- 1 | defmodule Coherence.CoherenceView do 2 | use <%= web_module %>, :view 3 | end 4 | -------------------------------------------------------------------------------- /priv/templates/coh.install/views/coherence/confirmation_view.ex: -------------------------------------------------------------------------------- 1 | defmodule <%= web_base %>.Coherence.ConfirmationView do 2 | use <%= web_module %>, :view 3 | 4 | def render("confirmation.json", %{info: info}) do 5 | %{ 6 | info: info 7 | } 8 | end 9 | 10 | def render("error.json", %{changeset: changeset}) do 11 | changeset = cond do 12 | is_nil(changeset) || changeset == "" -> "Unknown error." 13 | is_bitstring(changeset) -> changeset 14 | true -> error_string_from_changeset(changeset) 15 | end 16 | 17 | %{error: changeset} 18 | end 19 | def render("error.json", %{error: error}) do 20 | %{error: error} 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /priv/templates/coh.install/views/coherence/email_view.ex: -------------------------------------------------------------------------------- 1 | defmodule <%= web_base %>.Coherence.EmailView do 2 | use <%= web_module %>, :view 3 | end 4 | -------------------------------------------------------------------------------- /priv/templates/coh.install/views/coherence/invitation_view.ex: -------------------------------------------------------------------------------- 1 | defmodule <%= web_base %>.Coherence.InvitationView do 2 | use <%= web_module %>, :view 3 | 4 | def render("invitation.json", %{info: info}) do 5 | %{info: info} 6 | end 7 | 8 | def render("error.json", %{changeset: changeset}) do 9 | changeset = 10 | cond do 11 | is_nil(changeset) || changeset == "" -> "Unknown error." 12 | is_bitstring(changeset) -> changeset 13 | true -> error_string_from_changeset(changeset) 14 | end 15 | 16 | %{error: changeset} 17 | end 18 | 19 | def render("error.json", %{error: error}) do 20 | %{error: error} 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /priv/templates/coh.install/views/coherence/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule <%= web_base %>.Coherence.LayoutView do 2 | use <%= web_module %>, :view 3 | end 4 | -------------------------------------------------------------------------------- /priv/templates/coh.install/views/coherence/password_view.ex: -------------------------------------------------------------------------------- 1 | defmodule <%= web_base %>.Coherence.PasswordView do 2 | use <%= web_module %>, :view 3 | 4 | def render("password.json", %{info: info}) do 5 | %{info: info} 6 | end 7 | 8 | def render("password.json", %{error: error}) do 9 | %{error: error} 10 | end 11 | 12 | def render("error.json", %{error: error}) do 13 | %{error: error} 14 | end 15 | 16 | def render("error.json", %{changeset: changeset}) do 17 | changeset = 18 | cond do 19 | is_nil(changeset) || changeset == "" -> "Unknown error." 20 | is_bitstring(changeset) -> changeset 21 | true -> error_string_from_changeset(changeset) 22 | end 23 | 24 | %{error: changeset} 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /priv/templates/coh.install/views/coherence/registration_view.ex: -------------------------------------------------------------------------------- 1 | defmodule <%= web_base %>.Coherence.RegistrationView do 2 | use <%= web_module %>, :view 3 | 4 | def render("registration.json", %{user: user}) do 5 | %{ 6 | user: %{ 7 | id: user.id, 8 | name: user.name, 9 | email: user.email 10 | } 11 | } 12 | end 13 | 14 | def render("session.json", %{user: user}) do 15 | %{ 16 | user: %{ 17 | id: user.id, 18 | name: user.name, 19 | email: user.email 20 | } 21 | } 22 | end 23 | 24 | def render("error.json", %{changeset: changeset}) do 25 | changeset = 26 | cond do 27 | is_nil(changeset) || changeset == "" -> "Unknown error." 28 | is_bitstring(changeset) -> changeset 29 | true -> error_string_from_changeset(changeset) 30 | end 31 | %{error: changeset} 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /priv/templates/coh.install/views/coherence/session_view.ex: -------------------------------------------------------------------------------- 1 | defmodule <%= web_base %>.Coherence.SessionView do 2 | use <%= web_module %>, :view 3 | 4 | def render("session.json", %{user: user}) do 5 | %{ 6 | user: %{ 7 | id: user.id, 8 | name: user.name, 9 | email: user.email 10 | } 11 | } 12 | end 13 | 14 | def render("error.json", %{error: error}) do 15 | %{ 16 | error: error 17 | } 18 | end 19 | 20 | def render("error.json", _opts) do 21 | %{ 22 | error: "Invalid credentials" 23 | } 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /priv/templates/coh.install/views/coherence/unlock_view.ex: -------------------------------------------------------------------------------- 1 | defmodule <%= web_base %>.Coherence.UnlockView do 2 | use <%= web_module %>, :view 3 | 4 | def render("unlock.json", %{info: info}) do 5 | %{ 6 | info: info 7 | } 8 | end 9 | def render("unlock.json", %{user: user}) do 10 | %{ 11 | user: %{ 12 | id: user.id, 13 | name: user.name, 14 | email: user.email 15 | } 16 | } 17 | end 18 | 19 | def render("error.json", %{error: error}) do 20 | %{ 21 | error: error 22 | } 23 | end 24 | 25 | def render("error.json", %{changeset: changeset}) do 26 | changeset = 27 | cond do 28 | is_nil(changeset) || changeset == "" -> "Unknown error." 29 | is_bitstring(changeset) -> changeset 30 | true -> error_string_from_changeset(changeset) 31 | end 32 | 33 | %{error: changeset} 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/coherence_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CoherenceTest do 2 | use TestCoherence.ModelCase 3 | doctest Coherence 4 | alias TestCoherence.User 5 | 6 | test "creates a user" do 7 | changeset = 8 | User.changeset(%User{}, %{ 9 | name: "test", 10 | email: "test@example.com", 11 | password: "test", 12 | password_confirmation: "test" 13 | }) 14 | 15 | user = Repo.insert!(changeset) 16 | assert user.email == "test@example.com" 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/config_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CoherenceTest.Config do 2 | use ExUnit.Case 3 | alias Coherence.Config 4 | 5 | setup do 6 | defaults = Application.get_env(:coherence, :opts) 7 | 8 | on_exit(fn -> 9 | Application.put_env(:coherence, :opts, defaults) 10 | end) 11 | 12 | :ok 13 | end 14 | 15 | test "has_option accepts :all" do 16 | Application.put_env(:coherence, :opts, :all) 17 | assert Config.has_option(:a) 18 | end 19 | 20 | test "has_option checks if the option is in the opts" do 21 | Application.put_env(:coherence, :opts, [:a]) 22 | assert Config.has_option(:a) 23 | end 24 | 25 | test "has_option with missing option" do 26 | Application.put_env(:coherence, :opts, [:a]) 27 | refute Config.has_option(:b) 28 | end 29 | 30 | test "has_action? with :all" do 31 | Application.put_env(:coherence, :opts, :all) 32 | assert Config.has_action?(:a, :create) 33 | end 34 | 35 | test "has_action? with list" do 36 | Application.put_env(:coherence, :opts, [:a]) 37 | assert Config.has_action?(:a, :create) 38 | end 39 | 40 | test "has_action? with missing option" do 41 | Application.put_env(:coherence, :opts, [:a]) 42 | refute Config.has_action?(:b, :create) 43 | end 44 | 45 | test "has_action? with keywords" do 46 | Application.put_env(:coherence, :opts, a: [:create]) 47 | assert Config.has_action?(:a, :create) 48 | end 49 | 50 | test "has_action? with keywords and missing action" do 51 | Application.put_env(:coherence, :opts, a: [:create]) 52 | refute Config.has_action?(:a, :new) 53 | end 54 | 55 | describe "default_routes/0" do 56 | test "when are set configured globally" do 57 | Application.put_env(:coherence, :default_routes, %{registrations: "memberships"}) 58 | assert Config.default_routes() == %{registrations: "memberships"} 59 | end 60 | 61 | test "when are not configured" do 62 | Application.put_env(:coherence, :default_routes, nil) 63 | 64 | assert Config.default_routes() == %{ 65 | registrations_new: "/registrations/new", 66 | registrations: "/registrations", 67 | passwords: "/passwords", 68 | confirmations: "/confirmations", 69 | unlocks: "/unlocks", 70 | invitations: "/invitations", 71 | invitations_create: "/invitations/create", 72 | invitations_resend: "/invitations/:id/resend", 73 | sessions: "/sessions", 74 | registrations_edit: "/registrations/edit" 75 | } 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /test/controllers/confirmation_controller.exs: -------------------------------------------------------------------------------- 1 | defmodule CoherenceTest.ConfirmationController do 2 | use TestCoherence.ConnCase 3 | import TestCoherenceWeb.Router.Helpers 4 | 5 | setup %{conn: conn} do 6 | Application.put_env(:coherence, :opts, [:confirmable, :registerable]) 7 | Application.put_env(:coherence, :confirm_email_updates, false) 8 | 9 | user = 10 | %TestCoherence.User{ 11 | name: "John Doe", 12 | email: "user@example.com", 13 | password_hash: "superhash", 14 | unconfirmed_email: "unconfirmed@example.com", 15 | confirmation_token: "foobar", 16 | confirmation_sent_at: Timex.now() 17 | } 18 | |> TestCoherence.Repo.insert!() 19 | 20 | {:ok, conn: conn, user: user} 21 | end 22 | 23 | describe "create" do 24 | test "should respond with success if user is not confirmed", %{conn: conn, user: user} do 25 | user 26 | |> Ecto.Changeset.change(%{confirmed_at: nil}) 27 | |> TestCoherence.Repo.update!() 28 | 29 | conn = 30 | post conn, confirmation_path(conn, :create), %{ 31 | "confirmation" => %{"email" => "user@example.com"} 32 | } 33 | 34 | assert html_response(conn, 302) 35 | end 36 | 37 | test "should respond with error if user is confirmed", %{conn: conn, user: user} do 38 | user 39 | |> Ecto.Changeset.change(%{confirmed_at: Timex.now()}) 40 | |> TestCoherence.Repo.update!() 41 | 42 | conn = 43 | post conn, confirmation_path(conn, :create), %{ 44 | "confirmation" => %{"email" => "user@example.com"} 45 | } 46 | 47 | assert html_response(conn, 200) 48 | assert conn.private[:phoenix_template] == "new.html" 49 | end 50 | 51 | test "should respond with success if user is confirmed but have an unconfirmed email", %{ 52 | conn: conn, 53 | user: user 54 | } do 55 | Application.put_env(:coherence, :confirm_email_updates, true) 56 | 57 | user 58 | |> Ecto.Changeset.change(%{ 59 | confirmed_at: Timex.now(), 60 | unconfirmed_email: "unconfirmed@example.com" 61 | }) 62 | |> TestCoherence.Repo.update!() 63 | 64 | conn = 65 | post conn, confirmation_path(conn, :create), %{ 66 | "confirmation" => %{"email" => "user@example.com"} 67 | } 68 | 69 | assert html_response(conn, 302) 70 | end 71 | end 72 | 73 | describe "edit" do 74 | test "should confirm valid confirmation token", %{conn: conn} do 75 | conn = get(conn, confirmation_path(conn, :edit, "foobar")) 76 | assert html_response(conn, 302) 77 | user = get_user_by_email("user@example.com") 78 | assert user.confirmation_token == nil 79 | refute user.confirmed_at == nil 80 | end 81 | 82 | test "should set email from unconfirmed_email if confirm_email_updates is true", %{conn: conn} do 83 | Application.put_env(:coherence, :confirm_email_updates, true) 84 | conn = get(conn, confirmation_path(conn, :edit, "foobar")) 85 | assert html_response(conn, 302) 86 | user = get_user_by_email("unconfirmed@example.com") 87 | assert user.unconfirmed_email == nil 88 | assert user.confirmation_token == nil 89 | refute user.confirmed_at == nil 90 | end 91 | 92 | test "should not set email from unconfirmed_email if confirm_email_updates is false", %{ 93 | conn: conn 94 | } do 95 | Application.put_env(:coherence, :confirm_email_updates, false) 96 | conn = get(conn, confirmation_path(conn, :edit, "foobar")) 97 | assert html_response(conn, 302) 98 | user = get_user_by_email("user@example.com") 99 | refute user.unconfirmed_email == nil 100 | assert user.confirmation_token == nil 101 | refute user.confirmed_at == nil 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /test/controllers/controller_helpers_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CoherenceTest.Controller do 2 | use TestCoherence.ConnCase 3 | alias TestCoherence.User 4 | alias Coherence.Controller 5 | import TestCoherence.TestHelpers 6 | 7 | doctest Coherence.Controller 8 | 9 | setup do 10 | Application.put_env(:coherence, :opts, [ 11 | :authenticatable, 12 | :recoverable, 13 | :confirmable, 14 | :invitable, 15 | :registerable 16 | ]) 17 | end 18 | 19 | test "confirm!" do 20 | user = insert_user() 21 | refute User.confirmed?(user) 22 | {:ok, user} = Controller.confirm!(user) 23 | assert User.confirmed?(user) 24 | 25 | {:error, changeset} = Controller.confirm!(user) 26 | refute changeset.valid? 27 | assert changeset.errors == [confirmed_at: {"already confirmed", []}] 28 | end 29 | 30 | test "lock!" do 31 | user = insert_user() 32 | refute User.locked?(user) 33 | {:ok, user} = Controller.lock!(user) 34 | assert User.locked?(user) 35 | 36 | {:error, changeset} = Controller.lock!(user) 37 | refute changeset.valid? 38 | assert changeset.errors == [locked_at: {"already locked", []}] 39 | end 40 | 41 | test "unlock!" do 42 | user = insert_user(%{locked_at: NaiveDateTime.utc_now()}) 43 | assert User.locked?(user) 44 | {:ok, user} = Controller.unlock!(user) 45 | refute User.locked?(user) 46 | 47 | {:error, changeset} = Controller.unlock!(user) 48 | refute changeset.valid? 49 | assert changeset.errors == [locked_at: {"not locked", []}] 50 | end 51 | 52 | test "permit only permitted map keys" do 53 | params = %{ 54 | "id" => 1, 55 | "email" => "example@example.com", 56 | "name" => "tester", 57 | "password" => "super secret" 58 | } 59 | 60 | permitted = ["email", "name", "password"] 61 | 62 | assert %{"email" => "example@example.com", "name" => "tester", "password" => "super secret"} == 63 | Controller.permit(params, permitted) 64 | end 65 | 66 | test "permit permitted and params keys do not match in type" do 67 | params = %{ 68 | :id => 1, 69 | :email => "example@example.com", 70 | :name => "tester", 71 | :password => "super secret" 72 | } 73 | 74 | permitted = ["email", "name", "password"] 75 | assert %{} == Controller.permit(params, permitted) 76 | end 77 | 78 | test "permit with not defined permitted" do 79 | params = %{ 80 | :id => 1, 81 | :email => "example@example.com", 82 | :name => "tester", 83 | :password => "super secret" 84 | } 85 | 86 | assert %{} == Controller.permit(params, nil) 87 | end 88 | 89 | test "extra permitted attribute" do 90 | params = %{"email" => "example@example.com", "name" => "tester", "password" => "super secret"} 91 | assert params == Controller.permit(params, ["extra", "email", "name", "password"]) 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /test/controllers/invitation_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CoherenceTest.InvitationController do 2 | use TestCoherence.ConnCase 3 | import TestCoherenceWeb.Router.Helpers 4 | import Coherence.Controller, only: [random_string: 1] 5 | 6 | setup %{conn: conn} do 7 | Application.put_env(:coherence, :opts, [ 8 | :confirmable, 9 | :authenticatable, 10 | :recoverable, 11 | :lockable, 12 | :trackable, 13 | :unlockable_with_token, 14 | :invitable, 15 | :registerable 16 | ]) 17 | 18 | user = insert_user() 19 | conn = assign(conn, :current_user, user) 20 | {:ok, conn: conn, user: user} 21 | end 22 | 23 | describe "create" do 24 | test "can't invite an existing user", %{conn: conn, user: user} do 25 | params = %{"invitation" => %{"name" => user.name, "email" => user.email}} 26 | conn = post conn, invitation_path(conn, :create), params 27 | assert html_response(conn, 200) 28 | assert conn.private[:phoenix_template] == "new.html" 29 | end 30 | 31 | test "can invite new user", %{conn: conn} do 32 | params = %{"invitation" => %{"name" => "John Doe", "email" => "john@example.com"}} 33 | conn = post conn, invitation_path(conn, :create), params 34 | assert conn.private[:phoenix_flash] == %{"info" => "Invitation sent."} 35 | assert html_response(conn, 302) 36 | end 37 | 38 | test "mass asignment not allowed", %{conn: conn} do 39 | params = %{ 40 | "invitation" => %{ 41 | "name" => "John Doe", 42 | "email" => "john@example.com", 43 | "token" => "hacker token value" 44 | } 45 | } 46 | 47 | post conn, invitation_path(conn, :create), params 48 | 49 | %{:token => token} = 50 | Coherence.Schemas.get_by_invitation(email: params["invitation"]["email"]) 51 | 52 | refute token == params["invitation"]["token"] 53 | end 54 | end 55 | 56 | describe "new" do 57 | test "can visit registration page", %{conn: conn} do 58 | conn = assign(conn, :current_user, nil) 59 | conn = get(conn, invitation_path(conn, :new)) 60 | assert html_response(conn, 200) 61 | end 62 | end 63 | 64 | describe "create_user" do 65 | test "can't create new user when invitation token not exist", %{conn: conn} do 66 | token = random_string(48) 67 | params = %{"user" => %{}, "token" => token} 68 | conn = post conn, invitation_path(conn, :create_user), params 69 | 70 | assert conn.private[:phoenix_flash] == %{ 71 | "error" => "Invalid Invitation. Please contact the site administrator." 72 | } 73 | 74 | assert html_response(conn, 302) 75 | end 76 | 77 | test "can create new user when invitation token exist", %{conn: conn} do 78 | invitation = insert_invitation() 79 | 80 | params = %{ 81 | "user" => %{"name" => invitation.name, "email" => invitation.email, password: "12345678"}, 82 | "token" => invitation.token 83 | } 84 | 85 | conn = post conn, invitation_path(conn, :create_user), params 86 | assert conn.private[:phoenix_flash] == %{"error" => "Mailer configuration required!"} 87 | assert html_response(conn, 302) 88 | end 89 | end 90 | 91 | test "mass asignment not allowed", %{conn: conn} do 92 | invitation = insert_invitation() 93 | 94 | params = %{ 95 | "user" => %{ 96 | "name" => invitation.name, 97 | "email" => invitation.email, 98 | "password" => "12345678", 99 | "current_sign_in_ip" => "mass asignment" 100 | }, 101 | "token" => invitation.token 102 | } 103 | 104 | conn = post conn, invitation_path(conn, :create_user), params 105 | assert conn.private[:phoenix_flash] == %{"error" => "Mailer configuration required!"} 106 | assert html_response(conn, 302) 107 | %{:current_sign_in_ip => current_sign_in_ip} = get_user_by_email(params["user"]["email"]) 108 | refute current_sign_in_ip == params["user"]["current_sign_in_ip"] 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /test/controllers/rememberable_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CoherenceTest.Rememberable do 2 | use TestCoherence.ConnCase 3 | alias Coherence.{SessionController} 4 | alias TestCoherence.Coherence.Rememberable 5 | import TestCoherenceWeb.Router.Helpers 6 | import Ecto.Query 7 | 8 | def with_session(conn) do 9 | session_opts = 10 | Plug.Session.init(store: :cookie, key: "_binaryid_key", signing_salt: "JFbk5iZ6") 11 | 12 | conn 13 | |> Map.put( 14 | :secret_key_base, 15 | "HL0pikQMxNSA58Dv4mf26O/eh1e4vaJDmX0qLgqBcnS94gbKu9Xn3x114D+mHYcX" 16 | ) 17 | |> Plug.Session.call(session_opts) 18 | |> Plug.Conn.fetch_session() 19 | |> Plug.Conn.fetch_query_params() 20 | |> accepts(["html"]) 21 | end 22 | 23 | defp accepts(conn, opts) do 24 | Phoenix.Controller.accepts(conn, opts) 25 | end 26 | 27 | def login_cookie(%{conn: conn}) do 28 | user = insert_user() 29 | {_, series, token} = rememberable = insert_rememberable(user) 30 | 31 | conn = 32 | conn 33 | |> with_session 34 | |> SessionController.save_login_cookie(user.id, series, token) 35 | 36 | {:ok, conn: conn, user: user, rememberable: rememberable} 37 | end 38 | 39 | describe "public" do 40 | test "get public page", %{conn: conn} do 41 | conn = get(conn, dummy_path(conn, :index)) 42 | assert html_response(conn, 200) =~ "Index rendered" 43 | end 44 | 45 | test "private page protected", %{conn: conn} do 46 | conn = get(conn, dummy_path(conn, :new)) 47 | assert html_response(conn, 200) =~ "Login callback rendered" 48 | assert conn.halted 49 | assert conn.private[:plug_session]["user_return_to"] == "/dummies/new" 50 | end 51 | end 52 | 53 | describe "login cookie" do 54 | setup [:login_cookie] 55 | 56 | test "authenticates with correct login cookie", %{conn: conn} = meta do 57 | conn = get(conn, dummy_path(conn, :new)) 58 | assert html_response(conn, 200) =~ "New rendered" 59 | assert conn.assigns[:remembered] 60 | assert conn.assigns[:current_user].id == meta[:user].id 61 | end 62 | 63 | test "expired", %{conn: conn} = meta do 64 | {rememberable, _, _} = meta[:rememberable] 65 | datetime = Timex.shift(rememberable.token_created_at, months: -1) 66 | 67 | Rememberable.changeset(rememberable, %{token_created_at: datetime}) 68 | |> TestCoherence.Repo.update!() 69 | 70 | conn = get(conn, dummy_path(conn, :new)) 71 | 72 | assert Repo.one(from(r in Rememberable, select: count(r.id))) == 0 73 | assert html_response(conn, 200) =~ "Login callback rendered" 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /test/controllers/session_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CoherenceTest.SessionController do 2 | use TestCoherence.ConnCase 3 | import TestCoherenceWeb.Router.Helpers 4 | alias Coherence.Controller 5 | alias TestCoherence.Coherence.Trackable 6 | import Ecto.Query 7 | alias TestCoherence.User 8 | 9 | def setup_trackable_table(%{conn: conn}) do 10 | Application.put_env(:coherence, :opts, [ 11 | :authenticatable, 12 | :recoverable, 13 | :lockable, 14 | :trackable_table, 15 | :unlockable_with_token, 16 | :invitable, 17 | :registerable 18 | ]) 19 | 20 | Application.put_env(:coherence, :max_failed_login_attempts, 2) 21 | user = insert_user() 22 | conn = assign(conn, :current_user, user) 23 | {:ok, conn: conn, user: user} 24 | end 25 | 26 | describe "trackable table" do 27 | setup [:setup_trackable_table] 28 | 29 | test "track login", %{conn: conn, user: user} do 30 | conn = assign(conn, :current_user, nil) 31 | params = %{"session" => %{"email" => user.email, "password" => "supersecret"}} 32 | conn = post conn, session_path(conn, :create), params 33 | assert html_response(conn, 302) 34 | [t1] = Trackable |> order_by(asc: :id) |> Repo.all() 35 | assert t1.action == "login" 36 | end 37 | 38 | test "mass asignment not allowed", %{conn: conn, user: user} do 39 | conn = assign(conn, :current_user, nil) 40 | 41 | params = %{ 42 | "remember" => "on", 43 | "session" => %{ 44 | "email" => user.email, 45 | "password" => "supersecret", 46 | "current_sign_in_ip" => "mass_asignment" 47 | } 48 | } 49 | 50 | conn = post conn, session_path(conn, :create), params 51 | assert html_response(conn, 302) 52 | %{:current_sign_in_ip => current_sign_in_ip} = get_user_by_email(user.email) 53 | refute current_sign_in_ip == params["session"]["current_sign_in_ip"] 54 | end 55 | 56 | test "track logout", %{conn: conn, user: user} do 57 | conn = assign(conn, :current_user, nil) 58 | params = %{"session" => %{"email" => user.email, "password" => "supersecret"}} 59 | conn = post conn, session_path(conn, :create), params 60 | conn = delete(conn, session_path(conn, :delete)) 61 | assert html_response(conn, 302) 62 | [t1, t2] = Trackable |> order_by(asc: :id) |> Repo.all() 63 | assert t1.action == "login" 64 | assert t2.action == "logout" 65 | end 66 | 67 | test "failed login", %{conn: conn, user: user} do 68 | conn = assign(conn, :current_user, nil) 69 | params = %{"session" => %{"email" => user.email, "password" => "wrong"}} 70 | conn = post conn, session_path(conn, :create), params 71 | assert html_response(conn, 401) 72 | [t1] = Trackable |> order_by(asc: :id) |> Repo.all() 73 | assert t1.action == "failed_login" 74 | end 75 | 76 | test "lock", %{conn: conn, user: user} do 77 | conn = assign(conn, :current_user, nil) 78 | params = %{"session" => %{"email" => user.email, "password" => "wrong"}} 79 | conn = post conn, session_path(conn, :create), params 80 | conn = post conn, session_path(conn, :create), params 81 | assert html_response(conn, 401) 82 | trackables = Trackable |> order_by(asc: :id) |> Repo.all() 83 | assert Enum.at(trackables, 0).action == "failed_login" 84 | assert Enum.at(trackables, 1).action == "failed_login" 85 | assert Enum.at(trackables, 2).action == "lock" 86 | end 87 | 88 | test "unlock", %{conn: conn, user: user} do 89 | conn = assign(conn, :current_user, nil) 90 | params = %{"session" => %{"email" => user.email, "password" => "wrong"}} 91 | conn = post conn, session_path(conn, :create), params 92 | conn = post conn, session_path(conn, :create), params 93 | assert html_response(conn, 401) 94 | user = Repo.get(User, user.id) 95 | locked_at = user.locked_at |> Controller.shift(days: -10) 96 | 97 | User.changeset(user, %{locked_at: locked_at}) 98 | |> Repo.update!() 99 | 100 | params = put_in(params, ["session", "password"], "supersecret") 101 | post conn, session_path(conn, :create), params 102 | trackables = Trackable |> order_by(asc: :id) |> Repo.all() 103 | assert Enum.count(trackables) == 5 104 | assert Enum.at(trackables, 2).action == "lock" 105 | assert Enum.at(trackables, 3).action == "unlock" 106 | assert Enum.at(trackables, 4).action == "login" 107 | end 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /test/controllers/unlock_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CoherenceTest.UnlockController do 2 | use TestCoherence.ConnCase 3 | import TestCoherenceWeb.Router.Helpers 4 | alias Coherence.{Controller, LockableService} 5 | alias TestCoherence.Coherence.Trackable 6 | import Ecto.Query 7 | alias TestCoherence.{User} 8 | 9 | def setup_trackable_table(%{conn: conn}) do 10 | Application.put_env(:coherence, :opts, [ 11 | :authenticatable, 12 | :recoverable, 13 | :lockable, 14 | :trackable_table, 15 | :unlockable_with_token, 16 | :invitable, 17 | :registerable 18 | ]) 19 | 20 | Application.put_env(:coherence, :max_failed_login_attempts, 2) 21 | user = insert_user() 22 | {:ok, conn: conn, user: user} 23 | end 24 | 25 | def setup_controller(%{conn: conn}) do 26 | Application.put_env(:coherence, :opts, [:authenticatable, :lockable, :unlockable_with_token]) 27 | user = insert_user() 28 | {:ok, conn: conn, user: user} 29 | end 30 | 31 | describe "unlock controller" do 32 | setup [:setup_controller] 33 | 34 | test "POST create", %{conn: conn, user: user} do 35 | params = %{"unlock" => %{"password" => "supersecret", "email" => user.email}} 36 | conn = post conn, unlock_path(conn, :create), params 37 | assert html_response(conn, 302) 38 | user = Repo.get(User, user.id) 39 | assert user.unlock_token 40 | end 41 | 42 | test "GET edit", %{conn: conn, user: user} do 43 | {:ok, user} = 44 | Controller.lock!(user) 45 | |> elem(1) 46 | |> LockableService.unlock_token() 47 | 48 | conn = get(conn, unlock_path(conn, :edit, user.unlock_token)) 49 | assert html_response(conn, 302) 50 | end 51 | end 52 | 53 | describe "trackable table" do 54 | setup [:setup_trackable_table] 55 | 56 | test "unlock token", %{conn: conn, user: user} do 57 | {:ok, user} = 58 | Controller.lock!(user) 59 | |> elem(1) 60 | |> LockableService.unlock_token() 61 | 62 | get(conn, unlock_path(conn, :edit, user.unlock_token)) 63 | trackables = Trackable |> order_by(asc: :id) |> Repo.all() 64 | assert Enum.count(trackables) == 1 65 | assert Enum.at(trackables, 0).action == "unlock_token" 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /test/mix/tasks/coh.clean_test.exs: -------------------------------------------------------------------------------- 1 | Code.require_file("../../mix_helpers.exs", __DIR__) 2 | 3 | defmodule Mix.Tasks.Coh.CleanTest do 4 | use ExUnit.Case 5 | import MixHelper 6 | 7 | alias Mix.Tasks.Coh.Install 8 | alias Mix.Tasks.Coh.Gen.Controllers 9 | alias Mix.Tasks.Coh.Install 10 | alias Mix.Tasks.Coh.Clean 11 | 12 | @default_args ~w(--repo=TestCoherence.Repo --module=TestCoherence --log-only) 13 | 14 | describe "coh" do 15 | test "cleans all" do 16 | in_tmp("coh_cleans_all", fn -> 17 | mk_web_path() 18 | mk_config_exs() 19 | 20 | Install.run(install_args(~w(--full))) 21 | Clean.run(~w(--no-confirm --all)) 22 | 23 | Enum.each(coherence_web_items(), fn path -> 24 | refute File.exists?(path) 25 | end) 26 | 27 | assert_coherence_config() 28 | end) 29 | end 30 | 31 | test "cleans all with controllers" do 32 | in_tmp("coh_cleans_all_with_controllers", fn -> 33 | mk_web_path() 34 | mk_config_exs() 35 | 36 | Install.run(install_args(~w(--full))) 37 | Controllers.run(~w(--no-confirm)) 38 | assert_file(web_path("controllers/coherence/session_controller.ex")) 39 | 40 | Clean.run(~w(--no-confirm --all)) 41 | 42 | Enum.each(coherence_web_items(), fn path -> 43 | refute File.exists?(path) 44 | end) 45 | end) 46 | end 47 | end 48 | 49 | defp assert_coherence_config do 50 | assert_file("config/config.exs", fn file -> 51 | refute file =~ "%% Coherence Configuration %%" 52 | refute file =~ "config :coherence," 53 | refute file =~ "%% End Coherence Configuration %%" 54 | end) 55 | end 56 | 57 | defp coherence_web_folders, 58 | do: 59 | ~w(controllers emails templates views) 60 | |> Enum.map(&web_path([&1, "coherence"])) 61 | 62 | defp coherence_web_files, 63 | do: 64 | ~w(coherence_messages.ex coherence_web.ex) 65 | |> Enum.map(&web_path(&1)) 66 | 67 | defp coherence_web_items, 68 | do: coherence_web_files() ++ coherence_web_folders() 69 | 70 | @web_path "lib/coherence_web" 71 | 72 | defp web_path(paths) when is_list(paths), do: Path.join([@web_path | paths]) 73 | defp web_path(path), do: Path.join(@web_path, path) 74 | 75 | defp mk_web_path, do: mk_web_path(@web_path) 76 | defp mk_web_path(path), do: File.mkdir_p!(path) 77 | 78 | defp mk_config_exs do 79 | File.mkdir!("config") 80 | File.touch!("config/config.exs") 81 | end 82 | 83 | defp install_args(args) do 84 | args ++ @default_args 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /test/mix/tasks/coh.gen.controllers_test.exs: -------------------------------------------------------------------------------- 1 | Code.require_file("../../mix_helpers.exs", __DIR__) 2 | 3 | defmodule Mix.Tasks.Coh.Gen.ControllersTest do 4 | use ExUnit.Case 5 | import MixHelper 6 | 7 | @lib_path Path.join("lib", "coherence") 8 | @web_path Path.join("lib", "coherence_web") 9 | 10 | setup do 11 | Application.put_env(:coherence, :opts, [ 12 | :confirmable, 13 | :authenticatable, 14 | :recoverable, 15 | :lockable, 16 | :trackable, 17 | :unlockable_with_token, 18 | :invitable, 19 | :registerable, 20 | :rememberable 21 | ]) 22 | 23 | :ok 24 | end 25 | 26 | # opts: [:invitable, :authenticatable, :recoverable, :lockable, :trackable, :unlockable_with_token, :registerable] 27 | 28 | # @all_controllers ~w(session invitation password registration 29 | # unlock) |> Enum.map(& Kernel.<>(&1, "_controller.ex")) 30 | 31 | def mk_web_path(path \\ @web_path) do 32 | File.mkdir_p!(path) 33 | end 34 | 35 | test "coh geneates controllers" do 36 | in_tmp("coh_geneates_controllers", fn -> 37 | mk_web_path() 38 | 39 | ~w() 40 | |> Mix.Tasks.Coh.Gen.Controllers.run() 41 | 42 | assert_file("controllers/coherence/session_controller.ex" |> web_path, fn file -> 43 | assert file =~ "defmodule CoherenceWeb.Coherence.SessionController do" 44 | end) 45 | 46 | assert_file("controllers/coherence/password_controller.ex" |> web_path, fn file -> 47 | assert file =~ "defmodule CoherenceWeb.Coherence.PasswordController do" 48 | end) 49 | 50 | assert_file("controllers/coherence/invitation_controller.ex" |> web_path, fn file -> 51 | assert file =~ "defmodule CoherenceWeb.Coherence.InvitationController do" 52 | end) 53 | 54 | assert_file("controllers/coherence/registration_controller.ex" |> web_path, fn file -> 55 | assert file =~ "defmodule CoherenceWeb.Coherence.RegistrationController do" 56 | end) 57 | 58 | assert_file("controllers/coherence/unlock_controller.ex" |> web_path, fn file -> 59 | assert file =~ "defmodule CoherenceWeb.Coherence.UnlockController do" 60 | end) 61 | end) 62 | end 63 | 64 | def assert_dirs(dirs, full_dirs, path) do 65 | Enum.each(dirs, fn dir -> 66 | assert File.dir?(Path.join(path, dir)) 67 | end) 68 | 69 | Enum.each(full_dirs -- dirs, fn dir -> 70 | refute File.dir?(Path.join(path, dir)) 71 | end) 72 | end 73 | 74 | def assert_file_list(files, full_files, path) do 75 | Enum.each(files, fn file -> 76 | assert_file(Path.join(path, file)) 77 | end) 78 | 79 | Enum.each(full_files -- files, fn file -> 80 | refute_file(Path.join(path, file)) 81 | end) 82 | end 83 | 84 | def web_path(path \\ "") do 85 | Path.join(@web_path, path) 86 | end 87 | 88 | def lib_path(path \\ "") do 89 | Path.join(@lib_path, path) 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /test/mix_helpers.exs: -------------------------------------------------------------------------------- 1 | # Get Mix output sent to the current 2 | # process to avoid polluting tests. 3 | Mix.shell(Mix.Shell.Process) 4 | 5 | defmodule MixHelper do 6 | import ExUnit.Assertions 7 | 8 | def tmp_path do 9 | Path.expand("../../tmp", __DIR__) 10 | end 11 | 12 | def in_tmp(which, function) do 13 | path = Path.join(tmp_path(), which) 14 | File.rm_rf!(path) 15 | File.mkdir_p!(path) 16 | File.cd!(path, function) 17 | end 18 | 19 | def assert_file(file) do 20 | assert File.regular?(file), "Expected #{file} to exist, but does not" 21 | end 22 | 23 | def refute_file(file) do 24 | refute File.regular?(file), "Expected #{file} to not exist, but it does" 25 | end 26 | 27 | def assert_file(file, match) do 28 | cond do 29 | is_list(match) -> 30 | assert_file(file, &Enum.each(match, fn m -> assert &1 =~ m end)) 31 | 32 | is_binary(match) or Regex.regex?(match) -> 33 | assert_file(file, &assert(&1 =~ match)) 34 | 35 | is_function(match, 1) -> 36 | assert_file(file) 37 | match.(File.read!(file)) 38 | end 39 | end 40 | 41 | def with_generator_env(new_env, fun) do 42 | old = Application.get_env(:phoenix, :generators) 43 | Application.put_env(:phoenix, :generators, new_env) 44 | 45 | try do 46 | fun.() 47 | after 48 | Application.put_env(:phoenix, :generators, old) 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /test/models/rememberable_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Coherence.RememberableTest do 2 | use TestCoherence.ModelCase 3 | use Timex 4 | 5 | alias Coherence.Config 6 | alias TestCoherence.Coherence.Rememberable 7 | 8 | setup do 9 | user = %TestCoherence.User{id: 1} 10 | user_schema = Config.user_schema() 11 | Application.put_env(:coherence, :user_schema, TestCoherence.User) 12 | 13 | on_exit(fn -> 14 | Application.put_env(:coherence, :user_schema, user_schema) 15 | end) 16 | 17 | {:ok, user: user} 18 | end 19 | 20 | @test_date Timex.parse!("2010-04-17 14:00:00", "%Y-%m-%d %H:%M:%S", :strftime) 21 | |> Timex.to_datetime() 22 | @valid_attrs %{ 23 | user_id: 1, 24 | series_hash: "1234", 25 | token_hash: "abcd", 26 | token_created_at: @test_date 27 | } 28 | @invalid_attrs %{} 29 | 30 | test "changeset with valid attributes" do 31 | changeset = Rememberable.changeset(%Rememberable{}, @valid_attrs) 32 | assert changeset.valid? 33 | end 34 | 35 | test "changeset with invalid attributes" do 36 | changeset = Rememberable.changeset(%Rememberable{}, @invalid_attrs) 37 | refute changeset.valid? 38 | end 39 | 40 | test "create_login", %{user: user} do 41 | {changeset, series, token} = Rememberable.create_login(user) 42 | assert changeset.valid? 43 | assert series 44 | assert token 45 | assert {:user_id, user.id} in changeset.changes 46 | assert changeset.changes[:token_created_at] 47 | refute series == changeset.changes[:series_hash] 48 | refute token == changeset.changes[:token_hash] 49 | end 50 | 51 | test "update_login", %{user: user} do 52 | {%{changes: changes}, _series, _token} = Rememberable.create_login(user) 53 | 54 | {%{changes: new_changes}, _new_token} = 55 | build_rememberable(changes) 56 | |> Rememberable.update_login() 57 | 58 | assert new_changes[:token_hash] 59 | refute new_changes[:series_hash] 60 | refute new_changes[:token_hash] == changes[:token_hash] 61 | end 62 | 63 | def now, do: Timex.now() 64 | 65 | def rememberables, 66 | do: [ 67 | %Rememberable{user_id: 10, series_hash: "123", token_hash: "abc", token_created_at: now()}, 68 | %Rememberable{user_id: 1, series_hash: "123", token_hash: "abc", token_created_at: now()} 69 | ] 70 | 71 | def build_rememberable(changes) do 72 | struct(%Rememberable{}, changes) 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /test/plugs/authentication/basic_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Coherence.Authentication.Basic.Test do 2 | use ExUnit.Case, async: true 3 | use Plug.Test 4 | 5 | defmodule TestPlug do 6 | use Plug.Builder 7 | import Plug.Conn 8 | 9 | plug Coherence.Authentication.Basic, realm: "Secret" 10 | plug :index 11 | 12 | defp index(conn, _opts), do: send_resp(conn, 200, "Authorized") 13 | end 14 | 15 | defp call(plug, headers) do 16 | conn(:get, "/", []) 17 | |> put_req_header("authorization", headers) 18 | |> plug.call([]) 19 | end 20 | 21 | defp assert_unauthorized(conn, realm) do 22 | assert conn.status == 401 23 | assert get_resp_header(conn, "www-authenticate") == [~s{Basic realm="#{realm}"}] 24 | refute conn.assigns[:current_user] 25 | end 26 | 27 | defp assert_authorized(conn, content) do 28 | assert conn.status == 200 29 | assert conn.resp_body == content 30 | assert conn.assigns[:current_user] == %{id: 1, role: :admin} 31 | end 32 | 33 | defp auth_header(creds) do 34 | "Basic #{Base.encode64(creds)}" 35 | end 36 | 37 | setup do 38 | "Admin" 39 | |> Coherence.Authentication.Basic.encode_credentials("SecretPass") 40 | |> Coherence.CredentialStore.Server.put_credentials(%{id: 1, role: :admin}) 41 | 42 | :ok 43 | end 44 | 45 | test "request without credentials" do 46 | connection = conn(:get, "/", []) |> TestPlug.call([]) 47 | assert_unauthorized(connection, "Secret") 48 | end 49 | 50 | test "request with invalid user" do 51 | conn = call(TestPlug, auth_header("Hacker:SecretPass")) 52 | assert_unauthorized(conn, "Secret") 53 | end 54 | 55 | test "request with invalid password" do 56 | conn = call(TestPlug, auth_header("Admin:ASecretPass")) 57 | assert_unauthorized(conn, "Secret") 58 | end 59 | 60 | test "request with valid credentials" do 61 | conn = call(TestPlug, auth_header("Admin:SecretPass")) 62 | assert_authorized(conn, "Authorized") 63 | end 64 | 65 | test "request with malformed credentials" do 66 | conn = call(TestPlug, "Basic Zm9)") 67 | assert_unauthorized(conn, "Secret") 68 | end 69 | 70 | test "request with wrong scheme" do 71 | conn = call(TestPlug, "Bearer #{Base.encode64("Admin:SecretPass")}") 72 | assert_unauthorized(conn, "Secret") 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /test/plugs/authentication/credential_store/server_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Coherence.CredentialStore.Server.Test do 2 | use ExUnit.Case, async: true 3 | 4 | alias Coherence.CredentialStore.Server 5 | 6 | setup do 7 | {:ok, state: initial_state(), user_data: %{id: 1, name: "A"}} 8 | end 9 | 10 | test "put and get", %{state: state, user_data: user_data} do 11 | creds = uuid() 12 | state = put_credentials(state, creds, user_data) 13 | {_, user_data1} = get_user_data(state, creds) 14 | assert user_data == user_data1 15 | end 16 | 17 | test "delete", %{state: state, user_data: user_data} do 18 | creds = uuid() 19 | state = put_credentials(state, creds, user_data) 20 | state = delete_credentials(state, creds) 21 | {_, ud} = get_user_data(state, creds) 22 | refute ud 23 | end 24 | 25 | test "put 2 and get2", %{state: state, user_data: user_data1} do 26 | user_data2 = %{id: 2, name: "B"} 27 | creds1 = uuid() 28 | creds2 = uuid() 29 | 30 | state = put_credentials(state, creds1, user_data1) 31 | state = put_credentials(state, creds2, user_data2) 32 | {state, ud1} = get_user_data(state, creds1) 33 | assert ud1 == user_data1 34 | {_, ud2} = get_user_data(state, creds2) 35 | assert ud2 == user_data2 36 | end 37 | 38 | test "put 2 and get2 the same", %{state: state, user_data: user_data1} do 39 | creds1 = uuid() 40 | creds2 = uuid() 41 | 42 | state = put_credentials(state, creds1, user_data1) 43 | state = put_credentials(state, creds2, user_data1) 44 | {state, ud1} = get_user_data(state, creds1) 45 | assert ud1 == user_data1 46 | {_, ud2} = get_user_data(state, creds2) 47 | assert ud2 == user_data1 48 | end 49 | 50 | test "put 2 the same delete", %{state: state, user_data: user_data1} do 51 | creds1 = uuid() 52 | creds2 = uuid() 53 | 54 | state = put_credentials(state, creds1, user_data1) 55 | state = put_credentials(state, creds2, user_data1) 56 | state = delete_credentials(state, creds1) 57 | {state, ud1} = get_user_data(state, creds1) 58 | refute ud1 59 | {state, ud1} = get_user_data(state, creds2) 60 | assert ud1 == user_data1 61 | state = delete_credentials(state, creds2) 62 | {_, ud} = get_user_data(state, creds2) 63 | refute ud 64 | end 65 | 66 | test "update_user_logins", %{state: state, user_data: user_data1} do 67 | user_data2 = %{id: 2, name: "B"} 68 | user_data11 = Map.put(user_data1, :name, "AA") 69 | creds1 = uuid() 70 | creds2 = uuid() 71 | creds3 = uuid() 72 | 73 | state = put_credentials(state, creds1, user_data1) 74 | state = put_credentials(state, creds2, user_data1) 75 | state = put_credentials(state, creds3, user_data2) 76 | state = update_user_logins(state, user_data11) 77 | 78 | {state, ud1} = get_user_data(state, creds1) 79 | assert ud1 == user_data11 80 | {_, ud2} = get_user_data(state, creds2) 81 | assert ud2 == user_data11 82 | {_, ud3} = get_user_data(state, creds3) 83 | assert ud3 == user_data2 84 | end 85 | 86 | test "delete_user_logins", %{state: state, user_data: user_data1} do 87 | user_data2 = %{id: 2, name: "B"} 88 | creds1 = uuid() 89 | creds2 = uuid() 90 | creds3 = uuid() 91 | 92 | state = put_credentials(state, creds1, user_data1) 93 | state = put_credentials(state, creds2, user_data2) 94 | state = put_credentials(state, creds3, user_data1) 95 | 96 | state = delete_user_logins(state, user_data1) 97 | 98 | {_, ud1} = get_user_data(state, creds1) 99 | refute ud1 100 | {_, ud2} = get_user_data(state, creds2) 101 | assert ud2 == user_data2 102 | {_, ud3} = get_user_data(state, creds3) 103 | refute ud3 104 | end 105 | 106 | ############### 107 | # Helpers 108 | 109 | defp uuid, do: UUID.uuid1() 110 | 111 | defp put_credentials(state, credentials, user_data) do 112 | {:noreply, state1} = Server.handle_cast({:put_credentials, credentials, user_data}, state) 113 | state1 114 | end 115 | 116 | defp delete_credentials(state, credentials) do 117 | {:noreply, state1} = Server.handle_cast({:delete_credentials, credentials}, state) 118 | state1 119 | end 120 | 121 | defp get_user_data(state, credentials) do 122 | {:reply, data, state1} = Server.handle_call({:get_user_data, credentials}, nil, state) 123 | {state1, data} 124 | end 125 | 126 | defp update_user_logins(state, user_data) do 127 | {:noreply, state1} = Server.handle_cast({:update_user_logins, user_data}, state) 128 | state1 129 | end 130 | 131 | defp delete_user_logins(state, user_data) do 132 | {:noreply, state1} = Server.handle_cast({:delete_user_logins, user_data}, state) 133 | state1 134 | end 135 | 136 | defp initial_state do 137 | {:ok, state} = Server.init(nil) 138 | state 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /test/plugs/authentication/ip_address_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CoherenceTest.Authentication.IpAddress do 2 | use ExUnit.Case, async: true 3 | use Plug.Test 4 | alias Coherence.Authentication.IpAddress 5 | alias Plug.Adapters.CoherenceTest.Conn, as: TestConn 6 | 7 | @error_msg ~s'{"error":"authentication required"}' 8 | 9 | defmodule IpPlug do 10 | use Plug.Builder 11 | import Plug.Conn 12 | 13 | plug Coherence.Authentication.IpAddress, 14 | allow: ~w(192.168.1.200 10.10.10.10 47.21.0.0/16), 15 | deny: ~w(10.10.15.10 10.10.15.11 48.24.254.0/255.255.254.0), 16 | error: ~s'{"error":"authentication required"}' 17 | 18 | plug :index 19 | 20 | defp index(conn, _opts), do: send_resp(conn, 200, "Authorized") 21 | end 22 | 23 | defmodule IpAllowAllPlug do 24 | use Plug.Builder 25 | import Plug.Conn 26 | 27 | plug Coherence.Authentication.IpAddress, 28 | allow: ~w(0.0.0.0/0), 29 | deny: ~w(48.24.254.0/255.255.254.0), 30 | error: ~s'{"error":"authentication required"}' 31 | 32 | plug :index 33 | 34 | defp index(conn, _opts), do: send_resp(conn, 200, "Authorized") 35 | end 36 | 37 | defp call(plug, params) do 38 | :get 39 | |> conn("/", params) 40 | |> plug.call([]) 41 | end 42 | 43 | defp call(plug, params, ip_address) do 44 | :get 45 | |> conn("/", params) 46 | |> TestConn.set_peer(ip_address, 4000) 47 | |> plug.call([]) 48 | end 49 | 50 | defp assert_unauthorized(conn, content) do 51 | assert conn.status == 401 52 | assert conn.resp_body == content 53 | refute conn.assigns[:current_user] 54 | end 55 | 56 | defp assert_authorized(conn, content) do 57 | assert conn.status == 200 58 | assert conn.resp_body == content 59 | end 60 | 61 | defp assert_user_data(conn, user_data) do 62 | assert conn.assigns[:current_user] == user_data 63 | end 64 | 65 | setup do 66 | # Coherence.CredentialStore.Server.put_credentials("secret_token", %{id: 1, role: :admin}) 67 | :ok 68 | end 69 | 70 | test "request without credentials" do 71 | conn = call(IpPlug, []) 72 | assert_unauthorized(conn, @error_msg) 73 | end 74 | 75 | test "request with invalid IP" do 76 | conn = call(IpPlug, [], {192, 168, 1, 199}) 77 | assert_unauthorized(conn, @error_msg) 78 | end 79 | 80 | test "request with valid IP" do 81 | conn = call(IpPlug, [], {192, 168, 1, 200}) 82 | assert_authorized(conn, "Authorized") 83 | end 84 | 85 | test "request with IP in deny" do 86 | conn = call(IpPlug, [], {10, 10, 15, 11}) 87 | assert_unauthorized(conn, @error_msg) 88 | end 89 | 90 | test "request not in allow subnet" do 91 | conn = call(IpPlug, [], {47, 22, 15, 11}) 92 | assert_unauthorized(conn, @error_msg) 93 | end 94 | 95 | test "request in allow subnet" do 96 | user = %{id: 1, role: :admin} 97 | IpAddress.add_credentials({47, 21, 15, 11}, user) 98 | conn = call(IpPlug, [], {47, 21, 15, 11}) 99 | assert_authorized(conn, "Authorized") 100 | assert_user_data(conn, user) 101 | end 102 | 103 | test "request in deny subnet" do 104 | conn = call(IpAllowAllPlug, [], {48, 24, 254, 11}) 105 | assert_unauthorized(conn, @error_msg) 106 | end 107 | 108 | test "request not in deny subnet" do 109 | conn = call(IpAllowAllPlug, [], {48, 24, 253, 11}) 110 | assert_authorized(conn, "Authorized") 111 | conn = call(IpAllowAllPlug, [], {10, 24, 255, 11}) 112 | assert_authorized(conn, "Authorized") 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /test/plugs/authentication/token_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CoherenceTest.Authentication.Token do 2 | use ExUnit.Case, async: true 3 | use Plug.Test 4 | 5 | @error_msg ~s'{"error":"authentication required"}' 6 | 7 | defmodule ParamPlug do 8 | use Plug.Builder 9 | import Plug.Conn 10 | 11 | plug Coherence.Authentication.Token, 12 | source: :params, 13 | param: "auth_token", 14 | error: ~s'{"error":"authentication required"}' 15 | 16 | plug :index 17 | 18 | defp index(conn, _opts), do: send_resp(conn, 200, "Authorized") 19 | end 20 | 21 | defmodule HeaderPlug do 22 | use Plug.Builder 23 | import Plug.Conn 24 | 25 | plug Coherence.Authentication.Token, 26 | source: :header, 27 | param: "x-auth-token", 28 | error: ~s'{"error":"authentication required"}' 29 | 30 | plug :index 31 | 32 | defp index(conn, _opts), do: send_resp(conn, 200, "Authorized") 33 | end 34 | 35 | defmodule ParamErrorHandlerPlug do 36 | use Plug.Builder 37 | import Plug.Conn 38 | 39 | plug Coherence.Authentication.Token, 40 | source: :params, 41 | param: "auth_token", 42 | error: &TestCoherence.TestHelpers.handler/1 43 | end 44 | 45 | defmodule HeaderErrorHandlerPlug do 46 | use Plug.Builder 47 | import Plug.Conn 48 | 49 | plug Coherence.Authentication.Token, 50 | source: :header, 51 | param: "x-auth-token", 52 | error: &TestCoherence.TestHelpers.handler/1 53 | end 54 | 55 | defp call(plug, params) do 56 | conn(:get, "/", params) 57 | |> plug.call([]) 58 | end 59 | 60 | defp call(plug, params, token) do 61 | conn(:get, "/", params) 62 | |> put_req_header("x-auth-token", token) 63 | |> plug.call([]) 64 | end 65 | 66 | defp assert_unauthorized(conn, content) do 67 | assert conn.status == 401 68 | assert conn.resp_body == content 69 | refute conn.assigns[:current_user] 70 | end 71 | 72 | defp assert_authorized(conn, content) do 73 | assert conn.status == 200 74 | assert conn.resp_body == content 75 | assert conn.assigns[:current_user] == %{id: 1, role: :admin} 76 | end 77 | 78 | defp assert_error_handler_called(conn) do 79 | assert conn.status == 418 80 | assert conn.resp_body == "I'm a teapot" 81 | assert conn.assigns[:error_handler_called] 82 | end 83 | 84 | defp auth_param(creds), do: {"auth_token", creds} 85 | 86 | setup do 87 | Coherence.CredentialStore.Server.put_credentials("secret_token", %{id: 1, role: :admin}) 88 | :ok 89 | end 90 | 91 | test "request without credentials using header-based auth" do 92 | conn = call(HeaderPlug, []) 93 | assert_unauthorized(conn, @error_msg) 94 | end 95 | 96 | test "request with invalid credentials using header-based auth" do 97 | conn = call(HeaderPlug, [], "invalid_token") 98 | assert_unauthorized(conn, @error_msg) 99 | end 100 | 101 | test "request with valid credentials using header-based auth" do 102 | conn = call(HeaderPlug, [], "secret_token") 103 | assert_authorized(conn, "Authorized") 104 | end 105 | 106 | test "request without credentials using params-based auth" do 107 | conn = call(ParamPlug, []) 108 | assert_unauthorized(conn, @error_msg) 109 | end 110 | 111 | test "request with invalid credentials using params-based auth" do 112 | conn = call(ParamPlug, [auth_param("invalid_token")]) 113 | assert_unauthorized(conn, @error_msg) 114 | end 115 | 116 | test "request with valid credentials using params-based auth" do 117 | conn = call(ParamPlug, [auth_param("secret_token")]) 118 | assert_authorized(conn, "Authorized") 119 | end 120 | 121 | test "request without credentials using header-based auth and error handler" do 122 | call(HeaderErrorHandlerPlug, []) 123 | |> assert_error_handler_called 124 | end 125 | 126 | test "request with invalid credentials using header-based auth and error handler" do 127 | call(HeaderErrorHandlerPlug, [], "invalid_token") 128 | |> assert_error_handler_called 129 | end 130 | 131 | test "request without credentials using params-based auth and error handler" do 132 | call(ParamErrorHandlerPlug, []) 133 | |> assert_error_handler_called 134 | end 135 | 136 | test "request with invalid credentials using params-based auth and error handler" do 137 | call(ParamErrorHandlerPlug, [auth_param("invalid_token")]) 138 | |> assert_error_handler_called 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /test/schema_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CoherenceTest.Schema do 2 | use TestCoherence.ModelCase 3 | alias TestCoherence.User 4 | use Timex 5 | 6 | setup do 7 | :ok 8 | end 9 | 10 | @email "schema@test.com" 11 | @valid_params %{name: "test", email: @email, password: "12345", password_confirmation: "12345"} 12 | 13 | test "invalid email" do 14 | cs1 = 15 | User.changeset(%User{}, %{ 16 | name: "test", 17 | email: "john-example.com", 18 | password: "12345", 19 | password_confirmation: "12345" 20 | }) 21 | 22 | cs2 = 23 | User.changeset(%User{}, %{ 24 | name: "test", 25 | email: "john.doe-example.com", 26 | password: "12345", 27 | password_confirmation: "12345" 28 | }) 29 | 30 | refute cs1.valid? 31 | refute cs2.valid? 32 | end 33 | 34 | test "valid email" do 35 | cs1 = 36 | User.changeset(%User{}, %{ 37 | name: "test", 38 | email: "john@example.com", 39 | password: "12345", 40 | password_confirmation: "12345" 41 | }) 42 | 43 | cs2 = 44 | User.changeset(%User{}, %{ 45 | name: "test", 46 | email: "john.doe@example.com", 47 | password: "12345", 48 | password_confirmation: "12345" 49 | }) 50 | 51 | assert cs1.valid? 52 | assert cs2.valid? 53 | end 54 | 55 | test "validates correct password" do 56 | cs = 57 | User.changeset(%User{}, %{ 58 | name: "test", 59 | email: @email, 60 | password: "12345", 61 | password_confirmation: "12345" 62 | }) 63 | 64 | assert cs.valid? 65 | end 66 | 67 | test "invalidates incorrect password" do 68 | cs = 69 | User.changeset(%User{}, %{ 70 | name: "test", 71 | email: @email, 72 | password: "12345", 73 | password_confirmation: "" 74 | }) 75 | 76 | refute cs.valid? 77 | 78 | cs = 79 | User.changeset(%User{}, %{ 80 | name: "test", 81 | email: @email, 82 | password: "12345", 83 | password_confirmation: "99" 84 | }) 85 | 86 | refute cs.valid? 87 | end 88 | 89 | test "invalidates incorrect password length" do 90 | cs = 91 | User.changeset(%User{}, %{ 92 | name: "test", 93 | email: @email, 94 | password: "123", 95 | password_confirmation: "123" 96 | }) 97 | 98 | refute cs.valid? 99 | 100 | assert cs.errors == [ 101 | password: 102 | {"should be at least %{count} character(s)", 103 | [{:count, 4}, {:validation, :length}, {:kind, :min}, {:type, :string}]} 104 | ] 105 | end 106 | 107 | test "checkpw" do 108 | params = %{name: "test", email: @email, password: "test", password_confirmation: "test"} 109 | user = Repo.insert!(User.changeset(%User{}, params)) 110 | assert User.checkpw("test", user.password_hash) 111 | refute User.checkpw("t", user.password_hash) 112 | end 113 | 114 | test "checkpw invalid passwords" do 115 | refute User.checkpw("", "") 116 | refute User.checkpw(nil, nil) 117 | end 118 | 119 | test "enforces password" do 120 | cs = User.changeset(%User{}, %{name: "test", email: @email}) 121 | refute cs.valid? 122 | end 123 | 124 | test "does not require password on update" do 125 | user = struct(%User{}, Map.put(@valid_params, :password_hash, "123")) 126 | cs = User.changeset(user, %{name: "test123", email: @email}) 127 | assert cs.valid? 128 | end 129 | 130 | test "confirmed?" do 131 | refute User.confirmed?(%User{}) 132 | assert User.confirmed?(%User{confirmed_at: NaiveDateTime.utc_now()}) 133 | end 134 | 135 | test "confirm" do 136 | changeset = User.confirm(%User{confirmation_token: "1234"}) 137 | assert changeset.changes[:confirmed_at] 138 | refute changeset.changes[:confimrmation_token] 139 | end 140 | 141 | test "locked?" do 142 | refute User.locked?(%User{}) 143 | assert User.locked?(%User{locked_at: NaiveDateTime.utc_now()}) 144 | end 145 | end 146 | -------------------------------------------------------------------------------- /test/services/lockable_service_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CoherenceTest.LockableService do 2 | use TestCoherence.ConnCase 3 | alias Coherence.LockableService, as: Service 4 | 5 | setup %{conn: conn} do 6 | user = insert_user() 7 | {:ok, %{conn: conn, user: user}} 8 | end 9 | 10 | test "create token", %{user: user} do 11 | {:ok, user} = Service.unlock_token(user) 12 | assert user.unlock_token 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/services/password_service_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CoherenceTest.PasswordService do 2 | use TestCoherence.ConnCase 3 | alias Coherence.PasswordService, as: Service 4 | 5 | setup %{conn: conn} do 6 | user = insert_user() 7 | {:ok, %{conn: conn, user: user}} 8 | end 9 | 10 | test "create token", %{user: user} do 11 | {:ok, user} = Service.reset_password_token(user) 12 | assert user.reset_password_token 13 | assert user.reset_password_sent_at 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule TestCoherence.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | imports other functionality to make it easier 8 | to build and query models. 9 | 10 | Finally, if the test case interacts with the database, 11 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | 18 | using do 19 | quote do 20 | # Import conveniences for testing with connections 21 | import Plug.Conn 22 | import Phoenix.ConnTest 23 | import TestCoherence.ConnCase 24 | 25 | alias TestCoherence.Repo 26 | import Ecto 27 | import Ecto.Changeset 28 | import Ecto.Query, only: [from: 1, from: 2] 29 | 30 | import TestCoherenceWeb.Router.Helpers 31 | 32 | import TestCoherence.TestHelpers 33 | alias Coherence.Config 34 | 35 | # The default endpoint for testing 36 | @endpoint TestCoherenceWeb.Endpoint 37 | end 38 | end 39 | 40 | setup tags do 41 | unless tags[:async] do 42 | Ecto.Adapters.SQL.Sandbox.checkout(TestCoherence.Repo) 43 | end 44 | 45 | {:ok, conn: Phoenix.ConnTest.build_conn()} 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/support/dummy_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule TestCoherenceWeb.DummyController do 2 | use Phoenix.Controller 3 | 4 | def index(conn, _) do 5 | html(conn, "Index rendered") 6 | end 7 | 8 | def new(conn, _) do 9 | html(conn, "New rendered") 10 | end 11 | 12 | def edit(conn, _) do 13 | html(conn, "Edit rendered") 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/support/email.ex: -------------------------------------------------------------------------------- 1 | defmodule TestCoherenceWeb.Coherence.Email do 2 | defstruct [:from, :to, :subject, :reply_to, :template, :params] 3 | end 4 | 5 | defmodule TestCoherenceWeb.Coherence.Mailer do 6 | def deliver(email), do: email 7 | end 8 | 9 | defmodule TestCoherenceWeb.Coherence.UserEmail do 10 | defp site_name, do: Coherence.Config.site_name(inspect(Coherence.Config.module())) 11 | alias TestCoherenceWeb.Coherence.Email 12 | require Logger 13 | 14 | def password(user, url) do 15 | %Email{} 16 | |> from(from_email()) 17 | |> to(user_email(user)) 18 | |> add_reply_to 19 | |> subject("#{site_name()} - Reset password instructions") 20 | |> render_body("password.html", %{url: url, name: first_name(user.name)}) 21 | end 22 | 23 | def confirmation(user, url) do 24 | email = 25 | if Coherence.Config.get(:confirm_email_updates) && user.unconfirmed_email do 26 | unconfirmed_email(user) 27 | else 28 | user_email(user) 29 | end 30 | 31 | %Email{} 32 | |> from(from_email()) 33 | |> to(email) 34 | |> add_reply_to 35 | |> subject("#{site_name()} - Confirm your new account") 36 | |> render_body("confirmation.html", %{url: url, name: first_name(user.name)}) 37 | end 38 | 39 | def invitation(invitation, url) do 40 | %Email{} 41 | |> from(from_email()) 42 | |> to(user_email(invitation)) 43 | |> add_reply_to 44 | |> subject("#{site_name()} - Invitation to create a new account") 45 | |> render_body("invitation.html", %{url: url, name: first_name(invitation.name)}) 46 | end 47 | 48 | def unlock(user, url) do 49 | %Email{} 50 | |> from(from_email()) 51 | |> to(user_email(user)) 52 | |> add_reply_to 53 | |> subject("#{site_name()} - Unlock Instructions") 54 | |> render_body("unlock.html", %{url: url, name: first_name(user.name)}) 55 | end 56 | 57 | defp from(email, from), do: Map.put(email, :from, from) 58 | defp to(email, to), do: Map.put(email, :to, to) 59 | defp reply_to(email, address), do: Map.put(email, :reply_to, address) 60 | defp subject(email, subject), do: Map.put(email, :subject, subject) 61 | defp render_body(email, template, params), do: struct(email, template: template, params: params) 62 | 63 | defp add_reply_to(mail) do 64 | case Coherence.Config.email_reply_to() do 65 | nil -> mail 66 | true -> reply_to(mail, from_email()) 67 | address -> reply_to(mail, address) 68 | end 69 | end 70 | 71 | defp first_name(name) do 72 | case String.split(name, " ") do 73 | [first_name | _] -> first_name 74 | _ -> name 75 | end 76 | end 77 | 78 | defp user_email(user) do 79 | {user.name, user.email} 80 | end 81 | 82 | def unconfirmed_email(user) do 83 | {user.name, user.unconfirmed_email} 84 | end 85 | 86 | defp from_email do 87 | case Coherence.Config.email_from() do 88 | nil -> 89 | Logger.error( 90 | ~s|Need to configure :coherence, :email_from_name, "Name", and :email_from_email, "me@example.com"| 91 | ) 92 | 93 | nil 94 | 95 | {name, email} = email_tuple -> 96 | if is_nil(name) or is_nil(email) do 97 | Logger.error( 98 | ~s|Need to configure :coherence, :email_from_name, "Name", and :email_from_email, "me@example.com"| 99 | ) 100 | 101 | nil 102 | else 103 | email_tuple 104 | end 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /test/support/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule TestCoherenceWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :coherence 3 | 4 | # def config(one, two) do 5 | # IO.puts "endpoint config one: #{inspect one}, two: #{inspect two}" 6 | # String.duplicate("abcdefgh", 8) 7 | # end 8 | # Serve at "/" the static files from "priv/static" directory. 9 | # 10 | # You should set gzip to true if you are running phoenix.digest 11 | # when deploying your static files in production. 12 | plug Plug.Static, 13 | at: "/", 14 | from: :coherence, 15 | gzip: false, 16 | only: ~w(css fonts images js favicon.ico robots.txt) 17 | 18 | # Code reloading can be explicitly enabled under the 19 | # :code_reloader configuration of your endpoint. 20 | 21 | plug Plug.RequestId 22 | plug Plug.Logger 23 | 24 | plug Plug.Parsers, 25 | parsers: [:urlencoded, :multipart, :json], 26 | pass: ["*/*"], 27 | json_decoder: Jason 28 | 29 | plug Plug.MethodOverride 30 | plug Plug.Head 31 | 32 | plug Plug.Session, 33 | store: :cookie, 34 | key: "_binaryid_key", 35 | signing_salt: "JFbk5iZ6" 36 | 37 | plug TestCoherenceWeb.Router 38 | end 39 | -------------------------------------------------------------------------------- /test/support/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule TestCoherenceWeb.Gettext do 2 | @moduledoc """ 3 | A module providing Internationalization with a gettext-based API. 4 | 5 | By using [Gettext](http://hexdocs.pm/gettext), 6 | your module gains a set of macros for translations, for example: 7 | 8 | import Admin1.Gettext 9 | 10 | # Simple translation 11 | gettext "Here is the string to translate" 12 | 13 | # Plural translation 14 | ngettext "Here is the string to translate", 15 | "Here are the strings to translate", 16 | 3 17 | 18 | # Domain-based translation 19 | dgettext "errors", "Here is the error message to translate" 20 | 21 | See the [Gettext Docs](http://hexdocs.pm/gettext) for detailed usage. 22 | """ 23 | 24 | use Gettext, otp_app: :coherence 25 | end 26 | -------------------------------------------------------------------------------- /test/support/migrations.ex: -------------------------------------------------------------------------------- 1 | defmodule TestCoherence.Migrations do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:users) do 6 | add(:email, :string) 7 | add(:name, :string) 8 | # authenticatable 9 | add(:password_hash, :string) 10 | # recoverable 11 | add(:reset_password_token, :string) 12 | add(:reset_password_sent_at, :utc_datetime) 13 | # lockable 14 | add(:failed_attempts, :integer, default: 0) 15 | add(:unlock_token, :string) 16 | add(:locked_at, :utc_datetime) 17 | # trackable 18 | add(:sign_in_count, :integer, default: 0) 19 | add(:current_sign_in_at, :utc_datetime) 20 | add(:last_sign_in_at, :utc_datetime) 21 | add(:current_sign_in_ip, :string) 22 | add(:last_sign_in_ip, :string) 23 | # confirmable 24 | add(:confirmation_token, :string) 25 | add(:confirmed_at, :utc_datetime) 26 | add(:confirmation_sent_at, :utc_datetime) 27 | add(:unconfirmed_email, :string) 28 | # rememberable 29 | add(:remember_created_at, :utc_datetime) 30 | timestamps() 31 | end 32 | 33 | create(unique_index(:users, [:email])) 34 | 35 | create table(:rememberables) do 36 | add(:series_hash, :string) 37 | add(:token_hash, :string) 38 | add(:token_created_at, :utc_datetime) 39 | add(:user_id, references(:users, on_delete: :delete_all)) 40 | 41 | timestamps() 42 | end 43 | 44 | create(index(:rememberables, [:user_id])) 45 | create(index(:rememberables, [:series_hash])) 46 | create(index(:rememberables, [:token_hash])) 47 | create(unique_index(:rememberables, [:user_id, :series_hash, :token_hash])) 48 | 49 | # Invitation schema 50 | create table(:invitations) do 51 | add(:name, :string) 52 | add(:email, :string) 53 | add(:token, :string) 54 | timestamps() 55 | end 56 | 57 | create(unique_index(:invitations, [:email])) 58 | create(index(:invitations, [:token])) 59 | 60 | create table(:trackables) do 61 | add(:action, :string) 62 | add(:sign_in_count, :integer, default: 0) 63 | add(:current_sign_in_at, :utc_datetime) 64 | add(:last_sign_in_at, :utc_datetime) 65 | add(:current_sign_in_ip, :string) 66 | add(:last_sign_in_ip, :string) 67 | add(:user_id, references(:users, on_delete: :delete_all)) 68 | timestamps() 69 | end 70 | 71 | create(index(:trackables, [:user_id])) 72 | create(index(:trackables, [:action])) 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /test/support/model_case.ex: -------------------------------------------------------------------------------- 1 | defmodule TestCoherence.ModelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | model tests. 5 | 6 | You may define functions here to be used as helpers in 7 | your model tests. See `errors_on/2`'s definition as reference. 8 | 9 | Finally, if the test case interacts with the database, 10 | it cannot be async. For this reason, every test runs 11 | inside a transaction which is reset at the beginning 12 | of the test unless the test case is marked as async. 13 | """ 14 | 15 | use ExUnit.CaseTemplate 16 | 17 | using do 18 | quote do 19 | alias TestCoherence.Repo 20 | 21 | import Ecto 22 | import Ecto.Changeset 23 | import Ecto.Query, only: [from: 1, from: 2] 24 | import TestCoherence.ModelCase 25 | end 26 | end 27 | 28 | setup tags do 29 | unless tags[:async] do 30 | Ecto.Adapters.SQL.Sandbox.checkout(TestCoherence.Repo) 31 | end 32 | 33 | :ok 34 | end 35 | 36 | @doc """ 37 | Helper for returning list of errors in model when passed certain data. 38 | 39 | ## Examples 40 | 41 | Given a User model that lists `:name` as a required field and validates 42 | `:password` to be safe, it would return: 43 | 44 | iex> errors_on(%User{}, %{password: "password"}) 45 | [password: "is unsafe", name: "is blank"] 46 | 47 | You could then write your assertion like: 48 | 49 | assert {:password, "is unsafe"} in errors_on(%User{}, %{password: "password"}) 50 | 51 | You can also create the changeset manually and retrieve the errors 52 | field directly: 53 | 54 | iex> changeset = User.changeset(%User{}, password: "password") 55 | iex> {:password, "is unsafe"} in changeset.errors 56 | true 57 | """ 58 | def errors_on(model, data) do 59 | model.__struct__.changeset(model, data).errors 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /test/support/redirect.ex: -------------------------------------------------------------------------------- 1 | defmodule Coherence.Redirects do 2 | @moduledoc """ 3 | Define controller action redirection functions. 4 | 5 | This module contains default redirect functions for each of the controller 6 | actions that perform redirects. By using this Module you get the following 7 | functions: 8 | 9 | * session_create/2 10 | * session_delete/2 11 | * password_create/2 12 | * password_update/2, 13 | * unlock_create_not_locked/2 14 | * unlock_create_invalid/2 15 | * unlock_create/2 16 | * unlock_edit_not_locked/2 17 | * unlock_edit/2 18 | * unlock_edit_invalid/2 19 | * registration_create/2 20 | * invitation_create/2 21 | * confirmation_create/2 22 | * confirmation_edit_invalid/2 23 | * confirmation_edit_expired/2 24 | * confirmation_edit/2 25 | * confirmation_edit_error/2 26 | 27 | You can override any of the functions to customize the redirect path. Each 28 | function is passed the `conn` and `params` arguments from the controller. 29 | 30 | ## Examples 31 | 32 | import MyProject.Router.Helpers 33 | 34 | # override the log out action back to the log in page 35 | def session_delete(conn, _), do: redirect(conn, session_path(conn, :new)) 36 | 37 | # redirect the user to the login page after registering 38 | def registration_create(conn, _), do: redirect(conn, session_path(conn, :new)) 39 | 40 | # disable the user_return_to feature on login 41 | def session_create(conn, _), do: redirect(conn, landing_path(conn, :index)) 42 | 43 | """ 44 | use Redirects 45 | # Uncomment the import below if adding overrides 46 | # import <%= base %>.Router.Helpers 47 | 48 | # Add function overrides below 49 | 50 | # Example usage 51 | # Uncomment the following line to return the user to the login form after logging out 52 | # def session_delete(conn, _), do: redirect(conn, session_path(conn, :new)) 53 | end 54 | -------------------------------------------------------------------------------- /test/support/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule TestCoherence.Repo do 2 | use Ecto.Repo, 3 | otp_app: :coherence, 4 | adapter: Ecto.Adapters.Postgres 5 | end 6 | -------------------------------------------------------------------------------- /test/support/responders/html.ex: -------------------------------------------------------------------------------- 1 | defmodule Coherence.Responders.Html do 2 | use Responders.Html 3 | end 4 | -------------------------------------------------------------------------------- /test/support/responders/json.ex: -------------------------------------------------------------------------------- 1 | defmodule Coherence.Responders.Json do 2 | use Responders.Json 3 | end 4 | -------------------------------------------------------------------------------- /test/support/router.ex: -------------------------------------------------------------------------------- 1 | defmodule TestCoherenceWeb.Router do 2 | use Phoenix.Router 3 | use Coherence.Router 4 | 5 | def login_callback(conn) do 6 | Phoenix.Controller.html(conn, "Login callback rendered") 7 | |> Plug.Conn.halt() 8 | end 9 | 10 | pipeline :browser do 11 | plug :accepts, ["html", "text"] 12 | plug :fetch_session 13 | plug :fetch_flash 14 | # plug :protect_from_forgery 15 | plug :put_secure_browser_headers 16 | plug Coherence.Authentication.Session, db_model: TestCoherence.User 17 | end 18 | 19 | pipeline :protected do 20 | plug :accepts, ["html", "text"] 21 | plug :fetch_session 22 | plug :fetch_flash 23 | # plug :protect_from_forgery 24 | plug :put_secure_browser_headers 25 | 26 | plug Coherence.Authentication.Session, 27 | db_model: TestCoherence.User, 28 | rememberable: true, 29 | login: &__MODULE__.login_callback/1, 30 | rememberable_callback: &Coherence.SessionController.do_rememberable_callback/5 31 | end 32 | 33 | scope "/" do 34 | pipe_through :browser 35 | coherence_routes() 36 | 37 | get "/dummies", TestCoherenceWeb.DummyController, :index 38 | end 39 | 40 | scope "/" do 41 | pipe_through :protected 42 | coherence_routes(:protected) 43 | 44 | get "/dummies/new", TestCoherenceWeb.DummyController, :new 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /test/support/schemas.ex: -------------------------------------------------------------------------------- 1 | defmodule TestCoherence.Coherence.Schemas do 2 | use Coherence.Config 3 | 4 | import Ecto.Query 5 | 6 | alias TestCoherence.Coherence.{Invitation, Rememberable, Trackable} 7 | 8 | @user_schema Config.user_schema() 9 | @repo Config.repo() 10 | 11 | def list_user do 12 | @repo.all(@user_schema) 13 | end 14 | 15 | def list_by_user(opts) do 16 | @repo.all(query_by(@user_schema, opts)) 17 | end 18 | 19 | def get_by_user(opts) do 20 | @repo.get_by(@user_schema, opts) 21 | end 22 | 23 | def get_user(id) do 24 | @repo.get(@user_schema, id) 25 | end 26 | 27 | def get_user!(id) do 28 | @repo.get!(@user_schema, id) 29 | end 30 | 31 | def get_user_by_email(email) do 32 | @repo.get_by(@user_schema, email: email) 33 | end 34 | 35 | def change_user(struct, params) do 36 | @user_schema.changeset(struct, params) 37 | end 38 | 39 | def change_user(params) do 40 | @user_schema.changeset(@user_schema.__struct__, params) 41 | end 42 | 43 | def change_user do 44 | @user_schema.changeset(@user_schema.__struct__, %{}) 45 | end 46 | 47 | def create_user(params) do 48 | @repo.insert(change_user(params)) 49 | end 50 | 51 | def create_user!(params) do 52 | @repo.insert!(change_user(params)) 53 | end 54 | 55 | def update_user(user, params) do 56 | @repo.update(change_user(user, params)) 57 | end 58 | 59 | def update_user!(user, params) do 60 | @repo.update!(change_user(user, params)) 61 | end 62 | 63 | Enum.each([Rememberable, Invitation, Trackable], fn module -> 64 | name = 65 | module 66 | |> Module.split() 67 | |> List.last() 68 | |> String.downcase() 69 | 70 | def unquote(String.to_atom("list_#{name}"))() do 71 | @repo.all(unquote(module)) 72 | end 73 | 74 | def unquote(String.to_atom("list_#{name}"))(%Ecto.Query{} = query) do 75 | @repo.all(query) 76 | end 77 | 78 | def unquote(String.to_atom("list_by_#{name}"))(opts) do 79 | @repo.all(query_by(unquote(module), opts)) 80 | end 81 | 82 | def unquote(String.to_atom("get_#{name}"))(id) do 83 | @repo.get(unquote(module), id) 84 | end 85 | 86 | def unquote(String.to_atom("get_#{name}!"))(id) do 87 | @repo.get!(unquote(module), id) 88 | end 89 | 90 | def unquote(String.to_atom("get_by_#{name}"))(opts) do 91 | @repo.get_by(unquote(module), opts) 92 | end 93 | 94 | def unquote(String.to_atom("change_#{name}"))(struct, params) do 95 | unquote(module).changeset(struct, params) 96 | end 97 | 98 | def unquote(String.to_atom("change_#{name}"))(params) do 99 | unquote(module).new_changeset(params) 100 | end 101 | 102 | def unquote(String.to_atom("change_#{name}"))() do 103 | unquote(module).new_changeset(%{}) 104 | end 105 | 106 | def unquote(String.to_atom("create_#{name}"))(params) do 107 | @repo.insert(unquote(module).new_changeset(params)) 108 | end 109 | 110 | def unquote(String.to_atom("create_#{name}!"))(params) do 111 | @repo.insert!(unquote(module).new_changeset(params)) 112 | end 113 | 114 | def unquote(String.to_atom("update_#{name}"))(struct, params) do 115 | @repo.update(unquote(module).changeset(struct, params)) 116 | end 117 | 118 | def unquote(String.to_atom("update_#{name}!"))(struct, params) do 119 | @repo.update!(unquote(module).changeset(struct, params)) 120 | end 121 | 122 | def unquote(String.to_atom("delete_#{name}"))(struct) do 123 | @repo.delete(struct) 124 | end 125 | end) 126 | 127 | def last_trackable(user_id) do 128 | schema = 129 | @repo.one( 130 | Trackable 131 | |> where([t], t.user_id == ^user_id) 132 | |> order_by(desc: :id) 133 | |> limit(1) 134 | ) 135 | 136 | case schema do 137 | nil -> Trackable.__struct__() 138 | trackable -> trackable 139 | end 140 | end 141 | 142 | def query_by(schema, opts) do 143 | Enum.reduce(opts, schema, fn {k, v}, query -> 144 | where(query, [b], field(b, ^k) == ^v) 145 | end) 146 | end 147 | 148 | def delete_all(%Ecto.Query{} = query) do 149 | @repo.delete_all(query) 150 | end 151 | 152 | def delete_all(module) when is_atom(module) do 153 | @repo.delete_all(module) 154 | end 155 | 156 | def create(%Ecto.Changeset{} = changeset) do 157 | @repo.insert(changeset) 158 | end 159 | 160 | def create!(%Ecto.Changeset{} = changeset) do 161 | @repo.insert!(changeset) 162 | end 163 | 164 | def update(%Ecto.Changeset{} = changeset) do 165 | @repo.update(changeset) 166 | end 167 | 168 | def update!(%Ecto.Changeset{} = changeset) do 169 | @repo.update!(changeset) 170 | end 171 | 172 | def delete(schema) do 173 | @repo.delete(schema) 174 | end 175 | 176 | def delete!(schema) do 177 | @repo.delete!(schema) 178 | end 179 | end 180 | -------------------------------------------------------------------------------- /test/support/templates/invitation/edit.html.eex: -------------------------------------------------------------------------------- 1 |
2 |

Create Account

3 | <%= form_for @changeset, invitation_path(@conn, :create_user), fn f -> %> 4 | 5 | <%= if @changeset.action do %> 6 |
7 |

Oops, something went wrong! Please check the errors below.

8 |
9 | <% end %> 10 | 11 | 12 | 13 |
14 | <%= required_label f, :name, class: "control-label" %> 15 | <%= text_input f, :name, class: "form-control", required: "" %> 16 | <%= error_tag f, :name %> 17 |
18 | 19 |
20 | <%= required_label f, :email, class: "control-label" %> 21 | <%= text_input f, :email, class: "form-control", required: "" %> 22 | <%= error_tag f, :email %> 23 |
24 | 25 |
26 | <%= required_label f, :password, class: "control-label" %> 27 | <%= password_input f, :password, class: "form-control", required: "" %> 28 | <%= error_tag f, :password %> 29 |
30 | 31 |
32 | <%= required_label f, :password_confirmation, class: "control-label" %> 33 | <%= password_input f, :password_confirmation, class: "form-control", required: "" %> 34 | <%= error_tag f, :password_confirmation %> 35 |
36 | 37 |
38 | <%= submit "Create", class: "btn btn-primary" %> 39 | <%= link "Cancel", to: Coherence.Config.logged_out_url("/"), class: "btn" %> 40 |
41 | <% end %> 42 | -------------------------------------------------------------------------------- /test/support/templates/invitation/new.html.eex: -------------------------------------------------------------------------------- 1 |
2 | 3 | <%= form_for @changeset, invitation_path(@conn, :create), [as: :invitation], fn f -> %> 4 | <%= if @changeset.action do %> 5 |
6 |

Oops, something went wrong! Please check the errors below.

7 |
8 | <% end %> 9 |
10 | <%= required_label f, :name, class: "control-label" %> 11 | <%= text_input f, :name, class: "form-control", required: "" %> 12 | <%= error_tag f, :name %> 13 |
14 | 15 |
16 | <%= required_label f, :email, class: "control-label" %> 17 | <%= text_input f, :email, class: "form-control", required: "" %> 18 | <%= error_tag f, :email %> 19 |
20 | 21 |
22 | <%= submit "Send Invitation", class: "btn btn-primary" %> 23 | <%= link "Cancel", to: Coherence.Config.logged_out_url("/"), class: "btn" %> 24 |
25 | <% end %> 26 | -------------------------------------------------------------------------------- /test/support/templates/layout/app.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Hello Test Coherence 11 | "> 12 | 13 | 14 | 15 |
16 |
17 | 19 | 20 |
21 | 22 | 23 | 24 | 25 |
26 | <%%= render @view_module, @view_template, assigns %> 27 |
28 | 29 |
30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /test/support/test_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule TestCoherence.TestHelpers do 2 | alias TestCoherence.{Coherence.Rememberable} 3 | alias Coherence.Config 4 | import Phoenix.HTML, only: [safe_to_string: 1] 5 | import Coherence.Controller, only: [random_string: 1] 6 | import Plug.Conn 7 | 8 | def to_map(attrs) when is_list(attrs), do: Enum.into(attrs, %{}) 9 | def to_map(attrs), do: attrs 10 | 11 | def insert_user(attrs \\ %{}) do 12 | changes = 13 | Map.merge( 14 | %{ 15 | name: "Test User", 16 | email: "user#{Base.encode16(:crypto.strong_rand_bytes(8))}@example.com", 17 | password: "supersecret", 18 | password_confirmation: "supersecret" 19 | }, 20 | to_map(attrs) 21 | ) 22 | 23 | %TestCoherence.User{} 24 | |> TestCoherence.User.changeset(changes) 25 | |> TestCoherence.Repo.insert!() 26 | end 27 | 28 | def get_user_by_email(email) do 29 | Config.user_schema() 30 | |> TestCoherence.Repo.get_by!(email: email) 31 | end 32 | 33 | def get_user_by_name(name) do 34 | Config.user_schema() 35 | |> TestCoherence.Repo.get_by!(name: name) 36 | end 37 | 38 | def insert_invitation(attrs \\ %{}) do 39 | token = random_string(48) 40 | 41 | changes = 42 | Map.merge( 43 | %{ 44 | name: "Test User", 45 | email: "user#{Base.encode16(:crypto.strong_rand_bytes(8))}@example.com", 46 | token: token 47 | }, 48 | to_map(attrs) 49 | ) 50 | 51 | %TestCoherence.Invitation{} 52 | |> TestCoherence.Invitation.changeset(changes) 53 | |> TestCoherence.Repo.insert!() 54 | end 55 | 56 | def insert_rememberable(user, attrs \\ %{}) do 57 | {changeset, series, token} = Rememberable.create_login(user) 58 | changes = changeset.changes 59 | 60 | changes = 61 | Map.merge( 62 | %{ 63 | user_id: user.id, 64 | series_hash: changes[:series_hash], 65 | token_hash: changes[:token_hash], 66 | token_created_at: changes[:token_created_at] 67 | }, 68 | to_map(attrs) 69 | ) 70 | 71 | r1 = 72 | %Rememberable{} 73 | |> Rememberable.changeset(changes) 74 | |> TestCoherence.Repo.insert!() 75 | 76 | {r1, series, token} 77 | end 78 | 79 | def floki_link(safe) when is_tuple(safe) do 80 | safe |> safe_to_string |> floki_link 81 | end 82 | 83 | def floki_link(string) do 84 | result = Floki.find(string, "a[href]") 85 | [href] = Floki.attribute(result, "href") 86 | {href, Floki.text(result)} 87 | end 88 | 89 | def handler(conn) do 90 | conn 91 | |> assign(:error_handler_called, true) 92 | |> send_resp(418, "I'm a teapot") 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /test/support/views.ex: -------------------------------------------------------------------------------- 1 | defmodule Coherence.CoherenceView do 2 | use Phoenix.HTML 3 | use Phoenix.View, root: "web/templates/coherence" 4 | import TestCoherenceWeb.Router.Helpers 5 | 6 | @seperator {:safe, "  |  "} 7 | 8 | def coherence_links(conn, :new_session) do 9 | user_schema = Coherence.Config.user_schema() 10 | 11 | [ 12 | recovery_link(conn, user_schema), 13 | unlock_link(conn, user_schema) 14 | ] 15 | |> List.flatten() 16 | |> concat([]) 17 | end 18 | 19 | defp concat([], acc), do: Enum.reverse(acc) 20 | defp concat([h | t], []), do: concat(t, [h]) 21 | defp concat([h | t], acc), do: concat(t, [h, @seperator | acc]) 22 | 23 | defp recovery_link(conn, user_schema) do 24 | if user_schema.recoverable? do 25 | [link("Forgot Your Password?", to: password_path(conn, :new))] 26 | else 27 | [] 28 | end 29 | end 30 | 31 | defp unlock_link(conn, _user_schema) do 32 | if conn.assigns[:locked] do 33 | [link("Send an unlock email", to: unlock_path(conn, :new))] 34 | else 35 | [] 36 | end 37 | end 38 | end 39 | 40 | defmodule Coherence.LayoutView do 41 | use TestCoherenceWeb.Coherence, :view 42 | # import TestCoherence.Web.Router.Helpers 43 | end 44 | 45 | defmodule TestCoherenceWeb.Coherence.InvitationView do 46 | use TestCoherenceWeb.Coherence, :view 47 | 48 | def render("new.html", params) do 49 | "new data: #{inspect(params)}" 50 | end 51 | end 52 | 53 | defmodule TestCoherenceWeb.Coherence.SessionView do 54 | use TestCoherenceWeb.Coherence, :view 55 | def render("new.html", _params), do: "new session" 56 | end 57 | 58 | defmodule TestCoherenceWeb.ErrorView do 59 | def render("500.html", _changeset), do: "500.html" 60 | def render("400.html", _changeset), do: "400.html" 61 | end 62 | 63 | defmodule TestCoherenceWeb.Coherence.RegistrationView do 64 | use TestCoherenceWeb.Coherence, :view 65 | def render("new.html", _params), do: "new registration" 66 | def render("show.html", _params), do: "show registration" 67 | def render("edit.html", _params), do: "edit registration" 68 | end 69 | 70 | defmodule TestCoherenceWeb.Coherence.PasswordView do 71 | use TestCoherenceWeb.Coherence, :view 72 | def render("new.html", _params), do: "new password" 73 | def render("show.html", _params), do: "show password" 74 | def render("edit.html", _params), do: "edit password" 75 | end -------------------------------------------------------------------------------- /test/support/web.ex: -------------------------------------------------------------------------------- 1 | defmodule TestCoherenceWeb.Coherence do 2 | def view do 3 | quote do 4 | use Phoenix.View, root: "test/support/templates" 5 | # Import convenience functions from controllers 6 | import Phoenix.Controller, only: [get_csrf_token: 0, get_flash: 2, view_module: 1] 7 | 8 | # Use all HTML functionality (forms, tags, etc) 9 | use Phoenix.HTML 10 | 11 | import TestCoherenceWeb.Gettext 12 | import TestCoherenceWeb.Router.Helpers 13 | import TestCoherenceWeb.ViewHelpers 14 | end 15 | end 16 | 17 | defmacro __using__(which) when is_atom(which) do 18 | apply(__MODULE__, which, []) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | Application.ensure_all_started(:coherence) 3 | 4 | # Code.require_file("./support/gettext.exs", __DIR__) 5 | # Code.require_file("./support/messages.exs", __DIR__) 6 | # Code.require_file("./support/view_helpers.exs", __DIR__) 7 | # Code.require_file("./support/web.exs", __DIR__) 8 | # Code.require_file("./support/dummy_controller.exs", __DIR__) 9 | # Code.require_file("./support/schema.exs", __DIR__) 10 | # Code.require_file("./support/migrations.exs", __DIR__) 11 | # Code.require_file("./support/router.exs", __DIR__) 12 | # Code.require_file("./support/endpoint.exs", __DIR__) 13 | # Code.require_file("./support/model_case.exs", __DIR__) 14 | # Code.require_file("./support/conn_case.exs", __DIR__) 15 | # Code.require_file("./support/views.exs", __DIR__) 16 | # Code.require_file("./support/email.exs", __DIR__) 17 | # Code.require_file("./support/test_helpers.exs", __DIR__) 18 | # Code.require_file("./support/redirect.exs", __DIR__) 19 | # Code.require_file("./support/schemas.exs", __DIR__) 20 | 21 | defmodule Coherence.RepoSetup do 22 | use ExUnit.CaseTemplate 23 | end 24 | 25 | TestCoherence.Repo.__adapter__().storage_down(TestCoherence.Repo.config()) 26 | TestCoherence.Repo.__adapter__().storage_up(TestCoherence.Repo.config()) 27 | 28 | {:ok, _pid} = TestCoherenceWeb.Endpoint.start_link() 29 | {:ok, _pid} = TestCoherence.Repo.start_link() 30 | _ = Ecto.Migrator.up(TestCoherence.Repo, 0, TestCoherence.Migrations, log: false) 31 | Process.flag(:trap_exit, true) 32 | Ecto.Adapters.SQL.Sandbox.mode(TestCoherence.Repo, :manual) 33 | -------------------------------------------------------------------------------- /test/view_helpers_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CoherenceTestWeb.ViewHelpers do 2 | use TestCoherence.ConnCase 3 | import Plug.Conn 4 | alias TestCoherenceWeb.ViewHelpers 5 | alias TestCoherence.User 6 | import Phoenix.HTML, only: [safe_to_string: 1] 7 | 8 | @recover_link "Forgot your password?" 9 | @unlock_link "Send an unlock email" 10 | @register_link "Need An Account?" 11 | @confirmation_link "Resend confirmation email" 12 | @signin_link "Sign In" 13 | @signout_link "Sign Out" 14 | 15 | setup do 16 | Application.put_env(:coherence, :opts, [ 17 | :confirmable, 18 | :authenticatable, 19 | :recoverable, 20 | :lockable, 21 | :trackable, 22 | :unlockable_with_token, 23 | :invitable, 24 | :registerable 25 | ]) 26 | 27 | user = %User{name: "test", email: "test@example.com", id: 1} 28 | 29 | conn = 30 | %Plug.Conn{} 31 | |> assign(:current_user, user) 32 | 33 | {:ok, conn: conn, user: user} 34 | end 35 | 36 | @helpers Module.concat(Application.get_env(:coherence, :web_module), Router.Helpers) 37 | 38 | test "coherence_path", %{conn: conn} do 39 | assert ViewHelpers.coherence_path(@helpers, :unlock_path, conn, :new) == "/unlocks/new" 40 | 41 | assert ViewHelpers.coherence_path(@helpers, :registration_path, conn, :new) == 42 | "/registrations/new" 43 | 44 | assert ViewHelpers.coherence_path(@helpers, :session_path, conn, :new) == "/sessions/new" 45 | end 46 | 47 | test "unlock_link", %{conn: conn} do 48 | assert ViewHelpers.unlock_link(conn, "Unlock") 49 | |> floki_link == {"/unlocks/new", "Unlock"} 50 | 51 | user_schema = Config.user_schema() 52 | assert ViewHelpers.unlock_link(conn, user_schema, false) == [] 53 | assert ViewHelpers.unlock_link(conn, user_schema, "Unlock") == [] 54 | 55 | result = 56 | conn 57 | |> Plug.Conn.assign(:locked, true) 58 | |> ViewHelpers.unlock_link(user_schema, "Send Unlock link") 59 | |> hd 60 | 61 | assert floki_link(result) == {"/unlocks/new", "Send Unlock link"} 62 | end 63 | 64 | test "coherence_links :new_session defaults", %{conn: conn} do 65 | conn = Plug.Conn.assign(conn, :locked, true) 66 | 67 | [result1, "  |  ", result2, "  |  ", result3, "  |  ", result4] = 68 | ViewHelpers.coherence_links(conn, :new_session) 69 | |> Enum.map(&Phoenix.HTML.safe_to_string/1) 70 | 71 | assert floki_link(result1) == {"/passwords/new", @recover_link} 72 | assert floki_link(result2) == {"/unlocks/new", @unlock_link} 73 | assert floki_link(result3) == {"/registrations/new", @register_link} 74 | assert floki_link(result4) == {"/confirmations/new", @confirmation_link} 75 | end 76 | 77 | test "coherence_links :new_session no register", %{conn: conn} do 78 | conn = Plug.Conn.assign(conn, :locked, true) 79 | 80 | [result1, "  |  ", result2, "  |  ", result3] = 81 | ViewHelpers.coherence_links(conn, :new_session, register: false) 82 | |> Enum.map(&Phoenix.HTML.safe_to_string/1) 83 | 84 | assert floki_link(result1) == {"/passwords/new", @recover_link} 85 | assert floki_link(result2) == {"/unlocks/new", @unlock_link} 86 | assert floki_link(result3) == {"/confirmations/new", @confirmation_link} 87 | end 88 | 89 | test "coherence_links :new_session not locked no register", %{conn: conn} do 90 | [result1, "  |  ", result2] = 91 | ViewHelpers.coherence_links(conn, :new_session, register: false) 92 | |> Enum.map(&Phoenix.HTML.safe_to_string/1) 93 | 94 | assert floki_link(result1) == {"/passwords/new", @recover_link} 95 | assert floki_link(result2) == {"/confirmations/new", @confirmation_link} 96 | end 97 | 98 | test "coherence_links :layout signed in", %{conn: conn} do 99 | [item1, item2] = ViewHelpers.coherence_links(conn, :layout) 100 | 101 | result1 = item1 |> safe_to_string 102 | result2 = item2 |> safe_to_string 103 | 104 | assert Floki.find(result1, "li") |> Floki.text() == "test" 105 | assert Floki.find(result2, "li a") |> Floki.text() == @signout_link 106 | end 107 | 108 | test "coherence_links :layout not signed" do 109 | conn = %Plug.Conn{} 110 | 111 | [item1, item2] = 112 | ViewHelpers.coherence_links(conn, :layout, register: "New Account", signin: "Login") 113 | 114 | result1 = item1 |> safe_to_string 115 | result2 = item2 |> safe_to_string 116 | 117 | assert Floki.find(result1, "li") |> Floki.text() == "New Account" 118 | assert Floki.find(result2, "li a") |> Floki.text() == "Login" 119 | end 120 | 121 | test "coherence_links :layout not signed no register" do 122 | conn = %Plug.Conn{} 123 | 124 | assert ViewHelpers.coherence_links(conn, :layout, register: false) 125 | |> floki_link == {"/sessions/new", @signin_link} 126 | end 127 | end 128 | --------------------------------------------------------------------------------