#!/usr/bin/ruby # # Perforce Okta MFA trigger # # @copyright 2018 Perforce Software. All rights reserved. # @version / # 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) body = verifyOktaOTP( token, JSON.generate({'passCode' => otp}), opts ) if body['factorResult'] != "SUCCESS" # The "factorResult" could be "CHALLENGE" if the user did # not enter an OTP; however this script does not yet handle # that scenario. error("verification failed, factorResult was #{body['factorResult']}") end 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