├── .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", "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 |<%%= 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 |<%%= 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 |<%%= 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 |<%%= 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 |<%%= dgettext "coherence", "Oops, something went wrong! Please check the errors below." %>
8 |<%%= dgettext "coherence", "Oops, something went wrong! Please check the errors below." %>
5 |<%%= get_flash(@conn, :info) %>
23 |<%%= get_flash(@conn, :error) %>
24 | 25 |<%%= dgettext "coherence", "Oops, something went wrong! Please check the errors below." %>
6 |Oops, something went wrong! Please check the errors below.
8 |Oops, something went wrong! Please check the errors below.
7 |<%%= get_flash(@conn, :info) %>
23 |<%%= get_flash(@conn, :error) %>
24 | 25 |