-
Notifications
You must be signed in to change notification settings - Fork 6
Add Exchange #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add Exchange #2
Conversation
This exchange module will be used to exchange one set of credentials for another, such as "user/password => token".
|
@danielberkompas I like this but the API seems a bit confusing to me. Perhaps we could create Also is context required to authenticate now? Shouldn't just having the token be enough? |
|
@ryanlntn I'm working on making the API a lot less confusing by making Context probably needs to be required for tokens, because a token might not be valid for everything. A |
|
@danielberkompas Right but does it need to be passed to |
This will massively simplify how you integrate `Exchange` if you are using `EctoStore`.
|
I've massively simplified the steps and now allow you to set a default context for 1. Add
|
You can add `skip_validation` patterns which will exclude a credential from validations. The patterns can either be a regex or an Elixir pattern like a struct. Also, they're limited to a specific context.
|
I think this last round of refactoring is an improvement, but after thinking about this overnight, I still find the API a little confusing. I tried to think through what I find confusing, and how I might improve it, and came up with the following laundry list. These are all just highly biased ramblings, so please feel free to take or leave any of it. First, I wonder if token management really needs to be in a separate module, and could instead be part of Also, as a user of the API, I'd prefer to see more descriptive function names, rather than a single I'm not sure we need to make the client think too much about context, other than calling the right function. If they pass a recovery token to This is a smaller point, but I'd also suggest renaming Again, I'm just kind of spitballing here. Feel free to ignore 😄 |
|
@darinwilson I agree that this still doesn't feel quite right. I think that before merging this, we should discuss what we want the public API of a full-featured I'll put this PR on hold and create a separate discussion issue for this. |
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.
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.
Authentication Draft #2
This exchange module will be used to exchange one set of credentials for
another, for example:
Here's what you would to to allow it to support password reset tokens (only good for password resets) and identity tokens.
1. Update Store Module
You update your existing store module to exchange users for tokens, and validate them.
2. Update Authentication Module
3. Update Accounts Domain
3. Use
exchange/2Now that everything is set up, you can easily exchange an email address for a recovery token:
You can also exchange email/password for an identity token:
These tokens can be passed into
authenticate:If the given context doesn't match the token's context, you'll get an error:
Caveats
You need to pass structs as arguments. If the mobile app sends you a token, you need to wrap it with a
Tokenstruct to prevent the authentication system from thinking it's an email address.This isn't quite as straightforward as I would like, but I'm sure we could build a layer on top of this (like
EctoStore) that would do all this for the most common use cases.