├── test
├── test_helper.exs
├── support
│ ├── models.ex
│ ├── repo.ex
│ └── process_store.ex
├── plug_test.exs
├── authsense_test.exs
└── service_test.exs
├── .gitignore
├── config
├── dev.exs
├── config.exs
└── test.exs
├── priv
└── repo
│ └── migrations
│ └── 20160617033243_add_users.exs
├── .travis.yml
├── README.md
├── mix.exs
├── lib
├── authsense
│ ├── exceptions.ex
│ ├── plug.ex
│ └── service.ex
└── authsense.ex
├── HISTORY.md
├── mix.lock
└── docs
└── recipes.md
/test/test_helper.exs:
--------------------------------------------------------------------------------
1 | ExUnit.start()
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /_build
2 | /cover
3 | /deps
4 | erl_crash.dump
5 | *.ez
6 | /doc
7 |
--------------------------------------------------------------------------------
/config/dev.exs:
--------------------------------------------------------------------------------
1 | use Mix.Config
2 |
3 | config :authsense, Authsense.Test.User,
4 | repo: Authsense.Test.Repo
5 |
--------------------------------------------------------------------------------
/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 | use Mix.Config
4 |
5 | import_config "#{Mix.env}.exs"
6 |
--------------------------------------------------------------------------------
/config/test.exs:
--------------------------------------------------------------------------------
1 | use Mix.Config
2 |
3 | config :ex_unit, :capture_log, true
4 |
5 | config :authsense, Authsense.Test.User,
6 | repo: Authsense.Test.Repo
7 |
8 | config :comeonin, :pbkdf2_rounds, 1
9 |
--------------------------------------------------------------------------------
/test/support/models.ex:
--------------------------------------------------------------------------------
1 | defmodule Authsense.Test.User do
2 | use Ecto.Schema
3 | schema "" do
4 | field :email, :string
5 | field :password, :string
6 | field :hashed_password, :string
7 | timestamps()
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20160617033243_add_users.exs:
--------------------------------------------------------------------------------
1 | defmodule Authsense.Test.Repo.Migrations.AddUsers do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:users) do
6 | add :email, :string
7 | add :hashed_password, :string
8 | timestamps
9 | end
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: elixir
2 | elixir:
3 | - '1.5.0'
4 | script:
5 | - mix test --exclude pending
6 | cache:
7 | directories:
8 | - _deps
9 | - build
10 |
11 | after_success:
12 | - if [ "$TRAVIS_BRANCH" = "master" -a "$TRAVIS_PULL_REQUEST" = "false" ]; then mix docs; npm install git-update-ghpages; ./node_modules/.bin/git-update-ghpages -e; fi
13 | cache:
14 | directories:
15 | - node_modules
16 | env:
17 | global:
18 | - GIT_NAME: Travis CI
19 | - GIT_EMAIL: nobody@nobody.org
20 | - GITHUB_REPO: rstacruz/authsense
21 | - GIT_SOURCE: doc
22 |
--------------------------------------------------------------------------------
/test/support/repo.ex:
--------------------------------------------------------------------------------
1 | defmodule Authsense.Test.Repo do
2 | alias Authsense.Test.User
3 |
4 | def get(_model, 1) do
5 | valid_resource "rico@gmail.com", "foobar"
6 | end
7 | def get(_model, _id), do: nil
8 |
9 | def get_by(_model, email: "rico@gmail.com"), do: valid_resource "rico@gmail.com", "foobar"
10 | def get_by(_model, _email), do: nil
11 |
12 | defp valid_resource(email, password) do
13 | %User{id: 1, email: email, hashed_password: crypto().hashpwsalt(password)}
14 | end
15 |
16 | defp crypto do
17 | Authsense.config.crypto
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/test/support/process_store.ex:
--------------------------------------------------------------------------------
1 | defmodule Authsense.Test.ProcessStore do
2 | @moduledoc """
3 | Derived from:
4 | https://github.com/elixir-lang/plug/blob/master/test/test_helper.exs
5 | """
6 |
7 | @behaviour Plug.Session.Store
8 |
9 | def init(_opts) do
10 | nil
11 | end
12 |
13 | def get(_conn, sid, nil) do
14 | {sid, Process.get({:session, sid}) || %{}}
15 | end
16 |
17 | def delete(_conn, sid, nil) do
18 | Process.delete({:session, sid})
19 | :ok
20 | end
21 |
22 | def put(conn, nil, data, nil) do
23 | sid = :crypto.strong_rand_bytes(96) |> Base.encode64
24 | put(conn, sid, data, nil)
25 | end
26 |
27 | def put(_conn, sid, data, nil) do
28 | Process.put({:session, sid}, data)
29 | sid
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/test/plug_test.exs:
--------------------------------------------------------------------------------
1 | defmodule PlugTest do
2 | use ExUnit.Case, async: true
3 | use Plug.Test
4 |
5 | alias Authsense.Test.User
6 | alias Authsense.Test.ProcessStore
7 |
8 | setup do
9 | Application.delete_env :authsense, :included_applications
10 | :ok
11 | end
12 |
13 | # Enables sessions in a conn
14 | defp sign_conn(conn) do
15 | opts = Plug.Session.init(store: ProcessStore, key: "foobar")
16 | conn
17 | |> Plug.Session.call(opts)
18 | |> fetch_session
19 | end
20 |
21 | test "put_current_user(user)" do
22 | user = %User{id: 1}
23 | conn = conn(:get, "/")
24 | |> sign_conn()
25 | |> Authsense.Plug.put_current_user(user)
26 |
27 | assert conn.assigns.current_user == user
28 | assert get_session(conn, :current_user_id) == 1
29 | end
30 |
31 | test "put_current_user(nil)" do
32 | conn = conn(:get, "/")
33 | |> sign_conn()
34 | |> Authsense.Plug.put_current_user(nil)
35 |
36 | assert conn.assigns.current_user == nil
37 | assert get_session(conn, :current_user_id) == nil
38 | end
39 |
40 | test "fetch_current_user" do
41 | user = %User{id: 1}
42 | conn = conn(:get, "/")
43 | |> sign_conn()
44 | |> put_session(:current_user_id, user.id)
45 | |> Authsense.Plug.fetch_current_user()
46 |
47 | assert get_session(conn, :current_user_id) == user.id
48 | assert conn.assigns.current_user.id == user.id
49 | end
50 |
51 | test "fetch_current_user (nonexistent)" do
52 | conn = conn(:get, "/")
53 | |> sign_conn()
54 | |> put_session(:current_user_id, 31337) # presumably non-existent
55 | |> Authsense.Plug.fetch_current_user()
56 |
57 | assert get_session(conn, :current_user_id) == 31337
58 | assert conn.assigns.current_user == nil
59 | end
60 | end
61 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | , Authsense
2 |
3 | > Sensible authentication helpers for Phoenix/Ecto
4 |
5 | [](https://travis-ci.org/rstacruz/authsense "See test builds")
6 |
7 | ## Installation
8 |
9 | Add authsense to your list of dependencies in `mix.exs`:
10 |
11 | ```elixir
12 | def deps do
13 | [{:authsense, "~> 1.0.0"}]
14 | end
15 | ```
16 |
17 | ## Overview
18 |
19 | Please consult the [Authsense documentation](http://ricostacruz.com/authsense/) for full details.
20 |
21 | Configure authsense:
22 |
23 | ```elixir
24 | config :authsense, Myapp.User,
25 | repo: Myapp.Repo
26 | ```
27 |
28 | You can then call some helpers for authentication:
29 |
30 | ```elixir
31 | # For login actions
32 | authenticate(changeset) #=> {:ok, user} or {:error, changeset_with_errors}
33 | authenticate({ "userid", "password" }) #=> %User{} | nil
34 | ```
35 |
36 | ```elixir
37 | # For login/logout actions
38 | conn |> put_current_user(user) # login
39 | conn |> put_current_user(nil) # logout
40 | ```
41 |
42 | ```elixir
43 | # For model changesets
44 | changeset
45 | |> generate_hashed_password()
46 | ```
47 |
48 | ```elixir
49 | # For controllers
50 | import Authsense.Plug
51 | plug :fetch_current_user
52 | conn.assigns.current_user #=> %User{} | nil
53 | ```
54 |
55 | Please consult the [Authsense documentation](http://ricostacruz.com/authsense/) detailed info.
56 |
57 | ## Thanks
58 |
59 | **authsense** © 2016-2017, Rico Sta. Cruz. Released under the [MIT] License.
60 | Authored and maintained by Rico Sta. Cruz with help from contributors ([list][contributors]).
61 |
62 | > [ricostacruz.com](http://ricostacruz.com) ·
63 | > GitHub [@rstacruz](https://github.com/rstacruz) ·
64 | > Twitter [@rstacruz](https://twitter.com/rstacruz)
65 |
66 | [MIT]: http://mit-license.org/
67 | [contributors]: http://github.com/rstacruz/authsense/contributors
68 |
--------------------------------------------------------------------------------
/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule Authsense.Mixfile do
2 | use Mix.Project
3 |
4 | @version "1.0.0"
5 | @description """
6 | Sensible helpers for authentication for Phoenix/Ecto.
7 | """
8 |
9 | def project do
10 | [app: :authsense,
11 | version: @version,
12 | description: @description,
13 | elixir: "~> 1.5",
14 | elixirc_paths: elixirc_paths(Mix.env),
15 | build_embedded: Mix.env == :prod,
16 | start_permanent: Mix.env == :prod,
17 | source_url: "https://github.com/rstacruz/authsense",
18 | homepage_url: "https://github.com/rstacruz/authsense",
19 | docs: docs(),
20 | package: package(),
21 | deps: deps()]
22 | end
23 |
24 | # Configuration for the OTP application
25 | #
26 | # Type "mix help compile.app" for more information
27 | def application do
28 | [applications: [:logger]]
29 | end
30 |
31 | defp elixirc_paths(:test), do: ["lib", "test/support"]
32 | defp elixirc_paths(_), do: ["lib"]
33 |
34 | # Dependencies can be Hex packages:
35 | #
36 | # {:mydep, "~> 0.3.0"}
37 | #
38 | # Or git/path repositories:
39 | #
40 | # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1.0"}
41 | #
42 | # Type "mix help deps" for more examples and options
43 | defp deps do
44 | [
45 | {:ecto, "~> 2.0"},
46 | {:plug, "~> 1.0"},
47 | {:comeonin, "~> 4.0"},
48 | {:pbkdf2_elixir, "~> 0.12"},
49 | {:earmark, "~> 0.1", only: :dev},
50 | {:ex_doc, "~> 0.11", only: :dev},
51 | {:postgrex, "~> 0.11", only: :test}
52 | ]
53 | end
54 |
55 | def package do
56 | [
57 | maintainers: ["Rico Sta. Cruz"],
58 | licenses: ["MIT"],
59 | files: ["lib", "mix.exs", "README.md"],
60 | links: %{github: "https://github.com/rstacruz/authsense"}
61 | ]
62 | end
63 |
64 | def docs do
65 | [
66 | source_ref: "v#{@version}",
67 | main: "Authsense",
68 | extras:
69 | Path.wildcard("*.md") ++
70 | Path.wildcard("docs/**/*.md")
71 | ]
72 | end
73 | end
74 |
--------------------------------------------------------------------------------
/test/authsense_test.exs:
--------------------------------------------------------------------------------
1 | defmodule AuthsenseTest do
2 | use ExUnit.Case
3 | doctest Authsense
4 |
5 | setup do
6 | Application.delete_env :authsense, :included_applications
7 |
8 | on_exit fn ->
9 | Application.delete_env :authsense, Admin
10 | Application.put_env :authsense, Authsense.Test.User,
11 | repo: Authsense.Test.Repo
12 | end
13 |
14 | :ok
15 | end
16 |
17 | test "config with no resource configured" do
18 | Application.delete_env :authsense, Authsense.Test.User
19 |
20 | assert Authsense.config(Admin).model == Admin
21 | assert_raise Authsense.UnconfiguredException, fn ->
22 | Authsense.config
23 | end
24 | end
25 |
26 | test "config with only one resource configured" do
27 | assert Authsense.config.model == Authsense.Test.User
28 | assert Authsense.config.repo == Authsense.Test.Repo
29 | end
30 |
31 | test "config with multiple resources configured" do
32 | Application.put_env :authsense, Admin, password_field: :custom_field
33 |
34 | assert Authsense.config(Admin).model == Admin
35 | assert_raise Authsense.MultipleResourcesException, fn ->
36 | Authsense.config
37 | end
38 | end
39 |
40 | test "sets defaults" do
41 | assert Authsense.config.identity_field == :email
42 | end
43 |
44 | test "overrides defaults" do
45 | Application.put_env :authsense, Admin, password_field: :custom_field
46 |
47 | assert %{
48 | model: Admin,
49 | password_field: :custom_field,
50 | repo: nil,
51 | identity_field: :email
52 | } = Authsense.config(Admin)
53 | end
54 |
55 | test "works even if config for model isn't set" do
56 | assert %{
57 | model: NotConfigured,
58 | repo: nil,
59 | identity_field: :email
60 | } = Authsense.config(NotConfigured)
61 | end
62 |
63 | test "accepts lists" do
64 | assert %{
65 | model: NotConfigured,
66 | foo: :bar,
67 | identity_field: :email
68 | } = Authsense.config(model: NotConfigured, foo: :bar)
69 | end
70 |
71 | test "accepts empty lists" do
72 | assert Authsense.config([]).model == Authsense.Test.User
73 | end
74 | end
75 |
--------------------------------------------------------------------------------
/lib/authsense/exceptions.ex:
--------------------------------------------------------------------------------
1 | defmodule Authsense.UnconfiguredException do
2 | @moduledoc """
3 | Raised when no configuration for `Authsense` is provided.
4 | """
5 | defexception [:message]
6 |
7 | def exception(_) do
8 | message = """
9 | Please configure Authsense.
10 |
11 | Example configuration:
12 |
13 | config :authsense, MyApp.User,
14 | repo: MyApp.Repo
15 | """
16 | %Authsense.UnconfiguredException{message: message}
17 | end
18 | end
19 |
20 | defmodule Authsense.MultipleResourcesException do
21 | @moduledoc """
22 | When a single resource is configured for `Authsense`, it will
23 | automatically use that resource for function calls that need
24 | it, such as `Authsense.Service.generate_hashed_password/2`.
25 |
26 | However, when multiple resources are configured, this exception
27 | will be raised for functions that require a resource module as
28 | `Authsense` will not be able to determine which resource to use
29 | for that call.
30 |
31 | This can be avoided by providing the correct resource module to
32 | be used, e.g.,
33 |
34 | `Authsense.Service.generate_hashed_password(changeset, User)`
35 | """
36 | defexception [:message]
37 |
38 | def exception(_) do
39 | resources =
40 | Application.get_all_env(:authsense)
41 | |> Enum.map(fn {resource, _} -> resource end)
42 |
43 | message = """
44 | Multiple resources are configured.
45 |
46 | Authsense cannot determine which resource module to use.
47 | Available resource modules: #{inspect resources}
48 | """
49 | %Authsense.MultipleResourcesException{message: message}
50 | end
51 | end
52 |
53 | defmodule Authsense.InvalidScopeException do
54 | @moduledoc """
55 | Raised when passed scope to `Authsense.Service.authenticate/2`,
56 | `Authsense.Service.authenticate_user/2`, and `Authsense.Service.get_user/2`
57 | is either a lambda that does not return an `Ecto.Query`,
58 | or is not convertible to `Ecto.Query`
59 | """
60 |
61 | defexception [:message]
62 |
63 | def exception(message) do
64 | message = """
65 | Passed scope is of invalid type
66 |
67 | #{message}
68 | """
69 | %Authsense.InvalidScopeException{message: message}
70 | end
71 | end
72 |
--------------------------------------------------------------------------------
/HISTORY.md:
--------------------------------------------------------------------------------
1 | ## [v1.0.0]
2 | > Jan 22, 2018
3 |
4 | Thanks [@jekku]!
5 |
6 | - [#7] - Update to Ecto 2.2 (from 1.1).
7 | - [#7] - Update to Elixir 1.5 (from 1.2).
8 | - [#7] - Update to Comeonin 4.0 (from 2.4).
9 |
10 | [v1.0.0]: https://github.com/rstacruz/authsense/compare/v0.4.1...v1.0.0
11 | [#7]: https://github.com/rstacruz/authsense/issues/7
12 | [@jekku]: https://github.com/jekku
13 |
14 | ## [v0.4.1]
15 | > Sep 27, 2017
16 |
17 | - Restrict versions to old Ecto and Comeonin versions for now :(
18 |
19 | [v0.4.1]: https://github.com/rstacruz/authsense/compare/v0.4.0...v0.4.1
20 |
21 | ## [v0.4.0]
22 | > Sep 27, 2017
23 |
24 | - [#5] - Allow passing of scopes. (@jekku)
25 |
26 | [v0.4.0]: https://github.com/rstacruz/authsense/compare/v0.3.0...v0.4.0
27 | [#5]: https://github.com/rstacruz/authsense/issues/5
28 | [@jekku]: https://github.com/jekku
29 |
30 | ## [v0.3.0]
31 | > Jun 27, 2016
32 |
33 | - [#2] - Shows errors when multiple resources are configured (`MultipleResourcesException`). ([@victorsolis])
34 | - [#2] - Shows errors in compile time when Authsense isn't configured (`UnconfiguredException`). ([@victorsolis])
35 | - [#2] - General refactoring to improve configuration management. ([@victorsolis])
36 |
37 | [v0.3.0]: https://github.com/rstacruz/authsense/compare/v0.2.0...v0.3.0
38 | [#2]: https://github.com/rstacruz/authsense/issues/2
39 |
40 | ## [v0.2.0]
41 | > Jun 25, 2016
42 |
43 | Authsense's API has been significantly rewritten. It now uses `Mix.Config` for configuration.
44 |
45 | ```elixir
46 | config :authsense,
47 | Myapp.User,
48 | repo: Myapp.Repo
49 | ```
50 |
51 | Instead of `Auth.*`, the functions are now in `Authsense.Service` and `Authsense.Plug`.
52 |
53 | ```elixir
54 | changeset
55 | |> Authsense.Service.generate_hashed_password()
56 |
57 | conn
58 | |> Authsense.Plug.fetch_current_user()
59 | ```
60 |
61 | The new Mix-based config now means the module-based configuration is now deprecated.
62 |
63 | ```elixir
64 | #
65 | defmodule Myapp.Auth do
66 | use Authsense,
67 | model: Myapp.User,
68 | repo: Myapp.Repo
69 | end
70 | #
71 | ```
72 |
73 | Special thanks to [@victorsolis] for all the guidance that went into this release.
74 |
75 | [@victorsolis]: https://github.com/victorsolis
76 | [v0.2.0]: https://github.com/rstacruz/authsense/compare/v0.1.0...v0.2.0
77 |
78 | ## [v0.1.0]
79 | > Jun 23, 2016
80 |
81 | - Initial release.
82 |
83 | [v0.1.0]: https://github.com/rstacruz/authsense/tree/v0.1.0
84 |
--------------------------------------------------------------------------------
/lib/authsense/plug.ex:
--------------------------------------------------------------------------------
1 | defmodule Authsense.Plug do
2 | @moduledoc """
3 | Plug helpers for Authsense.
4 |
5 | You can use most of these functions as plugs.
6 |
7 | # in your controller or pipeline:
8 | import Authsense.Plug
9 | plug :fetch_current_user
10 |
11 | You can specify additional options.
12 |
13 | plug :fetch_current_user,
14 | repo: MyApp.Repo,
15 | model: MyApp.User
16 | """
17 |
18 | import Plug.Conn, only:
19 | [get_session: 2, put_session: 3, delete_session: 2, assign: 3]
20 |
21 | alias Plug.Conn
22 |
23 | @doc """
24 | Sets the `:current_user` assigns variable based on session.
25 |
26 | # in your controller or pipeline:
27 | import Authsense.Plug
28 | plug :fetch_current_user
29 |
30 | By doing so, you'll get access to the `:current_user` assigns. It will be set
31 | to the User model if logged in, or to `nil` if logged out.
32 |
33 | conn.assigns.current_user
34 |
35 | <%= if @current_user %>
36 | Hello, <%= @current_user.name %>
37 | <% else %>
38 | You are not logged in.
39 | <% end %>
40 | """
41 | def fetch_current_user(conn, opts \\ [])
42 | def fetch_current_user(%Conn{assigns: %{ current_user: _ }} = conn, _) do
43 | # Already ran fetch_current_user; no need to do so again.
44 | conn
45 | end
46 |
47 | def fetch_current_user(conn, opts) do
48 | %{repo: repo, model: model} = Authsense.config(opts)
49 |
50 | case get_session(conn, :current_user_id) do
51 | nil ->
52 | assign(conn, :current_user, nil)
53 | id ->
54 | user = repo.get(model, id)
55 | assign(conn, :current_user, user)
56 | end
57 | end
58 |
59 | @doc """
60 | Sets the current user for the session.
61 |
62 | import Authsense.Plug
63 |
64 | conn
65 | |> put_current_user(user)
66 | |> put_flash(:info, "Welcome.")
67 | |> redirect(to: "/")
68 |
69 | To logout, set it to nil.
70 |
71 | conn
72 | |> put_current_user(nil)
73 | |> put_flash(:info, "You've been logged out.")
74 | |> redirect(to: "/")
75 |
76 | This sets the `:current_user_id` in the Session store.
77 | """
78 | def put_current_user(conn, nil) do
79 | conn
80 | |> delete_session(:current_user_id)
81 | |> assign(:current_user, nil)
82 | end
83 |
84 | def put_current_user(conn, user) do
85 | id = Map.get(user, :id)
86 | conn
87 | |> put_session(:current_user_id, id)
88 | |> assign(:current_user, user)
89 | end
90 | end
91 |
--------------------------------------------------------------------------------
/mix.lock:
--------------------------------------------------------------------------------
1 | %{"comeonin": {:hex, :comeonin, "4.0.3", "4e257dcb748ed1ca2651b7ba24fdbd1bd24efd12482accf8079141e3fda23a10", [:mix], [{:argon2_elixir, "~> 1.2", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:bcrypt_elixir, "~> 0.12.1 or ~> 1.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: true]}, {:pbkdf2_elixir, "~> 0.12", [hex: :pbkdf2_elixir, repo: "hexpm", optional: true]}], "hexpm"},
2 | "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"},
3 | "db_connection": {:hex, :db_connection, "1.1.3", "89b30ca1ef0a3b469b1c779579590688561d586694a3ce8792985d4d7e575a61", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: true]}], "hexpm"},
4 | "decimal": {:hex, :decimal, "1.4.1", "ad9e501edf7322f122f7fc151cce7c2a0c9ada96f2b0155b8a09a795c2029770", [:mix], [], "hexpm"},
5 | "earmark": {:hex, :earmark, "0.2.1", "ba6d26ceb16106d069b289df66751734802777a3cbb6787026dd800ffeb850f3", [:mix], [], "hexpm"},
6 | "ecto": {:hex, :ecto, "2.2.8", "a4463c0928b970f2cee722cd29aaac154e866a15882c5737e0038bbfcf03ec2c", [:mix], [{:db_connection, "~> 1.1", [hex: :db_connection, repo: "hexpm", optional: true]}, {:decimal, "~> 1.2", [hex: :decimal, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.8.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: true]}], "hexpm"},
7 | "ex_doc": {:hex, :ex_doc, "0.12.0", "b774aabfede4af31c0301aece12371cbd25995a21bb3d71d66f5c2fe074c603f", [:mix], [{:earmark, "~> 0.2", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"},
8 | "mime": {:hex, :mime, "1.2.0", "78adaa84832b3680de06f88f0997e3ead3b451a440d183d688085be2d709b534", [], [], "hexpm"},
9 | "pbkdf2_elixir": {:hex, :pbkdf2_elixir, "0.12.3", "6706a148809a29c306062862c803406e88f048277f6e85b68faf73291e820b84", [:mix], [], "hexpm"},
10 | "plug": {:hex, :plug, "1.4.3", "236d77ce7bf3e3a2668dc0d32a9b6f1f9b1f05361019946aae49874904be4aed", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1", [hex: :cowboy, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}], "hexpm"},
11 | "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], [], "hexpm"},
12 | "postgrex": {:hex, :postgrex, "0.13.3", "c277cfb2a9c5034d445a722494c13359e361d344ef6f25d604c2353185682bfc", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 1.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm"}}
13 |
--------------------------------------------------------------------------------
/test/service_test.exs:
--------------------------------------------------------------------------------
1 | defmodule AuthsenseServiceTest do
2 | use ExUnit.Case, async: true
3 | doctest Authsense.Service
4 | alias Authsense.Test.User
5 | alias Authsense.Service
6 | import Ecto.Changeset, only: [change: 2]
7 | import Ecto.Query
8 |
9 | setup do
10 | Application.delete_env :authsense, :included_applications
11 | :ok
12 | end
13 |
14 | test "generate_hashed_password success" do
15 | changeset =
16 | %User{}
17 | |> change(%{email: "rico@gmail.com", password: "foobar"})
18 | |> Service.generate_hashed_password()
19 |
20 | assert changeset.changes.hashed_password
21 | |> String.starts_with?("$pbkdf2-sha512$")
22 | end
23 |
24 | test "generate_hashed_password failure" do
25 | changeset =
26 | %User{}
27 | |> change(%{email: "rico@gmail.com"})
28 | |> Service.generate_hashed_password()
29 |
30 | refute Map.has_key? changeset.changes, :hashed_password
31 | end
32 |
33 | test "authenticate via changeset" do
34 | assert {:ok, _user} = %User{}
35 | |> change(%{email: "rico@gmail.com", password: "foobar"})
36 | |> Service.authenticate()
37 | end
38 |
39 | test "authenticate via changeset failure" do
40 | {:error, changeset} = %User{}
41 | |> change(%{email: "rico@gmail.com", password: "nope"})
42 | |> Service.authenticate()
43 |
44 | assert changeset.errors == [password: {"Invalid credentials.", []}]
45 | end
46 |
47 | test "authenticate via password" do
48 | assert {:error, nil} == Service.authenticate({"rico@gmail.com", "nope"})
49 | end
50 |
51 | test "get_user" do
52 | assert Service.get_user("rico@gmail.com").email == "rico@gmail.com"
53 | end
54 |
55 | test "get_user failure" do
56 | assert Service.get_user("nobody@gmail.com") == nil
57 | end
58 |
59 | test "authenticate with Ecto.Queryable scope and retrieve correctly" do
60 | unicorns = from u in User, where: u.extra_field == "unicorn"
61 |
62 | assert {:ok, _user} = %User{}
63 | |> change(%{email: "rico@gmail.com", password: "foobar"})
64 | |> Service.authenticate(scope: unicorns, model: User)
65 | end
66 |
67 | test "authenticate non Ecto.Queryable or lambda scope" do
68 | invalid_scope = "Not a valid scope"
69 |
70 | assert_raise Authsense.InvalidScopeException, fn ->
71 | %User{}
72 | |> change(%{email: "rico@gmail.com", password: "foobar"})
73 | |> Service.authenticate(scope: invalid_scope, model: User)
74 | end
75 | end
76 |
77 | test "authenticate with lambda scope that returns Ecto.Queryable and retrieve correctly" do
78 | get_unicorns_query = fn ->
79 | from u in User, where: u.extra_field == "unicorn"
80 | end
81 |
82 | assert {:ok, _user} = %User{}
83 | |> change(%{email: "rico@gmail.com", password: "foobar"})
84 | |> Service.authenticate(scope: get_unicorns_query, model: User)
85 | end
86 |
87 | test "authenticate with invalid lambda scope" do
88 | invalid_lambda = fn -> "Not an Ecto Query return value" end
89 |
90 | assert_raise Authsense.InvalidScopeException, fn ->
91 | %User{}
92 | |> change(%{email: "rico@gmail.com", password: "foobar"})
93 | |> Service.authenticate(scope: invalid_lambda, model: User)
94 | end
95 | end
96 | end
97 |
--------------------------------------------------------------------------------
/docs/recipes.md:
--------------------------------------------------------------------------------
1 | # Recipes
2 |
3 | ## User model
4 |
5 | Aside from the obvious `:email` and `:hashed_password`, you should have
6 | `:password` and `:password_confirmation` _virtual_ fields for users. This
7 | allows you to have your forms ask users for their `:password`.
8 |
9 | ```elixir
10 | schema "users" do
11 | field :email, :string
12 | field :hashed_password, :string
13 | field :password, :string, virtual: true
14 | field :password_confirmation, :string, virtual: true
15 |
16 | timestamps
17 | end
18 | ```
19 |
20 | Use `Authsense.Serviec.generate_hashed_password/2` for their changesets. This
21 | way, when updating or creating users, any new `:password` fields will be hashed
22 | into `:hashed_password`.
23 |
24 | ```elixir
25 | # ecto 2.0
26 | def changeset(model, params \\ []) do
27 | model
28 | |> cast(params, [:email, :password, :password_confirmation])
29 | |> Authsense.Service.generate_hashed_password()
30 | |> validate_confirmation(:password, message: "password confirmation doesn't match")
31 | |> unique_constraint(:email)
32 | end
33 | ```
34 |
35 | ## Login page
36 |
37 | I typically like having an `SessionController` handle logins and logouts.
38 |
39 | ```elixir
40 | # web/router.ex
41 | get "/login", SessionController, :new
42 | post "/login", SessionController, :create
43 | get "/logout", SessionController, :delete
44 | ```
45 |
46 | `SessionController.new` gets you a form. Use a changeset here.
47 |
48 | ```elixir
49 | # web/controllers/session_controller.ex
50 |
51 | def new(conn, params) do
52 | changeset = User.changeset(%User{})
53 | render(conn, "new.html", changeset: changeset)
54 | end
55 | ```
56 |
57 | `SessionController.create` logs someone in (creates a session) using `Authsense.Plug.put_current_user/2`.
58 |
59 | ```elixir
60 | def create(conn, %{"user" => user_params}) do
61 | changeset = User.changeset(%User{}, user_params)
62 |
63 | case Auth.authenticate(changeset) do
64 | {:ok, user} ->
65 | conn
66 | |> Auth.put_current_user(user)
67 | |> put_flash(:info, "Welcome.")
68 | |> redirect(to: "/")
69 | {:error, changeset} ->
70 | render(conn, "new.html", changeset: changeset)
71 | end
72 | end
73 | ```
74 |
75 | `sessionController.delete` logs you out using `Authsense.Plug.put_current_user/2`.
76 |
77 | ```elixir
78 | def logout(conn, _params) do
79 | conn
80 | |> Authsense.Plug.put_current_user(nil)
81 | |> put_flash(:info, "You've been logged out.")
82 | |> redirect(to: "/")
83 | end
84 | ```
85 |
86 | ## Register/sign up
87 |
88 | This is just a simple `create` action for users.
89 |
90 | ## Secure pages
91 |
92 | _(To be documented)_
93 |
94 | ## Token-based authentication
95 |
96 | You can implement your own version of `Authsense.Plug.fetch_current_user/2` to
97 | authenticate based on something else other than passwords.
98 |
99 | ```elixir
100 | def authenticate_by_token(conn, _opts \\ []) do
101 | token = conn.params.token
102 | case Repo.get_by(User, api_token: token) do
103 | user ->
104 | assign(conn, :current_user, user)
105 | _ ->
106 | conn
107 | end
108 | end
109 | ```
110 |
111 | ```elixir
112 | # web/router.ex
113 | pipeline :api do
114 | plug :authenticate_by_token
115 | end
116 | ```
117 |
118 | ## Forgot your password
119 |
120 | You'll need to create 4 actions: one for the "forgot your password" page, one
121 | for the "reset your password" page, and one submission action for each of those.
122 |
123 | You'll also need a `:perishable_token` in your User model.
124 |
125 | ### GET /forgot_password
126 |
127 | - Show the "enter your email" form.
128 |
129 | ### POST /forgot_password
130 |
131 | - Update the user's perishable token.
132 |
133 | user
134 | |> change(:perishable_token, Ecto.UUID.generate)
135 | |> Repo.update()
136 |
137 | - Send an email to the user with a link to `/update_password?token=...`.
138 |
139 | ### GET /update_password?token=...
140 |
141 | - Find the user with the given token.
142 |
143 | Repo.get_by(User, perishable_token: token)
144 |
145 | - Show the "enter your new password" form.
146 |
147 | ### POST /update_password?token=...
148 |
149 | - Find the user with the given token.
150 | - Update their password and clear their perishable token.
151 |
152 | user
153 | |> User.changeset(user_params)
154 | |> change(:perishable_token, nil)
155 | |> Repo.update()
156 |
--------------------------------------------------------------------------------
/lib/authsense.ex:
--------------------------------------------------------------------------------
1 | defmodule Authsense do
2 | @moduledoc """
3 | Sensible authentication helpers for Phoenix/Ecto.
4 |
5 | ### Basic use
6 | Specify your configuration via Mix.Config in `config/config.exs`.
7 |
8 | config :authsense, Myapp.Model,
9 | repo: Myapp.Repo
10 |
11 | ## Authentication
12 | `Authsense.Service.authenticate/2` will validate a login.
13 |
14 | authenticate(changeset)
15 | #=> {:ok, user} or
16 | # {:error, changeset_with_errors}
17 |
18 | authenticate({ "userid", "password" })
19 | #=> {:ok, user} or {:error, nil}
20 |
21 | ## Logging in/out
22 | `Authsense.Plug.put_current_user/2` will set session variables for logging in or out.
23 |
24 | conn |> put_current_user(user) # login
25 | conn |> put_current_user(nil) # logout
26 |
27 | ## Get current user
28 | `Authsense.Plug.fetch_current_user/2` - to get authentication data, use `Auth` as a plug:
29 |
30 | # controller
31 | import Authsense.Plug
32 | plug :fetch_current_user
33 |
34 | When using this plug, you can then get the current user:
35 |
36 | conn.assigns.current_user #=> %User{} | nil
37 |
38 | ## Usage in models
39 | `Authsense.Service.generate_hashed_password/2` will update `:hashed_password` in a user changeset.
40 |
41 | User.changeset(...)
42 | |> generate_hashed_password()
43 |
44 | ## Configuration
45 | Specify your configuration via Mix.Config in `config/config.exs`.
46 |
47 | config :authsense, Myapp.User,
48 | repo: Myapp.Repo
49 |
50 | If you have more than one user, you can specify multiple configurations. (See `config/1` on info on how this is dealt with)
51 |
52 | config :authsense, Myapp.User,
53 | repo: Myapp.Repo
54 |
55 | config :authsense, Myapp.AdminUser,
56 | repo: Myapp.Repo
57 |
58 | These keys are available:
59 |
60 | - `repo` (required) - the Ecto repo to connect to.
61 | - `model` (required) - the user model to use.
62 | - `crypto` - the crypto module to use. (default: `Comeonin.Pbkdf2`)
63 | - `identity_field` - field that identifies the user. (default: `:email`)
64 | - `password_field` - virtual field that has the plaintext password. (default: `:password`)
65 | - `hashed_password_field` - field where the password is stored. (default: `:hashed_password`)
66 | - `login_error` - the error to add to the changeset on `Authsense.Service.authenticate/2`. (default: *"Invalid credentials."*)
67 |
68 | `config/1` has more info on how config is used.
69 |
70 | ## Recipes
71 |
72 | For information on how to build login pages, secure your website, and other
73 | things: see [Recipes](recipes.html).
74 | """
75 |
76 | if Application.get_all_env(:authsense) == [] do
77 | raise Authsense.UnconfiguredException
78 | end
79 |
80 | @doc false
81 | @defaults %{
82 | crypto: Comeonin.Pbkdf2,
83 | identity_field: :email,
84 | password_field: :password,
85 | hashed_password_field: :hashed_password,
86 | login_error: "Invalid credentials.",
87 | repo: nil,
88 | model: nil
89 | }
90 |
91 | @doc """
92 | _Internal:_ Retrieves configuration for a given model.
93 |
94 | ## Practical use
95 |
96 | This is used internally by other functions to retrieve your configuration. For example,
97 | you typically call `Authsense.Service.authenticate/2` with one parameter:
98 |
99 | authenticate({"rico@gmail.com", "password"})
100 |
101 | But you may pass options to it to override the config:
102 |
103 | authenticate({"rico@gmail.com", "password"},
104 | hashed_password_field: :hashed_password)
105 |
106 | Or if you provide multiple models to Authsense, you can pick which one to use:
107 |
108 | config :authsense,
109 | Myapp.User, repo: MyApp.Repo
110 |
111 | config :authsense,
112 | Myapp.AdminUser, repo: MyApp.Repo
113 |
114 | authenticate({"rico@gmail.com", "password"}, Myapp.User)
115 | authenticate({"rico@gmail.com", "password"}, Myapp.AdminUser)
116 |
117 | ## Internal use
118 |
119 | > Authsense.config
120 | %{ model: Example.User, repo: Example.Repo, ... }
121 |
122 | If there are multiple configurations, you can pass a `model`.
123 |
124 | > Authsense.config(Example.User)
125 | %{ model: Example.User, repo: Example.Repo, ... }
126 |
127 | You may also pass a list. Any values other than `model` will be added in.
128 |
129 | > Authsense.config(model: Example.User, foo: :bar)
130 | %{ model: Example.User, ... foo: :bar }
131 |
132 | """
133 | def config(opts \\ nil)
134 | def config([]), do: config(nil)
135 | def config(nil) do
136 | cond do
137 | length(all_config()) > 1 ->
138 | raise Authsense.MultipleResourcesException
139 |
140 | all_config() == [] -> # Runtime guard
141 | raise Authsense.UnconfiguredException
142 |
143 | true ->
144 | [{model, _opts}] = all_config()
145 | merge_with_defaults(all_config(), model)
146 | end
147 | end
148 |
149 | def config(opts) when is_list(opts),
150 | do: Enum.into(opts, merge_with_defaults(all_config(), opts[:model]))
151 |
152 | def config(model) when is_atom(model),
153 | do: merge_with_defaults(all_config(), model)
154 |
155 | defp all_config, do: Application.get_all_env(:authsense)
156 |
157 | # Runtime guard
158 | defp merge_with_defaults(_opts, nil),
159 | do: raise Authsense.UnconfiguredException
160 |
161 | defp merge_with_defaults(opts, model) do
162 | opts
163 | |> Keyword.get(model, [])
164 | |> Enum.into(%{model: model})
165 | |> Enum.into(@defaults)
166 | end
167 | end
168 |
--------------------------------------------------------------------------------
/lib/authsense/service.ex:
--------------------------------------------------------------------------------
1 | defmodule Authsense.Service do
2 | @moduledoc """
3 | Functions for working with models or changesets.
4 | """
5 |
6 | import Ecto.Changeset, only:
7 | [get_change: 2, put_change: 3, validate_change: 3]
8 |
9 | alias Ecto.Changeset
10 |
11 | @doc """
12 | Checks if someone can authenticate with a given username/password pair.
13 |
14 | Credentials can be given as either an Ecto changeset or a tuple.
15 |
16 | # Changeset:
17 | %User{}
18 | |> change(%{ email: "rico@gmail.com", password: "password" })
19 | |> authenticate()
20 |
21 | # Tuple:
22 | authenticate({ "rico@gmail.com", "password" })
23 |
24 | Returns `{:ok, user}` on success, or `{:error, changeset}` on failure. If
25 | used as a tuple, it returns `{:error, nil}` on failure.
26 |
27 | Typically used within a login action.
28 |
29 | def login_create(conn, %{"user" => user_params}) do
30 | changeset = User.changeset(%User{}, user_params)
31 |
32 | case authenticate(changeset) do
33 | {:ok, user} ->
34 | conn
35 | |> Auth.put_current_user(user)
36 | |> put_flash(:info, "Welcome.")
37 | |> redirect(to: "/")
38 |
39 | {:error, changeset} ->
40 | render(conn, "login.html", changeset: changeset)
41 | end
42 | end
43 |
44 | It's also possible to add opts as a second parameter, which may contain a keyword scope.
45 | Scope can be lambda that returns an `Ecto.Queryable`, an `Ecto.Query`, or an `Ecto.Queryable`
46 | This will override the model with a prepared queryable.
47 |
48 | %User{}
49 | |> change(%{ email: "rico@gmail.com", password: "password})
50 | |> authenticate([scope: User |> where(:field_for_filtering, ^somevar))
51 | """
52 |
53 | def authenticate(changeset_or_tuple, opts \\ [])
54 | def authenticate(changeset_or_tuple, model) when is_atom(model), do: authenticate(changeset_or_tuple, model: model)
55 | def authenticate(credentials, opts) do
56 | model = Keyword.get(opts, :model)
57 | case authenticate_user(credentials, opts) do
58 | false -> {:error, auth_failure(credentials, model)}
59 | user -> {:ok, user}
60 | end
61 | end
62 |
63 | @doc """
64 | Returns the user associated with these credentials. Returns the User record
65 | on success, or `false` on error.
66 |
67 | Accepts both `{ email, password }` tuples and `Ecto.Changeset`s.
68 |
69 | authenticate_user(changeset)
70 | authenticate_user({ email, password })
71 | """
72 |
73 | def authenticate_user(changeset_or_tuple, opts \\ [])
74 | def authenticate_user(changeset_or_tuple, model) when is_atom(model), do: authenticate_user(changeset_or_tuple, model: model)
75 | def authenticate_user(%Changeset{} = changeset, opts) do
76 | %{identity_field: id, password_field: passwd} =
77 | Authsense.config(Keyword.get(opts, :model))
78 |
79 | email = get_change(changeset, id)
80 | password = get_change(changeset, passwd)
81 | authenticate_user({email, password}, opts)
82 | end
83 |
84 | def authenticate_user({email, password}, opts) do
85 | %{crypto: crypto, hashed_password_field: hashed_passwd} =
86 | Authsense.config(Keyword.get(opts, :model))
87 |
88 | user = get_user(email, opts)
89 |
90 | if user do
91 | crypto.checkpw(password, Map.get(user, hashed_passwd)) && user
92 | else
93 | crypto.dummy_checkpw
94 | end
95 | end
96 |
97 | @doc """
98 | Loads a user by a given identity field value. Returns a nil on failure.
99 |
100 | get_user("rico@gmail.com") #=> %User{...}
101 | """
102 |
103 | def get_user(email, opts \\ [])
104 | def get_user(email, [scope: scope, model: model]) do
105 | %{repo: repo, identity_field: id} = Authsense.config(model)
106 |
107 | scoped_model = case validate_scope(scope) do
108 | {:ok, final_model} -> final_model
109 | {:error, error} -> raise Authsense.InvalidScopeException, error
110 | end
111 |
112 | repo.get_by(scoped_model, [{id, email}])
113 | end
114 |
115 | def get_user(email, opts) do
116 | %{repo: repo, model: model, identity_field: id} =
117 | Authsense.config(Keyword.get(opts, :model))
118 |
119 | repo.get_by(model, [{id, email}])
120 | end
121 |
122 | @doc """
123 | Updates an `Ecto.Changeset` to generate a hashed password.
124 |
125 | If the changeset has `:password` in it, it will be hashed and stored as
126 | `:hashed_password`. (Fields can be configured in `Authsense`.)
127 |
128 | changeset
129 | |> generate_hashed_password()
130 |
131 | It's typically used in a model's `changeset/2` function.
132 |
133 | defmodule Example.User do
134 | use Example.Web, :model
135 |
136 | def changeset(model, params \\ []) do
137 | model
138 | |> cast(params, [:email, :password, :password_confirmation])
139 | |> generate_hashed_password()
140 | |> validate_confirmation(:password, message: "password confirmation doesn't match")
141 | |> unique_constraint(:email)
142 | end
143 | end
144 | """
145 | def generate_hashed_password(%Changeset{} = changeset, model \\ nil) do
146 | %{password_field: passwd, hashed_password_field: hashed_passwd,
147 | crypto: crypto} = Authsense.config(model)
148 |
149 | case get_change(changeset, passwd) do
150 | nil ->
151 | changeset
152 | password ->
153 | changeset
154 | |> put_change(hashed_passwd, crypto.hashpwsalt(password))
155 | end
156 | end
157 |
158 | # Adds errors to a changeset.
159 | # Used by `authenticate/2`.
160 | defp auth_failure(%Changeset{} = changeset, model) do
161 | %{password_field: passwd, login_error: login_error} =
162 | Authsense.config(model)
163 |
164 | changeset
165 | |> validate_change(passwd, fn _, _ -> [{passwd, login_error}] end)
166 | end
167 |
168 | defp auth_failure(_opts, _), do: nil
169 |
170 | defp validate_scope(scope) when is_function(scope) do
171 | final_model = scope.()
172 | case final_model do
173 | %Ecto.Query{} -> {:ok, final_model}
174 | _ -> {:error, "The scope lambda's return value should be of type Ecto.Query"}
175 | end
176 | end
177 |
178 | defp validate_scope(%Ecto.Query{} = scope), do: {:ok, scope}
179 | defp validate_scope(_scope), do: {:error, "The scope should be of type Ecto.Query"}
180 | end
181 |
--------------------------------------------------------------------------------