315 lines
12 KiB
Elixir
315 lines
12 KiB
Elixir
defmodule Mv.Accounts.User do
|
|
@moduledoc """
|
|
The ressource for keeping user-specific data related to the login process. It is used by AshAuthentication to handle the Authentication strategies like SSO.
|
|
"""
|
|
use Ash.Resource,
|
|
domain: Mv.Accounts,
|
|
data_layer: AshPostgres.DataLayer,
|
|
extensions: [AshAuthentication]
|
|
|
|
# authorizers: [Ash.Policy.Authorizer]
|
|
|
|
postgres do
|
|
table "users"
|
|
repo Mv.Repo
|
|
|
|
references do
|
|
# When a member is deleted, set the user's member_id to NULL
|
|
# This allows users to continue existing even if their linked member is removed
|
|
reference :member, on_delete: :nilify
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
AshAuthentication specific: Defines the strategies we want to use for authentication.
|
|
Currently password and SSO with Rauthy as OIDC provider
|
|
"""
|
|
authentication do
|
|
session_identifier Application.compile_env!(:mv, :session_identifier)
|
|
|
|
tokens do
|
|
enabled? true
|
|
token_resource Mv.Accounts.Token
|
|
|
|
require_token_presence_for_authentication? Application.compile_env!(
|
|
:mv,
|
|
:require_token_presence_for_authentication
|
|
)
|
|
|
|
store_all_tokens? true
|
|
|
|
# signing_algorithm "EdDSA" -> https://git.local-it.org/local-it/mitgliederverwaltung/issues/87
|
|
|
|
signing_secret fn _, _ ->
|
|
{:ok, Application.get_env(:mv, :token_signing_secret)}
|
|
end
|
|
end
|
|
|
|
strategies do
|
|
oidc :rauthy do
|
|
client_id Mv.Secrets
|
|
base_url Mv.Secrets
|
|
redirect_uri Mv.Secrets
|
|
client_secret Mv.Secrets
|
|
auth_method :client_secret_jwt
|
|
code_verifier true
|
|
|
|
# id_token_signed_response_alg "EdDSA" #-> https://git.local-it.org/local-it/mitgliederverwaltung/issues/87
|
|
end
|
|
|
|
password :password do
|
|
identity_field :email
|
|
hash_provider AshAuthentication.BcryptProvider
|
|
confirmation_required? false
|
|
end
|
|
end
|
|
end
|
|
|
|
actions do
|
|
# Default actions kept for framework/tooling integration:
|
|
# - :create -> Used by AshAdmin's generated "Create" UI and by generic
|
|
# AshPhoenix helpers that assume a default create action.
|
|
# It does NOT manage the :member relationship. For admin
|
|
# flows that may link an existing member, use :create_user.
|
|
# - :read -> Standard read used across the app and by admin tooling.
|
|
# - :destroy-> Standard delete used by admin tooling and maintenance tasks.
|
|
defaults [:read, :create, :destroy]
|
|
|
|
# Primary generic update action:
|
|
# - Selected by AshAdmin's generated "Edit" UI and generic AshPhoenix
|
|
# helpers that assume a default update action.
|
|
# - Intended for simple attribute updates (e.g., :email) and scenarios
|
|
# that do NOT need to manage the :member relationship.
|
|
# - For linking/unlinking a member (and the related validations), prefer
|
|
# the specialized :update_user action below.
|
|
update :update do
|
|
primary? true
|
|
|
|
# Required because custom validation functions (email validation, member relationship validation)
|
|
# cannot be executed atomically. These validations need to query the database and perform
|
|
# complex checks that are not supported in atomic operations.
|
|
require_atomic? false
|
|
|
|
# Sync email changes to linked member
|
|
change Mv.Accounts.User.Changes.SyncEmailToMember
|
|
end
|
|
|
|
create :create_user do
|
|
description "Creates a new user with optional member relationship. The member relationship is managed through the :member argument."
|
|
# Only accept email directly - member_id is NOT in accept list
|
|
# This prevents direct foreign key manipulation, forcing use of manage_relationship
|
|
accept [:email]
|
|
# Allow member to be passed as argument for relationship management
|
|
argument :member, :map, allow_nil?: true
|
|
upsert? true
|
|
|
|
# Manage the member relationship during user creation
|
|
change manage_relationship(:member, :member,
|
|
# Look up existing member and relate to it
|
|
on_lookup: :relate,
|
|
# Error if member doesn't exist in database
|
|
on_no_match: :error,
|
|
# If member already linked to this user, ignore (shouldn't happen in create)
|
|
on_match: :ignore,
|
|
# If no member provided, that's fine (optional relationship)
|
|
on_missing: :ignore
|
|
)
|
|
|
|
# Override member email with user email when linking
|
|
change Mv.Accounts.User.Changes.OverrideMemberEmailOnLink
|
|
end
|
|
|
|
update :update_user do
|
|
description "Updates a user and manages the optional member relationship. To change an existing member link, first remove it (set member to nil), then add the new one."
|
|
# Only accept email directly - member_id is NOT in accept list
|
|
# This prevents direct foreign key manipulation, forcing use of manage_relationship
|
|
accept [:email]
|
|
# Allow member to be passed as argument for relationship management
|
|
argument :member, :map, allow_nil?: true
|
|
|
|
# Required because custom validation functions (email validation, member relationship validation)
|
|
# cannot be executed atomically. These validations need to query the database and perform
|
|
# complex checks that are not supported in atomic operations.
|
|
require_atomic? false
|
|
|
|
# Manage the member relationship during user update
|
|
change manage_relationship(:member, :member,
|
|
# Look up existing member and relate to it
|
|
on_lookup: :relate,
|
|
# Error if member doesn't exist in database
|
|
on_no_match: :error,
|
|
# If same member provided, that's fine (allows updates with same member)
|
|
on_match: :ignore,
|
|
# If no member provided, remove existing relationship (allows member removal)
|
|
on_missing: :unrelate
|
|
)
|
|
|
|
# Sync email changes to linked member
|
|
change Mv.Accounts.User.Changes.SyncEmailToMember
|
|
# Override member email with user email when linking
|
|
change Mv.Accounts.User.Changes.OverrideMemberEmailOnLink
|
|
end
|
|
|
|
# Admin action for direct password changes in admin panel
|
|
# Uses the official Ash Authentication HashPasswordChange with correct context
|
|
update :admin_set_password do
|
|
accept [:email]
|
|
argument :password, :string, allow_nil?: false, sensitive?: true
|
|
require_atomic? false
|
|
|
|
# Set the strategy context that HashPasswordChange expects
|
|
change set_context(%{strategy_name: :password})
|
|
|
|
# Use the official Ash Authentication password change
|
|
change AshAuthentication.Strategy.Password.HashPasswordChange
|
|
end
|
|
|
|
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
|
|
|
|
read :sign_in_with_rauthy do
|
|
argument :user_info, :map, allow_nil?: false
|
|
argument :oauth_tokens, :map, allow_nil?: false
|
|
prepare AshAuthentication.Strategy.OAuth2.SignInPreparation
|
|
|
|
filter expr(email == get_path(^arg(:user_info), [:preferred_username]))
|
|
end
|
|
|
|
create :register_with_rauthy do
|
|
argument :user_info, :map, allow_nil?: false
|
|
argument :oauth_tokens, :map, allow_nil?: false
|
|
upsert? true
|
|
upsert_identity :unique_oidc_id
|
|
|
|
validate &__MODULE__.validate_oidc_id_present/2
|
|
|
|
change AshAuthentication.GenerateTokenChange
|
|
|
|
change fn changeset, _ctx ->
|
|
user_info = Ash.Changeset.get_argument(changeset, :user_info)
|
|
|
|
changeset
|
|
|> Ash.Changeset.change_attribute(:email, user_info["preferred_username"])
|
|
|> Ash.Changeset.change_attribute(:oidc_id, user_info["sub"] || user_info["id"])
|
|
end
|
|
|
|
# Override member email with user email when linking (if member relationship exists)
|
|
change Mv.Accounts.User.Changes.OverrideMemberEmailOnLink
|
|
end
|
|
end
|
|
|
|
# Global validations - applied to all relevant actions
|
|
validations do
|
|
# Password strength policy: minimum 8 characters for all password-related actions
|
|
validate string_length(:password, min: 8),
|
|
where: [action_is([:register_with_password, :admin_set_password])],
|
|
message: "must have length of at least 8"
|
|
|
|
# Email validation with EctoCommons.EmailValidator (same as Member)
|
|
# This ensures consistency between User and Member email validation
|
|
validate fn changeset, _ ->
|
|
# Get email from attribute (Ash.CiString) and convert to string
|
|
email = Ash.Changeset.get_attribute(changeset, :email)
|
|
email_string = if email, do: to_string(email), else: nil
|
|
|
|
# Only validate if email is present
|
|
if email_string do
|
|
changeset2 =
|
|
{%{}, %{email: :string}}
|
|
|> Ecto.Changeset.cast(%{email: email_string}, [:email])
|
|
|> EctoCommons.EmailValidator.validate_email(:email, checks: [:html_input, :pow])
|
|
|
|
if changeset2.valid? do
|
|
:ok
|
|
else
|
|
{:error, field: :email, message: "is not a valid email"}
|
|
end
|
|
else
|
|
:ok
|
|
end
|
|
end
|
|
|
|
# Prevent overwriting existing member relationship
|
|
# This validation ensures race condition safety by requiring explicit two-step process:
|
|
# 1. Remove existing member (set member to nil)
|
|
# 2. Add new member
|
|
# This prevents accidental overwrites when multiple admins work simultaneously
|
|
validate fn changeset, _context ->
|
|
member_arg = Ash.Changeset.get_argument(changeset, :member)
|
|
current_member_id = changeset.data.member_id
|
|
|
|
# Only trigger if:
|
|
# - member argument is provided AND has an ID
|
|
# - user currently has a member
|
|
# - the new member ID is different from current member ID
|
|
if member_arg && member_arg[:id] && current_member_id &&
|
|
member_arg[:id] != current_member_id do
|
|
{:error,
|
|
field: :member, message: "User already has a member. Remove existing member first."}
|
|
else
|
|
:ok
|
|
end
|
|
end
|
|
end
|
|
|
|
def validate_oidc_id_present(changeset, _context) do
|
|
user_info = Ash.Changeset.get_argument(changeset, :user_info) || %{}
|
|
|
|
if is_binary(user_info["sub"]) or is_binary(user_info["id"]) do
|
|
:ok
|
|
else
|
|
{:error, [user_info: "OIDC user_info must contain a non-empty 'sub' or 'id' field"]}
|
|
end
|
|
end
|
|
|
|
attributes do
|
|
uuid_primary_key :id
|
|
|
|
# IMPORTANT: Email Synchronization
|
|
# When user and member are linked, emails are automatically synced bidirectionally.
|
|
# User.email is the source of truth - when a link is established, member.email
|
|
# is overridden to match user.email. Subsequent changes to either email will
|
|
# sync to the other resource.
|
|
# See: Mv.Accounts.User.Changes.SyncEmailToMember
|
|
# Mv.Accounts.User.Changes.OverrideMemberEmailOnLink
|
|
# Mv.Membership.Member.Changes.SyncEmailToUser
|
|
attribute :email, :ci_string do
|
|
allow_nil? false
|
|
public? true
|
|
# Same constraints as Member email for consistency
|
|
constraints min_length: 5, max_length: 254
|
|
end
|
|
|
|
attribute :hashed_password, :string, sensitive?: true, allow_nil?: true
|
|
attribute :oidc_id, :string, allow_nil?: true
|
|
end
|
|
|
|
relationships do
|
|
# 1:1 relationship - User can optionally belong to one Member
|
|
# This automatically creates a `member_id` attribute in the User table
|
|
# The relationship is optional (allow_nil? true by default)
|
|
belongs_to :member, Mv.Membership.Member
|
|
end
|
|
|
|
identities do
|
|
identity :unique_email, [:email]
|
|
identity :unique_oidc_id, [:oidc_id]
|
|
identity :unique_member, [:member_id]
|
|
end
|
|
|
|
# You can customize this if you wish, but this is a safe default that
|
|
# only allows user data to be interacted with via AshAuthentication.
|
|
# policies do
|
|
# bypass AshAuthentication.Checks.AshAuthenticationInteraction do
|
|
# authorize_if(always())
|
|
# end
|
|
|
|
# policy always() do
|
|
# forbid_if(always())
|
|
# end
|
|
# end
|
|
end
|