okta-mfa.rb #1

  • //
  • guest/
  • perforce_software/
  • mfa/
  • main/
  • okta/
  • okta-mfa.rb
  • View
  • Commits
  • Open Download .zip Download (13 KB)
#!/usr/bin/ruby

#
# Perforce Okta MFA trigger
#
# @copyright   2018 Perforce Software. All rights reserved.
# @version     <release>/<patch>

# You will need to install the rest-client gem and support package
# on linux:
# sudo apt-get install ruby-dev
##
# The above is to install the header files for ruby, so the gem
# install below can compile the native parts.
##
# sudo gem install rest-client

require 'rest-client'
require 'optparse'
require 'logger'
require 'json'
require 'yaml'

# configuration file format is YAML:
# logging : (path to log file, optional)
# okta_key: Okta API key
# okta_url: URL to Okta server
#
# Update the Okta credentials below this line, or provide a configuration file
@OKTA_KEY = 'OKTA_KEY'
@OKTA_URL = 'https://OKTA_URL'

# enable logging by uncommenting this line and setting the log file path
# @LOGGING='/var/log/triggers/triggers.log'

# * End of updates
#
def error( message="" )
	puts JSON.generate({
		"status" => 1,
		"message" => message
		})
	raise message
end

def auth_error( message )
	error( "Authentication failed.  Reason provided: #{message}")
end

def done( scheme, status, message="", otp="", token="")
	obj = {
		"status" => status
	}
	if scheme.size > 0
	  obj["scheme"] = scheme
	end
	if message.size > 0
	  obj["message"] = message
	end
	if otp.size > 0
	  obj["challenge"] = otp
	end
	if token.size > 0
	  obj["token"] = 'OKTA::' + token
	end
	puts JSON.generate( obj )
	return 0
end

def done_list( methods )
  methodlist  = []
	methods.each { |k,v|
	    methodlist.push( [ k, v ] )
	}
	return methodlist
end

def done_skip( message="" )
	# done( scheme, status, message)
	# 'done' does the exit for us.
	done( "", 2, message )
end

def waiting( message="" )
	#  Return 2 to indicate that we're waiting on the user to accept or deny the 2FA prompt
	done( "", 2, message )
end

def getOktaUser( username, email, options )
	res = nil;
	uid = (email != nil && email.length > 0) ? email : username
	begin
		res = RestClient.get(options[:okta_url] + '/api/v1/users/' + uid, 'Authorization' => 'SSWS ' + options[:okta_key] )
	rescue RestClient::ExceptionWithResponse => err
		error( JSON.parse(err.response)['errorSummary'] )
	end
	body = JSON.parse(res.body)
	error( body['errorSummary'] ) unless res.code == 200

	begin
	  res = RestClient.get(options[:okta_url] + '/api/v1/users/' + body['id'] + '/factors', 'Authorization' => 'SSWS ' + options[:okta_key] )
	rescue RestClient::ExceptionWithResponse => err
	  error( JSON.parse(err.response)['errorSummary'] )
	end
	body2 = JSON.parse(res.body)
	error( body2['errorSummary'] ) unless res.code == 200

	body['factors'] = body2

	body
end

def verifyOktaOTP( url, passcode, options )
	res = nil
	begin
	  res = RestClient.post(url, passcode, 'Authorization' => 'SSWS ' + options[:okta_key], 'Content-Type' => 'application/json', 'Accepts' => 'application/json' )
	rescue RestClient::ExceptionWithResponse => err
	  auth_error( JSON.parse(err.response)['errorSummary'] )
	end
	# options[:logger].debug("Got okta response: #{res}")
	body = JSON.parse(res.body)
	error( body['errorSummary'] ) unless res.code.between?(200,299)

	body
end

def pollOktaPush( url, options )
	res = nil
	begin
		res = RestClient.get(url, 'Authorization' => 'SSWS ' + options[:okta_key], 'Content-Type' => 'application/json', 'Accepts' => 'application/json' )
	rescue RestClient::ExceptionWithResponse => err
		error( JSON.parse(err.response)['errorSummary'] )
	end
	body = JSON.parse(res.body)
	error( body['errorSummary'] ) unless res.code.between?(200,299)

	return waiting( "Waiting for approval from your device...") if body['factorResult'] == "WAITING"
	auth_error( body['factorResult'] ) if body['factorResult'] != "SUCCESS"
end

def defaultDescription(m)
	m['provider'] + "-" + m['scheme']
end

def hidePhoneNumber(s)
	# replace all but the last 4 chars with *
	lastPos = s.length - 5
	for pos in 0..lastPos
		s[lastPos - pos] = '*'
	end
	return s
end

def hideEmail(s)
	# everything after the @ + 4 chars
	lastPos = s.index('@') + 5
	for pos in lastPos..s.length
		s[pos] = '*'
	end
	return s
end

def prettyDevice(s)
	return 'Android' if (s.downcase.include? 'android')
	return 'Windows' if (s.downcase.include? 'windows') # need to test this
	return 'iPhone'  if (s.downcase.include? 'iphone') # need to test this

	return s
end

def PrettyOktaFactor(m, logger)
	profile = m['profile']
	return defaultDescription(m) if profile.nil?
	phone = profile['phoneNumber']
	credId = profile['credentialId']
	device = profile['deviceType']

	# logger.debug("#{profile} : #{phone} : #{credId} : #{device}")
	# return a fancy version of text; this case list may be incomplete
	case m['scheme']
	when 'sms'
		return 'Text me a passcode by SMS to ' + hidePhoneNumber(phone) unless phone.nil?
	when 'token:software:totp'
		return 'Use a one-time passcode for ' + hideEmail(credId) + ' from ' + m['provider'].capitalize unless m['provider'].nil? || credId.nil?
	when 'question'
		return 'Answer a security question from ' + m['provider'].capitalize unless m['provider'].nil?
	when 'call'
		return 'Call me at ' + hidePhoneNumber(phone) unless phone.nil?
	when 'push'
		return 'Send a push notification to my ' + prettyDevice(device) unless device.nil?
	end
	defaultDescription(m)
end

def getEnrolledFactors(user, email, options)
		userObj = getOktaUser( user, email, options )
		# logger.debug("User obj: #{userObj}")
		enrolled = userObj['factors'].reject{ |f|
			f["status"] != "ACTIVE"
		}.map { |f|
			{'scheme'=>f['factorType'], 'id'=>f['id'], 'provider'=>f['provider'], '_links'=>f['_links'], 'profile'=>f['profile']}
		}
end

def oktaData(s)
	error("Bad okta data: #{s}") unless s.index('OKTA::') == 0
	return s.split('OKTA::')[1]
end

def isOktaData(s)
	return s.index('OKTA::') == 0
end

def oktaHandler(action, opts)
	# NOTE: we prefix both the method id and the token with OKTA:: so that
	#       it's easier to tell when something should be handled by us
	user = opts[:user]
	email = opts[:email]
	host = opts[:host]
	scheme = opts[:scheme] || opts[:method]
	token = opts[:token]
	logger = opts[:logger]

	# logger.debug("Action: #{action} User: #{user} Host: #{host} Scheme: #{scheme} Token: #{token}")
	@schemes = [ 'otp-generated', 'otp-requested', 'challenge', 'external' ]

	begin
		case action.to_sym
		when :list
			methods = {}
			getEnrolledFactors(user, email, opts).each do |m|
				methods[ "OKTA::" + m['id'] ] = PrettyOktaFactor(m, logger)
			end
			# don't output here, just return the list of methods for this user
			return done_list( methods )

		when :init
			# Get the user+factors (or die)
			userObj = getOktaUser( user, email, opts )
			enrolled = getEnrolledFactors(user, email, opts)

			# strip OKTA:: from the scheme (method)
			scheme = oktaData(scheme)

			# Pick a scheme the user is enrolled in
			itt = enrolled.index {|f| f['id'] == scheme }
			error("Unknown method #{scheme}") if itt == nil
			factor = enrolled[itt]
			opts[:logger].debug("Using factor #{factor}")

			case factor['scheme']
			when 'push'
				res = verifyOktaOTP( factor['_links']['verify']['href'], '{}', opts )
				return done( "external", 0, "Check your authorization application and enter the passcode", "", res['_links']['poll']['href'])
			when 'token:software:totp'
				return done( "otp-generated", 0, "Enter your passcode from #{factor['provider'].capitalize}", "",  factor['_links']['verify']['href'])
			when 'sms'
				res = verifyOktaOTP( factor['_links']['verify']['href'], '{}', opts )
				return done( "otp-requested", 0, "Enter the passcode from the text message", "", res['_links']['verify']['href'])
			when 'question'
				done( "challenge", 0, "", factor['profile']['questionText'], factor['_links']['self']['href'] +"/verify")
			when 'call'
				res = verifyOktaOTP( factor['_links']['verify']['href'], '{}', opts )
				return done( "otp-requested", 0, "Enter the passcode provided in the phone call", "", res['_links']['verify']['href'])
			else
				error("Not configured to handle init for scheme #{factor['scheme']}, aborting")
			end

		when :check
		  if !@schemes.include?(scheme)
				error("Unknown scheme #{scheme}!")
	    end

	    # strip the OKTA::
	    token = oktaData(token)

			case scheme
			when 'otp-generated'
				otp = $stdin.read.strip
				# Check token (or die)
				verifyOktaOTP( token, JSON.generate({'passCode' => otp}), opts )
				return done( "otp-generated", 0, "Success!  Completing login...")
			when 'otp-requested'
				otp = $stdin.read.strip
				# Check token (or die)
				verifyOktaOTP( token, JSON.generate({'passCode' => otp}), opts )
				return done( "otp-requested", 0, "Success!  Completing login...")
			when 'challenge'
				otp = $stdin.read.strip
				# Check answer (or die)
				verifyOktaOTP( token, JSON.generate({'answer' => otp}), opts )
				return done( "challenge", 0, "Success!  Completing login...")
			when 'external'
				pollOktaPush( token, opts )
				return done( "external", 0, "Success!  Completing login...")
			else
				error("Not configured to handle check for scheme #{scheme}, aborting")
			end
		end
	rescue Exception => e
		logger.debug("error: #{e}")
	end
end

def pre2FA(opts)
=begin {
	   "status" : code,
	   "message" : "a message for the caller",
	   "methodlist" : [
	      [ "method", "description" ],
	      [ "method", "description" ],
	      ....
	   ]
	}
=end

	obj = { "status" => 0,
				 "methodlist" => []
				}
	# also get the list from the okta script
	oktalist = oktaHandler( "list", opts )
	# opts[:logger].debug("okta list returned #{oktalist}")
	if oktalist.kind_of?(Array)
		obj["methodlist"].push(*oktalist)
	else
		obj["status"] = 1
	end

	puts obj.to_json
	return 0
end

def init2FA(opts)
	# oktaHandler barfs its own output
	return oktaHandler('init', opts) if isOktaData(opts[:method])
 	opts[:logger].debug("Bad token: #{opts[:method]}")
	return 1
end

def check2FA(opts)
	# oktaHandler barfs its own output
 	return oktaHandler('check', opts) if isOktaData(opts[:token])
 	opts[:logger].debug("Bad token: #{opts[:token]}")
	return 1
end

def trigger_lines(options)
	isWindows = false
  script = "%quote%#{File.absolute_path(__FILE__)}%quote%";
  config = options[:config_file] ?
  			" -c %quote%#{File.absolute_path(options[:config_file])}%quote%"
        : ""

    # Define the trigger entries suitable for this script; replace depot
    # paths as appropriate.
  puts "	okta.pre-2fa       auth-pre-2fa   auth \"#{script}#{config} --type=pre-2fa --email=%quote%%email%%quote% --user=%user% --host=%host%\""
  puts "	okta.init-2fa      auth-init-2fa  auth \"#{script}#{config} --type=init-2fa --email=%quote%%email%%quote% --user=%user% --host=%host% --method=%method%\""
  puts "	okta.check-2fa     auth-check-2fa auth \"#{script}#{config} --type=check-2fa --email=%quote%%email%%quote% --user=%user% --host=%host% --scheme=%scheme% --token=%token%\""
end

def main_processor
	options = {}
	optparser = OptionParser.new do |opts|
		opts.banner = "Usage: #{File.basename(__FILE__)} [options]\nTrigger script for supporting Okta MFA authentication in Perforce Server"

		# --output
		opts.on("-o", "--output", "Output the required trigger lines") do
			options[:trigger_output] = true
		end

		opts.on("-c", "--config=CONFIGFILE", "Configuration file for Okta presets") do |v|
			options[:config_file] = v
		end

		# --user=%user%
		opts.on("-u", "--user=USER", "User ID") do |v|
			options[:user] = v
		end

		# --email=%email%
		opts.on("-e", "--email=email", "User email") do |v|
			options[:email] = v
		end

		# --host=%host%
		opts.on("-h", "--host=HOST", "Host machine") do |v|
			options[:host] = v
		end

		# --type=pre-2fa|init-2fa|check-2fa
		opts.on("-t", "--type=TYPE", "Trigger type") do |v|
			options[:trigger_type] = v
		end

		# --scheme=%scheme%
		opts.on("-s", "--scheme=SCHEME", "2fa scheme") do |v|
			options[:scheme] = v
		end

		# --method=%method%
		opts.on("-m", "--method=METHOD", "2fa method") do |v|
			options[:method] = v
		end

		# --token=%token%
		opts.on("-T", "--token=TOKEN", "2fa token") do |v|
			options[:token] = v
		end
	end

	optparser.parse!

	# turn config file into options as well
	if !options[:config_file].nil?
		config = YAML.load_file(options[:config_file])
		config.each do |k,v|
			options[k.to_sym] = v
		end
	end

	# also (maybe) set the defaults from this file
	options[:okta_key] ||= @OKTA_KEY
	options[:okta_url] ||= @OKTA_URL
	options[:logging] ||= @LOGGING

	if !options[:trigger_output].nil?
		trigger_lines(options)
		exit 0
	end

	if options[:user].nil? || options[:trigger_type].nil?
		puts optparser.to_s
		exit 1
	end

  logger = Logger.new(options[:logging], 10, 1024000)
  # don't print the okta_key; the map mangles the output a little
	logger.debug("TRIGGER: #{options.map{|k,v| {k=>(k==:okta_key) ? '[hidden]':v}}}")
	options[:logger] = logger

	case options[:trigger_type]
		when 'pre-2fa'
			return pre2FA(options)
		when 'init-2fa'
			return init2FA(options)
		when 'check-2fa'
			return check2FA(options)
		else
			logger.debug("unknown type: #{options[:trigger_type]}")
			return 1
	end
end

main_processor
# Change User Description Committed
#2 26295 Nathan Fiedler Verify OTP response is successful.
#1 24367 Doug Scheirer Initial checkin