mirror of
https://github.com/System-End/identity-vault.git
synced 2026-04-19 22:05:07 +00:00
298 lines
9.6 KiB
Ruby
298 lines
9.6 KiB
Ruby
class SAMLController < ApplicationController
|
|
include SAMLHelper
|
|
|
|
layout "logged_out", only: [ :welcome ]
|
|
|
|
skip_before_action :authenticate_identity!, only: [ :metadata, :sp_initiated_get, :idp_initiated, :welcome ]
|
|
before_action :check_enterprise_features!, except: [ :welcome ]
|
|
|
|
AUTHN_REQUEST_TTL = 30.minutes
|
|
SSO_ENDPOINT_PATH = "/saml/auth"
|
|
|
|
def metadata
|
|
xml = SAMLService::Entities.metadata_xml
|
|
render xml:
|
|
end
|
|
|
|
def idp_initiated
|
|
if Rails.env.staging? && params[:slug] == "slack"
|
|
render "static_pages/slack_staging" and return
|
|
end
|
|
|
|
return unless ensure_sp_configured!(slug: params[:slug])
|
|
return unless check_allowed_emails!
|
|
|
|
unless @sp_config[:allow_idp_initiated]
|
|
@error = "This SP is not configured for IdP-initiated authentication"
|
|
render :error and return
|
|
end
|
|
|
|
unless current_identity
|
|
redirect_to saml_welcome_path(return_to: request.fullpath) and return
|
|
end
|
|
|
|
set_honeybadger_context
|
|
|
|
# Try to assign to Slack workspace if not yet done
|
|
if params[:slug] == "slack"
|
|
provision_slack_via_scim_if_needed
|
|
try_assign_to_slack_workspace unless current_identity.is_in_workspace
|
|
end
|
|
|
|
response = build_saml_response(
|
|
identity: current_identity,
|
|
sp_config: @sp_config,
|
|
in_response_to: nil
|
|
)
|
|
|
|
render_saml_response(saml_response: response, sp_config: @sp_config)
|
|
end
|
|
|
|
def sp_initiated_get
|
|
@authn_request, @relay_state = SAML2::Bindings::HTTPRedirect.decode(request.url)
|
|
return unless ensure_sp_configured!(entity_id: @authn_request.issuer.id)
|
|
return unless ensure_authn_request_valid!
|
|
return unless verify_authn_request_signature!
|
|
|
|
unless current_identity
|
|
redirect_to saml_welcome_path(return_to: request.fullpath) and return
|
|
end
|
|
|
|
return unless check_allowed_emails!
|
|
|
|
set_honeybadger_context
|
|
|
|
# Only check replay after authentication, since unauthenticated users will be redirected
|
|
# back to this same URL after login
|
|
return unless check_replay!
|
|
|
|
response = build_saml_response(
|
|
identity: current_identity,
|
|
sp_config: @sp_config,
|
|
in_response_to: @authn_request
|
|
)
|
|
|
|
render_saml_response(saml_response: response, sp_config: @sp_config)
|
|
|
|
rescue SAML2::MissingMessage
|
|
# hotfix for zach email
|
|
@missing_message = true
|
|
render :error and return
|
|
end
|
|
|
|
def welcome
|
|
@saml_return_to = params[:return_to]
|
|
|
|
# Only SP-initiated flows need the welcome page (users come from external SP with SAMLRequest)
|
|
# IdP-initiated flows assume the user is already logged in, so they never hit this page
|
|
if @saml_return_to.present?
|
|
begin
|
|
uri = URI.parse(@saml_return_to)
|
|
query_params = Rack::Utils.parse_query(uri.query)
|
|
|
|
if query_params["SAMLRequest"].present?
|
|
# Ensure the path starts with /
|
|
path = @saml_return_to.start_with?("/") ? @saml_return_to : "/#{@saml_return_to}"
|
|
full_url = "#{request.base_url}#{path}"
|
|
authn_request, _ = SAML2::Bindings::HTTPRedirect.decode(full_url)
|
|
@sp_config = SAMLService::Entities.sp_by_entity_id(authn_request.issuer.id) if authn_request&.issuer&.id
|
|
end
|
|
rescue => e
|
|
Rails.logger.error "SAML welcome: error parsing return_to: #{e.class} - #{e.message}"
|
|
end
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def provision_slack_via_scim_if_needed
|
|
return if current_identity.slack_id.present?
|
|
|
|
scenario = current_identity.onboarding_scenario_instance
|
|
|
|
slack_result = SCIMService.find_or_create_user(
|
|
identity: current_identity,
|
|
scenario: scenario
|
|
)
|
|
|
|
if slack_result[:success]
|
|
current_identity.update(slack_id: slack_result[:slack_id])
|
|
Rails.logger.info "Slack provisioning successful via SCIM for #{current_identity.id}: #{slack_result[:message]}"
|
|
else
|
|
Rails.logger.error "Slack provisioning failed via SCIM for #{current_identity.id}: #{slack_result[:error]}"
|
|
Sentry.capture_message(
|
|
"Slack provisioning failed via SCIM",
|
|
level: :error,
|
|
extra: {
|
|
identity_public_id: current_identity.public_id,
|
|
identity_email: current_identity.primary_email,
|
|
slack_error: slack_result[:error]
|
|
}
|
|
)
|
|
flash[:error] = "We couldn't create your Slack account. Please contact support."
|
|
end
|
|
end
|
|
|
|
def try_assign_to_slack_workspace
|
|
return unless current_identity.slack_id.present?
|
|
|
|
case SlackService.user_workspace_status(user_id: current_identity.slack_id)
|
|
when :in_workspace
|
|
current_identity.update(is_in_workspace: true) unless current_identity.is_in_workspace
|
|
when :not_in_workspace
|
|
scenario = current_identity.onboarding_scenario_instance
|
|
return unless scenario.slack_channels.any?
|
|
|
|
AssignSlackWorkspaceJob.perform_later(
|
|
slack_id: current_identity.slack_id,
|
|
user_type: :multi_channel_guest,
|
|
channel_ids: scenario.slack_channels,
|
|
identity_id: current_identity.id
|
|
)
|
|
end
|
|
end
|
|
|
|
def check_enterprise_features!
|
|
unless Flipper.enabled?(:are_we_enterprise_yet, current_identity)
|
|
@error = "SAML authentication is not available"
|
|
render :error, status: :forbidden and return false
|
|
end
|
|
end
|
|
|
|
def verify_authn_request_signature!
|
|
return true if @sp_config[:allow_unsigned_requests]
|
|
|
|
unless @sp_config[:signing_certificate].present?
|
|
@error = "SP signature verification required but no signing certificate configured"
|
|
render :error, status: :bad_request and return false
|
|
end
|
|
|
|
query_string = URI(request.url).query
|
|
query_params = Rack::Utils.parse_query(query_string)
|
|
|
|
unless query_params["Signature"].present?
|
|
@error = "AuthnRequest signature required but not provided"
|
|
render :error, status: :unauthorized and return false
|
|
end
|
|
|
|
begin
|
|
cert = OpenSSL::X509::Certificate.new(
|
|
"-----BEGIN CERTIFICATE-----\n#{@sp_config[:signing_certificate]}\n-----END CERTIFICATE-----"
|
|
)
|
|
|
|
signed_query = query_string.split("&Signature=").first
|
|
signature_bytes = Base64.decode64(query_params["Signature"])
|
|
|
|
digest = case query_params["SigAlg"]
|
|
when "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"
|
|
OpenSSL::Digest::SHA256.new
|
|
when "http://www.w3.org/2000/09/xmldsig#rsa-sha1"
|
|
OpenSSL::Digest::SHA1.new
|
|
else
|
|
raise "Unsupported signature algorithm: #{query_params['SigAlg']}"
|
|
end
|
|
|
|
verified = cert.public_key.verify(digest, signature_bytes, signed_query)
|
|
|
|
unless verified
|
|
@error = "AuthnRequest signature verification failed"
|
|
render :error, status: :unauthorized and return false
|
|
end
|
|
|
|
true
|
|
rescue => e
|
|
Rails.logger.error "SAML signature verification error: #{e.message}"
|
|
@error = "AuthnRequest signature verification failed"
|
|
render :error, status: :unauthorized and return false
|
|
end
|
|
end
|
|
|
|
def check_replay!
|
|
request_id = @authn_request.id
|
|
cache_key = "saml:authn_request:#{request_id}"
|
|
|
|
if Rails.cache.exist?(cache_key)
|
|
@error = "AuthnRequest has already been processed (replay detected)"
|
|
render :error, status: :bad_request and return false
|
|
end
|
|
|
|
# Cache the request ID for the TTL window
|
|
Rails.cache.write(cache_key, true, expires_in: AUTHN_REQUEST_TTL)
|
|
true
|
|
end
|
|
|
|
def ensure_authn_request_valid!
|
|
unless @authn_request.is_a?(SAML2::AuthnRequest)
|
|
@error = "SAML request is not a valid AuthnRequest"
|
|
render :error, status: :bad_request and return false
|
|
end
|
|
|
|
unless @authn_request.valid_schema?
|
|
@error = "SAML AuthnRequest does not conform to the required XML schema"
|
|
render :error, status: :bad_request and return false
|
|
end
|
|
|
|
unless @authn_request.valid_interoperable_profile?
|
|
@error = "SAML AuthnRequest does not conform to the SAML2 Interoperable Profile"
|
|
render :error, status: :bad_request and return false
|
|
end
|
|
|
|
unless @authn_request.resolve(@sp_config[:entity].service_providers.first)
|
|
@error = "SAML AuthnRequest could not be resolved for this Service Provider"
|
|
render :error, status: :bad_request and return false
|
|
end
|
|
|
|
expected_destination = request.base_url + SSO_ENDPOINT_PATH
|
|
if @authn_request.destination.present? && @authn_request.destination != expected_destination
|
|
@error = "AuthnRequest Destination does not match IdP SSO endpoint"
|
|
render :error, status: :bad_request and return false
|
|
end
|
|
|
|
# Validate IssueInstant is within acceptable time window
|
|
if @authn_request.issue_instant
|
|
issue_time = @authn_request.issue_instant
|
|
now = Time.now.utc
|
|
|
|
if issue_time > now + 1.minute # Allow 1 min clock skew forward
|
|
@error = "AuthnRequest IssueInstant is in the future"
|
|
render :error, status: :bad_request and return false
|
|
end
|
|
|
|
if issue_time < now - AUTHN_REQUEST_TTL
|
|
@error = "AuthnRequest has expired"
|
|
render :error, status: :bad_request and return false
|
|
end
|
|
end
|
|
|
|
true
|
|
end
|
|
|
|
def ensure_sp_configured!(entity_id: nil, slug: nil)
|
|
return unless entity_id || slug
|
|
|
|
if slug.present?
|
|
@sp_config = SAMLService::Entities.sp_by_slug(slug)
|
|
else
|
|
@sp_config = SAMLService::Entities.sp_by_entity_id(entity_id)
|
|
end
|
|
|
|
unless @sp_config.present?
|
|
@error = "Service Provider not configured"
|
|
render :error, status: :bad_request and return false
|
|
end
|
|
|
|
true
|
|
end
|
|
|
|
def check_allowed_emails!
|
|
return true unless @sp_config[:allowed_emails].present?
|
|
return true unless current_identity
|
|
|
|
unless @sp_config[:allowed_emails].include?(current_identity.primary_email)
|
|
@error = "You are not authorized to access this service"
|
|
render :error, status: :forbidden and return false
|
|
end
|
|
|
|
true
|
|
end
|
|
end
|