Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ def logout_all

def logout_session
begin
session = UserSession.find(params[:id])
session = User::Session.find(params[:id])
authorize session.user

session.update(signed_out_at: Time.now, expiration_at: Time.now)
Expand Down
4 changes: 2 additions & 2 deletions app/helpers/sessions_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def sign_in(user:, fingerprint_info: {}, impersonate: false, webauthn_credential
user.session_validity_preference
end
expiration_at = session_duration.seconds.from_now
cookies.encrypted[:session_token] = { value: session_token, expires: UserSession::MAX_SESSION_DURATION.from_now, httponly: true }
cookies.encrypted[:session_token] = { value: session_token, expires: User::Session::MAX_SESSION_DURATION.from_now, httponly: true }
cookies.encrypted[:signed_user] = user.signed_id(expires_in: 2.months, purpose: :signin_avatar)
user_session = user.user_sessions.build(
session_token:,
Expand Down Expand Up @@ -128,7 +128,7 @@ def current_session
return nil if session_token.nil?

# Find a valid session (not expired) using the session token
@current_session = UserSession.not_expired.find_by(session_token:)
@current_session = User::Session.not_expired.find_by(session_token:)
end

def signed_in_user
Expand Down
16 changes: 16 additions & 0 deletions app/jobs/user/session/clear_old_user_sessions_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# frozen_string_literal: true

class User
class Session
class ClearOldUserSessionsJob < ApplicationJob
queue_as :low

def perform
User::Session.expired.where("created_at < ?", 1.year.ago).find_each(&:clear_metadata!)
end

end

end

end
13 changes: 0 additions & 13 deletions app/jobs/user_session/clear_old_user_sessions_job.rb

This file was deleted.

20 changes: 20 additions & 0 deletions app/mailers/user/session_mailer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# frozen_string_literal: true

class User
class SessionMailer < ApplicationMailer
def new_login(user_session:)
@session = user_session
@user = user_session.user

mail to: @user.email, subject: "New login to your HCB account"
end

def first_login(user:)
@user = user

mail to: @user.email, subject: "Welcome to HCB!"
end

end

end
17 changes: 0 additions & 17 deletions app/mailers/user_session_mailer.rb

This file was deleted.

6 changes: 3 additions & 3 deletions app/models/governance/request_context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,9 @@ def impersonated? = impersonator.present?

def authentication_session_is_user_session
# authentication_session was made polymorphic to potentially support
# tracking API requests in the future, but for now we only want UserSession.
unless authentication_session.is_a?(UserSession)
errors.add(:authentication_session, "must be a UserSession")
# tracking API requests in the future, but for now we only want User::Session.
unless authentication_session.is_a?(User::Session)
errors.add(:authentication_session, "must be a User::Session")
end
end

Expand Down
2 changes: 1 addition & 1 deletion app/models/login.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ class Login < ApplicationRecord
include Hashid::Rails

belongs_to :user
belongs_to :user_session, optional: true
belongs_to :user_session, class_name: "User::Session", optional: true

scope(:initial, -> { where(is_reauthentication: false) })
scope(:reauthentication, -> { where(is_reauthentication: true) })
Expand Down
2 changes: 1 addition & 1 deletion app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ class User < ApplicationRecord
has_many :logins
has_many :login_codes
has_many :backup_codes, class_name: "User::BackupCode", inverse_of: :user, dependent: :destroy
has_many :user_sessions, dependent: :destroy
has_many :user_sessions, class_name: "User::Session", dependent: :destroy
has_many :organizer_position_invites, dependent: :destroy
has_many :organizer_position_invite_requests, class_name: "OrganizerPositionInvite::Request", inverse_of: :requester, dependent: :destroy
has_many :contracts, through: :organizer_position_invites
Expand Down
2 changes: 1 addition & 1 deletion app/models/user/seen_at_history.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
# fk_rails_... (user_id => users.id)
#
class User
# This table stores a history of Users' UserSession#last_seen_at.
# This table stores a history of Users' User::Session#last_seen_at.
# The data is sampled every 30 minutes using a cron job, but the sample
# collects data from the past hour (PERIOD_DURATION). Sampling more often
# prevents us from missing data in case the hourly job is delayed.
Expand Down
136 changes: 136 additions & 0 deletions app/models/user/session.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# frozen_string_literal: true

# == Schema Information
#
# Table name: user_sessions
#
# id :bigint not null, primary key
# device_info :string
# expiration_at :datetime not null
# fingerprint :string
# ip :string
# last_seen_at :datetime
# latitude :decimal(, )
# longitude :decimal(, )
# os_info :string
# session_token_bidx :string
# session_token_ciphertext :text
# signed_out_at :datetime
# timezone :string
# created_at :datetime not null
# updated_at :datetime not null
# impersonated_by_id :bigint
# user_id :bigint not null
# webauthn_credential_id :bigint
#
# Indexes
#
# index_user_sessions_on_impersonated_by_id (impersonated_by_id)
# index_user_sessions_on_session_token_bidx (session_token_bidx)
# index_user_sessions_on_user_id (user_id)
# index_user_sessions_on_webauthn_credential_id (webauthn_credential_id)
#
# Foreign Keys
#
# fk_rails_... (impersonated_by_id => users.id)
# fk_rails_... (user_id => users.id)
#
class User
class Session < ApplicationRecord
has_paper_trail skip: [:session_token] # ciphertext columns will still be tracked
has_encrypted :session_token
blind_index :session_token

belongs_to :user
belongs_to :impersonated_by, class_name: "User", optional: true
belongs_to :webauthn_credential, optional: true
has_many(:logins)

include PublicActivity::Model
tracked owner: proc{ |controller, record| record.impersonated_by || record.user }, recipient: proc { |controller, record| record.impersonated_by || record.user }, only: [:create]

scope :impersonated, -> { where.not(impersonated_by_id: nil) }
scope :not_impersonated, -> { where(impersonated_by_id: nil) }
scope :expired, -> { where("expiration_at <= ?", Time.now) }
scope :not_expired, -> { where("expiration_at > ?", Time.now) }
scope :recently_expired_within, ->(date) { expired.where("expiration_at >= ?", date) }

after_create_commit do
if user.user_sessions.size == 1
User::SessionMailer.first_login(user:).deliver_later
elsif fingerprint.present? && user.user_sessions.excluding(self).where(fingerprint:).none?
User::SessionMailer.new_login(user_session: self).deliver_later
end
end

extend Geocoder::Model::ActiveRecord
geocoded_by :ip
after_validation :geocode, if: ->(session){ session.ip.present? and session.ip_changed? }

validate :user_is_unlocked, on: :create

def impersonated?
!impersonated_by.nil?
end

LAST_SEEN_AT_COOLDOWN = 5.minutes

MAX_SESSION_DURATION = 3.weeks

def update_session_timestamps
return if last_seen_at&.after? LAST_SEEN_AT_COOLDOWN.ago # prevent spamming writes

updates = { last_seen_at: Time.now }
updates[:expiration_at] = [created_at + MAX_SESSION_DURATION, user.session_validity_preference.seconds.from_now].min unless impersonated?
update_columns(**updates)
end

def expired?
expiration_at <= Time.now
end

SUDO_MODE_TTL = 2.hours

# Determines whether the user can perform a sensitive action without
# reauthenticating.
#
# @return [Boolean]
def sudo_mode?
return true unless Flipper.enabled?(:sudo_mode_2015_07_21, user)

return false if last_authenticated_at.nil?

last_authenticated_at >= SUDO_MODE_TTL.ago
end

def clear_metadata!
update!(
device_info: nil,
latitude: nil,
longitude: nil,
)
end

def last_reauthenticated_at
logins.complete.reauthentication.max_by(&:created_at)&.created_at
end

private

def user_is_unlocked
if user.locked? && !impersonated?
errors.add(:user, "Your HCB account has been locked.")
end
end

# The last time the user went through a login flow. Used to determine whether
# sensitive actions can be performed.
#
# @return [ActiveSupport::TimeWithZone, nil]
def last_authenticated_at
logins.complete.max_by(&:created_at)&.created_at
end

end

end
Loading
Loading