💡

Ruby 任意の文字種を含んだパスワードを自動生成する

2024/04/18に公開

以下の要件に沿って一時的なパスワードを自動生成する必要があり、SecureRandom module をそのままでは使えなさそうだったので実現方法を考えてみました。

  • 最低 n 文字
  • アルファベットの大文字・小文字、0~9 の数字、いくつかの記号をそれぞれ 1 文字ずつ含む

結論

  • SecureRandom モジュールでは文字の種類を細かく指定してランダムで文字列を生成することはできない
  • 最低 n 文字と文字種を満たすまで各文字種を適当に pick していく方法
  • 条件の文字種を含んだ文字列を条件を満たすまで sample する方法

最初に考えた方法

最初に考えたのは、文字種の配列を作成し sample メソッドでピックアップしていく方法です。
こんなイメージで実装しようとしていました。

def generate_password
  chars_lower = ('a'..'z').to_a
  chars_upper = ('A'..'Z').to_a
  chars_number = ('0'..'9').to_a
  chars_special = ['~', '-', '|', '!', '?', '_']

  chars = [*chars_lower, *chars_upper, *chars_number, *chars_special]
  password_length = 8

  while true do
    tmp_password = ''
    is_lower = false
    is_upper = false
    is_number = false
    is_special = false

    # パスワード生成
    password_length.times do
      tmp_password += chars.sample
    end
    # パスワードの要件を満たしているか確認
    tmp_password.split("").each do |c|
      is_lower = chars_lower.include?(c) ? true : is_lower
      is_upper = chars_upper.include?(c) ? true : is_upper
      is_number = chars_number.include?(c) ? true : is_number
      is_special = chars_special.include?(c) ? true : is_special
    end

    if is_lower && is_upper && is_number && is_special
      return tmp_password
    end
  end
end

ただ、sample メソッドで使用している乱数生成器がパスワードで利用するには安全でないとのことから、この方法を使用するのは見送り、SecureRandom モジュールを使用した方法を考えることにしました。

https://teratail.com/questions/117807

SecureRandom モジュールでは文字の種類を細かく指定してランダムで文字列を生成することはできない

SecureRandom モジュールでは、今回の要件を満たすような文字列を生成することはできません。なぜなら、文字種を指定してランダムに文字列を作成することができないためです。
文字列 + 数値の組み合わせならば alphanumeric メソッドが使用できますが、記号も入れたいとなると一工夫必要そうです。

https://docs.ruby-lang.org/ja/latest/class/SecureRandom.html

最低 n 文字と文字種を満たすまで各文字種を適当に pick していく方法

array.sample を使用していた部分を SecureRandom の random_number メソッドを利用してインデックスを抽出する方法を使います。
random_number メソッドは引数を指定しなければ 0 以上 1 未満の数値を返しますが、引数 n を指定することで 0 以上 n 未満の数値を返してくれます。

def generate_password(password_length=8)
  raise ArgumentError if password_length < 4
  
  chars_lower = ('a'..'z').to_a
  chars_upper = ('A'..'Z').to_a
  chars_number = ('0'..'9').to_a
  chars_special = ['~', '-', '|', '!', '?', '_']

  chars = [*chars_lower, *chars_upper, *chars_number, *chars_special]

  while true do
    tmp_password = ''
    is_lower = false
    is_upper = false
    is_number = false
    is_special = false

    # パスワード生成
    password_length.times do
      tmp_password += chars[SecureRandom.random_number(chars.size)]
    end
    # パスワードの要件を満たしているか確認
    tmp_password.split("").each do |c|
      is_lower = chars_lower.include?(c) ? true : is_lower
      is_upper = chars_upper.include?(c) ? true : is_upper
      is_number = chars_number.include?(c) ? true : is_number
      is_special = chars_special.include?(c) ? true : is_special
    end

    if is_lower && is_upper && is_number && is_special
      return tmp_password
    end
  end
end

先ほどあげたコードから array.sample で文字を抽出していた部分を random_number メソッドで数値を生成し、その数値を配列のインデックスとすることで文字を抽出しています。

さいごに

ご意見やもっといい方法をご存じの方はぜひ教えてください!

参考

https://docs.ruby-lang.org/ja/latest/class/SecureRandom.html
https://docs.ruby-lang.org/ja/latest/class/SecureRandom.html

Discussion