Revocable sessions (#3616)
* feat: Revocable sessions * fix: Tests using sign_in * feat: Configuration entry for the maximum number of session activations
This commit is contained in:
parent
3783cadf2d
commit
2211e8d1cd
38
app/models/session_activation.rb
Normal file
38
app/models/session_activation.rb
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
# == Schema Information
|
||||||
|
#
|
||||||
|
# Table name: session_activations
|
||||||
|
#
|
||||||
|
# id :integer not null, primary key
|
||||||
|
# user_id :integer not null
|
||||||
|
# session_id :string not null
|
||||||
|
# created_at :datetime not null
|
||||||
|
# updated_at :datetime not null
|
||||||
|
#
|
||||||
|
|
||||||
|
class SessionActivation < ApplicationRecord
|
||||||
|
LIMIT = Rails.configuration.x.max_session_activations
|
||||||
|
|
||||||
|
def self.active?(id)
|
||||||
|
id && where(session_id: id).exists?
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.activate(id)
|
||||||
|
activation = create!(session_id: id)
|
||||||
|
purge_old
|
||||||
|
activation
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.deactivate(id)
|
||||||
|
return unless id
|
||||||
|
where(session_id: id).destroy_all
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.purge_old
|
||||||
|
order('created_at desc').offset(LIMIT).destroy_all
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.exclusive(id)
|
||||||
|
where('session_id != ?', id).destroy_all
|
||||||
|
end
|
||||||
|
end
|
|
@ -63,6 +63,8 @@ class User < ApplicationRecord
|
||||||
# handle this itself, and this can be removed from our User class.
|
# handle this itself, and this can be removed from our User class.
|
||||||
attribute :otp_secret
|
attribute :otp_secret
|
||||||
|
|
||||||
|
has_many :session_activations, dependent: :destroy
|
||||||
|
|
||||||
def confirmed?
|
def confirmed?
|
||||||
confirmed_at.present?
|
confirmed_at.present?
|
||||||
end
|
end
|
||||||
|
@ -89,6 +91,18 @@ class User < ApplicationRecord
|
||||||
settings.auto_play_gif
|
settings.auto_play_gif
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def activate_session
|
||||||
|
session_activations.activate(SecureRandom.hex).session_id
|
||||||
|
end
|
||||||
|
|
||||||
|
def exclusive_session(id)
|
||||||
|
session_activations.exclusive(id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def session_active?(id)
|
||||||
|
session_activations.active? id
|
||||||
|
end
|
||||||
|
|
||||||
protected
|
protected
|
||||||
|
|
||||||
def send_devise_notification(notification, *args)
|
def send_devise_notification(notification, *args)
|
||||||
|
|
|
@ -1,3 +1,19 @@
|
||||||
|
Warden::Manager.after_set_user except: :fetch do |user, warden|
|
||||||
|
SessionActivation.deactivate warden.raw_session['auth_id']
|
||||||
|
warden.raw_session['auth_id'] = user.activate_session
|
||||||
|
end
|
||||||
|
|
||||||
|
Warden::Manager.after_fetch do |user, warden|
|
||||||
|
unless user.session_active?(warden.raw_session['auth_id'])
|
||||||
|
warden.logout
|
||||||
|
throw :warden, message: :unauthenticated
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Warden::Manager.before_logout do |_, warden|
|
||||||
|
SessionActivation.deactivate warden.raw_session['auth_id']
|
||||||
|
end
|
||||||
|
|
||||||
Devise.setup do |config|
|
Devise.setup do |config|
|
||||||
config.warden do |manager|
|
config.warden do |manager|
|
||||||
manager.default_strategies(scope: :user).unshift :two_factor_authenticatable
|
manager.default_strategies(scope: :user).unshift :two_factor_authenticatable
|
||||||
|
|
5
config/initializers/session_activations.rb
Normal file
5
config/initializers/session_activations.rb
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
Rails.application.configure do
|
||||||
|
config.x.max_session_activations = ENV['MAX_SESSION_ACTIVATIONS'] || 10
|
||||||
|
end
|
13
db/migrate/20170623152212_create_session_activations.rb
Normal file
13
db/migrate/20170623152212_create_session_activations.rb
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
class CreateSessionActivations < ActiveRecord::Migration[5.1]
|
||||||
|
def change
|
||||||
|
create_table :session_activations do |t|
|
||||||
|
t.integer :user_id, null: false
|
||||||
|
t.string :session_id, null: false
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :session_activations, :user_id
|
||||||
|
add_index :session_activations, :session_id, unique: true
|
||||||
|
end
|
||||||
|
end
|
11
db/schema.rb
11
db/schema.rb
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema.define(version: 20170610000000) do
|
ActiveRecord::Schema.define(version: 20170623152212) do
|
||||||
|
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
|
@ -250,6 +250,15 @@ ActiveRecord::Schema.define(version: 20170610000000) do
|
||||||
t.index ["target_account_id"], name: "index_reports_on_target_account_id"
|
t.index ["target_account_id"], name: "index_reports_on_target_account_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "session_activations", force: :cascade do |t|
|
||||||
|
t.integer "user_id", null: false
|
||||||
|
t.string "session_id", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["session_id"], name: "index_session_activations_on_session_id", unique: true
|
||||||
|
t.index ["user_id"], name: "index_session_activations_on_user_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "settings", id: :serial, force: :cascade do |t|
|
create_table "settings", id: :serial, force: :cascade do |t|
|
||||||
t.string "var", null: false
|
t.string "var", null: false
|
||||||
t.text "value"
|
t.text "value"
|
||||||
|
|
4
spec/fabricators/session_activation_fabricator.rb
Normal file
4
spec/fabricators/session_activation_fabricator.rb
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
Fabricator(:session_activation) do
|
||||||
|
user_id 1
|
||||||
|
session_id "MyString"
|
||||||
|
end
|
5
spec/models/session_activation_spec.rb
Normal file
5
spec/models/session_activation_spec.rb
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe SessionActivation, type: :model do
|
||||||
|
pending "add some examples to (or delete) #{__FILE__}"
|
||||||
|
end
|
|
@ -16,6 +16,17 @@ WebMock.disable_net_connect!
|
||||||
Sidekiq::Testing.inline!
|
Sidekiq::Testing.inline!
|
||||||
Sidekiq::Logging.logger = nil
|
Sidekiq::Logging.logger = nil
|
||||||
|
|
||||||
|
Devise::Test::ControllerHelpers.module_eval do
|
||||||
|
alias_method :original_sign_in, :sign_in
|
||||||
|
|
||||||
|
def sign_in(resource, deprecated = nil, scope: nil)
|
||||||
|
original_sign_in(resource, scope: scope)
|
||||||
|
|
||||||
|
SessionActivation.deactivate warden.raw_session["auth_id"]
|
||||||
|
warden.raw_session["auth_id"] = resource.activate_session
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
RSpec.configure do |config|
|
RSpec.configure do |config|
|
||||||
config.fixture_path = "#{::Rails.root}/spec/fixtures"
|
config.fixture_path = "#{::Rails.root}/spec/fixtures"
|
||||||
config.use_transactional_fixtures = true
|
config.use_transactional_fixtures = true
|
||||||
|
|
Reference in a new issue