Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion app/jobs/user_session/clear_old_user_sessions_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ class ClearOldUserSessionsJob < ApplicationJob
queue_as :low

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

end
Expand Down
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
UserSessionMailer.first_login(user:).deliver_later
elsif fingerprint.present? && user.user_sessions.excluding(self).where(fingerprint:).none?
UserSessionMailer.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
133 changes: 0 additions & 133 deletions app/models/user_session.rb

This file was deleted.

2 changes: 1 addition & 1 deletion app/services/flavor_text_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,7 @@ def flavor_texts
"BOOOOOOOOOONNNNNNKKKKKKKKKKKKK",
"Wanna&nbsp;<a href='#{Rails.configuration.constants.github_url}' target='_blank' style='color: inherit'>hack on hcb</a>?".html_safe,
"everyone's favorite money thing!",
-> { "#{UserSession.not_impersonated.where("last_seen_at > ?", 15.minutes.ago).count("DISTINCT(user_id)")} online" },
-> { "#{User::Session.not_impersonated.where("last_seen_at > ?", 15.minutes.ago).count("DISTINCT(user_id)")} online" },
"We Column like we see 'em!",
"Raccoon-tested, dinosaur-approved.",
"original recipe!",
Expand Down
4 changes: 2 additions & 2 deletions app/views/users/edit_security.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@

<div class="grid grid--split">
<% @all_sessions.each do |session| %>
<% if session.is_a? UserSession %>
<% if session.is_a? User::Session %>
<%= render "users/user_session", session: %>
<% elsif session.respond_to? :authorization_count %>
<%= render "users/oauth_authorization", authorization: session, disabled: %>
Expand All @@ -178,7 +178,7 @@
</h3>
<div class="grid grid--split">
<% @expired_sessions.each do |session| %>
<% if session.is_a? UserSession %>
<% if session.is_a? User::Session %>
<%= render "users/user_session", session: %>
<% elsif session.respond_to? :authorization_count %>
<%= render "users/oauth_authorization", authorization: session, disabled: %>
Expand Down
6 changes: 3 additions & 3 deletions dev-docs/guides/authentication.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# Authentication on HCB
HCB’s authentication system is an awkward hodgepodge of systems, built one on top of the other. I’m going to start off by listing the models and their purposes:

* [`Login`](https://github.com/hackclub/hcb/blob/main/app/models/login.rb): stores information about an attempt to login, whether successful or not. It’s created when someone enters their email address and either expires or ends after they’ve provided one or two factors of authentication, which creates a [`UserSession`](https://github.com/hackclub/hcb/blob/main/app/models/user_session.rb).
* [`Login`](https://github.com/hackclub/hcb/blob/main/app/models/login.rb): stores information about an attempt to login, whether successful or not. It’s created when someone enters their email address and either expires or ends after they’ve provided one or two factors of authentication, which creates a [`User::Session`](https://github.com/hackclub/hcb/blob/main/app/models/user/session.rb).
* [`LoginCode`](https://github.com/hackclub/hcb/blob/main/app/models/login_code.rb): a temporary code sent via email to users. They can use this code as an authentication factor.
* [`LoginCodeService::Request`](https://github.com/hackclub/hcb/blob/main/app/services/login_code_service/request.rb) confusingly can also send SMS login codes, however, these don’t have an associated [`LoginCode`](https://github.com/hackclub/hcb/blob/main/app/models/login_code.rb) record and are done through Twilio.
* [`User::Totp`](https://github.com/hackclub/hcb/blob/main/app/models/user/totp.rb): a TOTP credential that users can use to login. One-per-user.
* [`WebauthnCredential`](https://github.com/hackclub/hcb/blob/main/app/models/webauthn_credential.rb): a WebAuthn credential that users can use to login, eg. a fingerprint or a Yubikey. Users can have multiple.
* [`UserSession`](https://github.com/hackclub/hcb/blob/main/app/models/user_session.rb): created after a successful [`Login`](https://github.com/hackclub/hcb/blob/main/app/models/login.rb). Has a `session_token` that is set as a browser cookie.
* [`User::Session`](https://github.com/hackclub/hcb/blob/main/app/models/user/session.rb): created after a successful [`Login`](https://github.com/hackclub/hcb/blob/main/app/models/login.rb). Has a `session_token` that is set as a browser cookie.

## Logging in

Expand Down Expand Up @@ -45,6 +45,6 @@ We use GitHub’s [`@github/webauthn-json`](https://github.com/github/webauthn-j

## Fingerprinting

We fingerprint every user session using [`@fingerprintjs/fingerprintjs`](https://github.com/fingerprintjs/fingerprintjs). This is passed into the [`UserSession`](https://github.com/hackclub/hcb/blob/main/app/models/user_session.rb) created inside of `complete_login_path`.
We fingerprint every user session using [`@fingerprintjs/fingerprintjs`](https://github.com/fingerprintjs/fingerprintjs). This is passed into the [`User::Session`](https://github.com/hackclub/hcb/blob/main/app/models/user/session.rb) created inside of `complete_login_path`.

\- [@sampoder](https://github.com/sampoder)
Loading
Loading