Adds API authentication with Azure AD [SCI-2608]

This commit is contained in:
Oleksii Kriuchykhin 2018-07-24 14:21:33 +02:00
parent 54caa772d9
commit 0e372af729
9 changed files with 150 additions and 22 deletions

View file

@ -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'

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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]