Packages

A grab bag of utilities I tend to want, so I can build faster.

Current section

Files

Jump to
fast lib auth auth.ex
Raw

lib/auth/auth.ex

defmodule Fast.Auth do
# -- Imports
# -- Contexts
# alias Fast.Auth.Token
@doc """
Available features:
- `:passwords`
- `:confirmable`
- `:invitable`
- `:magic_links`
- `:saml_sso`
- `:login_attempt_locking`
- `?`
Opts:
- `:schema` - The user account schema
- `:features` - A subset of features.
"""
@type login_method :: :password | :magic_link | :saml_sso
@callback handle_login_success(user :: any(), method :: login_method(), context :: map()) ::
any()
@callback handle_login_failure(user :: any(), method :: login_method(), context :: map()) ::
any()
@callback verify_login_allowed(user :: any(), method :: login_method(), context :: map()) ::
:ok | {:error, any()}
@optional_callbacks [
verify_login_allowed: 3,
handle_login_success: 3,
handle_login_failure: 3
]
@valid_features [
:passwords,
:confirmations,
:invitations,
:magic_links,
:saml_sso,
:login_attempt_locking
]
@password_fields [
:hashed_password,
:password_updated_at
]
@confirmable_fields [
:confirmation_token_created_at,
:confirmation_token_hash,
:confirmed_at
]
@invitable_fields [
:invitation_token_created_at,
:invitation_token_hash
]
@magic_link_fields [
:magic_link_token,
:magic_link_token_created_at
]
@saml_sso_fields [
:saml_idp_id,
:saml_idp_subject_id,
:saml_idp_username,
:sso_authenticated_nonce_created_at,
:sso_authenticated_nonce_hash
]
@login_attempt_locking_fields [
:locked_from_failed_login_attempts,
:locked_from_failed_login_attempts_at
]
defmodule MissingFieldsError do
defexception [:message]
end
defmodule InvalidFeaturesError do
defexception [:message]
end
defmacro __using__(opts) do
quote bind_quoted: [opts: opts] do
@behaviour Fast.Auth
@auth_schema Keyword.fetch!(opts, :schema)
@auth_features Keyword.fetch!(opts, :features)
@otp_app Keyword.fetch!(opts, :otp_app)
@repo Keyword.fetch!(opts, :repo)
@hashed_password_field Keyword.fetch!(opts, :hashed_password_field)
@guardian_module Keyword.fetch!(opts, :guardian_module)
@access_token_ttl Keyword.fetch!(opts, :access_token_ttl)
@refresh_token_ttl Keyword.fetch!(opts, :refresh_token_ttl)
Fast.Auth.ensure_valid_features!(__MODULE__, @auth_features)
def auth_features, do: @auth_features
def auth_schema, do: @auth_schema
def auth_otp_app, do: @otp_app
def auth_repo, do: @repo
def refresh_token(refresh_token, context) do
with {:ok, claims} <- @guardian_module.decode_and_verify(refresh_token),
{:ok, resource} <- @guardian_module.resource_from_claims(claims),
:ok <- verify_login_allowed(resource, :refresh_token, context) do
token_pair = generate_token_pair(resource)
{:ok, %{token_pair: token_pair, resource: @repo.reload(resource)}}
end
end
def revoke_all_tokens(resource) do
@guardian_module.revoke_all(resource)
end
def generate_token_pair(resource) do
%Fast.Auth.TokenPair{
access_token: generate_access_token(resource),
refresh_token: generate_refresh_token(resource)
}
end
def generate_access_token(resource) do
{:ok, access_token, _claims} =
@guardian_module.encode_and_sign(resource, %{},
token_type: "access",
ttl: @access_token_ttl
)
access_token
end
def generate_refresh_token(resource) do
{:ok, refresh_token, _claims} =
@guardian_module.encode_and_sign(resource, %{},
token_type: "refresh",
ttl: @refresh_token_ttl
)
refresh_token
end
if :passwords in @auth_features do
Fast.Auth.ensure_fields_present!(:passwords, @auth_schema)
def login_with_password(user, password, context) do
method = :password
with :ok <- verify_login_allowed(user, method, context),
{:ok, user} <- check_pass(user, password, hash_key: @hashed_password_field) do
handle_login_success(user, method, context)
token_pair = generate_token_pair(user)
{:ok, %{token_pair: token_pair, resource: @repo.reload(user)}}
else
error ->
if !is_nil(user) do
handle_login_failure(user, method, context)
end
case error do
{:error, "invalid password"} ->
{:error, :invalid_credentials}
error ->
Bcrypt.no_user_verify()
error
end
end
end
def check_user_password(user, password),
do: check_pass(user, password, hash_key: @hashed_password_field)
def check_pass(user, password, opts \\ [])
def check_pass(nil, _password, opts) do
unless opts[:hide_user] == false, do: Bcrypt.no_user_verify(opts)
{:error, "invalid user-identifier"}
end
def check_pass(user, password, opts) when is_binary(password) do
case get_hash(user, opts[:hash_key]) do
{:ok, hash} ->
if Bcrypt.verify_pass(password, hash),
do: {:ok, user},
else: {:error, "invalid password"}
_ ->
{:error, "no password hash found in the user struct"}
end
end
def check_pass(_, _, _) do
{:error, "password is not a string"}
end
defp get_hash(%{password_hash: hash}, nil), do: {:ok, hash}
defp get_hash(%{encrypted_password: hash}, nil), do: {:ok, hash}
defp get_hash(_, nil), do: nil
defp get_hash(user, hash_key) do
if hash = Map.get(user, hash_key), do: {:ok, hash}
end
end
if :confirmations in @auth_features do
Fast.Auth.ensure_fields_present!(:confirmations, @auth_schema)
# feature functions here...
end
if :invitations in @auth_features do
Fast.Auth.ensure_fields_present!(:invitations, @auth_schema)
# feature functions here...
end
if :magic_links in @auth_features do
Fast.Auth.ensure_fields_present!(:magic_link, @auth_schema)
# feature functions here...
end
if :saml_sso in @auth_features do
Fast.Auth.ensure_fields_present!(:saml_sso, @auth_schema)
# feature functions here...
end
if :login_attempt_locking in @auth_features do
Fast.Auth.ensure_fields_present!(:login_attempt_locking, @auth_schema)
# feature functions here...
end
def revoke(jwt) do
@guardian_module.revoke(jwt)
end
def user_for_token(token) do
with {:ok, claims} <- @guardian_module.decode_and_verify(token) do
@guardian_module.resource_from_claims(claims)
end
end
# NB: Replacing these with `verify_login_allowed/3`
# def allow_login?(nil, _method, _context), do: {:error, :nil_user}
# def allow_login?(_user), do: :ok
end
end
for {feature, required_fieldset} <- [
passwords: @password_fields,
confirmations: @confirmable_fields,
invitations: @invitable_fields,
magic_links: @magic_link_fields,
saml_sso: @saml_sso_fields,
login_attempt_locking: @login_attempt_locking_fields
] do
def ensure_fields_present!(unquote(feature), schema) do
missing = unquote(required_fieldset) -- schema.__schema__(:fields)
case missing do
[] -> :ok
_ -> raise_missing_fields!(unquote(feature), schema, missing)
end
end
end
def ensure_valid_features!(module, features) do
invalid = features -- @valid_features
unused_features = @valid_features -- features
case invalid do
[] -> :ok
_ -> raise_invalid_features!(module, invalid, unused_features)
end
end
def raise_missing_fields!(feature, schema, missing_fields) do
raise Fast.Auth.MissingFieldsError,
message: """
** You're trying to use a Fast.Auth feature for a schema that is missing the required fields:
- schema: #{inspect(schema)}
- feature: #{inspect(feature)}
- missing fields: #{inspect(missing_fields)}\n\n
"""
end
def raise_invalid_features!(module, invalid_features, unused_features) do
raise Fast.Auth.InvalidFeaturesError,
message: """
** You're trying to use invalid Fast.Auth features.
- module: #{inspect(module)}
- your invalid features: #{inspect(invalid_features)}
- maybe you meant one of: #{inspect(unused_features)}
"""
end
end