Skip to content

Instantly share code, notes, and snippets.

@ahey
Created October 11, 2023 05:07
Show Gist options
  • Save ahey/0c81097f0ce23054a00c8b77a26a0ab5 to your computer and use it in GitHub Desktop.
Save ahey/0c81097f0ce23054a00c8b77a26a0ab5 to your computer and use it in GitHub Desktop.
Using ash and ash_authentication, configure a user resource for auth via GraphQL
defmodule YourApp.User.Actions.ConfirmWithToken do
use Ash.Resource.ManualCreate
def create(changeset, _opts, _context) do
strategy = AshAuthentication.Info.strategy!(YourApp.User, :confirm)
AshAuthentication.Strategy.action(
strategy,
:confirm,
%{"confirm" => changeset.arguments[:token]}
)
end
end
defmodule YourApp.User.Actions.ResetPasswordCreate do
use Ash.Resource.ManualCreate
@impl true
def create(changeset, _opts, _context) do
strategy =
AshAuthentication.Info.strategy!(YourApp.User, :password)
AshAuthentication.Strategy.Password.Actions.reset(
strategy,
changeset.arguments |> YourApp.Util.atom_to_string_keys(),
[]
)
end
end
defmodule YourApp.User.Actions.SignIn do
use Ash.Resource.ManualCreate
def create(changeset, _opts, _context) do
result =
YourApp.User.sign_in_with_password_builtin(
changeset.arguments[:email],
changeset.arguments[:password]
)
with {:error, _} <- result do
{:error, login_failed_error(changeset)}
end
end
defp login_failed_error(changeset) do
changeset
|> Ash.Changeset.add_error([
Ash.Error.Changes.InvalidArgument.exception(message: "login failed")
])
end
end
defmodule YourApp.User do
use Ash.Resource,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer],
extensions: [
AshAuthentication,
AshGraphql.Resource
]
postgres do
table "users"
repo YourApp.Repo
end
identities do
identity :unique_email, [:email], eager_check_with: YourApp.Api
end
attributes do
uuid_primary_key :id
attribute :email, :ci_string, allow_nil?: false
attribute :hashed_password,
:string,
allow_nil?: true,
sensitive?: true,
private?: true
create_timestamp :inserted_at
update_timestamp :updated_at
end
code_interface do
define_for YourApp.Api
define :confirm, args: [:confirm]
define :change_password, args: [:current_password, :password]
define :change_email, args: [:current_password, :email]
define :confirm_without_token
define :confirm_with_token, args: [:token]
define :register_with_password, args: [:email, :password]
define :register_preconfirmed, args: [:email, :password]
define :sign_in_with_password_builtin, args: [:email, :password]
define :sign_in_with_password, args: [:email, :password]
define :request_password_reset,
action: :request_password_reset_with_password,
args: [:email]
define :reset_password, args: [:reset_token, :password]
end
actions do
defaults [:create, :read, :update, :destroy]
update :confirm_without_token do
accept []
change set_attribute(:confirmed_at, &DateTime.utc_now/0)
end
create :confirm_with_token do
accept []
argument :token, :string, sensitive?: true, allow_nil?: false
manual YourApp.User.Actions.ConfirmWithToken
end
# The sign_in action provided by ash_authentication is a read action. We
# need it as a create action so that the errors can be returned via GraphQL
create :sign_in_with_password do
accept []
argument :email, :string, allow_nil?: false
argument :password, :string, sensitive?: true, allow_nil?: false
metadata :token, :string, allow_nil?: false
manual YourApp.User.Actions.SignIn
end
# Confirm the credential immediately without sending confirmation email
create :register_preconfirmed do
accept [:email]
argument :password, :string, sensitive?: true, allow_nil?: false
metadata :token, :string, allow_nil?: false
allow_nil_input [:hashed_password]
change {AshAuthentication.Strategy.Password.HashPasswordChange,
strategy_name: :password}
change {AshAuthentication.GenerateTokenChange, strategy_name: :password}
change set_attribute(:confirmed_at, &DateTime.utc_now/0)
end
update :change_password do
accept []
argument :current_password, :string, sensitive?: true, allow_nil?: false
argument :password, :string, sensitive?: true, allow_nil?: false
validate {AshAuthentication.Strategy.Password.PasswordValidation,
strategy_name: :password, password_argument: :current_password}
change {AshAuthentication.Strategy.Password.HashPasswordChange,
strategy_name: :password}
end
update :change_email do
accept []
argument :current_password, :string, sensitive?: true, allow_nil?: false
argument :email, :string, allow_nil?: false
validate {AshAuthentication.Strategy.Password.PasswordValidation,
strategy_name: :password, password_argument: :current_password}
change set_attribute(:email, arg(:email))
end
# Had to create this action, as the one provided by ash_authentication
# requires the actor to be provided as input, strangely.
# See https://github.com/team-alembic/ash_authentication/issues/207
create :reset_password do
accept []
argument :reset_token, :string, allow_nil?: false
argument :password, :string, sensitive?: true, allow_nil?: false
manual YourApp.User.Actions.ResetPasswordCreate
end
end
graphql do
type :user
queries do
get :user_request_password_reset,
:request_password_reset_with_password do
identity false
as_mutation? true
end
end
mutations do
create :user_register, :register_with_password
update :user_change_email, :change_email
update :user_change_password, :change_password
create :user_reset_password, :reset_password
create :user_confirm_with_token, :confirm_with_token
create :user_sign_in, :sign_in_with_password
end
end
policies do
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
authorize_if always()
end
bypass action(:sign_in_with_password) do
authorize_if always()
end
bypass action(:sign_in_with_password_builtin) do
authorize_if always()
end
bypass action(:register_with_password) do
authorize_if always()
end
bypass action(:confirm_with_token) do
authorize_if always()
end
bypass action(:request_password_reset_with_password) do
authorize_if always()
end
bypass action(:reset_password) do
authorize_if always()
end
policy always() do
forbid_if always()
end
end
authentication do
api YourApp.Api
add_ons do
confirmation :confirm do
monitor_fields [:email]
sender fn credential, token, opts ->
changeset = Keyword.fetch!(opts, :changeset)
email = changeset.attributes[:email] |> Ash.CiString.value()
case changeset.action.name do
:register_with_password ->
YourApp.Mailer.send_confirm_email_instructions(credential, email, token)
:change_email ->
YourApp.Mailer.send_update_email_instructions(credential, email, token)
_ ->
nil
end
end
end
end
strategies do
password do
identity_field :email
sign_in_action_name :sign_in_with_password_builtin
confirmation_required? false
resettable do
sender fn credential, token, opts ->
email = credential.email |> Ash.CiString.value()
YourApp.Mailer.send_reset_password_instructions(credential, email, token)
end
end
end
end
tokens do
enabled? true
require_token_presence_for_authentication? true
signing_secret &YourApp.Auth.get_config/2
store_all_tokens? true
token_resource YourApp.AuthToken
token_lifetime YourApp.session_max_duration() |> YourApp.Util.to_hours()
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment