├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .release-please-manifest.json ├── .rubocop.yml ├── Appraisals ├── CHANGELOG.md ├── Gemfile ├── MIT-LICENSE ├── README.md ├── authie.gemspec ├── bin ├── appraisal ├── console ├── rspec └── rubocop ├── db └── migrate │ ├── 20141012174250_create_authie_sessions.rb │ ├── 20141013115205_add_indexes_to_authie_sessions.rb │ ├── 20150109144120_add_parent_id_to_authie_sessions.rb │ ├── 20150305135400_add_two_factor_auth_fields_to_authie.rb │ ├── 20170417170000_add_token_hashes_to_authie_sessions.rb │ ├── 20170421174100_add_index_to_token_hashes_on_authie_sessions.rb │ ├── 20180215152200_add_host_to_authie_sessions.rb │ ├── 20220502180100_add_two_factor_required_to_sessions.rb │ └── 20230627165500_add_countries_to_authie_sessions.rb ├── gemfiles ├── .bundle │ └── config ├── rails_7.1.gemfile ├── rails_7.2.gemfile └── rails_8.0.gemfile ├── lib ├── authie.rb └── authie │ ├── config.rb │ ├── controller_delegate.rb │ ├── controller_extension.rb │ ├── engine.rb │ ├── error.rb │ ├── rack_controller.rb │ ├── session.rb │ ├── session_model.rb │ ├── user.rb │ └── version.rb ├── release-please-config.json └── spec ├── dummy ├── Rakefile ├── app │ └── controllers │ │ ├── application_controller.rb │ │ └── pages_controller.rb ├── bin │ ├── rails │ ├── rake │ └── setup ├── config.ru ├── config │ ├── application.rb │ ├── boot.rb │ ├── database.yml │ ├── environment.rb │ ├── environments │ │ └── test.rb │ ├── initializers │ │ └── filter_parameter_logging.rb │ └── routes.rb └── public │ ├── 404.html │ ├── 422.html │ ├── 500.html │ ├── apple-touch-icon-precomposed.png │ ├── apple-touch-icon.png │ └── favicon.ico ├── integration └── controller_extension_spec.rb ├── lib ├── config_spec.rb ├── controller_delegate_spec.rb ├── session_model_spec.rb ├── session_spec.rb └── user_spec.rb ├── spec_helper.rb └── support ├── controller_helpers.rb └── user_model.rb /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: push 3 | jobs: 4 | release-please: 5 | runs-on: ubuntu-latest 6 | if: github.ref == 'refs/heads/main' 7 | outputs: 8 | release_created: ${{ steps.release-please.outputs.release_created }} 9 | tag_name: ${{ steps.release-please.outputs.tag_name }} # e.g. v1.0.0 10 | version: ${{ steps.release-please.outputs.version }} # e.g. 1.0.0 11 | all: ${{ toJSON(steps.release-please.outputs) }} 12 | steps: 13 | - uses: google-github-actions/release-please-action@v3 14 | id: release-please 15 | with: 16 | command: manifest 17 | 18 | lint: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v3 22 | 23 | - uses: ruby/setup-ruby@v1 24 | with: 25 | ruby-version: 3.3 26 | 27 | - name: Install dependencies 28 | run: bundle install 29 | 30 | - name: Run linter 31 | run: bundle exec rubocop 32 | 33 | test: 34 | runs-on: ubuntu-latest 35 | strategy: 36 | fail-fast: false 37 | matrix: 38 | ruby_version: 39 | - 3.1 40 | - 3.2 41 | - 3.3 42 | - 3.4 43 | gemfile: 44 | - rails_7.1 45 | - rails_7.2 46 | - rails_8.0 47 | exclude: 48 | - ruby_version: 3.1 49 | gemfile: rails_8.0 50 | env: 51 | BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/${{ matrix.gemfile }}.gemfile 52 | steps: 53 | - uses: actions/checkout@v3 54 | - uses: ruby/setup-ruby@v1 55 | with: 56 | ruby-version: ${{ matrix.ruby_version }} 57 | 58 | - name: Install dependencies 59 | run: bundle install 60 | 61 | - name: Run tests 62 | run: bundle exec rspec 63 | 64 | coverage: 65 | runs-on: ubuntu-latest 66 | steps: 67 | - uses: actions/checkout@v3 68 | - uses: ruby/setup-ruby@v1 69 | with: 70 | ruby-version: 3.1 71 | 72 | - name: Install dependencies 73 | run: bundle install 74 | 75 | - name: Generate and publish coverage 76 | uses: paambaati/codeclimate-action@v3.0.0 77 | env: 78 | COVERAGE: "1" 79 | CC_TEST_REPORTER_ID: "${{ secrets.CC_TEST_REPORTER_ID }}" 80 | with: 81 | coverageCommand: bundle exec rspec 82 | prefix: "${{ github.workspace }}" 83 | coverageLocations: | 84 | ${{ github.workspace }}/coverage/coverage.json:simplecov 85 | 86 | release: 87 | runs-on: ubuntu-latest 88 | needs: [test, release-please] 89 | if: needs.release-please.outputs.release_created 90 | steps: 91 | - uses: actions/checkout@v3 92 | 93 | - name: Set up Ruby 94 | uses: ruby/setup-ruby@v1 95 | with: 96 | ruby-version: 3.1 97 | 98 | - name: Export version from tag name 99 | run: echo ${{ needs.release-please.outputs.version }} > VERSION 100 | 101 | - name: Build Gem 102 | run: gem build *.gemspec 103 | 104 | - name: Setup credentials 105 | run: | 106 | mkdir -p $HOME/.gem 107 | touch $HOME/.gem/credentials 108 | chmod 0600 $HOME/.gem/credentials 109 | printf -- "---\n:rubygems_api_key: ${RUBYGEMS_API_KEY}\n" > $HOME/.gem/credentials 110 | printf -- ":github: Bearer ${GITHUB_API_KEY}\n" >> $HOME/.gem/credentials 111 | env: 112 | RUBYGEMS_API_KEY: ${{secrets.RUBYGEMS_API_KEY}} 113 | GITHUB_API_KEY: ${{secrets.GITHUB_TOKEN}} 114 | 115 | - name: Publish to RubyGems 116 | run: | 117 | gem push *.gem 118 | 119 | - name: Publish to GPR 120 | run: | 121 | gem push --key github --host https://rubygems.pkg.github.com/adamcooke *.gem 122 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | spec/dummy/log/* 3 | spec/dummy/tmp/* 4 | coverage/* 5 | Gemfile.lock 6 | gemfiles/*.gemfile.lock 7 | -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "5.0.0" 3 | } 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | SuggestExtensions: false 3 | NewCops: enable 4 | Exclude: 5 | - gemfiles/* 6 | - bin/* 7 | 8 | Style/AccessorGrouping: 9 | EnforcedStyle: separated 10 | 11 | Metrics/MethodLength: 12 | Enabled: false 13 | 14 | Metrics/AbcSize: 15 | Enabled: false 16 | 17 | Style/Documentation: 18 | Enabled: false 19 | 20 | Metrics/ClassLength: 21 | Enabled: false 22 | 23 | Metrics/CyclomaticComplexity: 24 | Enabled: false 25 | 26 | Gemspec/RequiredRubyVersion: 27 | Enabled: false 28 | 29 | Metrics/BlockLength: 30 | Enabled: false 31 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | appraise 'rails-7.1' do 4 | gem 'rails', '~> 7.1.0' 5 | end 6 | 7 | appraise 'rails-7.2' do 8 | gem 'rails', '~> 7.2.0' 9 | end 10 | 11 | appraise 'rails-8.0' do 12 | gem 'rails', '~> 8.0.0' 13 | end 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [5.0.0](https://github.com/adamcooke/authie/compare/v4.1.6...v5.0.0) (2025-02-20) 4 | 5 | 6 | ### ⚠ BREAKING CHANGES 7 | 8 | * Authie no longer supports Rails <= 7.0. Official support for Rails 7.0 ended over a year ago, we don't need to continue supporting it in Authie 5.0 and higher. Rails 7.0 can continue to use the 4.x releases. 9 | * If a coder was set using `config.active_record.default_column_serializer` then it will no longer be respected. You will need to set `Authie.config.serialize_coder` to override the coder for Authie sessions. 10 | 11 | ### Features 12 | 13 | * allow data serialization encoder to be defined using `Authie.config.serialize_coder`. ([588b5e7](https://github.com/adamcooke/authie/commit/588b5e7b0cc0e03fa3282b4fc088a18b866b0932)) 14 | 15 | 16 | ### Miscellaneous Chores 17 | 18 | * remove support for Rails 7.0 ([1b09a80](https://github.com/adamcooke/authie/commit/1b09a80efaf1525a4079abf793bdfb93d9246917)) 19 | 20 | 21 | ### Tests 22 | 23 | * use the >= 7.0 migration context ([7daabf1](https://github.com/adamcooke/authie/commit/7daabf1aff3cf06fec544f6b6e6f8fb9f7f55f6d)) 24 | 25 | ## [4.1.6](https://github.com/adamcooke/authie/compare/v4.1.5...v4.1.6) (2025-02-04) 26 | 27 | 28 | ### Miscellaneous Chores 29 | 30 | * update action dependencies for release ([c070a1e](https://github.com/adamcooke/authie/commit/c070a1e3a197c04dff8631839f4430596e8ab581)) 31 | 32 | ## [4.1.5](https://github.com/adamcooke/authie/compare/v4.1.4...v4.1.5) (2025-02-04) 33 | 34 | 35 | ### Miscellaneous Chores 36 | 37 | * add additional changelog sections to release please ([e6c763d](https://github.com/adamcooke/authie/commit/e6c763d1acb4f33ae2a988952329c815d5bb6d0c)) 38 | * exclude 3.1/8.0 and 3.4/7.0 ([8456d8d](https://github.com/adamcooke/authie/commit/8456d8ded1accbda3d8bad838ec20bf09af42eb2)) 39 | * unpin sqlite ([74043d6](https://github.com/adamcooke/authie/commit/74043d68a8d8aa6ce397255c84f7592be03b27a0)) 40 | * update ci to include rails 8 and ruby 3.3/3.4 ([1326f9e](https://github.com/adamcooke/authie/commit/1326f9e49b9edf050a24046b732907f56d1a893a)) 41 | 42 | 43 | ### Tests 44 | 45 | * require 'logger' in tests ([e1f55d3](https://github.com/adamcooke/authie/commit/e1f55d31315137b87ed2473deeb307ff1acee963)) 46 | 47 | ## [4.1.4](https://github.com/adamcooke/authie/compare/v4.1.3...v4.1.4) (2024-01-29) 48 | 49 | 50 | ### Bug Fixes 51 | 52 | * fixes RackController session lookup ([#40](https://github.com/adamcooke/authie/issues/40)) ([89395b4](https://github.com/adamcooke/authie/commit/89395b4e23d33193b3ca4a30c3ed0bb19fd533f3)) 53 | 54 | ## [4.1.3](https://github.com/adamcooke/authie/compare/v4.1.2...v4.1.3) (2023-11-02) 55 | 56 | 57 | ### Bug Fixes 58 | 59 | * fix dependency constraints ([0268de8](https://github.com/adamcooke/authie/commit/0268de87c02acd3ab0a1c01b70ed8153cb11d075)) 60 | 61 | ## [4.1.2](https://github.com/adamcooke/authie/compare/v4.1.1...v4.1.2) (2023-11-02) 62 | 63 | 64 | ### Bug Fixes 65 | 66 | * don't provide Schema::Migration in Rails >= 7.0 ([57f2857](https://github.com/adamcooke/authie/commit/57f2857ba9c38bbc2078ca45be6ca13c55ed9373)) 67 | * specify type of object for serialise data attribute ([48263f8](https://github.com/adamcooke/authie/commit/48263f84bae4a3e00ca67c878019503e83b09e34)) 68 | 69 | ## [4.1.1](https://github.com/adamcooke/authie/compare/v4.1.0...v4.1.1) (2023-06-27) 70 | 71 | 72 | ### Bug Fixes 73 | 74 | * expose Config#lookup_ip_country_backend ([8473337](https://github.com/adamcooke/authie/commit/8473337fce552cb4d1ae5788e304347b2266b3d9)) 75 | 76 | ## [4.1.0](https://github.com/adamcooke/authie/compare/v4.0.0...v4.1.0) (2023-06-27) 77 | 78 | 79 | ### Features 80 | 81 | * support for storing ip address countries ([90b2394](https://github.com/adamcooke/authie/commit/90b2394c7080feb9b355de0dec4e46e6683c64a2)) 82 | 83 | ## [4.0.0](https://github.com/adamcooke/authie/compare/v3.4.0...v4.0.0) (2023-05-02) 84 | 85 | 86 | ### Features 87 | 88 | * ability to have expiry times increased on session activity ([a67dbbe](https://github.com/adamcooke/authie/commit/a67dbbed0d7e6d322e2516dc296b25d339c51a6a)) 89 | * ability to pass session options to ControllerDelegate#create_auth_session ([38922f4](https://github.com/adamcooke/authie/commit/38922f4ac941dcebba5043dbf6ec8682dc213102)) 90 | * ability to skip session touching within a request ([593eacf](https://github.com/adamcooke/authie/commit/593eacf83c4d2fd5ce50f0703c88914a4971a9b7)) 91 | * active support notifications ([ce0c895](https://github.com/adamcooke/authie/commit/ce0c89574208091b0165c8133e4dd274f65aae4f)) 92 | * add boolean for storing two factor skip state ([ec834df](https://github.com/adamcooke/authie/commit/ec834dff52fb54d07f718e1e5fb5669ecde300d7)) 93 | * add notification on session invalidation ([cf9af97](https://github.com/adamcooke/authie/commit/cf9af97d5d76bf8539a54256a5975e7722e0cb9d)) 94 | * add session to validity exceptions ([9e23f19](https://github.com/adamcooke/authie/commit/9e23f19e4cf4c9ba25941f1104e4ee3d8e2580e7)) 95 | * allow persistent sessions to be created ([9ed6b6d](https://github.com/adamcooke/authie/commit/9ed6b6d759bc2ee7e68180a2e1bd52e64e8a7e43)) 96 | * customisable token lengths ([41431a6](https://github.com/adamcooke/authie/commit/41431a66cf943f5f70abe1fa6dc059271b5f46cd)) 97 | * separate touching & validating auth sessions ([e688762](https://github.com/adamcooke/authie/commit/e688762662215c823d9fe8bbf2cc6e1cef815b24)) 98 | * support for resetting a token to a new value ([ed6f138](https://github.com/adamcooke/authie/commit/ed6f1381a4a69913cede483bd3c947320ac3b543)) 99 | 100 | 101 | ### Bug Fixes 102 | 103 | * do not invalidate inactive sessions when invalidating others ([56a659b](https://github.com/adamcooke/authie/commit/56a659bec5438966fec24c3b8b48da5c68c7d5c9)) 104 | * don't inspect sessions when invalidating others ([5a81581](https://github.com/adamcooke/authie/commit/5a81581d66a8e56200bd67726f27f76a265593e4)) 105 | * don't override skip_two_factor whenever calling #mark_as_two_factored ([7e5c8a0](https://github.com/adamcooke/authie/commit/7e5c8a032c1383574f9c1f96d7f7007ff791130a)) 106 | * maintain Authie::Session.cleanup ([5776421](https://github.com/adamcooke/authie/commit/5776421bb4d2f8f4cbe71bae927b3a132d877b58)) 107 | * only add helper methods if the controller supports them ([bbeca3b](https://github.com/adamcooke/authie/commit/bbeca3b055b7b4ea0934d82e8ee4a3356dfe62de)), closes [#24](https://github.com/adamcooke/authie/issues/24) 108 | * require all of active record for the session model ([c042e34](https://github.com/adamcooke/authie/commit/c042e34f9002feaac9448de0cd9d4e58fbaec029)) 109 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | gemspec 5 | 6 | gem 'appraisal', '2.4.1' 7 | gem 'rails', '>= 5.0', '< 9.0' 8 | gem 'rspec' 9 | gem 'rspec-core' 10 | gem 'rspec-expectations' 11 | gem 'rspec-mocks' 12 | gem 'rspec-rails' 13 | gem 'rubocop' 14 | gem 'simplecov' 15 | gem 'simplecov-console' 16 | gem 'solargraph' 17 | gem 'sqlite3' 18 | gem 'timecop' 19 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2014 Adam Cooke. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Authie 2 | 3 | 4 | 5 | This is a Rails library which provides applications with a database-backed user 6 | sessions. This ensures that user sessions can be invalidated from the server and 7 | users activity can be easily tracked. 8 | 9 | The "traditional" way of simply setting a user ID in your session is insecure 10 | and unwise. If you simply do something like the example below, it means that anyone 11 | with access to the session cookie can login as the user whenever and wherever they wish. 12 | 13 | To clarify: while by default Rails session cookies are encrypted, there is 14 | nothing to allow them to be invalidated if someone were to "steal" an encrypted 15 | cookie from an authenticated user. This could be stolen using a MITM attack or 16 | simply by stealing it directly from their browser when they're off getting a coffee. 17 | 18 | ```ruby 19 | if user = User.authenticate(params[:username], params[:password]) 20 | # Don't do this... 21 | session[:user_id] = user.id 22 | redirect_to root_path, :notice => "Logged in successfully!" 23 | end 24 | ``` 25 | 26 | The design goals behind Authie are: 27 | 28 | - Any session can be invalidated instantly from the server without needing to make 29 | changes to remote cookies. 30 | - We can see who is logged in to our application at any point in time. 31 | - Sessions should automatically expire after a certain period of inactivity. 32 | - Sessions can be either permanent or temporary. 33 | 34 | ## Installation 35 | 36 | As usual, just pop this in your Gemfile: 37 | 38 | ```ruby 39 | gem 'authie', '~> 4.0' 40 | ``` 41 | 42 | You will then need add the database tables Authie needs to your database. You 43 | should copy Authie's migrations and then migrate. 44 | 45 | ``` 46 | rake authie:install:migrations 47 | rake db:migrate 48 | ``` 49 | 50 | ## Usage 51 | 52 | Authie is just a session manager and doesn't provide any functionality for your authentication or User models. Your `User` model should implement any methods needed to authenticate a username & password. 53 | 54 | ### Creating a new session 55 | 56 | When a user has been authenticated, you can simply set `current_user` to the user 57 | you wish to login. You may have a method like this in a controller. 58 | 59 | ```ruby 60 | class SessionsController < ApplicationController 61 | 62 | def create 63 | if user = User.authenticate(params[:username], params[:password]) 64 | create_auth_session(user) 65 | redirect_to root_path 66 | else 67 | flash.now[:alert] = "Username/password was invalid" 68 | end 69 | end 70 | 71 | end 72 | ``` 73 | 74 | ### Checking whether user's are logged in 75 | 76 | On any subsequent request, you should make sure that your user is logged in. 77 | You may wish to implement a `login_required` controller method which is called 78 | before every action in your application. 79 | 80 | ```ruby 81 | class ApplicationController < ActionController::Base 82 | 83 | before_action :login_required 84 | 85 | private 86 | 87 | def login_required 88 | return if logged_in? 89 | 90 | redirect_to login_path, :alert => "You must login to view this resource" 91 | end 92 | 93 | end 94 | ``` 95 | 96 | ### Accessing the current user (and session) 97 | 98 | There are a few controller methods which you can call which will return information about the current session: 99 | 100 | - `current_user` - returns the currently logged in user 101 | - `auth_session` - returns the current auth session 102 | - `logged_in?` - returns a true if there's a session or false if no user is logged in 103 | 104 | ### Catching session errors 105 | 106 | If there is an issue with an auth session, an error will be raised which you need 107 | to catch within your application. The errors which will be raised are: 108 | 109 | - `Authie::Session::InactiveSession` - is raised when a session has been de-activated. 110 | - `Authie::Session::ExpiredSession` - is raised when a session expires. 111 | - `Authie::Session::BrowserMismatch` - is raised when the browser ID provided does 112 | not match the browser ID associated with the session token provided. 113 | - `Authie::Session::HostMismatch` - is raised when the session is used on a hostname 114 | that does not match that which created the session 115 | 116 | The easiest way to rescue these to use a `rescue_from`. For example: 117 | 118 | ```ruby 119 | class ApplicationController < ActionController::Base 120 | 121 | rescue_from Authie::Session::ValidityError, :with => :auth_session_error 122 | 123 | private 124 | 125 | def auth_session_error 126 | redirect_to login_path, :alert => "Your session is no longer valid. Please login again to continue..." 127 | end 128 | 129 | end 130 | ``` 131 | 132 | ### Logging out 133 | 134 | In order to invalidate a session you can simply invalidate it. 135 | 136 | ```ruby 137 | def logout 138 | auth_session.invalidate 139 | redirect_to login_path, :notice => "Logged out successfully." 140 | end 141 | ``` 142 | 143 | ### Default session length 144 | 145 | By default, a session will last for however long it is being actively used in 146 | browser. If the user stops using your application, the session will last for 147 | 12 hours before becoming invalid. You can change this: 148 | 149 | ```ruby 150 | Authie.config.session_inactivity_timeout = 2.hours 151 | ``` 152 | 153 | This does not apply if the session is marked as persistent. See below. 154 | 155 | ### Persisting sessions 156 | 157 | In some cases, you may wish users to have a permanent sessions. In this case, 158 | you should ask users after they have logged in if they wish to "persist" their 159 | session across browser restarts. If they do wish to do this, just do something 160 | like this: 161 | 162 | ```ruby 163 | def persist_session 164 | auth_session.persist 165 | redirect_to root_path, :notice => "You will now be remembered!" 166 | end 167 | ``` 168 | 169 | By default, persistent sessions will last for 2 months before requring the user 170 | logs in again. You can increase (or decrease) this if needed: 171 | 172 | ```ruby 173 | Authie.config.persistent_session_length = 12.months 174 | ``` 175 | 176 | ### Accessing all user sessions 177 | 178 | If you want to provide users with a list of their sessions, you can access all 179 | active sessions for a user. The best way to do this will be to add a `has_many` 180 | association to your User model. 181 | 182 | ```ruby 183 | class User < ActiveRecord::Base 184 | has_many :sessions, :class_name => 'Authie::SessionModel', :as => :user, :dependent => :destroy 185 | end 186 | ``` 187 | 188 | ### Storing additional data in the user session 189 | 190 | If you need to store additional information in your database-backed database 191 | session, then you can use the following methods to achieve this: 192 | 193 | ```ruby 194 | auth_session.set :two_factor_seen_at, Time.now 195 | auth_session.get :two_factor_seen_at 196 | ``` 197 | 198 | ### Invalidating all but current session 199 | 200 | You may wish to allow users to easily invalidate all sessions which aren't their 201 | current one. Some applications invalidate old sessions whenever a user changes 202 | their password. The `invalidate_others!` method can be called on any 203 | `Authie::Session` object and will invalidate all sessions which aren't itself. 204 | 205 | ```ruby 206 | def change_password 207 | @user.change_password(params[:new_password]) 208 | auth_session.invalidate_others! 209 | end 210 | ``` 211 | 212 | ### Sudo functions 213 | 214 | In some applications, you may want to require that the user has recently provided 215 | their password to you before executing certain sensitive actions. Authie provides 216 | some methods which can help you keep track of when a user last provided their 217 | password in a session and whether you need to prompt them before continuing. 218 | 219 | ```ruby 220 | # When the user logs into your application, run the see_password method to note 221 | # that we have just seen their password. 222 | def login 223 | if user = User.authenticate(params[:username], params[:password]) 224 | create_auth_session(user, see_password: true) 225 | redirect_to root_path 226 | end 227 | end 228 | 229 | # Before executing any dangerous actions, check to see whether the password has 230 | # recently been seen. 231 | def change_password 232 | if auth_session.recently_seen_password? 233 | # Allow the user to change their password as normal. 234 | else 235 | # Redirect the user a page which allows them to re-enter their password. 236 | # The method here should verify the password is correct and call the 237 | # see_password method as above. Once verified, you can return them back to 238 | # this page. 239 | redirect_to reauth_path(:return_to => request.fullpath) 240 | end 241 | end 242 | ``` 243 | 244 | By default, a password will be said to have been recently seen if it has been 245 | seen in the last 10 minutes. You can change this configuration if needed: 246 | 247 | ```ruby 248 | Authie.config.sudo_session_timeout = 30.minutes 249 | ``` 250 | 251 | ### Working with two factor authentication 252 | 253 | Authie provides a couple of methods to help you determine when two factor 254 | authentication is required for a request. Whenever a user logs in and has 255 | enabled two factor authentication, you can mark sessions as being permitted. 256 | 257 | You can add the following to your application controller and ensure that it runs 258 | on every request to your application. 259 | 260 | ```ruby 261 | class ApplicationController < ActionController::Base 262 | 263 | before_action :check_two_factor_auth 264 | 265 | def check_two_factor_auth 266 | if logged_in? && current_user.has_two_factor_auth? && !auth_session.two_factored? 267 | # If the user has two factor auth enabled, and we haven't already checked it 268 | # in this auth session, redirect the user to an action which prompts the user 269 | # to do their two factor auth check. 270 | redirect_to two_factor_auth_path 271 | end 272 | end 273 | 274 | end 275 | ``` 276 | 277 | Then, on your two factor auth action, you need to ensure that you mark the auth 278 | session as being verified with two factor auth. 279 | 280 | ```ruby 281 | class LoginController < ApplicationController 282 | 283 | skip_before_action :check_two_factor_auth 284 | 285 | def two_factor_auth 286 | if user.verify_two_factor_token(params[:token]) 287 | auth_session.mark_as_two_factored 288 | redirect_to root_path, :notice => "Logged in successfully!" 289 | end 290 | end 291 | 292 | end 293 | ``` 294 | 295 | ## Storing IP address countries 296 | 297 | Authie has support for storing the country that an IP address is located in whenever they are saved to the database. To use this, you need to specify a backend to use in the Authie configuration. The backend should respond to `#call(ip_address)`. 298 | 299 | ```ruby 300 | Authie.config.lookup_ip_country_backend = proc do |ip_address| 301 | SomeService.lookup_country_from_ip(ip_address) 302 | end 303 | ``` 304 | 305 | ## Instrumentation/Notification 306 | 307 | Authie will publish events to the ActiveSupport::Notification instrumentation system. The following events are published 308 | with the given attributes. 309 | 310 | - `set_browser_id.authie` - when a new browser ID is set for a user. Provides `:browser_id` and `:controller` arguments. 311 | - `cleanup.authie` - when session cleanup is run. Provides no arguments. 312 | - `touch.authie` - when a session is touched. Provides `:session` argument. 313 | - `see_password.authie` - when a session sees a password. Provides `:session` argument. 314 | - `mark_as_two_factor.authie` - when a session has two factor credentials provided. Provides `:session` argument. 315 | - `session_start.authie` - when a session is started. Provides `:session` argument. 316 | - `session_invalidate.authie` - when a session is intentionally invalidated. Provides `:session` argument with session model instance. 317 | - `browser_id_mismatch_error.authie` - when a session is validated when the browser ID does not match. Provides `:session` argument. 318 | - `invalid_session_error.authie` - when a session is validated when invalid. Provides `:session` argument. 319 | - `expired_session_error.authie` - when a session is validated when expired. Provides `:session` argument. 320 | - `inactive_session_error.authie` - when a session is validated when inactive. Provides `:session` argument. 321 | - `host_mismatch_error.authie` - when a session is validated and the host does not match. Provides `:session` argument. 322 | 323 | ## Differences for Authie 4.0 324 | 325 | Authie 4.0 introduces a number of changes to the library which are worth noting when upgrading from any version less than 4. 326 | 327 | - Authie 4.0 removes the impersonation features which may make a re-appearance in a futre version. 328 | - All previous callback/events have been replaced with standard ActiveSupport instrumentation notifications. 329 | - `Authie::SessionModel` has been introduced to represent the instance of the underlying database record. 330 | - Various methods on Authie::Session (more commonly known as `auth_session`) have been renamed as follows. 331 | - `check_security!` is now `validate` 332 | - `persist!` is now `persist` 333 | - `invalidate!` is now `invalidate` 334 | - `touch!` is now `touch` 335 | - `set_cookie!` is now `set_cookie` and is now a private method and should not be called directly. 336 | - `see_password!` is now `see_password` 337 | - `mark_as_two_factored!` is now `mark_as_two_factored` 338 | - A new `Authie::Session#reset_token` has been added which will generate a new token for a session, save it and update the cookie. 339 | - When starting a session using `Authie::Session.start` or `create_auth_session` you can provide the following additional options: 340 | - `persistent: true` to mark the session as persistent (i.e. give it an expiry time) 341 | - `see_password: true` to set the password seen timestamp at the same time as creation 342 | - If the `extend_session_expiry_on_touch` config option is set to true (default is false), the expiry time for a persistent session will be extended whenver a session is touched. 343 | - When making a request, the session will be touched **after** the action rather than before. Previously, the `touch_auth_session` method was added before every action and it both validated the session and touched it. Now, there are two separate methods - `validate_auth_session` which is run before every action and `touch_auth_session` runs after every action. If you don't want to touch a session in a request you can either use `skip_around_action :touch_auth_session` or call `skip_touch_auth_session!` anywhere in the action. 344 | - A new config option called `session_token_length` is available which allows you to change the length of the random token used for sessions (default 64). 345 | -------------------------------------------------------------------------------- /authie.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require File.expand_path('lib/authie/version', __dir__) 4 | 5 | # rubocop:disable Gemspec/RequireMFA 6 | Gem::Specification.new do |s| 7 | s.name = 'authie' 8 | s.description = 'A Rails library for storing user sessions in a backend database' 9 | s.summary = s.description 10 | s.homepage = 'https://github.com/adamcooke/authie' 11 | s.licenses = ['MIT'] 12 | s.version = Authie::VERSION 13 | s.files = Dir.glob('{lib,db}/**/*') 14 | s.require_paths = ['lib'] 15 | s.authors = ['Adam Cooke'] 16 | s.email = ['me@adamcooke.io'] 17 | 18 | s.add_dependency 'activerecord', '>= 6.1', '< 9.0' 19 | end 20 | # rubocop:enable Gemspec/RequireMFA 21 | -------------------------------------------------------------------------------- /bin/appraisal: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'appraisal' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("appraisal", "appraisal") 28 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'authie' 6 | 7 | require 'irb' 8 | IRB.start(__FILE__) 9 | -------------------------------------------------------------------------------- /bin/rspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rspec' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("rspec-core", "rspec") 30 | -------------------------------------------------------------------------------- /bin/rubocop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rubocop' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("rubocop", "rubocop") 30 | -------------------------------------------------------------------------------- /db/migrate/20141012174250_create_authie_sessions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateAuthieSessions < ActiveRecord::Migration[6.1] 4 | def change 5 | create_table :authie_sessions do |t| 6 | t.string :token, :browser_id 7 | t.bigint :user_id 8 | t.boolean :active, default: true 9 | t.text :data 10 | t.datetime :expires_at 11 | t.datetime :login_at 12 | t.string :login_ip 13 | t.datetime :last_activity_at 14 | t.string :last_activity_ip, :last_activity_path 15 | t.string :user_agent 16 | t.timestamps null: true 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /db/migrate/20141013115205_add_indexes_to_authie_sessions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddIndexesToAuthieSessions < ActiveRecord::Migration[6.1] 4 | def change 5 | add_column :authie_sessions, :user_type, :string 6 | add_index :authie_sessions, :token, length: 10 7 | add_index :authie_sessions, :browser_id, length: 10 8 | add_index :authie_sessions, :user_id 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /db/migrate/20150109144120_add_parent_id_to_authie_sessions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddParentIdToAuthieSessions < ActiveRecord::Migration[6.1] 4 | def change 5 | add_column :authie_sessions, :parent_id, :bigint 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20150305135400_add_two_factor_auth_fields_to_authie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddTwoFactorAuthFieldsToAuthie < ActiveRecord::Migration[6.1] 4 | def change 5 | add_column :authie_sessions, :two_factored_at, :datetime 6 | add_column :authie_sessions, :two_factored_ip, :string 7 | add_column :authie_sessions, :requests, :integer, default: 0 8 | add_column :authie_sessions, :password_seen_at, :datetime 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /db/migrate/20170417170000_add_token_hashes_to_authie_sessions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddTokenHashesToAuthieSessions < ActiveRecord::Migration[6.1] 4 | def change 5 | add_column :authie_sessions, :token_hash, :string 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20170421174100_add_index_to_token_hashes_on_authie_sessions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddIndexToTokenHashesOnAuthieSessions < ActiveRecord::Migration[6.1] 4 | def change 5 | add_index :authie_sessions, :token_hash, length: 10 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20180215152200_add_host_to_authie_sessions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddHostToAuthieSessions < ActiveRecord::Migration[6.1] 4 | def change 5 | add_column :authie_sessions, :host, :string 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20220502180100_add_two_factor_required_to_sessions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddTwoFactorRequiredToSessions < ActiveRecord::Migration[6.1] 4 | def change 5 | add_column :authie_sessions, :skip_two_factor, :boolean, default: false 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20230627165500_add_countries_to_authie_sessions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddCountriesToAuthieSessions < ActiveRecord::Migration[6.1] 4 | def change 5 | add_column :authie_sessions, :login_ip_country, :string 6 | add_column :authie_sessions, :two_factored_ip_country, :string 7 | add_column :authie_sessions, :last_activity_ip_country, :string 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /gemfiles/.bundle/config: -------------------------------------------------------------------------------- 1 | --- 2 | BUNDLE_RETRY: "1" 3 | -------------------------------------------------------------------------------- /gemfiles/rails_7.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal", "2.4.1" 6 | gem "rails", "~> 7.1.0" 7 | gem "rspec" 8 | gem "rspec-core" 9 | gem "rspec-expectations" 10 | gem "rspec-mocks" 11 | gem "rspec-rails" 12 | gem "rubocop" 13 | gem "simplecov" 14 | gem "simplecov-console" 15 | gem "solargraph" 16 | gem "sqlite3" 17 | gem "timecop" 18 | 19 | gemspec path: "../" 20 | -------------------------------------------------------------------------------- /gemfiles/rails_7.2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal", "2.4.1" 6 | gem "rails", "~> 7.2.0" 7 | gem "rspec" 8 | gem "rspec-core" 9 | gem "rspec-expectations" 10 | gem "rspec-mocks" 11 | gem "rspec-rails" 12 | gem "rubocop" 13 | gem "simplecov" 14 | gem "simplecov-console" 15 | gem "solargraph" 16 | gem "sqlite3" 17 | gem "timecop" 18 | 19 | gemspec path: "../" 20 | -------------------------------------------------------------------------------- /gemfiles/rails_8.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal", "2.4.1" 6 | gem "rails", "~> 8.0.0" 7 | gem "rspec" 8 | gem "rspec-core" 9 | gem "rspec-expectations" 10 | gem "rspec-mocks" 11 | gem "rspec-rails" 12 | gem "rubocop" 13 | gem "simplecov" 14 | gem "simplecov-console" 15 | gem "solargraph" 16 | gem "sqlite3" 17 | gem "timecop" 18 | 19 | gemspec path: "../" 20 | -------------------------------------------------------------------------------- /lib/authie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'authie/version' 4 | require 'authie/config' 5 | require 'authie/error' 6 | require 'authie/user' 7 | 8 | require 'authie/engine' if defined?(Rails) 9 | -------------------------------------------------------------------------------- /lib/authie/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Authie 4 | class Config 5 | attr_accessor :session_inactivity_timeout 6 | attr_accessor :persistent_session_length 7 | attr_accessor :sudo_session_timeout 8 | attr_accessor :browser_id_cookie_name 9 | attr_accessor :session_token_length 10 | attr_accessor :extend_session_expiry_on_touch 11 | attr_accessor :lookup_ip_country_backend 12 | attr_accessor :serialize_coder 13 | 14 | def initialize 15 | set_defaults 16 | end 17 | 18 | def lookup_ip_country(ip) 19 | return nil if @lookup_ip_country_backend.nil? 20 | 21 | @lookup_ip_country_backend.call(ip) 22 | end 23 | 24 | def set_defaults 25 | @session_inactivity_timeout = 12.hours 26 | @persistent_session_length = 2.months 27 | @sudo_session_timeout = 10.minutes 28 | @browser_id_cookie_name = :browser_id 29 | @session_token_length = 64 30 | @extend_session_expiry_on_touch = false 31 | @lookup_ip_country_backend = nil 32 | @serialize_coder = ActiveRecord::Coders::YAMLColumn 33 | end 34 | end 35 | 36 | class << self 37 | def config 38 | @config ||= Config.new 39 | end 40 | 41 | def configure(&block) 42 | block.call(config) 43 | config 44 | end 45 | 46 | def notify(event, args = {}, &block) 47 | ActiveSupport::Notifications.instrument("#{event}.authie", args, &block) 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/authie/controller_delegate.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'securerandom' 4 | require 'authie/session' 5 | require 'authie/config' 6 | require 'authie/session_model' 7 | 8 | module Authie 9 | # The controller delegate implements methods that can be used by a controller. These are then 10 | # extended into controllers as needed (see ControllerExtension). 11 | class ControllerDelegate 12 | attr_accessor :touch_auth_session_enabled 13 | 14 | # @param controller [ActionController::Base] 15 | # @return [Authie::ControllerDelegate] 16 | def initialize(controller) 17 | @controller = controller 18 | @touch_auth_session_enabled = true 19 | end 20 | 21 | # Sets a browser ID. This must be performed on any page request where AUthie will be used. 22 | # It should be triggered before any other Authie provided methods. This will ensure that 23 | # the given browser ID is unique. 24 | # 25 | # @return [String] the generated browser ID 26 | def set_browser_id 27 | until cookies[Authie.config.browser_id_cookie_name] 28 | proposed_browser_id = SecureRandom.uuid 29 | next if Authie::SessionModel.where(browser_id: proposed_browser_id).exists? 30 | 31 | cookies[Authie.config.browser_id_cookie_name] = { 32 | value: proposed_browser_id, 33 | expires: 5.years.from_now, 34 | httponly: true, 35 | secure: @controller.request.ssl? 36 | } 37 | Authie.notify(:set_browser_id, 38 | browser_id: proposed_browser_id, 39 | controller: @controller) 40 | end 41 | proposed_browser_id 42 | end 43 | 44 | # Validate the auth session to ensure that it is current validate and raise an error if it 45 | # is not suitable for use. 46 | # 47 | # @return [Authie::Session, false] 48 | def validate_auth_session 49 | return false unless logged_in? 50 | 51 | auth_session.validate 52 | end 53 | 54 | # Touch the session to update details on the latest activity. 55 | # 56 | # @return [Authie::Session, false] 57 | def touch_auth_session 58 | yield if block_given? 59 | ensure 60 | auth_session.touch if @touch_auth_session_enabled && logged_in? 61 | end 62 | 63 | # Return the user for the currently logged in user or nil if no user is logged in 64 | # 65 | # @return [ActiveRecord::Base, nil] 66 | def current_user 67 | return nil unless logged_in? 68 | 69 | auth_session.session.user 70 | end 71 | 72 | # Create a new session for the given user. If nil is provided as a user, the existing session 73 | # will be invalidated. 74 | # 75 | # @return [Authie::Session, nil] 76 | def create_auth_session(user, **kwargs) 77 | if user.nil? 78 | invalidate_auth_session 79 | return nil 80 | end 81 | 82 | @auth_session = Authie::Session.start(@controller, user: user, **kwargs) 83 | end 84 | 85 | # Invalidate the existing auth session if one exists. Return true if a sesion has been invalidated 86 | # otherwise return false. 87 | # 88 | # @return [Boolean] 89 | def invalidate_auth_session 90 | return false unless logged_in? 91 | 92 | auth_session.invalidate 93 | @auth_session = nil 94 | true 95 | end 96 | 97 | # Is anyone currently logged in? Return true if there is an auth session present. 98 | # 99 | # Note: this does not check the validatity of the session. You must always ensure that the `validate` 100 | # or `touch` method is invoked to ensure that the session that has been found is active. 101 | # 102 | # @return [Boolean] 103 | def logged_in? 104 | auth_session.is_a?(Session) 105 | end 106 | 107 | # Return an auth session that has been found in the current cookies. 108 | # 109 | # @return [Authie::Session] 110 | def auth_session 111 | return @auth_session if instance_variable_defined?('@auth_session') 112 | 113 | @auth_session = Authie::Session.get_session(@controller) 114 | end 115 | 116 | private 117 | 118 | def cookies 119 | @controller.send(:cookies) 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /lib/authie/controller_extension.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'authie/controller_delegate' 4 | 5 | module Authie 6 | module ControllerExtension 7 | class << self 8 | def included(base) 9 | base.helper_method :logged_in?, :current_user, :auth_session if base.respond_to?(:helper_method) 10 | 11 | base.before_action :set_browser_id, :validate_auth_session 12 | base.around_action :touch_auth_session 13 | 14 | base.delegate :set_browser_id, to: :auth_session_delegate 15 | base.delegate :validate_auth_session, to: :auth_session_delegate 16 | base.delegate :touch_auth_session, to: :auth_session_delegate 17 | base.delegate :current_user, to: :auth_session_delegate 18 | base.delegate :create_auth_session, to: :auth_session_delegate 19 | base.delegate :invalidate_auth_session, to: :auth_session_delegate 20 | base.delegate :logged_in?, to: :auth_session_delegate 21 | base.delegate :auth_session, to: :auth_session_delegate 22 | end 23 | end 24 | 25 | private 26 | 27 | def auth_session_delegate 28 | @auth_session_delegate ||= Authie::ControllerDelegate.new(self) 29 | end 30 | 31 | def skip_touch_auth_session! 32 | auth_session_delegate.touch_auth_session_enabled = false 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/authie/engine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Authie 4 | class Engine < ::Rails::Engine 5 | engine_name 'authie' 6 | 7 | initializer 'authie.initialize' do |_app| 8 | ActiveSupport.on_load :active_record do 9 | require 'authie/session' 10 | end 11 | 12 | ActiveSupport.on_load :action_controller do 13 | require 'authie/controller_extension' 14 | include Authie::ControllerExtension 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/authie/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Authie 4 | class Error < StandardError 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/authie/rack_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # If you're dealing with your authentication in a middleware and you only have 4 | # access to your rack environment, this will wrap around rack and make it look 5 | # close enough to an ActionController to work with Authie 6 | # 7 | # Usage: 8 | # 9 | # controller = Authie::RackController.new(@env) 10 | # controller.current_user = user 11 | 12 | module Authie 13 | class RackController 14 | attr_reader :request 15 | 16 | def initialize(env) 17 | @env = env 18 | @request = ActionDispatch::Request.new(@env) 19 | set_browser_id 20 | end 21 | 22 | def cookies 23 | @request.cookie_jar 24 | end 25 | 26 | # Set a random browser ID for this browser. 27 | def set_browser_id 28 | until cookies[:browser_id] 29 | proposed_browser_id = SecureRandom.uuid 30 | unless SessionModel.where(browser_id: proposed_browser_id).exists? 31 | cookies[:browser_id] = { value: proposed_browser_id, expires: 20.years.from_now } 32 | end 33 | end 34 | end 35 | 36 | def current_user=(user) 37 | Session.start(self, user: user) 38 | end 39 | 40 | def current_user 41 | auth_session.user if auth_session.is_a?(Session) 42 | end 43 | 44 | def auth_session 45 | @auth_session ||= Session.get_session(self) 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/authie/session.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'authie/session_model' 4 | require 'authie/error' 5 | require 'authie/config' 6 | require 'active_support/core_ext/module/delegation' 7 | 8 | module Authie 9 | class Session 10 | # The underlying session model instance 11 | # 12 | # @return [Authie::SessionModel] 13 | attr_reader :session 14 | 15 | # A parent class that encapsulates all session validity errors. 16 | class ValidityError < Error 17 | attr_reader :session 18 | 19 | def initialize(message, session = nil) 20 | super(message) 21 | @session = session 22 | end 23 | end 24 | 25 | # Raised when a session is used but it is no longer active 26 | class InactiveSession < ValidityError; end 27 | 28 | # Raised when a session is used but it has expired 29 | class ExpiredSession < ValidityError; end 30 | 31 | # Raised when a session is used but the browser ID does not match 32 | class BrowserMismatch < ValidityError; end 33 | 34 | # Raised when a session is used but the hostname does not match 35 | # the session hostname 36 | class HostMismatch < ValidityError; end 37 | 38 | # Initialize a new session object 39 | # 40 | # @param controller [ActionController::Base] any controller 41 | # @param session [Authie::SessionModel] an Authie session model instance 42 | # @return [Authie::Session] 43 | def initialize(controller, session) 44 | @controller = controller 45 | @session = session 46 | end 47 | 48 | # Validate that the session is valid and raise and error if not 49 | # 50 | # @raises [Authie::Session::BrowserMismatch] 51 | # @raises [Authie::Session::InactiveSession] 52 | # @raises [Authie::Session::ExpiredSession] 53 | # @raises [Authie::Session::HostMismatch] 54 | # @return [Authie::Session] 55 | def validate 56 | validate_browser_id 57 | validate_active 58 | validate_expiry 59 | validate_inactivity 60 | validate_host 61 | self 62 | end 63 | 64 | # Mark the current session as persistent. Will set the expiry time of the underlying 65 | # session and update the cookie. 66 | # 67 | # @raises [ActiveRecord::RecordInvalid] 68 | # @return [Authie::Session] 69 | def persist 70 | @session.expires_at = Authie.config.persistent_session_length.from_now 71 | @session.save! 72 | set_cookie 73 | self 74 | end 75 | 76 | # Invalidates the current session by marking it inactive and removing the current cookie. 77 | # 78 | # @raises [ActiveRecord::RecordInvalid] 79 | # @return [Authie::Session] 80 | def invalidate 81 | @session.invalidate! 82 | cookies.delete(:user_session) 83 | self 84 | end 85 | 86 | # Touches the current session to ensure it is currently valid and to update attributes 87 | # which should be updatd on each request. This will raise the same errors as the #validate 88 | # method. It will set the last activity time, IP and path as well as incrementing 89 | # the request counter. 90 | # 91 | # @raises [Authie::Session::BrowserMismatch] 92 | # @raises [Authie::Session::InactiveSession] 93 | # @raises [Authie::Session::ExpiredSession] 94 | # @raises [Authie::Session::HostMismatch] 95 | # @raises [ActiveRecord::RecordInvalid] 96 | # @return [Authie::Session] 97 | def touch 98 | @session.last_activity_at = Time.now 99 | if @controller.request.ip != @session.last_activity_ip 100 | @session.last_activity_ip_country = Authie.config.lookup_ip_country(@controller.request.ip) 101 | end 102 | @session.last_activity_ip = @controller.request.ip 103 | 104 | @session.last_activity_path = @controller.request.path 105 | @session.requests += 1 106 | extend_session_expiry_if_appropriate 107 | @session.save! 108 | Authie.notify(:touch, session: self) 109 | self 110 | end 111 | 112 | # Mark the session's password as seen at the current time 113 | # 114 | # @raises [ActiveRecord::RecordInvalid] 115 | # @return [Authie::Session] 116 | def see_password 117 | @session.password_seen_at = Time.now 118 | @session.save! 119 | Authie.notify(:see_password, session: self) 120 | self 121 | end 122 | 123 | # Mark this request as two factored by setting the time and the current 124 | # IP address. 125 | # 126 | # @raises [ActiveRecord::RecordInvalid] 127 | # @return [Authie::Session] 128 | def mark_as_two_factored(skip: nil) 129 | @session.two_factored_at = Time.now 130 | @session.two_factored_ip = @controller.request.ip 131 | @session.two_factored_ip_country = Authie.config.lookup_ip_country(@controller.request.ip) 132 | @session.skip_two_factor = skip unless skip.nil? 133 | @session.save! 134 | Authie.notify(:mark_as_two_factor, session: self) 135 | self 136 | end 137 | 138 | # Starts a new session by setting the cookie. This should be invoked whenever 139 | # a new session begins. It usually does not need to be called directly as it 140 | # will be taken care of by the class-level start method. 141 | # 142 | # @return [Authie::Session] 143 | def start 144 | set_cookie 145 | Authie.notify(:session_start, session: self) 146 | self 147 | end 148 | 149 | # Resets the token for the currently active session to a new string 150 | # 151 | # @return [Authie::Session] 152 | def reset_token 153 | @session.reset_token 154 | set_cookie 155 | self 156 | end 157 | 158 | private 159 | 160 | def set_cookie(value = @session.temporary_token) 161 | cookies[:user_session] = { 162 | value: value, 163 | secure: @controller.request.ssl?, 164 | httponly: true, 165 | expires: @session.expires_at 166 | } 167 | Authie.notify(:cookie_updated, session: session) 168 | true 169 | end 170 | 171 | def cookies 172 | @controller.send(:cookies) 173 | end 174 | 175 | def validate_browser_id 176 | if cookies[:browser_id] != @session.browser_id 177 | Authie.notify(:browser_id_mismatch_error, session: self) 178 | invalidate 179 | raise BrowserMismatch.new('Browser ID mismatch', self) 180 | end 181 | 182 | self 183 | end 184 | 185 | def validate_active 186 | unless @session.active? 187 | invalidate 188 | Authie.notify(:invalid_session_error, session: self) 189 | raise InactiveSession.new('Session is no longer active', self) 190 | end 191 | 192 | self 193 | end 194 | 195 | def validate_expiry 196 | if @session.expired? 197 | invalidate 198 | Authie.notify(:expired_session_error, session: self) 199 | raise ExpiredSession.new('Persistent session has expired', self) 200 | end 201 | 202 | self 203 | end 204 | 205 | def validate_inactivity 206 | if @session.inactive? 207 | invalidate 208 | Authie.notify(:inactive_session_error, session: self) 209 | raise InactiveSession.new('Non-persistent session has expired', self) 210 | end 211 | 212 | self 213 | end 214 | 215 | def validate_host 216 | if @session.host && @session.host != @controller.request.host 217 | invalidate 218 | Authie.notify(:host_mismatch_error, session: self) 219 | raise HostMismatch.new("Session was created on #{@session.host} but accessed using #{@controller.request.host}", 220 | self) 221 | end 222 | 223 | self 224 | end 225 | 226 | def extend_session_expiry_if_appropriate 227 | return if @session.expires_at.nil? 228 | return unless Authie.config.extend_session_expiry_on_touch 229 | 230 | # If enabled, sessions with an expiry time will automatiaclly be incremented 231 | # whenever a page is touched. The cookie will also be updated as appropriate. 232 | @session.expires_at = Authie.config.persistent_session_length.from_now 233 | set_cookie 234 | end 235 | 236 | class << self 237 | # Create a new session within the given controller for the 238 | # 239 | # @param controller [ActionController::Base] 240 | # @param user [ActiveRecord::Base] user 241 | # @param persistent [Boolean] create a persistent session 242 | # @return [Authie::Session] 243 | def start(controller, user:, persistent: false, see_password: false, **params) 244 | cookies = controller.send(:cookies) 245 | SessionModel.active.where(browser_id: cookies[:browser_id]).each(&:invalidate!) 246 | 247 | session = SessionModel.new(params) 248 | session.user = user 249 | session.browser_id = cookies[:browser_id] 250 | session.login_at = Time.now 251 | session.login_ip = controller.request.ip 252 | session.login_ip_country = Authie.config.lookup_ip_country(session.login_ip) 253 | session.host = controller.request.host 254 | session.user_agent = controller.request.user_agent 255 | session.expires_at = Time.now + Authie.config.persistent_session_length if persistent 256 | session.password_seen_at = Time.now if see_password 257 | session.save! 258 | 259 | new(controller, session).start 260 | end 261 | 262 | # Lookup a session for a given controller and return the session 263 | # object. 264 | # 265 | # @param controller [ActionController::Base] 266 | # @return [Authie::Session] 267 | def get_session(controller) 268 | cookies = controller.send(:cookies) 269 | return nil if cookies[:user_session].blank? 270 | 271 | session = SessionModel.find_session_by_token(cookies[:user_session]) 272 | return nil if session.blank? 273 | 274 | session.temporary_token = cookies[:user_session] 275 | new(controller, session) 276 | end 277 | 278 | delegate :hash_token, to: SessionModel 279 | delegate :cleanup, to: SessionModel 280 | end 281 | 282 | # Backwards compatibility with Authie < 4.0. These methods were all available on sessions 283 | # in previous versions of Authie. They have been maintained for backwards-compatibility but 284 | # will be removed entirely in Authie 5.0. 285 | alias check_security! validate 286 | alias persist! persist 287 | alias invalidate! invalidate 288 | alias touch! touch 289 | alias set_cookie! set_cookie 290 | alias see_password! see_password 291 | alias mark_as_two_factored! mark_as_two_factored 292 | 293 | # Delegate key methods back to the underlying session model. Previous behaviour in Authie 294 | # exposed all methods on the session model. It is useful that these methods can be accessed 295 | # easily from this session proxy model so these are maintained as delegated methods. 296 | delegate :active?, to: :session 297 | delegate :browser_id, to: :session 298 | delegate :expired?, to: :session 299 | delegate :expires_at, to: :session 300 | delegate :first_session_for_browser?, to: :session 301 | delegate :first_session_for_ip?, to: :session 302 | delegate :get, to: :session 303 | delegate :inactive?, to: :session 304 | delegate :invalidate_others!, to: :session 305 | delegate :last_activity_at, to: :session 306 | delegate :last_activity_ip, to: :session 307 | delegate :last_activity_ip_country, to: :session 308 | delegate :last_activity_path, to: :session 309 | delegate :login_at, to: :session 310 | delegate :login_ip, to: :session 311 | delegate :login_ip_country, to: :session 312 | delegate :password_seen_at, to: :session 313 | delegate :persisted?, to: :session 314 | delegate :persistent?, to: :session 315 | delegate :recently_seen_password?, to: :session 316 | delegate :requests, to: :session 317 | delegate :set, to: :session 318 | delegate :temporary_token, to: :session 319 | delegate :token_hash, to: :session 320 | delegate :two_factored_at, to: :session 321 | delegate :two_factored_ip, to: :session 322 | delegate :two_factored_ip_country, to: :session 323 | delegate :two_factored?, to: :session 324 | delegate :skip_two_factor?, to: :session 325 | delegate :update, to: :session 326 | delegate :update!, to: :session 327 | delegate :user_agent, to: :session 328 | delegate :user, to: :session 329 | end 330 | end 331 | -------------------------------------------------------------------------------- /lib/authie/session_model.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_record' 4 | require 'securerandom' 5 | require 'authie/config' 6 | 7 | module Authie 8 | class SessionModel < ActiveRecord::Base 9 | attr_accessor :temporary_token 10 | 11 | self.table_name = 'authie_sessions' 12 | 13 | belongs_to :parent, class_name: 'Authie::SessionModel', optional: true 14 | 15 | scope :active, -> { where(active: true) } 16 | scope :asc, -> { order(last_activity_at: :desc) } 17 | scope :for_user, ->(user) { where(user_type: user.class.name, user_id: user.id) } 18 | 19 | # Attributes 20 | serialize :data, type: Hash, coder: Authie.config.serialize_coder 21 | 22 | before_validation :shorten_strings 23 | before_create :set_new_token 24 | 25 | # Return the user that 26 | def user 27 | return unless user_id && user_type 28 | return @user if instance_variable_defined?('@user') 29 | 30 | @user = user_type.constantize.find_by(id: user_id) 31 | end 32 | 33 | # Set the user 34 | def user=(user) 35 | @user = user 36 | if user 37 | self.user_type = user.class.name 38 | self.user_id = user.id 39 | else 40 | self.user_type = nil 41 | self.user_id = nil 42 | end 43 | end 44 | 45 | def expired? 46 | expires_at.present? && 47 | expires_at < Time.now 48 | end 49 | 50 | def inactive? 51 | expires_at.nil? && 52 | last_activity_at.present? && 53 | last_activity_at < Authie.config.session_inactivity_timeout.ago 54 | end 55 | 56 | def persistent? 57 | !!expires_at 58 | end 59 | 60 | def activate! 61 | self.active = true 62 | save! 63 | end 64 | 65 | def invalidate! 66 | active_now = active? 67 | self.active = false 68 | save! 69 | Authie.notify(:session_invalidate, session: self) if active_now 70 | true 71 | end 72 | 73 | def set(key, value) 74 | self.data ||= {} 75 | self.data[key.to_s] = value 76 | save! 77 | end 78 | 79 | def get(key) 80 | (self.data ||= {})[key.to_s] 81 | end 82 | 83 | def invalidate_others! 84 | self.class.where('id != ?', id).active.for_user(user).each(&:invalidate!) 85 | end 86 | 87 | # Have we seen the user's password recently in this sesion? 88 | def recently_seen_password? 89 | !!(password_seen_at && password_seen_at >= Authie.config.sudo_session_timeout.ago) 90 | end 91 | 92 | # Is two factor authentication required for this request? 93 | def two_factored? 94 | !!(two_factored_at || parent_id) 95 | end 96 | 97 | # Is this the first session for this session's browser? 98 | def first_session_for_browser? 99 | self.class.where('id < ?', id).for_user(user).where(browser_id: browser_id).empty? 100 | end 101 | 102 | # Is this the first session for the IP? 103 | def first_session_for_ip? 104 | self.class.where('id < ?', id).for_user(user).where(login_ip: login_ip).empty? 105 | end 106 | 107 | # Reset a new token for the session and return the new token 108 | # 109 | # @return [String] 110 | def reset_token 111 | set_new_token 112 | save! 113 | temporary_token 114 | end 115 | 116 | private 117 | 118 | def shorten_strings 119 | self.user_agent = user_agent[0, 255] if user_agent.is_a?(String) 120 | self.last_activity_path = last_activity_path[0, 255] if last_activity_path.is_a?(String) 121 | end 122 | 123 | def set_new_token 124 | self.temporary_token = SecureRandom.alphanumeric(Authie.config.session_token_length) 125 | self.token_hash = self.class.hash_token(temporary_token) 126 | end 127 | 128 | class << self 129 | # Find a session from the database for the given controller instance. 130 | # Returns a session object or :none if no session is found. 131 | 132 | # Find a session by a token (either from a hash or from the raw token) 133 | def find_session_by_token(token) 134 | return nil if token.blank? 135 | 136 | active.where(token_hash: hash_token(token)).first 137 | end 138 | 139 | # Cleanup any old sessions. 140 | def cleanup 141 | Authie.notify(:cleanup) do 142 | # Invalidate transient sessions that haven't been used 143 | active.where('expires_at IS NULL AND last_activity_at < ?', 144 | Authie.config.session_inactivity_timeout.ago).each(&:invalidate!) 145 | # Invalidate persistent sessions that have expired 146 | active.where('expires_at IS NOT NULL AND expires_at < ?', Time.now).each(&:invalidate!) 147 | end 148 | true 149 | end 150 | 151 | # Return a hash of a given token 152 | def hash_token(token) 153 | Digest::SHA256.hexdigest(token) 154 | end 155 | end 156 | end 157 | end 158 | -------------------------------------------------------------------------------- /lib/authie/user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Authie 4 | module User 5 | def self.included(base) 6 | base.has_many :user_sessions, class_name: 'Authie::SessionModel', as: :user, dependent: :delete_all 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/authie/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Authie 4 | VERSION_FILE_ROOT = File.expand_path('../../VERSION', __dir__) 5 | VERSION = if File.file?(VERSION_FILE_ROOT) 6 | File.read(VERSION_FILE_ROOT).strip.sub(/\Av/, '') 7 | else 8 | '0.0.0.dev' 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "bootstrap-sha": "23c79175d75dc85cf0b059d9b33d8da8928e0384", 3 | "packages": { 4 | ".": { 5 | "release-type": "ruby", 6 | "changelog-path": "CHANGELOG.md", 7 | "bump-minor-pre-major": true, 8 | "bump-patch-for-minor-pre-major": true, 9 | "draft": false, 10 | "prerelease": false, 11 | "changelog-sections": [ 12 | { 13 | "type": "feat", 14 | "section": "Features" 15 | }, 16 | { 17 | "type": "feature", 18 | "section": "Features" 19 | }, 20 | { 21 | "type": "fix", 22 | "section": "Bug Fixes" 23 | }, 24 | { 25 | "type": "perf", 26 | "section": "Performance Improvements" 27 | }, 28 | { 29 | "type": "revert", 30 | "section": "Reverts" 31 | }, 32 | { 33 | "type": "docs", 34 | "section": "Documentation" 35 | }, 36 | { 37 | "type": "style", 38 | "section": "Styles" 39 | }, 40 | { 41 | "type": "chore", 42 | "section": "Miscellaneous Chores" 43 | }, 44 | { 45 | "type": "refactor", 46 | "section": "Code Refactoring" 47 | }, 48 | { 49 | "type": "test", 50 | "section": "Tests" 51 | }, 52 | { 53 | "type": "build", 54 | "section": "Build System" 55 | }, 56 | { 57 | "type": "ci", 58 | "section": "Continuous Integration" 59 | } 60 | ] 61 | } 62 | }, 63 | "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json" 64 | } 65 | -------------------------------------------------------------------------------- /spec/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Add your own tasks in files placed in lib/tasks ending in .rake, 4 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 5 | 6 | require_relative 'config/application' 7 | 8 | Rails.application.load_tasks 9 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationController < ActionController::Base 4 | end 5 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/pages_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class PagesController < ApplicationController 4 | def index 5 | render plain: 'Hello world!' 6 | end 7 | 8 | def authenticated 9 | render plain: "Hello #{current_user.username}!" 10 | end 11 | 12 | def request_count 13 | render plain: "Count: #{auth_session.requests}" 14 | end 15 | 16 | def logged_in 17 | if logged_in? 18 | render plain: 'Logged in' 19 | else 20 | render plain: 'Not logged in' 21 | end 22 | end 23 | 24 | def error 25 | 1 / 0 26 | end 27 | 28 | def no_touching 29 | skip_touch_auth_session! 30 | render plain: 'Blah' 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/dummy/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | APP_PATH = File.expand_path('../config/application', __dir__) 5 | require_relative '../config/boot' 6 | require 'rails/commands' 7 | -------------------------------------------------------------------------------- /spec/dummy/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require_relative '../config/boot' 5 | require 'rake' 6 | Rake.application.run 7 | -------------------------------------------------------------------------------- /spec/dummy/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'fileutils' 5 | 6 | # path to your application root. 7 | APP_ROOT = File.expand_path('..', __dir__) 8 | 9 | def system!(*args) 10 | system(*args) || abort("\n== Command #{args} failed ==") 11 | end 12 | 13 | FileUtils.chdir APP_ROOT do 14 | # This script is a way to set up or update your development environment automatically. 15 | # This script is idempotent, so that you can run it at any time and get an expectable outcome. 16 | # Add necessary setup steps to this file. 17 | 18 | puts '== Installing dependencies ==' 19 | system! 'gem install bundler --conservative' 20 | system('bundle check') || system!('bundle install') 21 | 22 | # puts "\n== Copying sample files ==" 23 | # unless File.exist?("config/database.yml") 24 | # FileUtils.cp "config/database.yml.sample", "config/database.yml" 25 | # end 26 | 27 | puts "\n== Preparing database ==" 28 | system! 'bin/rails db:prepare' 29 | 30 | puts "\n== Removing old logs and tempfiles ==" 31 | system! 'bin/rails log:clear tmp:clear' 32 | 33 | puts "\n== Restarting application server ==" 34 | system! 'bin/rails restart' 35 | end 36 | -------------------------------------------------------------------------------- /spec/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file is used by Rack-based servers to start the application. 4 | 5 | require_relative 'config/environment' 6 | 7 | run Rails.application 8 | Rails.application.load_server 9 | -------------------------------------------------------------------------------- /spec/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'boot' 4 | 5 | require 'action_controller/railtie' 6 | 7 | # Require the gems listed in Gemfile, including any gems 8 | # you've limited to :test, :development, or :production. 9 | Bundler.require(*Rails.groups) 10 | require 'authie/engine' 11 | 12 | module Dummy 13 | class Application < Rails::Application 14 | config.load_defaults Rails::VERSION::STRING.to_f 15 | 16 | # For compatibility with applications that use this config 17 | config.action_controller.include_all_helpers = false 18 | 19 | # Configuration for the application, engines, and railties goes here. 20 | # 21 | # These settings can be overridden in specific environments using the files 22 | # in config/environments, which are processed later. 23 | # 24 | # config.time_zone = "Central Time (US & Canada)" 25 | # config.eager_load_paths << Rails.root.join("extras") 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Set up gems listed in the Gemfile. 4 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../../Gemfile', __dir__) 5 | 6 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) 7 | $LOAD_PATH.unshift File.expand_path('../../../lib', __dir__) 8 | -------------------------------------------------------------------------------- /spec/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite. Versions 3.8.0 and up are supported. 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem "sqlite3" 6 | # 7 | default: &default 8 | adapter: sqlite3 9 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 10 | timeout: 5000 11 | 12 | development: 13 | <<: *default 14 | database: ':memory:' 15 | 16 | # Warning: The database defined as "test" will be erased and 17 | # re-generated from your development database when you run "rake". 18 | # Do not set this db to the same as development or production. 19 | test: 20 | <<: *default 21 | database: ':memory:' 22 | 23 | production: 24 | <<: *default 25 | database: ':memory:' 26 | -------------------------------------------------------------------------------- /spec/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Load the Rails application. 4 | require_relative 'application' 5 | 6 | # Initialize the Rails application. 7 | Rails.application.initialize! 8 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/core_ext/integer/time' 4 | 5 | # The test environment is used exclusively to run your application's 6 | # test suite. You never need to work with it otherwise. Remember that 7 | # your test database is "scratch space" for the test suite and is wiped 8 | # and recreated between test runs. Don't rely on the data there! 9 | 10 | Rails.application.configure do 11 | # Settings specified here will take precedence over those in config/application.rb. 12 | 13 | # Turn false under Spring and add config.action_view.cache_template_loading = true. 14 | config.cache_classes = true 15 | 16 | # Eager loading loads your whole application. When running a single test locally, 17 | # this probably isn't necessary. It's a good idea to do in a continuous integration 18 | # system, or in some way before deploying your code. 19 | config.eager_load = true 20 | 21 | # Configure public file server for tests with Cache-Control for performance. 22 | config.public_file_server.enabled = true 23 | config.public_file_server.headers = { 24 | 'Cache-Control' => "public, max-age=#{1.hour.to_i}" 25 | } 26 | 27 | # Show full error reports and disable caching. 28 | config.consider_all_requests_local = true 29 | config.action_controller.perform_caching = false 30 | config.cache_store = :null_store 31 | 32 | # Raise exceptions instead of rendering exception templates. 33 | config.action_dispatch.show_exceptions = false 34 | 35 | # Disable request forgery protection in test environment. 36 | config.action_controller.allow_forgery_protection = false 37 | 38 | # Store uploaded files on the local file system in a temporary directory. 39 | # config.active_storage.service = :test 40 | 41 | # config.action_mailer.perform_caching = false 42 | 43 | # Tell Action Mailer not to deliver emails to the real world. 44 | # The :test delivery method accumulates sent emails in the 45 | # ActionMailer::Base.deliveries array. 46 | # config.action_mailer.delivery_method = :test 47 | 48 | # Print deprecation notices to the stderr. 49 | config.active_support.deprecation = :stderr 50 | 51 | # Raise exceptions for disallowed deprecations. 52 | config.active_support.disallowed_deprecation = :raise 53 | 54 | # Tell Active Support which deprecation messages to disallow. 55 | config.active_support.disallowed_deprecation_warnings = [] 56 | 57 | # Raises error for missing translations. 58 | # config.i18n.raise_on_missing_translations = true 59 | 60 | # Annotate rendered view with file names. 61 | # config.action_view.annotate_rendered_view_with_filenames = true 62 | end 63 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Configure parameters to be filtered from the log file. Use this to limit dissemination of 6 | # sensitive information. See the ActiveSupport::ParameterFilter documentation for supported 7 | # notations and behaviors. 8 | Rails.application.config.filter_parameters += %i[ 9 | passw secret token _key crypt salt certificate otp ssn 10 | ] 11 | -------------------------------------------------------------------------------- /spec/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.routes.draw do 4 | get '/', to: 'pages#index' 5 | get '/authenticated', to: 'pages#authenticated' 6 | get '/logged_in', to: 'pages#logged_in' 7 | get '/request_count', to: 'pages#request_count' 8 | get '/error', to: 'pages#error' 9 | get '/no_touching', to: 'pages#no_touching' 10 | end 11 | -------------------------------------------------------------------------------- /spec/dummy/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

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

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /spec/dummy/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

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

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /spec/dummy/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /spec/dummy/public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcooke/authie/f3bc654865dc580eda802fa4944a9e2816832142/spec/dummy/public/apple-touch-icon-precomposed.png -------------------------------------------------------------------------------- /spec/dummy/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcooke/authie/f3bc654865dc580eda802fa4944a9e2816832142/spec/dummy/public/apple-touch-icon.png -------------------------------------------------------------------------------- /spec/dummy/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcooke/authie/f3bc654865dc580eda802fa4944a9e2816832142/spec/dummy/public/favicon.ico -------------------------------------------------------------------------------- /spec/integration/controller_extension_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe PagesController, type: :controller do 6 | it 'sets a browser ID is set on every page request' do 7 | get :index 8 | expect(cookies[:browser_id]).to match(/\A[a-f0-9-]{36}\z/) 9 | expect(response.body).to eq 'Hello world!' 10 | end 11 | 12 | it 'can access the current user' do 13 | setup_session 14 | get :authenticated 15 | expect(response.body).to eq 'Hello adam!' 16 | end 17 | 18 | it 'can check logged in state' do 19 | get :logged_in 20 | expect(response.body).to eq 'Not logged in' 21 | end 22 | 23 | it 'can check logged in state' do 24 | setup_session 25 | get :logged_in 26 | expect(response.body).to eq 'Logged in' 27 | end 28 | 29 | it 'touches the session on each page request' do 30 | setup_session 31 | 3.times do |i| 32 | get :request_count 33 | expect(response.body).to eq "Count: #{i}" 34 | end 35 | end 36 | 37 | it 'does not touch sessions were touching has been disabled' do 38 | session = setup_session 39 | Timecop.freeze(session.login_at) { get :index } 40 | Timecop.freeze(session.login_at + 10.minutes) { get :no_touching } 41 | session.reload 42 | expect(session.last_activity_path).to eq '/' 43 | expect(session.last_activity_at).to eq session.login_at 44 | end 45 | 46 | it 'touches the session even if there is an error' do 47 | session = setup_session 48 | time = Time.new(2022, 2, 4, 2, 11) 49 | Timecop.freeze(time) do 50 | expect { get :error }.to raise_error ZeroDivisionError 51 | end 52 | session.reload 53 | expect(session.last_activity_path).to eq '/error' 54 | expect(session.last_activity_at).to eq time 55 | end 56 | 57 | it 'raises an error if the browser ID mismatches' do 58 | setup_session { |s| s.browser_id = 'abc' } 59 | expect { get(:authenticated) }.to raise_error Authie::Session::BrowserMismatch 60 | end 61 | 62 | it 'raises an error if the session has expired' do 63 | setup_session { |s| s.expires_at = 2.week.ago } 64 | expect { get(:authenticated) }.to raise_error Authie::Session::ExpiredSession 65 | end 66 | 67 | it 'raises an error if the session has become inactive' do 68 | setup_session { |s| s.last_activity_at = 2.week.ago } 69 | expect { get(:authenticated) }.to raise_error Authie::Session::InactiveSession 70 | end 71 | 72 | it 'raises an error if the host is not the same' do 73 | setup_session { |s| s.host = 'example.com' } 74 | expect { get(:authenticated) }.to raise_error Authie::Session::HostMismatch 75 | end 76 | 77 | def setup_session 78 | browser_id = SecureRandom.uuid 79 | user = User.create!(username: 'adam') 80 | session = Authie::SessionModel.create!(user: user, 81 | login_at: Time.current, 82 | login_ip: '1.2.3.4', 83 | browser_id: browser_id, 84 | active: true) 85 | if block_given? 86 | yield(session) 87 | session.save! 88 | end 89 | cookies[:browser_id] = browser_id 90 | cookies[:user_session] = session.temporary_token 91 | session 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /spec/lib/config_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'authie/config' 5 | 6 | RSpec.describe Authie::Config do 7 | subject(:config) { described_class.new } 8 | 9 | after do 10 | Authie.instance_variable_set('@config', nil) 11 | end 12 | 13 | describe Authie do 14 | describe '.config' do 15 | it 'returns a config instance' do 16 | expect(described_class.config).to be_a Authie::Config 17 | end 18 | end 19 | 20 | describe '.configure' do 21 | it 'yields a block with the configuration object' do 22 | described_class.configure { |c| c.session_inactivity_timeout = 5.minutes } 23 | expect(described_class.config.session_inactivity_timeout).to eq 5.minutes 24 | end 25 | end 26 | end 27 | 28 | describe '#session_inactivity_timeout' do 29 | it 'returns the default value' do 30 | expect(config.session_inactivity_timeout).to eq 12.hours 31 | end 32 | 33 | it 'returns an overriden value' do 34 | config.session_inactivity_timeout = 24.hours 35 | expect(config.session_inactivity_timeout).to eq 24.hours 36 | end 37 | end 38 | 39 | describe '#persistent_session_length' do 40 | it 'returns the default value' do 41 | expect(config.persistent_session_length).to eq 2.months 42 | end 43 | 44 | it 'returns an overriden value' do 45 | config.persistent_session_length = 12.months 46 | expect(config.persistent_session_length).to eq 12.months 47 | end 48 | end 49 | 50 | describe '#sudo_session_timeout' do 51 | it 'returns the default value' do 52 | expect(config.sudo_session_timeout).to eq 10.minutes 53 | end 54 | 55 | it 'returns an overriden value' do 56 | config.sudo_session_timeout = 1.hour 57 | expect(config.sudo_session_timeout).to eq 1.hour 58 | end 59 | end 60 | 61 | describe '#browser_id_cookie_name' do 62 | it 'returns the default value' do 63 | expect(config.browser_id_cookie_name).to eq :browser_id 64 | end 65 | 66 | it 'returns an overriden value' do 67 | config.browser_id_cookie_name = :auth_browser_id 68 | expect(config.browser_id_cookie_name).to eq :auth_browser_id 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /spec/lib/controller_delegate_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'authie/controller_delegate' 5 | require 'action_controller' 6 | require 'action_controller/test_case' 7 | 8 | RSpec.describe Authie::ControllerDelegate do 9 | subject(:controller) { make_controller } 10 | subject(:delegate) { described_class.new(controller) } 11 | 12 | context 'when a user is logged in' do 13 | subject(:user) { User.create!(username: 'adam') } 14 | before { delegate.create_auth_session(user) } 15 | 16 | describe '#current_user' do 17 | it 'returns the current user' do 18 | expect(delegate.current_user).to eq user 19 | end 20 | end 21 | 22 | describe '#logged_in' do 23 | it 'returns true' do 24 | expect(delegate.logged_in?).to be true 25 | end 26 | end 27 | 28 | describe '#invalidate_auth_session' do 29 | it 'retuns true' do 30 | expect(delegate.invalidate_auth_session).to be true 31 | end 32 | 33 | it 'invalidates the session' do 34 | expect(delegate.auth_session).to receive(:invalidate).and_call_original 35 | delegate.invalidate_auth_session 36 | end 37 | 38 | it 'removes the cached session immediately' do 39 | expect(delegate.instance_variable_get('@auth_session')).to be_a Authie::Session 40 | delegate.invalidate_auth_session 41 | expect(delegate.instance_variable_get('@auth_session')).to be nil 42 | end 43 | end 44 | 45 | describe '#touch_auth_session' do 46 | it 'will call the touch method on the underlying' do 47 | expect(delegate.auth_session).to receive(:touch) 48 | delegate.touch_auth_session 49 | end 50 | 51 | it 'will call a block before running touch' do 52 | count = 0 53 | delegate.touch_auth_session { count += 1 } 54 | expect(count).to eq 1 55 | end 56 | 57 | it 'will return the return value of the executed block' do 58 | expect(delegate.touch_auth_session { 1234 }).to eq 1234 59 | end 60 | 61 | it 'will not touch the session if disabled' do 62 | delegate.touch_auth_session_enabled = false 63 | expect(delegate.auth_session).to_not receive(:touch) 64 | delegate.touch_auth_session 65 | end 66 | end 67 | end 68 | 69 | context 'when a user is not logged in' do 70 | describe '#current_user' do 71 | it 'returns nil if nobody is logged in' do 72 | expect(delegate.current_user).to be nil 73 | end 74 | end 75 | 76 | describe '#logged_in' do 77 | it 'returns false' do 78 | expect(delegate.logged_in?).to be false 79 | end 80 | end 81 | 82 | describe '#invalidate_auth_session' do 83 | it 'does nothing' do 84 | expect(delegate.invalidate_auth_session).to be false 85 | end 86 | end 87 | 88 | describe '#touch_auth_session' do 89 | it 'will execute the block and return the value' do 90 | expect(delegate.touch_auth_session { 'abcdef' }).to eq 'abcdef' 91 | end 92 | end 93 | end 94 | 95 | describe '#set_browser_id' do 96 | subject(:set_cookies) { controller.send(:cookies).instance_variable_get('@set_cookies') } 97 | 98 | it 'sets a unique browser ID into the cookie' do 99 | new_browser_id = delegate.set_browser_id 100 | expect(new_browser_id).to match(/\A[a-f0-9-]{36}\z/) 101 | expect(controller.send(:cookies)[:browser_id]).to eq new_browser_id 102 | end 103 | 104 | it 'sets the cookie as httponly' do 105 | delegate.set_browser_id 106 | expect(set_cookies['browser_id'][:httponly]).to be true 107 | end 108 | 109 | it 'sets the cookie as secure if the request is SSL' do 110 | allow(controller.request).to receive(:ssl?).and_return true 111 | delegate.set_browser_id 112 | expect(set_cookies['browser_id'][:secure]).to be true 113 | end 114 | 115 | it 'sets the cookie with a long expiry time' do 116 | time = Time.new(2022, 3, 2, 20, 22, 33) 117 | Timecop.freeze(time) { delegate.set_browser_id } 118 | expect(set_cookies['browser_id'][:expires]).to eq time + 5.years 119 | end 120 | 121 | it 'does not use brower IDs that already exist' do 122 | existing_session = Authie::SessionModel.create!(browser_id: SecureRandom.uuid) 123 | allow(SecureRandom).to receive(:uuid).and_return(existing_session.browser_id, SecureRandom.uuid) 124 | expect(delegate.set_browser_id).to_not eq existing_session.browser_id 125 | end 126 | 127 | it 'dispatches an event' do 128 | expect(Authie).to receive(:notify).with(:set_browser_id, hash_including(browser_id: /\A[a-f0-9-]{36}\z/)) 129 | delegate.set_browser_id 130 | end 131 | end 132 | 133 | describe '#auth_session' do 134 | it 'returns the value from the Authie::Session.get_session method' do 135 | allow(Authie::Session).to receive(:get_session).and_return(1234) 136 | expect(delegate.auth_session).to eq 1234 137 | end 138 | 139 | it 'retuns a cached value if one exists' do 140 | delegate.instance_variable_set('@auth_session', 9876) 141 | expect(delegate.auth_session).to eq 9876 142 | end 143 | end 144 | 145 | describe '#create_auth_session' do 146 | context 'when a user is provided' do 147 | it 'creates a new auth session' do 148 | user = User.create!(username: 'adam') 149 | session = delegate.create_auth_session(user) 150 | expect(session).to be_a Authie::Session 151 | expect(session.user).to eq user 152 | expect(controller.send(:cookies)[:user_session]).to eq session.temporary_token 153 | end 154 | 155 | it 'can receive other options for the session too' do 156 | expiry_time = 6.months.from_now 157 | user = User.create!(username: 'adam') 158 | session = delegate.create_auth_session(user, expires_at: expiry_time) 159 | expect(session).to be_a Authie::Session 160 | expect(session.persistent?).to be true 161 | expect(session.expires_at).to eq expiry_time 162 | end 163 | end 164 | 165 | context 'when nil is provided' do 166 | context 'when no user is logged in' do 167 | it 'will return none' do 168 | expect(delegate.create_auth_session(nil)).to eq nil 169 | end 170 | end 171 | 172 | context 'when a user is logged in' do 173 | it 'will invalidate their existing session' do 174 | allow(delegate).to receive(:logged_in?).and_return(true) 175 | expect(delegate.auth_session).to receive(:invalidate).and_return(true) 176 | expect(delegate.create_auth_session(nil)).to be nil 177 | end 178 | end 179 | end 180 | end 181 | end 182 | -------------------------------------------------------------------------------- /spec/lib/session_model_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'authie/session_model' 5 | 6 | RSpec.describe Authie::SessionModel do 7 | subject(:user) { User.create!(username: 'adam') } 8 | subject(:session_model) { described_class.new(user: user) } 9 | 10 | context '#on creation' do 11 | it 'limits the size of user agents to 255 characters' do 12 | session_model.user_agent = 'A' * 500 13 | session_model.save! 14 | expect(session_model.user_agent.size).to eq 255 15 | end 16 | 17 | it 'limits the size of last activity paths to 255 characters' do 18 | session_model.last_activity_path = 'A' * 500 19 | session_model.save! 20 | expect(session_model.last_activity_path.size).to eq 255 21 | end 22 | it 'generates a new token' do 23 | session_model.save! 24 | expect(session_model.temporary_token).to be_a String 25 | expect(session_model.temporary_token).to match(/\A[A-Za-z0-9]{64}\z/) 26 | end 27 | 28 | it 'stores the newly generated token as a SHA256 hash' do 29 | session_model.save! 30 | expect(session_model.token_hash).to be_a String 31 | expect(session_model.token_hash).to match(/\A[a-f0-9]{64}\z/) 32 | expect(session_model.token_hash).to eq Digest::SHA256.hexdigest(session_model.temporary_token) 33 | end 34 | end 35 | 36 | context '#user' do 37 | it 'returns the user object' do 38 | expect(session_model.user).to eq user 39 | end 40 | 41 | it 'looks up the user object from the database' do 42 | session_model.save! 43 | new_session_model = described_class.find_by(id: session_model.id) 44 | expect(new_session_model.user).to eq user 45 | end 46 | 47 | it 'returns nil if user_id is nil' do 48 | session_model.user_id = nil 49 | expect(session_model.user).to be nil 50 | end 51 | 52 | it 'returns nil if user_type is nil' do 53 | session_model.user_type = nil 54 | expect(session_model.user).to be nil 55 | end 56 | end 57 | 58 | context '#user=' do 59 | it 'sets the user type and ID' do 60 | user = User.create!(username: 'other') 61 | session_model.user = user 62 | expect(session_model.user_type).to eq 'User' 63 | expect(session_model.user_id).to eq user.id 64 | end 65 | 66 | it 'caches the value in an instance variable' do 67 | user = User.create!(username: 'other') 68 | session_model.user = user 69 | expect(session_model.instance_variable_get('@user')).to eq user 70 | end 71 | 72 | it 'sets the user type and ID to nil if nil is provided' do 73 | session_model.user = nil 74 | expect(session_model.user_type).to be nil 75 | expect(session_model.user_id).to be nil 76 | expect(session_model.instance_variable_get('@user')).to be nil 77 | end 78 | end 79 | 80 | context '#expired?' do 81 | it 'returns false if there is no expiry time' do 82 | expect(session_model.expired?).to be false 83 | end 84 | 85 | it 'returns false if the expiry time is in the future' do 86 | session_model.expires_at = 2.hours.from_now 87 | expect(session_model.expired?).to be false 88 | end 89 | 90 | it 'returns true if the expiry time is in the past' do 91 | session_model.expires_at = 2.hours.ago 92 | expect(session_model.expired?).to be true 93 | end 94 | end 95 | 96 | context '#inactive?' do 97 | it 'returns false if there is an expiry time given' do 98 | session_model.expires_at = 2.hours.from_now 99 | expect(session_model.inactive?).to be false 100 | end 101 | 102 | it 'returns false if there is no last activity time' do 103 | expect(session_model.inactive?).to be false 104 | end 105 | 106 | it 'returns false if the last activity time is within the inactivity timeout' do 107 | session_model.last_activity_at = 2.hours.ago 108 | expect(session_model.inactive?).to be false 109 | end 110 | 111 | it 'returns true if the last activity time is more than the inactivity timeout' do 112 | session_model.last_activity_at = 13.hours.ago 113 | expect(session_model.inactive?).to be true 114 | end 115 | end 116 | 117 | context '#persistent?' do 118 | it 'returns true if there is an expiry date' do 119 | session_model.expires_at = 2.hours.from_now 120 | expect(session_model.persistent?).to be true 121 | end 122 | 123 | it 'returns false if there is no expiry date' do 124 | expect(session_model.persistent?).to be false 125 | end 126 | end 127 | 128 | context '#activate!' do 129 | it 'sets the active boolean to true' do 130 | session_model.active = false 131 | session_model.activate! 132 | expect(session_model.active).to be true 133 | end 134 | end 135 | 136 | context '#invalidate!' do 137 | it 'sets the active boolean to false' do 138 | session_model.active = true 139 | session_model.invalidate! 140 | expect(session_model.active).to be false 141 | end 142 | 143 | it 'sends a notification' do 144 | expect(Authie).to receive(:notify).with(:session_invalidate, session: session_model) 145 | session_model.invalidate! 146 | end 147 | 148 | it 'does not send a notification if called on a session that is already not active' do 149 | session_model.active = false 150 | expect(Authie).to_not receive(:notify) 151 | session_model.invalidate! 152 | end 153 | end 154 | 155 | context '#set' do 156 | it 'sets the given value in the data hash' do 157 | session_model.set('hello', 'world') 158 | expect(session_model.data['hello']).to eq 'world' 159 | end 160 | 161 | it 'converts symbols to strings in keys' do 162 | session_model.set(:hello, 'world') 163 | expect(session_model.data['hello']).to eq 'world' 164 | end 165 | 166 | it 'stores values as YAML in the data column' do 167 | session_model.set(:hello, 'world') 168 | expect(session_model.read_attribute_before_type_cast('data')).to eq "---\nhello: world\n" 169 | end 170 | 171 | it 'stores values as JSON if configured to do so' do 172 | Authie.config.serialize_coder = JSON 173 | # Reload the class because the model was already loaded 174 | load File.expand_path('../../lib/authie/session_model.rb', __dir__) 175 | session_model.set(:hello, 'world') 176 | expect(session_model.read_attribute_before_type_cast('data')).to eq '{"hello":"world"}' 177 | ensure 178 | Authie.config.serialize_coder = nil 179 | load File.expand_path('../../lib/authie/session_model.rb', __dir__) 180 | end 181 | end 182 | 183 | context '#get' do 184 | it 'reads a value from the data hash' do 185 | session_model.data = { 'hello' => 'world' } 186 | expect(session_model.get('hello')).to eq 'world' 187 | end 188 | 189 | it 'works with symbols for keys' do 190 | session_model.data = { 'hello' => 'world' } 191 | expect(session_model.get(:hello)).to eq 'world' 192 | end 193 | end 194 | 195 | context '#invalidate_others!' do 196 | before do 197 | @other_session1 = described_class.create!(active: true, user: user) 198 | @other_session2 = described_class.create!(active: true, user: user) 199 | session_model.save! 200 | end 201 | 202 | it 'marks all other sessions for the same user as inaactive' do 203 | session_model.invalidate_others! 204 | @other_session1.reload 205 | @other_session2.reload 206 | expect(@other_session1.active?).to be false 207 | expect(@other_session2.active?).to be false 208 | end 209 | 210 | it 'does not mark the current session as inactive' do 211 | session_model.invalidate_others! 212 | session_model.reload 213 | @other_session1.reload 214 | @other_session2.reload 215 | expect(@other_session1.active?).to be false 216 | expect(@other_session2.active?).to be false 217 | expect(session_model.active?).to be true 218 | end 219 | 220 | it 'does not invalidate sessions that are not active' do 221 | described_class.create!(active: false, user: user) 222 | sessions = session_model.invalidate_others! 223 | expect(sessions).to eq [@other_session1, @other_session2] 224 | end 225 | 226 | it 'does not mark sessions for other users as inactive' do 227 | other_user_session = described_class.create!(active: true, user: User.create!(username: 'bob')) 228 | session_model.invalidate_others! 229 | @other_session1.reload 230 | @other_session2.reload 231 | other_user_session.reload 232 | expect(@other_session1.active?).to be false 233 | expect(@other_session2.active?).to be false 234 | expect(other_user_session.active?).to be true 235 | end 236 | end 237 | 238 | context '#recently_seen_password?' do 239 | it 'returns true if we have seen the password within the sudo timeout' do 240 | session_model.password_seen_at = 5.minutes.ago 241 | expect(session_model.recently_seen_password?).to be true 242 | end 243 | 244 | it 'returns false if we have never seen the password' do 245 | expect(session_model.recently_seen_password?).to be false 246 | end 247 | 248 | it 'returns false if we last saw a password more than than sudo timeout' do 249 | session_model.password_seen_at = 15.minutes.ago 250 | expect(session_model.recently_seen_password?).to be false 251 | end 252 | end 253 | 254 | context '#two_factored?' do 255 | it 'returns true if there is a two factored time stamp' do 256 | session_model.two_factored_at = 15.minutes.ago 257 | expect(session_model.two_factored?).to be true 258 | end 259 | 260 | it 'returns false if there is no factored time stamp' do 261 | expect(session_model.two_factored?).to be false 262 | end 263 | end 264 | 265 | context '#first_session_for_browser?' do 266 | it 'returns true if there are no other sessions for the browser ID created before this one' do 267 | session_model.browser_id = SecureRandom.uuid 268 | session_model.save! 269 | expect(session_model.first_session_for_browser?).to be true 270 | end 271 | 272 | it 'returns false if there is another session for the browser ID before this one' do 273 | id = SecureRandom.uuid 274 | described_class.create!(user: user, active: true, browser_id: id) 275 | session_model.browser_id = id 276 | session_model.save! 277 | expect(session_model.first_session_for_browser?).to be false 278 | end 279 | end 280 | 281 | context '#first_session_for_ip?' do 282 | it 'returns true if there are no other sessions for the login IP address created before this one' do 283 | session_model.login_ip = '2.2.2.2' 284 | session_model.save! 285 | expect(session_model.first_session_for_ip?).to be true 286 | end 287 | 288 | it 'returns false if there is another session for the login IP address before this one' do 289 | ip = '2.2.2.2' 290 | described_class.create!(user: user, active: true, login_ip: ip) 291 | session_model.login_ip = ip 292 | session_model.save! 293 | expect(session_model.first_session_for_ip?).to be false 294 | end 295 | end 296 | 297 | context '#reset_token' do 298 | it 'sets a new token' do 299 | original_token = session_model.temporary_token 300 | session_model.reset_token 301 | expect(session_model.temporary_token).to_not eq original_token 302 | end 303 | 304 | it 'sets a new token hash' do 305 | token = session_model.reset_token 306 | expect(session_model.token_hash).to eq described_class.hash_token(token) 307 | end 308 | 309 | it 'saves the record to the database' do 310 | token = session_model.reset_token 311 | expect(described_class.find_by(token_hash: described_class.hash_token(token))).to eq session_model 312 | end 313 | end 314 | 315 | context '.find_session_by_token' do 316 | it 'returns nil if the given token is blank' do 317 | expect(described_class.find_session_by_token(nil)).to be nil 318 | end 319 | 320 | it 'returns a session if one exists' do 321 | session = described_class.create! 322 | expect(described_class.find_session_by_token(session.temporary_token)).to eq session 323 | end 324 | 325 | it 'returns nil if no session can be found with the given token' do 326 | expect(described_class.find_session_by_token('abcdef1234123123')).to be nil 327 | end 328 | end 329 | 330 | context '.cleanup' do 331 | it 'invalidates all sessions that have no had recently activity and are not persistent' do 332 | session1 = described_class.create!(last_activity_at: 10.months.ago, active: true) 333 | session2 = described_class.create!(last_activity_at: 5.minutes.ago, active: true) 334 | described_class.cleanup 335 | expect(session1.reload.active?).to be false 336 | expect(session2.reload.active?).to be true 337 | end 338 | 339 | it 'invalidates all sessions which have expired' do 340 | session1 = described_class.create!(expires_at: 10.months.ago, active: true) 341 | session2 = described_class.create!(expires_at: 5.minutes.from_now, active: true) 342 | described_class.cleanup 343 | expect(session1.reload.active?).to be false 344 | expect(session2.reload.active?).to be true 345 | end 346 | 347 | it 'dispatches an event before and after' do 348 | expect(Authie).to receive(:notify).with(:cleanup) 349 | described_class.cleanup 350 | end 351 | end 352 | end 353 | -------------------------------------------------------------------------------- /spec/lib/session_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'authie/session' 5 | 6 | RSpec.describe Authie::Session do 7 | subject(:browser_id) { SecureRandom.uuid } 8 | subject(:user) { User.create!(username: 'adam') } 9 | subject(:session_model) { Authie::SessionModel.create!(user: user, browser_id: browser_id) } 10 | subject(:controller) { make_controller { |c| c.send(:cookies)[:browser_id] = browser_id } } 11 | subject(:session) { described_class.new(controller, session_model) } 12 | subject(:set_cookies) { controller.send(:cookies).instance_variable_get('@set_cookies') } 13 | 14 | describe '#validate' do 15 | it 'raises an error if the browser ID does not match' do 16 | controller.send(:cookies)[:browser_id] = 'invalid' 17 | expect { session.validate }.to raise_error Authie::Session::BrowserMismatch do |e| 18 | expect(e.session).to eq session 19 | end 20 | end 21 | 22 | it 'dispatches an event if the browser ID does not match' do 23 | controller.send(:cookies)[:browser_id] = 'invalid' 24 | allow(Authie).to receive(:notify) 25 | begin 26 | session.validate 27 | rescue StandardError 28 | nil 29 | end 30 | expect(Authie).to have_received(:notify).with(:browser_id_mismatch_error, session: session) 31 | end 32 | 33 | it 'raises an error if the session is not valid' do 34 | session_model.update!(active: false) 35 | expect { session.validate }.to raise_error Authie::Session::InactiveSession do |e| 36 | expect(e.session).to eq session 37 | end 38 | end 39 | 40 | it 'dispatches an event if the session is not valid' do 41 | session_model.update!(active: false) 42 | allow(Authie).to receive(:notify) 43 | begin 44 | session.validate 45 | rescue StandardError 46 | nil 47 | end 48 | expect(Authie).to have_received(:notify).with(:invalid_session_error, session: session) 49 | end 50 | 51 | it 'raises an error if the session has expired' do 52 | session_model.update!(expires_at: 5.minutes.ago) 53 | expect { session.validate }.to raise_error Authie::Session::ExpiredSession do |e| 54 | expect(e.session).to eq session 55 | end 56 | end 57 | 58 | it 'dispatches an event if the session has expired' do 59 | session_model.update!(expires_at: 5.minutes.ago) 60 | allow(Authie).to receive(:notify) 61 | begin 62 | session.validate 63 | rescue StandardError 64 | nil 65 | end 66 | expect(Authie).to have_received(:notify).with(:expired_session_error, session: session) 67 | end 68 | 69 | it 'raises an error if the session is inactive' do 70 | session_model.update!(last_activity_at: 13.hours.ago, active: true) 71 | expect { session.validate }.to raise_error Authie::Session::InactiveSession do |e| 72 | expect(e.session).to eq session 73 | end 74 | end 75 | 76 | it 'dispatches an event if the session is inactive' do 77 | session_model.update!(last_activity_at: 13.hours.ago, active: true) 78 | allow(Authie).to receive(:notify) 79 | begin 80 | session.validate 81 | rescue StandardError 82 | nil 83 | end 84 | expect(Authie).to have_received(:notify).with(:inactive_session_error, session: session) 85 | end 86 | 87 | it 'raises an error if the hostname does not match the session' do 88 | session_model.update!(host: 'example.com') 89 | expect { session.validate }.to raise_error Authie::Session::HostMismatch do |e| 90 | expect(e.session).to eq session 91 | end 92 | end 93 | 94 | it 'returns true if the session is OK' do 95 | session_model.update!(host: 'example.com') 96 | controller.request.headers['Host'] = 'example.com' 97 | expect(session.validate).to eq session 98 | end 99 | end 100 | 101 | describe '#persist' do 102 | it 'sets the expired time on the session' do 103 | expect(session_model.expires_at).to be nil 104 | session.persist 105 | expect(session_model.expires_at).to be_a Time 106 | end 107 | 108 | it 'sets the cookie to include the new expiry time' do 109 | session.start 110 | expect(set_cookies['user_session'][:expires]).to be nil 111 | session.persist 112 | expect(set_cookies['user_session'][:expires]).to be_a Time 113 | expect(set_cookies['user_session'][:expires]).to eq session_model.expires_at 114 | end 115 | end 116 | 117 | describe '#invalidate' do 118 | it 'invalidates the session' do 119 | expect(session_model.active?).to be true 120 | session.invalidate 121 | expect(session_model.active?).to be false 122 | end 123 | 124 | it 'deletes the cookie' do 125 | expect(session_model.active?).to be true 126 | session.start 127 | expect(controller.send(:cookies)['user_session']).to eq session_model.temporary_token 128 | session.invalidate 129 | expect(controller.send(:cookies)['user_session']).to be nil 130 | end 131 | end 132 | 133 | describe '#touch' do 134 | it 'not call the validate method as it previously did' do 135 | expect(session).to_not receive(:validate) 136 | session.touch 137 | end 138 | 139 | it 'sets the last activity IP' do 140 | allow(controller.request).to receive(:ip).and_return('1.2.3.4') 141 | session.touch 142 | expect(session.last_activity_ip).to eq '1.2.3.4' 143 | end 144 | 145 | it 'sets the last activity IP country' do 146 | allow(controller.request).to receive(:ip).and_return('1.2.3.4') 147 | allow(Authie.config).to receive(:lookup_ip_country).with('1.2.3.4').and_return('FR') 148 | session.touch 149 | expect(session.last_activity_ip_country).to eq 'FR' 150 | end 151 | 152 | it 'does not lookup an IP if the last activity IP does not change' do 153 | allow(controller.request).to receive(:ip).and_return('1.2.3.4') 154 | session.update!(last_activity_ip: '1.2.3.4', last_activity_ip_country: 'FR') 155 | expect(Authie.config).to_not receive(:lookup_ip_country) 156 | session.touch 157 | expect(session.last_activity_ip_country).to eq 'FR' 158 | end 159 | 160 | it 'sets the last activity path' do 161 | allow(controller.request).to receive(:path).and_return('/blah/blah') 162 | session.touch 163 | expect(session.last_activity_path).to eq '/blah/blah' 164 | end 165 | 166 | it 'sets the last activity time' do 167 | time = Time.new(2022, 3, 2, 12, 32, 22) 168 | Timecop.freeze(time) do 169 | session.touch 170 | expect(session.last_activity_at).to eq time 171 | end 172 | end 173 | 174 | it 'increments the request counter' do 175 | 4.times do |i| 176 | session.touch 177 | expect(session.requests).to eq i + 1 178 | end 179 | end 180 | 181 | it 'dispatches an event' do 182 | expect(Authie).to receive(:notify).with(:touch, session: session) 183 | session.touch 184 | end 185 | 186 | context 'when session expiry extension is not enabled' do 187 | subject(:session_model) do 188 | Authie::SessionModel.create!(user: user, browser_id: browser_id, expires_at: 4.hours.from_now) 189 | end 190 | 191 | before { allow(Authie.config).to receive(:extend_session_expiry_on_touch).and_return(false) } 192 | 193 | it 'does not extend the expiry date on the session' do 194 | original_time = session_model.expires_at 195 | Timecop.freeze(original_time + 10.hours) { session.touch } 196 | session.session.reload 197 | expect(session.expires_at.to_i).to eq original_time.to_i 198 | end 199 | 200 | it 'does not set the expiry time' do 201 | expect(session.session).to_not receive(:expires_at=) 202 | session.touch 203 | end 204 | 205 | it 'does not update the cookie' do 206 | expect(session).to_not receive(:set_cookie) 207 | session.touch 208 | end 209 | end 210 | 211 | context 'when session expiry extension is enabled' do 212 | subject(:session_model) do 213 | Authie::SessionModel.create!(user: user, browser_id: browser_id, expires_at: Time.now) 214 | end 215 | 216 | before { allow(Authie.config).to receive(:extend_session_expiry_on_touch).and_return(true) } 217 | 218 | it 'does not set the expiry time' do 219 | expect(session.session).to receive(:expires_at=).and_call_original 220 | session.touch 221 | end 222 | 223 | it 'extends the expiry date on the session' do 224 | time = session_model.created_at 225 | Timecop.freeze(time) { session.touch } 226 | session.session.reload 227 | expect(session.expires_at.to_i).to eq (time + Authie.config.persistent_session_length).to_i 228 | end 229 | 230 | it 'updates the cookie' do 231 | time = session_model.created_at 232 | Timecop.freeze(time) { session.touch } 233 | expect(set_cookies['user_session'][:expires]).to eq time + Authie.config.persistent_session_length 234 | end 235 | end 236 | end 237 | 238 | describe '#see_password' do 239 | it 'sets the password seen at time' do 240 | time = Time.new(2022, 3, 2, 12, 32, 22) 241 | Timecop.freeze(time) do 242 | session.see_password 243 | expect(session.password_seen_at).to eq time 244 | end 245 | end 246 | 247 | it 'dispatches an event' do 248 | expect(Authie).to receive(:notify).with(:see_password, session: session) 249 | session.see_password 250 | end 251 | end 252 | 253 | describe '#mark_as_two_factored' do 254 | it 'sets the two factored time' do 255 | time = Time.new(2022, 3, 2, 12, 32, 22) 256 | Timecop.freeze(time) do 257 | session.mark_as_two_factored 258 | expect(session.two_factored_at).to eq time 259 | end 260 | end 261 | 262 | it 'sets the ip address' do 263 | allow(controller.request).to receive(:ip).and_return('1.2.3.4') 264 | session.mark_as_two_factored 265 | expect(session.two_factored_ip).to eq '1.2.3.4' 266 | end 267 | 268 | it 'sets the ip address country' do 269 | allow(controller.request).to receive(:ip).and_return('1.2.3.4') 270 | allow(Authie.config).to receive(:lookup_ip_country).with('1.2.3.4').and_return('AU') 271 | session.mark_as_two_factored 272 | expect(session.two_factored_ip_country).to eq 'AU' 273 | end 274 | 275 | it 'it dispatched an event' do 276 | expect(Authie).to receive(:notify).with(:mark_as_two_factor, session: session) 277 | session.mark_as_two_factored 278 | end 279 | 280 | it 'can set the skip two factor boolean to true if provided' do 281 | session.mark_as_two_factored(skip: true) 282 | expect(session.skip_two_factor?).to eq true 283 | end 284 | 285 | it 'can set the skip two factor boolean to false if provided' do 286 | session.update!(skip_two_factor: true) # default is false, set to true to check it does actually change 287 | session.mark_as_two_factored(skip: false) 288 | expect(session.skip_two_factor?).to eq false 289 | end 290 | 291 | it 'does not override the two factor boolean if not provided' do 292 | session.update!(skip_two_factor: true) 293 | session.mark_as_two_factored 294 | expect(session.skip_two_factor?).to eq true 295 | end 296 | end 297 | 298 | describe '#reset_token' do 299 | it 'sets a new token on the session' do 300 | expect(session.session).to receive(:reset_token) 301 | session.reset_token 302 | end 303 | 304 | it 'updates the cookie' do 305 | original_cookie_value = controller.send(:cookies)[:user_session] 306 | session.reset_token 307 | expect(controller.send(:cookies)[:user_session]).to_not eq original_cookie_value 308 | expect(controller.send(:cookies)[:user_session]).to eq session.temporary_token 309 | end 310 | 311 | it 'returns itself' do 312 | expect(session.reset_token).to be session 313 | end 314 | end 315 | 316 | describe '#start' do 317 | it 'sets the cookie' do 318 | expect(session).to receive(:set_cookie) 319 | session.start 320 | end 321 | 322 | it 'dispatches an event' do 323 | expect(Authie).to receive(:notify) # for cookies 324 | expect(Authie).to receive(:notify).with(:session_start, session: session) 325 | session.start 326 | end 327 | end 328 | 329 | describe '.start' do 330 | it 'creates a new session with details from the request' do 331 | time = Time.new(2022, 3, 4, 2, 31, 22) 332 | allow(controller.request).to receive(:ip).and_return('1.2.3.4') 333 | Timecop.freeze(time) do 334 | session = described_class.start(controller, user: user) 335 | expect(session).to be_a Authie::Session 336 | expect(session.session.user).to eq user 337 | expect(session.session.browser_id).to eq browser_id 338 | expect(session.session.login_at).to eq time 339 | expect(session.session.login_ip).to eq '1.2.3.4' 340 | expect(session.session.host).to eq 'test.host' 341 | expect(session.session.user_agent).to eq 'Rails Testing' 342 | end 343 | end 344 | 345 | it 'adds the login IP coutnry' do 346 | allow(Authie.config).to receive(:lookup_ip_country).with('1.2.3.4').and_return('GB') 347 | allow(controller.request).to receive(:ip).and_return('1.2.3.4') 348 | session = described_class.start(controller, user: user) 349 | expect(session).to be_a Authie::Session 350 | expect(session.session.login_ip_country).to eq 'GB' 351 | end 352 | 353 | it 'invalidates all other sessions for the same browser' do 354 | existing_session = Authie::SessionModel.create!(user: user, active: true, browser_id: browser_id) 355 | described_class.start(controller, user: user) 356 | expect(existing_session.reload.active?).to be false 357 | end 358 | 359 | it 'allows persistent sessions to be created' do 360 | time = Time.new(2022, 3, 4, 2, 31, 22) 361 | Timecop.freeze(time) do 362 | session = described_class.start(controller, user: user, persistent: true) 363 | expect(session.persistent?).to be true 364 | expect(session.expires_at).to eq time + 2.months 365 | end 366 | end 367 | 368 | it 'allows password to be seen' do 369 | time = Time.new(2022, 3, 4, 2, 31, 22) 370 | Timecop.freeze(time) do 371 | session = described_class.start(controller, user: user, see_password: true) 372 | expect(session.password_seen_at).to eq time 373 | end 374 | end 375 | end 376 | 377 | describe '.get_session' do 378 | it 'returns nil if there is no user session cookie' do 379 | expect(described_class.get_session(controller)).to be nil 380 | end 381 | 382 | it 'returns nil if there is no session matching the value in the cookie' do 383 | controller.send(:cookies)[:user_session] = 'invalid' 384 | expect(described_class.get_session(controller)).to be nil 385 | end 386 | 387 | it 'returns a session object if a session is found' do 388 | controller.send(:cookies)[:user_session] = session_model.temporary_token 389 | expect(described_class.get_session(controller)).to be_a Authie::Session 390 | expect(described_class.get_session(controller).session).to eq session_model 391 | end 392 | 393 | it 'sets the temporary token on the underlying session' do 394 | controller.send(:cookies)[:user_session] = session_model.temporary_token 395 | expect(described_class.get_session(controller).session.temporary_token).to eq session_model.temporary_token 396 | end 397 | end 398 | end 399 | -------------------------------------------------------------------------------- /spec/lib/user_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Authie::User do 6 | subject(:user) { User.create!(username: 'adam') } 7 | 8 | describe '#user_sessions' do 9 | it 'returns sessions belonging to the user' do 10 | session = Authie::SessionModel.create!(user: user) 11 | expect(user.user_sessions).to eq [session] 12 | end 13 | 14 | it 'deletes all sessions when the user is deleted' do 15 | session = Authie::SessionModel.create!(user: user) 16 | user.destroy! 17 | expect { session.reload }.to raise_error(ActiveRecord::RecordNotFound) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'logger' 4 | require 'active_record' 5 | require 'timecop' 6 | 7 | ENV['RAILS_ENV'] = 'test' 8 | 9 | if %w[yes true 1].include?(ENV['COVERAGE']) 10 | require 'simplecov' 11 | require 'simplecov-console' 12 | require 'simplecov_json_formatter' 13 | 14 | SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new( 15 | [ 16 | SimpleCov::Formatter::HTMLFormatter, 17 | SimpleCov::Formatter::JSONFormatter, 18 | SimpleCov::Formatter::Console 19 | ] 20 | ) 21 | 22 | SimpleCov.start 'test_frameworks' do 23 | enable_coverage :branch 24 | end 25 | end 26 | 27 | ActiveRecord::Base.establish_connection adapter: 'sqlite3', database: ':memory:' 28 | ActiveRecord::Migration.verbose = false 29 | RSpec::Mocks.configuration.allow_message_expectations_on_nil = true 30 | 31 | require_relative 'dummy/config/environment' 32 | require_relative 'support/controller_helpers' 33 | require_relative 'support/user_model' 34 | 35 | require 'rspec/rails' 36 | 37 | RSpec.configure do |config| 38 | config.color = true 39 | config.include ControllerHelpers 40 | 41 | config.expect_with :rspec do |expectations| 42 | expectations.max_formatted_output_length = 1_000_000 43 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 44 | end 45 | 46 | config.mock_with :rspec do |mocks| 47 | mocks.verify_partial_doubles = true 48 | end 49 | 50 | config.before(:suite) do 51 | context = ActiveRecord::MigrationContext.new(File.expand_path('../db/migrate', __dir__)) 52 | context.migrate(nil) 53 | end 54 | 55 | config.before(:each) do 56 | Authie.config.set_defaults 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /spec/support/controller_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'action_controller' 4 | require 'action_controller/test_case' 5 | 6 | module ControllerHelpers 7 | def make_controller 8 | controller_class = Class.new(ActionController::Base) 9 | controller = controller_class.new 10 | controller.set_request!(ActionController::TestRequest.create(controller_class)) 11 | yield controller if block_given? 12 | controller 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/support/user_model.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveRecord::Migration.create_table :users do |t| 4 | t.string :username 5 | end 6 | 7 | require 'authie/user' 8 | 9 | class User < ActiveRecord::Base 10 | include Authie::User 11 | end 12 | --------------------------------------------------------------------------------