lib/rodauth/features/password_complexity.rb
# frozen-string-literal: true module Rodauth PasswordComplexity = Feature.define(:password_complexity) do depends :login_password_requirements_base auth_value_method :password_dictionary_file, nil auth_value_method :password_dictionary, nil auth_value_method :password_character_groups, [/[a-z]/, /[A-Z]/, /\d/, /[^a-zA-Z\d]/] auth_value_method :password_min_groups, 3 auth_value_method :password_max_length_for_groups_check, 11 auth_value_method :password_max_repeating_characters, 3 auth_value_method :password_invalid_pattern, Regexp.union([/qwerty/i, /azerty/i, /asdf/i, /zxcv/i] + (1..8).map{|i| /#{i}#{i+1}#{(i+2)%10}/}) auth_value_method :password_not_enough_character_groups_message, "does not include uppercase letters, lowercase letters, and numbers" auth_value_method :password_invalid_pattern_message, "includes common character sequence" auth_value_method :password_in_dictionary_message, "is a word in a dictionary" auth_value_methods( :password_too_many_repeating_characters_message ) def password_meets_requirements?(password) super && \ password_has_enough_character_groups?(password) && \ password_has_no_invalid_pattern?(password) && \ password_not_too_many_repeating_characters?(password) && \ password_not_in_dictionary?(password) end def post_configure super return if singleton_methods.map(&:to_sym).include?(:password_dictionary) case dictionary_file = password_dictionary_file when false return when nil default_dictionary_file = '/usr/share/dict/words' if File.file?(default_dictionary_file) words = File.read(default_dictionary_file) end else words = File.read(password_dictionary_file) end return unless words require 'set' dict = Set.new(words.downcase.split) self.class.send(:define_method, :password_dictionary){dict} end private def password_has_enough_character_groups?(password) return true if password.length > password_max_length_for_groups_check return true if password_character_groups.select{|re| password =~ re}.length >= password_min_groups @password_requirement_message = password_not_enough_character_groups_message false end def password_has_no_invalid_pattern?(password) return true unless password_invalid_pattern return true if password !~ password_invalid_pattern @password_requirement_message = password_invalid_pattern_message false end def password_not_too_many_repeating_characters?(password) return true if password_max_repeating_characters < 2 return true if password !~ /(.)(\1){#{password_max_repeating_characters-1}}/ @password_requirement_message = password_too_many_repeating_characters_message false end def password_too_many_repeating_characters_message "contains #{password_max_repeating_characters} or more of the same character in a row" end def password_not_in_dictionary?(password) return true unless dict = password_dictionary return true unless password =~ /\A(?:\d*)([A-Za-z!@$+|][A-Za-z!@$+|0134578]+[A-Za-z!@$+|])(?:\d*)\z/ word = $1.downcase.tr('!@$+|0134578', 'iastloleastb') return true if !dict.include?(word) @password_requirement_message = password_in_dictionary_message false end end end