├── .github └── workflows │ └── ci.yml ├── .gitignore ├── Appraisals ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── app ├── assets │ └── stylesheets │ │ └── devise-otp.css ├── controllers │ └── devise_otp │ │ └── devise │ │ ├── otp_credentials_controller.rb │ │ └── otp_tokens_controller.rb └── views │ └── devise │ ├── otp_credentials │ ├── refresh.html.erb │ └── show.html.erb │ └── otp_tokens │ ├── _token_secret.html.erb │ ├── _trusted_devices.html.erb │ ├── edit.html.erb │ ├── recovery.html.erb │ ├── recovery_codes.text.erb │ └── show.html.erb ├── config └── locales │ └── en.yml ├── devise-otp.gemspec ├── gemfiles ├── rails_7.1.gemfile ├── rails_7.2.gemfile └── rails_8.0.gemfile ├── lib ├── devise-otp.rb ├── devise-otp │ └── version.rb ├── devise │ └── strategies │ │ └── database_authenticatable.rb ├── devise_otp_authenticatable │ ├── controllers │ │ ├── helpers.rb │ │ ├── public_helpers.rb │ │ └── url_helpers.rb │ ├── engine.rb │ ├── hooks │ │ └── refreshable.rb │ ├── models │ │ └── otp_authenticatable.rb │ └── routes.rb └── generators │ ├── active_record │ ├── devise_otp_generator.rb │ └── templates │ │ └── migration.rb │ └── devise_otp │ ├── devise_otp_generator.rb │ ├── install_generator.rb │ └── views_generator.rb └── test ├── dummy ├── README.rdoc ├── Rakefile ├── app │ ├── assets │ │ ├── config │ │ │ └── manifest.js │ │ ├── javascripts │ │ │ └── application.js │ │ └── stylesheets │ │ │ └── application.css │ ├── controllers │ │ ├── admin_posts_controller.rb │ │ ├── application_controller.rb │ │ ├── base_controller.rb │ │ └── posts_controller.rb │ ├── helpers │ │ ├── application_helper.rb │ │ └── posts_helper.rb │ ├── mailers │ │ └── .gitkeep │ ├── models │ │ ├── admin.rb │ │ ├── post.rb │ │ └── user.rb │ └── views │ │ ├── admin_posts │ │ ├── _form.html.erb │ │ ├── edit.html.erb │ │ ├── index.html.erb │ │ ├── new.html.erb │ │ └── show.html.erb │ │ ├── base │ │ └── home.html.erb │ │ ├── layouts │ │ └── application.html.erb │ │ └── posts │ │ ├── _form.html.erb │ │ ├── edit.html.erb │ │ ├── index.html.erb │ │ ├── new.html.erb │ │ └── show.html.erb ├── config.ru ├── config │ ├── application.rb │ ├── boot.rb │ ├── database.yml │ ├── environment.rb │ ├── environments │ │ ├── development.rb │ │ ├── production.rb │ │ └── test.rb │ ├── initializers │ │ ├── backtrace_silencers.rb │ │ ├── devise.rb │ │ ├── inflections.rb │ │ ├── mime_types.rb │ │ ├── secret_token.rb │ │ ├── session_store.rb │ │ └── wrap_parameters.rb │ ├── locales │ │ └── en.yml │ └── routes.rb ├── db │ └── migrate │ │ ├── 20130125101430_create_users.rb │ │ ├── 20130131092406_add_devise_to_users.rb │ │ ├── 20130131142320_create_posts.rb │ │ ├── 20130131160351_devise_otp_add_to_users.rb │ │ ├── 20240604000001_create_admins.rb │ │ ├── 20240604000002_add_devise_to_admins.rb │ │ └── 20240604000003_devise_otp_add_to_admins.rb ├── lib │ └── assets │ │ └── .gitkeep ├── public │ ├── 404.html │ ├── 422.html │ ├── 500.html │ └── favicon.ico └── script │ └── rails ├── integration ├── disable_token_test.rb ├── enable_otp_form_test.rb ├── persistence_test.rb ├── refresh_test.rb ├── reset_token_test.rb ├── sign_in_test.rb └── trackable_test.rb ├── integration_tests_helper.rb ├── model_tests_helper.rb ├── models └── otp_authenticatable_test.rb ├── orm └── active_record.rb └── test_helper.rb /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | 4 | on: 5 | - push 6 | - pull_request 7 | 8 | jobs: 9 | rspec: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | ruby: 15 | - '3.4' 16 | - '3.3' 17 | - '3.2' 18 | - 'head' 19 | rails: 20 | - rails_8.0 21 | - rails_7.2 22 | - rails_7.1 23 | exclude: 24 | - ruby: '3.1' 25 | rails: 'rails_8.0' 26 | 27 | env: # $BUNDLE_GEMFILE must be set at the job level, so it is set for all steps 28 | BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/${{ matrix.rails }}.gemfile 29 | 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v4 33 | 34 | - name: Setup Ruby 35 | uses: ruby/setup-ruby@v1 36 | with: 37 | ruby-version: ${{ matrix.ruby }} 38 | bundler-cache: true 39 | 40 | - name: Run tests 41 | env: 42 | DEVISE_ORM: active_record 43 | run: bundle exec rake test 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## RubyMine 2 | .idea 3 | 4 | ## MAC OS 5 | .DS_Store 6 | 7 | ## TEXTMATE 8 | *.tmproj 9 | tmtags 10 | 11 | ## EMACS 12 | *~ 13 | \#* 14 | .\#* 15 | 16 | ## VIM 17 | *.swp 18 | 19 | *.gem 20 | *.rbc 21 | .bundle 22 | .config 23 | .yardoc 24 | 25 | ## PROJECT::GENERAL 26 | _yardoc 27 | doc/ 28 | coverage 29 | rdoc 30 | pkg 31 | spec/reports 32 | lib/bundler/man 33 | 34 | ## PROJECT::SPECIFIC 35 | test/dummy/log/** 36 | test/dummy/tmp/** 37 | test/dummy/db/*.sqlite3 38 | test/dummy/db/*.sqlite3-shm 39 | test/dummy/db/*.sqlite3-wal 40 | 41 | # Ignore Gemfile.lock 42 | Gemfile.lock 43 | gemfiles/*.lock 44 | 45 | # Generated test files 46 | tmp/* 47 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | appraise 'rails_7.1' do 4 | gem 'rails', '~> 7.1.0' 5 | gem 'sqlite3', '~> 1.5.0' 6 | 7 | # Fix: 8 | # warning: logger was loaded from the standard library, but will no longer be part of the default gems since Ruby 3.5.0. 9 | # Add logger to your Gemfile or gemspec. 10 | install_if '-> { Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.4.0") }' do 11 | gem 'logger' 12 | end 13 | end 14 | 15 | appraise 'rails_7.2' do 16 | gem 'rails', '~> 7.2.0' 17 | gem 'sqlite3', '~> 1.5.0' 18 | end 19 | 20 | appraise 'rails_8.0' do 21 | gem 'rails', '~> 8.0.0' 22 | end 23 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Unreleased 4 | 5 | - Upgrade gemspec to support Rails v7.2 6 | 7 | ## 0.7.0 8 | 9 | Breaking changes: 10 | 11 | - Require confirmation token before enabling Two Factor Authentication (2FA) to ensure that user has added OTP token properly to their device 12 | - Update DeviseAuthenticatable to redirect user (rather than login user) when OTP is enabled 13 | - Remove OtpAuthenticatable callbacks for setting OTP credentials on create action (no longer needed) 14 | - Replace OtpAuthenticatable "reset_otp_credentials" methods with "clear_otp_fields!" method 15 | - Update otp_tokens#edit to populate OTP secrets (rather than assuming they are populated via callbacks in OTPDeviseAuthenticatable module) 16 | - Repurpose otp_tokens#destroy to disable 2FA and clear OTP secrets (rather than resetting them) 17 | - Add reset token action and hide/repurpose disable token action 18 | - Update disable action to preserve the existing token secret 19 | - Hide button for mandatory OTP 20 | - Add Refreshable hook, and tie into after\_set\_user calback 21 | - Utilize native warden session for scoping of credentials\_refreshed\_at and refresh\_return\_url properties 22 | - Require adding "ensure\_mandatory\_{scope}\_otp! to controllers for mandatory OTP 23 | - Update locales to support the new workflow 24 | 25 | ### Upgrading 26 | 27 | Regenerate your views with `rails g devise_otp:views` and update locales. 28 | 29 | Changes to locales: 30 | 31 | - Remove: 32 | - otp_tokens.enable_request 33 | - otp_tokens.status 34 | - otp_tokens.submit 35 | - Add to otp_tokens scope: 36 | - enable_link 37 | - Move/rename devise.otp.token_secret.reset_\* values to devise.otp.otp_tokens.disable_\* (for consistency with "enable_link") 38 | - disable_link 39 | - disable_explain 40 | - disable_explain_warn 41 | - Add to new edit_otp_token scope: 42 | - title 43 | - lead_in 44 | - step1 45 | - step2 46 | - confirmation_code 47 | - submit 48 | - Move "explain" to new edit_otp_token scope 49 | - Add devise.otp.otp_tokens.could_not_confirm 50 | - Rename "successfully_reset_creds" to "successfully_disabled_otp" 51 | 52 | You can grab the full locale file [here](https://github.com/wmlele/devise-otp/blob/master/config/locales/en.yml). 53 | 54 | ## 0.6.0 55 | 56 | Improvements: 57 | 58 | - support rails 6.1 by @cotcomsol in #67 59 | 60 | Fixes: 61 | 62 | - mandatory otp fix by @cotcomsol in #68 63 | - remove success message by @strzibny in #69 64 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in devise-otp.gemspec 4 | gemspec 5 | 6 | gem "appraisal", git: "https://github.com/thoughtbot/appraisal.git" 7 | 8 | gem "capybara" 9 | gem "minitest-reporters", ">= 0.5.0" 10 | gem "puma" 11 | gem "rake" 12 | gem "rdoc" 13 | gem "shoulda" 14 | gem "sprockets-rails" 15 | gem "sqlite3", "~> 2.1" 16 | gem "standardrb" 17 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Lele Forzani 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Devise::OTP 2 | 3 | Devise OTP is a Two-Factor Authentication extension for Devise. The second factor is done using an [RFC 6238](https://datatracker.ietf.org/doc/html/rfc6238) Time-Based One-Time Password (TOTP) implemented by the [rotp library](https://github.com/mdp/rotp). 4 | 5 | It has the following features: 6 | 7 | - Optional and mandatory OTP enforcement 8 | - Setting up trusted browsers for limited access 9 | - Generating QR codes 10 | 11 | Some of the compatible token devices are: 12 | 13 | * [Google Authenticator](https://code.google.com/p/google-authenticator/) 14 | * [FreeOTP](https://fedorahosted.org/freeotp/) 15 | 16 | Devise OTP was recently updated to work with Rails 7+ and Turbo. 17 | 18 | ## Sponsor 19 | 20 | Devise::OTP development is sponsored by [Business Class](https://businessclasskit.com/) Rails SaaS starter kit. If you don't want to setup OTP yourself for your new project, consider starting one on Business Class. 21 | 22 | ## Two-Factor Authentication using OTP 23 | 24 | * A shared secret is generated on the server, and stored both on the token device (e.g. the phone) and the server itself. 25 | * The secret is used to generate short numerical tokens that are either time or sequence based. 26 | * Tokens can be generated on a phone without internet connectivity. 27 | * The token provides an additional layer of security against password theft. 28 | * OTP's should always be used as a second factor of authentication(if your phone is lost, you account is still secured with a password) 29 | * Google Authenticator allows you to store multiple OTP secrets and provision those using a QR Code 30 | 31 | *Although there's an adjustable drift window, it is important that both the server and the token device (phone) have their clocks set (eg: using NTP).* 32 | 33 | ## Installation 34 | 35 | If you haven't, set up [Devise](https://github.com/heartcombo/devise) first. 36 | 37 | To add Devise OTP, add this line to your application's Gemfile: 38 | 39 | gem "devise-otp" 40 | 41 | And then execute: 42 | 43 | $ bundle 44 | 45 | Or install it yourself as: 46 | 47 | $ gem install devise-otp 48 | 49 | Run the following generator to add the necessary configuration options to Devise's config file: 50 | 51 | rails g devise_otp:install 52 | 53 | After you've created your Devise user models (which is usually done with a `rails g devise MODEL`), set up your Devise OTP additions: 54 | 55 | rails g devise_otp MODEL 56 | 57 | Don't forget to migrate: 58 | 59 | rake db:migrate 60 | 61 | ### Default CSS 62 | 63 | To use the default CSS for devise-otp, just require the devise-otp.css file as usual in your application.css file (or equivalent): 64 | 65 | *= require devise-otp 66 | 67 | It might be even easier to just copy the styles to your project. 68 | 69 | ### Custom views 70 | 71 | If you want to customise your views, you can use the following generator to eject the default view files: 72 | 73 | rails g devise_otp:views 74 | 75 | By default, the files live within the Devise namespace (`app/views/devise`, but if you want to move them or want to match the Devise configuration, set `config.otp_controller_path` in your initializers. 76 | 77 | ### I18n 78 | 79 | The install generator also installs an english copy of a Devise OTP i18n file. This can be modified (or used to create other language versions) and is located at: _config/locales/devise.otp.en.yml_ 80 | 81 | ### QR codes 82 | 83 | Devise OTP generates QR Codes directly as SVG's via the [rqrcode](https://github.com/whomwah/rqrcode), so there are no JavaScript (or Sprockets) dependencies. 84 | 85 | ## Configuration 86 | 87 | The install generator adds some options to the end of your Devise config file (`config/initializers/devise.rb`): 88 | 89 | * `config.otp_mandatory`: OTP is mandatory, users are going to be asked to enroll the next time they sign in, before they can successfully complete the session establishment. 90 | * `config.otp_authentication_timeout`: How long the user has to authenticate with their token. (defaults to `3.minutes`) 91 | * `config.otp_drift_window`: A window which provides allowance for drift between a user's token device clock (and therefore their OTP tokens) and the authentication server's clock. Expressed in minutes centered at the current time. (default: `3`) 92 | * `config.otp_credentials_refresh`: Users that have logged in longer than this time ago, are going to be asked their password (and an OTP challenge, if enabled) before they can see or change their otp informations. (defaults to `15.minutes`) 93 | * `config.otp_recovery_tokens`: Whether the users are given a list of one-time recovery tokens, for emergency access (default: `10`, set to `false` to disable) 94 | * `config.otp_trust_persistence`: The user is allowed to set his browser as "trusted", no more OTP challenges will be asked for that browser, for a limited time. (default: `1.month`, set to false to disable setting the browser as trusted) 95 | * `config.otp_issuer`: The name of the token issuer, to be added to the provisioning url. Display will vary based on token application. (defaults to the Rails application class) 96 | * `config.otp_controller_path`: The view path for Devise OTP controllers. The default being 'devise' to match Devise default installation. 97 | 98 | ## Mandatory OTP 99 | Enforcing mandatory OTP requires adding the ensure\_mandatory\_{scope}\_otp! method to the desired controller(s) to ensure that the user is redirected to the Enable Two-Factor Authentication form before proceeding to other parts of the application. This functions the same way as the authenticate\_{scope}! methods, and can be included inline with them in the controllers, e.g.: 100 | 101 | before_action :authenticate_user! 102 | before_action :ensure_mandatory_user_otp! 103 | 104 | ## Authors 105 | 106 | The project was originally started by Lele Forzani by forking [devise_google_authenticator](https://github.com/AsteriskLabs/devise_google_authenticator) and still contains some devise_google_authenticator code. It's now maintained by [Josef Strzibny](https://github.com/strzibny/) and [Laney Stroup](https://github.com/strouptl). 107 | 108 | Contributions are welcome! 109 | 110 | ## License 111 | 112 | MIT Licensed 113 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | begin 3 | require "bundler/setup" 4 | rescue LoadError 5 | puts "You must `gem install bundler` and `bundle install` to run rake tasks" 6 | end 7 | begin 8 | require "rdoc/task" 9 | rescue LoadError 10 | require "rdoc/rdoc" 11 | require "rake/rdoctask" 12 | RDoc::Task = Rake::RDocTask 13 | end 14 | 15 | RDoc::Task.new(:rdoc) do |rdoc| 16 | rdoc.rdoc_dir = "rdoc" 17 | rdoc.title = "Foobar" 18 | rdoc.options << "--line-numbers" 19 | rdoc.rdoc_files.include("README.rdoc") 20 | rdoc.rdoc_files.include("lib/**/*.rb") 21 | end 22 | 23 | Bundler::GemHelper.install_tasks 24 | 25 | require "rake/testtask" 26 | Rake::TestTask.new(:test) do |test| 27 | test.libs << "lib" << "test" 28 | test.pattern = "test/**/*_test.rb" 29 | test.verbose = true 30 | end 31 | 32 | desc "Run Devise tests for all ORMs." 33 | task :tests do 34 | Dir[File.join(File.dirname(__FILE__), "test", "orm", "*.rb")].each do |file| 35 | orm = File.basename(file).split(".").first 36 | system "rake test DEVISE_ORM=#{orm}" 37 | end 38 | end 39 | 40 | desc "Default: run tests for all ORMs." 41 | task default: :tests 42 | -------------------------------------------------------------------------------- /app/assets/stylesheets/devise-otp.css: -------------------------------------------------------------------------------- 1 | .qrcode-container { 2 | max-width: 300px; 3 | margin: 0 auto; 4 | } 5 | -------------------------------------------------------------------------------- /app/controllers/devise_otp/devise/otp_credentials_controller.rb: -------------------------------------------------------------------------------- 1 | module DeviseOtp 2 | module Devise 3 | class OtpCredentialsController < DeviseController 4 | helper_method :new_session_path 5 | 6 | prepend_before_action :authenticate_scope!, only: [:get_refresh, :set_refresh] 7 | prepend_before_action :require_no_authentication, only: [:show, :update] 8 | before_action :set_challenge, only: [:show, :update] 9 | before_action :set_recovery, only: [:show, :update] 10 | before_action :set_resource, only: [:show, :update] 11 | before_action :set_token, only: [:update] 12 | before_action :skip_challenge_if_trusted_browser, only: [:show, :update] 13 | 14 | # 15 | # show a request for the OTP token 16 | # 17 | def show 18 | if @recovery 19 | @recovery_count = resource.otp_recovery_counter 20 | end 21 | 22 | render :show 23 | end 24 | 25 | # 26 | # signs the resource in, if the OTP token is valid and the user has a valid challenge 27 | # 28 | def update 29 | if resource.otp_challenge_valid? && resource.validate_otp_token(@token, @recovery) 30 | sign_in(resource_name, resource) 31 | 32 | otp_set_trusted_device_for(resource) if params[:enable_persistence] == "true" 33 | otp_refresh_credentials_for(resource) 34 | respond_with resource, location: after_sign_in_path_for(resource) 35 | else 36 | kind = (@token.blank? ? :token_blank : :token_invalid) 37 | otp_set_flash_message :alert, kind, :now => true 38 | render :show 39 | end 40 | end 41 | 42 | # 43 | # displays the request for a credentials refresh 44 | # 45 | def get_refresh 46 | ensure_resource! 47 | render :refresh 48 | end 49 | 50 | # 51 | # lets the user through is the refresh is valid 52 | # 53 | def set_refresh 54 | ensure_resource! 55 | 56 | if resource.valid_password?(params[resource_name][:refresh_password]) 57 | done_valid_refresh 58 | else 59 | failed_refresh 60 | end 61 | end 62 | 63 | private 64 | 65 | def set_challenge 66 | @challenge = params[:challenge] 67 | 68 | unless @challenge.present? 69 | redirect_to :root 70 | end 71 | end 72 | 73 | def set_recovery 74 | @recovery = (recovery_enabled? && params[:recovery] == "true") 75 | end 76 | 77 | def set_resource 78 | self.resource = resource_class.find_valid_otp_challenge(@challenge) 79 | 80 | unless resource.present? 81 | otp_set_flash_message(:alert, :otp_session_invalid) 82 | redirect_to new_session_path(resource_name) 83 | end 84 | end 85 | 86 | def set_token 87 | @token = params[:token] 88 | end 89 | 90 | def skip_challenge_if_trusted_browser 91 | if is_otp_trusted_browser_for?(resource) 92 | sign_in(resource_name, resource) 93 | otp_refresh_credentials_for(resource) 94 | redirect_to after_sign_in_path_for(resource) 95 | end 96 | end 97 | 98 | def done_valid_refresh 99 | otp_refresh_credentials_for(resource) 100 | respond_with resource, location: otp_fetch_refresh_return_url 101 | end 102 | 103 | def failed_refresh 104 | otp_set_flash_message :alert, :invalid_refresh, :now => true 105 | render :refresh 106 | end 107 | 108 | def self.controller_path 109 | "#{::Devise.otp_controller_path}/otp_credentials" 110 | end 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /app/controllers/devise_otp/devise/otp_tokens_controller.rb: -------------------------------------------------------------------------------- 1 | module DeviseOtp 2 | module Devise 3 | class OtpTokensController < DeviseController 4 | include ::Devise::Controllers::Helpers 5 | 6 | prepend_before_action :ensure_credentials_refresh 7 | prepend_before_action :authenticate_scope! 8 | 9 | protect_from_forgery except: [:clear_persistence, :delete_persistence] 10 | 11 | # 12 | # Displays the status of OTP authentication 13 | # 14 | def show 15 | if resource.nil? 16 | redirect_to stored_location_for(scope) || :root 17 | else 18 | render :show 19 | end 20 | end 21 | 22 | # 23 | # Displays the QR Code and Validation Token form for enabling the OTP 24 | # 25 | def edit 26 | resource.populate_otp_secrets! 27 | end 28 | 29 | # 30 | # Updates the status of OTP authentication 31 | # 32 | def update 33 | if resource.valid_otp_token?(params[:confirmation_code]) 34 | resource.enable_otp! 35 | otp_set_flash_message :success, :successfully_updated 36 | redirect_to otp_token_path_for(resource) 37 | else 38 | otp_set_flash_message :danger, :could_not_confirm, :now => true 39 | render :edit 40 | end 41 | end 42 | 43 | # 44 | # Resets OTP authentication, generates new credentials, sets it to off 45 | # 46 | def destroy 47 | if resource.disable_otp! 48 | otp_set_flash_message :success, :successfully_disabled_otp 49 | end 50 | 51 | redirect_to otp_token_path_for(resource) 52 | end 53 | 54 | # 55 | # makes the current browser persistent 56 | # 57 | def get_persistence 58 | if otp_set_trusted_device_for(resource) 59 | otp_set_flash_message :success, :successfully_set_persistence 60 | end 61 | 62 | redirect_to otp_token_path_for(resource) 63 | end 64 | 65 | # 66 | # clears persistence for the current browser 67 | # 68 | def clear_persistence 69 | if otp_clear_trusted_device_for(resource) 70 | otp_set_flash_message :success, :successfully_cleared_persistence 71 | end 72 | 73 | redirect_to otp_token_path_for(resource) 74 | end 75 | 76 | # 77 | # rehash the persistence secret, thus, making all the persistence cookies invalid 78 | # 79 | def delete_persistence 80 | if otp_reset_persistence_for(resource) 81 | otp_set_flash_message :notice, :successfully_reset_persistence 82 | end 83 | 84 | redirect_to otp_token_path_for(resource) 85 | end 86 | 87 | def recovery 88 | respond_to do |format| 89 | format.html 90 | format.js 91 | format.text do 92 | send_data render_to_string(template: "#{controller_path}/recovery_codes"), filename: "otp-recovery-codes.txt", format: "text" 93 | end 94 | end 95 | end 96 | 97 | def reset 98 | if resource.disable_otp! 99 | resource.clear_otp_fields! 100 | otp_set_flash_message :success, :successfully_reset_otp 101 | end 102 | 103 | redirect_to edit_otp_token_path_for(resource) 104 | end 105 | 106 | private 107 | 108 | def ensure_credentials_refresh 109 | ensure_resource! 110 | 111 | if needs_credentials_refresh?(resource) 112 | redirect_to refresh_otp_credential_path_for(resource) 113 | end 114 | end 115 | 116 | def scope 117 | resource_name.to_sym 118 | end 119 | 120 | def self.controller_path 121 | "#{::Devise.otp_controller_path}/otp_tokens" 122 | end 123 | end 124 | end 125 | end 126 | -------------------------------------------------------------------------------- /app/views/devise/otp_credentials/refresh.html.erb: -------------------------------------------------------------------------------- 1 |

<%= I18n.t('title', :scope => 'devise.otp.credentials_refresh') %>

2 |

<%= I18n.t('explain', :scope => 'devise.otp.credentials_refresh') %>

3 | 4 | <%= form_for(resource, :as => resource_name, :url => [:refresh, resource_name, :otp_credential], :html => { :method => :put, "data-turbo" => false }) do |f| %> 5 | 6 | <%= render "devise/shared/error_messages", resource: resource %> 7 | 8 |
9 | <%= f.label :email %>
10 | <%= f.text_field :email, :disabled => :true%> 11 |
12 | 13 |
14 | <%= f.label :password %>
15 | <%= f.password_field :refresh_password, :autocomplete => :off, :autofocus => true %> 16 |
17 | 18 |
<%= f.submit I18n.t(:go_on, :scope => 'devise.otp.credentials_refresh') %>
19 | <% end %> 20 | -------------------------------------------------------------------------------- /app/views/devise/otp_credentials/show.html.erb: -------------------------------------------------------------------------------- 1 |

<%= I18n.t('title', :scope => 'devise.otp.submit_token') %>

2 |

<%= I18n.t('explain', :scope => 'devise.otp.submit_token') %>

3 | 4 | <%= form_for(resource, :as => resource_name, :url => [resource_name, :otp_credential], :html => { :method => :put, "data-turbo" => false }) do |f| %> 5 | 6 | <%= hidden_field_tag :challenge, @challenge %> 7 | <%= hidden_field_tag :recovery, @recovery %> 8 | 9 | <% if @recovery %> 10 |

11 | <%= label_tag :token, I18n.t('recovery_prompt', :scope => 'devise.otp.submit_token') %>
12 | <%= text_field_tag :otp_recovery_counter, resource.otp_recovery_counter, :autocomplete => :off, :disabled => true, :size => 4 %> 13 |

14 | <% else %> 15 |

16 | <%= label_tag :token, I18n.t('prompt', :scope => 'devise.otp.submit_token') %>
17 |

18 | <% end %> 19 | 20 | <%= text_field_tag :token, nil, :autocomplete => :off, :autofocus => true, :size => 6 %>
21 | 22 | <%= label_tag :enable_persistence do %> 23 | <%= check_box_tag :enable_persistence, true, false %> <%= I18n.t('remember', :scope => 'devise.otp.general') %> 24 | <% end %> 25 | 26 |

<%= f.submit I18n.t('submit', :scope => 'devise.otp.submit_token') %>

27 | 28 | <% if !@recovery && recovery_enabled? %> 29 |

<%= link_to I18n.t('recovery_link', :scope => 'devise.otp.submit_token'), otp_credential_path_for(resource_name, :challenge => @challenge, :recovery => true) %>

30 | <% end %> 31 | <% end %> 32 | -------------------------------------------------------------------------------- /app/views/devise/otp_tokens/_token_secret.html.erb: -------------------------------------------------------------------------------- 1 |

<%= I18n.t('title', :scope => 'devise.otp.token_secret') %>

2 | 3 | <%= otp_authenticator_token_image(resource) %> 4 | 5 |

6 | <%= I18n.t('manual_provisioning', :scope => 'devise.otp.token_secret') %>: 7 | <%= resource.otp_auth_secret %> 8 |

9 | 10 |

<%= button_to I18n.t('reset_link', :scope => 'devise.otp.otp_tokens'), reset_otp_token_path_for(resource), :method => :post , :data => { "turbo-method": "POST" } %>

11 | 12 |

13 | <%= I18n.t('reset_explain', :scope => 'devise.otp.otp_tokens') %> 14 | <%= I18n.t('reset_explain_warn', :scope => 'devise.otp.otp_tokens') %> 15 |

16 | 17 | <%- if recovery_enabled? %> 18 |

<%= I18n.t('title', :scope => 'devise.otp.otp_tokens.recovery') %>

19 |

<%= I18n.t('explain', :scope => 'devise.otp.otp_tokens.recovery') %>

20 |

<%= link_to I18n.t('codes_list', :scope => 'devise.otp.otp_tokens.recovery'), recovery_otp_token_for(resource_name) %>

21 |

<%= link_to I18n.t('download_codes', :scope => 'devise.otp.otp_tokens.recovery'), recovery_otp_token_for(resource_name, format: :text) %>

22 | <% end %> 23 | -------------------------------------------------------------------------------- /app/views/devise/otp_tokens/_trusted_devices.html.erb: -------------------------------------------------------------------------------- 1 |

<%= I18n.t('title', :scope => 'devise.otp.trusted_browsers') %>

2 |

<%= I18n.t('explain', :scope => 'devise.otp.trusted_browsers') %>

3 | 4 | <%- if is_otp_trusted_browser_for? resource %> 5 |

<%= I18n.t('browser_trusted', :scope => 'devise.otp.trusted_browsers') %>

6 |

<%= link_to I18n.t('trust_remove', :scope => 'devise.otp.trusted_browsers'), persistence_otp_token_path_for(resource_name), :method => :post, :data => { "turbo-method": "POST" } %>

7 | <% else %> 8 |

<%= I18n.t('browser_not_trusted', :scope => 'devise.otp.trusted_browsers') %>

9 |

<%= link_to I18n.t('trust_add', :scope => 'devise.otp.trusted_browsers'), persistence_otp_token_path_for(resource_name) %>

10 | <% end %> 11 | 12 |

<%= button_to I18n.t('trust_clear', :scope => 'devise.otp.trusted_browsers'), persistence_otp_token_path_for(resource_name), :method => :delete, :data => { "turbo-method": "DELETE" } %>

13 | -------------------------------------------------------------------------------- /app/views/devise/otp_tokens/edit.html.erb: -------------------------------------------------------------------------------- 1 |

<%= I18n.t('title', :scope => 'devise.otp.edit_otp_token') %>

2 |

<%= I18n.t('explain', :scope => 'devise.otp.edit_otp_token') %>

3 | 4 |

<%= I18n.t('lead_in', :scope => 'devise.otp.edit_otp_token') %>

5 | 6 |

<%= I18n.t('step_1', :scope => 'devise.otp.edit_otp_token') %>

7 | 8 | <%= otp_authenticator_token_image(resource) %> 9 | 10 |

11 | <%= I18n.t('manual_provisioning', :scope => 'devise.otp.token_secret') %>: 12 | <%= resource.otp_auth_secret %> 13 |

14 | 15 |

<%= I18n.t('step_2', :scope => 'devise.otp.edit_otp_token') %>

16 | 17 | <%= form_with(:url => [resource_name, :otp_token], :method => :put) do |f| %> 18 | 19 |

20 | <%= f.label :confirmation_code, I18n.t('confirmation_code', :scope => 'devise.otp.edit_otp_token') %> 21 | <%= f.text_field :confirmation_code %> 22 |

23 | 24 |

<%= f.submit I18n.t('submit', :scope => 'devise.otp.edit_otp_token') %>

25 | 26 | <% end %> 27 | -------------------------------------------------------------------------------- /app/views/devise/otp_tokens/recovery.html.erb: -------------------------------------------------------------------------------- 1 |

<%= I18n.t('title', :scope => 'devise.otp.otp_tokens.recovery') %>

2 |

<%= I18n.t('explain', :scope => 'devise.otp.otp_tokens.recovery') %>

3 | 4 | 5 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | <%- resource.next_otp_recovery_tokens.each do |seq, code| %> 14 | 15 | 16 | 17 | 18 | <% end %> 19 | 20 | 21 |
6 |
<%= I18n.t('sequence', :scope => 'devise.otp.otp_tokens.recovery') %><%= I18n.t('code', :scope => 'devise.otp.otp_tokens.recovery') %>
<%= seq %><%= code %>
22 | -------------------------------------------------------------------------------- /app/views/devise/otp_tokens/recovery_codes.text.erb: -------------------------------------------------------------------------------- 1 | <% resource.next_otp_recovery_tokens.each do |seq, code| %> 2 | <%= code %> 3 | <% end %> 4 | -------------------------------------------------------------------------------- /app/views/devise/otp_tokens/show.html.erb: -------------------------------------------------------------------------------- 1 |

<%= I18n.t('title', :scope => 'devise.otp.otp_tokens') %>

2 | 3 |

Status: <%= resource.otp_enabled? ? "Enabled" : "Disabled" %>

4 | 5 | <%- if resource.otp_enabled? %> 6 | <%= render :partial => 'token_secret' if resource.otp_enabled? %> 7 | <%= render :partial => 'trusted_devices' if trusted_devices_enabled? %> 8 | 9 | <% unless otp_mandatory_on?(resource) %> 10 | <%= button_to I18n.t('disable_link', :scope => 'devise.otp.otp_tokens'), otp_token_path_for(resource), :method => :delete, :data => { "turbo-method": "DELETE" } %> 11 | <% end %> 12 | <% else %> 13 | <%= link_to I18n.t('enable_link', :scope => 'devise.otp.otp_tokens'), edit_otp_token_path_for(resource) %> 14 | <% end %> 15 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | devise: 3 | otp: 4 | general: 5 | remember: Remember me 6 | submit_token: 7 | title: 'Check Token' 8 | explain: "You're getting this because you enabled Two-Factor Authentication on your account" 9 | prompt: 'Please enter your Two-Factor Authentication token:' 10 | recovery_prompt: 'Please enter your recovery code:' 11 | submit: 'Submit Token' 12 | recovery_link: "I don't have my device, I want to use a recovery code" 13 | otp_credentials: 14 | otp_session_invalid: Session invalid. Please start again. 15 | token_invalid: 'The token you provided was invalid.' 16 | token_blank: 'You need to type in the token you generated with your device.' 17 | valid_refresh: 'Thank you, your credentials were accepted.' 18 | invalid_refresh: 'Sorry, you provided the wrong credentials.' 19 | credentials_refresh: 20 | title: 'Please enter your password again.' 21 | explain: 'In order to ensure this is safe, please enter your password again.' 22 | go_on: 'Continue...' 23 | identity: 'Identity:' 24 | token: 'Your Two-Factor Authentication token' 25 | token_secret: 26 | title: 'Your token secret' 27 | explain: 'Take a photo of this QR code with your mobile' 28 | manual_provisioning: 'Manual provisioning code' 29 | otp_tokens: 30 | title: 'Two-Factor Authentication:' 31 | enable_link: 'Enable Two-Factor Authentication' 32 | disable_link: 'Disable Two-Factor Authentication' 33 | reset_link: 'Reset Token Secret' 34 | reset_explain: 'Resetting your token secret will temporarilly disable Two-Factor Authentication.' 35 | reset_explain_warn: 'To re-enable Two-Factor Authentication, you will need to re-enroll your mobile device with the new token secret.' 36 | successfully_updated: 'Your Two-Factor Authentication settings have been updated.' 37 | could_not_confirm: 'The Confirmation Code you entered did not match the QR code shown below.' 38 | successfully_disabled_otp: 'Two-Factor Authentication has been disabled.' 39 | successfully_reset_otp: 'Your token secret has been reset. Please confirm your new token secret below.' 40 | successfully_set_persistence: 'Your device is now trusted.' 41 | successfully_cleared_persistence: 'Your device has been removed from the list of trusted devices.' 42 | successfully_reset_persistence: 'Your list of trusted devices has been cleared.' 43 | recovery: 44 | title: 'Your Emergency Recovery Codes' 45 | explain: 'Take note or print these recovery codes. The will allow you to log back in in case your token device is lost, stolen, or unavailable.' 46 | sequence: 'Sequence' 47 | code: 'Recovery Code' 48 | codes_list: 'Here is the list of your recovery codes' 49 | download_codes: 'Download recovery codes' 50 | edit_otp_token: 51 | title: 'Enable Two-factor Authentication' 52 | explain: 'Two-Factor Authentication adds an additional layer of security to your account. When logging in you will be asked for a code that you can generate on a physical device, like your phone.' 53 | lead_in: 'To Enable Two-Factor Authentication:' 54 | step_1: '1. Open your authenticator app and scan the QR code shown below:' 55 | step_2: '2. Enter the 6-digit code shown in your authenticator app below:' 56 | confirmation_code: "Confirmation Code" 57 | submit: 'Continue...' 58 | trusted_browsers: 59 | title: 'Trusted Browsers' 60 | explain: 'If you set your browser as trusted, you will not be asked to provide a Two-Factor Authentication token when logging in from that browser.' 61 | browser_trusted: 'Your browser is trusted.' 62 | browser_not_trusted: 'Your browser is not trusted.' 63 | trust_remove: 'Remove this browser from the list of trusted browsers' 64 | trust_add: 'Trust this browser' 65 | trust_clear: 'Clear the list of trusted browsers' 66 | -------------------------------------------------------------------------------- /devise-otp.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/devise-otp/version" 4 | 5 | Gem::Specification.new do |gem| 6 | gem.name = "devise-otp" 7 | gem.version = Devise::OTP::VERSION 8 | gem.authors = ["Lele Forzani", "Josef Strzibny", "Laney Stroup"] 9 | gem.email = ["lele@windmill.it", "strzibny@strzibny.name", "laney@stroupsolutions.com"] 10 | gem.description = "OTP authentication for Devise" 11 | gem.summary = "Time Based OTP/rfc6238 compatible authentication for Devise" 12 | gem.homepage = "https://github.com/wmlele/devise-otp" 13 | gem.license = "MIT" 14 | 15 | gem.files = `git ls-files`.split($/) 16 | gem.require_paths = ["lib"] 17 | 18 | gem.required_ruby_version = ">= 3.2.0" 19 | 20 | gem.add_dependency "rails", ">= 7.1" 21 | gem.add_dependency "devise", ">= 4.8.0", "< 5.0" 22 | gem.add_dependency "rotp", ">= 2.0.0" 23 | gem.add_dependency "rqrcode", "~> 2.0" 24 | end 25 | -------------------------------------------------------------------------------- /gemfiles/rails_7.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal", git: "https://github.com/thoughtbot/appraisal.git" 6 | gem "capybara" 7 | gem "minitest-reporters", ">= 0.5.0" 8 | gem "puma" 9 | gem "rake" 10 | gem "rdoc" 11 | gem "shoulda" 12 | gem "sprockets-rails" 13 | gem "sqlite3", "~> 1.5.0" 14 | gem "standardrb" 15 | gem "rails", "~> 7.1.0" 16 | 17 | install_if -> { Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.4.0") } do 18 | gem "logger" 19 | end 20 | 21 | gemspec path: "../" 22 | -------------------------------------------------------------------------------- /gemfiles/rails_7.2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal", git: "https://github.com/thoughtbot/appraisal.git" 6 | gem "capybara" 7 | gem "minitest-reporters", ">= 0.5.0" 8 | gem "puma" 9 | gem "rake" 10 | gem "rdoc" 11 | gem "shoulda" 12 | gem "sprockets-rails" 13 | gem "sqlite3", "~> 1.5.0" 14 | gem "standardrb" 15 | gem "rails", "~> 7.2.0" 16 | 17 | gemspec path: "../" 18 | -------------------------------------------------------------------------------- /gemfiles/rails_8.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal", git: "https://github.com/thoughtbot/appraisal.git" 6 | gem "capybara" 7 | gem "minitest-reporters", ">= 0.5.0" 8 | gem "puma" 9 | gem "rake" 10 | gem "rdoc" 11 | gem "shoulda" 12 | gem "sprockets-rails" 13 | gem "sqlite3", "~> 2.1" 14 | gem "standardrb" 15 | gem "rails", "~> 8.0.0" 16 | 17 | gemspec path: "../" 18 | -------------------------------------------------------------------------------- /lib/devise-otp.rb: -------------------------------------------------------------------------------- 1 | require "devise-otp/version" 2 | 3 | # cherry pick active-support extensions 4 | # require 'active_record/connection_adapters/abstract/schema_definitions' 5 | require "active_support/core_ext/integer" 6 | require "active_support/core_ext/string" 7 | require "active_support/ordered_hash" 8 | require "active_support/concern" 9 | 10 | require "devise" 11 | 12 | # 13 | # define DeviseOtpAuthenticatable module, and autoload hooks and helpers 14 | # 15 | module DeviseOtpAuthenticatable 16 | module Controllers 17 | autoload :Helpers, "devise_otp_authenticatable/controllers/helpers" 18 | autoload :UrlHelpers, "devise_otp_authenticatable/controllers/url_helpers" 19 | autoload :PublicHelpers, "devise_otp_authenticatable/controllers/public_helpers" 20 | end 21 | end 22 | 23 | require "devise_otp_authenticatable/routes" 24 | require "devise_otp_authenticatable/engine" 25 | require "devise_otp_authenticatable/hooks/refreshable" 26 | 27 | # 28 | # update Devise module with additions needed for DeviseOtpAuthenticatable 29 | # 30 | module Devise 31 | mattr_accessor :otp_mandatory 32 | @@otp_mandatory = false 33 | 34 | mattr_accessor :otp_authentication_timeout 35 | @@otp_authentication_timeout = 3.minutes 36 | 37 | mattr_accessor :otp_recovery_tokens 38 | @@otp_recovery_tokens = 10 ## false to disable 39 | 40 | # 41 | # If the user is given the chance to set his browser as trusted, how long will it stay trusted. 42 | # set to nil/false to disable the ability to set a device as trusted 43 | # 44 | mattr_accessor :otp_trust_persistence 45 | @@otp_trust_persistence = 30.days 46 | 47 | mattr_accessor :otp_drift_window 48 | @@otp_drift_window = 3 # in minutes 49 | 50 | # 51 | # if the user wants to change Otp settings, 52 | # ask the password (and the token) again if this time has passed since the last 53 | # time the user has provided valid credentials 54 | # 55 | mattr_accessor :otp_credentials_refresh 56 | @@otp_credentials_refresh = 15.minutes # or like 15.minutes, false to disable 57 | 58 | # 59 | # the name of the token issuer 60 | # 61 | mattr_accessor :otp_issuer 62 | @@otp_issuer = Rails.application.class.module_parent_name 63 | 64 | # 65 | # custom view path 66 | # 67 | mattr_accessor :otp_controller_path 68 | @@otp_controller_path = "devise" 69 | 70 | # 71 | # add PublicHelpers to helpers class variable to ensure that per-mapping helpers are present. 72 | # this integrates with the "define_helpers," which is run when adding each mapping in the Devise gem (lib/devise.rb#541) 73 | # 74 | @@helpers << DeviseOtpAuthenticatable::Controllers::PublicHelpers 75 | 76 | module Otp 77 | end 78 | 79 | end 80 | 81 | Devise.add_module :otp_authenticatable, 82 | controller: :tokens, 83 | model: "devise_otp_authenticatable/models/otp_authenticatable", route: :otp 84 | 85 | # 86 | # add PublicHelpers after adding Devise module to ensure that per-mapping routes from above are included 87 | # 88 | ActiveSupport.on_load(:action_controller) do 89 | include DeviseOtpAuthenticatable::Controllers::PublicHelpers 90 | end 91 | -------------------------------------------------------------------------------- /lib/devise-otp/version.rb: -------------------------------------------------------------------------------- 1 | module Devise 2 | module OTP 3 | VERSION = "1.0.0" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/devise/strategies/database_authenticatable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'devise/strategies/authenticatable' 4 | 5 | module Devise 6 | module Strategies 7 | # Default strategy for signing in a user, based on their email and password in the database. 8 | class DatabaseAuthenticatable < Authenticatable 9 | def authenticate! 10 | resource = password.present? && mapping.to.find_for_database_authentication(authentication_hash) 11 | hashed = false 12 | 13 | if validate(resource){ hashed = true; resource.valid_password?(password) } 14 | if otp_challenge_required_on?(resource) 15 | # Redirect to challenge 16 | challenge = resource.generate_otp_challenge! 17 | redirect!(otp_challenge_url, {:challenge => challenge}) 18 | else 19 | # Sign in user as usual 20 | remember_me(resource) 21 | resource.after_database_authentication 22 | success!(resource) 23 | end 24 | end 25 | 26 | # In paranoid mode, hash the password even when a resource doesn't exist for the given authentication key. 27 | # This is necessary to prevent enumeration attacks - e.g. the request is faster when a resource doesn't 28 | # exist in the database if the password hashing algorithm is not called. 29 | mapping.to.new.password = password if !hashed && Devise.paranoid 30 | unless resource 31 | Devise.paranoid ? fail(:invalid) : fail(:not_found_in_database) 32 | end 33 | end 34 | 35 | private 36 | 37 | # 38 | # resource should be challenged for otp 39 | # 40 | def otp_challenge_required_on?(resource) 41 | resource.respond_to?(:otp_enabled?) && resource.otp_enabled? 42 | end 43 | 44 | def otp_challenge_url 45 | if Rails.env.development? || Rails.env.test? 46 | host = "#{request.host}:#{request.port}" 47 | else 48 | host = "#{request.host}" 49 | end 50 | 51 | path_fragments = ["otp", mapping.path_names[:credentials]] 52 | if mapping.fullpath == "/" 53 | path = mapping.fullpath + path_fragments.join("/") 54 | else 55 | path = path_fragments.prepend(mapping.fullpath).join("/") 56 | end 57 | 58 | request.protocol + host + path 59 | end 60 | end 61 | end 62 | end 63 | 64 | Warden::Strategies.add(:database_authenticatable, Devise::Strategies::DatabaseAuthenticatable) 65 | -------------------------------------------------------------------------------- /lib/devise_otp_authenticatable/controllers/helpers.rb: -------------------------------------------------------------------------------- 1 | require "rqrcode" 2 | 3 | module DeviseOtpAuthenticatable 4 | module Controllers 5 | module Helpers 6 | def authenticate_scope! 7 | send(:"authenticate_#{resource_name}!", force: true) 8 | self.resource = send("current_#{resource_name}") 9 | end 10 | 11 | # 12 | # similar to DeviseController#set_flash_message, but sets the scope inside 13 | # the otp controller 14 | # 15 | def otp_set_flash_message(key, kind, options = {}) 16 | options[:scope] ||= "devise.otp.#{controller_name}" 17 | 18 | set_flash_message(key, kind, options) 19 | end 20 | 21 | def otp_t 22 | end 23 | 24 | def trusted_devices_enabled? 25 | resource.class.otp_trust_persistence && (resource.class.otp_trust_persistence > 0) 26 | end 27 | 28 | def recovery_enabled? 29 | resource_class.otp_recovery_tokens && (resource_class.otp_recovery_tokens > 0) 30 | end 31 | 32 | # 33 | # Sanity check for resource validity 34 | # 35 | def ensure_resource! 36 | if resource.nil? 37 | raise ArgumentError, "Should not happen" 38 | end 39 | end 40 | 41 | # 42 | # check if the resource needs a credentials refresh. IE, they need to be asked a password again to access 43 | # this resource. 44 | # 45 | def needs_credentials_refresh?(resource) 46 | return false unless resource.class.otp_credentials_refresh 47 | 48 | (!warden.session(resource_name)[otp_refresh_property].present? || 49 | (warden.session(resource_name)[otp_refresh_property] < DateTime.now)).tap { |need| otp_set_refresh_return_url if need } 50 | end 51 | 52 | # 53 | # credentials are refreshed 54 | # 55 | def otp_refresh_credentials_for(resource) 56 | return false unless resource.class.otp_credentials_refresh 57 | warden.session(resource_name)[otp_refresh_property] = (Time.now + resource.class.otp_credentials_refresh) 58 | end 59 | 60 | # 61 | # is the current browser trusted? 62 | # 63 | def is_otp_trusted_browser_for?(resource) 64 | return false unless resource.class.otp_trust_persistence 65 | if cookies[otp_scoped_persistence_cookie].present? 66 | cookies.signed[otp_scoped_persistence_cookie] == 67 | [resource.to_key, resource.authenticatable_salt, resource.otp_persistence_seed] 68 | else 69 | false 70 | end 71 | end 72 | 73 | # 74 | # make the current browser trusted 75 | # 76 | def otp_set_trusted_device_for(resource) 77 | return unless resource.class.otp_trust_persistence 78 | cookies.signed[otp_scoped_persistence_cookie] = { 79 | httponly: true, 80 | expires: Time.now + resource.class.otp_trust_persistence, 81 | value: [resource.to_key, resource.authenticatable_salt, resource.otp_persistence_seed] 82 | } 83 | end 84 | 85 | def otp_set_refresh_return_url 86 | warden.session(resource_name)[otp_refresh_return_url_property] = request.fullpath 87 | end 88 | 89 | def otp_fetch_refresh_return_url 90 | warden.session(resource_name).delete(otp_refresh_return_url_property) { :root } 91 | end 92 | 93 | def otp_refresh_return_url_property 94 | "refresh_return_url" 95 | end 96 | 97 | def otp_refresh_property 98 | "credentials_refreshed_at" 99 | end 100 | 101 | def otp_scoped_persistence_cookie 102 | "otp_#{resource_name}_device_trusted" 103 | end 104 | 105 | # 106 | # make the current browser NOT trusted 107 | # 108 | def otp_clear_trusted_device_for(resource) 109 | cookies.delete(otp_scoped_persistence_cookie) 110 | end 111 | 112 | # 113 | # clears the persistence list for this kind of resource 114 | # 115 | def otp_reset_persistence_for(resource) 116 | otp_clear_trusted_device_for(resource) 117 | resource.reset_otp_persistence! 118 | end 119 | 120 | # 121 | # returns the URL for the QR Code to initialize the Authenticator device 122 | # 123 | def otp_authenticator_token_image(resource) 124 | content_tag(:div, class: "qrcode-container") do 125 | raw RQRCode::QRCode.new(resource.otp_provisioning_uri).as_svg(:module_size => 5, :viewbox => true, :use_path => true) 126 | end 127 | end 128 | 129 | end 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /lib/devise_otp_authenticatable/controllers/public_helpers.rb: -------------------------------------------------------------------------------- 1 | module DeviseOtpAuthenticatable 2 | module Controllers 3 | module PublicHelpers 4 | extend ActiveSupport::Concern 5 | 6 | def self.generate_helpers! 7 | Devise.mappings.each do |key, mapping| 8 | self.define_helpers(mapping) 9 | end 10 | end 11 | 12 | def self.define_helpers(mapping) #:nodoc: 13 | mapping = mapping.name 14 | 15 | class_eval <<-METHODS, __FILE__, __LINE__ + 1 16 | def ensure_mandatory_#{mapping}_otp! 17 | resource = current_#{mapping} 18 | if !devise_controller? 19 | if mandatory_otp_missing_on?(resource) 20 | redirect_to edit_#{mapping}_otp_token_path 21 | end 22 | end 23 | end 24 | METHODS 25 | end 26 | 27 | def otp_mandatory_on?(resource) 28 | return false unless resource.respond_to?(:otp_mandatory) 29 | 30 | resource.class.otp_mandatory or resource.otp_mandatory 31 | end 32 | 33 | def mandatory_otp_missing_on?(resource) 34 | otp_mandatory_on?(resource) && !resource.otp_enabled 35 | end 36 | 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/devise_otp_authenticatable/controllers/url_helpers.rb: -------------------------------------------------------------------------------- 1 | module DeviseOtpAuthenticatable 2 | module Controllers 3 | module UrlHelpers 4 | def recovery_otp_token_for(resource_or_scope, opts = {}) 5 | scope = ::Devise::Mapping.find_scope!(resource_or_scope) 6 | send("recovery_#{scope}_otp_token_path", opts) 7 | end 8 | 9 | def refresh_otp_credential_path_for(resource_or_scope, opts = {}) 10 | scope = ::Devise::Mapping.find_scope!(resource_or_scope) 11 | send("refresh_#{scope}_otp_credential_path", opts) 12 | end 13 | 14 | def persistence_otp_token_path_for(resource_or_scope, opts = {}) 15 | scope = ::Devise::Mapping.find_scope!(resource_or_scope) 16 | send("persistence_#{scope}_otp_token_path", opts) 17 | end 18 | 19 | def otp_token_path_for(resource_or_scope, opts = {}) 20 | scope = ::Devise::Mapping.find_scope!(resource_or_scope) 21 | send("#{scope}_otp_token_path", opts) 22 | end 23 | 24 | def edit_otp_token_path_for(resource_or_scope, opts = {}) 25 | scope = ::Devise::Mapping.find_scope!(resource_or_scope) 26 | send("edit_#{scope}_otp_token_path", opts) 27 | end 28 | 29 | def reset_otp_token_path_for(resource_or_scope, opts = {}) 30 | scope = ::Devise::Mapping.find_scope!(resource_or_scope) 31 | send("reset_#{scope}_otp_token_path", opts) 32 | end 33 | 34 | def otp_credential_path_for(resource_or_scope, opts = {}) 35 | scope = ::Devise::Mapping.find_scope!(resource_or_scope) 36 | send("#{scope}_otp_credential_path", opts) 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/devise_otp_authenticatable/engine.rb: -------------------------------------------------------------------------------- 1 | module DeviseOtpAuthenticatable 2 | class Engine < ::Rails::Engine 3 | config.devise_otp = ActiveSupport::OrderedOptions.new 4 | config.devise_otp.precompile_assets = true 5 | 6 | initializer "devise-otp", group: :all do |app| 7 | ActiveSupport.on_load(:devise_controller) do 8 | include DeviseOtpAuthenticatable::Controllers::UrlHelpers 9 | include DeviseOtpAuthenticatable::Controllers::Helpers 10 | include DeviseOtpAuthenticatable::Controllers::PublicHelpers 11 | end 12 | 13 | ActiveSupport.on_load(:action_view) do 14 | include DeviseOtpAuthenticatable::Controllers::UrlHelpers 15 | include DeviseOtpAuthenticatable::Controllers::Helpers 16 | include DeviseOtpAuthenticatable::Controllers::PublicHelpers 17 | end 18 | 19 | # See: https://guides.rubyonrails.org/engines.html#separate-assets-and-precompiling 20 | # check if Rails api mode 21 | if app.config.respond_to?(:assets) && app.config.devise_otp.precompile_assets 22 | app.config.assets.precompile << if defined?(Sprockets) && Sprockets::VERSION >= "4" 23 | "devise-otp.js" 24 | else 25 | # use a proc instead of a string 26 | proc { |path| path == "devise-otp.js" } 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/devise_otp_authenticatable/hooks/refreshable.rb: -------------------------------------------------------------------------------- 1 | # After each sign in, update credentials refreshed at time 2 | Warden::Manager.after_set_user except: :fetch do |record, warden, options| 3 | warden.session(options[:scope])["credentials_refreshed_at"] = (Time.now + record.class.otp_credentials_refresh) 4 | end 5 | 6 | -------------------------------------------------------------------------------- /lib/devise_otp_authenticatable/models/otp_authenticatable.rb: -------------------------------------------------------------------------------- 1 | require "rotp" 2 | 3 | module Devise::Models 4 | module OtpAuthenticatable 5 | extend ActiveSupport::Concern 6 | 7 | included do 8 | scope :with_valid_otp_challenge, lambda { |time| where("otp_challenge_expires > ?", time) } 9 | end 10 | 11 | module ClassMethods 12 | ::Devise::Models.config(self, :otp_authentication_timeout, :otp_drift_window, :otp_trust_persistence, 13 | :otp_mandatory, :otp_credentials_refresh, :otp_issuer, :otp_recovery_tokens, 14 | :otp_controller_path) 15 | 16 | def find_valid_otp_challenge(challenge) 17 | with_valid_otp_challenge(Time.now).where(otp_session_challenge: challenge).first 18 | end 19 | end 20 | 21 | def time_based_otp 22 | @time_based_otp ||= ROTP::TOTP.new(otp_auth_secret, issuer: (self.class.otp_issuer || Rails.application.class.module_parent_name).to_s) 23 | end 24 | 25 | def recovery_otp 26 | @recovery_otp ||= ROTP::HOTP.new(otp_recovery_secret) 27 | end 28 | 29 | def otp_provisioning_uri 30 | time_based_otp.provisioning_uri(otp_provisioning_identifier) 31 | end 32 | 33 | def otp_provisioning_identifier 34 | email 35 | end 36 | 37 | def reset_otp_persistence 38 | generate_otp_persistence_seed 39 | end 40 | 41 | def reset_otp_persistence! 42 | reset_otp_persistence 43 | save! 44 | end 45 | 46 | def populate_otp_secrets! 47 | if [otp_auth_secret, otp_recovery_secret, otp_persistence_seed].any? { |a| a.blank? } 48 | generate_otp_auth_secret 49 | generate_otp_persistence_seed 50 | self.save! 51 | end 52 | end 53 | 54 | def clear_otp_fields! 55 | @time_based_otp = nil 56 | @recovery_otp = nil 57 | 58 | self.update!( 59 | :otp_auth_secret => nil, 60 | :otp_recovery_secret => nil, 61 | :otp_persistence_seed => nil, 62 | :otp_session_challenge => nil, 63 | :otp_challenge_expires => nil, 64 | :otp_failed_attempts => 0, 65 | :otp_recovery_counter => 0 66 | ) 67 | end 68 | 69 | def enable_otp! 70 | update!(otp_enabled: true, otp_enabled_on: Time.now) 71 | end 72 | 73 | def disable_otp! 74 | update!(otp_enabled: false, otp_enabled_on: nil) 75 | end 76 | 77 | def generate_otp_challenge!(expires = nil) 78 | update!(otp_session_challenge: SecureRandom.hex, 79 | otp_challenge_expires: DateTime.now + (expires || self.class.otp_authentication_timeout)) 80 | otp_session_challenge 81 | end 82 | 83 | def otp_challenge_valid? 84 | (otp_challenge_expires.nil? || otp_challenge_expires > Time.now) 85 | end 86 | 87 | def validate_otp_token(token, recovery = false) 88 | if recovery 89 | validate_otp_recovery_token token 90 | else 91 | validate_otp_time_token token 92 | end 93 | end 94 | alias_method :valid_otp_token?, :validate_otp_token 95 | 96 | def validate_otp_time_token(token) 97 | return false if token.blank? 98 | validate_otp_token_with_drift(token) 99 | end 100 | alias_method :valid_otp_time_token?, :validate_otp_time_token 101 | 102 | def next_otp_recovery_tokens(number = self.class.otp_recovery_tokens) 103 | (otp_recovery_counter..otp_recovery_counter + number).each_with_object({}) do |index, h| 104 | h[index] = recovery_otp.at(index) 105 | end 106 | end 107 | 108 | def validate_otp_recovery_token(token) 109 | recovery_otp.verify(token, otp_recovery_counter).tap do 110 | self.otp_recovery_counter += 1 111 | save! 112 | end 113 | end 114 | alias_method :valid_otp_recovery_token?, :validate_otp_recovery_token 115 | 116 | private 117 | 118 | def validate_otp_token_with_drift(token) 119 | # should be centered around saved drift 120 | (-self.class.otp_drift_window..self.class.otp_drift_window).any? { |drift| 121 | time_based_otp.verify(token, at: Time.now.ago(30 * drift)) 122 | } 123 | end 124 | 125 | def generate_otp_persistence_seed 126 | self.otp_persistence_seed = SecureRandom.hex 127 | end 128 | 129 | def generate_otp_auth_secret 130 | self.otp_auth_secret = ROTP::Base32.random_base32 131 | self.otp_recovery_secret = ROTP::Base32.random_base32 132 | end 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /lib/devise_otp_authenticatable/routes.rb: -------------------------------------------------------------------------------- 1 | module ActionDispatch::Routing 2 | class Mapper 3 | protected 4 | 5 | def devise_otp(mapping, controllers) 6 | namespace :otp, module: :devise_otp do 7 | resource :token, only: [:show, :edit, :update, :destroy], 8 | path: mapping.path_names[:token], controller: controllers[:otp_tokens] do 9 | if Devise.otp_trust_persistence 10 | get :persistence, action: "get_persistence" 11 | post :persistence, action: "clear_persistence" 12 | delete :persistence, action: "delete_persistence" 13 | end 14 | 15 | get :recovery 16 | post :reset 17 | end 18 | 19 | resource :credential, only: [:show, :update], 20 | path: mapping.path_names[:credentials], controller: controllers[:otp_credentials] do 21 | get :refresh, action: "get_refresh" 22 | put :refresh, action: "set_refresh" 23 | end 24 | 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/generators/active_record/devise_otp_generator.rb: -------------------------------------------------------------------------------- 1 | require "rails/generators/active_record" 2 | 3 | module ActiveRecord 4 | module Generators 5 | class DeviseOtpGenerator < ActiveRecord::Generators::Base 6 | source_root File.expand_path("../templates", __FILE__) 7 | 8 | def copy_devise_migration 9 | migration_template "migration.rb", "db/migrate/devise_otp_add_to_#{table_name}.rb" 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/generators/active_record/templates/migration.rb: -------------------------------------------------------------------------------- 1 | class DeviseOtpAddTo<%= table_name.camelize %> < ActiveRecord::Migration[7.0] 2 | def self.up 3 | change_table :<%= table_name %> do |t| 4 | t.string :otp_auth_secret 5 | t.string :otp_recovery_secret 6 | t.boolean :otp_enabled, :default => false, :null => false 7 | t.boolean :otp_mandatory, :default => false, :null => false 8 | t.datetime :otp_enabled_on 9 | t.integer :otp_failed_attempts, :default => 0, :null => false 10 | t.integer :otp_recovery_counter, :default => 0, :null => false 11 | t.string :otp_persistence_seed 12 | 13 | t.string :otp_session_challenge 14 | t.datetime :otp_challenge_expires 15 | end 16 | add_index :<%= table_name %>, :otp_session_challenge, :unique => true 17 | add_index :<%= table_name %>, :otp_challenge_expires 18 | end 19 | 20 | def self.down 21 | change_table :<%= table_name %> do |t| 22 | t.remove :otp_auth_secret, :otp_recovery_secret, :otp_enabled, :otp_mandatory, :otp_enabled_on, :otp_session_challenge, 23 | :otp_challenge_expires, :otp_failed_attempts, :otp_recovery_counter, :otp_persistence_seed 24 | 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/generators/devise_otp/devise_otp_generator.rb: -------------------------------------------------------------------------------- 1 | module DeviseOtp 2 | module Generators 3 | class DeviseOtpGenerator < Rails::Generators::NamedBase 4 | namespace "devise_otp" 5 | 6 | desc "Add :otp_authenticatable directive in the given model, plus accessors. Also generate migration for ActiveRecord" 7 | 8 | def inject_devise_otp_content 9 | path = File.join("app", "models", "#{file_path}.rb") 10 | inject_into_file(path, "otp_authenticatable, :", after: "devise :") if File.exist?(path) 11 | end 12 | 13 | hook_for :orm 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/generators/devise_otp/install_generator.rb: -------------------------------------------------------------------------------- 1 | module DeviseOtp 2 | module Generators # :nodoc: 3 | # Install Generator 4 | class InstallGenerator < Rails::Generators::Base 5 | source_root File.expand_path("../../templates", __FILE__) 6 | 7 | desc "Install the devise OTP authentication extension" 8 | 9 | def add_configs 10 | content = <<-CONTENT 11 | 12 | # ==> Devise OTP Extension 13 | # Configure OTP extension for devise 14 | 15 | # OTP is mandatory, users are going to be asked to 16 | # enroll OTP the next time they sign in, before they can successfully complete the session establishment. 17 | # This is the global value, can also be set on each user. 18 | #config.otp_mandatory = false 19 | 20 | # Drift: a window which provides allowance for drift between a user's token device clock 21 | # (and therefore their OTP tokens) and the authentication server's clock. 22 | # Expressed in minutes centered at the current time. (Note: it's a number, *NOT* 3.minutes ) 23 | #config.otp_drift_window = 3 24 | 25 | # Users that have logged in longer than this time ago, are going to be asked their password 26 | # (and an OTP challenge, if enabled) before they can see or change their otp informations. 27 | #config.otp_credentials_refresh = 15.minutes 28 | 29 | # Users are given a list of one-time recovery tokens, for emergency access 30 | # set to false to disable giving recovery tokens. 31 | #config.otp_recovery_tokens = 10 32 | 33 | # The user is allowed to set his browser as "trusted", no more OTP challenges will be 34 | # asked for that browser, for a limited time. 35 | # set to false to disable setting the browser as trusted 36 | #config.otp_trust_persistence = 1.month 37 | 38 | # The name of the token issuer, to be added to the provisioning 39 | # url. Display will vary based on token application. (defaults to the Rails application class) 40 | #config.otp_issuer = 'my_application' 41 | 42 | # Custom view path for Devise OTP controllers 43 | #config.otp_controller_path = 'devise' 44 | 45 | CONTENT 46 | 47 | inject_into_file "config/initializers/devise.rb", content, before: /end[ |\n]+\Z/ 48 | end 49 | 50 | def copy_locale 51 | copy_file "../../../config/locales/en.yml", "config/locales/devise.otp.en.yml" 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/generators/devise_otp/views_generator.rb: -------------------------------------------------------------------------------- 1 | require "generators/devise/views_generator" 2 | 3 | module DeviseOtp 4 | module Generators 5 | class ViewsGenerator < Rails::Generators::Base 6 | desc "Copies all Devise OTP views to your application." 7 | 8 | argument :scope, required: false, default: nil, 9 | desc: "The scope to copy views to" 10 | 11 | include ::Devise::Generators::ViewPathTemplates 12 | source_root File.expand_path("../../../../app/views", __FILE__) 13 | def copy_views 14 | view_directory :devise, "app/views/devise" 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/dummy/README.rdoc: -------------------------------------------------------------------------------- 1 | == Welcome to Rails 2 | 3 | Rails is a web-application framework that includes everything needed to create 4 | database-backed web applications according to the Model-View-Control pattern. 5 | 6 | This pattern splits the view (also called the presentation) into "dumb" 7 | templates that are primarily responsible for inserting pre-built data in between 8 | HTML tags. The model contains the "smart" domain objects (such as Account, 9 | Product, Person, Post) that holds all the business logic and knows how to 10 | persist themselves to a database. The controller handles the incoming requests 11 | (such as Save New Account, Update Product, Show Post) by manipulating the model 12 | and directing data to the view. 13 | 14 | In Rails, the model is handled by what's called an object-relational mapping 15 | layer entitled Active Record. This layer allows you to present the data from 16 | database rows as objects and embellish these data objects with business logic 17 | methods. You can read more about Active Record in 18 | link:files/vendor/rails/activerecord/README.html. 19 | 20 | The controller and view are handled by the Action Pack, which handles both 21 | layers by its two parts: Action View and Action Controller. These two layers 22 | are bundled in a single package due to their heavy interdependence. This is 23 | unlike the relationship between the Active Record and Action Pack that is much 24 | more separate. Each of these packages can be used independently outside of 25 | Rails. You can read more about Action Pack in 26 | link:files/vendor/rails/actionpack/README.html. 27 | 28 | 29 | == Getting Started 30 | 31 | 1. At the command prompt, create a new Rails application: 32 | rails new myapp (where myapp is the application name) 33 | 34 | 2. Change directory to myapp and start the web server: 35 | cd myapp; rails server (run with --help for options) 36 | 37 | 3. Go to http://localhost:3000/ and you'll see: 38 | "Welcome aboard: You're riding Ruby on Rails!" 39 | 40 | 4. Follow the guidelines to start developing your application. You can find 41 | the following resources handy: 42 | 43 | * The Getting Started Guide: http://guides.rubyonrails.org/getting_started.html 44 | * Ruby on Rails Tutorial Book: http://www.railstutorial.org/ 45 | 46 | 47 | == Debugging Rails 48 | 49 | Sometimes your application goes wrong. Fortunately there are a lot of tools that 50 | will help you debug it and get it back on the rails. 51 | 52 | First area to check is the application log files. Have "tail -f" commands 53 | running on the server.log and development.log. Rails will automatically display 54 | debugging and runtime information to these files. Debugging info will also be 55 | shown in the browser on requests from 127.0.0.1. 56 | 57 | You can also log your own messages directly into the log file from your code 58 | using the Ruby logger class from inside your controllers. Example: 59 | 60 | class WeblogController < ActionController::Base 61 | def destroy 62 | @weblog = Weblog.find(params[:id]) 63 | @weblog.destroy 64 | logger.info("#{Time.now} Destroyed Weblog ID ##{@weblog.id}!") 65 | end 66 | end 67 | 68 | The result will be a message in your log file along the lines of: 69 | 70 | Mon Oct 08 14:22:29 +1000 2007 Destroyed Weblog ID #1! 71 | 72 | More information on how to use the logger is at http://www.ruby-doc.org/core/ 73 | 74 | Also, Ruby documentation can be found at http://www.ruby-lang.org/. There are 75 | several books available online as well: 76 | 77 | * Programming Ruby: http://www.ruby-doc.org/docs/ProgrammingRuby/ (Pickaxe) 78 | * Learn to Program: http://pine.fm/LearnToProgram/ (a beginners guide) 79 | 80 | These two books will bring you up to speed on the Ruby language and also on 81 | programming in general. 82 | 83 | 84 | == Debugger 85 | 86 | Debugger support is available through the debugger command when you start your 87 | Mongrel or WEBrick server with --debugger. This means that you can break out of 88 | execution at any point in the code, investigate and change the model, and then, 89 | resume execution! You need to install ruby-debug to run the server in debugging 90 | mode. With gems, use sudo gem install ruby-debug. Example: 91 | 92 | class WeblogController < ActionController::Base 93 | def index 94 | @posts = Post.all 95 | debugger 96 | end 97 | end 98 | 99 | So the controller will accept the action, run the first line, then present you 100 | with a IRB prompt in the server window. Here you can do things like: 101 | 102 | >> @posts.inspect 103 | => "[#nil, "body"=>nil, "id"=>"1"}>, 105 | #"Rails", "body"=>"Only ten..", "id"=>"2"}>]" 107 | >> @posts.first.title = "hello from a debugger" 108 | => "hello from a debugger" 109 | 110 | ...and even better, you can examine how your runtime objects actually work: 111 | 112 | >> f = @posts.first 113 | => #nil, "body"=>nil, "id"=>"1"}> 114 | >> f. 115 | Display all 152 possibilities? (y or n) 116 | 117 | Finally, when you're ready to resume execution, you can enter "cont". 118 | 119 | 120 | == Console 121 | 122 | The console is a Ruby shell, which allows you to interact with your 123 | application's domain model. Here you'll have all parts of the application 124 | configured, just like it is when the application is running. You can inspect 125 | domain models, change values, and save to the database. Starting the script 126 | without arguments will launch it in the development environment. 127 | 128 | To start the console, run rails console from the application 129 | directory. 130 | 131 | Options: 132 | 133 | * Passing the -s, --sandbox argument will rollback any modifications 134 | made to the database. 135 | * Passing an environment name as an argument will load the corresponding 136 | environment. Example: rails console production. 137 | 138 | To reload your controllers and models after launching the console run 139 | reload! 140 | 141 | More information about irb can be found at: 142 | link:http://www.rubycentral.org/pickaxe/irb.html 143 | 144 | 145 | == dbconsole 146 | 147 | You can go to the command line of your database directly through rails 148 | dbconsole. You would be connected to the database with the credentials 149 | defined in database.yml. Starting the script without arguments will connect you 150 | to the development database. Passing an argument will connect you to a different 151 | database, like rails dbconsole production. Currently works for MySQL, 152 | PostgreSQL and SQLite 3. 153 | 154 | == Description of Contents 155 | 156 | The default directory structure of a generated Ruby on Rails application: 157 | 158 | |-- app 159 | | |-- assets 160 | | |-- images 161 | | |-- javascripts 162 | | `-- stylesheets 163 | | |-- controllers 164 | | |-- helpers 165 | | |-- mailers 166 | | |-- models 167 | | `-- views 168 | | `-- layouts 169 | |-- config 170 | | |-- environments 171 | | |-- initializers 172 | | `-- locales 173 | |-- db 174 | |-- doc 175 | |-- lib 176 | | `-- tasks 177 | |-- log 178 | |-- public 179 | |-- script 180 | |-- test 181 | | |-- fixtures 182 | | |-- functional 183 | | |-- integration 184 | | |-- performance 185 | | `-- unit 186 | |-- tmp 187 | | |-- cache 188 | | |-- pids 189 | | |-- sessions 190 | | `-- sockets 191 | `-- vendor 192 | |-- assets 193 | `-- stylesheets 194 | `-- plugins 195 | 196 | app 197 | Holds all the code that's specific to this particular application. 198 | 199 | app/assets 200 | Contains subdirectories for images, stylesheets, and JavaScript files. 201 | 202 | app/controllers 203 | Holds controllers that should be named like weblogs_controller.rb for 204 | automated URL mapping. All controllers should descend from 205 | ApplicationController which itself descends from ActionController::Base. 206 | 207 | app/models 208 | Holds models that should be named like post.rb. Models descend from 209 | ActiveRecord::Base by default. 210 | 211 | app/views 212 | Holds the template files for the view that should be named like 213 | weblogs/index.html.erb for the WeblogsController#index action. All views use 214 | eRuby syntax by default. 215 | 216 | app/views/layouts 217 | Holds the template files for layouts to be used with views. This models the 218 | common header/footer method of wrapping views. In your views, define a layout 219 | using the layout :default and create a file named default.html.erb. 220 | Inside default.html.erb, call <% yield %> to render the view using this 221 | layout. 222 | 223 | app/helpers 224 | Holds view helpers that should be named like weblogs_helper.rb. These are 225 | generated for you automatically when using generators for controllers. 226 | Helpers can be used to wrap functionality for your views into methods. 227 | 228 | config 229 | Configuration files for the Rails environment, the routing map, the database, 230 | and other dependencies. 231 | 232 | db 233 | Contains the database schema in schema.rb. db/migrate contains all the 234 | sequence of Migrations for your schema. 235 | 236 | doc 237 | This directory is where your application documentation will be stored when 238 | generated using rake doc:app 239 | 240 | lib 241 | Application specific libraries. Basically, any kind of custom code that 242 | doesn't belong under controllers, models, or helpers. This directory is in 243 | the load path. 244 | 245 | public 246 | The directory available for the web server. Also contains the dispatchers and the 247 | default HTML files. This should be set as the DOCUMENT_ROOT of your web 248 | server. 249 | 250 | script 251 | Helper scripts for automation and generation. 252 | 253 | test 254 | Unit and functional tests along with fixtures. When using the rails generate 255 | command, template test files will be generated for you and placed in this 256 | directory. 257 | 258 | vendor 259 | External libraries that the application depends on. Also includes the plugins 260 | subdirectory. If the app has frozen rails, those gems also go here, under 261 | vendor/rails/. This directory is in the load path. 262 | -------------------------------------------------------------------------------- /test/dummy/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 | -------------------------------------------------------------------------------- /test/dummy/app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_directory ../javascripts .js 2 | //= link_directory ../stylesheets .css -------------------------------------------------------------------------------- /test/dummy/app/assets/javascripts/application.js: -------------------------------------------------------------------------------- 1 | // This is a manifest file that'll be compiled into application.js, which will include all the files 2 | // listed below. 3 | // 4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, 5 | // or vendor/assets/javascripts of plugins, if any, can be referenced here using a relative path. 6 | // 7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the 8 | // the compiled file. 9 | // 10 | // WARNING: THE FIRST BLANK LINE MARKS THE END OF WHAT'S TO BE PROCESSED, ANY BLANK LINE SHOULD 11 | // GO AFTER THE REQUIRES BELOW. 12 | // 13 | //= require_tree . 14 | -------------------------------------------------------------------------------- /test/dummy/app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or vendor/assets/stylesheets of plugins, if any, can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the top of the 9 | * compiled file, but it's generally better to create a new file per style scope. 10 | * 11 | *= require devise-otp 12 | *= require_self 13 | *= require_tree . 14 | */ 15 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/admin_posts_controller.rb: -------------------------------------------------------------------------------- 1 | class AdminPostsController < ApplicationController 2 | before_action :authenticate_admin! 3 | 4 | # GET /posts 5 | # GET /posts.json 6 | def index 7 | @posts = Post.all 8 | 9 | respond_to do |format| 10 | format.html # index.html.erb 11 | format.json { render json: @posts } 12 | end 13 | end 14 | 15 | # GET /posts/1 16 | # GET /posts/1.json 17 | def show 18 | @post = Post.find(params[:id]) 19 | 20 | respond_to do |format| 21 | format.html # show.html.erb 22 | format.json { render json: @post } 23 | end 24 | end 25 | 26 | # GET /posts/new 27 | # GET /posts/new.json 28 | def new 29 | @post = Post.new 30 | 31 | respond_to do |format| 32 | format.html # new.html.erb 33 | format.json { render json: @post } 34 | end 35 | end 36 | 37 | # GET /posts/1/edit 38 | def edit 39 | @post = Post.find(params[:id]) 40 | end 41 | 42 | # POST /posts 43 | # POST /posts.json 44 | def create 45 | @post = Post.new(params[:post]) 46 | 47 | respond_to do |format| 48 | if @post.save 49 | format.html { redirect_to @post, notice: "Post was successfully created." } 50 | format.json { render json: @post, status: :created, location: @post } 51 | else 52 | format.html { render action: "new" } 53 | format.json { render json: @post.errors, status: :unprocessable_entity } 54 | end 55 | end 56 | end 57 | 58 | # PUT /posts/1 59 | # PUT /posts/1.json 60 | def update 61 | @post = Post.find(params[:id]) 62 | 63 | respond_to do |format| 64 | if @post.update_attributes(params[:post]) 65 | format.html { redirect_to @post, notice: "Post was successfully updated." } 66 | format.json { head :ok } 67 | else 68 | format.html { render action: "edit" } 69 | format.json { render json: @post.errors, status: :unprocessable_entity } 70 | end 71 | end 72 | end 73 | 74 | # DELETE /posts/1 75 | # DELETE /posts/1.json 76 | def destroy 77 | @post = Post.find(params[:id]) 78 | @post.destroy 79 | 80 | respond_to do |format| 81 | format.html { redirect_to posts_url } 82 | format.json { head :ok } 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | protect_from_forgery 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/base_controller.rb: -------------------------------------------------------------------------------- 1 | class BaseController < ApplicationController 2 | 3 | def home 4 | end 5 | 6 | end 7 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/posts_controller.rb: -------------------------------------------------------------------------------- 1 | class PostsController < ApplicationController 2 | before_action :authenticate_user! 3 | 4 | # GET /posts 5 | # GET /posts.json 6 | def index 7 | @posts = Post.all 8 | 9 | respond_to do |format| 10 | format.html # index.html.erb 11 | format.json { render json: @posts } 12 | end 13 | end 14 | 15 | # GET /posts/1 16 | # GET /posts/1.json 17 | def show 18 | @post = Post.find(params[:id]) 19 | 20 | respond_to do |format| 21 | format.html # show.html.erb 22 | format.json { render json: @post } 23 | end 24 | end 25 | 26 | # GET /posts/new 27 | # GET /posts/new.json 28 | def new 29 | @post = Post.new 30 | 31 | respond_to do |format| 32 | format.html # new.html.erb 33 | format.json { render json: @post } 34 | end 35 | end 36 | 37 | # GET /posts/1/edit 38 | def edit 39 | @post = Post.find(params[:id]) 40 | end 41 | 42 | # POST /posts 43 | # POST /posts.json 44 | def create 45 | @post = Post.new(params[:post]) 46 | 47 | respond_to do |format| 48 | if @post.save 49 | format.html { redirect_to @post, notice: "Post was successfully created." } 50 | format.json { render json: @post, status: :created, location: @post } 51 | else 52 | format.html { render action: "new" } 53 | format.json { render json: @post.errors, status: :unprocessable_entity } 54 | end 55 | end 56 | end 57 | 58 | # PUT /posts/1 59 | # PUT /posts/1.json 60 | def update 61 | @post = Post.find(params[:id]) 62 | 63 | respond_to do |format| 64 | if @post.update_attributes(params[:post]) 65 | format.html { redirect_to @post, notice: "Post was successfully updated." } 66 | format.json { head :ok } 67 | else 68 | format.html { render action: "edit" } 69 | format.json { render json: @post.errors, status: :unprocessable_entity } 70 | end 71 | end 72 | end 73 | 74 | # DELETE /posts/1 75 | # DELETE /posts/1.json 76 | def destroy 77 | @post = Post.find(params[:id]) 78 | @post.destroy 79 | 80 | respond_to do |format| 81 | format.html { redirect_to posts_url } 82 | format.json { head :ok } 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /test/dummy/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/helpers/posts_helper.rb: -------------------------------------------------------------------------------- 1 | module PostsHelper 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/mailers/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmlele/devise-otp/3cf5d150fef602b3307c30ced6f0870c14624c36/test/dummy/app/mailers/.gitkeep -------------------------------------------------------------------------------- /test/dummy/app/models/admin.rb: -------------------------------------------------------------------------------- 1 | class Admin < PARENT_MODEL_CLASS 2 | if DEVISE_ORM == :mongoid 3 | include Mongoid::Document 4 | 5 | ## Database authenticatable 6 | field :email, type: String, null: false, default: "" 7 | field :encrypted_password, type: String, null: false, default: "" 8 | 9 | ## Recoverable 10 | field :reset_password_token, type: String 11 | field :reset_password_sent_at, type: Time 12 | end 13 | 14 | devise :otp_authenticatable, :database_authenticatable, :registerable, 15 | :trackable, :validatable 16 | 17 | # Setup accessible (or protected) attributes for your model 18 | # attr_accessible :otp_enabled, :otp_mandatory, :as => :otp_privileged 19 | # attr_accessible :email, :password, :password_confirmation, :remember_me 20 | 21 | def self.otp_mandatory 22 | true 23 | end 24 | 25 | end 26 | -------------------------------------------------------------------------------- /test/dummy/app/models/post.rb: -------------------------------------------------------------------------------- 1 | class Post < PARENT_MODEL_CLASS 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/models/user.rb: -------------------------------------------------------------------------------- 1 | class User < PARENT_MODEL_CLASS 2 | if DEVISE_ORM == :mongoid 3 | include Mongoid::Document 4 | 5 | ## Database authenticatable 6 | field :email, type: String, null: false, default: "" 7 | field :encrypted_password, type: String, null: false, default: "" 8 | 9 | ## Recoverable 10 | field :reset_password_token, type: String 11 | field :reset_password_sent_at, type: Time 12 | end 13 | 14 | devise :otp_authenticatable, :database_authenticatable, :registerable, 15 | :trackable, :validatable 16 | 17 | # Setup accessible (or protected) attributes for your model 18 | # attr_accessible :otp_enabled, :otp_mandatory, :as => :otp_privileged 19 | # attr_accessible :email, :password, :password_confirmation, :remember_me 20 | end 21 | -------------------------------------------------------------------------------- /test/dummy/app/views/admin_posts/_form.html.erb: -------------------------------------------------------------------------------- 1 | <%= form_for([:admin, @post]) do |f| %> 2 | <% if @post.errors.any? %> 3 |
4 |

<%= pluralize(@post.errors.count, "error") %> prohibited this post from being saved:

5 | 6 |
    7 | <% @post.errors.full_messages.each do |msg| %> 8 |
  • <%= msg %>
  • 9 | <% end %> 10 |
11 |
12 | <% end %> 13 | 14 |
15 | <%= f.label :title %>
16 | <%= f.text_field :title %> 17 |
18 |
19 | <%= f.label :body %>
20 | <%= f.text_area :body %> 21 |
22 |
23 | <%= f.submit %> 24 |
25 | <% end %> 26 | -------------------------------------------------------------------------------- /test/dummy/app/views/admin_posts/edit.html.erb: -------------------------------------------------------------------------------- 1 |

Editing post

2 | 3 | <%= render 'form' %> 4 | 5 | <%= link_to 'Show', @post %> | 6 | <%= link_to 'Back', admin_posts_path %> 7 | -------------------------------------------------------------------------------- /test/dummy/app/views/admin_posts/index.html.erb: -------------------------------------------------------------------------------- 1 |

Listing posts

2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | <% @posts.each do |post| %> 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | <% end %> 21 |
TitleBody
<%= post.title %><%= post.body %><%= link_to 'Show', post %><%= link_to 'Edit', edit_admin_post_path(post) %><%= link_to 'Destroy', [:admin, post], confirm: 'Are you sure?', method: :delete %>
22 | 23 |
24 | 25 | <%= link_to 'New Post', new_admin_post_path %> 26 | -------------------------------------------------------------------------------- /test/dummy/app/views/admin_posts/new.html.erb: -------------------------------------------------------------------------------- 1 |

New post

2 | 3 | <%= render 'form' %> 4 | 5 | <%= link_to 'Back', admin_posts_path %> 6 | -------------------------------------------------------------------------------- /test/dummy/app/views/admin_posts/show.html.erb: -------------------------------------------------------------------------------- 1 |

<%= notice %>

2 | 3 |

4 | Title: 5 | <%= @post.title %> 6 |

7 | 8 |

9 | Body: 10 | <%= @post.body %> 11 |

12 | 13 | 14 | <%= link_to 'Edit', edit_admin_post_path(@post) %> | 15 | <%= link_to 'Back', admin_posts_path %> 16 | -------------------------------------------------------------------------------- /test/dummy/app/views/base/home.html.erb: -------------------------------------------------------------------------------- 1 |

Hello world!

2 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Dummy 5 | <%= stylesheet_link_tag "application", :media => "all" %> 6 | <%= javascript_include_tag "application" %> 7 | <%= csrf_meta_tags %> 8 | 9 | 10 | 11 |
12 | <% flash.keys.each do |key| %> 13 | <%= content_tag :p, flash[key], :id => key %> 14 | <% end %> 15 |
16 | 17 | <%= yield %> 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /test/dummy/app/views/posts/_form.html.erb: -------------------------------------------------------------------------------- 1 | <%= form_for(@post) do |f| %> 2 | <% if @post.errors.any? %> 3 |
4 |

<%= pluralize(@post.errors.count, "error") %> prohibited this post from being saved:

5 | 6 |
    7 | <% @post.errors.full_messages.each do |msg| %> 8 |
  • <%= msg %>
  • 9 | <% end %> 10 |
11 |
12 | <% end %> 13 | 14 |
15 | <%= f.label :title %>
16 | <%= f.text_field :title %> 17 |
18 |
19 | <%= f.label :body %>
20 | <%= f.text_area :body %> 21 |
22 |
23 | <%= f.submit %> 24 |
25 | <% end %> 26 | -------------------------------------------------------------------------------- /test/dummy/app/views/posts/edit.html.erb: -------------------------------------------------------------------------------- 1 |

Editing post

2 | 3 | <%= render 'form' %> 4 | 5 | <%= link_to 'Show', @post %> | 6 | <%= link_to 'Back', posts_path %> 7 | -------------------------------------------------------------------------------- /test/dummy/app/views/posts/index.html.erb: -------------------------------------------------------------------------------- 1 |

Listing posts

2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | <% @posts.each do |post| %> 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | <% end %> 21 |
TitleBody
<%= post.title %><%= post.body %><%= link_to 'Show', post %><%= link_to 'Edit', edit_post_path(post) %><%= link_to 'Destroy', post, confirm: 'Are you sure?', method: :delete %>
22 | 23 |
24 | 25 | <%= link_to 'New Post', new_post_path %> 26 | -------------------------------------------------------------------------------- /test/dummy/app/views/posts/new.html.erb: -------------------------------------------------------------------------------- 1 |

New post

2 | 3 | <%= render 'form' %> 4 | 5 | <%= link_to 'Back', posts_path %> 6 | -------------------------------------------------------------------------------- /test/dummy/app/views/posts/show.html.erb: -------------------------------------------------------------------------------- 1 |

<%= notice %>

2 | 3 |

4 | Title: 5 | <%= @post.title %> 6 |

7 | 8 |

9 | Body: 10 | <%= @post.body %> 11 |

12 | 13 | 14 | <%= link_to 'Edit', edit_post_path(@post) %> | 15 | <%= link_to 'Back', posts_path %> 16 | -------------------------------------------------------------------------------- /test/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require ::File.expand_path("../config/environment", __FILE__) 4 | run Dummy::Application 5 | -------------------------------------------------------------------------------- /test/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../boot", __FILE__) 2 | 3 | # Pick the frameworks you want: 4 | require "active_record/railtie" 5 | require "action_controller/railtie" 6 | require "action_mailer/railtie" 7 | # require "active_resource/railtie" 8 | require "sprockets/railtie" 9 | # require "rails/test_unit/railtie" 10 | 11 | Bundler.require 12 | Bundler.require(:default, DEVISE_ORM) if defined?(Bundler) 13 | 14 | begin 15 | require "#{DEVISE_ORM}/railtie" 16 | rescue LoadError 17 | end 18 | PARENT_MODEL_CLASS = (DEVISE_ORM == :active_record) ? ActiveRecord::Base : Object 19 | 20 | require "devise" 21 | require "devise-otp" 22 | 23 | module Dummy 24 | class Application < Rails::Application 25 | # Settings in config/environments/* take precedence over those specified here. 26 | # Application configuration should go into files in config/initializers 27 | # -- all .rb files in that directory are automatically loaded. 28 | 29 | # Custom directories with classes and modules you want to be autoloadable. 30 | # config.autoload_paths += %W(#{config.root}/extras) 31 | 32 | # Only load the plugins named here, in the order given (default is alphabetical). 33 | # :all can be used as a placeholder for all plugins not explicitly named. 34 | # config.plugins = [ :exception_notification, :ssl_requirement, :all ] 35 | 36 | # Activate observers that should always be running. 37 | # config.active_record.observers = :cacher, :garbage_collector, :forum_observer 38 | 39 | # Set Time.zone default to the specified zone and make Active Record auto-convert to this zone. 40 | # Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC. 41 | # config.time_zone = 'Central Time (US & Canada)' 42 | 43 | # The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded. 44 | # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s] 45 | # config.i18n.default_locale = :de 46 | 47 | # Configure the default encoding used in templates for Ruby 1.9. 48 | config.encoding = "utf-8" 49 | 50 | # Configure sensitive parameters which will be filtered from the log file. 51 | config.filter_parameters += [:password] 52 | 53 | # Enable escaping HTML in JSON. 54 | config.active_support.escape_html_entities_in_json = true 55 | 56 | # Use SQL instead of Active Record's schema dumper when creating the database. 57 | # This is necessary if your schema can't be completely dumped by the schema dumper, 58 | # like if you have constraints or database-specific column types 59 | # config.active_record.schema_format = :sql 60 | 61 | # Enable the asset pipeline 62 | config.assets.enabled = true 63 | 64 | # Version of your assets, change this if you want to expire all your assets 65 | config.assets.version = "1.0" 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /test/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | gemfile = File.expand_path("../../../../Gemfile", __FILE__) 3 | 4 | if File.exist?(gemfile) 5 | ENV["BUNDLE_GEMFILE"] = gemfile 6 | require "bundler" 7 | Bundler.setup 8 | end 9 | 10 | $:.unshift File.expand_path("../../../../lib", __FILE__) 11 | -------------------------------------------------------------------------------- /test/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite version 3.x 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem 'sqlite3' 6 | development: 7 | adapter: sqlite3 8 | database: ":memory:" 9 | pool: 5 10 | timeout: 5000 11 | 12 | # Warning: The database defined as "test" will be erased and 13 | # re-generated from your development database when you run "rake". 14 | # Do not set this db to the same as development or production. 15 | test: 16 | adapter: sqlite3 17 | database: db/test.sqlite3 18 | pool: 5 19 | timeout: 5000 20 | 21 | production: 22 | adapter: sqlite3 23 | database: db/production.sqlite3 24 | pool: 5 25 | timeout: 5000 26 | -------------------------------------------------------------------------------- /test/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the rails application 2 | require File.expand_path("../application", __FILE__) 3 | 4 | # Initialize the rails application 5 | Dummy::Application.initialize! 6 | -------------------------------------------------------------------------------- /test/dummy/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | Dummy::Application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb 3 | 4 | # In the development environment your application's code is reloaded on 5 | # every request. This slows down response time but is perfect for development 6 | # since you don't have to restart the web server when you make code changes. 7 | config.cache_classes = false 8 | 9 | # Do not eager load code on boot. 10 | config.eager_load = false 11 | 12 | # Show full error reports and disable caching 13 | config.consider_all_requests_local = true 14 | config.action_controller.perform_caching = false 15 | 16 | # Don't care if the mailer can't send 17 | config.action_mailer.raise_delivery_errors = false 18 | 19 | # Print deprecation notices to the Rails logger 20 | config.active_support.deprecation = :log 21 | 22 | # Only use best-standards-support built into browsers 23 | config.action_dispatch.best_standards_support = :builtin 24 | 25 | # Do not compress assets 26 | config.assets.compress = false 27 | 28 | # Expands the lines which load the assets 29 | config.assets.debug = true 30 | end 31 | -------------------------------------------------------------------------------- /test/dummy/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | Dummy::Application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb 3 | 4 | # Code is not reloaded between requests 5 | config.cache_classes = true 6 | 7 | # Eager load code on boot. This eager loads most of Rails and 8 | # your application in memory, allowing both thread web servers 9 | # and those relying on copy on write to perform better. 10 | # Rake tasks automatically ignore this option for performance. 11 | config.eager_load = true 12 | 13 | # Full error reports are disabled and caching is turned on 14 | config.consider_all_requests_local = false 15 | config.action_controller.perform_caching = true 16 | 17 | # Disable Rails's static asset server (Apache or nginx will already do this) 18 | config.serve_static_assets = false 19 | 20 | # Compress JavaScripts and CSS 21 | config.assets.compress = true 22 | 23 | # Don't fallback to assets pipeline if a precompiled asset is missed 24 | config.assets.compile = false 25 | 26 | # Generate digests for assets URLs 27 | config.assets.digest = true 28 | 29 | # Defaults to nil and saved in location specified by config.assets.prefix 30 | # config.assets.manifest = YOUR_PATH 31 | 32 | # Specifies the header that your server uses for sending files 33 | # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for apache 34 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for nginx 35 | 36 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 37 | # config.force_ssl = true 38 | 39 | # See everything in the log (default is :info) 40 | # config.log_level = :debug 41 | 42 | # Prepend all log lines with the following tags 43 | # config.log_tags = [ :subdomain, :uuid ] 44 | 45 | # Use a different logger for distributed setups 46 | # config.logger = ActiveSupport::TaggedLogging.new(SyslogLogger.new) 47 | 48 | # Use a different cache store in production 49 | # config.cache_store = :mem_cache_store 50 | 51 | # Enable serving of images, stylesheets, and JavaScripts from an asset server 52 | # config.action_controller.asset_host = "http://assets.example.com" 53 | 54 | # Precompile additional assets (application.js, application.css, and all non-JS/CSS are already added) 55 | # config.assets.precompile += %w( search.js ) 56 | 57 | # Disable delivery errors, bad email addresses will be ignored 58 | # config.action_mailer.raise_delivery_errors = false 59 | 60 | # Enable threaded mode 61 | # config.threadsafe! 62 | 63 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 64 | # the I18n.default_locale when a translation can not be found) 65 | config.i18n.fallbacks = true 66 | 67 | # Send deprecation notices to registered listeners 68 | config.active_support.deprecation = :notify 69 | end 70 | -------------------------------------------------------------------------------- /test/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | Dummy::Application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb 3 | 4 | # The test environment is used exclusively to run your application's 5 | # test suite. You never need to work with it otherwise. Remember that 6 | # your test database is "scratch space" for the test suite and is wiped 7 | # and recreated between test runs. Don't rely on the data there! 8 | config.cache_classes = true 9 | 10 | # Do not eager load code on boot. This avoids loading your whole application 11 | # just for the purpose of running a single test. If you are using a tool that 12 | # preloads Rails for running tests, you may have to set it to true. 13 | config.eager_load = false 14 | 15 | # Configure static asset server for tests with Cache-Control for performance 16 | config.serve_static_assets = true 17 | config.static_cache_control = "public, max-age=3600" 18 | 19 | # Show full error reports and disable caching 20 | config.consider_all_requests_local = true 21 | config.action_controller.perform_caching = false 22 | 23 | # Raise exceptions instead of rendering exception templates 24 | config.action_dispatch.show_exceptions = false 25 | 26 | # Disable request forgery protection in test environment 27 | config.action_controller.allow_forgery_protection = false 28 | 29 | # Tell Action Mailer not to deliver emails to the real world. 30 | # The :test delivery method accumulates sent emails in the 31 | # ActionMailer::Base.deliveries array. 32 | config.action_mailer.delivery_method = :test 33 | 34 | # Print deprecation notices to the stderr 35 | config.active_support.deprecation = :stderr 36 | end 37 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. 7 | # Rails.backtrace_cleaner.remove_silencers! 8 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/devise.rb: -------------------------------------------------------------------------------- 1 | # Use this hook to configure devise mailer, warden hooks and so forth. 2 | # Many of these configuration options can be set straight in your model. 3 | Devise.setup do |config| 4 | config.secret_key = "638da6a325f1de9038321504c4a06ef7f4f7f835331a63ba41b93732b3830d032b6a10b38afa67427e050b19f9717b1e7a45f650ac5631c53cc9dd85264fdfb0" 5 | 6 | # ==> Mailer Configuration 7 | # Configure the e-mail address which will be shown in Devise::Mailer, 8 | # note that it will be overwritten if you use your own mailer class with default "from" parameter. 9 | config.mailer_sender = "please-change-me-at-config-initializers-devise@example.com" 10 | 11 | # Configure the class responsible to send e-mails. 12 | # config.mailer = "Devise::Mailer" 13 | 14 | # ==> ORM configuration 15 | # Load and configure the ORM. Supports :active_record (default) and 16 | # :mongoid (bson_ext recommended) by default. Other ORMs may be 17 | # available as additional gems. 18 | require "devise/orm/active_record" 19 | 20 | # ==> Configuration for any authentication mechanism 21 | # Configure which keys are used when authenticating a user. The default is 22 | # just :email. You can configure it to use [:username, :subdomain], so for 23 | # authenticating a user, both parameters are required. Remember that those 24 | # parameters are used only when authenticating and not when retrieving from 25 | # session. If you need permissions, you should implement that in a before filter. 26 | # You can also supply a hash where the value is a boolean determining whether 27 | # or not authentication should be aborted when the value is not present. 28 | # config.authentication_keys = [ :email ] 29 | 30 | # Configure parameters from the request object used for authentication. Each entry 31 | # given should be a request method and it will automatically be passed to the 32 | # find_for_authentication method and considered in your model lookup. For instance, 33 | # if you set :request_keys to [:subdomain], :subdomain will be used on authentication. 34 | # The same considerations mentioned for authentication_keys also apply to request_keys. 35 | # config.request_keys = [] 36 | 37 | # Configure which authentication keys should be case-insensitive. 38 | # These keys will be downcased upon creating or modifying a user and when used 39 | # to authenticate or find a user. Default is :email. 40 | config.case_insensitive_keys = [:email] 41 | 42 | # Configure which authentication keys should have whitespace stripped. 43 | # These keys will have whitespace before and after removed upon creating or 44 | # modifying a user and when used to authenticate or find a user. Default is :email. 45 | config.strip_whitespace_keys = [:email] 46 | 47 | # Tell if authentication through request.params is enabled. True by default. 48 | # It can be set to an array that will enable params authentication only for the 49 | # given strategies, for example, `config.params_authenticatable = [:database]` will 50 | # enable it only for database (email + password) authentication. 51 | # config.params_authenticatable = true 52 | 53 | # Tell if authentication through HTTP Basic Auth is enabled. False by default. 54 | # It can be set to an array that will enable http authentication only for the 55 | # given strategies, for example, `config.http_authenticatable = [:token]` will 56 | # enable it only for token authentication. 57 | # config.http_authenticatable = false 58 | 59 | # If http headers should be returned for AJAX requests. True by default. 60 | # config.http_authenticatable_on_xhr = true 61 | 62 | # The realm used in Http Basic Authentication. "Application" by default. 63 | # config.http_authentication_realm = "Application" 64 | 65 | # It will change confirmation, password recovery and other workflows 66 | # to behave the same regardless if the e-mail provided was right or wrong. 67 | # Does not affect registerable. 68 | # config.paranoid = true 69 | 70 | # By default Devise will store the user in session. You can skip storage for 71 | # :http_auth and :token_auth by adding those symbols to the array below. 72 | # Notice that if you are skipping storage for all authentication paths, you 73 | # may want to disable generating routes to Devise's sessions controller by 74 | # passing :skip => :sessions to `devise_for` in your config/routes.rb 75 | config.skip_session_storage = [:http_auth] 76 | 77 | # ==> Configuration for :database_authenticatable 78 | # For bcrypt, this is the cost for hashing the password and defaults to 10. If 79 | # using other encryptors, it sets how many times you want the password re-encrypted. 80 | # 81 | # Limiting the stretches to just one in testing will increase the performance of 82 | # your test suite dramatically. However, it is STRONGLY RECOMMENDED to not use 83 | # a value less than 10 in other environments. 84 | config.stretches = Rails.env.test? ? 1 : 10 85 | 86 | # Setup a pepper to generate the encrypted password. 87 | # config.pepper = "8586740d30581d9e81c8389ed1a8690d02bda3bb71fa883967a14a7523ba625bba72715ab3b97de565c04ac8da0dfe3c48fbaf451b03609b0b23c04eeed26335" 88 | 89 | # ==> Configuration for :confirmable 90 | # A period that the user is allowed to access the website even without 91 | # confirming his account. For instance, if set to 2.days, the user will be 92 | # able to access the website for two days without confirming his account, 93 | # access will be blocked just in the third day. Default is 0.days, meaning 94 | # the user cannot access the website without confirming his account. 95 | # config.allow_unconfirmed_access_for = 2.days 96 | 97 | # A period that the user is allowed to confirm their account before their 98 | # token becomes invalid. For example, if set to 3.days, the user can confirm 99 | # their account within 3 days after the mail was sent, but on the fourth day 100 | # their account can't be confirmed with the token any more. 101 | # Default is nil, meaning there is no restriction on how long a user can take 102 | # before confirming their account. 103 | # config.confirm_within = 3.days 104 | 105 | # If true, requires any email changes to be confirmed (exactly the same way as 106 | # initial account confirmation) to be applied. Requires additional unconfirmed_email 107 | # db field (see migrations). Until confirmed new email is stored in 108 | # unconfirmed email column, and copied to email column on successful confirmation. 109 | config.reconfirmable = true 110 | 111 | # Defines which key will be used when confirming an account 112 | # config.confirmation_keys = [ :email ] 113 | 114 | # ==> Configuration for :rememberable 115 | # The time the user will be remembered without asking for credentials again. 116 | # config.remember_for = 2.weeks 117 | 118 | # If true, extends the user's remember period when remembered via cookie. 119 | # config.extend_remember_period = false 120 | 121 | # Options to be passed to the created cookie. For instance, you can set 122 | # :secure => true in order to force SSL only cookies. 123 | # config.rememberable_options = {} 124 | 125 | # ==> Configuration for :validatable 126 | # Range for password length. Default is 8..128. 127 | config.password_length = 8..128 128 | 129 | # Email regex used to validate email formats. It simply asserts that 130 | # an one (and only one) @ exists in the given string. This is mainly 131 | # to give user feedback and not to assert the e-mail validity. 132 | # config.email_regexp = /\A[^@]+@[^@]+\z/ 133 | 134 | # ==> Configuration for :timeoutable 135 | # The time you want to timeout the user session without activity. After this 136 | # time the user will be asked for credentials again. Default is 30 minutes. 137 | # config.timeout_in = 30.minutes 138 | 139 | # If true, expires auth token on session timeout. 140 | # config.expire_auth_token_on_timeout = false 141 | 142 | # ==> Configuration for :lockable 143 | # Defines which strategy will be used to lock an account. 144 | # :failed_attempts = Locks an account after a number of failed attempts to sign in. 145 | # :none = No lock strategy. You should handle locking by yourself. 146 | # config.lock_strategy = :failed_attempts 147 | 148 | # Defines which key will be used when locking and unlocking an account 149 | # config.unlock_keys = [ :email ] 150 | 151 | # Defines which strategy will be used to unlock an account. 152 | # :email = Sends an unlock link to the user email 153 | # :time = Re-enables login after a certain amount of time (see :unlock_in below) 154 | # :both = Enables both strategies 155 | # :none = No unlock strategy. You should handle unlocking by yourself. 156 | # config.unlock_strategy = :both 157 | 158 | # Number of authentication tries before locking an account if lock_strategy 159 | # is failed attempts. 160 | # config.maximum_attempts = 20 161 | 162 | # Time interval to unlock the account if :time is enabled as unlock_strategy. 163 | # config.unlock_in = 1.hour 164 | 165 | # ==> Configuration for :recoverable 166 | # 167 | # Defines which key will be used when recovering the password for an account 168 | # config.reset_password_keys = [ :email ] 169 | 170 | # Time interval you can reset your password with a reset password key. 171 | # Don't put a too small interval or your users won't have the time to 172 | # change their passwords. 173 | config.reset_password_within = 6.hours 174 | 175 | # ==> Configuration for :encryptable 176 | # Allow you to use another encryption algorithm besides bcrypt (default). You can use 177 | # :sha1, :sha512 or encryptors from others authentication tools as :clearance_sha1, 178 | # :authlogic_sha512 (then you should set stretches above to 20 for default behavior) 179 | # and :restful_authentication_sha1 (then you should set stretches to 10, and copy 180 | # REST_AUTH_SITE_KEY to pepper) 181 | # config.encryptor = :sha512 182 | 183 | # ==> Configuration for :token_authenticatable 184 | # Defines name of the authentication token params key 185 | # config.token_authentication_key = :auth_token 186 | 187 | # ==> Scopes configuration 188 | # Turn scoped views on. Before rendering "sessions/new", it will first check for 189 | # "users/sessions/new". It's turned off by default because it's slower if you 190 | # are using only default views. 191 | # config.scoped_views = false 192 | 193 | # Configure the default scope given to Warden. By default it's the first 194 | # devise role declared in your routes (usually :user). 195 | # config.default_scope = :user 196 | 197 | # Set this configuration to false if you want /users/sign_out to sign out 198 | # only the current scope. By default, Devise signs out all scopes. 199 | # config.sign_out_all_scopes = true 200 | 201 | # ==> Navigation configuration 202 | # Lists the formats that should be treated as navigational. Formats like 203 | # :html, should redirect to the sign in page when the user does not have 204 | # access, but formats like :xml or :json, should return 401. 205 | # 206 | # If you have any extra navigational formats, like :iphone or :mobile, you 207 | # should add them to the navigational formats lists. 208 | # 209 | # The "*/*" below is required to match Internet Explorer requests. 210 | # config.navigational_formats = ["*/*", :html] 211 | 212 | # The default HTTP method used to sign out a resource. Default is :delete. 213 | config.sign_out_via = :delete 214 | 215 | # ==> OmniAuth 216 | # Add a new OmniAuth provider. Check the wiki for more information on setting 217 | # up on your models and hooks. 218 | # config.omniauth :github, 'APP_ID', 'APP_SECRET', :scope => 'user,public_repo' 219 | 220 | # ==> Warden configuration 221 | # If you want to use other strategies, that are not supported by Devise, or 222 | # change the failure app, you can configure them inside the config.warden block. 223 | # 224 | # config.warden do |manager| 225 | # manager.intercept_401 = false 226 | # manager.default_strategies(:scope => :user).unshift :some_external_strategy 227 | # end 228 | 229 | # ==> Mountable engine configurations 230 | # When using Devise inside an engine, let's call it `MyEngine`, and this engine 231 | # is mountable, there are some extra configurations to be taken into account. 232 | # The following options are available, assuming the engine is mounted as: 233 | # 234 | # mount MyEngine, at: "/my_engine" 235 | # 236 | # The router that invoked `devise_for`, in the example above, would be: 237 | # config.router_name = :my_engine 238 | # 239 | # When using omniauth, Devise cannot automatically set Omniauth path, 240 | # so you need to do it manually. For the users scope, it would be: 241 | # config.omniauth_path_prefix = "/my_engine/users/auth" 242 | 243 | # ==> Devise OTP Extension 244 | # Configure extension for devise 245 | 246 | # How long should the user have to enter their token. To change the default, uncomment and change the below: 247 | # config.otp_authentication_timeout = 3.minutes 248 | 249 | # Change time drift settings for valid token values. To change the default, uncomment and change the below: 250 | # config.otp_authentication_time_drift = 3 251 | end 252 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format 4 | # (all these examples are active by default): 5 | # ActiveSupport::Inflector.inflections do |inflect| 6 | # inflect.plural /^(ox)$/i, '\1en' 7 | # inflect.singular /^(ox)en/i, '\1' 8 | # inflect.irregular 'person', 'people' 9 | # inflect.uncountable %w( fish sheep ) 10 | # end 11 | # 12 | # These inflection rules are supported but not enabled by default: 13 | # ActiveSupport::Inflector.inflections do |inflect| 14 | # inflect.acronym 'RESTful' 15 | # end 16 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | # Mime::Type.register_alias "text/html", :iphone 6 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/secret_token.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Your secret key for verifying the integrity of signed cookies. 4 | # If you change this key, all old signed cookies will become invalid! 5 | # Make sure the secret is at least 30 characters and all random, 6 | # no regular words or you'll be exposed to dictionary attacks. 7 | Dummy::Application.config.secret_token = "7854ba4c663086c191afbc2e05384503b5529fa2c8e51417539db1cbe7c68e8490e9d57a1d908d4e82816a522edb97f71a8de9233272a5598534a38ef1b08697" 8 | Dummy::Application.config.secret_key_base = "7854ba4c663086c191afbc2e05384503b5529fa2c8e51417539db1cbe7c68e8490e9d57a1d908d4e82816a522edb97f71a8de9233272a5598534a38ef1b08697" 9 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/session_store.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | Dummy::Application.config.session_store :cookie_store, key: "_dummy_session" 4 | 5 | # Use the database for sessions instead of the cookie-based default, 6 | # which shouldn't be used to store highly confidential information 7 | # (create the session table with "rails generate session_migration") 8 | # Dummy::Application.config.session_store :active_record_store 9 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | # 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] 9 | end 10 | 11 | # Disable root element in JSON by default. 12 | ActiveSupport.on_load(:active_record) do 13 | self.include_root_in_json = false 14 | end 15 | -------------------------------------------------------------------------------- /test/dummy/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Sample localization file for English. Add more files in this directory for other locales. 2 | # See https://github.com/svenfuchs/rails-i18n/tree/master/rails%2Flocale for starting points. 3 | 4 | en: 5 | hello: "Hello world" 6 | -------------------------------------------------------------------------------- /test/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | Dummy::Application.routes.draw do 2 | devise_for :admins 3 | devise_for :users 4 | 5 | resources :posts 6 | resources :admin_posts 7 | 8 | root to: "base#home" 9 | end 10 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20130125101430_create_users.rb: -------------------------------------------------------------------------------- 1 | class CreateUsers < ActiveRecord::Migration[5.0] 2 | def change 3 | create_table :users do |t| 4 | t.string :name 5 | 6 | t.timestamps 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20130131092406_add_devise_to_users.rb: -------------------------------------------------------------------------------- 1 | class AddDeviseToUsers < ActiveRecord::Migration[5.0] 2 | def self.up 3 | change_table(:users) do |t| 4 | ## Database authenticatable 5 | t.string :email, null: false, default: "" 6 | t.string :encrypted_password, null: false, default: "" 7 | 8 | ## Recoverable 9 | t.string :reset_password_token 10 | t.datetime :reset_password_sent_at 11 | 12 | ## Rememberable 13 | t.datetime :remember_created_at 14 | 15 | ## Trackable 16 | t.integer :sign_in_count, default: 0 17 | t.datetime :current_sign_in_at 18 | t.datetime :last_sign_in_at 19 | t.string :current_sign_in_ip 20 | t.string :last_sign_in_ip 21 | 22 | ## Confirmable 23 | # t.string :confirmation_token 24 | # t.datetime :confirmed_at 25 | # t.datetime :confirmation_sent_at 26 | # t.string :unconfirmed_email # Only if using reconfirmable 27 | 28 | ## Lockable 29 | t.integer :failed_attempts, default: 0 # Only if lock strategy is :failed_attempts 30 | t.string :unlock_token # Only if unlock strategy is :email or :both 31 | t.datetime :locked_at 32 | 33 | ## Token authenticatable 34 | t.string :authentication_token 35 | 36 | # Uncomment below if timestamps were not included in your original model. 37 | # t.timestamps 38 | end 39 | 40 | add_index :users, :email, unique: true 41 | add_index :users, :reset_password_token, unique: true 42 | # add_index :users, :confirmation_token, :unique => true 43 | add_index :users, :unlock_token, unique: true 44 | add_index :users, :authentication_token, unique: true 45 | end 46 | 47 | def self.down 48 | # By default, we don't want to make any assumption about how to roll back a migration when your 49 | # model already existed. Please edit below which fields you would like to remove in this migration. 50 | raise ActiveRecord::IrreversibleMigration 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20130131142320_create_posts.rb: -------------------------------------------------------------------------------- 1 | class CreatePosts < ActiveRecord::Migration[5.0] 2 | def change 3 | create_table :posts do |t| 4 | t.string :title 5 | t.text :body 6 | 7 | t.timestamps 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20130131160351_devise_otp_add_to_users.rb: -------------------------------------------------------------------------------- 1 | class DeviseOtpAddToUsers < ActiveRecord::Migration[5.0] 2 | def self.up 3 | change_table :users do |t| 4 | t.string :otp_auth_secret 5 | t.string :otp_recovery_secret 6 | t.boolean :otp_enabled, default: false, null: false 7 | t.boolean :otp_mandatory, default: false, null: false 8 | t.datetime :otp_enabled_on 9 | t.integer :otp_time_drift, default: 0, null: false 10 | t.integer :otp_failed_attempts, default: 0, null: false 11 | t.integer :otp_recovery_counter, default: 0, null: false 12 | t.string :otp_persistence_seed 13 | 14 | t.string :otp_session_challenge 15 | t.datetime :otp_challenge_expires 16 | end 17 | 18 | add_index :users, :otp_session_challenge, unique: true 19 | add_index :users, :otp_challenge_expires 20 | end 21 | 22 | def self.down 23 | change_table :users do |t| 24 | t.remove :otp_auth_secret, :otp_recovery_secret, :otp_enabled, :otp_mandatory, :otp_enabled_on, :otp_session_challenge, 25 | :otp_challenge_expires, :otp_time_drift, :otp_failed_attempts, :otp_recovery_counter, :otp_persistence_seed 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20240604000001_create_admins.rb: -------------------------------------------------------------------------------- 1 | class CreateAdmins < ActiveRecord::Migration[5.0] 2 | def change 3 | create_table :admins do |t| 4 | t.string :name 5 | 6 | t.timestamps 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20240604000002_add_devise_to_admins.rb: -------------------------------------------------------------------------------- 1 | class AddDeviseToAdmins < ActiveRecord::Migration[5.0] 2 | def self.up 3 | change_table(:admins) do |t| 4 | ## Database authenticatable 5 | t.string :email, null: false, default: "" 6 | t.string :encrypted_password, null: false, default: "" 7 | 8 | ## Recoverable 9 | t.string :reset_password_token 10 | t.datetime :reset_password_sent_at 11 | 12 | ## Rememberable 13 | t.datetime :remember_created_at 14 | 15 | ## Trackable 16 | t.integer :sign_in_count, default: 0 17 | t.datetime :current_sign_in_at 18 | t.datetime :last_sign_in_at 19 | t.string :current_sign_in_ip 20 | t.string :last_sign_in_ip 21 | 22 | ## Confirmable 23 | # t.string :confirmation_token 24 | # t.datetime :confirmed_at 25 | # t.datetime :confirmation_sent_at 26 | # t.string :unconfirmed_email # Only if using reconfirmable 27 | 28 | ## Lockable 29 | t.integer :failed_attempts, default: 0 # Only if lock strategy is :failed_attempts 30 | t.string :unlock_token # Only if unlock strategy is :email or :both 31 | t.datetime :locked_at 32 | 33 | ## Token authenticatable 34 | t.string :authentication_token 35 | 36 | # Uncomment below if timestamps were not included in your original model. 37 | # t.timestamps 38 | end 39 | 40 | add_index :admins, :email, unique: true 41 | add_index :admins, :reset_password_token, unique: true 42 | # add_index :admins, :confirmation_token, :unique => true 43 | add_index :admins, :unlock_token, unique: true 44 | add_index :admins, :authentication_token, unique: true 45 | end 46 | 47 | def self.down 48 | # By default, we don't want to make any assumption about how to roll back a migration when your 49 | # model already existed. Please edit below which fields you would like to remove in this migration. 50 | raise ActiveRecord::IrreversibleMigration 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20240604000003_devise_otp_add_to_admins.rb: -------------------------------------------------------------------------------- 1 | class DeviseOtpAddToAdmins < ActiveRecord::Migration[5.0] 2 | def self.up 3 | change_table :admins do |t| 4 | t.string :otp_auth_secret 5 | t.string :otp_recovery_secret 6 | t.boolean :otp_enabled, default: false, null: false 7 | t.boolean :otp_mandatory, default: false, null: false 8 | t.datetime :otp_enabled_on 9 | t.integer :otp_time_drift, default: 0, null: false 10 | t.integer :otp_failed_attempts, default: 0, null: false 11 | t.integer :otp_recovery_counter, default: 0, null: false 12 | t.string :otp_persistence_seed 13 | 14 | t.string :otp_session_challenge 15 | t.datetime :otp_challenge_expires 16 | end 17 | 18 | add_index :admins, :otp_session_challenge, unique: true 19 | add_index :admins, :otp_challenge_expires 20 | end 21 | 22 | def self.down 23 | change_table :admins do |t| 24 | t.remove :otp_auth_secret, :otp_recovery_secret, :otp_enabled, :otp_mandatory, :otp_enabled_on, :otp_session_challenge, 25 | :otp_challenge_expires, :otp_time_drift, :otp_failed_attempts, :otp_recovery_counter, :otp_persistence_seed 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/dummy/lib/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmlele/devise-otp/3cf5d150fef602b3307c30ced6f0870c14624c36/test/dummy/lib/assets/.gitkeep -------------------------------------------------------------------------------- /test/dummy/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 17 | 18 | 19 | 20 | 21 |
22 |

The page you were looking for doesn't exist.

23 |

You may have mistyped the address or the page may have moved.

24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /test/dummy/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 17 | 18 | 19 | 20 | 21 |
22 |

The change you wanted was rejected.

23 |

Maybe you tried to change something you didn't have access to.

24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /test/dummy/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 17 | 18 | 19 | 20 | 21 |
22 |

We're sorry, but something went wrong.

23 |
24 | 25 | 26 | -------------------------------------------------------------------------------- /test/dummy/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmlele/devise-otp/3cf5d150fef602b3307c30ced6f0870c14624c36/test/dummy/public/favicon.ico -------------------------------------------------------------------------------- /test/dummy/script/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # This command will automatically be run when you run "rails" with Rails 3 gems installed from the root of your application. 3 | 4 | APP_PATH = File.expand_path("../../config/application", __FILE__) 5 | require File.expand_path("../../config/boot", __FILE__) 6 | require "rails/commands" 7 | -------------------------------------------------------------------------------- /test/integration/disable_token_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "integration_tests_helper" 3 | 4 | class DisableTokenTest < ActionDispatch::IntegrationTest 5 | 6 | def setup 7 | # log in 1fa 8 | @user = enable_otp_and_sign_in 9 | assert_equal user_otp_credential_path, current_path 10 | 11 | # otp 2fa 12 | fill_in "token", with: ROTP::TOTP.new(@user.otp_auth_secret).at(Time.now) 13 | click_button "Submit Token" 14 | assert_equal root_path, current_path 15 | end 16 | 17 | def teardown 18 | Capybara.reset_sessions! 19 | end 20 | 21 | test "disabling OTP after successfully enabling" do 22 | # disable OTP 23 | disable_otp 24 | 25 | assert page.has_content? "Disabled" 26 | within "#alerts" do 27 | assert page.has_content? 'Two-Factor Authentication has been disabled.' 28 | end 29 | 30 | # logout 31 | sign_out 32 | 33 | # log back in 1fa 34 | sign_user_in(@user) 35 | 36 | assert_equal root_path, current_path 37 | end 38 | 39 | test "disabling OTP does not reset token secrets" do 40 | # get otp secrets 41 | @user.reload 42 | auth_secret = @user.otp_auth_secret 43 | recovery_secret = @user.otp_recovery_secret 44 | 45 | # disable OTP 46 | disable_otp 47 | 48 | # compare otp secrets 49 | assert_not_nil @user.otp_auth_secret 50 | assert_equal @user.otp_auth_secret, auth_secret 51 | 52 | assert_not_nil @user.otp_recovery_secret 53 | assert_equal @user.otp_recovery_secret, recovery_secret 54 | end 55 | 56 | end 57 | -------------------------------------------------------------------------------- /test/integration/enable_otp_form_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "integration_tests_helper" 3 | 4 | class EnableOtpFormTest < ActionDispatch::IntegrationTest 5 | def teardown 6 | Capybara.reset_sessions! 7 | end 8 | 9 | test "a user should be able enable their OTP authentication by entering a confirmation code" do 10 | user = sign_user_in 11 | 12 | visit edit_user_otp_token_path 13 | 14 | user.reload 15 | 16 | fill_in "confirmation_code", with: ROTP::TOTP.new(user.otp_auth_secret).at(Time.now) 17 | 18 | click_button "Continue..." 19 | 20 | assert_equal user_otp_token_path, current_path 21 | assert page.has_content?("Enabled") 22 | 23 | within "#alerts" do 24 | assert page.has_content? 'Your Two-Factor Authentication settings have been updated.' 25 | end 26 | 27 | user.reload 28 | assert user.otp_enabled? 29 | end 30 | 31 | test "a user should not be able enable their OTP authentication with an incorrect confirmation code" do 32 | user = sign_user_in 33 | 34 | visit edit_user_otp_token_path 35 | 36 | fill_in "confirmation_code", with: "123456" 37 | 38 | click_button "Continue..." 39 | 40 | assert page.has_content?("To Enable Two-Factor Authentication") 41 | 42 | user.reload 43 | assert_not user.otp_enabled? 44 | 45 | within "#alerts" do 46 | assert page.has_content? 'The Confirmation Code you entered did not match the QR code shown below.' 47 | end 48 | 49 | visit "/" 50 | within "#alerts" do 51 | assert !page.has_content?('The Confirmation Code you entered did not match the QR code shown below.') 52 | end 53 | end 54 | 55 | test "a user should not be able enable their OTP authentication with a blank confirmation code" do 56 | user = sign_user_in 57 | 58 | visit edit_user_otp_token_path 59 | 60 | fill_in "confirmation_code", with: "" 61 | 62 | click_button "Continue..." 63 | 64 | assert page.has_content?("To Enable Two-Factor Authentication") 65 | 66 | within "#alerts" do 67 | assert page.has_content? 'The Confirmation Code you entered did not match the QR code shown below.' 68 | end 69 | 70 | user.reload 71 | assert_not user.otp_enabled? 72 | end 73 | 74 | end 75 | -------------------------------------------------------------------------------- /test/integration/persistence_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "integration_tests_helper" 3 | 4 | class PersistenceTest < ActionDispatch::IntegrationTest 5 | def setup 6 | @old_persistence = User.otp_trust_persistence 7 | User.otp_trust_persistence = 3.seconds 8 | end 9 | 10 | def teardown 11 | User.otp_trust_persistence = @old_persistence 12 | Capybara.reset_sessions! 13 | end 14 | 15 | test "a user should be requested the otp challenge every log in" do 16 | # log in 1fa 17 | user = enable_otp_and_sign_in 18 | otp_challenge_for user 19 | 20 | visit user_otp_token_path 21 | assert_equal user_otp_token_path, current_path 22 | 23 | sign_out 24 | sign_user_in 25 | 26 | assert_equal user_otp_credential_path, current_path 27 | end 28 | 29 | test "a user should be able to set their browser as trusted" do 30 | # log in 1fa 31 | user = enable_otp_and_sign_in 32 | otp_challenge_for user 33 | 34 | visit user_otp_token_path 35 | assert_equal user_otp_token_path, current_path 36 | 37 | click_link("Trust this browser") 38 | assert_text "Your browser is trusted." 39 | within "#alerts" do 40 | assert page.has_content? 'Your device is now trusted.' 41 | end 42 | sign_out 43 | 44 | sign_user_in 45 | 46 | assert_equal root_path, current_path 47 | end 48 | 49 | test "a user should be able to download its recovery codes" do 50 | # log in 1fa 51 | user = enable_otp_and_sign_in 52 | otp_challenge_for user 53 | 54 | visit user_otp_token_path 55 | assert_equal user_otp_token_path, current_path 56 | 57 | click_link("Download recovery codes") 58 | 59 | assert current_path.match?(/recovery\.text/) 60 | assert page.body.match?(user.next_otp_recovery_tokens.values.join("\n")) 61 | end 62 | 63 | test "trusted status should expire" do 64 | # log in 1fa 65 | user = enable_otp_and_sign_in 66 | otp_challenge_for user 67 | 68 | visit user_otp_token_path 69 | assert_equal user_otp_token_path, current_path 70 | 71 | click_link("Trust this browser") 72 | assert_text "Your browser is trusted." 73 | sign_out 74 | 75 | sleep User.otp_trust_persistence.to_i + 1 76 | sign_user_in 77 | 78 | assert_equal user_otp_credential_path, current_path 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /test/integration/refresh_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "integration_tests_helper" 3 | 4 | class RefreshTest < ActionDispatch::IntegrationTest 5 | def setup 6 | @old_refresh = User.otp_credentials_refresh 7 | User.otp_credentials_refresh = 1.second 8 | Admin.otp_credentials_refresh = 1.second 9 | end 10 | 11 | def teardown 12 | User.otp_credentials_refresh = @old_refresh 13 | Admin.otp_credentials_refresh = @old_refresh 14 | Capybara.reset_sessions! 15 | end 16 | 17 | test "a user that just signed in should be able to access their OTP settings without refreshing" do 18 | sign_user_in 19 | 20 | visit user_otp_token_path 21 | assert_equal user_otp_token_path, current_path 22 | end 23 | 24 | test "a user should be prompted for credentials when the credentials_refresh time is expired" do 25 | sign_user_in 26 | visit user_otp_token_path 27 | assert_equal user_otp_token_path, current_path 28 | 29 | sleep(2) 30 | 31 | visit user_otp_token_path 32 | assert_equal refresh_user_otp_credential_path, current_path 33 | end 34 | 35 | test "a user should be able to access their OTP settings after refreshing" do 36 | sign_user_in 37 | visit user_otp_token_path 38 | assert_equal user_otp_token_path, current_path 39 | 40 | sleep(2) 41 | 42 | visit user_otp_token_path 43 | assert_equal refresh_user_otp_credential_path, current_path 44 | 45 | fill_in "user_refresh_password", with: "12345678" 46 | click_button "Continue..." 47 | assert_equal user_otp_token_path, current_path 48 | end 49 | 50 | test "a user should NOT be able to access their OTP settings unless refreshing" do 51 | sign_user_in 52 | visit user_otp_token_path 53 | assert_equal user_otp_token_path, current_path 54 | 55 | sleep(2) 56 | 57 | visit user_otp_token_path 58 | assert_equal refresh_user_otp_credential_path, current_path 59 | 60 | fill_in "user_refresh_password", with: "12345670" 61 | click_button "Continue..." 62 | assert_equal refresh_user_otp_credential_path, current_path 63 | 64 | within "#alerts" do 65 | assert page.has_content? 'Sorry, you provided the wrong credentials.' 66 | end 67 | 68 | visit "/" 69 | within "#alerts" do 70 | assert !page.has_content?('Sorry, you provided the wrong credentials.') 71 | end 72 | end 73 | 74 | test "user should be finally be able to access their settings, and just password is enough" do 75 | user = enable_otp_and_sign_in_with_otp 76 | 77 | sleep(2) 78 | visit user_otp_token_path 79 | assert_equal refresh_user_otp_credential_path, current_path 80 | 81 | fill_in "user_refresh_password", with: "12345678" 82 | click_button "Continue..." 83 | 84 | assert_equal user_otp_token_path, current_path 85 | end 86 | 87 | test "works for non-default warden scopes" do 88 | admin = create_full_admin 89 | 90 | admin.populate_otp_secrets! 91 | admin.enable_otp! 92 | 93 | visit new_admin_session_path 94 | fill_in "admin_email", with: admin.email 95 | fill_in "admin_password", with: admin.password 96 | 97 | page.has_content?("Log in") ? click_button("Log in") : click_button("Sign in") 98 | 99 | assert_equal admin_otp_credential_path, current_path 100 | 101 | fill_in "token", with: ROTP::TOTP.new(admin.otp_auth_secret).at(Time.now) 102 | click_button "Submit Token" 103 | assert_equal "/", current_path 104 | 105 | sleep(2) 106 | 107 | visit admin_otp_token_path 108 | assert_equal refresh_admin_otp_credential_path, current_path 109 | 110 | fill_in "admin_refresh_password", with: "12345678" 111 | click_button "Continue..." 112 | 113 | assert_equal admin_otp_token_path, current_path 114 | end 115 | 116 | end 117 | -------------------------------------------------------------------------------- /test/integration/reset_token_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "integration_tests_helper" 3 | 4 | class ResetTokenTest < ActionDispatch::IntegrationTest 5 | 6 | def setup 7 | # log in 1fa 8 | @user = enable_otp_and_sign_in 9 | assert_equal user_otp_credential_path, current_path 10 | 11 | # otp 2fa 12 | fill_in "token", with: ROTP::TOTP.new(@user.otp_auth_secret).at(Time.now) 13 | click_button "Submit Token" 14 | assert_equal root_path, current_path 15 | end 16 | 17 | 18 | def teardown 19 | Capybara.reset_sessions! 20 | end 21 | 22 | test "redirects to otp_tokens#edit page" do 23 | reset_otp 24 | 25 | assert_equal "/users/otp/token/edit", current_path 26 | within "#alerts" do 27 | assert page.has_content? 'Your token secret has been reset. Please confirm your new token secret below.' 28 | end 29 | end 30 | 31 | test "generates new token secrets" do 32 | # get auth secrets 33 | auth_secret = @user.otp_auth_secret 34 | recovery_secret = @user.otp_recovery_secret 35 | 36 | # reset otp 37 | reset_otp 38 | 39 | # compare auth secrets 40 | @user.reload 41 | assert_not_nil @user.otp_auth_secret 42 | assert_not_equal @user.otp_auth_secret, auth_secret 43 | 44 | assert_not_nil @user.otp_recovery_secret 45 | assert_not_equal @user.otp_recovery_secret, recovery_secret 46 | end 47 | 48 | end 49 | -------------------------------------------------------------------------------- /test/integration/sign_in_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "integration_tests_helper" 3 | 4 | class SignInTest < ActionDispatch::IntegrationTest 5 | def teardown 6 | Capybara.reset_sessions! 7 | end 8 | 9 | test "a new user should be able to sign in without using their token" do 10 | create_full_user 11 | 12 | visit posts_path 13 | fill_in "user_email", with: "user@email.invalid" 14 | fill_in "user_password", with: "12345678" 15 | page.has_content?("Log in") ? click_button("Log in") : click_button("Sign in") 16 | 17 | assert_equal posts_path, current_path 18 | end 19 | 20 | test "a new user, just signed in, should be able to see and click the 'Enable Two-Factor Authentication' link" do 21 | user = sign_user_in 22 | 23 | visit user_otp_token_path 24 | assert page.has_content?("Disabled") 25 | 26 | click_link "Enable Two-Factor Authentication" 27 | 28 | assert page.has_content?("Enable Two-Factor Authentication") 29 | assert_equal edit_user_otp_token_path, current_path 30 | end 31 | 32 | test "a new user should be able to sign in enable OTP and be prompted for their token" do 33 | enable_otp_and_sign_in 34 | 35 | assert_equal user_otp_credential_path, current_path 36 | end 37 | 38 | test "fail token authentication" do 39 | enable_otp_and_sign_in 40 | assert_equal user_otp_credential_path, current_path 41 | 42 | fill_in "token", with: "123456" 43 | click_button "Submit Token" 44 | 45 | assert_equal user_otp_credential_path, current_path 46 | assert page.has_content? "The token you provided was invalid." 47 | end 48 | 49 | test "fail blank token authentication" do 50 | enable_otp_and_sign_in 51 | assert_equal user_otp_credential_path, current_path 52 | 53 | fill_in "token", with: "" 54 | click_button "Submit Token" 55 | 56 | assert_equal user_otp_credential_path, current_path 57 | assert page.has_content? "You need to type in the token you generated with your device." 58 | end 59 | 60 | test "successful token authentication" do 61 | user = enable_otp_and_sign_in 62 | 63 | fill_in "token", with: ROTP::TOTP.new(user.otp_auth_secret).at(Time.now) 64 | click_button "Submit Token" 65 | 66 | assert_equal root_path, current_path 67 | end 68 | 69 | test "should fail if the the challenge times out" do 70 | old_timeout = User.otp_authentication_timeout 71 | User.otp_authentication_timeout = 1.second 72 | 73 | user = enable_otp_and_sign_in 74 | 75 | sleep(2) 76 | 77 | fill_in "token", with: ROTP::TOTP.new(user.otp_auth_secret).at(Time.now) 78 | click_button "Submit Token" 79 | 80 | User.otp_authentication_timeout = old_timeout 81 | assert_equal new_user_session_path, current_path 82 | end 83 | 84 | test "blank token flash message does not persist to successful authentication redirect." do 85 | user = enable_otp_and_sign_in 86 | 87 | fill_in "token", with: "123456" 88 | click_button "Submit Token" 89 | 90 | assert page.has_content?("The token you provided was invalid.") 91 | 92 | fill_in "token", with: ROTP::TOTP.new(user.otp_auth_secret).at(Time.now) 93 | click_button "Submit Token" 94 | 95 | assert !page.has_content?("The token you provided was invalid.") 96 | end 97 | 98 | test "invalid token flash message does not persist to successful authentication redirect." do 99 | user = enable_otp_and_sign_in 100 | 101 | fill_in "token", with: "" 102 | click_button "Submit Token" 103 | 104 | assert page.has_content?("You need to type in the token you generated with your device.") 105 | 106 | fill_in "token", with: ROTP::TOTP.new(user.otp_auth_secret).at(Time.now) 107 | click_button "Submit Token" 108 | 109 | assert !page.has_content?("You need to type in the token you generated with your device.") 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /test/integration/trackable_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "integration_tests_helper" 3 | 4 | class TrackableTest < ActionDispatch::IntegrationTest 5 | 6 | def setup 7 | @user = sign_user_in 8 | 9 | @user.reload 10 | 11 | @sign_in_count = @user.sign_in_count 12 | @current_sign_in_at = @user.current_sign_in_at 13 | 14 | sign_out 15 | end 16 | 17 | def teardown 18 | Capybara.reset_sessions! 19 | end 20 | 21 | test "if otp is disabled, it should update devise trackable fields as usual when the user signs in" do 22 | sign_user_in(@user) 23 | 24 | @user.reload 25 | 26 | assert_not_equal @sign_in_count, @user.sign_in_count 27 | assert_not_equal @current_sign_in_at, @user.current_sign_in_at 28 | end 29 | 30 | test "if otp is enabled, it should not update devise trackable fields until user enters their user token to complete their sign in" do 31 | @user.populate_otp_secrets! 32 | @user.enable_otp! 33 | 34 | sign_user_in(@user) 35 | 36 | @user.reload 37 | 38 | assert_equal @sign_in_count, @user.sign_in_count 39 | assert_equal @current_sign_in_at, @user.current_sign_in_at 40 | 41 | fill_in "token", with: ROTP::TOTP.new(@user.otp_auth_secret).at(Time.now) 42 | click_button "Submit Token" 43 | 44 | @user.reload 45 | 46 | assert_not_equal @sign_in_count, @user.sign_in_count 47 | assert_not_equal @current_sign_in_at, @user.current_sign_in_at 48 | end 49 | 50 | end 51 | -------------------------------------------------------------------------------- /test/integration_tests_helper.rb: -------------------------------------------------------------------------------- 1 | class ActionDispatch::IntegrationTest 2 | include Warden::Test::Helpers 3 | 4 | def warden 5 | request.env["warden"] 6 | end 7 | 8 | def create_full_user 9 | @user ||= begin 10 | user = User.create!( 11 | email: "user@email.invalid", 12 | password: "12345678", 13 | password_confirmation: "12345678" 14 | ) 15 | user 16 | end 17 | end 18 | 19 | def create_full_admin 20 | @admin ||= begin 21 | admin = Admin.create!( 22 | email: "admin@email.invalid", 23 | password: "12345678", 24 | password_confirmation: "12345678" 25 | ) 26 | admin 27 | end 28 | end 29 | 30 | def enable_otp_and_sign_in_with_otp 31 | enable_otp_and_sign_in.tap do |user| 32 | fill_in "token", with: ROTP::TOTP.new(user.otp_auth_secret).at(Time.now) 33 | click_button "Submit Token" 34 | end 35 | end 36 | 37 | def enable_otp_and_sign_in 38 | user = create_full_user 39 | user.populate_otp_secrets! 40 | 41 | sign_user_in(user) 42 | visit edit_user_otp_token_path 43 | fill_in "confirmation_code", with: ROTP::TOTP.new(user.otp_auth_secret).at(Time.now) 44 | click_button "Continue..." 45 | 46 | Capybara.reset_sessions! 47 | 48 | sign_user_in(user) 49 | user 50 | end 51 | 52 | def otp_challenge_for(user) 53 | fill_in "token", with: ROTP::TOTP.new(user.otp_auth_secret).at(Time.now) 54 | click_button "Submit Token" 55 | end 56 | 57 | def disable_otp 58 | visit user_otp_token_path 59 | click_button "Disable Two-Factor Authentication" 60 | end 61 | 62 | def reset_otp 63 | visit user_otp_token_path 64 | click_button "Reset Token Secret" 65 | end 66 | 67 | def sign_out 68 | logout :user 69 | end 70 | 71 | def sign_user_in(user = nil) 72 | user ||= create_full_user 73 | resource_name = user.class.name.underscore 74 | visit send("new_#{resource_name}_session_path") 75 | fill_in "#{resource_name}_email", with: user.email 76 | fill_in "#{resource_name}_password", with: user.password 77 | 78 | page.has_content?("Log in") ? click_button("Log in") : click_button("Sign in") 79 | user 80 | end 81 | 82 | end 83 | -------------------------------------------------------------------------------- /test/model_tests_helper.rb: -------------------------------------------------------------------------------- 1 | class ActiveSupport::TestCase 2 | # 3 | # Helpers for creating new users 4 | # 5 | def unique_identity 6 | @@unique_identity_count ||= 0 7 | @@unique_identity_count += 1 8 | "user-#{@@unique_identity_count}@mail.invalid" 9 | end 10 | 11 | def valid_attributes(attributes = {}) 12 | {email: unique_identity, 13 | password: "12345678", 14 | password_confirmation: "12345678"}.update(attributes) 15 | end 16 | 17 | def new_user(attributes = {}) 18 | User.new(valid_attributes(attributes)).save 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/models/otp_authenticatable_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "model_tests_helper" 3 | 4 | class OtpAuthenticatableTest < ActiveSupport::TestCase 5 | def setup 6 | new_user 7 | end 8 | 9 | test "new users do not have a secret set" do 10 | user = User.first 11 | 12 | [:otp_auth_secret, :otp_recovery_secret, :otp_persistence_seed].each do |field| 13 | assert_nil user.send(field) 14 | end 15 | end 16 | 17 | test "new users have OTP disabled by default" do 18 | assert !User.first.otp_enabled 19 | end 20 | 21 | test "populating otp secrets should populate all required fields" do 22 | user = User.first 23 | user.populate_otp_secrets! 24 | 25 | [:otp_auth_secret, :otp_recovery_secret, :otp_persistence_seed].each do |field| 26 | assert_not_nil user.send(field) 27 | end 28 | end 29 | 30 | test "time_based_otp and recover_otp fields should be an instance of TOTP/ROTP objects" do 31 | user = User.first 32 | user.populate_otp_secrets! 33 | 34 | assert user.time_based_otp.is_a? ROTP::TOTP 35 | assert user.recovery_otp.is_a? ROTP::HOTP 36 | end 37 | 38 | test "clear_otp_fields should clear all otp fields" do 39 | user = User.first 40 | user.populate_otp_secrets! 41 | 42 | user.enable_otp! 43 | user.generate_otp_challenge! 44 | user.update( 45 | :otp_failed_attempts => 1, 46 | :otp_recovery_counter => 1 47 | ) 48 | 49 | 50 | assert user.otp_enabled 51 | [:otp_auth_secret, :otp_recovery_secret, :otp_persistence_seed].each do |field| 52 | assert_not_nil user.send(field) 53 | end 54 | [:otp_failed_attempts, :otp_recovery_counter].each do |field| 55 | assert_not user.send(field) == 0 56 | end 57 | 58 | user.clear_otp_fields! 59 | [:otp_auth_secret, :otp_recovery_secret, :otp_persistence_seed].each do |field| 60 | assert_nil user.send(field) 61 | end 62 | [:otp_failed_attempts, :otp_recovery_counter].each do |field| 63 | assert user.send(field) == 0 64 | end 65 | end 66 | 67 | test "reset_otp_persistence should generate new persistence_seed but NOT change the otp_auth_secret" do 68 | u = User.first 69 | u.populate_otp_secrets! 70 | u.enable_otp! 71 | assert u.otp_enabled 72 | 73 | otp_auth_secret = u.otp_auth_secret 74 | otp_persistence_seed = u.otp_persistence_seed 75 | 76 | u.reset_otp_persistence! 77 | assert(otp_auth_secret == u.otp_auth_secret) 78 | assert !(otp_persistence_seed == u.otp_persistence_seed) 79 | assert u.otp_enabled 80 | end 81 | 82 | test "generating a challenge, should retrieve the user later" do 83 | u = User.first 84 | u.populate_otp_secrets! 85 | u.enable_otp! 86 | challenge = u.generate_otp_challenge! 87 | 88 | w = User.find_valid_otp_challenge(challenge) 89 | assert w.is_a? User 90 | assert_equal w, u 91 | end 92 | 93 | test "expiring the challenge, should retrieve nothing" do 94 | u = User.first 95 | u.populate_otp_secrets! 96 | u.enable_otp! 97 | challenge = u.generate_otp_challenge!(1.second) 98 | sleep(2) 99 | 100 | w = User.find_valid_otp_challenge(challenge) 101 | assert_nil w 102 | end 103 | 104 | test "expired challenges should not be valid" do 105 | u = User.first 106 | u.populate_otp_secrets! 107 | u.enable_otp! 108 | challenge = u.generate_otp_challenge!(1.second) 109 | sleep(2) 110 | assert_equal false, u.otp_challenge_valid? 111 | end 112 | 113 | test "null otp challenge" do 114 | u = User.first 115 | u.populate_otp_secrets! 116 | u.enable_otp! 117 | assert_equal false, u.validate_otp_token("") 118 | assert_equal false, u.validate_otp_token(nil) 119 | end 120 | 121 | test "generated otp token should be valid for the user" do 122 | u = User.first 123 | u.populate_otp_secrets! 124 | u.enable_otp! 125 | 126 | secret = u.otp_auth_secret 127 | token = ROTP::TOTP.new(secret).now 128 | 129 | assert_equal true, u.validate_otp_token(token) 130 | end 131 | 132 | test "generated otp token, out of drift window, should be NOT valid for the user" do 133 | u = User.first 134 | u.populate_otp_secrets! 135 | u.enable_otp! 136 | 137 | secret = u.otp_auth_secret 138 | 139 | [3.minutes.from_now, 3.minutes.ago].each do |time| 140 | token = ROTP::TOTP.new(secret).at(time) 141 | assert_equal false, u.valid_otp_token?(token) 142 | end 143 | end 144 | 145 | test "recovery secrets should be valid, and valid only once" do 146 | u = User.first 147 | u.populate_otp_secrets! 148 | u.enable_otp! 149 | recovery = u.next_otp_recovery_tokens 150 | 151 | assert u.valid_otp_recovery_token? recovery.fetch(0) 152 | assert_nil u.valid_otp_recovery_token?(recovery.fetch(0)) 153 | assert u.valid_otp_recovery_token? recovery.fetch(2) 154 | end 155 | end 156 | -------------------------------------------------------------------------------- /test/orm/active_record.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Migration.verbose = false 2 | ActiveRecord::Base.logger = Logger.new(nil) 3 | 4 | migrations_path = File.expand_path("../../dummy/db/migrate/", __FILE__) 5 | 6 | if Rails.version.to_f >= 7.2 7 | ActiveRecord::MigrationContext.new(migrations_path).migrate 8 | else 9 | # To support order versions of Rails (pre v7.2) 10 | ActiveRecord::MigrationContext.new(migrations_path, ActiveRecord::SchemaMigration).migrate 11 | end 12 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | ENV["RAILS_ENV"] = "test" 2 | DEVISE_ORM = (ENV["DEVISE_ORM"] || :active_record).to_sym 3 | 4 | puts "\n==> Devise.orm = #{DEVISE_ORM.inspect}" 5 | require "dummy/config/environment" 6 | require "orm/#{DEVISE_ORM}" 7 | require "rails/test_help" 8 | require "capybara/rails" 9 | require "minitest/reporters" 10 | 11 | Minitest::Reporters.use! 12 | 13 | # I18n.load_path << File.expand_path("../support/locale/en.yml", __FILE__) if DEVISE_ORM == :mongoid 14 | 15 | # ActiveSupport::Deprecation.silenced = true 16 | 17 | class ActionDispatch::IntegrationTest 18 | include Capybara::DSL 19 | end 20 | 21 | require "devise-otp" 22 | --------------------------------------------------------------------------------