440 lines
12 KiB
Elixir
440 lines
12 KiB
Elixir
defmodule Mixer.Accounts.User do
|
|
import Ash.Expr
|
|
|
|
use Ash.Resource,
|
|
otp_app: :mixer,
|
|
domain: Mixer.Accounts,
|
|
data_layer: AshPostgres.DataLayer,
|
|
authorizers: [Ash.Policy.Authorizer],
|
|
extensions: [AshAuthentication, AshTypescript.Resource]
|
|
|
|
authentication do
|
|
add_ons do
|
|
log_out_everywhere do
|
|
apply_on_password_change? true
|
|
end
|
|
|
|
confirmation :confirm_new_user do
|
|
monitor_fields [:email]
|
|
confirm_on_create? true
|
|
confirm_on_update? false
|
|
require_interaction? true
|
|
confirmed_at_field :confirmed_at
|
|
auto_confirm_actions [:sign_in_with_magic_link, :reset_password_with_token]
|
|
sender Mixer.Accounts.User.Senders.SendNewUserConfirmationEmail
|
|
end
|
|
end
|
|
|
|
tokens do
|
|
enabled? true
|
|
token_resource Mixer.Accounts.Token
|
|
signing_secret Mixer.Secrets
|
|
store_all_tokens? true
|
|
require_token_presence_for_authentication? true
|
|
end
|
|
|
|
strategies do
|
|
password :password do
|
|
identity_field :email
|
|
hash_provider AshAuthentication.BcryptProvider
|
|
require_confirmed_with :confirmed_at
|
|
|
|
resettable do
|
|
sender Mixer.Accounts.User.Senders.SendPasswordResetEmail
|
|
# these configurations will be the default in a future release
|
|
password_reset_action_name :reset_password_with_token
|
|
request_password_reset_action_name :request_password_reset_token
|
|
end
|
|
end
|
|
|
|
remember_me :remember_me
|
|
|
|
magic_link do
|
|
identity_field :email
|
|
registration_enabled? true
|
|
require_interaction? true
|
|
|
|
sender Mixer.Accounts.User.Senders.SendMagicLinkEmail
|
|
end
|
|
|
|
api_key :api_key do
|
|
api_key_relationship :valid_api_keys
|
|
api_key_hash_attribute :api_key_hash
|
|
end
|
|
end
|
|
end
|
|
|
|
postgres do
|
|
table "users"
|
|
repo Mixer.Repo
|
|
end
|
|
|
|
typescript do
|
|
type_name "users"
|
|
end
|
|
|
|
actions do
|
|
defaults [:read]
|
|
|
|
read :get_by_subject do
|
|
description "Get a user by the subject claim in a JWT"
|
|
argument :subject, :string, allow_nil?: false
|
|
get? true
|
|
prepare AshAuthentication.Preparations.FilterBySubject
|
|
end
|
|
|
|
update :change_password do
|
|
# Use this action to allow users to change their password by providing
|
|
# their current password and a new password.
|
|
|
|
require_atomic? false
|
|
accept []
|
|
argument :current_password, :string, sensitive?: true, allow_nil?: false
|
|
|
|
argument :password, :string,
|
|
sensitive?: true,
|
|
allow_nil?: false,
|
|
constraints: [min_length: 8]
|
|
|
|
argument :password_confirmation, :string, sensitive?: true, allow_nil?: false
|
|
|
|
validate confirm(:password, :password_confirmation)
|
|
|
|
validate {AshAuthentication.Strategy.Password.PasswordValidation,
|
|
strategy_name: :password, password_argument: :current_password}
|
|
|
|
change {AshAuthentication.Strategy.Password.HashPasswordChange, strategy_name: :password}
|
|
end
|
|
|
|
read :sign_in_with_password do
|
|
description "Attempt to sign in using a email and password."
|
|
get? true
|
|
|
|
argument :email, :ci_string do
|
|
description "The email to use for retrieving the user."
|
|
allow_nil? false
|
|
end
|
|
|
|
argument :password, :string do
|
|
description "The password to check for the matching user."
|
|
allow_nil? false
|
|
sensitive? true
|
|
end
|
|
|
|
# validates the provided email and password and generates a token
|
|
prepare AshAuthentication.Strategy.Password.SignInPreparation
|
|
|
|
metadata :token, :string do
|
|
description "A JWT that can be used to authenticate the user."
|
|
allow_nil? false
|
|
end
|
|
end
|
|
|
|
read :sign_in_with_token do
|
|
# In the generated sign in components, we validate the
|
|
# email and password directly in the LiveView
|
|
# and generate a short-lived token that can be used to sign in over
|
|
# a standard controller action, exchanging it for a standard token.
|
|
# This action performs that exchange. If you do not use the generated
|
|
# liveviews, you may remove this action, and set
|
|
# `sign_in_tokens_enabled? false` in the password strategy.
|
|
|
|
description "Attempt to sign in using a short-lived sign in token."
|
|
get? true
|
|
|
|
argument :token, :string do
|
|
description "The short-lived sign in token."
|
|
allow_nil? false
|
|
sensitive? true
|
|
end
|
|
|
|
# validates the provided sign in token and generates a token
|
|
prepare AshAuthentication.Strategy.Password.SignInWithTokenPreparation
|
|
|
|
metadata :token, :string do
|
|
description "A JWT that can be used to authenticate the user."
|
|
allow_nil? false
|
|
end
|
|
end
|
|
|
|
create :register_with_password do
|
|
description "Register a new user with a email and password."
|
|
|
|
argument :email, :ci_string do
|
|
allow_nil? false
|
|
end
|
|
|
|
argument :password, :string do
|
|
description "The proposed password for the user, in plain text."
|
|
allow_nil? false
|
|
constraints min_length: 8
|
|
sensitive? true
|
|
end
|
|
|
|
argument :password_confirmation, :string do
|
|
description "The proposed password for the user (again), in plain text."
|
|
allow_nil? false
|
|
sensitive? true
|
|
end
|
|
|
|
argument :username, :string do
|
|
description "The desired username for the user (letters, numbers, underscores)."
|
|
allow_nil? false
|
|
|
|
constraints match: ~r/^[a-zA-Z0-9_]+$/,
|
|
min_length: 3,
|
|
max_length: 30
|
|
end
|
|
|
|
# Sets the email from the argument
|
|
change set_attribute(:email, arg(:email))
|
|
|
|
# Sets the username from the argument
|
|
change set_attribute(:username, arg(:username))
|
|
|
|
# Hashes the provided password
|
|
change AshAuthentication.Strategy.Password.HashPasswordChange
|
|
|
|
# Generates an authentication token for the user
|
|
change AshAuthentication.GenerateTokenChange
|
|
|
|
# validates that the password matches the confirmation
|
|
validate AshAuthentication.Strategy.Password.PasswordConfirmationValidation
|
|
|
|
metadata :token, :string do
|
|
description "A JWT that can be used to authenticate the user."
|
|
allow_nil? false
|
|
end
|
|
end
|
|
|
|
action :request_password_reset_token do
|
|
description "Send password reset instructions to a user if they exist."
|
|
|
|
argument :email, :ci_string do
|
|
allow_nil? false
|
|
end
|
|
|
|
# creates a reset token and invokes the relevant senders
|
|
run {AshAuthentication.Strategy.Password.RequestPasswordReset, action: :get_by_email}
|
|
end
|
|
|
|
read :get_by_email do
|
|
description "Looks up a user by their email"
|
|
get_by :email
|
|
end
|
|
|
|
update :update_profile do
|
|
description "Update the user's public profile (username, display name)."
|
|
accept [:username, :display_name]
|
|
require_atomic? false
|
|
end
|
|
|
|
update :update_avatar do
|
|
description "Store the S3 key of the user's processed avatar thumbnail."
|
|
accept [:avatar_url]
|
|
require_atomic? false
|
|
end
|
|
|
|
update :reset_password_with_token do
|
|
argument :reset_token, :string do
|
|
allow_nil? false
|
|
sensitive? true
|
|
end
|
|
|
|
argument :password, :string do
|
|
description "The proposed password for the user, in plain text."
|
|
allow_nil? false
|
|
constraints min_length: 8
|
|
sensitive? true
|
|
end
|
|
|
|
argument :password_confirmation, :string do
|
|
description "The proposed password for the user (again), in plain text."
|
|
allow_nil? false
|
|
sensitive? true
|
|
end
|
|
|
|
# validates the provided reset token
|
|
validate AshAuthentication.Strategy.Password.ResetTokenValidation
|
|
|
|
# validates that the password matches the confirmation
|
|
validate AshAuthentication.Strategy.Password.PasswordConfirmationValidation
|
|
|
|
# Hashes the provided password
|
|
change AshAuthentication.Strategy.Password.HashPasswordChange
|
|
|
|
# Generates an authentication token for the user
|
|
change AshAuthentication.GenerateTokenChange
|
|
end
|
|
|
|
create :sign_in_with_magic_link do
|
|
description "Sign in or register a user with magic link."
|
|
|
|
argument :token, :string do
|
|
description "The token from the magic link that was sent to the user"
|
|
allow_nil? false
|
|
end
|
|
|
|
argument :remember_me, :boolean do
|
|
description "Whether to generate a remember me token"
|
|
allow_nil? true
|
|
end
|
|
|
|
argument :username, :string do
|
|
description "Username chosen during first-time magic link registration."
|
|
allow_nil? true
|
|
|
|
constraints match: ~r/^[a-zA-Z0-9_]+$/,
|
|
min_length: 3,
|
|
max_length: 30
|
|
end
|
|
|
|
upsert? true
|
|
upsert_identity :unique_email
|
|
upsert_fields [:email]
|
|
|
|
# Uses the information from the token to create or sign in the user
|
|
change AshAuthentication.Strategy.MagicLink.SignInChange
|
|
|
|
change {AshAuthentication.Strategy.RememberMe.MaybeGenerateTokenChange,
|
|
strategy_name: :remember_me}
|
|
|
|
# Set username on new users (or existing users who haven't set one yet)
|
|
change fn changeset, _ctx ->
|
|
case Ash.Changeset.get_argument(changeset, :username) do
|
|
nil ->
|
|
changeset
|
|
|
|
username ->
|
|
Ash.Changeset.after_action(changeset, fn _cs, user ->
|
|
if is_nil(user.username) do
|
|
user
|
|
|> Ash.Changeset.for_update(:update_profile, %{username: username},
|
|
authorize?: false
|
|
)
|
|
|> Ash.update()
|
|
|> case do
|
|
{:ok, updated} -> {:ok, updated}
|
|
# Don't fail the sign-in just because username set failed
|
|
{:error, _} -> {:ok, user}
|
|
end
|
|
else
|
|
{:ok, user}
|
|
end
|
|
end)
|
|
end
|
|
end
|
|
|
|
metadata :token, :string do
|
|
allow_nil? false
|
|
end
|
|
end
|
|
|
|
action :request_magic_link do
|
|
argument :email, :ci_string do
|
|
allow_nil? false
|
|
end
|
|
|
|
run AshAuthentication.Strategy.MagicLink.Request
|
|
end
|
|
|
|
read :sign_in_with_api_key do
|
|
argument :api_key, :string, allow_nil?: false
|
|
prepare AshAuthentication.Strategy.ApiKey.SignInPreparation
|
|
end
|
|
end
|
|
|
|
policies do
|
|
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
|
|
authorize_if always()
|
|
end
|
|
|
|
policy action_type(:read) do
|
|
authorize_if always()
|
|
end
|
|
|
|
policy action(:update_profile) do
|
|
authorize_if expr(id == ^actor(:id))
|
|
end
|
|
|
|
policy action(:update_avatar) do
|
|
authorize_if expr(id == ^actor(:id))
|
|
end
|
|
end
|
|
|
|
attributes do
|
|
uuid_primary_key :id
|
|
|
|
attribute :email, :ci_string do
|
|
allow_nil? false
|
|
public? true
|
|
end
|
|
|
|
attribute :hashed_password, :string do
|
|
sensitive? true
|
|
end
|
|
|
|
attribute :confirmed_at, :utc_datetime_usec
|
|
|
|
attribute :username, :string do
|
|
public? true
|
|
|
|
constraints match: ~r/^[a-zA-Z0-9_]+$/,
|
|
min_length: 3,
|
|
max_length: 30
|
|
end
|
|
|
|
attribute :display_name, :string do
|
|
public? true
|
|
constraints max_length: 50
|
|
end
|
|
|
|
attribute :avatar_url, :string do
|
|
public? true
|
|
end
|
|
end
|
|
|
|
relationships do
|
|
has_many :valid_api_keys, Mixer.Accounts.ApiKey do
|
|
filter expr(valid)
|
|
end
|
|
|
|
has_many :tweet_likes, Mixer.Posts.TweetLike
|
|
|
|
has_many :tweets, Mixer.Posts.Tweet
|
|
|
|
has_many :followers, Mixer.Accounts.Follow do
|
|
destination_attribute :following_id
|
|
end
|
|
|
|
has_many :following, Mixer.Accounts.Follow do
|
|
destination_attribute :follower_id
|
|
end
|
|
end
|
|
|
|
aggregates do
|
|
count :follower_count, :followers do
|
|
public? true
|
|
end
|
|
|
|
count :following_count, :following do
|
|
public? true
|
|
end
|
|
|
|
exists :am_i_following, :followers do
|
|
public? true
|
|
filter expr(follower_id == ^actor(:id))
|
|
end
|
|
|
|
first :my_follow_id, :followers, :id do
|
|
public? true
|
|
filter expr(follower_id == ^actor(:id))
|
|
end
|
|
end
|
|
|
|
identities do
|
|
identity :unique_email, [:email]
|
|
identity :unique_username, [:username], nils_distinct?: true
|
|
end
|
|
end
|