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 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, :jti) tokens do enabled? true token_resource Mv.Accounts.Token require_token_presence_for_authentication? Application.compile_env( :mv, :require_token_presence_for_authentication, false ) 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 defaults [:read, :create, :destroy, :update] create :create_user do accept [:email] upsert? true end update :update_user do accept [:email] 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 # 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 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) do where action_is([:register_with_password, :admin_set_password]) 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 attribute :email, :ci_string, allow_nil?: false, public?: true attribute :hashed_password, :string, sensitive?: true, allow_nil?: true attribute :oidc_id, :string, allow_nil?: true end relationships do belongs_to :member, Mv.Membership.Member end identities do identity :unique_email, [:email] identity :unique_oidc_id, [:oidc_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