mirror of
https://github.com/discourse/discourse.git
synced 2024-11-22 11:44:49 +08:00
DEV: Migrate user passwords data to UserPassword table (#28746)
* Add migrations to ensure password hash is synced across users & user_passwords * Persist password-related data in user_passwords instead of users * Merge User#expire_old_email_tokens with User#expire_tokens_if_password_changed * Add post deploy migration to mark password-related columns from users table as read-only * Refactored UserPassword#confirm_password? and changes required to accommodate hashing the password after validations
This commit is contained in:
parent
c1f25cdf5b
commit
32e261ef73
|
@ -3,6 +3,9 @@
|
||||||
class User < ActiveRecord::Base
|
class User < ActiveRecord::Base
|
||||||
self.ignored_columns = [
|
self.ignored_columns = [
|
||||||
:old_seen_notification_id, # TODO: Remove when column is dropped. At this point, the migration to drop the column has not been written.
|
:old_seen_notification_id, # TODO: Remove when column is dropped. At this point, the migration to drop the column has not been written.
|
||||||
|
:salt, # TODO: Remove when column is dropped. At this point, the migration to drop the column has not been written.
|
||||||
|
:password_hash, # TODO: Remove when column is dropped. At this point, the migration to drop the column has not been written.
|
||||||
|
:password_algorithm, # TODO: Remove when column is dropped. At this point, the migration to drop the column has not been written.
|
||||||
]
|
]
|
||||||
|
|
||||||
include Searchable
|
include Searchable
|
||||||
|
@ -13,11 +16,6 @@ class User < ActiveRecord::Base
|
||||||
include HasDeprecatedColumns
|
include HasDeprecatedColumns
|
||||||
|
|
||||||
DEFAULT_FEATURED_BADGE_COUNT = 3
|
DEFAULT_FEATURED_BADGE_COUNT = 3
|
||||||
|
|
||||||
PASSWORD_SALT_LENGTH = 16
|
|
||||||
TARGET_PASSWORD_ALGORITHM =
|
|
||||||
"$pbkdf2-#{Rails.configuration.pbkdf2_algorithm}$i=#{Rails.configuration.pbkdf2_iterations},l=32$"
|
|
||||||
|
|
||||||
MAX_SIMILAR_USERS = 10
|
MAX_SIMILAR_USERS = 10
|
||||||
|
|
||||||
deprecate_column :flag_level, drop_from: "3.2"
|
deprecate_column :flag_level, drop_from: "3.2"
|
||||||
|
@ -190,7 +188,6 @@ class User < ActiveRecord::Base
|
||||||
after_update :change_display_name, if: :saved_change_to_name?
|
after_update :change_display_name, if: :saved_change_to_name?
|
||||||
|
|
||||||
before_save :update_usernames
|
before_save :update_usernames
|
||||||
before_save :ensure_password_is_hashed
|
|
||||||
before_save :match_primary_group_changes
|
before_save :match_primary_group_changes
|
||||||
before_save :check_if_title_is_badged_granted
|
before_save :check_if_title_is_badged_granted
|
||||||
before_save :apply_watched_words, unless: :should_skip_user_fields_validation?
|
before_save :apply_watched_words, unless: :should_skip_user_fields_validation?
|
||||||
|
@ -201,7 +198,6 @@ class User < ActiveRecord::Base
|
||||||
after_save :clear_global_notice_if_needed
|
after_save :clear_global_notice_if_needed
|
||||||
after_save :refresh_avatar
|
after_save :refresh_avatar
|
||||||
after_save :badge_grant
|
after_save :badge_grant
|
||||||
after_save :expire_old_email_tokens
|
|
||||||
after_save :index_search
|
after_save :index_search
|
||||||
after_save :check_site_contact_username
|
after_save :check_site_contact_username
|
||||||
after_save :add_to_user_directory,
|
after_save :add_to_user_directory,
|
||||||
|
@ -410,7 +406,7 @@ class User < ActiveRecord::Base
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.max_password_length
|
def self.max_password_length
|
||||||
200
|
UserPassword::MAX_PASSWORD_LENGTH
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.username_length
|
def self.username_length
|
||||||
|
@ -923,15 +919,49 @@ class User < ActiveRecord::Base
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def password=(password)
|
def password=(pw)
|
||||||
# special case for passwordless accounts
|
# special case for passwordless accounts
|
||||||
@raw_password = password if password.present?
|
return if pw.blank?
|
||||||
|
|
||||||
|
if user_password
|
||||||
|
user_password.password = pw
|
||||||
|
else
|
||||||
|
build_user_password(password: pw)
|
||||||
|
end
|
||||||
|
@raw_password = pw # still required to maintain compatibility with usage of password-related User interface
|
||||||
end
|
end
|
||||||
|
|
||||||
def password
|
def password
|
||||||
"" # so that validator doesn't complain that a password attribute doesn't exist
|
"" # so that validator doesn't complain that a password attribute doesn't exist
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def password_hash
|
||||||
|
Discourse.deprecate(
|
||||||
|
"User#password_hash is deprecated, use UserPassword#password_hash instead.",
|
||||||
|
drop_from: "3.3",
|
||||||
|
raise_error: false,
|
||||||
|
)
|
||||||
|
user_password&.password_hash
|
||||||
|
end
|
||||||
|
|
||||||
|
def password_algorithm
|
||||||
|
Discourse.deprecate(
|
||||||
|
"User#password_algorithm is deprecated, use UserPassword#password_algorithm instead.",
|
||||||
|
drop_from: "3.3",
|
||||||
|
raise_error: false,
|
||||||
|
)
|
||||||
|
user_password&.password_algorithm
|
||||||
|
end
|
||||||
|
|
||||||
|
def salt
|
||||||
|
Discourse.deprecate(
|
||||||
|
"User#password_salt is deprecated, use UserPassword#password_salt instead.",
|
||||||
|
drop_from: "3.3",
|
||||||
|
raise_error: false,
|
||||||
|
)
|
||||||
|
user_password&.password_salt
|
||||||
|
end
|
||||||
|
|
||||||
# Indicate that this is NOT a passwordless account for the purposes of validation
|
# Indicate that this is NOT a passwordless account for the purposes of validation
|
||||||
def password_required!
|
def password_required!
|
||||||
@password_required = true
|
@password_required = true
|
||||||
|
@ -946,7 +976,7 @@ class User < ActiveRecord::Base
|
||||||
end
|
end
|
||||||
|
|
||||||
def has_password?
|
def has_password?
|
||||||
password_hash.present?
|
user_password ? true : false
|
||||||
end
|
end
|
||||||
|
|
||||||
def password_validator
|
def password_validator
|
||||||
|
@ -960,20 +990,8 @@ class User < ActiveRecord::Base
|
||||||
end
|
end
|
||||||
|
|
||||||
def confirm_password?(password)
|
def confirm_password?(password)
|
||||||
return false unless password_hash && salt && password_algorithm
|
return false if !user_password
|
||||||
confirmed = self.password_hash == hash_password(password, salt, password_algorithm)
|
user_password.confirm_password?(password)
|
||||||
|
|
||||||
if confirmed && persisted? && password_algorithm != TARGET_PASSWORD_ALGORITHM
|
|
||||||
# Regenerate password_hash with new algorithm and persist
|
|
||||||
salt = SecureRandom.hex(PASSWORD_SALT_LENGTH)
|
|
||||||
update_columns(
|
|
||||||
password_algorithm: TARGET_PASSWORD_ALGORITHM,
|
|
||||||
salt: salt,
|
|
||||||
password_hash: hash_password(password, salt, TARGET_PASSWORD_ALGORITHM),
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
confirmed
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def new_user_posting_on_first_day?
|
def new_user_posting_on_first_day?
|
||||||
|
@ -1903,12 +1921,6 @@ class User < ActiveRecord::Base
|
||||||
BadgeGranter.queue_badge_grant(Badge::Trigger::UserChange, user: self)
|
BadgeGranter.queue_badge_grant(Badge::Trigger::UserChange, user: self)
|
||||||
end
|
end
|
||||||
|
|
||||||
def expire_old_email_tokens
|
|
||||||
if saved_change_to_password_hash? && !saved_change_to_id?
|
|
||||||
email_tokens.where("not expired").update_all(expired: true)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def index_search
|
def index_search
|
||||||
# force is needed as user custom fields are updated using SQL and after_save callback is not triggered
|
# force is needed as user custom fields are updated using SQL and after_save callback is not triggered
|
||||||
SearchIndexer.index(self, force: true)
|
SearchIndexer.index(self, force: true)
|
||||||
|
@ -1939,20 +1951,15 @@ class User < ActiveRecord::Base
|
||||||
email_tokens.create!(email: email, scope: EmailToken.scopes[:signup])
|
email_tokens.create!(email: email, scope: EmailToken.scopes[:signup])
|
||||||
end
|
end
|
||||||
|
|
||||||
def ensure_password_is_hashed
|
|
||||||
if @raw_password
|
|
||||||
self.salt = SecureRandom.hex(PASSWORD_SALT_LENGTH)
|
|
||||||
self.password_algorithm = TARGET_PASSWORD_ALGORITHM
|
|
||||||
self.password_hash = hash_password(@raw_password, salt, password_algorithm)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def expire_tokens_if_password_changed
|
def expire_tokens_if_password_changed
|
||||||
# NOTE: setting raw password is the only valid way of changing a password
|
# NOTE: setting raw password is the only valid way of changing a password
|
||||||
# the password field in the DB is actually hashed, nobody should be amending direct
|
# the password field in the DB is actually hashed, nobody should be amending direct
|
||||||
if @raw_password
|
if @raw_password
|
||||||
# Association in model may be out-of-sync
|
# Association in model may be out-of-sync
|
||||||
UserAuthToken.where(user_id: id).destroy_all
|
UserAuthToken.where(user_id: id).destroy_all
|
||||||
|
|
||||||
|
email_tokens.where("not expired").update_all(expired: true) if !saved_change_to_id?
|
||||||
|
|
||||||
# We should not carry this around after save
|
# We should not carry this around after save
|
||||||
@raw_password = nil
|
@raw_password = nil
|
||||||
@password_required = false
|
@password_required = false
|
||||||
|
@ -2253,8 +2260,6 @@ end
|
||||||
# updated_at :datetime not null
|
# updated_at :datetime not null
|
||||||
# name :string
|
# name :string
|
||||||
# last_posted_at :datetime
|
# last_posted_at :datetime
|
||||||
# password_hash :string(64)
|
|
||||||
# salt :string(32)
|
|
||||||
# active :boolean default(FALSE), not null
|
# active :boolean default(FALSE), not null
|
||||||
# username_lower :string(60) not null
|
# username_lower :string(60) not null
|
||||||
# last_seen_at :datetime
|
# last_seen_at :datetime
|
||||||
|
@ -2285,7 +2290,6 @@ end
|
||||||
# secure_identifier :string
|
# secure_identifier :string
|
||||||
# flair_group_id :integer
|
# flair_group_id :integer
|
||||||
# last_seen_reviewable_id :integer
|
# last_seen_reviewable_id :integer
|
||||||
# password_algorithm :string(64)
|
|
||||||
# required_fields_version :integer
|
# required_fields_version :integer
|
||||||
# seen_notification_id :bigint default(0), not null
|
# seen_notification_id :bigint default(0), not null
|
||||||
#
|
#
|
||||||
|
|
|
@ -1,14 +1,75 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class UserPassword < ActiveRecord::Base
|
class UserPassword < ActiveRecord::Base
|
||||||
validates :user_id, presence: true
|
MAX_PASSWORD_LENGTH = 200
|
||||||
|
TARGET_PASSWORD_ALGORITHM =
|
||||||
|
"$pbkdf2-#{Rails.configuration.pbkdf2_algorithm}$i=#{Rails.configuration.pbkdf2_iterations},l=32$"
|
||||||
|
PASSWORD_SALT_LENGTH = 16
|
||||||
|
|
||||||
|
belongs_to :user, required: true
|
||||||
|
|
||||||
validates :user_id, uniqueness: true
|
validates :user_id, uniqueness: true
|
||||||
validates :password_hash, presence: true, length: { is: 64 }, uniqueness: { scope: :user_id }
|
validate :password_validator
|
||||||
validates :password_salt, presence: true, length: { is: 32 }
|
before_save :ensure_password_is_hashed
|
||||||
validates :password_algorithm, presence: true, length: { maximum: 64 }
|
after_save :clear_raw_password
|
||||||
|
|
||||||
belongs_to :user
|
def password
|
||||||
|
# this getter method is still required, but we store the set password in @raw_password instead of making it easily accessible from the getter
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def password=(pw)
|
||||||
|
return if pw.blank?
|
||||||
|
|
||||||
|
self.password_hash_will_change!
|
||||||
|
@raw_password = pw
|
||||||
|
end
|
||||||
|
|
||||||
|
def password_validation_required?
|
||||||
|
@raw_password.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def confirm_password?(pw)
|
||||||
|
# nothing to confirm if this record has not been persisted yet
|
||||||
|
return false if !persisted?
|
||||||
|
return false if password_hash != hash_password(pw, password_salt, password_algorithm)
|
||||||
|
regen_password!(pw) if password_algorithm != TARGET_PASSWORD_ALGORITHM
|
||||||
|
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def clear_raw_password
|
||||||
|
@raw_password = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def password_validator
|
||||||
|
UserPasswordValidator.new(attributes: :password).validate_each(self, :password, @raw_password)
|
||||||
|
end
|
||||||
|
|
||||||
|
def hash_password(pw, salt, algorithm)
|
||||||
|
raise StandardError.new("password is too long") if pw.size > MAX_PASSWORD_LENGTH
|
||||||
|
PasswordHasher.hash_password(password: pw, salt: salt, algorithm: algorithm)
|
||||||
|
end
|
||||||
|
|
||||||
|
def ensure_password_is_hashed
|
||||||
|
return if @raw_password.blank?
|
||||||
|
|
||||||
|
self.password_salt = SecureRandom.hex(PASSWORD_SALT_LENGTH)
|
||||||
|
self.password_algorithm = TARGET_PASSWORD_ALGORITHM
|
||||||
|
self.password_hash = hash_password(@raw_password, password_salt, password_algorithm)
|
||||||
|
end
|
||||||
|
|
||||||
|
def regen_password!(pw)
|
||||||
|
# Regenerate password_hash with new algorithm and persist, we skip validation here since it has already run once when the hash was persisted the first time
|
||||||
|
salt = SecureRandom.hex(PASSWORD_SALT_LENGTH)
|
||||||
|
update_columns(
|
||||||
|
password_algorithm: TARGET_PASSWORD_ALGORITHM,
|
||||||
|
password_salt: salt,
|
||||||
|
password_hash: hash_password(pw, salt, TARGET_PASSWORD_ALGORITHM),
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# == Schema Information
|
# == Schema Information
|
||||||
|
|
|
@ -2,14 +2,6 @@
|
||||||
|
|
||||||
class UserPasswordExpirer
|
class UserPasswordExpirer
|
||||||
def self.expire_user_password(user)
|
def self.expire_user_password(user)
|
||||||
UserPassword
|
user.user_password&.update!(password_expired_at: Time.zone.now)
|
||||||
.where(user:)
|
|
||||||
.first_or_initialize
|
|
||||||
.update!(
|
|
||||||
password_hash: user.password_hash,
|
|
||||||
password_salt: user.salt,
|
|
||||||
password_algorithm: user.password_algorithm,
|
|
||||||
password_expired_at: Time.zone.now,
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -770,7 +770,6 @@ en:
|
||||||
same_as_username: "is the same as your username. Please use a more secure password."
|
same_as_username: "is the same as your username. Please use a more secure password."
|
||||||
same_as_email: "is the same as your email. Please use a more secure password."
|
same_as_email: "is the same as your email. Please use a more secure password."
|
||||||
same_as_current: "is the same as your current password."
|
same_as_current: "is the same as your current password."
|
||||||
same_as_previous: "is the same as your previous password."
|
|
||||||
same_as_name: "is the same as your name."
|
same_as_name: "is the same as your name."
|
||||||
unique_characters: "has too many repeated characters. Please use a more secure password."
|
unique_characters: "has too many repeated characters. Please use a more secure password."
|
||||||
username:
|
username:
|
||||||
|
@ -780,6 +779,15 @@ en:
|
||||||
ip_address:
|
ip_address:
|
||||||
blocked: "New registrations are not allowed from your IP address."
|
blocked: "New registrations are not allowed from your IP address."
|
||||||
max_new_accounts_per_registration_ip: "New registrations are not allowed from your IP address (maximum limit reached). Contact a staff member."
|
max_new_accounts_per_registration_ip: "New registrations are not allowed from your IP address (maximum limit reached). Contact a staff member."
|
||||||
|
user_password:
|
||||||
|
attributes:
|
||||||
|
password:
|
||||||
|
common: "is one of the 10000 most common passwords. Please use a more secure password."
|
||||||
|
same_as_username: "is the same as your username. Please use a more secure password."
|
||||||
|
same_as_email: "is the same as your email. Please use a more secure password."
|
||||||
|
same_as_current: "is the same as your current password."
|
||||||
|
same_as_name: "is the same as your name."
|
||||||
|
unique_characters: "has too many repeated characters. Please use a more secure password."
|
||||||
user_profile:
|
user_profile:
|
||||||
attributes:
|
attributes:
|
||||||
featured_topic_id:
|
featured_topic_id:
|
||||||
|
|
|
@ -0,0 +1,67 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
class AddTriggerToUsersToSyncUserPasswords < ActiveRecord::Migration[7.1]
|
||||||
|
def up
|
||||||
|
# necessary for postgres < v14 which does not have CREATE OR REPLACE TRIGGER
|
||||||
|
execute <<~SQL.squish
|
||||||
|
DROP TRIGGER IF EXISTS
|
||||||
|
users_password_sync ON users;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
execute <<~SQL.squish
|
||||||
|
CREATE OR REPLACE FUNCTION mirror_user_password_data() RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO user_passwords (user_id, password_hash, password_salt, password_algorithm, password_expired_at, created_at, updated_at)
|
||||||
|
VALUES (NEW.id, NEW.password_hash, NEW.salt, NEW.password_algorithm, NULL, now(), now())
|
||||||
|
ON CONFLICT(user_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
password_hash = EXCLUDED.password_hash,
|
||||||
|
password_salt = EXCLUDED.password_salt,
|
||||||
|
password_algorithm = EXCLUDED.password_algorithm,
|
||||||
|
password_expired_at = EXCLUDED.password_expired_at,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE user_passwords.password_hash <> EXCLUDED.password_hash;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
execute <<~SQL.squish
|
||||||
|
CREATE TRIGGER users_password_sync
|
||||||
|
AFTER INSERT OR UPDATE OF password_hash ON users
|
||||||
|
FOR EACH ROW
|
||||||
|
WHEN (NEW.password_hash IS NOT NULL)
|
||||||
|
EXECUTE PROCEDURE mirror_user_password_data();
|
||||||
|
SQL
|
||||||
|
|
||||||
|
execute <<~SQL.squish
|
||||||
|
DROP TRIGGER IF EXISTS
|
||||||
|
users_password_sync_on_delete_password ON users;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
execute <<~SQL.squish
|
||||||
|
CREATE OR REPLACE FUNCTION delete_user_password() RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM user_passwords WHERE user_id = NEW.id;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
execute <<~SQL.squish
|
||||||
|
CREATE TRIGGER users_password_sync_on_delete_password
|
||||||
|
AFTER UPDATE OF password_hash ON users
|
||||||
|
FOR EACH ROW
|
||||||
|
WHEN (NEW.password_hash IS NULL)
|
||||||
|
EXECUTE PROCEDURE delete_user_password();
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
execute <<~SQL.squish
|
||||||
|
DROP TRIGGER IF EXISTS users_password_sync_on_delete_password ON users;
|
||||||
|
DROP FUNCTION IF EXISTS delete_user_password;
|
||||||
|
DROP TRIGGER IF EXISTS users_password_sync ON users;
|
||||||
|
DROP FUNCTION IF EXISTS mirror_user_password_data;
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,33 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
class BackfillUserPasswordsFromUsers < ActiveRecord::Migration[7.1]
|
||||||
|
disable_ddl_transaction!
|
||||||
|
BATCH_SIZE = 50_000
|
||||||
|
|
||||||
|
def up
|
||||||
|
min_id, max_id = DB.query_single(<<~SQL.squish)
|
||||||
|
SELECT MIN(id), MAX(id)
|
||||||
|
FROM users
|
||||||
|
WHERE password_hash IS NOT NULL AND salt IS NOT NULL AND password_algorithm IS NOT NULL;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
return if max_id.nil?
|
||||||
|
|
||||||
|
(min_id..max_id).step(BATCH_SIZE) { |start_id| execute <<~SQL }
|
||||||
|
INSERT INTO user_passwords (user_id, password_hash, password_salt, password_algorithm, password_expired_at, created_at, updated_at)
|
||||||
|
SELECT id, password_hash, salt, password_algorithm, NULL, now(), now()
|
||||||
|
FROM users
|
||||||
|
WHERE id >= #{start_id} AND id < #{start_id + BATCH_SIZE} AND password_hash IS NOT NULL AND salt IS NOT NULL AND password_algorithm IS NOT NULL
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET
|
||||||
|
password_hash = EXCLUDED.password_hash,
|
||||||
|
password_salt = EXCLUDED.password_salt,
|
||||||
|
password_algorithm = EXCLUDED.password_algorithm,
|
||||||
|
password_expired_at = EXCLUDED.password_expired_at,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE user_passwords.password_hash <> EXCLUDED.password_hash
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
raise ActiveRecord::IrreversibleMigration
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,20 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
class MakePasswordColumnsFromUsersReadOnly < ActiveRecord::Migration[7.1]
|
||||||
|
def up
|
||||||
|
# remove invalid triggers/functions dependent on the columns to be dropped
|
||||||
|
execute <<~SQL.squish
|
||||||
|
DROP TRIGGER IF EXISTS users_password_sync_on_delete_password ON users;
|
||||||
|
DROP FUNCTION IF EXISTS delete_user_password;
|
||||||
|
DROP TRIGGER IF EXISTS users_password_sync ON users;
|
||||||
|
DROP FUNCTION IF EXISTS mirror_user_password_data;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
%i[password_hash salt password_algorithm].each do |column|
|
||||||
|
Migration::ColumnDropper.mark_readonly(:users, column)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
raise ActiveRecord::IrreversibleMigration
|
||||||
|
end
|
||||||
|
end
|
|
@ -4,32 +4,6 @@ class PasswordValidator < ActiveModel::EachValidator
|
||||||
def validate_each(record, attribute, value)
|
def validate_each(record, attribute, value)
|
||||||
return unless record.password_validation_required?
|
return unless record.password_validation_required?
|
||||||
|
|
||||||
if value.nil?
|
record.errors.add(attribute, :blank) if value.blank?
|
||||||
record.errors.add(attribute, :blank)
|
|
||||||
elsif value.length < SiteSetting.min_admin_password_length &&
|
|
||||||
(record.admin? || is_developer?(record.email))
|
|
||||||
record.errors.add(attribute, :too_short, count: SiteSetting.min_admin_password_length)
|
|
||||||
elsif value.length < SiteSetting.min_password_length
|
|
||||||
record.errors.add(attribute, :too_short, count: SiteSetting.min_password_length)
|
|
||||||
elsif record.username.present? && value == record.username
|
|
||||||
record.errors.add(attribute, :same_as_username)
|
|
||||||
elsif record.name.present? && value == record.name
|
|
||||||
record.errors.add(attribute, :same_as_name)
|
|
||||||
elsif record.email.present? && value == record.email
|
|
||||||
record.errors.add(attribute, :same_as_email)
|
|
||||||
elsif record.confirm_password?(value)
|
|
||||||
record.errors.add(attribute, :same_as_current)
|
|
||||||
elsif record.password_expired?(value)
|
|
||||||
record.errors.add(attribute, :same_as_previous)
|
|
||||||
elsif SiteSetting.block_common_passwords && CommonPasswords.common_password?(value)
|
|
||||||
record.errors.add(attribute, :common)
|
|
||||||
elsif value.chars.uniq.length < SiteSetting.password_unique_characters
|
|
||||||
record.errors.add(attribute, :unique_characters)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def is_developer?(value)
|
|
||||||
Rails.configuration.respond_to?(:developer_emails) &&
|
|
||||||
Rails.configuration.developer_emails.include?(value)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
36
lib/validators/user_password_validator.rb
Normal file
36
lib/validators/user_password_validator.rb
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class UserPasswordValidator < ActiveModel::EachValidator
|
||||||
|
def validate_each(record, attribute, value)
|
||||||
|
return unless record.password_validation_required?
|
||||||
|
user = record.user
|
||||||
|
|
||||||
|
if value.nil?
|
||||||
|
record.errors.add(attribute, :blank)
|
||||||
|
elsif value.length < SiteSetting.min_admin_password_length &&
|
||||||
|
(user.admin? || is_developer?(user.email))
|
||||||
|
record.errors.add(attribute, :too_short, count: SiteSetting.min_admin_password_length)
|
||||||
|
elsif value.length < SiteSetting.min_password_length
|
||||||
|
record.errors.add(attribute, :too_short, count: SiteSetting.min_password_length)
|
||||||
|
elsif user.username.present? && value == user.username
|
||||||
|
record.errors.add(attribute, :same_as_username)
|
||||||
|
elsif user.name.present? && value == user.name
|
||||||
|
record.errors.add(attribute, :same_as_name)
|
||||||
|
elsif user.email.present? && value == user.email
|
||||||
|
record.errors.add(attribute, :same_as_email)
|
||||||
|
elsif record.password_hash_changed? && record.confirm_password?(value)
|
||||||
|
record.errors.add(attribute, :same_as_current)
|
||||||
|
elsif SiteSetting.block_common_passwords && CommonPasswords.common_password?(value)
|
||||||
|
record.errors.add(attribute, :common)
|
||||||
|
elsif value.chars.uniq.length < SiteSetting.password_unique_characters
|
||||||
|
record.errors.add(attribute, :unique_characters)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def is_developer?(value)
|
||||||
|
Rails.configuration.respond_to?(:developer_emails) &&
|
||||||
|
Rails.configuration.developer_emails.include?(value)
|
||||||
|
end
|
||||||
|
end
|
|
@ -3,17 +3,19 @@
|
||||||
Fabricator(:user_password) do
|
Fabricator(:user_password) do
|
||||||
transient password: "myawesomefakepassword"
|
transient password: "myawesomefakepassword"
|
||||||
|
|
||||||
user { Fabricate(:user) }
|
user { Fabricate(:user, password: nil) }
|
||||||
password_salt { SecureRandom.hex(User::PASSWORD_SALT_LENGTH) }
|
password_salt { SecureRandom.hex(UserPassword::PASSWORD_SALT_LENGTH) }
|
||||||
password_algorithm { User::TARGET_PASSWORD_ALGORITHM }
|
password_algorithm { UserPassword::TARGET_PASSWORD_ALGORITHM }
|
||||||
|
|
||||||
after_build do |user_password, transients|
|
after_build do |user_password, transients|
|
||||||
user_password.password_hash =
|
if transients[:password].present?
|
||||||
PasswordHasher.hash_password(
|
user_password.password_hash =
|
||||||
password: transients[:password],
|
PasswordHasher.hash_password(
|
||||||
salt: user_password.password_salt,
|
password: transients[:password],
|
||||||
algorithm: user_password.password_algorithm,
|
salt: user_password.password_salt,
|
||||||
)
|
algorithm: user_password.password_algorithm,
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,6 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
RSpec.describe PasswordValidator do
|
RSpec.describe PasswordValidator do
|
||||||
def password_error_message(key)
|
|
||||||
I18n.t("activerecord.errors.models.user.attributes.password.#{key}")
|
|
||||||
end
|
|
||||||
|
|
||||||
subject(:validate) { validator.validate_each(record, :password, @password) }
|
subject(:validate) { validator.validate_each(record, :password, @password) }
|
||||||
|
|
||||||
let(:validator) { described_class.new(attributes: :password) }
|
let(:validator) { described_class.new(attributes: :password) }
|
||||||
|
@ -16,152 +12,16 @@ RSpec.describe PasswordValidator do
|
||||||
u
|
u
|
||||||
end
|
end
|
||||||
|
|
||||||
context "when password is not common" do
|
it "adds an error when password is blank" do
|
||||||
before { CommonPasswords.stubs(:common_password?).returns(false) }
|
@password = ""
|
||||||
|
|
||||||
context "when min password length is 8" do
|
|
||||||
before { SiteSetting.min_password_length = 8 }
|
|
||||||
|
|
||||||
it "doesn't add an error when password is good" do
|
|
||||||
@password = "weron235alsfn234"
|
|
||||||
validate
|
|
||||||
expect(record.errors[:password]).not_to be_present
|
|
||||||
end
|
|
||||||
|
|
||||||
it "adds an error when password is too short" do
|
|
||||||
@password = "p"
|
|
||||||
validate
|
|
||||||
expect(record.errors[:password]).to be_present
|
|
||||||
end
|
|
||||||
|
|
||||||
it "adds an error when password is blank" do
|
|
||||||
@password = ""
|
|
||||||
validate
|
|
||||||
expect(record.errors[:password]).to be_present
|
|
||||||
end
|
|
||||||
|
|
||||||
it "adds an error when password is nil" do
|
|
||||||
@password = nil
|
|
||||||
validate
|
|
||||||
expect(record.errors[:password]).to be_present
|
|
||||||
end
|
|
||||||
|
|
||||||
it "adds an error when user is admin and password is less than 15 chars" do
|
|
||||||
SiteSetting.min_admin_password_length = 15
|
|
||||||
|
|
||||||
@password = "12345678912"
|
|
||||||
record.admin = true
|
|
||||||
validate
|
|
||||||
expect(record.errors[:password]).to be_present
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context "when min password length is 12" do
|
|
||||||
before { SiteSetting.min_password_length = 12 }
|
|
||||||
|
|
||||||
it "adds an error when password length is 11" do
|
|
||||||
@password = "gt38sdt92bv"
|
|
||||||
validate
|
|
||||||
expect(record.errors[:password]).to be_present
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context "when password is commonly used" do
|
|
||||||
before do
|
|
||||||
SiteSetting.min_password_length = 8
|
|
||||||
CommonPasswords.stubs(:common_password?).returns(true)
|
|
||||||
end
|
|
||||||
|
|
||||||
it "adds an error when block_common_passwords is enabled" do
|
|
||||||
SiteSetting.block_common_passwords = true
|
|
||||||
@password = "password"
|
|
||||||
validate
|
|
||||||
expect(record.errors[:password]).to include(password_error_message(:common))
|
|
||||||
end
|
|
||||||
|
|
||||||
it "doesn't add an error when block_common_passwords is disabled" do
|
|
||||||
SiteSetting.block_common_passwords = false
|
|
||||||
@password = "password"
|
|
||||||
validate
|
|
||||||
expect(record.errors[:password]).not_to be_present
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context "when password_unique_characters is 5" do
|
|
||||||
before { SiteSetting.password_unique_characters = 5 }
|
|
||||||
|
|
||||||
it "adds an error when there are too few unique characters" do
|
|
||||||
SiteSetting.password_unique_characters = 6
|
|
||||||
@password = "aaaaaa5432"
|
|
||||||
validate
|
|
||||||
expect(record.errors[:password]).to include(password_error_message(:unique_characters))
|
|
||||||
end
|
|
||||||
|
|
||||||
it "doesn't add an error when there are enough unique characters" do
|
|
||||||
@password = "aaaaa12345"
|
|
||||||
validate
|
|
||||||
expect(record.errors[:password]).not_to be_present
|
|
||||||
end
|
|
||||||
|
|
||||||
it "counts capital letters as different" do
|
|
||||||
@password = "aaaAaa1234"
|
|
||||||
validate
|
|
||||||
expect(record.errors[:password]).not_to be_present
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it "adds an error when password is the same as the username" do
|
|
||||||
@password = "porkchops1234"
|
|
||||||
record.username = @password
|
|
||||||
validate
|
validate
|
||||||
expect(record.errors[:password]).to include(password_error_message(:same_as_username))
|
expect(record.errors[:password]).to be_present
|
||||||
end
|
end
|
||||||
|
|
||||||
it "adds an error when password is the same as the name" do
|
it "adds an error when password is nil" do
|
||||||
@password = "myawesomepassword"
|
@password = nil
|
||||||
record.name = @password
|
|
||||||
validate
|
validate
|
||||||
expect(record.errors[:password]).to include(password_error_message(:same_as_name))
|
expect(record.errors[:password]).to be_present
|
||||||
end
|
|
||||||
|
|
||||||
it "adds an error when password is the same as the email" do
|
|
||||||
@password = "pork@chops.com"
|
|
||||||
record.email = @password
|
|
||||||
validate
|
|
||||||
expect(record.errors[:password]).to include(password_error_message(:same_as_email))
|
|
||||||
end
|
|
||||||
|
|
||||||
it "adds an error when new password is same as current password" do
|
|
||||||
@password = "mypetsname"
|
|
||||||
record.save!
|
|
||||||
record.reload
|
|
||||||
record.password = @password
|
|
||||||
validate
|
|
||||||
expect(record.errors[:password]).to include(password_error_message(:same_as_current))
|
|
||||||
end
|
|
||||||
|
|
||||||
it "adds an error when new password is same as a previous password" do
|
|
||||||
@password = "thisisaoldpassword"
|
|
||||||
record.save!
|
|
||||||
record.reload
|
|
||||||
|
|
||||||
new_password = "thisisanewpassword"
|
|
||||||
|
|
||||||
_expired_user_password =
|
|
||||||
Fabricate(
|
|
||||||
:expired_user_password,
|
|
||||||
password: new_password,
|
|
||||||
password_algorithm: record.password_algorithm,
|
|
||||||
password_salt: record.salt,
|
|
||||||
user: record,
|
|
||||||
)
|
|
||||||
|
|
||||||
record.password = new_password
|
|
||||||
@password = new_password
|
|
||||||
validate
|
|
||||||
|
|
||||||
expect(record.errors[:password]).to include(password_error_message(:same_as_previous))
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it "validation required if password is required" do
|
it "validation required if password is required" do
|
||||||
|
@ -191,11 +51,5 @@ RSpec.describe PasswordValidator do
|
||||||
@password = "mygameshow"
|
@password = "mygameshow"
|
||||||
expect(record.password_validation_required?).to eq(true)
|
expect(record.password_validation_required?).to eq(true)
|
||||||
end
|
end
|
||||||
|
|
||||||
it "adds an error even password not required" do
|
|
||||||
@password = "p"
|
|
||||||
validate
|
|
||||||
expect(record.errors[:password]).to be_present
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
129
spec/lib/validators/user_password_validator_spec.rb
Normal file
129
spec/lib/validators/user_password_validator_spec.rb
Normal file
|
@ -0,0 +1,129 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
RSpec.describe UserPasswordValidator do
|
||||||
|
def password_error_message(key)
|
||||||
|
I18n.t("activerecord.errors.models.user_password.attributes.password.#{key}")
|
||||||
|
end
|
||||||
|
|
||||||
|
subject(:validate) { validator.validate_each(record, :password, @password) }
|
||||||
|
|
||||||
|
let(:validator) { described_class.new(attributes: :password) }
|
||||||
|
|
||||||
|
# fabrication doesn't work here as it somehow bypasses the password= setter logic
|
||||||
|
let(:record) do
|
||||||
|
UserPassword.build(password: @password, user: Fabricate.build(:user, password: nil))
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when password is not common" do
|
||||||
|
before { CommonPasswords.stubs(:common_password?).returns(false) }
|
||||||
|
|
||||||
|
context "when min password length is 8" do
|
||||||
|
before { SiteSetting.min_password_length = 8 }
|
||||||
|
|
||||||
|
it "doesn't add an error when password is good" do
|
||||||
|
@password = "weron235alsfn234"
|
||||||
|
validate
|
||||||
|
expect(record.errors[:password]).not_to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it "adds an error when password is too short" do
|
||||||
|
@password = "p"
|
||||||
|
validate
|
||||||
|
expect(record.errors[:password]).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it "adds an error when user is admin and password is less than 15 chars" do
|
||||||
|
SiteSetting.min_admin_password_length = 15
|
||||||
|
|
||||||
|
@password = "12345678912"
|
||||||
|
record.user.admin = true
|
||||||
|
validate
|
||||||
|
expect(record.errors[:password]).to be_present
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when min password length is 12" do
|
||||||
|
before { SiteSetting.min_password_length = 12 }
|
||||||
|
|
||||||
|
it "adds an error when password length is 11" do
|
||||||
|
@password = "gt38sdt92bv"
|
||||||
|
validate
|
||||||
|
expect(record.errors[:password]).to be_present
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
context "when password is commonly used" do
|
||||||
|
before do
|
||||||
|
SiteSetting.min_password_length = 8
|
||||||
|
CommonPasswords.stubs(:common_password?).returns(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "adds an error when block_common_passwords is enabled" do
|
||||||
|
SiteSetting.block_common_passwords = true
|
||||||
|
@password = "password"
|
||||||
|
validate
|
||||||
|
expect(record.errors[:password]).to include(password_error_message(:common))
|
||||||
|
end
|
||||||
|
|
||||||
|
it "doesn't add an error when block_common_passwords is disabled" do
|
||||||
|
SiteSetting.block_common_passwords = false
|
||||||
|
@password = "password"
|
||||||
|
validate
|
||||||
|
expect(record.errors[:password]).not_to be_present
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when password_unique_characters is 5" do
|
||||||
|
before { SiteSetting.password_unique_characters = 5 }
|
||||||
|
|
||||||
|
it "adds an error when there are too few unique characters" do
|
||||||
|
SiteSetting.password_unique_characters = 6
|
||||||
|
@password = "aaaaaa5432"
|
||||||
|
validate
|
||||||
|
expect(record.errors[:password]).to include(password_error_message(:unique_characters))
|
||||||
|
end
|
||||||
|
|
||||||
|
it "doesn't add an error when there are enough unique characters" do
|
||||||
|
@password = "aaaaa12345"
|
||||||
|
validate
|
||||||
|
expect(record.errors[:password]).not_to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it "counts capital letters as different" do
|
||||||
|
@password = "aaaAaa1234"
|
||||||
|
validate
|
||||||
|
expect(record.errors[:password]).not_to be_present
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "adds an error when password is the same as the username" do
|
||||||
|
@password = "porkchops1234"
|
||||||
|
record.user.username = @password
|
||||||
|
validate
|
||||||
|
expect(record.errors[:password]).to include(password_error_message(:same_as_username))
|
||||||
|
end
|
||||||
|
|
||||||
|
it "adds an error when password is the same as the name" do
|
||||||
|
@password = "myawesomepassword"
|
||||||
|
record.user.name = @password
|
||||||
|
validate
|
||||||
|
expect(record.errors[:password]).to include(password_error_message(:same_as_name))
|
||||||
|
end
|
||||||
|
|
||||||
|
it "adds an error when password is the same as the email" do
|
||||||
|
@password = "pork@chops.com"
|
||||||
|
record.user.email = @password
|
||||||
|
validate
|
||||||
|
expect(record.errors[:password]).to include(password_error_message(:same_as_email))
|
||||||
|
end
|
||||||
|
|
||||||
|
it "adds an error when new password is same as current password" do
|
||||||
|
@password = "mypetsname"
|
||||||
|
record.save!
|
||||||
|
record.reload
|
||||||
|
record.password = @password
|
||||||
|
validate
|
||||||
|
|
||||||
|
expect(record.errors[:password]).to include(password_error_message(:same_as_current))
|
||||||
|
end
|
||||||
|
end
|
|
@ -150,7 +150,7 @@ RSpec.describe InviteRedeemer do
|
||||||
error = e
|
error = e
|
||||||
end
|
end
|
||||||
expect(error).to be_present
|
expect(error).to be_present
|
||||||
expect(error.record.errors[:password]).to be_present
|
expect(error.record.errors.errors[0].attribute).to eq :"user_password.password"
|
||||||
end
|
end
|
||||||
|
|
||||||
it "should unstage user" do
|
it "should unstage user" do
|
||||||
|
|
|
@ -1,97 +1,71 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
RSpec.describe UserPassword do
|
RSpec.describe UserPassword do
|
||||||
|
describe "#confirm_password?" do
|
||||||
|
context "when input password is same as saved password" do
|
||||||
|
let(:pw) { SecureRandom.hex }
|
||||||
|
|
||||||
|
it "returns true after saving password for the first time" do
|
||||||
|
u = Fabricate(:user, password: nil)
|
||||||
|
u.password = pw
|
||||||
|
u.save!
|
||||||
|
expect(u.confirm_password?(pw)).to eq true
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns true after updating existing password" do
|
||||||
|
pw = SecureRandom.hex
|
||||||
|
u = Fabricate(:user, password: "initial_password_123")
|
||||||
|
u.update!(password: pw)
|
||||||
|
expect(u.confirm_password?(pw)).to eq true
|
||||||
|
end
|
||||||
|
|
||||||
|
it "updates the algorithm if it's outdated and password_hash, and returns true" do
|
||||||
|
user_password = Fabricate(:user, password: pw).user_password
|
||||||
|
old_algorithm = "$pbkdf2-sha256$i=5,l=32$"
|
||||||
|
old_hash =
|
||||||
|
described_class.new.send(:hash_password, pw, user_password.password_salt, old_algorithm)
|
||||||
|
user_password.update_columns(password_algorithm: old_algorithm, password_hash: old_hash)
|
||||||
|
|
||||||
|
result = nil
|
||||||
|
expect { result = user_password.confirm_password?(pw) }.to change {
|
||||||
|
user_password.password_algorithm
|
||||||
|
}.from(old_algorithm).to(described_class::TARGET_PASSWORD_ALGORITHM)
|
||||||
|
expect(result).to eq true
|
||||||
|
|
||||||
|
new_hash =
|
||||||
|
described_class.new.send(
|
||||||
|
:hash_password,
|
||||||
|
pw,
|
||||||
|
user_password.password_salt,
|
||||||
|
user_password.password_algorithm,
|
||||||
|
)
|
||||||
|
expect(user_password.password_hash).to eq(new_hash)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when input password is not the same as saved password" do
|
||||||
|
let(:actual_pw) { SecureRandom.hex }
|
||||||
|
|
||||||
|
it "returns false" do
|
||||||
|
u = Fabricate(:user, password: actual_pw)
|
||||||
|
expect(u.confirm_password?(SecureRandom.hex)).to eq false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when used on an unpersisted record" do
|
||||||
|
it "returns false" do
|
||||||
|
user_password = Fabricate.build(:user_password, user: nil)
|
||||||
|
expect(user_password.confirm_password?(user_password.password)).to eq false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
context "for validations" do
|
context "for validations" do
|
||||||
it "should validate presence of user_id" do
|
it "should validate presence of user_id" do
|
||||||
user_password = Fabricate.build(:user_password, user_id: nil)
|
user_password = Fabricate.build(:user_password, user: nil)
|
||||||
|
|
||||||
expect(user_password).not_to be_valid
|
expect(user_password).not_to be_valid
|
||||||
expect(user_password.errors[:user_id]).to include("can't be blank")
|
expect(user_password.errors[:user]).to include("must exist")
|
||||||
end
|
|
||||||
|
|
||||||
it "should validate presence of password_hash" do
|
|
||||||
user_password = Fabricate.build(:user_password)
|
|
||||||
user_password.password_hash = nil
|
|
||||||
|
|
||||||
expect(user_password).not_to be_valid
|
|
||||||
expect(user_password.errors[:password_hash]).to include("can't be blank")
|
|
||||||
end
|
|
||||||
|
|
||||||
it "should validate that password_hash is 64 characters long" do
|
|
||||||
user_password = Fabricate.build(:user_password)
|
|
||||||
user_password.password_hash = "a" * 65
|
|
||||||
|
|
||||||
expect(user_password).not_to be_valid
|
|
||||||
|
|
||||||
expect(user_password.errors[:password_hash]).to include(
|
|
||||||
"is the wrong length (should be 64 characters)",
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
it "should validate uniqueness of password_hash scoped to user_id" do
|
|
||||||
password = "password"
|
|
||||||
user_password_1 = Fabricate(:user_password, password:)
|
|
||||||
user = user_password_1.user
|
|
||||||
|
|
||||||
user_password_2 =
|
|
||||||
Fabricate.build(
|
|
||||||
:user_password,
|
|
||||||
user:,
|
|
||||||
password:,
|
|
||||||
password_salt: user_password_1.password_salt,
|
|
||||||
password_algorithm: user_password_1.password_algorithm,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(user_password_2).not_to be_valid
|
|
||||||
expect(user_password_2.errors[:password_hash]).to include("has already been taken")
|
|
||||||
end
|
|
||||||
|
|
||||||
it "should validate uniqueness of user_id scoped to password_expired_at" do
|
|
||||||
user = Fabricate(:user)
|
|
||||||
user_password_1 = Fabricate.create(:user_password, user:, password_expired_at: nil)
|
|
||||||
|
|
||||||
user_password_2 =
|
|
||||||
Fabricate.build(:user_password, user: user_password_1.user, password_expired_at: nil)
|
|
||||||
|
|
||||||
expect(user_password_2).not_to be_valid
|
|
||||||
expect(user_password_2.errors[:user_id]).to include("has already been taken")
|
|
||||||
end
|
|
||||||
|
|
||||||
it "should validate presence of password_salt" do
|
|
||||||
user_password = Fabricate.build(:user_password)
|
|
||||||
user_password.password_salt = nil
|
|
||||||
|
|
||||||
expect(user_password).not_to be_valid
|
|
||||||
expect(user_password.errors[:password_salt]).to include("can't be blank")
|
|
||||||
end
|
|
||||||
|
|
||||||
it "should validate that password_salt is 32 characters long" do
|
|
||||||
user_password = Fabricate.build(:user_password)
|
|
||||||
user_password.password_salt = "a" * 33
|
|
||||||
|
|
||||||
expect(user_password).not_to be_valid
|
|
||||||
|
|
||||||
expect(user_password.errors[:password_salt]).to include(
|
|
||||||
"is the wrong length (should be 32 characters)",
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
it "should validate presence of password_algorithm" do
|
|
||||||
user_password = Fabricate.build(:user_password)
|
|
||||||
user_password.password_algorithm = nil
|
|
||||||
|
|
||||||
expect(user_password).not_to be_valid
|
|
||||||
expect(user_password.errors[:password_algorithm]).to include("can't be blank")
|
|
||||||
end
|
|
||||||
|
|
||||||
it "should validate that password_algorithm is at most 64 characters long" do
|
|
||||||
user_password = Fabricate.build(:user_password)
|
|
||||||
user_password.password_algorithm = "a" * 65
|
|
||||||
|
|
||||||
expect(user_password).not_to be_valid
|
|
||||||
expect(user_password.errors[:password_algorithm]).to include(
|
|
||||||
"is too long (maximum is 64 characters)",
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1909,7 +1909,7 @@ RSpec.describe User do
|
||||||
describe "hash_passwords" do
|
describe "hash_passwords" do
|
||||||
let(:too_long) { "x" * (User.max_password_length + 1) }
|
let(:too_long) { "x" * (User.max_password_length + 1) }
|
||||||
|
|
||||||
def hash(password, salt, algorithm = User::TARGET_PASSWORD_ALGORITHM)
|
def hash(password, salt, algorithm = UserPassword::TARGET_PASSWORD_ALGORITHM)
|
||||||
User.new.send(:hash_password, password, salt, algorithm)
|
User.new.send(:hash_password, password, salt, algorithm)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -1930,17 +1930,17 @@ RSpec.describe User do
|
||||||
end
|
end
|
||||||
|
|
||||||
it "uses the target algorithm for new users" do
|
it "uses the target algorithm for new users" do
|
||||||
expect(user.password_algorithm).to eq(User::TARGET_PASSWORD_ALGORITHM)
|
expect(user.password_algorithm).to eq(UserPassword::TARGET_PASSWORD_ALGORITHM)
|
||||||
end
|
end
|
||||||
|
|
||||||
it "can use an older algorithm to verify existing passwords, then upgrade" do
|
it "can use an older algorithm to verify existing passwords, then upgrade" do
|
||||||
old_algorithm = "$pbkdf2-sha256$i=5,l=32$"
|
old_algorithm = "$pbkdf2-sha256$i=5,l=32$"
|
||||||
expect(old_algorithm).not_to eq(User::TARGET_PASSWORD_ALGORITHM)
|
expect(old_algorithm).not_to eq(UserPassword::TARGET_PASSWORD_ALGORITHM)
|
||||||
|
|
||||||
password = "poutine"
|
password = "poutine"
|
||||||
old_hash = hash(password, user.salt, old_algorithm)
|
old_hash = hash(password, user.salt, old_algorithm)
|
||||||
|
|
||||||
user.update!(password_algorithm: old_algorithm, password_hash: old_hash)
|
user.user_password.update_columns(password_algorithm: old_algorithm, password_hash: old_hash)
|
||||||
|
|
||||||
expect(user.password_algorithm).to eq(old_algorithm)
|
expect(user.password_algorithm).to eq(old_algorithm)
|
||||||
expect(user.password_hash).to eq(old_hash)
|
expect(user.password_hash).to eq(old_hash)
|
||||||
|
@ -1954,13 +1954,13 @@ RSpec.describe User do
|
||||||
expect(user.confirm_password?(password)).to eq(true)
|
expect(user.confirm_password?(password)).to eq(true)
|
||||||
|
|
||||||
# Auto-upgrades to new algorithm
|
# Auto-upgrades to new algorithm
|
||||||
expected_new_hash = hash(password, user.salt, User::TARGET_PASSWORD_ALGORITHM)
|
expected_new_hash = hash(password, user.salt, UserPassword::TARGET_PASSWORD_ALGORITHM)
|
||||||
expect(user.password_algorithm).to eq(User::TARGET_PASSWORD_ALGORITHM)
|
expect(user.password_algorithm).to eq(UserPassword::TARGET_PASSWORD_ALGORITHM)
|
||||||
expect(user.password_hash).to eq(expected_new_hash)
|
expect(user.password_hash).to eq(expected_new_hash)
|
||||||
|
|
||||||
# And persists to the db
|
# And persists to the db
|
||||||
user.reload
|
user.reload
|
||||||
expect(user.password_algorithm).to eq(User::TARGET_PASSWORD_ALGORITHM)
|
expect(user.password_algorithm).to eq(UserPassword::TARGET_PASSWORD_ALGORITHM)
|
||||||
expect(user.password_hash).to eq(expected_new_hash)
|
expect(user.password_hash).to eq(expected_new_hash)
|
||||||
|
|
||||||
# And can still log in
|
# And can still log in
|
||||||
|
|
|
@ -2023,19 +2023,10 @@ RSpec.describe SessionController do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "when user's password has been marked as expired" do
|
describe "when user's password has been marked as expired" do
|
||||||
let!(:expired_user_password) do
|
|
||||||
Fabricate(
|
|
||||||
:expired_user_password,
|
|
||||||
user:,
|
|
||||||
password: "myawesomepassword",
|
|
||||||
password_salt: user.salt,
|
|
||||||
password_algorithm: user.password_algorithm,
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
before { RateLimiter.enable }
|
before { RateLimiter.enable }
|
||||||
|
|
||||||
it "should return an error response code with the right error message" do
|
it "should return an error response code with the right error message" do
|
||||||
|
UserPasswordExpirer.expire_user_password(user)
|
||||||
post "/session.json", params: { login: user.username, password: "myawesomepassword" }
|
post "/session.json", params: { login: user.username, password: "myawesomepassword" }
|
||||||
|
|
||||||
expect(response.status).to eq(200)
|
expect(response.status).to eq(200)
|
||||||
|
|
|
@ -5,19 +5,6 @@ RSpec.describe UserPasswordExpirer do
|
||||||
fab!(:user) { Fabricate(:user, password:) }
|
fab!(:user) { Fabricate(:user, password:) }
|
||||||
|
|
||||||
describe ".expire_user_password" do
|
describe ".expire_user_password" do
|
||||||
it "should create a new UserPassword record with the user's current password information" do
|
|
||||||
freeze_time
|
|
||||||
|
|
||||||
expect { described_class.expire_user_password(user) }.to change(UserPassword, :count).by 1
|
|
||||||
|
|
||||||
user_password = user.reload.user_password
|
|
||||||
|
|
||||||
expect(user_password.password_hash).to eq(user.password_hash)
|
|
||||||
expect(user_password.password_salt).to eq(user.salt)
|
|
||||||
expect(user_password.password_algorithm).to eq(user.password_algorithm)
|
|
||||||
expect(user_password.password_expired_at).to eq_time(Time.zone.now)
|
|
||||||
end
|
|
||||||
|
|
||||||
it "should update `UserPassword#password_expired_at` if the user already has an existing UserPassword record with the same password hash, salt and algorithm" do
|
it "should update `UserPassword#password_expired_at` if the user already has an existing UserPassword record with the same password hash, salt and algorithm" do
|
||||||
freeze_time(1.hour.ago) do
|
freeze_time(1.hour.ago) do
|
||||||
described_class.expire_user_password(user)
|
described_class.expire_user_password(user)
|
||||||
|
|
|
@ -81,7 +81,7 @@ shared_examples "login scenarios" do
|
||||||
it "displays the right message when user's email has been marked as expired" do
|
it "displays the right message when user's email has been marked as expired" do
|
||||||
password = "myawesomepassword"
|
password = "myawesomepassword"
|
||||||
user.update!(password:)
|
user.update!(password:)
|
||||||
Fabricate(:expired_user_password, user:, password:)
|
UserPasswordExpirer.expire_user_password(user)
|
||||||
|
|
||||||
login_modal.open
|
login_modal.open
|
||||||
login_modal.fill(username: user.username, password:)
|
login_modal.fill(username: user.username, password:)
|
||||||
|
|
Loading…
Reference in New Issue
Block a user