├── 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 | [![Status](https://travis-ci.org/rstacruz/authsense.svg?branch=master)](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 | --------------------------------------------------------------------------------