Bumps dependencies (jwt 3.2.0, puma 8.0.2, net-imap 0.6.4.1 and others via bundle update) to resolve bundler-audit advisories, and applies standardrb autofixes so the lint job passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
256 lines
9.5 KiB
Ruby
256 lines
9.5 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
|
|
# Fail closed: with no host we cannot resolve an application or evaluate its
|
|
# group policy. Emitting identity headers here would bypass all per-domain
|
|
# access control, so reject instead.
|
|
Rails.logger.info "ForwardAuth: Access denied - no host header present"
|
|
return render_forbidden("No host header present")
|
|
end
|
|
|
|
# Reaching here implies a matching, active application was resolved above
|
|
# (every other path returns forbidden), so headers are always scoped to it.
|
|
headers = app.headers_for_user(user)
|
|
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
|
|
|
|
# Re-check group membership at use-time. The ApiKey model only validates
|
|
# access on creation, so a user removed from the app's allowed groups
|
|
# afterwards must not keep access via an existing key.
|
|
unless app.user_allowed?(user)
|
|
Rails.logger.info "ForwardAuth: API key '#{api_key.name}' denied - user #{user.email_address} lacks group access to #{app.domain_pattern}"
|
|
return render_bearer_error("Access denied: insufficient group membership")
|
|
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 if token.blank?
|
|
|
|
cached = Rails.cache.read("forward_auth_token:#{token}")
|
|
return nil unless cached.is_a?(Hash)
|
|
|
|
# The token is bound to the host that created it. If the request is
|
|
# arriving at a different host, refuse — and do NOT burn the cache
|
|
# entry, so that the legitimate destination can still redeem within
|
|
# the 60s TTL.
|
|
request_host = (request.headers["X-Forwarded-Host"] || request.headers["Host"])
|
|
.to_s.sub(/:\d+\z/, "").downcase
|
|
return nil if request_host.blank?
|
|
return nil unless cached[:host] == request_host
|
|
|
|
session = Session.find_by(id: cached[:session_id])
|
|
return nil unless session && !session.expired?
|
|
|
|
Rails.cache.delete("forward_auth_token:#{token}")
|
|
cached[: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"] || "/"
|
|
|
|
# X-Forwarded-Host is attacker-influenceable, so only honour the forwarded
|
|
# URL as a post-login redirect target if it resolves to a known, active
|
|
# forward-auth application. Otherwise this is an open redirect: a spoofed
|
|
# host would be stored and reflected into the signin `rd`, then followed
|
|
# (with allow_other_host) after the user authenticates. Fall back to a
|
|
# validated `rd` or, failing that, the IdP's own base URL.
|
|
forwarded_url = "https://#{original_host}#{original_uri}" if original_host.present?
|
|
original_url = validate_redirect_url(forwarded_url) || redirect_url || base_url
|
|
|
|
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?
|
|
|
|
# CLINCH_HOST is the IdP's canonical origin and is mandatory in deployed
|
|
# environments (enforced at boot in config/initializers/clinch_host.rb).
|
|
# We never fall back to the request host: a spoofed X-Forwarded-Host would
|
|
# otherwise redirect the login flow to an attacker-controlled origin. The
|
|
# localhost default only applies to local dev/test.
|
|
host = ENV["CLINCH_HOST"].presence || "http://localhost:3000"
|
|
host.match?(%r{\Ahttps?://}) ? host : "https://#{host}"
|
|
end
|
|
end
|
|
end
|