Skip to content

Commit

Permalink
Backup codes support (#79)
Browse files Browse the repository at this point in the history
  • Loading branch information
dmitry-ilyashevich authored Mar 7, 2021
1 parent c8f657f commit 4dbe641
Showing 12 changed files with 137 additions and 5 deletions.
5 changes: 5 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -10,12 +10,17 @@ gemfile:
- gemfiles/rails_5.1.gemfile
- gemfiles/rails_5.2.gemfile
- gemfiles/rails_6.0.gemfile
- gemfiles/rails_6.1.gemfile
matrix:
exclude:
- rvm: 2.3
gemfile: gemfiles/rails_6.0.gemfile
- rvm: 2.3
gemfile: gemfiles/rails_6.1.gemfile
- rvm: 2.4
gemfile: gemfiles/rails_6.0.gemfile
- rvm: 2.4
gemfile: gemfiles/rails_6.1.gemfile
fast_finish: true
allow_failures:
- rvm: ruby-head
11 changes: 11 additions & 0 deletions Appraisals
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
appraise "rails-4.2" do
gem "activemodel", "~> 4.2"
gem "sqlite3", "~> 1.3.6"
end

appraise "rails-5.0" do
gem "activemodel", "~> 5.0"
gem "activemodel-serializers-xml"
gem "sqlite3", "~> 1.3.6"
end

appraise "rails-5.1" do
gem "activemodel", "~> 5.1"
gem "activemodel-serializers-xml"
gem "sqlite3", "~> 1.3.6"
end

appraise "rails-5.2" do
gem "activemodel", "~> 5.2"
gem "activemodel-serializers-xml"
gem "sqlite3", "~> 1.3.6"
end

appraise "rails-6.0" do
@@ -23,3 +27,10 @@ appraise "rails-6.0" do
gem "activemodel-serializers-xml"
gem "sqlite3", "~> 1.4"
end

appraise "rails-6.1" do
gem "activerecord", "~> 6.1"
gem "activemodel", "~> 6.1"
gem "activemodel-serializers-xml"
gem "sqlite3", "~> 1.4"
end
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -150,6 +150,51 @@ user.otp_code(auto_increment: true) # => '002811'
user.otp_code # => '002811'
```

## Backup codes

We're going to add a field to our ``User`` Model, so each user can have an otp backup codes. The next step is to run the migration generator in order to add the backup codes field.

```ruby
rails g migration AddOtpBackupCodesToUsers otp_backup_codes:text
=>
invoke active_record
create db/migrate/20210126030834_add_otp_backup_codes_to_users.rb
```

You can change backup codes column name by option `backup_codes_column_name`:

```ruby
class User < ApplicationRecord
has_one_time_password backup_codes_column_name: 'secret_codes'
end
```

Then use array type in schema or serialize attribute in model as Array (depending on used db type). Or even consider to use some libs like (lockbox)[https://github.com/ankane/lockbox] with type array.

After that user can use one of automatically generated backup codes for authentication using same method `authenticate_otp`.

By default it generates 12 backup codes. You can change it by option `backup_codes_count`:

```ruby
class User < ApplicationRecord
has_one_time_password backup_codes_count: 6
end
```

By default each backup code can be reused an infinite number of times. You can
change it with option `one_time_backup_codes`:

```ruby
class User < ApplicationRecord
has_one_time_password one_time_backup_codes: true
end
```

```ruby
user.authenticate_otp('186522') # => true
user.authenticate_otp('186522') # => false
```

## Google Authenticator Compatible

The library works with the Google Authenticator iPhone and Android app, and also includes the ability to generate provisioning URI's to use with the QR Code scanner built into the app.
2 changes: 1 addition & 1 deletion active_model_otp.gemspec
Original file line number Diff line number Diff line change
@@ -31,6 +31,6 @@ Gem::Specification.new do |spec|
if RUBY_PLATFORM == "java"
spec.add_development_dependency "activerecord-jdbcsqlite3-adapter"
else
spec.add_development_dependency "sqlite3", "~> 1.3.6"
spec.add_development_dependency "sqlite3"
end
end
1 change: 1 addition & 0 deletions gemfiles/rails_4.2.gemfile
Original file line number Diff line number Diff line change
@@ -3,5 +3,6 @@
source "https://rubygems.org"

gem "activemodel", "~> 4.2"
gem "sqlite3", "~> 1.3.6"

gemspec path: "../"
1 change: 1 addition & 0 deletions gemfiles/rails_5.0.gemfile
Original file line number Diff line number Diff line change
@@ -4,5 +4,6 @@ source "https://rubygems.org"

gem "activemodel", "~> 5.0"
gem "activemodel-serializers-xml"
gem "sqlite3", "~> 1.3.6"

gemspec path: "../"
1 change: 1 addition & 0 deletions gemfiles/rails_5.1.gemfile
Original file line number Diff line number Diff line change
@@ -4,5 +4,6 @@ source "https://rubygems.org"

gem "activemodel", "~> 5.1"
gem "activemodel-serializers-xml"
gem "sqlite3", "~> 1.3.6"

gemspec path: "../"
1 change: 1 addition & 0 deletions gemfiles/rails_5.2.gemfile
Original file line number Diff line number Diff line change
@@ -4,5 +4,6 @@ source "https://rubygems.org"

gem "activemodel", "~> 5.2"
gem "activemodel-serializers-xml"
gem "sqlite3", "~> 1.3.6"

gemspec path: "../"
10 changes: 10 additions & 0 deletions gemfiles/rails_6.1.gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# This file was generated by Appraisal

source "https://rubygems.org"

gem "activerecord", "~> 6.1"
gem "activemodel", "~> 6.1"
gem "activemodel-serializers-xml"
gem "sqlite3", "~> 1.4"

gemspec path: "../"
46 changes: 44 additions & 2 deletions lib/active_model/one_time_password.rb
Original file line number Diff line number Diff line change
@@ -4,20 +4,31 @@ module OneTimePassword

module ClassMethods
def has_one_time_password(options = {})
cattr_accessor :otp_column_name, :otp_counter_column_name
class_attribute :otp_digits, :otp_counter_based
cattr_accessor :otp_column_name, :otp_counter_column_name,
:otp_backup_codes_column_name
class_attribute :otp_digits, :otp_counter_based,
:otp_backup_codes_count, :otp_one_time_backup_codes

self.otp_column_name = (options[:column_name] || "otp_secret_key").to_s
self.otp_digits = options[:length] || 6

self.otp_counter_based = (options[:counter_based] || false)
self.otp_counter_column_name = (options[:counter_column_name] || "otp_counter").to_s

self.otp_backup_codes_column_name = (
options[:backup_codes_column_name] || 'otp_backup_codes'
).to_s
self.otp_backup_codes_count = options[:backup_codes_count] || 12
self.otp_one_time_backup_codes = (
options[:one_time_backup_codes] || false
)

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?
end

if respond_to?(:attributes_protected_by_default)
@@ -44,6 +55,8 @@ def otp_regenerate_counter
end

def authenticate_otp(code, options = {})
return true if backup_codes_enabled? && authenticate_backup_code(code)

if otp_counter_based
hotp = ROTP::HOTP.new(otp_column, digits: otp_digits)
result = hotp.verify(code, otp_counter)
@@ -120,6 +133,35 @@ def serializable_hash(options = nil)
options[:except] << self.class.otp_column_name
super(options)
end

def otp_regenerate_backup_codes
otp = ROTP::OTP.new(otp_column)
backup_codes = Array.new(self.class.otp_backup_codes_count) do
otp.generate_otp((SecureRandom.random_number(9e5) + 1e5).to_i)
end

public_send("#{self.class.otp_backup_codes_column_name}=", backup_codes)
end

def backup_codes_enabled?
self.class.attribute_method?(self.class.otp_backup_codes_column_name)
end

private

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.include?(code)

if self.class.otp_one_time_backup_codes
backup_codes.delete(code)
public_send("#{backup_codes_column_name}=", backup_codes)
save if respond_to?(:changed?) && !new_record?
end

true
end
end
end
end
4 changes: 2 additions & 2 deletions test/models/user.rb
Original file line number Diff line number Diff line change
@@ -5,9 +5,9 @@ class User
include ActiveModel::OneTimePassword

define_model_callbacks :create
attr_accessor :otp_secret_key, :email
attr_accessor :otp_secret_key, :otp_backup_codes, :email

has_one_time_password
has_one_time_password one_time_backup_codes: true
def attributes
{ "otp_secret_key" => otp_secret_key, "email" => email }
end
15 changes: 15 additions & 0 deletions test/one_time_password_test.rb
Original file line number Diff line number Diff line change
@@ -69,6 +69,21 @@ def test_authenticate_with_otp_when_drift_is_allowed
assert @visitor.authenticate_otp(code, drift: 60)
end

def test_authenticate_with_backup_code
backup_code = @user.public_send(@user.otp_backup_codes_column_name).first
assert @user.authenticate_otp(backup_code)

backup_code = @user.public_send(@user.otp_backup_codes_column_name).last
@user.otp_regenerate_backup_codes
assert !@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 @user.authenticate_otp(backup_code)
assert !@user.authenticate_otp(backup_code)
end

def test_otp_code
assert_match(/^\d{6}$/, @user.otp_code.to_s)
assert_match(/^\d{4}$/, @visitor.otp_code.to_s)

0 comments on commit 4dbe641

Please sign in to comment.