这是indexloc提供的服务,不要输入任何密码
Skip to content

Conversation

@danielberkompas
Copy link
Contributor

@danielberkompas danielberkompas commented Dec 14, 2017

I reworked Authenticate to rely on internal callbacks rather than an
external "store" module. This feels more natural to me, because it's
more like a GenServer. The distinction between the authentication module
and the store felt arbitrary.

My goal is to get the callback system right, because that's what you'll
need to use whenever you want to do something custom.

After that, we can examine if we need to add an additional layer on top
for common configurations.

Here's an example of how you might implement an Accounts domain which
supports email/password and tokenization.

defmodule MyApp.Accounts do
  alias MyApp.Accounts.{
    Authentication,
    Tokenization
  }

  defdelegate authenticate(credential, purpose \\ :any), to: Authentication
  defdelegate tokenize(credential, purpose \\ :any), to: Tokenization
end

defmodule MyApp.Accounts.Authentication do
  use Authority.Authentication

  alias MyApp.{
    Accounts.User,
    Accounts.Token,
    Repo
  }

  # Refresh the token from the `token` attribute, so that you
  # don't have to pass the full token
  def before_identify(%Token{token: token}) do
    token =
      Token
      |> Repo.get_by(token: token)
      |> Repo.preload(:user)

    case token do
      nil -> {:error, :invalid_token}
      token -> {:ok, token}
    end
  end

  def before_identify(identifier), do: {:ok, identifier}

  def identify(%Token{user: %User{} = user}) do
    {:ok, user}
  end

  def identify(email) do
    case Repo.get_by(User, email: email) do
      nil -> {:error, :invalid_email}
      user -> {:ok, user}
    end
  end

  def validate(%Token{expires_at: expires_at, purpose: token_purpose}, _user, purpose)
        when token_purpose == :any or token_purpose == purpose do
    if DateTime.compare(DateTime.utc_now(), expires_at) == :lt do
      :ok
    else
      {:error, :expired_token}
    end
  end

  def validate(%Token{}, _user, _purpose) do
    {:error, :invalid_token_for_purpose}
  end

  def validate(password, user, _purpose) do
    case Comeonin.Bcrypt.checkpw(password, user.encrypted_password) do
      true -> :ok
      false -> {:error, :invalid_password}
    end
  end
end

defmodule MyApp.Accounts.Tokenization do
  use Authority.Tokenization

  alias MyApp.Accounts.Authentication

  def tokenize({email, password}, purpose) do
    with {:ok, user} <- Authentication.authenticate({email, password}, purpose) do
      do_tokenize(user, purpose)
    end
  end

  def tokenize(email, :recovery) do
    with {:ok, user} <- Authentication.identify(email) do
      do_tokenize(user, purpose)
    end
  end

  def tokenize(_other, _purpose) do
    {:error, :invalid_credential_for_purpose}
  end

  defp do_tokenize(user, purpose) do
    %Token{user: user}
    |> Token.insert_changeset(%{purpose: purpose})
    |> Repo.insert()
  end
end

As you can see, it isn't a lot of code. Perhaps we could do a few projects this
way, and then see if we feel like we really need an extra layer on top
for common configurations.

I reworked `Authenticate` to rely on internal callbacks rather than an
external "store" module. This feels more natural to me, because it's
more like a GenServer. The distinction between the authentication module
and the store felt arbitrary.

My goal is to get the callback system right, because that's what you'll
need to use whenever you want to do something custom.

After that, we can examine if we need to add an additional layer on top
for common configurations.

Here's an example of how you might implement an `Accounts` domain which
supports email/password and tokenization.

```elixir
defmodule MyApp.Accounts do
  alias MyApp.Accounts.{
    Authentication,
    Tokenization
  }

  defdelegate authenticate(credential, purpose \\ :any), to: Authentication
  defdelegate tokenize(credential, purpose \\ :any), to: Tokenization
end

defmodule MyApp.Accounts.Authentication do
  use Authority.Authentication

  alias MyApp.{
    Accounts.User,
    Accounts.Token,
    Repo
  }

  # Refresh the token from the `token` attribute, so that you
  # don't have to pass the full token
  def before_lookup(%Token{token: token}) do
    token =
      Token
      |> Repo.get_by(token: token)
      |> Repo.preload(:user)

    case token do
      nil -> {:error, :invalid_token}
      token -> {:ok, token}
    end
  end

  def before_lookup(identifier), do: {:ok, identifier}

  def lookup(%Token{user: %User{} = user}) do
    {:ok, user}
  end

  def lookup(email) do
    case Repo.get_by(User, email: email) do
      nil -> {:error, :invalid_email}
      user -> {:ok, user}
    end
  end

  def validate(%Token{expires_at: expires_at, purpose: _purpose}, _user, _purpose) do
    if DateTime.compare(DateTime.utc_now(), expires_at) == :lt do
      :ok
    else
      {:error, :expired_token}
    end
  end

  def validate(%Token{}, _user, _purpose) do
    {:error, :invalid_token_for_purpose}
  end

  def validate(password, user, _purpose) do
    case Comeonin.Bcrypt.checkpw(password, user.encrypted_password) do
      true -> :ok
      false -> {:error, :invalid_password}
    end
  end
end

defmodule MyApp.Accounts.Tokenization do
  use Authority.Tokenization

  alias MyApp.Accounts.Authentication

  def tokenize({email, password}, purpose) do
    with {:ok, user} <- Authentication.authenticate({email, password}, purpose) do
      do_tokenize(user, purpose)
    end
  end

  def tokenize(email, :recovery) do
    do_tokenize(user, purpose)
  end

  def tokenize(_other, _purpose) do
    {:error, :invalid_credential_for_purpose}
  end

  defp do_tokenize(user, purpose) do
    %Token{user: user}
    |> Token.insert_changeset(%{purpose: purpose})
    |> Repo.insert()
  end
end
```

As you can see, it isn't a lot of code. Perhaps we could do a few projects this
way, and then see if we feel like we really need an extra layer on top
for common configurations.
@danielberkompas
Copy link
Contributor Author

I added some templates that show how I think Authority would work in most use cases. If you just need basic email/password authentication:

defmodule MyApp.Accounts do
  use Authority.Template.Authenticate,
    repo: MyApp.Repo,
    user_module: MyApp.Accounts.User
end

If you need tokenization:

defmodule MyApp.Accounts do
  use Authority.Template.AuthenticateTokenize,
    repo: MyApp.Repo,
    user_module: MyApp.Accounts.User,
    token_module: MyApp.Accounts.Token
end

There are more options you can pass in each example, but they have sensible defaults.

Ideally, I'd like for you not to specify exactly which template you are using, but rather which features (modules) you want. Something like:

defmodule MyApp.Accounts do
  use Authority.Template, 
    modules: [Authority.Authentication, Authority.Tokenization],
    config: %{
      repo: MyApp.Repo,
      # ...
    }
end

This would decide what template you need and inject it, passing on the configuration.

This demonstrates how we can easily make templates out of common use
cases which you can just drop into your projects.
@danielberkompas
Copy link
Contributor Author

danielberkompas commented Dec 15, 2017

I added Authority.Template. The above examples could now be done like so:

Email/Password Authentication

defmodule MyApp.Accounts do
  use Authority.Template,
    behaviours: [Authority.Authentication],
    config: [repo: MyApp.Repo, user_module: MyApp.Accounts.User]
end

Email/Password + Tokenization

defmodule MyApp.Accounts do
  use Authority.Template,
    behaviours: [Authority.Authentication, Authority.Tokenization],
    config: [
      repo: MyApp.Repo,
      user_module: MyApp.Accounts.User,
      token_module: MyApp.Accounts.Token
    ]
end

@darinwilson
Copy link
Member

@danielberkompas Thanks for adding the templates - that makes it easier to see everything in the context of an end user.

I think this looks good, and agree that it's ready to start using on actual projects. That will be the only way to know if we've got the design right.

@danielberkompas danielberkompas merged commit 8094112 into master Dec 15, 2017
@danielberkompas danielberkompas deleted the behaviourize branch December 16, 2017 00:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants