Skip to content

Commit

Permalink
Downsize!
Browse files Browse the repository at this point in the history
  • Loading branch information
Ray Zane committed Oct 31, 2017
1 parent ce4f313 commit 8232b52
Show file tree
Hide file tree
Showing 9 changed files with 117 additions and 267 deletions.
213 changes: 39 additions & 174 deletions README.md
@@ -1,213 +1,78 @@
# SansPassword

A simple, passwordless authentication system based on [Guardian](https://github.com/ueberauth/guardian).
Passwordless authentication helpers for [Guardian](https://github.com/ueberauth/guardian).

SansPassword supports two different authentication flows:

+ _Login_ - When a user enters their email address, if their account exists, they'll be sent an email containing a link to login.
+ _Register_ - When a user enters their email address, if their account does not exist, they'll be sent an email containing a link. When they click the link, an account will be created using the provided email address, and they'll be signed in.

See the source code for the demo app [here](https://github.com/promptworks/sans_password_demo).
Take a look at the [demo app](https://github.com/promptworks/sans_password_demo).

## Installation

1. Add `sans_password` to your list of dependencies in `mix.exs`:
Add `sans_password` to your list of dependencies in `mix.exs`:

```elixir
def deps do
[{:sans_password, "~> 0.1.0"}]
end
```

2. Ensure bamboo is started before your application:

```elixir
def application do
[applications: [:sans_password]]
[{:sans_password, "~> 1.0.0"}]
end
```

## Usage
Next, follow Guardian's [installation instructions](https://github.com/ueberauth/guardian#installation) to setup a Guardian module.

First, you'll need to configure `sans_password` and `guardian`. A minimal configuration looks like this:
Now, you should have a `Guardian` module, so you can sprinkle in `SansPassword`.

```elixir
config :sans_password, SansPassword,
repo: MyApp.Repo,
schema: MyApp.User,
mailer: SansPassword.Adapters.Bamboo

config :sans_password, SansPassword.Adapters.Bamboo,
emails: MyApp.Emails,
mailer: MyApp.Mailer

config :guardian, Guardian,
issuer: "MyApp",
ttl: {30, :days},
secret_key: "super secret key!",
serializer: SansPassword.Serializer
```
defmodule MyApp.Guardian do
use Guardian, otp_app: :my_app
use SansPassword
You'll want to look at [Guardian's documentation](https://github.com/ueberauth/guardian) for all of it's configuration options.

The configuration above uses Bamboo for emails, but you could very easily implement your own adapter.

### Controllers/Views

SansPassword includes a macro for creating a controller. You'll need to tell it which view to use to render templates, as well as which module to use for hooks.

`SansPassword.Hooks` brings in a behaviour that will tell you which functions need to be implemented.

```elixir
# web/views/session_controller.ex
defmodule MyApp.SessionController do
use MyApp.Web, :controller
use SansPassword.Hooks
use Passwordles.Controller, view: MyApp.SessionView, hooks: __MODULE__
...
# SansPassword.Hooks requires that you implement the following functions:

def after_invite_path(conn, _params), do: session_path(conn, :new)
def after_invite_failed_path(conn, _params), do: session_path(conn, :new)

def after_login_path(conn, _params), do: page_path(conn, :index)
def after_login_failed_path(conn, _params), do: session_path(conn, :new)

def after_logout_path(conn, _parms), do: session_path(conn, :new)
end
```

You'll just need to create a view:

```elixir
# web/views/session_view.ex
defmodule MyApp.SessionView do
use MyApp.Web, :view
@impl true
def deliver_magic_link(user, magic_token) do
user
|> MyMailer.magic_link_email(magic_token)
|> MyMailer.deliver
end
end
```

Then, create a template for the login form:
`SansPassword` uses two token types:

```eex
<!-- web/templates/new.html.eex -->
<%= form_for @conn, session_path(@conn, :create), [as: :session], fn f -> %>
<div class="form-group">
<label for="session[email]" class="control-label">Email</label>
<%= text_input f, :email, class: "form-control" %>
</div>
<%= submit "Submit", class: "btn btn-primary" %>
<% end %>
```
* `magic` - This token will be sent to your users in an email. They are very short-lived and will be sent to your users via email. This token will be included in the "magic link" to your app.
* `access` - This token will allow your users to request content from your app.

### Routing

We'll need to add Guardian's plugs to our routes, and declare routes for our new session controller.
This part is VERY important, you need to configure the TTLs for the token types that `SansPassword` uses:

```elixir
# web/router.ex
pipeline :browser do
# ...
end

pipeline :browser_session do
plug Guardian.Plug.VerifySession
plug Guardian.Plug.LoadResource
end

pipeline :require_auth do
plug Guardian.Plug.EnsureAuthenticated
end

# Unauthenticated routes go here
scope "/", MyApp do
pipe_through [:browser, :browser_session]

get "/login", SessionController, :new
post "/login", SessionController, :create
get "/login/callback", SessionController, :callback
end

# Authenticated routes go here
scope "/", MyApp do
pipe_through [:browser, :browser_session, :require_auth]

get "/logout", SessionController, :delete
end
config :guardian, MyApp.Guardian,
secret_key: "super secret key",
issuer: "my_app",
token_ttl: %{
"magic" => {30, :minutes},
"access" => {1, :days}
}
```

### Mailers
## Usage

Here's an example Emails module using Bamboo:
SansPassword provides just a few convenience functions.

```elixir
# web/emails.ex
defmodule MyApp.Emails do
import Bamboo.Email
use Bamboo.Phoenix, view: MyApp.EmailView

@from "admin@myapp.com"

def login(user, params) do
new_email
|> from(@from)
|> to(user.email)
|> subject("Login to MyApp")
|> assign(:user, user)
|> assign(:params, params)
|> render(:login)
end
# We'll assume you have a user record
{:ok, user} = Repo.insert(%User{email: "user@example.com"})

def register(email, params) do
new_email
|> from(@from)
|> to(email)
|> subject("Register with MyApp")
|> assign(:params, params)
|> render(:register)
end
end
```
# Send a login link that will allow the user to login
{:ok, magic_token, _claims} = MyApp.Guardian.send_magic_link(user)

When rendering the email template, all that matters is that you include the login link like so:
# If you're building an API, you'll probably want to convert the magic token to an access token
{:ok, access_token, _claims} = MyApp.Guardian.exchange_magic(magic_token)

```eex
<%= link "Click here to login", to: session_url(MyApp.Endpoint, :callback, @params) %>
# If you're storing sessions, you can sign your users in like this
{:ok, user, _claims} = MyApp.Guardian.decode_magic(magic_token)
MyApp.Guardian.Plug.sign_in(user)
```

### Trackable (optional)

Add the required fields in a migration:

```elixir
alter table(:users) do
add :sign_in_count, :integer, default: 0
add :last_sign_in_ip, :string
add :last_sign_in_at, :datetime
add :current_sign_in_ip, :string
add :current_sign_in_at, :datetime
end
```

Make some minor tweaks to your model:

```elixir
defmodule MyApp.User do
use SansPassword.Schema

schema "users" do
# ...
trackable_fields()
end
end
```

Configure Guardian to use `SansPassword.Trackable` module:

```elixir
config :guardian, Guardian,
hooks: SansPassword.Trackable
```
To track your users sessions, see [guardian_track](https://github.com/promptworks/guardian_track).

### Accessing the current user

Expand Down
13 changes: 6 additions & 7 deletions config/config.exs
Expand Up @@ -5,13 +5,12 @@ use Mix.Config
config :logger, level: :warn

config :sans_password, SansPassword.Dummy.Guardian,
secret_key: "asdklfasjfkladsjfaklsjfaksdfjkaslfjakslfjdklasjfdklsajfkalsjf"

config :sans_password, SansPassword.Dummy.Repo,
adapter: Ecto.Adapters.Postgres,
username: "postgres",
password: "postgres",
database: "sans_password_test"
secret_key: "asdklfasjfkladsjfaklsjfaksdfjkaslfjakslfjdklasjfdklsajfkalsjf",
issuer: "sans_password",
token_ttl: %{
"login" => {30, :minutes},
"access" => {1, :days}
}

# This configuration is loaded before any dependency and is restricted
# to this project. If another project depends on this project, this
Expand Down
42 changes: 27 additions & 15 deletions lib/sans_password.ex
@@ -1,23 +1,23 @@
defmodule SansPassword do
@moduledoc """
A passwordless authentication system based on Guardian.
Passwordless authentication helpers for Guardian.
"""

@login "login"
@magic "magic"
@access "access"

@callback resource_from_params(params :: map) :: {:ok, any} | {:error, atom}
@callback deliver_magic_link(resource :: any, magic_token :: String.t) :: any

defmacro __using__(_) do
quote do
@behaviour SansPassword

def encode_login(resource, claims \\ %{}) do
SansPassword.encode_login(__MODULE__, resource, claims)
def encode_magic(resource, claims \\ %{}) do
SansPassword.encode_magic(__MODULE__, resource, claims)
end

def decode_login(login_token, claims \\ %{}) do
SansPassword.decode_login(__MODULE__, login_token, claims)
def decode_magic(magic_token, claims \\ %{}) do
SansPassword.decode_magic(__MODULE__, magic_token, claims)
end

def encode_access(resource, claims \\ %{}) do
Expand All @@ -28,18 +28,22 @@ defmodule SansPassword do
SansPassword.decode_access(__MODULE__, access_token, claims)
end

def exchange_login(login_token) do
SansPassword.exchange_login(__MODULE__, login_token)
def exchange_magic(magic_token) do
SansPassword.exchange_magic(__MODULE__, magic_token)
end

def send_magic_link(resource, claims \\ %{}) do
SansPassword.send_magic_link(__MODULE__, resource, claims)
end
end
end

def encode_login(guardian, resource, claims \\ %{}) do
guardian.encode_and_sign(resource, claims, token_type: @login)
def encode_magic(guardian, resource, claims \\ %{}) do
guardian.encode_and_sign(resource, claims, token_type: @magic)
end

def decode_login(guardian, login_token, claims \\ %{}) do
guardian.resource_from_token(login_token, claims, token_type: @login)
def decode_magic(guardian, magic_token, claims \\ %{}) do
guardian.resource_from_token(magic_token, claims, token_type: @magic)
end

def encode_access(guardian, resource, claims \\ %{}) do
Expand All @@ -50,9 +54,17 @@ defmodule SansPassword do
guardian.resource_from_token(access_token, claims, token_type: @access)
end

def exchange_login(guardian, login_token) do
with {:ok, _, {token, claims}} <- guardian.exchange(login_token, @login, @access) do
def exchange_magic(guardian, magic_token) do
with {:ok, _, {token, claims}} <- guardian.exchange(magic_token, @magic, @access) do
{:ok, token, claims}
end
end

def send_magic_link(guardian, resource, claims \\ %{}) do
with {:ok, magic_token, claims} <- guardian.encode_magic(resource, claims),
:ok <- guardian.deliver_magic_link(resource, magic_token)
do
{:ok, magic_token, claims}
end
end
end
12 changes: 7 additions & 5 deletions mix.exs
Expand Up @@ -10,7 +10,8 @@ defmodule SansPassword.Mixfile do
build_embedded: Mix.env == :prod,
start_permanent: Mix.env == :prod,
package: package(),
deps: deps()
deps: deps(),
aliases: aliases()
]
end

Expand All @@ -22,7 +23,7 @@ defmodule SansPassword.Mixfile do
defp package do
[
name: :sans_password,
description: "A passwordless authentication system based on Guardian.",
description: "Passwordless authentication utilities based on Guardian.",
files: ["lib", "mix.exs", "README.md", "LICENSE.txt"],
maintainers: ["Ray Zane"],
licenses: ["MIT"],
Expand All @@ -33,10 +34,11 @@ defmodule SansPassword.Mixfile do
defp deps do
[
{:guardian, "~> 1.0-beta"},
{:ecto, "~> 2.1", optional: true},
{:postgrex, ">= 0.0.0", optional: true},
{:plug, "~> 1.0", optional: true},
{:ex_doc, ">= 0.0.0", only: :dev}
]
end

def aliases do
["test.setup": ["ecto.drop", "ecto.create", "ecto.migrate"]]
end
end

0 comments on commit 8232b52

Please sign in to comment.