Files
clinch/app/controllers/api/forward_auth_controller.rb
Dan Milne 6844c5fab3 Clean up forward auth caching: remove duplication, fix rate limiting, and plug cache gaps
- Remove duplicated app_allows_user_cached?/headers_for_user_cached methods; call model methods directly
- Fix sliding-window rate limit bug by using increment instead of write (avoids TTL reset)
- Use cached app lookup in validate_redirect_url instead of hitting DB on every unauthorized request
- Add cache busting to ApplicationGroup so group assignment changes invalidate the cache
- Eager-load user groups (includes(user: :groups)) to eliminate N+1 queries
- Replace pluck(:name) with map(&:name) to use already-loaded associations
- Remove hardcoded fallback domain, dead methods, and unnecessary comments
- Fix test indentation and make group-order assertions deterministic

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 23:54:19 +11:00

251 lines
8.2 KiB
Ruby

module Api
class ForwardAuthController < ApplicationController
allow_unauthenticated_access
skip_before_action :verify_authenticity_token
before_action :check_forward_auth_rate_limit
after_action :track_failed_forward_auth_attempt
# GET /api/verify
# Called by reverse proxies (Traefik, Caddy, nginx) to verify authentication and authorization.
def verify
bearer_result = authenticate_bearer_token
return bearer_result if bearer_result
session_id = check_forward_auth_token
session_id ||= extract_session_id
unless session_id
return render_unauthorized("No session cookie")
end
session = Session.includes(user: :groups).find_by(id: session_id)
unless session
return render_unauthorized("Invalid session")
end
if session.expired?
session.destroy
return render_unauthorized("Session expired")
end
# Debounce last_activity_at updates (at most once per minute)
if session.last_activity_at.nil? || session.last_activity_at < 1.minute.ago
session.update_column(:last_activity_at, Time.current)
end
user = session.user
unless user.active?
return render_unauthorized("User account is not active")
end
forwarded_host = request.headers["X-Forwarded-Host"] || request.headers["Host"]
app = nil
if forwarded_host.present?
apps = cached_forward_auth_apps
app = apps.find { |a| a.matches_domain?(forwarded_host) }
if app
unless app.active?
Rails.logger.info "ForwardAuth: Access denied to #{forwarded_host} - application is inactive"
return render_forbidden("No authentication rule configured for this domain")
end
unless app.user_allowed?(user)
Rails.logger.info "ForwardAuth: User #{user.email_address} denied access to #{forwarded_host} by app #{app.domain_pattern}"
return render_forbidden("You do not have permission to access this domain")
end
Rails.logger.info "ForwardAuth: User #{user.email_address} granted access to #{forwarded_host} by app #{app.domain_pattern} (policy: #{app.policy_for_user(user)})"
else
Rails.logger.info "ForwardAuth: Access denied to #{forwarded_host} - no authentication rule configured"
return render_forbidden("No authentication rule configured for this domain")
end
else
Rails.logger.info "ForwardAuth: User #{user.email_address} authenticated (no domain specified)"
end
headers = if app
app.headers_for_user(user)
else
Application::DEFAULT_HEADERS.map { |key, header_name|
case key
when :user, :email, :name
[header_name, user.email_address]
when :username
[header_name, user.username] if user.username.present?
when :groups
user.groups.any? ? [header_name, user.groups.map(&:name).join(",")] : nil
when :admin
[header_name, user.admin? ? "true" : "false"]
end
}.compact.to_h
end
headers.each { |key, value| response.headers[key] = value }
Rails.logger.debug "ForwardAuth: Headers sent: #{headers.keys.join(", ")}" if headers.any?
head :ok
end
private
def fa_cache
Rails.application.config.forward_auth_cache
end
def cached_forward_auth_apps
fa_cache.fetch("fa_apps", expires_in: 5.minutes) do
Application.forward_auth.includes(:allowed_groups).to_a
end
end
RATE_LIMIT_MAX_FAILURES = 50
RATE_LIMIT_WINDOW = 1.minute
def check_forward_auth_rate_limit
count = fa_cache.read("fa_fail:#{request.remote_ip}")
return unless count && count >= RATE_LIMIT_MAX_FAILURES
response.headers["Retry-After"] = "60"
head :too_many_requests
end
def track_failed_forward_auth_attempt
return unless response.status.in?([401, 403, 302])
return if response.status == 302 && !response.headers["X-Auth-Reason"]
cache_key = "fa_fail:#{request.remote_ip}"
# Use increment to avoid resetting TTL on each failure (fixed window)
unless fa_cache.increment(cache_key)
fa_cache.write(cache_key, 1, expires_in: RATE_LIMIT_WINDOW)
end
end
def authenticate_bearer_token
auth_header = request.headers["Authorization"]
return nil unless auth_header&.start_with?("Bearer ")
token = auth_header.delete_prefix("Bearer ").strip
return render_bearer_error("Missing token") if token.blank?
api_key = ApiKey.find_by_token(token)
return render_bearer_error("Invalid or expired API key") unless api_key&.active?
user = api_key.user
return render_bearer_error("User account is not active") unless user.active?
forwarded_host = request.headers["X-Forwarded-Host"] || request.headers["Host"]
app = api_key.application
if forwarded_host.present? && !app.matches_domain?(forwarded_host)
return render_bearer_error("API key not valid for this domain")
end
unless app.active?
return render_bearer_error("Application is inactive")
end
api_key.touch_last_used!
headers = app.headers_for_user(user)
headers.each { |key, value| response.headers[key] = value }
Rails.logger.info "ForwardAuth: API key '#{api_key.name}' authenticated user #{user.email_address} for #{forwarded_host}"
head :ok
end
def render_bearer_error(message)
render json: { error: message }, status: :unauthorized
end
def check_forward_auth_token
token = params[:fa_token]
return nil unless token.present?
session_id = Rails.cache.read("forward_auth_token:#{token}")
return nil unless session_id
session = Session.find_by(id: session_id)
return nil unless session && !session.expired?
Rails.cache.delete("forward_auth_token:#{token}")
session_id
end
def extract_session_id
cookies.signed[:session_id]
end
def render_unauthorized(reason = nil)
Rails.logger.info "ForwardAuth: Unauthorized - #{reason}"
response.headers["X-Auth-Reason"] = reason if reason.present?
redirect_url = validate_redirect_url(params[:rd])
base_url = determine_base_url(redirect_url)
original_host = request.headers["X-Forwarded-Host"]
original_uri = request.headers["X-Forwarded-Uri"] || request.headers["X-Forwarded-Path"] || "/"
original_url = if original_host
"https://#{original_host}#{original_uri}"
else
redirect_url || base_url
end
session[:return_to_after_authenticating] = original_url
login_params = { rd: original_url, rm: request.method }
login_url = "#{base_url}/signin?#{login_params.to_query}"
redirect_to login_url, allow_other_host: true, status: :found
end
def render_forbidden(reason = nil)
Rails.logger.info "ForwardAuth: Forbidden - #{reason}"
response.headers["X-Auth-Reason"] = reason if reason.present?
head :forbidden
end
def validate_redirect_url(url)
return nil unless url.present?
begin
uri = URI.parse(url)
return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
return nil unless Rails.env.development? || uri.scheme == "https"
redirect_domain = uri.host.downcase
return nil unless redirect_domain.present?
matching_app = cached_forward_auth_apps.find do |app|
app.active? && app.matches_domain?(redirect_domain)
end
matching_app ? url : nil
rescue URI::InvalidURIError
nil
end
end
def determine_base_url(redirect_url)
return redirect_url if redirect_url.present?
if ENV["CLINCH_HOST"].present?
host = ENV["CLINCH_HOST"]
host.match?(/^https?:\/\//) ? host : "https://#{host}"
else
request_host = request.host || request.headers["X-Forwarded-Host"]
if request_host.present?
Rails.logger.warn "ForwardAuth: CLINCH_HOST not set, using request host: #{request_host}"
"https://#{request_host}"
else
raise StandardError, "ForwardAuth: CLINCH_HOST environment variable not set and no request host available."
end
end
end
end
end