diff --git a/Gemfile b/Gemfile index 4ff1548..8e37c91 100644 --- a/Gemfile +++ b/Gemfile @@ -15,3 +15,5 @@ gem 'google-api-client', '~> 0.8.2' gem 'nokogiri', '~> 1.6.6.2' gem 'archieml', '~> 0.3.0' +gem 'jwt' +gem 'typhoeus' \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index aaf0c6a..8871f9f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -25,10 +25,13 @@ GEM backports (3.6.4) builder (3.2.2) daemons (1.1.9) + ethon (0.10.1) + ffi (>= 1.3.0) eventmachine (1.0.7) extlib (0.9.16) faraday (0.9.1) multipart-post (>= 1.2, < 3) + ffi (1.9.18) google-api-client (0.8.2) activesupport (>= 3.2) addressable (~> 2.3) @@ -87,6 +90,8 @@ GEM rack (~> 1.0) thread_safe (0.3.4) tilt (1.4.1) + typhoeus (1.1.2) + ethon (>= 0.9.0) tzinfo (1.2.2) thread_safe (~> 0.1) @@ -97,6 +102,7 @@ DEPENDENCIES archieml (~> 0.3.0) aws-sdk (~> 2.0.23) google-api-client (~> 0.8.2) + jwt nokogiri (~> 1.6.6.2) puma rack (~> 1.6.1) @@ -105,3 +111,7 @@ DEPENDENCIES sinatra (~> 1.4.6) sinatra-contrib (~> 1.4.2) thin (~> 1.6.2) + typhoeus + +BUNDLED WITH + 1.14.6 diff --git a/config.ru b/config.ru index 8bc294d..5e2db37 100644 --- a/config.ru +++ b/config.ru @@ -1,5 +1,6 @@ require 'rubygems' require 'bundler' +require './lib/iap_verifier' Bundler.require require 'rack' @@ -53,4 +54,5 @@ require './lib/driveshaft' require './lib/driveshaft/app' require './lib/driveshaft/auth' +use Auth::IAPVerifier run Driveshaft::App.new diff --git a/lib/iap_verifier.rb b/lib/iap_verifier.rb new file mode 100644 index 0000000..9ae2ff7 --- /dev/null +++ b/lib/iap_verifier.rb @@ -0,0 +1,197 @@ +require 'jwt' +require 'typhoeus' +module Auth + class IAPVerifier + + ## + # Example usage: + # (in config.ru) + # + # ``` + # require 'badcom/IAPVerifier' + # use Badcom::IAPVerifier, skip_paths: ["/skip"] + # ``` + # + # OPTIONS: + # skip_paths: an array of paths to skip. Publically accessable paths should be listed here. + # + # ENVARS + # ENV['IAP_SKIP_AUTH']: disables iap verification for all incoming requests. + # ENV['IAP_EMAIL_WHITELIST']: comma seperated list of emails and domains to whitelist. defaults to @nytimes.com + # + def initialize(app, options={}) + @app, @options, @key_cache = app, options, {} + + @skip_hoist = !ENV['IAP_SKIP_HOIST'].nil? + @skip_auth = !ENV['IAP_SKIP_AUTH'].nil? + @rackenv = ENV['RACK_ENV'] + envar_whitelist = (ENV['IAP_EMAIL_WHITELIST'] || '@nytimes.com').split(',').map(&:strip) + + @domain_whitelist = envar_whitelist.select{ |itm| itm[0] == '@' } + @email_whitelist = envar_whitelist.select{ |itm| itm[0] != '@' } + + @logger = Logger.new(STDOUT) + end + + def return_forbidden(logger_message, request, override_response=false) + @logger.info "REQUEST FORBIDDEN: BASEURL [#{request.base_url}], EMAIL [#{request.env['auth.verified_email']}], IP [#{request.ip}], XFORWARDEDFOR [#{request.env['HTTP_X_FORWARDED_FOR']}], PATH [#{request.path_info}], REASON [#{logger_message}]" + [403, {"Content-Type" => "text/plain"}, [ override_response ? logger_message : 'FORBIDDEN (BADCOM). SEE APPLICATION LOGS FOR DETAILS']] + end + + def continue_request(env, request, message) + @logger.info "REQUEST PERMITTED: BASEURL [#{request.base_url}], EMAIL [#{request.env['auth.verified_email']}], IP [#{request.ip}], XFORWARDEDFOR [#{request.env['HTTP_X_FORWARDED_FOR']}], PATH [#{request.path_info}], REASON [#{message}]" + iaap_auth_cookie = request.params['cookie'] #request.cookies['GCP_IAAP_AUTH_TOKEN'] + + return @app.call(env) if (iaap_auth_cookie.nil? || @skip_hoist) + + begin + #unverified = JWT.decode(iaap_auth_cookie, nil, false) + #@logger.info "UNVERIFIED TOKEN #{'.'+request.host.split('.')[1..-1].join('.')}" + #exp = unverified[0]['exp'] + status, headers, body = @app.call(env) + response = Rack::Response.new body, status, headers + response.set_cookie("GCP_IAAP_AUTH_TOKEN", {value: iaap_auth_cookie, domain: '.'+request.host.split('.')[1..-1].join('.'), path: "/", expires: Time.now+24*60*60}) + response.set_cookie("GCP_IAAP_AUTH_TOKEN2", {value: iaap_auth_cookie, domain: '.'+request.host.split('.')[1..-1].join('.'), path: "/", expires: Time.now+24*60*60}) + return response.finish + rescue + @logger.info "RESCUE COOKIE SETTING #{errors}" + return @app.call(env) + end + + end + + def decode(token, api_key) + pub = OpenSSL::PKey::EC.new api_key + JWT.decode token, pub, true, { :algorithm => 'ES256' } + end + + def allow_email?(verified_email) + domain = '@' + verified_email.split('@')[1] + + if @domain_whitelist.include? domain + return true + end + + if @email_whitelist.include? verified_email + return true + end + return false + end + + def call(env) + request = Rack::Request.new(env) + + if @skip_auth + env['auth.skipped'] = 'envar' + return continue_request(env, request, "IAP_SKIP_AUTH ACTIVATED") + end + + # Whitelist requests if in dev or test + if @rackenv == 'development' || @rackenv == 'test' + env['auth.skipped'] = "rackenv" + env['auth.rackenv'] = @rackenv + return continue_request(env, request, "RACK_ENV DEV/TEST") + end + + # Check route whitelist + if @options[:skip_paths] && @options[:skip_paths].any? { |skip| skip.match(request.path) } + env['auth.skipped'] = 'route' + return continue_request(env, request, "ROUTE WHITELIST") + end + + # Whitelist cluster.local requests + if request.ip.to_s.match /^10\./ + env['auth.skipped'] = 'ip' + return continue_request(env, request, "IP WHITELIST") + end + + # Whitelist localhost + if request.ip == '127.0.0.1' + env['auth.skipped'] = 'ip' + return continue_request(env, request, "IP WHITELIST") + end + + # Whitelist ssh tunnel + if request.ip == '::1' + env['auth.skipped'] = 'ip' + return continue_request(env, request, "IP WHITELIST") + end + + jwt_token = request.env['HTTP_X_GOOG_AUTHENTICATED_USER_JWT'] + header_email = request.env['HTTP_X_GOOG_AUTHENTICATED_USER_EMAIL'] + + if ENV['IAP_VERBOSE'] + @logger.info "jwt_token: #{jwt_token}, header_email: #{header_email}" + end + + if jwt_token.nil? + return return_forbidden "REQUEST MISSING JWT TOKEN HEADER. IP: [#{request.ip}]", request + end + + unverified = nil + + begin + unverified = JWT.decode(jwt_token, nil, false) + if ( unverified.nil? || + unverified[1].nil? || + unverified[1]['kid'].nil? || + unverified[0].nil? || + unverified[0]['sub'].nil? || + unverified[0]['email'].nil? ) + + return return_forbidden "BAD JWT TOKEN, MISSING FIELDS. IP: [#{request.ip}]", request + end + rescue + return return_forbidden "BAD JWT TOKEN, MALFORMED1. TOKEN [#{jwt_token}]", request + end + + api_key = nil + begin + api_key = get_iap_key unverified[1]['kid'] + rescue + @logger.info "500 ERROR: COULD NOT RETRIEVE PUBLIC KEY. IP [#{request.ip}], PATH [#{request.path_info}]" + return [500, {"Content-Type" => "text/plain"}, ["COULD NOT RETRIEVE PUBLIC KEY"]] + end + + begin + decoded = decode(jwt_token, api_key) + env['auth.verified_email'] = decoded[0]['email'] + env['auth.verified_sub'] = decoded[0]['sub'] + + # Check that header email matches verified jwt email + if header_email.gsub('accounts.google.com:', '') != decoded[0]['email'] + return return_forbidden "HEADER EMAIL DOES NOT MATCH JWT EMAIL. IP: [#{request.ip}]", request + end + + # Check that email is in whitelist + if !allow_email?( decoded[0]['email']) + return return_forbidden "EMAIL NOT PERMITTED ACCESS. CONTACT APPLICATION OWNER. IP: [#{request.ip}]", request, true + end + + rescue + return return_forbidden "BAD JWT TOKEN. MALFORMED2. IP: [#{request.ip}]", request + end + + continue_request(env, request, 'NO SKIP') + end + + # Returns key if key in @key_cache. + # Otherwise refresh cache from gstatic.com + def get_iap_key(kid) + + return @key_cache[kid] if @key_cache.include? kid + + res = Typhoeus.get('https://www.gstatic.com/iap/verify/public_key') + if res.code != 200 + raise 'Non 200 response from google key server' + end + @key_cache = JSON.parse(res.body) + + if @key_cache[kid].nil? + raise 'key not found in response from google key server' + end + + @key_cache[kid] + end + end +end \ No newline at end of file