diff --git a/Gemfile b/Gemfile
index acb42a470..89bf0bdb6 100644
--- a/Gemfile
+++ b/Gemfile
@@ -23,6 +23,7 @@ gem "importmap-rails", "~> 2.0"
 gem 'commonmarker', '~> 2.10.0'
 gem 'doorkeeper', '~> 5.9.6'
 gem "bcrypt", require: false
+gem "argon2id", "~> 0.10.0"
 gem "doorkeeper-i18n", "~> 5.2"
 gem "requestjs-rails", "~> 0.0.13"
 
diff --git a/app/models/user.rb b/app/models/user.rb
index 0045fb85a..2803cf365 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -243,6 +243,8 @@ def self.try_to_login!(login, password, active_only=true)
       # user is already in local database
       return nil unless user.check_password?(password)
       return nil if !user.active? && active_only
+
+      user.rehash_password(password) if user.password_hash_outdated?
     else
       # user is not yet registered, try to authenticate with available sources
       attrs = AuthSource.authenticate(login, password)
@@ -350,21 +352,40 @@ def update_last_login_on!
 
   # Returns true if +clear_password+ is the correct user's password, otherwise false
   def check_password?(clear_password)
-    if auth_source_id.present?
-      auth_source.authenticate(self.login, clear_password)
-    else
-      User.hash_password("#{salt}#{User.hash_password clear_password}") == hashed_password
-    end
+    return auth_source.authenticate(self.login, clear_password) if auth_source_id.present?
+
+    algorithm = password_hash_algorithm
+    return false unless algorithm
+
+    algorithm.verify_password(clear_password, salt, hashed_password)
   end
 
-  # Generates a random salt and computes hashed_password for +clear_password+
-  # The hashed password is stored in the following form: SHA1(salt + SHA1(password))
+  # Computes salt and hashed_password for +clear_password+ with the configured
+  # password hash algorithm, see Redmine::PasswordHash
   def salt_password(clear_password)
-    self.salt = User.generate_salt
-    self.hashed_password = User.hash_password("#{salt}#{User.hash_password clear_password}")
+    self.salt, self.hashed_password = Redmine::PasswordHash.current_algorithm.digest_password(clear_password)
     self.passwd_changed_on = Time.now.change(:usec => 0)
   end
 
+  # Returns true if hashed_password was not computed with the configured
+  # password hash algorithm
+  def password_hash_outdated?
+    return false if auth_source_id.present? || hashed_password.blank?
+
+    Redmine::PasswordHash.current_algorithm != password_hash_algorithm
+  end
+
+  # Returns the algorithm that hashed hashed_password, or nil if the password
+  # is blank or hashed with an unknown algorithm
+  def password_hash_algorithm = Redmine::PasswordHash.algorithm_for(hashed_password)
+
+  # Rehashes +clear_password+ with the configured password hash algorithm
+  # without changing passwd_changed_on nor invalidating the user's tokens
+  def rehash_password(clear_password)
+    salt, hashed_password = Redmine::PasswordHash.current_algorithm.digest_password(clear_password)
+    update_columns(salt:, hashed_password:)
+  end
+
   # Does the backend storage allow this user to change their password?
   def change_password_allowed?
     auth_source.nil? ? true : auth_source.allow_password_changes?
diff --git a/config/configuration.yml.example b/config/configuration.yml.example
index db415641a..d5dcce7f4 100644
--- a/config/configuration.yml.example
+++ b/config/configuration.yml.example
@@ -151,6 +151,14 @@ default:
   # * encrypt data using 'rake db:encrypt RAILS_ENV=production'
   database_cipher_key:
 
+  # Algorithm used to hash the passwords of the users authenticated by
+  # Redmine itself: sha1 (default) or argon2id.
+  # Passwords hashed with either algorithm can be verified whatever this
+  # setting is. When a user logs in, their password is rehashed with the
+  # algorithm set here if it was hashed with the other one.
+  #
+  #password_hash_algorithm: argon2id
+
   # Your secret key for verifying cookie session data integrity. If you
   # change this key, all old sessions will become invalid! Make sure the
   # secret is at least 30 characters and all random, no regular words or
diff --git a/db/migrate/20261026000000_change_users_hashed_password_limit.rb b/db/migrate/20261026000000_change_users_hashed_password_limit.rb
new file mode 100644
index 000000000..51993570f
--- /dev/null
+++ b/db/migrate/20261026000000_change_users_hashed_password_limit.rb
@@ -0,0 +1,17 @@
+class ChangeUsersHashedPasswordLimit < ActiveRecord::Migration[8.1]
+  def up
+    # Argon2id hashes in the PHC string format are longer than SHA1 hex digests
+    change_column :users, :hashed_password, :string, limit: 255, default: '', null: false
+  end
+
+  def down
+    # Argon2id hashes do not fit in 40 characters
+    count = User.where('hashed_password LIKE ?', '$argon2id$%').count
+    if count > 0
+      raise ActiveRecord::IrreversibleMigration,
+            "#{count} user(s) have passwords hashed with Argon2id, reset them with SHA1 first"
+    end
+
+    change_column :users, :hashed_password, :string, limit: 40, default: '', null: false
+  end
+end
diff --git a/lib/redmine/password_hash.rb b/lib/redmine/password_hash.rb
new file mode 100644
index 000000000..c81080892
--- /dev/null
+++ b/lib/redmine/password_hash.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006-  Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+module Redmine
+  # Hashes and verifies the passwords of the users authenticated by Redmine
+  # itself. New passwords are hashed with the algorithm given by the
+  # password_hash_algorithm setting of config/configuration.yml, and
+  # passwords hashed with any of ALGORITHMS can be verified.
+  module PasswordHash
+    algorithm_names = [
+      # Order of strength
+      :argon2id,
+      :sha1,
+    ]
+    ALGORITHMS = algorithm_names.each.with_object({}) do |name, h|
+      h[name] = Redmine::PasswordHash.const_get(name.to_s.camelize)
+    end
+
+    class << self
+      # Returns the algorithm given by the password_hash_algorithm setting
+      def current_algorithm
+        name = Redmine::Configuration['password_hash_algorithm'].presence&.to_sym || :sha1
+        ALGORITHMS[name] || raise(ArgumentError, "Unsupported password hash algorithm: name=#{name}")
+      end
+
+      # Returns the algorithm that hashed +hashed_password+, or nil
+      def algorithm_for(hashed_password)
+        ALGORITHMS.values.find {|algorithm| algorithm.match?(hashed_password)}
+      end
+    end
+  end
+end
diff --git a/lib/redmine/password_hash/argon2id.rb b/lib/redmine/password_hash/argon2id.rb
new file mode 100644
index 000000000..6780ba084
--- /dev/null
+++ b/lib/redmine/password_hash/argon2id.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006-  Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+module Redmine
+  module PasswordHash
+    # Stores the PHC string format (e.g. "$argon2id$v=19$m=...") that includes
+    # the salt and the parameters, so the salt is left blank
+    module Argon2id
+      class << self
+        def match?(hashed_password) = hashed_password.to_s.start_with?('$argon2id$')
+
+        # Returns [salt, hashed_password] for +clear_password+
+        def digest_password(clear_password)
+          [nil, ::Argon2id::Password.create(clear_password.to_s).to_s]
+        end
+
+        # Returns true if +clear_password+ matches +hashed_password+
+        def verify_password(clear_password, salt, hashed_password)
+          ::Argon2id::Password.new(hashed_password).is_password?(clear_password.to_s)
+        rescue ArgumentError, ::Argon2id::Error
+          false
+        end
+      end
+    end
+  end
+end
diff --git a/lib/redmine/password_hash/sha1.rb b/lib/redmine/password_hash/sha1.rb
new file mode 100644
index 000000000..75fde2286
--- /dev/null
+++ b/lib/redmine/password_hash/sha1.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006-  Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+module Redmine
+  module PasswordHash
+    # Stores SHA1(salt + SHA1(password)) as a hex string with a random salt
+    module Sha1
+      class << self
+        def match?(hashed_password) = /\A\h{40}\z/.match?(hashed_password.to_s)
+
+        # Returns [salt, hashed_password] for +clear_password+
+        def digest_password(clear_password)
+          salt = Redmine::Utils.random_hex(16)
+          [salt, hash("#{salt}#{hash(clear_password)}")]
+        end
+
+        # Returns true if +clear_password+ matches +hashed_password+
+        def verify_password(clear_password, salt, hashed_password)
+          hash("#{salt}#{hash(clear_password)}") == hashed_password
+        end
+
+        private
+
+        def hash(value)
+          Digest::SHA1.hexdigest(value || '')
+        end
+      end
+    end
+  end
+end
diff --git a/test/functional/account_controller_test.rb b/test/functional/account_controller_test.rb
index e4289b0d9..5caa4a5c8 100644
--- a/test/functional/account_controller_test.rb
+++ b/test/functional/account_controller_test.rb
@@ -173,6 +173,43 @@ def test_login_with_wrong_password
     assert_select 'input[name=password][value]', 0
   end
 
+  def test_login_should_rehash_sha1_password_with_argon2id
+    Redmine::Configuration.with 'password_hash_algorithm' => 'argon2id' do
+      post :login, params: {username: 'jsmith', password: 'jsmith'}
+      assert_redirected_to '/my/page'
+    end
+    assert Redmine::PasswordHash::Argon2id.match?(User.find(2).hashed_password)
+  end
+
+  def test_login_should_rehash_argon2id_password_with_sha1
+    hash_password_with :argon2id, User.find(2), 'jsmith'
+    Redmine::Configuration.with 'password_hash_algorithm' => 'sha1' do
+      post :login, params: {username: 'jsmith', password: 'jsmith'}
+      assert_redirected_to '/my/page'
+    end
+    assert Redmine::PasswordHash::Sha1.match?(User.find(2).hashed_password)
+  end
+
+  def test_login_with_argon2id_password
+    hash_password_with :argon2id, User.find(2), 'jsmith'
+    hashed_password = User.find(2).hashed_password
+    Redmine::Configuration.with 'password_hash_algorithm' => 'argon2id' do
+      post :login, params: {username: 'jsmith', password: 'jsmith'}
+      assert_redirected_to '/my/page'
+    end
+    assert_equal hashed_password, User.find(2).hashed_password
+  end
+
+  def test_login_with_wrong_password_should_not_rehash_password
+    hashed_password = User.find(2).hashed_password
+    Redmine::Configuration.with 'password_hash_algorithm' => 'argon2id' do
+      post :login, params: {username: 'jsmith', password: 'bad'}
+      assert_response :success
+      assert_select 'div.flash.error', text: /Invalid user or password/
+    end
+    assert_equal hashed_password, User.find(2).hashed_password
+  end
+
   def test_login_with_locked_account_should_fail
     User.find(2).update_attribute :status, User::STATUS_LOCKED
     post(
@@ -587,6 +624,25 @@ def test_post_lost_password_with_token_should_change_the_user_password
     end
   end
 
+  def test_post_lost_password_with_token_should_hash_password_with_configured_algorithm
+    user = User.find(2)
+    token = Token.create!(action: 'recovery', user:)
+    Redmine::Configuration.with 'password_hash_algorithm' => 'argon2id' do
+      post(
+        :lost_password,
+        params: {
+          token: token.value,
+          new_password: 'newpass123',
+          new_password_confirmation: 'newpass123'
+        }
+      )
+      assert_redirected_to '/login'
+    end
+    user.reload
+    assert Redmine::PasswordHash::Argon2id.match?(user.hashed_password)
+    assert user.check_password?('newpass123')
+  end
+
   def test_post_lost_password_with_token_for_non_active_user_should_fail
     user = User.find(2)
     token = Token.create!(:action => 'recovery', :user => user)
diff --git a/test/functional/my_controller_test.rb b/test/functional/my_controller_test.rb
index f1798cc38..7636813b3 100644
--- a/test/functional/my_controller_test.rb
+++ b/test/functional/my_controller_test.rb
@@ -693,6 +693,52 @@ def test_update_password
     assert User.try_to_login('jsmith', 'secret123')
   end
 
+  def test_update_password_should_hash_password_with_configured_algorithm
+    Redmine::Configuration.with 'password_hash_algorithm' => 'argon2id' do
+      post(
+        :password,
+        params: {
+          password: 'jsmith',
+          new_password: 'secret123',
+          new_password_confirmation: 'secret123'
+        }
+      )
+      assert_redirected_to '/my/account'
+    end
+    user = User.find(2)
+    assert Redmine::PasswordHash::Argon2id.match?(user.hashed_password)
+    assert user.check_password?('secret123')
+  end
+
+  def test_update_password_with_argon2id_password
+    hash_password_with :argon2id, User.find(2), 'jsmith'
+    post(
+      :password,
+      params: {
+        password: 'jsmith',
+        new_password: 'secret123',
+        new_password_confirmation: 'secret123'
+      }
+    )
+    assert_redirected_to '/my/account'
+    assert User.try_to_login('jsmith', 'secret123')
+  end
+
+  def test_update_password_with_wrong_argon2id_password
+    hash_password_with :argon2id, User.find(2), 'jsmith'
+    post(
+      :password,
+      params: {
+        password: 'wrongpassword',
+        new_password: 'secret123',
+        new_password_confirmation: 'secret123'
+      }
+    )
+    assert_response :success
+    assert_equal 'Wrong password', flash[:error]
+    assert User.try_to_login('jsmith', 'jsmith')
+  end
+
   def test_update_password_with_non_matching_confirmation
     post(
       :password,
diff --git a/test/functional/users_controller_test.rb b/test/functional/users_controller_test.rb
index 8ce8e8f27..70c3baee7 100644
--- a/test/functional/users_controller_test.rb
+++ b/test/functional/users_controller_test.rb
@@ -502,6 +502,28 @@ def test_create
     assert_mail_body_match 'secret', mail
   end
 
+  def test_create_should_hash_password_with_configured_algorithm
+    Redmine::Configuration.with 'password_hash_algorithm' => 'argon2id' do
+      assert_difference 'User.count' do
+        post :create, params: {
+          user: {
+            firstname: 'John',
+            lastname: 'Doe',
+            login: 'jdoe',
+            password: 'secret123',
+            password_confirmation: 'secret123',
+            mail: 'jdoe@gmail.com',
+            mail_notification: 'none'
+          }
+        }
+      end
+    end
+    user = User.find_by_login('jdoe')
+    assert Redmine::PasswordHash::Argon2id.match?(user.hashed_password)
+    assert_nil user.salt
+    assert user.check_password?('secret123')
+  end
+
   def test_create_with_preferences
     assert_difference 'User.count' do
       post :create, :params => {
@@ -804,6 +826,19 @@ def test_update_with_password_change_by_admin_should_send_a_security_notificatio
     assert_mail_body_match 'Your password has been changed.', mail
   end
 
+  def test_update_with_password_change_should_hash_password_with_configured_algorithm
+    Redmine::Configuration.with 'password_hash_algorithm' => 'argon2id' do
+      put :update, params: {
+        id: 2,
+        user: {password: 'newpass123', password_confirmation: 'newpass123'}
+      }
+      assert_redirected_to '/users/2/edit'
+    end
+    user = User.find(2)
+    assert Redmine::PasswordHash::Argon2id.match?(user.hashed_password)
+    assert user.check_password?('newpass123')
+  end
+
   def test_update_with_generate_password_should_email_the_password
     ActionMailer::Base.deliveries.clear
 
diff --git a/test/integration/account_test.rb b/test/integration/account_test.rb
index 08e82215b..9cf1350e9 100644
--- a/test/integration/account_test.rb
+++ b/test/integration/account_test.rb
@@ -84,6 +84,7 @@ def test_autologin_should_use_autologin_cookie_name
     Redmine::Configuration.stubs(:[]).with('autologin_cookie_path').returns('/')
     Redmine::Configuration.stubs(:[]).with('autologin_cookie_secure').returns(false)
     Redmine::Configuration.stubs(:[]).with('sudo_mode_timeout').returns(15)
+    Redmine::Configuration.stubs(:[]).with('password_hash_algorithm').returns(nil)
 
     with_settings :autologin => '7' do
       assert_difference 'Token.count', 2 do
diff --git a/test/integration/api_test/authentication_test.rb b/test/integration/api_test/authentication_test.rb
index 76d74c309..113741b44 100644
--- a/test/integration/api_test/authentication_test.rb
+++ b/test/integration/api_test/authentication_test.rb
@@ -47,6 +47,31 @@ def test_api_should_deny_http_basic_auth_using_username_and_wrong_password
     assert response.headers.has_key?('WWW-Authenticate')
   end
 
+  def test_api_should_accept_http_basic_auth_using_username_and_argon2id_password
+    user = User.generate!
+    hash_password_with :argon2id, user, 'my_password'
+    get '/users/current.xml', headers: credentials(user.login, 'my_password')
+    assert_response :ok
+  end
+
+  def test_api_should_deny_http_basic_auth_using_username_and_wrong_argon2id_password
+    user = User.generate!
+    hash_password_with :argon2id, user, 'my_password'
+    get '/users/current.xml', headers: credentials(user.login, 'wrong_password')
+    assert_response :unauthorized
+  end
+
+  def test_api_http_basic_auth_should_rehash_password_with_configured_algorithm
+    user = User.generate! do |user|
+      user.password = 'my_password'
+    end
+    Redmine::Configuration.with 'password_hash_algorithm' => 'argon2id' do
+      get '/users/current.xml', headers: credentials(user.login, 'my_password')
+      assert_response :ok
+    end
+    assert Redmine::PasswordHash::Argon2id.match?(user.reload.hashed_password)
+  end
+
   def test_api_should_deny_http_basic_auth_if_twofa_is_active
     user = User.generate! do |user|
       user.password = 'my_password'
diff --git a/test/integration/sudo_mode_test.rb b/test/integration/sudo_mode_test.rb
index 0c7de64e0..a2bf15740 100644
--- a/test/integration/sudo_mode_test.rb
+++ b/test/integration/sudo_mode_test.rb
@@ -72,6 +72,29 @@ def test_add_user
     assert_kind_of User, user
   end
 
+  def test_add_user_with_argon2id_password
+    hash_password_with :argon2id, User.find(1), 'admin'
+    Redmine::Configuration.with 'password_hash_algorithm' => 'argon2id' do
+      log_user("admin", "admin")
+      expire_sudo_mode!
+      assert Redmine::PasswordHash::Argon2id.match?(User.find(1).hashed_password)
+
+      user_params = {
+        login: "psmith", firstname: "Paul",
+        lastname: "Smith", mail: "psmith@somenet.foo",
+        language: "en", password: "psmith09",
+        password_confirmation: "psmith09"
+      }
+      post "/users", params: {user: user_params, sudo_password: 'wrong'}
+      assert_response :success
+      assert_nil User.find_by_login("psmith")
+
+      post "/users", params: {user: user_params, sudo_password: 'admin'}
+      assert_response :found
+      assert_kind_of User, User.find_by_login("psmith")
+    end
+  end
+
   def test_create_member_xhr
     log_user 'admin', 'admin'
     expire_sudo_mode!
diff --git a/test/test_helper.rb b/test/test_helper.rb
index 4c1faabeb..d8195f753 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -146,6 +146,13 @@ def with_settings(options, &)
     saved_settings.each {|k, v| Setting[k] = v} if saved_settings
   end
 
+  # Stores password of user hashed with the password hash algorithm name,
+  # whatever the password_hash_algorithm setting is
+  def hash_password_with(name, user, password)
+    salt, hashed_password = Redmine::PasswordHash::ALGORITHMS.fetch(name).digest_password(password)
+    user.update_columns(salt:, hashed_password:)
+  end
+
   # Yields the block with user as the current user
   def with_current_user(user, &)
     saved_user = User.current
diff --git a/test/unit/lib/redmine/password_hash/argon2id_test.rb b/test/unit/lib/redmine/password_hash/argon2id_test.rb
new file mode 100644
index 000000000..ec35acc92
--- /dev/null
+++ b/test/unit/lib/redmine/password_hash/argon2id_test.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006-  Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+require_relative '../../../../test_helper'
+
+class Redmine::PasswordHash::Argon2idTest < ActiveSupport::TestCase
+  def test_s_digest_password
+    salt, hashed_password = Redmine::PasswordHash::Argon2id.digest_password('hello123')
+
+    assert_nil salt
+    assert hashed_password.start_with?('$argon2id$')
+  end
+
+  def test_s_match_p
+    _, hashed_password = Redmine::PasswordHash::Argon2id.digest_password('hello123')
+    _, sha1 = Redmine::PasswordHash::Sha1.digest_password('hello123')
+
+    assert Redmine::PasswordHash::Argon2id.match?(hashed_password)
+    assert_not Redmine::PasswordHash::Argon2id.match?(sha1)
+    assert_not Redmine::PasswordHash::Argon2id.match?('')
+    assert_not Redmine::PasswordHash::Argon2id.match?(nil)
+  end
+
+  def test_s_verify_password
+    _, hashed_password = Redmine::PasswordHash::Argon2id.digest_password('hello123')
+
+    assert Redmine::PasswordHash::Argon2id.verify_password('hello123', nil, hashed_password)
+    assert_not Redmine::PasswordHash::Argon2id.verify_password('hello124', nil, hashed_password)
+  end
+
+  def test_s_verify_password_should_return_false_with_broken_hash
+    assert_not Redmine::PasswordHash::Argon2id.verify_password('hello123', nil, '$argon2id$broken')
+  end
+end
diff --git a/test/unit/lib/redmine/password_hash/sha1_test.rb b/test/unit/lib/redmine/password_hash/sha1_test.rb
new file mode 100644
index 000000000..0cabbe897
--- /dev/null
+++ b/test/unit/lib/redmine/password_hash/sha1_test.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006-  Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+require_relative '../../../../test_helper'
+
+class Redmine::PasswordHash::Sha1Test < ActiveSupport::TestCase
+  def test_s_digest_password
+    salt, hashed_password = Redmine::PasswordHash::Sha1.digest_password('hello123')
+
+    assert_match /\A\h{32}\z/, salt
+    assert_match /\A\h{40}\z/, hashed_password
+  end
+
+  def test_s_match_p
+    _, hashed_password = Redmine::PasswordHash::Sha1.digest_password('hello123')
+    _, argon2id = Redmine::PasswordHash::Argon2id.digest_password('hello123')
+
+    assert Redmine::PasswordHash::Sha1.match?(hashed_password)
+    assert_not Redmine::PasswordHash::Sha1.match?(argon2id)
+    assert_not Redmine::PasswordHash::Sha1.match?('')
+    assert_not Redmine::PasswordHash::Sha1.match?(nil)
+  end
+
+  def test_s_verify_password
+    salt, hashed_password = Redmine::PasswordHash::Sha1.digest_password('hello123')
+
+    assert Redmine::PasswordHash::Sha1.verify_password('hello123', salt, hashed_password)
+    assert_not Redmine::PasswordHash::Sha1.verify_password('hello124', salt, hashed_password)
+  end
+end
diff --git a/test/unit/lib/redmine/password_hash_test.rb b/test/unit/lib/redmine/password_hash_test.rb
new file mode 100644
index 000000000..36720710d
--- /dev/null
+++ b/test/unit/lib/redmine/password_hash_test.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006-  Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+require_relative '../../../test_helper'
+
+class Redmine::PasswordHashTest < ActiveSupport::TestCase
+  def test_s_current_algorithm_should_return_configured_algorithm
+    Redmine::Configuration.with 'password_hash_algorithm' => 'sha1' do
+      assert_equal Redmine::PasswordHash::Sha1, Redmine::PasswordHash.current_algorithm
+    end
+    Redmine::Configuration.with 'password_hash_algorithm' => 'argon2id' do
+      assert_equal Redmine::PasswordHash::Argon2id, Redmine::PasswordHash.current_algorithm
+    end
+  end
+
+  def test_s_current_algorithm_should_return_sha1_by_default
+    Redmine::Configuration.with 'password_hash_algorithm' => nil do
+      assert_equal Redmine::PasswordHash::Sha1, Redmine::PasswordHash.current_algorithm
+    end
+  end
+
+  def test_s_current_algorithm_should_raise_error_with_unsupported_algorithm
+    Redmine::Configuration.with 'password_hash_algorithm' => 'md5' do
+      error = assert_raise ArgumentError do
+        Redmine::PasswordHash.current_algorithm
+      end
+      assert_equal 'Unsupported password hash algorithm: name=md5', error.message
+    end
+  end
+
+  def test_s_algorithm_for
+    _, sha1 = Redmine::PasswordHash::Sha1.digest_password('hello123')
+    _, argon2id = Redmine::PasswordHash::Argon2id.digest_password('hello123')
+
+    %w[sha1 argon2id].each do |name|
+      Redmine::Configuration.with 'password_hash_algorithm' => name do
+        assert_equal Redmine::PasswordHash::Sha1, Redmine::PasswordHash.algorithm_for(sha1)
+        assert_equal Redmine::PasswordHash::Argon2id, Redmine::PasswordHash.algorithm_for(argon2id)
+      end
+    end
+  end
+
+  def test_s_algorithm_for_should_return_nil_with_unknown_hash
+    assert_nil Redmine::PasswordHash.algorithm_for('')
+    assert_nil Redmine::PasswordHash.algorithm_for(nil)
+    assert_nil Redmine::PasswordHash.algorithm_for('$2a$12$unknown')
+  end
+end
diff --git a/test/unit/user_test.rb b/test/unit/user_test.rb
index b44db0615..144a1c285 100644
--- a/test/unit/user_test.rb
+++ b/test/unit/user_test.rb
@@ -613,6 +613,119 @@ def test_password
     assert_equal "admin", user.login
   end
 
+  def test_password_with_argon2id
+    Redmine::Configuration.with 'password_hash_algorithm' => 'argon2id' do
+      user = User.find(1)
+      user.password = "hello123"
+      user.save!
+
+      user.reload
+      assert user.hashed_password.start_with?('$argon2id$')
+      assert_nil user.salt
+      assert user.check_password?("hello123")
+      assert_not user.check_password?("hello124")
+      assert_not user.password_hash_outdated?
+      assert_equal user, User.try_to_login("admin", "hello123")
+      assert_nil User.try_to_login("admin", "hello124")
+    end
+  end
+
+  def test_argon2id_password_should_be_verified_when_algorithm_is_sha1
+    user = User.find(1)
+    Redmine::Configuration.with 'password_hash_algorithm' => 'argon2id' do
+      user.password = "hello123"
+      user.save!
+    end
+
+    Redmine::Configuration.with 'password_hash_algorithm' => 'sha1' do
+      user.reload
+      assert user.check_password?("hello123")
+      assert user.password_hash_outdated?
+    end
+  end
+
+  def test_try_to_login_should_rehash_sha1_password_with_argon2id
+    user = User.find(1)
+    token = Token.create!(user:, action: 'autologin')
+    passwd_changed_on = 1.day.ago.change(usec: 0)
+    user.update_column(:passwd_changed_on, passwd_changed_on)
+
+    Redmine::Configuration.with 'password_hash_algorithm' => 'argon2id' do
+      assert_equal user, User.try_to_login("admin", "admin")
+
+      user.reload
+      assert user.hashed_password.start_with?('$argon2id$')
+      assert_nil user.salt
+      assert_equal passwd_changed_on, user.passwd_changed_on
+      assert Token.exists?(token.id)
+      assert_equal user, User.try_to_login("admin", "admin")
+    end
+  end
+
+  def test_try_to_login_should_rehash_argon2id_password_with_sha1
+    user = User.find(1)
+    Redmine::Configuration.with 'password_hash_algorithm' => 'argon2id' do
+      user.password = "hello123"
+      user.save!
+    end
+
+    Redmine::Configuration.with 'password_hash_algorithm' => 'sha1' do
+      assert_equal user, User.try_to_login("admin", "hello123")
+
+      user.reload
+      assert_match /\A\h{40}\z/, user.hashed_password
+      assert user.salt.present?
+      assert_equal user, User.try_to_login("admin", "hello123")
+    end
+  end
+
+  def test_check_password_should_return_false_without_hashed_password
+    user = User.find(1)
+    user.hashed_password = ''
+
+    assert_not user.check_password?('admin')
+    assert_not user.check_password?('')
+  end
+
+  def test_check_password_should_return_false_with_unknown_hashed_password
+    user = User.find(1)
+    user.hashed_password = '$2a$12$unknown'
+
+    assert_not user.check_password?('admin')
+  end
+
+  def test_try_to_login_should_return_nil_without_hashed_password
+    User.find(1).update_columns(hashed_password: '')
+
+    assert_nil User.try_to_login('admin', 'admin')
+  end
+
+  def test_password_hash_outdated_p_should_return_false_without_hashed_password
+    user = User.find(1)
+    user.hashed_password = ''
+
+    Redmine::Configuration.with 'password_hash_algorithm' => 'argon2id' do
+      assert_not user.password_hash_outdated?
+    end
+  end
+
+  def test_password_hash_outdated_p_should_return_false_with_auth_source
+    user = User.find(1)
+    user.auth_source_id = 1
+
+    Redmine::Configuration.with 'password_hash_algorithm' => 'argon2id' do
+      assert_not user.password_hash_outdated?
+    end
+  end
+
+  def test_try_to_login_should_not_rehash_password_with_wrong_password
+    user = User.find(1)
+    Redmine::Configuration.with 'password_hash_algorithm' => 'argon2id' do
+      assert_nil User.try_to_login("admin", "wrong")
+      assert_equal user.hashed_password, user.reload.hashed_password
+    end
+  end
+
   def test_validate_password_length
     with_settings :password_min_length => '100' do
       user = User.new(:firstname => "new100",
