From 0e372af7297a6388f3105cc56fb9c3d95a7786d2 Mon Sep 17 00:00:00 2001 From: Oleksii Kriuchykhin Date: Tue, 24 Jul 2018 14:21:33 +0200 Subject: [PATCH] Adds API authentication with Azure AD [SCI-2608] --- Gemfile | 1 + Gemfile.lock | 7 ++++ app/controllers/api/api_controller.rb | 43 ++++++++++++++----- app/models/user.rb | 15 +++++++ app/services/api.rb | 2 + app/services/api/azure_jwt.rb | 60 +++++++++++++++++++++++++++ config/initializers/api.rb | 31 ++++++++++++++ config/initializers/core_api.rb | 11 ----- config/initializers/extends.rb | 2 +- 9 files changed, 150 insertions(+), 22 deletions(-) create mode 100644 app/services/api/azure_jwt.rb create mode 100644 config/initializers/api.rb delete mode 100644 config/initializers/core_api.rb diff --git a/Gemfile b/Gemfile index 652c9d90c..3bbb09b1c 100644 --- a/Gemfile +++ b/Gemfile @@ -20,6 +20,7 @@ gem 'omniauth-linkedin-oauth2' # Gems for API implementation gem 'jwt', '~> 1.5' +gem 'json-jwt' # JS datetime library, requirement of datetime picker gem 'momentjs-rails', '~> 2.17.1' diff --git a/Gemfile.lock b/Gemfile.lock index df1dcf418..8319bd721 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -92,6 +92,7 @@ GEM tzinfo (~> 1.1) addressable (2.5.2) public_suffix (>= 2.0.2, < 4.0) + aes_key_wrap (1.0.1) ajax-datatables-rails (0.3.1) railties (>= 3.1) arel (8.0.0) @@ -123,6 +124,7 @@ GEM coderay (>= 1.0.0) erubi (>= 1.0.0) rack (>= 0.9.0) + bindata (2.4.3) binding_of_caller (0.8.0) debug_inspector (>= 0.0.1) bootstrap-sass (3.3.7) @@ -246,6 +248,10 @@ GEM js_cookie_rails (2.1.4) railties (>= 3.1) json (1.8.6) + json-jwt (1.9.4) + activesupport + aes_key_wrap + bindata json-schema (2.8.0) addressable (>= 2.4) json_matchers (0.7.2) @@ -561,6 +567,7 @@ DEPENDENCIES jquery-turbolinks jquery-ui-rails js_cookie_rails + json-jwt json_matchers jwt (~> 1.5) kaminari diff --git a/app/controllers/api/api_controller.rb b/app/controllers/api/api_controller.rb index bb06def19..630e7fa4c 100644 --- a/app/controllers/api/api_controller.rb +++ b/app/controllers/api/api_controller.rb @@ -10,15 +10,24 @@ module Api rescue_from StandardError do |e| logger.error e.message + logger.error e.backtrace.join("\n") render json: {}, status: :bad_request end - rescue_from JWT::InvalidPayload, JWT::DecodeError do |e| + rescue_from JWT::DecodeError, + JWT::InvalidPayload, + JWT::VerificationError do |e| logger.error e.message render json: { message: I18n.t('api.core.invalid_token') }, status: :unauthorized end + rescue_from JWT::ExpiredSignature do |e| + logger.error e.message + render json: { message: I18n.t('api.core.expired_token') }, + status: :unauthorized + end + def initialize super @iss = nil @@ -42,13 +51,13 @@ module Api if auth_params[:grant_type] == 'password' user = User.find_by_email(auth_params[:email]) unless user && user.valid_password?(auth_params[:password]) - raise StandardError, 'Wrong user password' + raise StandardError, 'Default: Wrong user password' end payload = { user_id: user.id } token = CoreJwt.encode(payload) render json: { token_type: 'bearer', access_token: token } else - raise StandardError, 'Wrong grant type in request' + raise StandardError, 'Default: Wrong grant type in request' end end @@ -59,7 +68,16 @@ module Api @token = request.headers['Authorization'].scan(/Bearer (.*)$/).flatten.last end - raise StandardError, 'No token in the header' unless @token + raise StandardError, 'Common: No token in the header' unless @token + end + + def azure_jwt_auth + return unless iss =~ %r{windows.net/|microsoftonline.com/} + token_payload, = Api::AzureJwt.decode(token) + @current_user = User.from_azure_jwt_token(token_payload) + unless current_user + raise JWT::InvalidPayload, 'Azure AD: User mapping not found' + end end def authenticate_request! @@ -67,24 +85,29 @@ module Api method(auth_method).call return true if current_user end - # Check request header for proper auth token + + # Default token implementation + unless iss == Api.configuration.core_api_token_iss + raise JWT::InvalidPayload, 'Default: Wrong ISS in the token' + end payload = CoreJwt.decode(token) @current_user = User.find_by_id(payload['user_id']) + unless current_user + raise JWT::InvalidPayload, 'Default: User mapping not found' + end + # Implement sliding sessions, i.e send new token in case of successful # authorization and when tokens TTL reached specific value (to avoid token # generation on each request) if CoreJwt.refresh_needed?(payload) - new_token = CoreJwt.encode(user_id: @current_user.id) + new_token = CoreJwt.encode(user_id: current_user.id) response.headers['X-Access-Token'] = new_token end - rescue JWT::ExpiredSignature - render json: { message: I18n.t('api.core.expired_token') }, - status: :unauthorized end def load_iss @iss = CoreJwt.read_iss(token) - raise JWT::InvalidPayload, 'Wrong ISS in the token' unless @iss + raise JWT::InvalidPayload, 'Common: Missing ISS in the token' unless @iss end def auth_params diff --git a/app/models/user.rb b/app/models/user.rb index 9e953670e..c124181ca 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -437,6 +437,21 @@ class User < ApplicationRecord statistics end + def self.from_azure_jwt_token(token_payload) + includes(:user_identities) + .where( + 'user_identities.provider=? AND user_identities.uid=?', + Api.configuration.azure_ad_apps[token_payload[:aud]][:provider], + token_payload[:sub] + ) + .references(:user_identities) + .take + end + + def has_linked_account?(provider) + user_identities.where(provider: provider).exists? + end + # json friendly attributes NOTIFICATIONS_TYPES = %w(assignments_notification recent_notification assignments_email_notification diff --git a/app/services/api.rb b/app/services/api.rb index da7998464..589cc0275 100644 --- a/app/services/api.rb +++ b/app/services/api.rb @@ -15,11 +15,13 @@ module Api attr_accessor :core_api_sign_alg attr_accessor :core_api_token_ttl attr_accessor :core_api_token_iss + attr_accessor :azure_ad_apps def initialize @core_api_sign_alg = 'HS256' @core_api_token_ttl = 30.minutes @core_api_token_iss = 'SciNote' + @azure_ad_apps = {} end end end diff --git a/app/services/api/azure_jwt.rb b/app/services/api/azure_jwt.rb new file mode 100644 index 000000000..4df270651 --- /dev/null +++ b/app/services/api/azure_jwt.rb @@ -0,0 +1,60 @@ +module Api + class AzureJwt + require 'jwt' + + KEYS_CACHING_PERIOD = 7.days + + LEEWAY = 30 + + def self.fetch_rsa_key(k_id, app_id) + cache_key = "api_azure_ad_rsa_key_#{k_id}" + Rails.cache.fetch(cache_key, expires_in: KEYS_CACHING_PERIOD) do + conf_url = Api.configuration.azure_ad_apps[app_id][:conf_url] + keys_url = JSON.parse(Net::HTTP.get(URI(conf_url)))['jwks_uri'] + data = JSON.parse(Net::HTTP.get(URI.parse(keys_url))) + verif_key = data['keys'].find { |key| key['kid'] == k_id } + unless verif_key + raise JWT::VerificationError, + 'Azure AD: No keys from key endpoint match the key in the token' + end + JSON::JWK.new(verif_key).to_key.to_s + end + end + + def self.decode(token) + # First, extract key id from token header, + # [1] is position of the header. + # We will use this ID to fetch correct public key needed for + # verification of the token signature + unverified_token = JWT.decode(token, nil, false) + + k_id = unverified_token[1]['kid'] + unless k_id + raise JWT::VerificationError, 'Azure AD: No Key ID in token header' + end + + # Now search for matching app variables in configuration + app_id = unverified_token[0]['aud'] + app_config = Api.configuration.azure_ad_apps[app_id] + unless app_config + raise JWT::VerificationError, + 'Azure AD: No application configured with such ID' + end + + # Decode token payload and verify it's signature. + payload, = JWT.decode( + token, + OpenSSL::PKey::RSA.new(fetch_rsa_key(k_id, app_id)), + true, + algorithm: 'RS256', + verify_expiration: true, + verify_aud: true, + aud: app_id, + verify_iss: true, + iss: app_config[:iss], + nbf_leeway: LEEWAY + ) + HashWithIndifferentAccess.new(payload) + end + end +end diff --git a/config/initializers/api.rb b/config/initializers/api.rb new file mode 100644 index 000000000..4ab3e3898 --- /dev/null +++ b/config/initializers/api.rb @@ -0,0 +1,31 @@ +Api.configure do |config| + if ENV['CORE_API_SIGN_ALG'] + config.core_api_sign_alg = ENV['CORE_API_SIGN_ALG'] + end + if ENV['CORE_API_TOKEN_TTL'] + config.core_api_token_ttl = ENV['CORE_API_TOKEN_TTL'] + end + if ENV['CORE_API_TOKEN_ISS'] + config.core_api_token_iss = ENV['CORE_API_TOKEN_ISS'] + end + + vars = ENV.select { |name, _| name =~ /^[[:alnum:]]*_AZURE_AD_APP_ID/ } + vars.each do |name, value| + app_name = name.sub('_AZURE_AD_APP_ID', '') + config.azure_ad_apps[value] = {} + + iss = ENV["#{app_name}_AZURE_AD_ISS"] + raise StandardError, "No ISS for #{app_name} Azure app" unless iss + config.azure_ad_apps[value][:iss] = iss + + conf_url = ENV["#{app_name}_AZURE_AD_CONF_URL"] + raise StandardError, "No CONF_URL for #{app_name} Azure app" unless conf_url + config.azure_ad_apps[value][:conf_url] = conf_url + + provider = ENV["#{app_name}_AZURE_AD_PROVIDER_NAME"] + unless provider + raise StandardError, "No PROVIDER_NAME for #{app_name} Azure app" + end + config.azure_ad_apps[value][:provider] = provider + end +end diff --git a/config/initializers/core_api.rb b/config/initializers/core_api.rb deleted file mode 100644 index c76ddf54d..000000000 --- a/config/initializers/core_api.rb +++ /dev/null @@ -1,11 +0,0 @@ -Api.configure do |config| - if ENV['CORE_API_SIGN_ALG'] - config.core_api_sign_alg = ENV['CORE_API_SIGN_ALG'] - end - if ENV['CORE_API_TOKEN_TTL'] - config.core_api_token_ttl = ENV['CORE_API_TOKEN_TTL'] - end - if ENV['CORE_API_TOKEN_ISS'] - config.core_api_token_iss = ENV['CORE_API_TOKEN_ISS'] - end -end diff --git a/config/initializers/extends.rb b/config/initializers/extends.rb index 80f3e4273..d186191da 100644 --- a/config/initializers/extends.rb +++ b/config/initializers/extends.rb @@ -64,7 +64,7 @@ class Extends API_VERSIONS = ['20170715'] # Array used for injecting names of additional authentication methods for API - API_PLUGABLE_AUTH_METHODS = [] + API_PLUGABLE_AUTH_METHODS = [:azure_jwt_auth] OMNIAUTH_PROVIDERS = [:linkedin]