mirror of
https://github.com/scinote-eln/scinote-web.git
synced 2024-12-25 17:24:51 +08:00
Adds API authentication with Azure AD [SCI-2608]
This commit is contained in:
parent
54caa772d9
commit
0e372af729
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
|
||||
gem 'jwt', '~> 1.5'
|
||||
gem 'json-jwt'
|
||||
|
||||
# JS datetime library, requirement of datetime picker
|
||||
gem 'momentjs-rails', '~> 2.17.1'
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
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']
|
||||
|
||||
# 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]
|
||||
|
||||
|
|
Loading…
Reference in a new issue