#!/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