-
Notifications
You must be signed in to change notification settings - Fork 81
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Use BCrypt to encrypt backup codes #111
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,21 +2,27 @@ module ActiveModel | |
module OneTimePassword | ||
extend ActiveSupport::Concern | ||
|
||
class << self | ||
attr_accessor :min_bcrypt_cost # :nodoc: | ||
end | ||
self.min_bcrypt_cost = false | ||
|
||
OTP_DEFAULT_COLUMN_NAME = 'otp_secret_key'.freeze | ||
OTP_DEFAULT_COUNTER_COLUMN_NAME = 'otp_counter'.freeze | ||
OTP_DEFAULT_BACKUP_CODES_COLUMN_NAME = 'otp_backup_codes'.freeze | ||
OTP_DEFAULT_DIGITS = 6 | ||
OTP_DEFAULT_BACKUP_CODES_COUNT = 12 | ||
OTP_COUNTER_ENABLED_BY_DEFAULT = false | ||
OTP_BACKUP_CODES_ENABLED_BY_DEFAULT = false | ||
OTP_BACKUP_CODES_ENCRYPTED_BY_DEFAULT = true | ||
|
||
module ClassMethods | ||
def has_one_time_password(options = {}) | ||
cattr_accessor :otp_column_name, :otp_counter_column_name, | ||
:otp_backup_codes_column_name, :otp_after_column_name | ||
class_attribute :otp_digits, :otp_counter_based, | ||
:otp_backup_codes_count, :otp_one_time_backup_codes, | ||
:otp_interval | ||
:otp_interval, :otp_backup_codes_encrypted | ||
|
||
self.otp_column_name = (options[:column_name] || OTP_DEFAULT_COLUMN_NAME).to_s | ||
self.otp_digits = options[:length] || OTP_DEFAULT_DIGITS | ||
|
@@ -27,13 +33,14 @@ def has_one_time_password(options = {}) | |
self.otp_backup_codes_column_name = (options[:backup_codes_column_name] || OTP_DEFAULT_BACKUP_CODES_COLUMN_NAME).to_s | ||
self.otp_backup_codes_count = options[:backup_codes_count] || OTP_DEFAULT_BACKUP_CODES_COUNT | ||
self.otp_one_time_backup_codes = options[:one_time_backup_codes] || OTP_BACKUP_CODES_ENABLED_BY_DEFAULT | ||
self.otp_backup_codes_encrypted = options.fetch(:backup_codes_encrypted, OTP_BACKUP_CODES_ENCRYPTED_BY_DEFAULT) | ||
|
||
include InstanceMethodsOnActivation | ||
|
||
before_create(**options.slice(:if, :unless)) do | ||
self.otp_regenerate_secret if !otp_column | ||
self.otp_regenerate_counter if otp_counter_based && !otp_counter | ||
otp_regenerate_backup_codes if backup_codes_enabled? | ||
self.otp_regenerate_backup_codes if backup_codes_enabled? | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Style/RedundantSelf: Redundant self detected. |
||
end | ||
|
||
if respond_to?(:attributes_protected_by_default) | ||
|
@@ -51,6 +58,8 @@ def otp_random_secret(length = 20) | |
end | ||
|
||
module InstanceMethodsOnActivation | ||
attr_accessor :plain_backup_codes | ||
|
||
def otp_regenerate_secret | ||
self.otp_column = self.class.otp_random_secret | ||
end | ||
|
@@ -127,6 +136,15 @@ def otp_regenerate_backup_codes | |
otp.generate_otp((SecureRandom.random_number(9e5) + 1e5).to_i) | ||
end | ||
|
||
if self.class.otp_backup_codes_encrypted | ||
self.plain_backup_codes = backup_codes | ||
|
||
backup_codes = backup_codes.map do |code| | ||
cost = ActiveModel::OneTimePassword.min_bcrypt_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Metrics/LineLength: Line is too long. [112/80] |
||
BCrypt::Password.create(code, cost: cost) | ||
end | ||
end | ||
|
||
public_send("#{self.class.otp_backup_codes_column_name}=", backup_codes) | ||
end | ||
|
||
|
@@ -180,10 +198,18 @@ def totp_code(options = {}) | |
def authenticate_backup_code(code) | ||
backup_codes_column_name = self.class.otp_backup_codes_column_name | ||
backup_codes = public_send(backup_codes_column_name) | ||
return false unless backup_codes.present? && backup_codes.include?(code) | ||
|
||
return false unless backup_codes.present? | ||
|
||
valid_code = backup_codes.find do |backup_code| | ||
backup_code = BCrypt::Password.new(backup_code) if self.class.otp_backup_codes_encrypted | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Metrics/LineLength: Line is too long. [98/80] |
||
backup_code == code | ||
end | ||
|
||
return false unless valid_code | ||
|
||
if self.class.otp_one_time_backup_codes | ||
backup_codes.delete(code) | ||
backup_codes.delete(valid_code) | ||
public_send("#{backup_codes_column_name}=", backup_codes) | ||
save if respond_to?(:changed?) && !new_record? | ||
end | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,6 @@ | ||
require "active_model" | ||
require "active_support/core_ext/module/attribute_accessors" | ||
require "bcrypt" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Style/StringLiterals: Prefer single-quoted strings when you don't need string interpolation or special symbols. |
||
require "cgi" | ||
require "rotp" | ||
require "active_model/one_time_password" | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -7,7 +7,7 @@ class User | |
define_model_callbacks :create | ||
attr_accessor :otp_secret_key, :otp_backup_codes, :email | ||
|
||
has_one_time_password one_time_backup_codes: true | ||
has_one_time_password one_time_backup_codes: true, backup_codes_encrypted: false | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Metrics/LineLength: Line is too long. [82/80] |
||
|
||
def attributes | ||
{ "otp_secret_key" => otp_secret_key, "email" => email } | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
class UserWithEncryptedCodes | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Style/FrozenStringLiteralComment: Missing magic comment # frozen_string_literal: true. |
||
extend ActiveModel::Callbacks | ||
include ActiveModel::Serializers::JSON | ||
include ActiveModel::Validations | ||
include ActiveModel::OneTimePassword | ||
|
||
define_model_callbacks :create | ||
attr_accessor :otp_secret_key, :otp_backup_codes, :email | ||
|
||
has_one_time_password backup_codes_encrypted: true | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -29,6 +29,10 @@ def setup | |
@after_user = AfterUser.new | ||
@after_user.email = '[email protected]' | ||
@after_user.run_callbacks :create | ||
|
||
@user_with_encrypted_code = UserWithEncryptedCodes.new | ||
@user_with_encrypted_code.email = '[email protected]' | ||
@user_with_encrypted_code.run_callbacks :create | ||
end | ||
|
||
def test_authenticate_with_otp | ||
|
@@ -112,13 +116,22 @@ def test_authenticate_with_backup_code | |
|
||
backup_code = @user.public_send(@user.otp_backup_codes_column_name).last | ||
@user.otp_regenerate_backup_codes | ||
assert_equal true, [email protected]_otp(backup_code) | ||
assert_equal false, @user.authenticate_otp(backup_code) | ||
end | ||
|
||
def test_authenticate_with_encrypted_backup_code | ||
backup_code = @user_with_encrypted_code.plain_backup_codes.first | ||
assert_equal true, @user_with_encrypted_code.authenticate_otp(backup_code) | ||
|
||
backup_code = @user_with_encrypted_code.plain_backup_codes.last | ||
@user_with_encrypted_code.otp_regenerate_backup_codes | ||
assert_equal false, @user.authenticate_otp(backup_code) | ||
end | ||
|
||
def test_authenticate_with_one_time_backup_code | ||
backup_code = @user.public_send(@user.otp_backup_codes_column_name).first | ||
assert_equal true, @user.authenticate_otp(backup_code) | ||
assert_equal true, !@user.authenticate_otp(backup_code) | ||
assert_equal false, @user.authenticate_otp(backup_code) | ||
end | ||
|
||
def test_otp_code | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Metrics/LineLength: Line is too long. [119/80]