Update docs. Implemented a one-time token to work around domain cookies not being immediately return by the browser. Reduce db queries on /api/verify requests.
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled

This commit is contained in:
Dan Milne
2025-10-28 08:20:12 +11:00
parent 10bbbc8c40
commit 94785dbfe7
5 changed files with 183 additions and 37 deletions

View File

@@ -10,15 +10,19 @@ module Api
def verify
# Note: app_slug parameter is no longer used - we match domains directly with ForwardAuthRule
# Get the session from cookie
session_id = extract_session_id
# Check for one-time forward auth token first (to handle race condition)
session_id = check_forward_auth_token
# If no token found, try to get session from cookie
session_id ||= extract_session_id
unless session_id
# No session cookie - user is not authenticated
# No session cookie or token - user is not authenticated
return render_unauthorized("No session cookie")
end
# Find the session
session = Session.find_by(id: session_id)
# Find the session with user association (eager loading for performance)
session = Session.includes(:user).find_by(id: session_id)
unless session
# Invalid session
return render_unauthorized("Invalid session")
@@ -30,10 +34,10 @@ module Api
return render_unauthorized("Session expired")
end
# Update last activity
# Update last activity (skip validations for performance)
session.update_column(:last_activity_at, Time.current)
# Get the user
# Get the user (already loaded via includes(:user))
user = session.user
unless user.active?
return render_unauthorized("User account is not active")
@@ -44,8 +48,12 @@ module Api
forwarded_host = request.headers["X-Forwarded-Host"] || request.headers["Host"]
if forwarded_host.present?
# Load active rules with their associations for better performance
# Preload groups to avoid N+1 queries in user_allowed? checks
rules = ForwardAuthRule.includes(:groups).active
# Find matching forward auth rule for this domain
rule = ForwardAuthRule.active.find { |r| r.matches_domain?(forwarded_host) }
rule = rules.find { |r| r.matches_domain?(forwarded_host) }
unless rule
Rails.logger.warn "ForwardAuth: No rule found for domain: #{forwarded_host}"
@@ -91,13 +99,30 @@ module Api
private
def check_forward_auth_token
# Check for one-time token in query parameters (for race condition handling)
token = params[:fa_token]
return nil unless token.present?
# Try to get session ID from cache
session_id = Rails.cache.read("forward_auth_token:#{token}")
return nil unless session_id
# Verify the session exists and is valid
session = Session.find_by(id: session_id)
return nil unless session && !session.expired?
# Delete the token immediately (one-time use)
Rails.cache.delete("forward_auth_token:#{token}")
session_id
end
def extract_session_id
# Extract session ID from cookie
# Rails uses signed cookies by default
session_id = cookies.signed[:session_id]
Rails.logger.info "ForwardAuth: Session cookie present: #{session_id.present?}, value: #{session_id&.to_s&.first(10)}..."
Rails.logger.info "ForwardAuth: All cookies: #{cookies.to_h.keys.join(', ')}"
session_id
session_id
end
def extract_app_from_headers

View File

@@ -1,3 +1,5 @@
require 'uri'
module Authentication
extend ActiveSupport::Concern
@@ -36,9 +38,7 @@ module Authentication
def after_authentication_url
return_url = session[:return_to_after_authenticating]
Rails.logger.info "Authentication: after_authentication_url - session[:return_to_after_authenticating] = #{return_url.inspect}"
final_url = session.delete(:return_to_after_authenticating) || root_url
Rails.logger.info "Authentication: Final redirect URL: #{final_url}"
final_url
end
@@ -60,9 +60,11 @@ module Authentication
# Set domain for cross-subdomain authentication if we can extract it
cookie_options[:domain] = domain if domain.present?
Rails.logger.info "Authentication: Setting session cookie with options: #{cookie_options.except(:value).merge(value: cookie_options[:value]&.to_s&.first(10) + '...')}"
Rails.logger.info "Authentication: Extracted domain from #{request.host}: #{domain.inspect}"
cookies.signed.permanent[:session_id] = cookie_options
cookies.signed.permanent[:session_id] = cookie_options
# Create a one-time token for immediate forward auth after authentication
# This solves the race condition where browser hasn't processed cookie yet
create_forward_auth_token(session)
end
end
@@ -103,4 +105,35 @@ module Authentication
root_parts = parts[-2..-1]
".#{root_parts.join('.')}"
end
# Create a one-time token for forward auth to handle the race condition
# where the browser hasn't processed the session cookie yet
def create_forward_auth_token(session_obj)
# Generate a secure random token
token = SecureRandom.urlsafe_base64(32)
# Store it with an expiry of 30 seconds
Rails.cache.write(
"forward_auth_token:#{token}",
session_obj.id,
expires_in: 30.seconds
)
# Set the token as a query parameter on the redirect URL
# We need to store this in the controller's session
controller_session = session
if controller_session[:return_to_after_authenticating].present?
original_url = controller_session[:return_to_after_authenticating]
uri = URI.parse(original_url)
# Add token as query parameter
query_params = URI.decode_www_form(uri.query || "").to_h
query_params['fa_token'] = token
uri.query = URI.encode_www_form(query_params)
# Update the session with the tokenized URL
controller_session[:return_to_after_authenticating] = uri.to_s
end
end
end

View File

@@ -67,6 +67,12 @@ class SessionsController < ApplicationController
if request.post?
code = params[:code]&.strip
# Check if user is already authenticated (prevent duplicate submissions)
if authenticated?
redirect_to root_path, notice: "Already signed in."
return
end
# Try TOTP verification first
if user.verify_totp(code)
session.delete(:pending_totp_user_id)