mirror of
https://github.com/scinote-eln/scinote-web.git
synced 2025-10-11 06:16:32 +08:00
Merge pull request #1253 from okriuchykhin/ok_SCI_2608
Adds API authentication with Azure AD [SCI-2608]
This commit is contained in:
commit
dd9090efef
9 changed files with 150 additions and 22 deletions
1
Gemfile
1
Gemfile
|
@ -20,6 +20,7 @@ gem 'omniauth-linkedin-oauth2'
|
||||||
|
|
||||||
# Gems for API implementation
|
# Gems for API implementation
|
||||||
gem 'jwt', '~> 1.5'
|
gem 'jwt', '~> 1.5'
|
||||||
|
gem 'json-jwt'
|
||||||
|
|
||||||
# JS datetime library, requirement of datetime picker
|
# JS datetime library, requirement of datetime picker
|
||||||
gem 'momentjs-rails', '~> 2.17.1'
|
gem 'momentjs-rails', '~> 2.17.1'
|
||||||
|
|
|
@ -92,6 +92,7 @@ GEM
|
||||||
tzinfo (~> 1.1)
|
tzinfo (~> 1.1)
|
||||||
addressable (2.5.2)
|
addressable (2.5.2)
|
||||||
public_suffix (>= 2.0.2, < 4.0)
|
public_suffix (>= 2.0.2, < 4.0)
|
||||||
|
aes_key_wrap (1.0.1)
|
||||||
ajax-datatables-rails (0.3.1)
|
ajax-datatables-rails (0.3.1)
|
||||||
railties (>= 3.1)
|
railties (>= 3.1)
|
||||||
arel (8.0.0)
|
arel (8.0.0)
|
||||||
|
@ -123,6 +124,7 @@ GEM
|
||||||
coderay (>= 1.0.0)
|
coderay (>= 1.0.0)
|
||||||
erubi (>= 1.0.0)
|
erubi (>= 1.0.0)
|
||||||
rack (>= 0.9.0)
|
rack (>= 0.9.0)
|
||||||
|
bindata (2.4.3)
|
||||||
binding_of_caller (0.8.0)
|
binding_of_caller (0.8.0)
|
||||||
debug_inspector (>= 0.0.1)
|
debug_inspector (>= 0.0.1)
|
||||||
bootstrap-sass (3.3.7)
|
bootstrap-sass (3.3.7)
|
||||||
|
@ -246,6 +248,10 @@ GEM
|
||||||
js_cookie_rails (2.1.4)
|
js_cookie_rails (2.1.4)
|
||||||
railties (>= 3.1)
|
railties (>= 3.1)
|
||||||
json (1.8.6)
|
json (1.8.6)
|
||||||
|
json-jwt (1.9.4)
|
||||||
|
activesupport
|
||||||
|
aes_key_wrap
|
||||||
|
bindata
|
||||||
json-schema (2.8.0)
|
json-schema (2.8.0)
|
||||||
addressable (>= 2.4)
|
addressable (>= 2.4)
|
||||||
json_matchers (0.7.2)
|
json_matchers (0.7.2)
|
||||||
|
@ -561,6 +567,7 @@ DEPENDENCIES
|
||||||
jquery-turbolinks
|
jquery-turbolinks
|
||||||
jquery-ui-rails
|
jquery-ui-rails
|
||||||
js_cookie_rails
|
js_cookie_rails
|
||||||
|
json-jwt
|
||||||
json_matchers
|
json_matchers
|
||||||
jwt (~> 1.5)
|
jwt (~> 1.5)
|
||||||
kaminari
|
kaminari
|
||||||
|
|
|
@ -10,15 +10,24 @@ module Api
|
||||||
|
|
||||||
rescue_from StandardError do |e|
|
rescue_from StandardError do |e|
|
||||||
logger.error e.message
|
logger.error e.message
|
||||||
|
logger.error e.backtrace.join("\n")
|
||||||
render json: {}, status: :bad_request
|
render json: {}, status: :bad_request
|
||||||
end
|
end
|
||||||
|
|
||||||
rescue_from JWT::InvalidPayload, JWT::DecodeError do |e|
|
rescue_from JWT::DecodeError,
|
||||||
|
JWT::InvalidPayload,
|
||||||
|
JWT::VerificationError do |e|
|
||||||
logger.error e.message
|
logger.error e.message
|
||||||
render json: { message: I18n.t('api.core.invalid_token') },
|
render json: { message: I18n.t('api.core.invalid_token') },
|
||||||
status: :unauthorized
|
status: :unauthorized
|
||||||
end
|
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
|
def initialize
|
||||||
super
|
super
|
||||||
@iss = nil
|
@iss = nil
|
||||||
|
@ -42,13 +51,13 @@ module Api
|
||||||
if auth_params[:grant_type] == 'password'
|
if auth_params[:grant_type] == 'password'
|
||||||
user = User.find_by_email(auth_params[:email])
|
user = User.find_by_email(auth_params[:email])
|
||||||
unless user && user.valid_password?(auth_params[:password])
|
unless user && user.valid_password?(auth_params[:password])
|
||||||
raise StandardError, 'Wrong user password'
|
raise StandardError, 'Default: Wrong user password'
|
||||||
end
|
end
|
||||||
payload = { user_id: user.id }
|
payload = { user_id: user.id }
|
||||||
token = CoreJwt.encode(payload)
|
token = CoreJwt.encode(payload)
|
||||||
render json: { token_type: 'bearer', access_token: token }
|
render json: { token_type: 'bearer', access_token: token }
|
||||||
else
|
else
|
||||||
raise StandardError, 'Wrong grant type in request'
|
raise StandardError, 'Default: Wrong grant type in request'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -59,7 +68,16 @@ module Api
|
||||||
@token =
|
@token =
|
||||||
request.headers['Authorization'].scan(/Bearer (.*)$/).flatten.last
|
request.headers['Authorization'].scan(/Bearer (.*)$/).flatten.last
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
def authenticate_request!
|
def authenticate_request!
|
||||||
|
@ -67,24 +85,29 @@ module Api
|
||||||
method(auth_method).call
|
method(auth_method).call
|
||||||
return true if current_user
|
return true if current_user
|
||||||
end
|
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)
|
payload = CoreJwt.decode(token)
|
||||||
@current_user = User.find_by_id(payload['user_id'])
|
@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
|
# Implement sliding sessions, i.e send new token in case of successful
|
||||||
# authorization and when tokens TTL reached specific value (to avoid token
|
# authorization and when tokens TTL reached specific value (to avoid token
|
||||||
# generation on each request)
|
# generation on each request)
|
||||||
if CoreJwt.refresh_needed?(payload)
|
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
|
response.headers['X-Access-Token'] = new_token
|
||||||
end
|
end
|
||||||
rescue JWT::ExpiredSignature
|
|
||||||
render json: { message: I18n.t('api.core.expired_token') },
|
|
||||||
status: :unauthorized
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def load_iss
|
def load_iss
|
||||||
@iss = CoreJwt.read_iss(token)
|
@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
|
end
|
||||||
|
|
||||||
def auth_params
|
def auth_params
|
||||||
|
|
|
@ -437,6 +437,21 @@ class User < ApplicationRecord
|
||||||
statistics
|
statistics
|
||||||
end
|
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
|
# json friendly attributes
|
||||||
NOTIFICATIONS_TYPES = %w(assignments_notification recent_notification
|
NOTIFICATIONS_TYPES = %w(assignments_notification recent_notification
|
||||||
assignments_email_notification
|
assignments_email_notification
|
||||||
|
|
|
@ -15,11 +15,13 @@ module Api
|
||||||
attr_accessor :core_api_sign_alg
|
attr_accessor :core_api_sign_alg
|
||||||
attr_accessor :core_api_token_ttl
|
attr_accessor :core_api_token_ttl
|
||||||
attr_accessor :core_api_token_iss
|
attr_accessor :core_api_token_iss
|
||||||
|
attr_accessor :azure_ad_apps
|
||||||
|
|
||||||
def initialize
|
def initialize
|
||||||
@core_api_sign_alg = 'HS256'
|
@core_api_sign_alg = 'HS256'
|
||||||
@core_api_token_ttl = 30.minutes
|
@core_api_token_ttl = 30.minutes
|
||||||
@core_api_token_iss = 'SciNote'
|
@core_api_token_iss = 'SciNote'
|
||||||
|
@azure_ad_apps = {}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
60
app/services/api/azure_jwt.rb
Normal file
60
app/services/api/azure_jwt.rb
Normal 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
|
31
config/initializers/api.rb
Normal file
31
config/initializers/api.rb
Normal 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
|
|
@ -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
|
|
|
@ -64,7 +64,7 @@ class Extends
|
||||||
API_VERSIONS = ['20170715']
|
API_VERSIONS = ['20170715']
|
||||||
|
|
||||||
# Array used for injecting names of additional authentication methods for API
|
# 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]
|
OMNIAUTH_PROVIDERS = [:linkedin]
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue