├── .codeclimate.yml ├── .gitignore ├── .rubocop.yml ├── .travis.yml ├── CHANGELOG.md ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── app ├── controllers │ └── devise │ │ └── two_factor_authentication_controller.rb └── views │ └── devise │ └── two_factor_authentication │ ├── max_login_attempts_reached.html.erb │ └── show.html.erb ├── config └── locales │ ├── de.yml │ ├── en.yml │ ├── es.yml │ ├── fr.yml │ └── ru.yml ├── lib ├── generators │ ├── active_record │ │ ├── templates │ │ │ └── migration.rb │ │ └── two_factor_authentication_generator.rb │ └── two_factor_authentication │ │ └── two_factor_authentication_generator.rb ├── two_factor_authentication.rb └── two_factor_authentication │ ├── controllers │ └── helpers.rb │ ├── hooks │ └── two_factor_authenticatable.rb │ ├── models │ └── two_factor_authenticatable.rb │ ├── orm │ └── active_record.rb │ ├── rails.rb │ ├── routes.rb │ ├── schema.rb │ └── version.rb ├── spec ├── controllers │ └── two_factor_authentication_controller_spec.rb ├── features │ └── two_factor_authenticatable_spec.rb ├── generators │ └── active_record │ │ └── two_factor_authentication_generator_spec.rb ├── lib │ └── two_factor_authentication │ │ └── models │ │ └── two_factor_authenticatable_spec.rb ├── rails_app │ ├── .gitignore │ ├── README.md │ ├── Rakefile │ ├── app │ │ ├── assets │ │ │ ├── javascripts │ │ │ │ └── application.js │ │ │ └── stylesheets │ │ │ │ └── application.css │ │ ├── controllers │ │ │ ├── application_controller.rb │ │ │ └── home_controller.rb │ │ ├── helpers │ │ │ └── application_helper.rb │ │ ├── mailers │ │ │ └── .gitkeep │ │ ├── models │ │ │ ├── .gitkeep │ │ │ ├── admin.rb │ │ │ ├── encrypted_user.rb │ │ │ ├── guest_user.rb │ │ │ └── user.rb │ │ └── views │ │ │ ├── home │ │ │ ├── dashboard.html.erb │ │ │ └── index.html.erb │ │ │ └── layouts │ │ │ └── application.html.erb │ ├── config.ru │ ├── config │ │ ├── application.rb │ │ ├── boot.rb │ │ ├── database.yml │ │ ├── environment.rb │ │ ├── environments │ │ │ ├── development.rb │ │ │ ├── production.rb │ │ │ └── test.rb │ │ ├── initializers │ │ │ ├── backtrace_silencers.rb │ │ │ ├── cookies_serializer.rb │ │ │ ├── devise.rb │ │ │ ├── inflections.rb │ │ │ ├── mime_types.rb │ │ │ ├── secret_token.rb │ │ │ ├── session_store.rb │ │ │ └── wrap_parameters.rb │ │ ├── locales │ │ │ ├── devise.en.yml │ │ │ └── en.yml │ │ └── routes.rb │ ├── db │ │ ├── migrate │ │ │ ├── 20140403184646_devise_create_users.rb │ │ │ ├── 20140407172619_two_factor_authentication_add_to_users.rb │ │ │ ├── 20140407215513_add_nickanme_to_users.rb │ │ │ ├── 20151224171231_add_encrypted_columns_to_user.rb │ │ │ ├── 20151224180310_populate_otp_column.rb │ │ │ ├── 20151228230340_remove_otp_secret_key_from_user.rb │ │ │ └── 20160209032439_devise_create_admins.rb │ │ └── schema.rb │ ├── lib │ │ ├── assets │ │ │ └── .gitkeep │ │ └── sms_provider.rb │ ├── public │ │ ├── 404.html │ │ ├── 422.html │ │ ├── 500.html │ │ └── favicon.ico │ └── script │ │ └── rails ├── spec_helper.rb └── support │ ├── authenticated_model_helper.rb │ ├── capybara.rb │ ├── controller_helper.rb │ ├── features_spec_helper.rb │ ├── sms_provider.rb │ └── totp_helper.rb └── two_factor_authentication.gemspec /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | engines: 2 | brakeman: 3 | enabled: true 4 | duplication: 5 | enabled: true 6 | config: 7 | languages: 8 | - ruby 9 | # mass_threshold: 30 10 | exclude_paths: 11 | - 'spec/**/*' 12 | fixme: 13 | enabled: true 14 | rubocop: 15 | enabled: true 16 | 17 | ratings: 18 | paths: 19 | - app/** 20 | - lib/** 21 | - '**.rb' 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .bundle 3 | Gemfile.lock 4 | pkg/* 5 | 6 | # Temporary files of every sort 7 | .DS_Store 8 | .idea 9 | .rvmrc 10 | .stgit* 11 | *.swap 12 | *.swo 13 | *.swp 14 | *~ 15 | bin/* 16 | nbproject 17 | patches-* 18 | capybara-*.html 19 | dump.rdb 20 | *.ids 21 | .rbenv-version 22 | .ruby-gemset 23 | .ruby-version 24 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | Include: 3 | - '**/Gemfile' 4 | - '**/Rakefile' 5 | UseCache: true 6 | 7 | Lint/AssignmentInCondition: 8 | Description: Don't use assignment in conditions. 9 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#safe-assignment-in-condition 10 | Enabled: true 11 | AllowSafeAssignment: true 12 | Lint/EachWithObjectArgument: 13 | Description: Check for immutable argument given to each_with_object. 14 | Enabled: true 15 | Lint/HandleExceptions: 16 | Description: Don't suppress exception. 17 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#dont-hide-exceptions 18 | Enabled: true 19 | Lint/LiteralInCondition: 20 | Description: Checks of literals used in conditions. 21 | Enabled: true 22 | Lint/LiteralInInterpolation: 23 | Description: Checks for literals used in interpolation. 24 | Enabled: true 25 | Lint/ParenthesesAsGroupedExpression: 26 | Description: Checks for method calls with a space before the opening parenthesis. 27 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#parens-no-spaces 28 | Enabled: true 29 | 30 | Metrics/AbcSize: 31 | Description: A calculated magnitude based on number of assignments, branches, and 32 | conditions. 33 | Enabled: true 34 | Max: 15 35 | Exclude: 36 | - spec/**/* 37 | Metrics/ClassLength: 38 | Description: Avoid classes longer than 100 lines of code. 39 | Enabled: true 40 | CountComments: false 41 | Max: 100 42 | Exclude: 43 | - spec/**/* 44 | Metrics/CyclomaticComplexity: 45 | Description: A complexity metric that is strongly correlated to the number of test 46 | cases needed to validate a method. 47 | Enabled: true 48 | Max: 6 49 | Metrics/LineLength: 50 | Description: Limit lines to 80 characters. 51 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#80-character-limits 52 | Enabled: true 53 | Max: 100 54 | AllowURI: true 55 | URISchemes: 56 | - http 57 | - https 58 | Metrics/MethodLength: 59 | Description: Avoid methods longer than 10 lines of code. 60 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#short-methods 61 | Enabled: true 62 | CountComments: false 63 | Max: 10 64 | Exclude: 65 | - spec/**/* 66 | Metrics/ModuleLength: 67 | CountComments: false 68 | Max: 100 69 | Description: Avoid modules longer than 100 lines of code. 70 | Enabled: true 71 | Exclude: 72 | - spec/**/* 73 | Metrics/ParameterLists: 74 | Description: Avoid parameter lists longer than three or four parameters. 75 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#too-many-params 76 | Enabled: true 77 | Max: 5 78 | CountKeywordArgs: true 79 | Metrics/PerceivedComplexity: 80 | Description: A complexity metric geared towards measuring complexity for a human 81 | reader. 82 | Enabled: true 83 | Max: 7 84 | 85 | Rails/ScopeArgs: 86 | Description: Checks the arguments of ActiveRecord scopes. 87 | Enabled: true 88 | Rails/TimeZone: 89 | # The value `strict` means that `Time` should be used with `zone`. 90 | # The value `flexible` allows usage of `in_time_zone` instead of `zone`. 91 | Enabled: true 92 | EnforcedStyle: flexible 93 | SupportedStyles: 94 | - strict 95 | - flexible 96 | 97 | Style/AccessorMethodName: 98 | Description: Check the naming of accessor methods for get_/set_. 99 | Enabled: false 100 | Style/AndOr: 101 | Description: Use &&/|| instead of and/or. 102 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-and-or-or 103 | Enabled: true 104 | EnforcedStyle: conditionals 105 | SupportedStyles: 106 | - always 107 | - conditionals 108 | Style/Alias: 109 | Description: Use alias_method instead of alias. 110 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#alias-method 111 | Enabled: true 112 | Style/ClassAndModuleChildren: 113 | EnforcedStyle: nested 114 | SupportedStyles: 115 | - nested 116 | - compact 117 | Style/CollectionMethods: 118 | Description: Preferred collection methods. 119 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#map-find-select-reduce-size 120 | Enabled: true 121 | PreferredMethods: 122 | collect: map 123 | collect!: map! 124 | find: detect 125 | find_all: select 126 | reduce: inject 127 | Style/Documentation: 128 | Description: Document classes and non-namespace modules. 129 | Enabled: false 130 | Style/DotPosition: 131 | Description: Checks the position of the dot in multi-line method calls. 132 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#consistent-multi-line-chains 133 | Enabled: true 134 | EnforcedStyle: trailing 135 | SupportedStyles: 136 | - leading 137 | - trailing 138 | Style/DoubleNegation: 139 | Description: Checks for uses of double negation (!!). 140 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-bang-bang 141 | Enabled: true 142 | Style/EachWithObject: 143 | Description: Prefer `each_with_object` over `inject` or `reduce`. 144 | Enabled: true 145 | Style/EmptyLiteral: 146 | Description: Prefer literals to Array.new/Hash.new/String.new. 147 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#literal-array-hash 148 | Enabled: true 149 | Style/FileName: 150 | Description: Use snake_case for source file names. 151 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#snake-case-files 152 | Enabled: true 153 | Exclude: [] 154 | Style/GuardClause: 155 | Description: Check for conditionals that can be replaced with guard clauses 156 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-nested-conditionals 157 | Enabled: true 158 | MinBodyLength: 1 159 | Style/IfUnlessModifier: 160 | Description: Favor modifier if/unless usage when you have a single-line body. 161 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#if-as-a-modifier 162 | Enabled: false 163 | MaxLineLength: 80 164 | Style/InlineComment: 165 | Description: Avoid inline comments. 166 | Enabled: false 167 | Style/ModuleFunction: 168 | Description: Checks for usage of `extend self` in modules. 169 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#module-function 170 | Enabled: false 171 | Style/OneLineConditional: 172 | Description: Favor the ternary operator(?:) over if/then/else/end constructs. 173 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#ternary-operator 174 | Enabled: false 175 | Style/OptionHash: 176 | Description: Don't use option hashes when you can use keyword arguments. 177 | Enabled: false 178 | Style/PercentLiteralDelimiters: 179 | Description: Use `%`-literal delimiters consistently 180 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#percent-literal-braces 181 | Enabled: true 182 | PreferredDelimiters: 183 | "%": "()" 184 | "%i": "()" 185 | "%q": "()" 186 | "%Q": "()" 187 | "%r": "{}" 188 | "%s": "()" 189 | "%w": "()" 190 | "%W": "()" 191 | "%x": "()" 192 | Style/PerlBackrefs: 193 | Description: Avoid Perl-style regex back references. 194 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-perl-regexp-last-matchers 195 | Enabled: false 196 | Style/PredicateName: 197 | Description: Check the names of predicate methods. 198 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#bool-methods-qmark 199 | Enabled: true 200 | NamePrefix: 201 | - is_ 202 | - has_ 203 | - have_ 204 | NamePrefixBlacklist: 205 | - is_ 206 | Exclude: 207 | - spec/**/* 208 | Style/RaiseArgs: 209 | Description: Checks the arguments passed to raise/fail. 210 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#exception-class-messages 211 | Enabled: true 212 | EnforcedStyle: exploded 213 | SupportedStyles: 214 | - compact 215 | - exploded 216 | Style/Send: 217 | Description: Prefer `Object#__send__` or `Object#public_send` to `send`, as `send` 218 | may overlap with existing methods. 219 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#prefer-public-send 220 | Enabled: false 221 | Style/SignalException: 222 | Description: Checks for proper usage of fail and raise. 223 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#fail-method 224 | Enabled: true 225 | EnforcedStyle: semantic 226 | SupportedStyles: 227 | - only_raise 228 | - only_fail 229 | - semantic 230 | Style/SingleLineBlockParams: 231 | Description: Enforces the names of some block params. 232 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#reduce-blocks 233 | Enabled: true 234 | Methods: 235 | - reduce: 236 | - a 237 | - e 238 | - inject: 239 | - a 240 | - e 241 | Style/SingleLineMethods: 242 | Description: Avoid single-line methods. 243 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-single-line-methods 244 | Enabled: true 245 | AllowIfMethodIsEmpty: true 246 | Style/SpecialGlobalVars: 247 | Description: Avoid Perl-style global variables. 248 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-cryptic-perlisms 249 | Enabled: false 250 | Style/StringLiterals: 251 | Description: Checks if uses of quotes match the configured preference. 252 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#consistent-string-literals 253 | Enabled: true 254 | EnforcedStyle: single_quotes 255 | SupportedStyles: 256 | - single_quotes 257 | - double_quotes 258 | Style/StringLiteralsInInterpolation: 259 | Description: Checks if uses of quotes inside expressions in interpolated strings 260 | match the configured preference. 261 | Enabled: true 262 | EnforcedStyle: single_quotes 263 | SupportedStyles: 264 | - single_quotes 265 | - double_quotes 266 | Style/TrailingCommaInArguments: 267 | Description: 'Checks for trailing comma in argument lists.' 268 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-trailing-array-commas' 269 | Enabled: true 270 | EnforcedStyleForMultiline: no_comma 271 | SupportedStyles: 272 | - comma 273 | - consistent_comma 274 | - no_comma 275 | Style/TrailingCommaInLiteral: 276 | Description: 'Checks for trailing comma in array and hash literals.' 277 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-trailing-array-commas' 278 | Enabled: true 279 | EnforcedStyleForMultiline: no_comma 280 | SupportedStyles: 281 | - comma 282 | - consistent_comma 283 | - no_comma 284 | Style/VariableInterpolation: 285 | Description: Don't interpolate global, instance and class variables directly in 286 | strings. 287 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#curlies-interpolate 288 | Enabled: false 289 | Style/WhenThen: 290 | Description: Use when x then ... for one-line cases. 291 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#one-line-cases 292 | Enabled: false 293 | Style/ZeroLengthPredicate: 294 | Description: 'Use #empty? when testing for objects of length 0.' 295 | Enabled: true 296 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | 3 | env: 4 | - "RAILS_VERSION=4.2" 5 | - "RAILS_VERSION=5.2" 6 | - "RAILS_VERSION=master" 7 | 8 | rvm: 9 | - 2.3.8 10 | - 2.4.5 11 | - 2.5.3 12 | 13 | matrix: 14 | fast_finish: true 15 | allow_failures: 16 | - env: "RAILS_VERSION=master" 17 | include: 18 | - rvm: 2.2 19 | env: RAILS_VERSION=4.2 20 | 21 | before_install: 22 | - gem uninstall -v '>= 2' -i $(rvm gemdir)@global -ax bundler || true 23 | - gem install bundler -v '< 2' 24 | 25 | before_script: 26 | - bundle exec rake app:db:setup 27 | 28 | script: bundle exec rake spec 29 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [Unreleased](https://github.com/Houdini/two_factor_authentication/tree/HEAD) 4 | 5 | [Full Changelog](https://github.com/Houdini/two_factor_authentication/compare/v1.1.5...HEAD) 6 | 7 | **Merged pull requests:** 8 | 9 | - Fix class detection in reset\_otp\_state\_for\(user\) [\#69](https://github.com/Houdini/two_factor_authentication/pull/69) ([monfresh](https://github.com/monfresh)) 10 | - Add ability to resend code [\#52](https://github.com/Houdini/two_factor_authentication/pull/52) ([iDiogenes](https://github.com/iDiogenes)) 11 | 12 | ## [v1.1.5](https://github.com/Houdini/two_factor_authentication/tree/v1.1.5) (2016-02-01) 13 | [Full Changelog](https://github.com/Houdini/two_factor_authentication/compare/v1.1.4...v1.1.5) 14 | 15 | **Closed issues:** 16 | 17 | - How should I integrate Devise two factor authentication with custom sessions controller? [\#60](https://github.com/Houdini/two_factor_authentication/issues/60) 18 | 19 | **Merged pull requests:** 20 | 21 | - added french translation [\#68](https://github.com/Houdini/two_factor_authentication/pull/68) ([qsypoq](https://github.com/qsypoq)) 22 | - Drop support for Ruby 1.9.3 & update .travis.yml [\#67](https://github.com/Houdini/two_factor_authentication/pull/67) ([monfresh](https://github.com/monfresh)) 23 | - Fix reset\_otp\_state specs [\#66](https://github.com/Houdini/two_factor_authentication/pull/66) ([monfresh](https://github.com/monfresh)) 24 | - Add a CHANGELOG.md [\#65](https://github.com/Houdini/two_factor_authentication/pull/65) ([monfresh](https://github.com/monfresh)) 25 | - Update bundler on Travis before installing gems [\#63](https://github.com/Houdini/two_factor_authentication/pull/63) ([monfresh](https://github.com/monfresh)) 26 | - Add support for OTP secret key encryption [\#62](https://github.com/Houdini/two_factor_authentication/pull/62) ([monfresh](https://github.com/monfresh)) 27 | - Allow executing code after sign in and before sign out [\#61](https://github.com/Houdini/two_factor_authentication/pull/61) ([monfresh](https://github.com/monfresh)) 28 | 29 | ## [v1.1.4](https://github.com/Houdini/two_factor_authentication/tree/v1.1.4) (2016-01-01) 30 | [Full Changelog](https://github.com/Houdini/two_factor_authentication/compare/v1.1.3...v1.1.4) 31 | 32 | **Closed issues:** 33 | 34 | - Old OTP can be used after a new one has been generated [\#59](https://github.com/Houdini/two_factor_authentication/issues/59) 35 | - Do we have any two\_factor\_method like authenticate\_user! [\#58](https://github.com/Houdini/two_factor_authentication/issues/58) 36 | - Configuration [\#57](https://github.com/Houdini/two_factor_authentication/issues/57) 37 | 38 | **Merged pull requests:** 39 | 40 | - Abstract logic for two factor success and fail into separate methods.… [\#56](https://github.com/Houdini/two_factor_authentication/pull/56) ([kpheasey](https://github.com/kpheasey)) 41 | - Move require rotp library to the file where it is used [\#55](https://github.com/Houdini/two_factor_authentication/pull/55) ([gkopylov](https://github.com/gkopylov)) 42 | - Add support for remembering a user's 2FA session in a cookie [\#54](https://github.com/Houdini/two_factor_authentication/pull/54) ([boffbowsh](https://github.com/boffbowsh)) 43 | - Test against Ruby 2.2 and Rails 4.2 [\#53](https://github.com/Houdini/two_factor_authentication/pull/53) ([boffbowsh](https://github.com/boffbowsh)) 44 | - Eliminates appended '?' to redirects that have no query string [\#46](https://github.com/Houdini/two_factor_authentication/pull/46) ([daveriess](https://github.com/daveriess)) 45 | 46 | ## [v1.1.3](https://github.com/Houdini/two_factor_authentication/tree/v1.1.3) (2014-12-14) 47 | [Full Changelog](https://github.com/Houdini/two_factor_authentication/compare/v1.1.2...v1.1.3) 48 | 49 | **Closed issues:** 50 | 51 | - rails g two\_factor\_authentication MODEL does not append .rb to end of migration [\#40](https://github.com/Houdini/two_factor_authentication/issues/40) 52 | 53 | **Merged pull requests:** 54 | 55 | - Allows length of OTP to be configured [\#44](https://github.com/Houdini/two_factor_authentication/pull/44) ([amoose](https://github.com/amoose)) 56 | - Missing translation. [\#43](https://github.com/Houdini/two_factor_authentication/pull/43) ([sadfuzzy](https://github.com/sadfuzzy)) 57 | - Preserve query parameters in \_return\_to for redirect. [\#42](https://github.com/Houdini/two_factor_authentication/pull/42) ([omb-awong](https://github.com/omb-awong)) 58 | - Add file extension to ActiveRecord generator [\#41](https://github.com/Houdini/two_factor_authentication/pull/41) ([jackturnbull](https://github.com/jackturnbull)) 59 | 60 | ## [v1.1.2](https://github.com/Houdini/two_factor_authentication/tree/v1.1.2) (2014-07-14) 61 | [Full Changelog](https://github.com/Houdini/two_factor_authentication/compare/v1.1.1...v1.1.2) 62 | 63 | **Closed issues:** 64 | 65 | - NoMethodError \(undefined method `scan' for nil:NilClass\) [\#37](https://github.com/Houdini/two_factor_authentication/issues/37) 66 | 67 | **Merged pull requests:** 68 | 69 | - Updated readme with rake task to update existing users with OTP secret k... [\#39](https://github.com/Houdini/two_factor_authentication/pull/39) ([Znow](https://github.com/Znow)) 70 | - Updated readme with view overriding [\#38](https://github.com/Houdini/two_factor_authentication/pull/38) ([Znow](https://github.com/Znow)) 71 | 72 | ## [v1.1.1](https://github.com/Houdini/two_factor_authentication/tree/v1.1.1) (2014-05-31) 73 | [Full Changelog](https://github.com/Houdini/two_factor_authentication/compare/v1.1...v1.1.1) 74 | 75 | **Closed issues:** 76 | 77 | - Override views [\#36](https://github.com/Houdini/two_factor_authentication/issues/36) 78 | - NoMethodError in Devise::TwoFactorAuthenticationController\#update [\#30](https://github.com/Houdini/two_factor_authentication/issues/30) 79 | 80 | **Merged pull requests:** 81 | 82 | - Use Strings and not Symbols for keys when storing variable in warden session [\#35](https://github.com/Houdini/two_factor_authentication/pull/35) ([karolsarnacki](https://github.com/karolsarnacki)) 83 | - Chore/extract reused hash key [\#34](https://github.com/Houdini/two_factor_authentication/pull/34) ([rud](https://github.com/rud)) 84 | - Pad OTP codes with less than 6 digits [\#31](https://github.com/Houdini/two_factor_authentication/pull/31) ([brissmyr](https://github.com/brissmyr)) 85 | 86 | ## [v1.1](https://github.com/Houdini/two_factor_authentication/tree/v1.1) (2014-04-16) 87 | **Closed issues:** 88 | 89 | - Update [\#15](https://github.com/Houdini/two_factor_authentication/issues/15) 90 | - Data in formats other than HTML left unprotected [\#6](https://github.com/Houdini/two_factor_authentication/issues/6) 91 | - Wordlists [\#5](https://github.com/Houdini/two_factor_authentication/issues/5) 92 | - devise - wrong number of arguments \(1 for 0\) [\#3](https://github.com/Houdini/two_factor_authentication/issues/3) 93 | - gem? [\#1](https://github.com/Houdini/two_factor_authentication/issues/1) 94 | 95 | **Merged pull requests:** 96 | 97 | - added is\_fully\_authenticated helper for current version [\#28](https://github.com/Houdini/two_factor_authentication/pull/28) ([edg3r](https://github.com/edg3r)) 98 | - Adds integration spec to ensure authentication code is sent on sign in [\#27](https://github.com/Houdini/two_factor_authentication/pull/27) ([rossta](https://github.com/rossta)) 99 | - ensure return\_to location is properly stored [\#26](https://github.com/Houdini/two_factor_authentication/pull/26) ([rossta](https://github.com/rossta)) 100 | - travis badge in README [\#25](https://github.com/Houdini/two_factor_authentication/pull/25) ([rossta](https://github.com/rossta)) 101 | - Integration specs [\#24](https://github.com/Houdini/two_factor_authentication/pull/24) ([rossta](https://github.com/rossta)) 102 | - README updates [\#23](https://github.com/Houdini/two_factor_authentication/pull/23) ([rossta](https://github.com/rossta)) 103 | - extract method \#max\_login\_attempts [\#22](https://github.com/Houdini/two_factor_authentication/pull/22) ([rossta](https://github.com/rossta)) 104 | - extract method \#populate\_otp\_column [\#21](https://github.com/Houdini/two_factor_authentication/pull/21) ([rossta](https://github.com/rossta)) 105 | - specs for Model\#provisioning\_uri [\#20](https://github.com/Houdini/two_factor_authentication/pull/20) ([rossta](https://github.com/rossta)) 106 | - Provide options for \#provisioning\_uri [\#19](https://github.com/Houdini/two_factor_authentication/pull/19) ([rossta](https://github.com/rossta)) 107 | - Use time-based authentication codes [\#16](https://github.com/Houdini/two_factor_authentication/pull/16) ([mattmueller](https://github.com/mattmueller)) 108 | - Add ru locales and locales for max\_limit\_reached view [\#13](https://github.com/Houdini/two_factor_authentication/pull/13) ([edg3r](https://github.com/edg3r)) 109 | - Update README.md [\#11](https://github.com/Houdini/two_factor_authentication/pull/11) ([edg3r](https://github.com/edg3r)) 110 | - Changed route from user to admin\_user [\#10](https://github.com/Houdini/two_factor_authentication/pull/10) ([ilanstern](https://github.com/ilanstern)) 111 | - Changed :notice to :error when setting flash message on attempt failure. [\#9](https://github.com/Houdini/two_factor_authentication/pull/9) ([johnmichaelbradley](https://github.com/johnmichaelbradley)) 112 | - Typo and punctuation corrections. [\#8](https://github.com/Houdini/two_factor_authentication/pull/8) ([johnmichaelbradley](https://github.com/johnmichaelbradley)) 113 | - Respond with 401 for request non-HTML requests [\#7](https://github.com/Houdini/two_factor_authentication/pull/7) ([WojtekKruszewski](https://github.com/WojtekKruszewski)) 114 | - need\_two\_factor\_authentication? method should accept request param. [\#4](https://github.com/Houdini/two_factor_authentication/pull/4) ([VladimirMikhailov](https://github.com/VladimirMikhailov)) 115 | - Add generators to make it easier to install and fix deprecation warnings [\#2](https://github.com/Houdini/two_factor_authentication/pull/2) ([carvil](https://github.com/carvil)) 116 | 117 | 118 | 119 | \* *This Change Log was automatically generated by [github_changelog_generator](https://github.com/skywinder/Github-Changelog-Generator)* 120 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in devise_ip_filter.gemspec 4 | gemspec 5 | 6 | rails_version = ENV["RAILS_VERSION"] || "default" 7 | 8 | rails = case rails_version 9 | when "master" 10 | {github: "rails/rails"} 11 | when "default" 12 | "~> 5.2" 13 | else 14 | "~> #{rails_version}" 15 | end 16 | 17 | gem "rails", rails 18 | 19 | if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('2.2.0') 20 | gem "test-unit", "~> 3.0" 21 | end 22 | 23 | group :test, :development do 24 | gem 'sqlite3' 25 | end 26 | 27 | group :test do 28 | gem 'rack_session_access' 29 | gem 'ammeter' 30 | end 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2012 Dmitrii Golub 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Two factor authentication for Devise 2 | 3 | [](https://gitter.im/Houdini/two_factor_authentication?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 4 | 5 | [](https://travis-ci.org/Houdini/two_factor_authentication) 6 | [](https://codeclimate.com/github/Houdini/two_factor_authentication) 7 | 8 | ## Features 9 | 10 | * Support for 2 types of OTP codes 11 | 1. Codes delivered directly to the user 12 | 2. TOTP (Google Authenticator) codes based on a shared secret (HMAC) 13 | * Configurable OTP code digit length 14 | * Configurable max login attempts 15 | * Customizable logic to determine if a user needs two factor authentication 16 | * Configurable period where users won't be asked for 2FA again 17 | * Option to encrypt the TOTP secret in the database, with iv and salt 18 | 19 | ## Configuration 20 | 21 | ### Initial Setup 22 | 23 | In a Rails environment, require the gem in your Gemfile: 24 | 25 | gem 'two_factor_authentication' 26 | 27 | Once that's done, run: 28 | 29 | bundle install 30 | 31 | Note that Ruby 2.1 or greater is required. 32 | 33 | ### Installation 34 | 35 | #### Automatic initial setup 36 | 37 | To set up the model and database migration file automatically, run the 38 | following command: 39 | 40 | bundle exec rails g two_factor_authentication MODEL 41 | 42 | Where MODEL is your model name (e.g. User or Admin). This generator will add 43 | `:two_factor_authenticatable` to your model's Devise options and create a 44 | migration in `db/migrate/`, which will add the following columns to your table: 45 | 46 | - `:second_factor_attempts_count` 47 | - `:encrypted_otp_secret_key` 48 | - `:encrypted_otp_secret_key_iv` 49 | - `:encrypted_otp_secret_key_salt` 50 | - `:direct_otp` 51 | - `:direct_otp_sent_at` 52 | - `:totp_timestamp` 53 | 54 | #### Manual initial setup 55 | 56 | If you prefer to set up the model and migration manually, add the 57 | `:two_factor_authenticatable` option to your existing devise options, such as: 58 | 59 | ```ruby 60 | devise :database_authenticatable, :registerable, :recoverable, :rememberable, 61 | :trackable, :validatable, :two_factor_authenticatable 62 | ``` 63 | 64 | Then create your migration file using the Rails generator, such as: 65 | 66 | ``` 67 | rails g migration AddTwoFactorFieldsToUsers second_factor_attempts_count:integer encrypted_otp_secret_key:string:index encrypted_otp_secret_key_iv:string encrypted_otp_secret_key_salt:string direct_otp:string direct_otp_sent_at:datetime totp_timestamp:timestamp 68 | ``` 69 | 70 | Open your migration file (it will be in the `db/migrate` directory and will be 71 | named something like `20151230163930_add_two_factor_fields_to_users.rb`), and 72 | add `unique: true` to the `add_index` line so that it looks like this: 73 | 74 | ```ruby 75 | add_index :users, :encrypted_otp_secret_key, unique: true 76 | ``` 77 | Save the file. 78 | 79 | #### Complete the setup 80 | 81 | Run the migration with: 82 | 83 | bundle exec rake db:migrate 84 | 85 | Add the following line to your model to fully enable two-factor auth: 86 | 87 | has_one_time_password(encrypted: true) 88 | 89 | Set config values in `config/initializers/devise.rb`: 90 | 91 | ```ruby 92 | config.max_login_attempts = 3 # Maximum second factor attempts count. 93 | config.allowed_otp_drift_seconds = 30 # Allowed TOTP time drift between client and server. 94 | config.otp_length = 6 # TOTP code length 95 | config.direct_otp_valid_for = 5.minutes # Time before direct OTP becomes invalid 96 | config.direct_otp_length = 6 # Direct OTP code length 97 | config.remember_otp_session_for_seconds = 30.days # Time before browser has to perform 2fA again. Default is 0. 98 | config.otp_secret_encryption_key = ENV['OTP_SECRET_ENCRYPTION_KEY'] 99 | config.second_factor_resource_id = 'id' # Field or method name used to set value for 2fA remember cookie 100 | config.delete_cookie_on_logout = false # Delete cookie when user signs out, to force 2fA again on login 101 | ``` 102 | The `otp_secret_encryption_key` must be a random key that is not stored in the 103 | DB, and is not checked in to your repo. It is recommended to store it in an 104 | environment variable, and you can generate it with `bundle exec rake secret`. 105 | 106 | Override the method in your model in order to send direct OTP codes. This is 107 | automatically called when a user logs in unless they have TOTP enabled (see 108 | below): 109 | 110 | ```ruby 111 | def send_two_factor_authentication_code(code) 112 | # Send code via SMS, etc. 113 | end 114 | ``` 115 | 116 | ### Customisation and Usage 117 | 118 | By default, second factor authentication is required for each user. You can 119 | change that by overriding the following method in your model: 120 | 121 | ```ruby 122 | def need_two_factor_authentication?(request) 123 | request.ip != '127.0.0.1' 124 | end 125 | ``` 126 | 127 | In the example above, two factor authentication will not be required for local 128 | users. 129 | 130 | This gem is compatible with [Google Authenticator](https://support.google.com/accounts/answer/1066447?hl=en). 131 | To enable this a shared secret must be generated by invoking the following 132 | method on your model: 133 | 134 | ```ruby 135 | user.generate_totp_secret 136 | ``` 137 | 138 | This must then be shared via a provisioning uri: 139 | 140 | ```ruby 141 | user.provisioning_uri # This assumes a user model with an email attribute 142 | ``` 143 | 144 | This provisioning uri can then be turned in to a QR code if desired so that 145 | users may add the app to Google Authenticator easily. Once this is done, they 146 | may retrieve a one-time password directly from the Google Authenticator app. 147 | 148 | #### Overriding the view 149 | 150 | The default view that shows the form can be overridden by adding a 151 | file named `show.html.erb` (or `show.html.haml` if you prefer HAML) 152 | inside `app/views/devise/two_factor_authentication/` and customizing it. 153 | Below is an example using ERB: 154 | 155 | 156 | ```html 157 |
<%= I18n.t("devise.two_factor_authentication.contact_administrator") %>
3 | 4 | -------------------------------------------------------------------------------- /app/views/devise/two_factor_authentication/show.html.erb: -------------------------------------------------------------------------------- 1 | <% if resource.direct_otp %> 2 |<%= flash[:notice] %>
8 | 9 | <%= form_tag([resource_name, :two_factor_authentication], :method => :put) do %> 10 | <%= text_field_tag :code, '', autofocus: true %> 11 | <%= submit_tag "Submit" %> 12 | <% end %> 13 | 14 | <% if resource.direct_otp %> 15 | <%= link_to "Resend Code", send("resend_code_#{resource_name}_two_factor_authentication_path"), action: :get %> 16 | <% else %> 17 | <%= link_to "Send me a code instead", send("resend_code_#{resource_name}_two_factor_authentication_path"), action: :get %> 18 | <% end %> 19 | <%= link_to "Sign out", send("destroy_#{resource_name}_session_path"), :method => :delete %> 20 | -------------------------------------------------------------------------------- /config/locales/de.yml: -------------------------------------------------------------------------------- 1 | de: 2 | devise: 3 | two_factor_authentication: 4 | success: "Ihre Zwei-Faktor-Authentifizierung war erfolgreich." 5 | attempt_failed: "Authentifizierungsversuch fehlgeschlagen." 6 | max_login_attempts_reached: "Ihr Zugang wurde ganz verweigert, da Sie Ihr Versuchslimit erreicht haben." 7 | contact_administrator: "Kontaktieren Sie bitte einen Ihrer Administratoren." 8 | code_has_been_sent: "Ihr Einmal-Passwort wurde verschickt." 9 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | devise: 3 | two_factor_authentication: 4 | success: "Two factor authentication successful." 5 | attempt_failed: "Attempt failed." 6 | max_login_attempts_reached: "Access completely denied as you have reached your attempts limit" 7 | contact_administrator: "Please contact your system administrator." 8 | code_has_been_sent: "Your authentication code has been sent." 9 | -------------------------------------------------------------------------------- /config/locales/es.yml: -------------------------------------------------------------------------------- 1 | es: 2 | devise: 3 | two_factor_authentication: 4 | success: "Autenticación multi-factor realizada exitosamente." 5 | attempt_failed: "La autenticación ha fallado." 6 | max_login_attempts_reached: "Has llegado al límite de intentos fallidos, acceso denegado." 7 | contact_administrator: "Contacte a su administrador de sistema." 8 | code_has_been_sent: "El código de autenticación ha sido enviado." 9 | -------------------------------------------------------------------------------- /config/locales/fr.yml: -------------------------------------------------------------------------------- 1 | fr: 2 | devise: 3 | two_factor_authentication: 4 | success: "Validation en deux étapes effectuée avec succès." 5 | attempt_failed: "La connexion a échoué." 6 | max_login_attempts_reached: "Limite de tentatives atteinte, accès refusé." 7 | contact_administrator: "Merci de contacter votre administrateur système." 8 | code_has_been_sent: "Votre code de validation envoyé." 9 | -------------------------------------------------------------------------------- /config/locales/ru.yml: -------------------------------------------------------------------------------- 1 | ru: 2 | devise: 3 | two_factor_authentication: 4 | success: "Двухфакторная авторизация успешно пройдена." 5 | attempt_failed: "Неверный код." 6 | max_login_attempts_reached: "Доступ заблокирован. Превышено число попыток авторизации" 7 | contact_administrator: "Пожалуйста, свяжитесь с системным администратором." 8 | code_has_been_sent: "Ваш персональный код был отправлен." 9 | -------------------------------------------------------------------------------- /lib/generators/active_record/templates/migration.rb: -------------------------------------------------------------------------------- 1 | class TwoFactorAuthenticationAddTo<%= table_name.camelize %> < ActiveRecord::Migration 2 | disable_ddl_transaction! 3 | 4 | def change 5 | add_column :<%= table_name %>, :second_factor_attempts_count, :integer, default: 0 6 | add_column :<%= table_name %>, :encrypted_otp_secret_key, :string 7 | add_column :<%= table_name %>, :encrypted_otp_secret_key_iv, :string 8 | add_column :<%= table_name %>, :encrypted_otp_secret_key_salt, :string 9 | add_column :<%= table_name %>, :direct_otp, :string 10 | add_column :<%= table_name %>, :direct_otp_sent_at, :datetime 11 | add_column :<%= table_name %>, :totp_timestamp, :timestamp 12 | 13 | add_index :<%= table_name %>, :encrypted_otp_secret_key, unique: true, algorithm: :concurrently 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/generators/active_record/two_factor_authentication_generator.rb: -------------------------------------------------------------------------------- 1 | require 'rails/generators/active_record' 2 | 3 | module ActiveRecord 4 | module Generators 5 | class TwoFactorAuthenticationGenerator < ActiveRecord::Generators::Base 6 | source_root File.expand_path("../templates", __FILE__) 7 | 8 | def copy_two_factor_authentication_migration 9 | migration_template "migration.rb", "db/migrate/two_factor_authentication_add_to_#{table_name}.rb" 10 | end 11 | 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/generators/two_factor_authentication/two_factor_authentication_generator.rb: -------------------------------------------------------------------------------- 1 | module TwoFactorAuthenticatable 2 | module Generators 3 | class TwoFactorAuthenticationGenerator < Rails::Generators::NamedBase 4 | namespace "two_factor_authentication" 5 | 6 | desc "Adds :two_factor_authenticable directive in the given model. It also generates an active record migration." 7 | 8 | def inject_two_factor_authentication_content 9 | path = File.join("app", "models", "#{file_path}.rb") 10 | inject_into_file(path, "two_factor_authenticatable, :", :after => "devise :") if File.exists?(path) 11 | end 12 | 13 | hook_for :orm 14 | 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/two_factor_authentication.rb: -------------------------------------------------------------------------------- 1 | require 'two_factor_authentication/version' 2 | require 'devise' 3 | require 'active_support/concern' 4 | require "active_model" 5 | require "active_support/core_ext/class/attribute_accessors" 6 | require "cgi" 7 | 8 | module Devise 9 | mattr_accessor :max_login_attempts 10 | @@max_login_attempts = 3 11 | 12 | mattr_accessor :allowed_otp_drift_seconds 13 | @@allowed_otp_drift_seconds = 30 14 | 15 | mattr_accessor :otp_length 16 | @@otp_length = 6 17 | 18 | mattr_accessor :direct_otp_length 19 | @@direct_otp_length = 6 20 | 21 | mattr_accessor :direct_otp_valid_for 22 | @@direct_otp_valid_for = 5.minutes 23 | 24 | mattr_accessor :remember_otp_session_for_seconds 25 | @@remember_otp_session_for_seconds = 0 26 | 27 | mattr_accessor :otp_secret_encryption_key 28 | @@otp_secret_encryption_key = '' 29 | 30 | mattr_accessor :second_factor_resource_id 31 | @@second_factor_resource_id = 'id' 32 | 33 | mattr_accessor :delete_cookie_on_logout 34 | @@delete_cookie_on_logout = false 35 | end 36 | 37 | module TwoFactorAuthentication 38 | NEED_AUTHENTICATION = 'need_two_factor_authentication' 39 | REMEMBER_TFA_COOKIE_NAME = "remember_tfa" 40 | 41 | autoload :Schema, 'two_factor_authentication/schema' 42 | module Controllers 43 | autoload :Helpers, 'two_factor_authentication/controllers/helpers' 44 | end 45 | end 46 | 47 | Devise.add_module :two_factor_authenticatable, :model => 'two_factor_authentication/models/two_factor_authenticatable', :controller => :two_factor_authentication, :route => :two_factor_authentication 48 | 49 | require 'two_factor_authentication/orm/active_record' if defined?(ActiveRecord::Base) 50 | require 'two_factor_authentication/routes' 51 | require 'two_factor_authentication/models/two_factor_authenticatable' 52 | require 'two_factor_authentication/rails' 53 | -------------------------------------------------------------------------------- /lib/two_factor_authentication/controllers/helpers.rb: -------------------------------------------------------------------------------- 1 | module TwoFactorAuthentication 2 | module Controllers 3 | module Helpers 4 | extend ActiveSupport::Concern 5 | 6 | included do 7 | before_action :handle_two_factor_authentication 8 | end 9 | 10 | private 11 | 12 | def handle_two_factor_authentication 13 | unless devise_controller? 14 | Devise.mappings.keys.flatten.any? do |scope| 15 | if signed_in?(scope) and warden.session(scope)[TwoFactorAuthentication::NEED_AUTHENTICATION] 16 | handle_failed_second_factor(scope) 17 | end 18 | end 19 | end 20 | end 21 | 22 | def handle_failed_second_factor(scope) 23 | if request.format.present? 24 | if request.format.html? 25 | session["#{scope}_return_to"] = request.original_fullpath if request.get? 26 | redirect_to two_factor_authentication_path_for(scope) 27 | elsif request.format.json? 28 | session["#{scope}_return_to"] = root_path(format: :html) 29 | render json: { redirect_to: two_factor_authentication_path_for(scope) }, status: :unauthorized 30 | end 31 | else 32 | head :unauthorized 33 | end 34 | end 35 | 36 | def two_factor_authentication_path_for(resource_or_scope = nil) 37 | scope = Devise::Mapping.find_scope!(resource_or_scope) 38 | change_path = "#{scope}_two_factor_authentication_path" 39 | send(change_path) 40 | end 41 | 42 | end 43 | end 44 | end 45 | 46 | module Devise 47 | module Controllers 48 | module Helpers 49 | def is_fully_authenticated? 50 | !session["warden.user.user.session"].try(:[], TwoFactorAuthentication::NEED_AUTHENTICATION) 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/two_factor_authentication/hooks/two_factor_authenticatable.rb: -------------------------------------------------------------------------------- 1 | Warden::Manager.after_authentication do |user, auth, options| 2 | if auth.env["action_dispatch.cookies"] 3 | expected_cookie_value = "#{user.class}-#{user.public_send(Devise.second_factor_resource_id)}" 4 | actual_cookie_value = auth.env["action_dispatch.cookies"].signed[TwoFactorAuthentication::REMEMBER_TFA_COOKIE_NAME] 5 | bypass_by_cookie = actual_cookie_value == expected_cookie_value 6 | end 7 | 8 | if user.respond_to?(:need_two_factor_authentication?) && !bypass_by_cookie 9 | if auth.session(options[:scope])[TwoFactorAuthentication::NEED_AUTHENTICATION] = user.need_two_factor_authentication?(auth.request) 10 | user.send_new_otp if user.send_new_otp_after_login? 11 | end 12 | end 13 | end 14 | 15 | Warden::Manager.before_logout do |user, auth, _options| 16 | auth.cookies.delete TwoFactorAuthentication::REMEMBER_TFA_COOKIE_NAME if Devise.delete_cookie_on_logout 17 | end 18 | -------------------------------------------------------------------------------- /lib/two_factor_authentication/models/two_factor_authenticatable.rb: -------------------------------------------------------------------------------- 1 | require 'two_factor_authentication/hooks/two_factor_authenticatable' 2 | require 'rotp' 3 | require 'encryptor' 4 | 5 | module Devise 6 | module Models 7 | module TwoFactorAuthenticatable 8 | extend ActiveSupport::Concern 9 | 10 | module ClassMethods 11 | def has_one_time_password(options = {}) 12 | include InstanceMethodsOnActivation 13 | include EncryptionInstanceMethods if options[:encrypted] == true 14 | end 15 | 16 | ::Devise::Models.config( 17 | self, :max_login_attempts, :allowed_otp_drift_seconds, :otp_length, 18 | :remember_otp_session_for_seconds, :otp_secret_encryption_key, 19 | :direct_otp_length, :direct_otp_valid_for, :totp_timestamp, :delete_cookie_on_logout 20 | ) 21 | end 22 | 23 | module InstanceMethodsOnActivation 24 | def authenticate_otp(code, options = {}) 25 | return true if direct_otp && authenticate_direct_otp(code) 26 | return true if totp_enabled? && authenticate_totp(code, options) 27 | false 28 | end 29 | 30 | def authenticate_direct_otp(code) 31 | return false if direct_otp.nil? || direct_otp != code || direct_otp_expired? 32 | clear_direct_otp 33 | true 34 | end 35 | 36 | def authenticate_totp(code, options = {}) 37 | totp_secret = options[:otp_secret_key] || otp_secret_key 38 | digits = options[:otp_length] || self.class.otp_length 39 | drift = options[:drift] || self.class.allowed_otp_drift_seconds 40 | raise "authenticate_totp called with no otp_secret_key set" if totp_secret.nil? 41 | totp = ROTP::TOTP.new(totp_secret, digits: digits) 42 | new_timestamp = totp.verify( 43 | without_spaces(code), 44 | drift_ahead: drift, drift_behind: drift, after: totp_timestamp 45 | ) 46 | return false unless new_timestamp 47 | self.totp_timestamp = new_timestamp 48 | true 49 | end 50 | 51 | def provisioning_uri(account = nil, options = {}) 52 | totp_secret = options[:otp_secret_key] || otp_secret_key 53 | options[:digits] ||= options[:otp_length] || self.class.otp_length 54 | raise "provisioning_uri called with no otp_secret_key set" if totp_secret.nil? 55 | account ||= email if respond_to?(:email) 56 | ROTP::TOTP.new(totp_secret, options).provisioning_uri(account) 57 | end 58 | 59 | def need_two_factor_authentication?(request) 60 | true 61 | end 62 | 63 | def send_new_otp(options = {}) 64 | create_direct_otp options 65 | send_two_factor_authentication_code(direct_otp) 66 | end 67 | 68 | def send_new_otp_after_login? 69 | !totp_enabled? 70 | end 71 | 72 | def send_two_factor_authentication_code(code) 73 | raise NotImplementedError.new("No default implementation - please define in your class.") 74 | end 75 | 76 | def max_login_attempts? 77 | second_factor_attempts_count.to_i >= max_login_attempts.to_i 78 | end 79 | 80 | def max_login_attempts 81 | self.class.max_login_attempts 82 | end 83 | 84 | def totp_enabled? 85 | respond_to?(:otp_secret_key) && !otp_secret_key.nil? 86 | end 87 | 88 | def confirm_totp_secret(secret, code, options = {}) 89 | return false unless authenticate_totp(code, {otp_secret_key: secret}) 90 | self.otp_secret_key = secret 91 | true 92 | end 93 | 94 | def generate_totp_secret 95 | # ROTP gem since version 5 to version 5.1 96 | # at version 5.1 ROTP gem reinstates. 97 | # Details: https://github.com/mdp/rotp/blob/master/CHANGELOG.md#510 98 | ROTP::Base32.try(:random) || ROTP::Base32.random_base32 99 | end 100 | 101 | def create_direct_otp(options = {}) 102 | # Create a new random OTP and store it in the database 103 | digits = options[:length] || self.class.direct_otp_length || 6 104 | update_attributes( 105 | direct_otp: random_base10(digits), 106 | direct_otp_sent_at: Time.now.utc 107 | ) 108 | end 109 | 110 | private 111 | 112 | def without_spaces(code) 113 | code.gsub(/\s/, '') 114 | end 115 | 116 | def random_base10(digits) 117 | SecureRandom.random_number(10**digits).to_s.rjust(digits, '0') 118 | end 119 | 120 | def direct_otp_expired? 121 | Time.now.utc > direct_otp_sent_at + self.class.direct_otp_valid_for 122 | end 123 | 124 | def clear_direct_otp 125 | update_attributes(direct_otp: nil, direct_otp_sent_at: nil) 126 | end 127 | end 128 | 129 | module EncryptionInstanceMethods 130 | def otp_secret_key 131 | otp_decrypt(encrypted_otp_secret_key) 132 | end 133 | 134 | def otp_secret_key=(value) 135 | self.encrypted_otp_secret_key = otp_encrypt(value) 136 | end 137 | 138 | private 139 | 140 | def otp_decrypt(encrypted_value) 141 | return encrypted_value if encrypted_value.blank? 142 | 143 | encrypted_value = encrypted_value.unpack('m').first 144 | 145 | value = ::Encryptor.decrypt(encryption_options_for(encrypted_value)) 146 | 147 | if defined?(Encoding) 148 | encoding = Encoding.default_internal || Encoding.default_external 149 | value = value.force_encoding(encoding.name) 150 | end 151 | 152 | value 153 | end 154 | 155 | def otp_encrypt(value) 156 | return value if value.blank? 157 | 158 | value = value.to_s 159 | encrypted_value = ::Encryptor.encrypt(encryption_options_for(value)) 160 | 161 | encrypted_value = [encrypted_value].pack('m') 162 | 163 | encrypted_value 164 | end 165 | 166 | def encryption_options_for(value) 167 | { 168 | value: value, 169 | key: Devise.otp_secret_encryption_key, 170 | iv: iv_for_attribute, 171 | salt: salt_for_attribute, 172 | algorithm: 'aes-256-cbc' 173 | } 174 | end 175 | 176 | def iv_for_attribute(algorithm = 'aes-256-cbc') 177 | iv = encrypted_otp_secret_key_iv 178 | 179 | if iv.nil? 180 | algo = OpenSSL::Cipher.new(algorithm) 181 | iv = [algo.random_iv].pack('m') 182 | self.encrypted_otp_secret_key_iv = iv 183 | end 184 | 185 | iv.unpack('m').first if iv.present? 186 | end 187 | 188 | def salt_for_attribute 189 | salt = encrypted_otp_secret_key_salt || 190 | self.encrypted_otp_secret_key_salt = generate_random_base64_encoded_salt 191 | 192 | decode_salt_if_encoded(salt) 193 | end 194 | 195 | def generate_random_base64_encoded_salt 196 | prefix = '_' 197 | prefix + [SecureRandom.random_bytes].pack('m') 198 | end 199 | 200 | def decode_salt_if_encoded(salt) 201 | salt.slice(0).eql?('_') ? salt.slice(1..-1).unpack('m').first : salt 202 | end 203 | end 204 | end 205 | end 206 | end 207 | -------------------------------------------------------------------------------- /lib/two_factor_authentication/orm/active_record.rb: -------------------------------------------------------------------------------- 1 | require "active_record" 2 | 3 | module TwoFactorAuthentication 4 | module Orm 5 | module ActiveRecord 6 | module Schema 7 | include TwoFactorAuthentication::Schema 8 | end 9 | end 10 | end 11 | end 12 | 13 | ActiveRecord::ConnectionAdapters::Table.send :include, TwoFactorAuthentication::Orm::ActiveRecord::Schema 14 | ActiveRecord::ConnectionAdapters::TableDefinition.send :include, TwoFactorAuthentication::Orm::ActiveRecord::Schema 15 | -------------------------------------------------------------------------------- /lib/two_factor_authentication/rails.rb: -------------------------------------------------------------------------------- 1 | module TwoFactorAuthentication 2 | class Engine < ::Rails::Engine 3 | ActiveSupport.on_load(:action_controller) do 4 | include TwoFactorAuthentication::Controllers::Helpers 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/two_factor_authentication/routes.rb: -------------------------------------------------------------------------------- 1 | module ActionDispatch::Routing 2 | class Mapper 3 | protected 4 | 5 | def devise_two_factor_authentication(mapping, controllers) 6 | resource :two_factor_authentication, :only => [:show, :update, :resend_code], :path => mapping.path_names[:two_factor_authentication], :controller => controllers[:two_factor_authentication] do 7 | collection { get "resend_code" } 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/two_factor_authentication/schema.rb: -------------------------------------------------------------------------------- 1 | module TwoFactorAuthentication 2 | module Schema 3 | def second_factor_attempts_count 4 | apply_devise_schema :second_factor_attempts_count, Integer, :default => 0 5 | end 6 | 7 | def encrypted_otp_secret_key 8 | apply_devise_schema :encrypted_otp_secret_key, String 9 | end 10 | 11 | def encrypted_otp_secret_key_iv 12 | apply_devise_schema :encrypted_otp_secret_key_iv, String 13 | end 14 | 15 | def encrypted_otp_secret_key_salt 16 | apply_devise_schema :encrypted_otp_secret_key_salt, String 17 | end 18 | 19 | def direct_otp 20 | apply_devise_schema :direct_otp, String 21 | end 22 | 23 | def direct_otp_sent_at 24 | apply_devise_schema :direct_otp_sent_at, DateTime 25 | end 26 | 27 | def totp_timestamp 28 | apply_devise_schema :totp_timestamp, Timestamp 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/two_factor_authentication/version.rb: -------------------------------------------------------------------------------- 1 | module TwoFactorAuthentication 2 | VERSION = "2.2.0".freeze 3 | end 4 | -------------------------------------------------------------------------------- /spec/controllers/two_factor_authentication_controller_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Devise::TwoFactorAuthenticationController, type: :controller do 4 | describe 'is_fully_authenticated? helper' do 5 | def post_code(code) 6 | if Rails::VERSION::MAJOR >= 5 7 | post :update, params: { code: code } 8 | else 9 | post :update, code: code 10 | end 11 | end 12 | 13 | before do 14 | sign_in 15 | end 16 | 17 | context 'after user enters valid OTP code' do 18 | it 'returns true' do 19 | controller.current_user.send_new_otp 20 | post_code controller.current_user.direct_otp 21 | expect(subject.is_fully_authenticated?).to eq true 22 | end 23 | end 24 | 25 | context 'when user has not entered any OTP yet' do 26 | it 'returns false' do 27 | get :show 28 | 29 | expect(subject.is_fully_authenticated?).to eq false 30 | end 31 | end 32 | 33 | context 'when user enters an invalid OTP' do 34 | it 'returns false' do 35 | post_code '12345' 36 | 37 | expect(subject.is_fully_authenticated?).to eq false 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/features/two_factor_authenticatable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | include AuthenticatedModelHelper 3 | 4 | feature "User of two factor authentication" do 5 | context 'sending two factor authentication code via SMS' do 6 | shared_examples 'sends and authenticates code' do |user, type| 7 | before do 8 | user.reload 9 | if type == 'encrypted' 10 | allow(User).to receive(:has_one_time_password).with(encrypted: true) 11 | end 12 | end 13 | 14 | it 'does not send an SMS before the user has signed in' do 15 | expect(SMSProvider.messages).to be_empty 16 | end 17 | 18 | it 'sends code via SMS after sign in' do 19 | visit new_user_session_path 20 | complete_sign_in_form_for(user) 21 | 22 | expect(page).to have_content 'Enter the code that was sent to you' 23 | 24 | expect(SMSProvider.messages.size).to eq(1) 25 | message = SMSProvider.last_message 26 | expect(message.to).to eq(user.phone_number) 27 | expect(message.body).to eq(user.reload.direct_otp) 28 | end 29 | 30 | it 'authenticates a valid OTP code' do 31 | visit new_user_session_path 32 | complete_sign_in_form_for(user) 33 | 34 | expect(page).to have_content('You are signed in as Marissa') 35 | 36 | fill_in 'code', with: SMSProvider.last_message.body 37 | click_button 'Submit' 38 | 39 | within('.flash.notice') do 40 | expect(page).to have_content('Two factor authentication successful.') 41 | end 42 | 43 | expect(current_path).to eq root_path 44 | end 45 | end 46 | 47 | it_behaves_like 'sends and authenticates code', create_user('not_encrypted') 48 | it_behaves_like 'sends and authenticates code', create_user, 'encrypted' 49 | end 50 | 51 | scenario "must be logged in" do 52 | visit user_two_factor_authentication_path 53 | 54 | expect(page).to have_content("Welcome Home") 55 | expect(page).to have_content("You are signed out") 56 | end 57 | 58 | context "when logged in" do 59 | let(:user) { create_user } 60 | 61 | background do 62 | login_as user 63 | end 64 | 65 | scenario "is redirected to TFA when path requires authentication" do 66 | visit dashboard_path + "?A=param%20a&B=param%20b" 67 | 68 | expect(page).to_not have_content("Your Personal Dashboard") 69 | 70 | fill_in "code", with: SMSProvider.last_message.body 71 | click_button "Submit" 72 | 73 | expect(page).to have_content("Your Personal Dashboard") 74 | expect(page).to have_content("You are signed in as Marissa") 75 | expect(page).to have_content("Param A is param a") 76 | expect(page).to have_content("Param B is param b") 77 | end 78 | 79 | scenario "is locked out after max failed attempts" do 80 | visit user_two_factor_authentication_path 81 | 82 | max_attempts = User.max_login_attempts 83 | 84 | max_attempts.times do 85 | fill_in "code", with: "incorrect#{rand(100)}" 86 | click_button "Submit" 87 | 88 | within(".flash.alert") do 89 | expect(page).to have_content("Attempt failed") 90 | end 91 | end 92 | 93 | expect(page).to have_content("Access completely denied") 94 | expect(page).to have_content("You are signed out") 95 | end 96 | 97 | scenario "cannot retry authentication after max attempts" do 98 | user.update_attribute(:second_factor_attempts_count, User.max_login_attempts) 99 | 100 | visit user_two_factor_authentication_path 101 | 102 | expect(page).to have_content("Access completely denied") 103 | expect(page).to have_content("You are signed out") 104 | end 105 | 106 | describe "rememberable TFA" do 107 | before do 108 | @original_remember_otp_session_for_seconds = User.remember_otp_session_for_seconds 109 | User.remember_otp_session_for_seconds = 30.days 110 | end 111 | 112 | after do 113 | User.remember_otp_session_for_seconds = @original_remember_otp_session_for_seconds 114 | end 115 | 116 | scenario "doesn't require TFA code again within 30 days" do 117 | sms_sign_in 118 | 119 | logout 120 | 121 | login_as user 122 | visit dashboard_path 123 | expect(page).to have_content("Your Personal Dashboard") 124 | expect(page).to have_content("You are signed in as Marissa") 125 | end 126 | 127 | scenario "requires TFA code again after 30 days" do 128 | sms_sign_in 129 | 130 | logout 131 | 132 | Timecop.travel(30.days.from_now) 133 | login_as user 134 | visit dashboard_path 135 | expect(page).to have_content("You are signed in as Marissa") 136 | expect(page).to have_content("Enter the code that was sent to you") 137 | end 138 | 139 | scenario 'TFA should be different for different users' do 140 | sms_sign_in 141 | 142 | tfa_cookie1 = get_tfa_cookie() 143 | 144 | logout 145 | reset_session! 146 | 147 | user2 = create_user() 148 | login_as(user2) 149 | sms_sign_in 150 | 151 | tfa_cookie2 = get_tfa_cookie() 152 | 153 | expect(tfa_cookie1).not_to eq tfa_cookie2 154 | end 155 | 156 | def sms_sign_in 157 | SMSProvider.messages.clear() 158 | visit user_two_factor_authentication_path 159 | fill_in 'code', with: SMSProvider.last_message.body 160 | click_button 'Submit' 161 | end 162 | 163 | scenario 'TFA should be unique for specific user' do 164 | sms_sign_in 165 | 166 | tfa_cookie1 = get_tfa_cookie() 167 | 168 | logout 169 | reset_session! 170 | 171 | user2 = create_user() 172 | set_tfa_cookie(tfa_cookie1) 173 | login_as(user2) 174 | visit dashboard_path 175 | expect(page).to have_content("Enter the code that was sent to you") 176 | end 177 | 178 | scenario 'Delete cookie when user logs out if enabled' do 179 | user.class.delete_cookie_on_logout = true 180 | 181 | login_as user 182 | logout 183 | 184 | login_as user 185 | 186 | visit dashboard_path 187 | expect(page).to have_content("Enter the code that was sent to you") 188 | end 189 | end 190 | 191 | it 'sets the warden session need_two_factor_authentication key to true' do 192 | session_hash = { 'need_two_factor_authentication' => true } 193 | 194 | expect(page.get_rack_session_key('warden.user.user.session')).to eq session_hash 195 | end 196 | end 197 | 198 | describe 'signing in' do 199 | let(:user) { create_user } 200 | let(:admin) { create_admin } 201 | 202 | scenario 'user signs is' do 203 | visit new_user_session_path 204 | complete_sign_in_form_for(user) 205 | 206 | expect(page).to have_content('Signed in successfully.') 207 | end 208 | 209 | scenario 'admin signs in' do 210 | visit new_admin_session_path 211 | complete_sign_in_form_for(admin) 212 | 213 | expect(page).to have_content('Signed in successfully.') 214 | end 215 | end 216 | 217 | describe 'signing out' do 218 | let(:user) { create_user } 219 | let(:admin) { create_admin } 220 | 221 | scenario 'user signs out' do 222 | visit new_user_session_path 223 | complete_sign_in_form_for(user) 224 | visit destroy_user_session_path 225 | 226 | expect(page).to have_content('Signed out successfully.') 227 | end 228 | 229 | scenario 'admin signs out' do 230 | visit new_admin_session_path 231 | complete_sign_in_form_for(admin) 232 | visit destroy_admin_session_path 233 | 234 | expect(page).to have_content('Signed out successfully.') 235 | end 236 | end 237 | end 238 | -------------------------------------------------------------------------------- /spec/generators/active_record/two_factor_authentication_generator_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | require 'generators/active_record/two_factor_authentication_generator' 4 | 5 | describe ActiveRecord::Generators::TwoFactorAuthenticationGenerator, type: :generator do 6 | destination File.expand_path('../../../../../tmp', __FILE__) 7 | 8 | before do 9 | prepare_destination 10 | end 11 | 12 | it 'runs all methods in the generator' do 13 | gen = generator %w(users) 14 | expect(gen).to receive(:copy_two_factor_authentication_migration) 15 | gen.invoke_all 16 | end 17 | 18 | describe 'the generated files' do 19 | before do 20 | run_generator %w(users) 21 | end 22 | 23 | describe 'the migration' do 24 | subject { migration_file('db/migrate/two_factor_authentication_add_to_users.rb') } 25 | 26 | it { is_expected.to exist } 27 | it { is_expected.to be_a_migration } 28 | it { is_expected.to contain /def change/ } 29 | it { is_expected.to contain /add_column :users, :second_factor_attempts_count, :integer, default: 0/ } 30 | it { is_expected.to contain /add_column :users, :encrypted_otp_secret_key, :string/ } 31 | it { is_expected.to contain /add_column :users, :encrypted_otp_secret_key_iv, :string/ } 32 | it { is_expected.to contain /add_column :users, :encrypted_otp_secret_key_salt, :string/ } 33 | it { is_expected.to contain /add_index :users, :encrypted_otp_secret_key, unique: true/ } 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/lib/two_factor_authentication/models/two_factor_authenticatable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | include AuthenticatedModelHelper 3 | 4 | describe Devise::Models::TwoFactorAuthenticatable do 5 | describe '#create_direct_otp' do 6 | let(:instance) { build_guest_user } 7 | 8 | it 'set direct_otp field' do 9 | expect(instance.direct_otp).to be_nil 10 | instance.create_direct_otp 11 | expect(instance.direct_otp).not_to be_nil 12 | end 13 | 14 | it 'set direct_otp_send_at field to current time' do 15 | Timecop.freeze() do 16 | instance.create_direct_otp 17 | expect(instance.direct_otp_sent_at).to eq(Time.now) 18 | end 19 | end 20 | 21 | it 'honors .direct_otp_length' do 22 | expect(instance.class).to receive(:direct_otp_length).and_return(10) 23 | instance.create_direct_otp 24 | expect(instance.direct_otp.length).to equal(10) 25 | 26 | expect(instance.class).to receive(:direct_otp_length).and_return(6) 27 | instance.create_direct_otp 28 | expect(instance.direct_otp.length).to equal(6) 29 | end 30 | 31 | it "honors 'direct_otp_length' in options paramater" do 32 | instance.create_direct_otp(length: 8) 33 | expect(instance.direct_otp.length).to equal(8) 34 | instance.create_direct_otp(length: 10) 35 | expect(instance.direct_otp.length).to equal(10) 36 | end 37 | end 38 | 39 | describe '#authenticate_direct_otp' do 40 | let(:instance) { build_guest_user } 41 | it 'fails if no direct_otp has been set' do 42 | expect(instance.authenticate_direct_otp('12345')).to eq(false) 43 | end 44 | 45 | context 'after generating an OTP' do 46 | before :each do 47 | instance.create_direct_otp 48 | end 49 | 50 | it 'accepts correct OTP' do 51 | Timecop.freeze(Time.now + instance.class.direct_otp_valid_for - 1.second) 52 | expect(instance.authenticate_direct_otp(instance.direct_otp)).to eq(true) 53 | end 54 | 55 | it 'rejects invalid OTP' do 56 | Timecop.freeze(Time.now + instance.class.direct_otp_valid_for - 1.second) 57 | expect(instance.authenticate_direct_otp('12340')).to eq(false) 58 | end 59 | 60 | it 'rejects expired OTP' do 61 | Timecop.freeze(Time.now + instance.class.direct_otp_valid_for + 1.second) 62 | expect(instance.authenticate_direct_otp(instance.direct_otp)).to eq(false) 63 | end 64 | 65 | it 'prevents code re-use' do 66 | expect(instance.authenticate_direct_otp(instance.direct_otp)).to eq(true) 67 | expect(instance.authenticate_direct_otp(instance.direct_otp)).to eq(false) 68 | end 69 | end 70 | end 71 | 72 | describe '#authenticate_totp' do 73 | shared_examples 'authenticate_totp' do |instance| 74 | before :each do 75 | instance.otp_secret_key = '2z6hxkdwi3uvrnpn' 76 | instance.totp_timestamp = nil 77 | @totp_helper = TotpHelper.new(instance.otp_secret_key, instance.class.otp_length) 78 | end 79 | 80 | def do_invoke(code, user) 81 | user.authenticate_totp(code) 82 | end 83 | 84 | it 'authenticates a recently created code' do 85 | code = @totp_helper.totp_code 86 | expect(do_invoke(code, instance)).to eq(true) 87 | end 88 | 89 | it 'authenticates a code entered with a space' do 90 | code = @totp_helper.totp_code.insert(3, ' ') 91 | expect(do_invoke(code, instance)).to eq(true) 92 | end 93 | 94 | it 'does not authenticate an old code' do 95 | code = @totp_helper.totp_code(1.minutes.ago.to_i) 96 | expect(do_invoke(code, instance)).to eq(false) 97 | end 98 | 99 | it 'prevents code reuse' do 100 | code = @totp_helper.totp_code 101 | expect(do_invoke(code, instance)).to eq(true) 102 | expect(do_invoke(code, instance)).to eq(false) 103 | end 104 | end 105 | 106 | it_behaves_like 'authenticate_totp', GuestUser.new 107 | it_behaves_like 'authenticate_totp', EncryptedUser.new 108 | end 109 | 110 | describe '#send_two_factor_authentication_code' do 111 | let(:instance) { build_guest_user } 112 | 113 | it 'raises an error by default' do 114 | expect { instance.send_two_factor_authentication_code(123) }. 115 | to raise_error(NotImplementedError) 116 | end 117 | 118 | it 'is overrideable' do 119 | def instance.send_two_factor_authentication_code(code) 120 | 'Code sent' 121 | end 122 | expect(instance.send_two_factor_authentication_code(123)).to eq('Code sent') 123 | end 124 | end 125 | 126 | describe '#provisioning_uri' do 127 | 128 | shared_examples 'provisioning_uri' do |instance| 129 | it 'fails until generate_totp_secret is called' do 130 | expect { instance.provisioning_uri }.to raise_error(Exception) 131 | end 132 | 133 | describe 'with secret set' do 134 | before do 135 | instance.email = 'houdini@example.com' 136 | instance.otp_secret_key = instance.generate_totp_secret 137 | end 138 | 139 | it "returns uri with user's email" do 140 | expect(instance.provisioning_uri). 141 | to match(%r{otpauth://totp/houdini@example.com\?secret=\w{32}}) 142 | end 143 | 144 | it 'returns uri with issuer option' do 145 | expect(instance.provisioning_uri('houdini')). 146 | to match(%r{otpauth://totp/houdini\?secret=\w{32}$}) 147 | end 148 | 149 | it 'returns uri with issuer option' do 150 | require 'cgi' 151 | uri = URI.parse(instance.provisioning_uri('houdini', issuer: 'Magic')) 152 | params = CGI.parse(uri.query) 153 | 154 | expect(uri.scheme).to eq('otpauth') 155 | expect(uri.host).to eq('totp') 156 | expect(uri.path).to eq('/Magic:houdini') 157 | expect(params['issuer'].shift).to eq('Magic') 158 | expect(params['secret'].shift).to match(/\w{32}/) 159 | end 160 | end 161 | end 162 | 163 | it_behaves_like 'provisioning_uri', GuestUser.new 164 | it_behaves_like 'provisioning_uri', EncryptedUser.new 165 | end 166 | 167 | describe '#generate_totp_secret' do 168 | shared_examples 'generate_totp_secret' do |klass| 169 | let(:instance) { klass.new } 170 | 171 | it 'returns a 32 character string' do 172 | secret = instance.generate_totp_secret 173 | 174 | expect(secret).to match(/\w{32}/) 175 | end 176 | end 177 | 178 | it_behaves_like 'generate_totp_secret', GuestUser 179 | it_behaves_like 'generate_totp_secret', EncryptedUser 180 | end 181 | 182 | describe '#confirm_totp_secret' do 183 | shared_examples 'confirm_totp_secret' do |klass| 184 | let(:instance) { klass.new } 185 | let(:secret) { instance.generate_totp_secret } 186 | let(:totp_helper) { TotpHelper.new(secret, instance.class.otp_length) } 187 | 188 | it 'populates otp_secret_key column when given correct code' do 189 | instance.confirm_totp_secret(secret, totp_helper.totp_code) 190 | 191 | expect(instance.otp_secret_key).to match(secret) 192 | end 193 | 194 | it 'does not populate otp_secret_key when when given incorrect code' do 195 | instance.confirm_totp_secret(secret, '123') 196 | expect(instance.otp_secret_key).to be_nil 197 | end 198 | 199 | it 'returns true when given correct code' do 200 | expect(instance.confirm_totp_secret(secret, totp_helper.totp_code)).to be true 201 | end 202 | 203 | it 'returns false when given incorrect code' do 204 | expect(instance.confirm_totp_secret(secret, '123')).to be false 205 | end 206 | 207 | end 208 | 209 | it_behaves_like 'confirm_totp_secret', GuestUser 210 | it_behaves_like 'confirm_totp_secret', EncryptedUser 211 | end 212 | 213 | describe '#max_login_attempts' do 214 | let(:instance) { build_guest_user } 215 | 216 | before do 217 | @original_max_login_attempts = GuestUser.max_login_attempts 218 | GuestUser.max_login_attempts = 3 219 | end 220 | 221 | after { GuestUser.max_login_attempts = @original_max_login_attempts } 222 | 223 | it 'returns class setting' do 224 | expect(instance.max_login_attempts).to eq(3) 225 | end 226 | 227 | it 'returns false as boolean' do 228 | instance.second_factor_attempts_count = nil 229 | expect(instance.max_login_attempts?).to be_falsey 230 | instance.second_factor_attempts_count = 0 231 | expect(instance.max_login_attempts?).to be_falsey 232 | instance.second_factor_attempts_count = 1 233 | expect(instance.max_login_attempts?).to be_falsey 234 | instance.second_factor_attempts_count = 2 235 | expect(instance.max_login_attempts?).to be_falsey 236 | end 237 | 238 | it 'returns true as boolean after too many attempts' do 239 | instance.second_factor_attempts_count = 3 240 | expect(instance.max_login_attempts?).to be_truthy 241 | instance.second_factor_attempts_count = 4 242 | expect(instance.max_login_attempts?).to be_truthy 243 | end 244 | end 245 | 246 | describe '.has_one_time_password' do 247 | context 'when encrypted: true option is passed' do 248 | let(:instance) { EncryptedUser.new } 249 | 250 | it 'encrypts otp_secret_key with iv, salt, and encoding' do 251 | instance.otp_secret_key = '2z6hxkdwi3uvrnpn' 252 | 253 | expect(instance.encrypted_otp_secret_key).to match(/.{44}/) 254 | 255 | expect(instance.encrypted_otp_secret_key_iv).to match(/.{24}/) 256 | 257 | expect(instance.encrypted_otp_secret_key_salt).to match(/.{25}/) 258 | end 259 | 260 | it 'does not encrypt a nil otp_secret_key' do 261 | instance.otp_secret_key = nil 262 | 263 | expect(instance.encrypted_otp_secret_key).to be_nil 264 | 265 | expect(instance.encrypted_otp_secret_key_iv).to be_nil 266 | 267 | expect(instance.encrypted_otp_secret_key_salt).to be_nil 268 | end 269 | 270 | it 'does not encrypt an empty otp_secret_key' do 271 | instance.otp_secret_key = '' 272 | 273 | expect(instance.encrypted_otp_secret_key).to eq '' 274 | 275 | expect(instance.encrypted_otp_secret_key_iv).to be_nil 276 | 277 | expect(instance.encrypted_otp_secret_key_salt).to be_nil 278 | end 279 | 280 | it 'raises an error when Devise.otp_secret_encryption_key is not set' do 281 | allow(Devise).to receive(:otp_secret_encryption_key).and_return nil 282 | 283 | # This error is raised by the encryptor gem 284 | expect { instance.otp_secret_key = '2z6hxkdwi3uvrnpn' }. 285 | to raise_error ArgumentError 286 | end 287 | 288 | it 'passes in the correct options to Encryptor. 289 | We test here output of 290 | Devise::Models::TwoFactorAuthenticatable::EncryptionInstanceMethods.encryption_options_for' do 291 | instance.otp_secret_key = 'testing' 292 | iv = instance.encrypted_otp_secret_key_iv 293 | salt = instance.encrypted_otp_secret_key_salt 294 | 295 | # it's important here to put the same crypto algorithm from that method 296 | encrypted = Encryptor.encrypt( 297 | value: 'testing', 298 | key: Devise.otp_secret_encryption_key, 299 | iv: iv.unpack('m').first, 300 | salt: salt.unpack('m').first, 301 | algorithm: 'aes-256-cbc' 302 | ) 303 | 304 | expect(instance.encrypted_otp_secret_key).to eq [encrypted].pack('m') 305 | end 306 | 307 | it 'varies the iv per instance' do 308 | instance.otp_secret_key = 'testing' 309 | user2 = EncryptedUser.new 310 | user2.otp_secret_key = 'testing' 311 | 312 | expect(user2.encrypted_otp_secret_key_iv). 313 | to_not eq instance.encrypted_otp_secret_key_iv 314 | end 315 | 316 | it 'varies the salt per instance' do 317 | instance.otp_secret_key = 'testing' 318 | user2 = EncryptedUser.new 319 | user2.otp_secret_key = 'testing' 320 | 321 | expect(user2.encrypted_otp_secret_key_salt). 322 | to_not eq instance.encrypted_otp_secret_key_salt 323 | end 324 | end 325 | end 326 | end 327 | -------------------------------------------------------------------------------- /spec/rails_app/.gitignore: -------------------------------------------------------------------------------- 1 | log/ 2 | tmp/ 3 | *.sqlite3 4 | -------------------------------------------------------------------------------- /spec/rails_app/README.md: -------------------------------------------------------------------------------- 1 | # Dummy 2 | 3 | You have found the dummy rails app used for integration testing of the `two_factor_authentication` gem. 4 | -------------------------------------------------------------------------------- /spec/rails_app/Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | # Add your own tasks in files placed in lib/tasks ending in .rake, 3 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 4 | 5 | require File.expand_path('../config/application', __FILE__) 6 | 7 | Dummy::Application.load_tasks 8 | -------------------------------------------------------------------------------- /spec/rails_app/app/assets/javascripts/application.js: -------------------------------------------------------------------------------- 1 | //= require_tree . 2 | -------------------------------------------------------------------------------- /spec/rails_app/app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | *= require_self 3 | *= require_tree . 4 | */ 5 | -------------------------------------------------------------------------------- /spec/rails_app/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | protect_from_forgery 3 | end 4 | -------------------------------------------------------------------------------- /spec/rails_app/app/controllers/home_controller.rb: -------------------------------------------------------------------------------- 1 | class HomeController < ApplicationController 2 | before_action :authenticate_user!, only: :dashboard 3 | 4 | def index 5 | end 6 | 7 | def dashboard 8 | end 9 | 10 | end 11 | -------------------------------------------------------------------------------- /spec/rails_app/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | 3 | def render_flash 4 | flash.map do |name, message| 5 | content_tag(:p, message, class: "flash #{name}") 6 | end.join.html_safe 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/rails_app/app/mailers/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Houdini/two_factor_authentication/c68d9eae8efef1e341dda731d5f98f3755b2761a/spec/rails_app/app/mailers/.gitkeep -------------------------------------------------------------------------------- /spec/rails_app/app/models/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Houdini/two_factor_authentication/c68d9eae8efef1e341dda731d5f98f3755b2761a/spec/rails_app/app/models/.gitkeep -------------------------------------------------------------------------------- /spec/rails_app/app/models/admin.rb: -------------------------------------------------------------------------------- 1 | class Admin < ActiveRecord::Base 2 | # Include default devise modules. Others available are: 3 | # :confirmable, :lockable, :timeoutable and :omniauthable 4 | devise :database_authenticatable, :registerable, 5 | :recoverable, :rememberable, :trackable, :validatable 6 | end 7 | -------------------------------------------------------------------------------- /spec/rails_app/app/models/encrypted_user.rb: -------------------------------------------------------------------------------- 1 | class EncryptedUser 2 | extend ActiveModel::Callbacks 3 | include ActiveModel::Validations 4 | include Devise::Models::TwoFactorAuthenticatable 5 | 6 | define_model_callbacks :create 7 | attr_accessor :encrypted_otp_secret_key, 8 | :encrypted_otp_secret_key_iv, 9 | :encrypted_otp_secret_key_salt, 10 | :email, 11 | :second_factor_attempts_count, 12 | :totp_timestamp 13 | 14 | has_one_time_password(encrypted: true) 15 | end 16 | -------------------------------------------------------------------------------- /spec/rails_app/app/models/guest_user.rb: -------------------------------------------------------------------------------- 1 | class GuestUser 2 | extend ActiveModel::Callbacks 3 | include ActiveModel::Validations 4 | include Devise::Models::TwoFactorAuthenticatable 5 | 6 | define_model_callbacks :create 7 | attr_accessor :direct_otp, :direct_otp_sent_at, :otp_secret_key, :email, 8 | :second_factor_attempts_count, :totp_timestamp 9 | 10 | def update_attributes(attrs) 11 | attrs.each do |key, value| 12 | send(key.to_s + '=', value) 13 | end 14 | end 15 | 16 | has_one_time_password 17 | end 18 | -------------------------------------------------------------------------------- /spec/rails_app/app/models/user.rb: -------------------------------------------------------------------------------- 1 | class User < ActiveRecord::Base 2 | devise :two_factor_authenticatable, :database_authenticatable, :registerable, 3 | :recoverable, :rememberable, :trackable, :validatable 4 | 5 | has_one_time_password 6 | 7 | def send_two_factor_authentication_code(code) 8 | SMSProvider.send_message(to: phone_number, body: code) 9 | end 10 | 11 | def phone_number 12 | '14159341234' 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/rails_app/app/views/home/dashboard.html.erb: -------------------------------------------------------------------------------- 1 |Hi <%= current_user.nickname %>
4 | 5 |Your registered email address is <%= current_user.email %>
6 | 7 |Param A is <%= params[:A] %>
8 | 9 |Param B is <%= params[:B] %>
10 | 11 |You can only see this page after successfully completing two factor authentication
12 | -------------------------------------------------------------------------------- /spec/rails_app/app/views/home/index.html.erb: -------------------------------------------------------------------------------- 1 |Find me in app/views/home/index.html.erb
4 | -------------------------------------------------------------------------------- /spec/rails_app/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |You may have mistyped the address or the page may have moved.
24 |Maybe you tried to change something you didn't have access to.
24 |