## Description Cap active sessions at `MAX_USER_SESSIONS` which defaults to existing value of `25` per user. This ensure existing user login behavior is not affected for self-hosted installations. Browser users at the cap see a session picker (409 response) to choose which session to end. Non-browser clients and partially-tracked users get silent oldest-session eviction. Depends on #14556. ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? Specs cover: under limit, at limit (browser picker, non-browser eviction), partial tracking fallback, revoke single/all sessions during login, session row creation on successful login. --------- Co-authored-by: Sony Mathew <sony@chatwoot.com>
237 lines
7.3 KiB
Ruby
237 lines
7.3 KiB
Ruby
class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
|
MAX_SESSIONS = ENV.fetch('MAX_USER_SESSIONS', 25).to_i
|
|
|
|
# Prevent session parameter from being passed
|
|
# Unpermitted parameter: session
|
|
wrap_parameters format: []
|
|
before_action :process_sso_auth_token, only: [:create]
|
|
|
|
def new
|
|
redirect_to login_page_url(error: 'access-denied')
|
|
end
|
|
|
|
def create
|
|
return handle_mfa_verification if mfa_verification_request?
|
|
return handle_sso_authentication if sso_authentication_request?
|
|
|
|
user = find_user_for_authentication
|
|
return handle_mfa_required(user) if user&.mfa_enabled?
|
|
return if user && enforce_session_limit_for_password_login(user)
|
|
|
|
# Only proceed with standard authentication if no MFA is required
|
|
super
|
|
end
|
|
|
|
def render_create_success
|
|
track_user_session unless @impersonation
|
|
render partial: 'devise/auth', formats: [:json], locals: { resource: @resource }
|
|
end
|
|
|
|
private
|
|
|
|
def render_create_error_not_confirmed
|
|
render_error(
|
|
:unauthorized,
|
|
I18n.t('devise_token_auth.sessions.not_confirmed', email: @resource.email),
|
|
error_code: 'user_not_confirmed'
|
|
)
|
|
end
|
|
|
|
def find_user_for_authentication
|
|
return nil unless params[:email].present? && params[:password].present?
|
|
|
|
normalized_email = params[:email].strip.downcase
|
|
user = User.from_email(normalized_email)
|
|
return nil unless user&.valid_password?(params[:password])
|
|
return nil unless user.active_for_authentication?
|
|
|
|
user
|
|
end
|
|
|
|
def mfa_verification_request?
|
|
params[:mfa_token].present?
|
|
end
|
|
|
|
def sso_authentication_request?
|
|
params[:sso_auth_token].present? && @resource.present?
|
|
end
|
|
|
|
def handle_sso_authentication
|
|
return if !@impersonation && enforce_session_limit_for_password_login(@resource)
|
|
|
|
authenticate_resource_with_sso_token
|
|
yield @resource if block_given?
|
|
render_create_success
|
|
end
|
|
|
|
def login_page_url(error: nil)
|
|
frontend_url = ENV.fetch('FRONTEND_URL', nil)
|
|
|
|
"#{frontend_url}/app/login?error=#{error}"
|
|
end
|
|
|
|
def authenticate_resource_with_sso_token
|
|
# DTA evicts the earliest-expiring token after save when at max_number_of_devices.
|
|
# The short-lived impersonation token would always be that one, so pre-evict to make room.
|
|
make_room_for_impersonation_token if @impersonation
|
|
@token = @resource.create_token(lifespan: @impersonation ? 2.days.to_i : nil)
|
|
@resource.save!
|
|
|
|
sign_in(:user, @resource, store: false, bypass: false)
|
|
# invalidate the token after the user is signed in
|
|
@resource.invalidate_sso_auth_token(params[:sso_auth_token])
|
|
end
|
|
|
|
def make_room_for_impersonation_token
|
|
return if @resource.tokens.size < DeviseTokenAuth.max_number_of_devices
|
|
|
|
oldest_client_id = @resource.tokens.min_by { |_, v| v['expiry'].to_i }&.first
|
|
@resource.tokens.delete(oldest_client_id) if oldest_client_id
|
|
end
|
|
|
|
def process_sso_auth_token
|
|
return if params[:email].blank?
|
|
|
|
user = User.from_email(params[:email])
|
|
return unless user&.valid_sso_auth_token?(params[:sso_auth_token])
|
|
|
|
@resource = user
|
|
@impersonation = user.sso_auth_token_impersonation?(params[:sso_auth_token])
|
|
end
|
|
|
|
def handle_mfa_required(user)
|
|
render json: {
|
|
mfa_required: true,
|
|
mfa_token: Mfa::TokenService.new(user: user).generate_token
|
|
}, status: :partial_content
|
|
end
|
|
|
|
def handle_mfa_verification
|
|
user = Mfa::TokenService.new(token: params[:mfa_token]).verify_token
|
|
return render_mfa_error('errors.mfa.invalid_token', :unauthorized) unless user
|
|
|
|
authenticated = Mfa::AuthenticationService.new(
|
|
user: user,
|
|
otp_code: params[:otp_code],
|
|
backup_code: params[:backup_code]
|
|
).authenticate
|
|
|
|
return render_mfa_error('errors.mfa.invalid_code') unless authenticated
|
|
|
|
sign_in_mfa_user(user)
|
|
end
|
|
|
|
def sign_in_mfa_user(user)
|
|
evict_oldest_session(user) if sessions_limit_reached?(user)
|
|
@resource = user
|
|
@token = @resource.create_token
|
|
@resource.save!
|
|
|
|
sign_in(:user, @resource, store: false, bypass: false)
|
|
render_create_success
|
|
end
|
|
|
|
def render_mfa_error(message_key, status = :bad_request)
|
|
render json: { error: I18n.t(message_key) }, status: status
|
|
end
|
|
|
|
def sessions_limit_reached?(user)
|
|
active_token_count(user) >= MAX_SESSIONS
|
|
end
|
|
|
|
def active_token_count(user)
|
|
now = Time.current.to_i
|
|
(user.tokens || {}).count { |_, v| v['expiry'].to_i > now }
|
|
end
|
|
|
|
# Returns true when a response has been rendered (e.g., 409 picker). Non-browser clients
|
|
# auto-evict instead of getting stuck on a UI they can't render.
|
|
def enforce_session_limit_for_password_login(user)
|
|
if revoking_sessions?
|
|
revoke_sessions_for_login(user)
|
|
return false
|
|
end
|
|
|
|
return false unless sessions_limit_reached?(user)
|
|
|
|
# Picker only when every token has a tracked session; partial tracking would
|
|
# show a misleading count, so fall through to silent eviction instead.
|
|
if browser_request? && user.user_sessions.count >= user.tokens.size
|
|
handle_sessions_limit_for_login(user)
|
|
true
|
|
else
|
|
evict_oldest_session(user)
|
|
false
|
|
end
|
|
end
|
|
|
|
def browser_request?
|
|
request.user_agent.to_s.include?('Mozilla')
|
|
end
|
|
|
|
def revoking_sessions?
|
|
params[:revoke_session_id].present? || params[:revoke_all_sessions].present?
|
|
end
|
|
|
|
def revoke_sessions_for_login(user)
|
|
if params[:revoke_all_sessions].present?
|
|
user.tokens = {}
|
|
user.save!
|
|
user.user_sessions.destroy_all
|
|
elsif params[:revoke_session_id].present?
|
|
session = user.user_sessions.find_by(id: params[:revoke_session_id])
|
|
return unless session
|
|
|
|
user.tokens.delete(session.client_id)
|
|
user.save!
|
|
session.destroy!
|
|
end
|
|
end
|
|
|
|
def evict_oldest_session(user)
|
|
# Drop pre-rollout untracked tokens first so freshly tracked logins aren't evicted.
|
|
return evict_oldest_token(user) if user.user_sessions.count < user.tokens.size
|
|
|
|
oldest_session = user.user_sessions.order(Arel.sql('COALESCE(last_activity_at, created_at) ASC')).first
|
|
return evict_oldest_token(user) unless oldest_session
|
|
|
|
user.tokens.delete(oldest_session.client_id)
|
|
user.save!
|
|
oldest_session.destroy!
|
|
end
|
|
|
|
# Fallback if a token exists without a UserSession row (e.g., legacy data before tracking shipped).
|
|
def evict_oldest_token(user)
|
|
return if user.tokens.blank?
|
|
|
|
oldest_client_id = user.tokens.min_by { |_, v| v['expiry'].to_i }&.first
|
|
return unless oldest_client_id
|
|
|
|
user.tokens.delete(oldest_client_id)
|
|
user.save!
|
|
end
|
|
|
|
PICKER_SESSION_FIELDS = %i[id browser_name browser_version device_name platform_name platform_version
|
|
ip_address city country last_activity_at created_at].freeze
|
|
|
|
def handle_sessions_limit_for_login(user)
|
|
sessions = user.user_sessions.order(last_activity_at: :desc).map { |s| s.slice(*PICKER_SESSION_FIELDS) }
|
|
render json: { sessions_limit_reached: true, sessions: sessions }, status: :conflict
|
|
end
|
|
|
|
def track_user_session
|
|
client_id = @token&.try(:client) || response.headers['client']
|
|
return unless client_id.present? && @resource.present?
|
|
|
|
UserSessionTrackingService.new(
|
|
user: @resource,
|
|
request: request,
|
|
client_id: client_id
|
|
).create_or_update!
|
|
rescue StandardError => e
|
|
Rails.logger.warn "Session tracking failed: #{e.message}"
|
|
end
|
|
end
|
|
|
|
DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController')
|