lib/rodauth/features/password_complexity.rb
# frozen-string-literal: true module Rodauth Feature.define(:password_complexity, :PasswordComplexity) 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}/}) translatable_method :password_not_enough_character_groups_message, "does not include uppercase letters, lowercase letters, and numbers" translatable_method :password_invalid_pattern_message, "includes common character sequence" translatable_method :password_in_dictionary_message, "is a word in a dictionary" translatable_method :password_too_many_repeating_characters_message, "contains too many of the same character in a row" 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 method(:password_dictionary).owner != Rodauth::PasswordComplexity case password_dictionary_file when false # nothing when nil default_dictionary_file = '/usr/share/dict/words' # :nocov: if File.file?(default_dictionary_file) # :nocov: 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 set_password_requirement_error_message(:not_enough_character_groups_in_password, 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 set_password_requirement_error_message(:invalid_password_pattern, 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}}/ set_password_requirement_error_message(:too_many_repeating_characters_in_password, password_too_many_repeating_characters_message) false 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) set_password_requirement_error_message(:password_in_dictionary, password_in_dictionary_message) false end end end