diff --git a/app/controllers/api/auth_providers_controller.rb b/app/controllers/api/auth_providers_controller.rb index 8eb5dfb5e..3aa69adeb 100644 --- a/app/controllers/api/auth_providers_controller.rb +++ b/app/controllers/api/auth_providers_controller.rb @@ -96,10 +96,8 @@ class API::AuthProvidersController < API::ApiController elsif params['auth_provider']['providable_type'] == OpenIdConnectProvider.name params.require(:auth_provider) .permit(:name, :providable_type, - providable_attributes: %i[id issuer discovery client_auth_method scope prompt - send_scope_to_token_endpoint post_logout_redirect_uri uid_field extra_authorize_params - allow_authorize_params client__identifier client__secret client__redirect_uri - client__scheme client__host client__port client__authorization_endpoint client__token_endpoint + providable_attributes: %i[id issuer discovery client_auth_method scope prompt send_scope_to_token_endpoint + client__identifier client__secretclient__authorization_endpoint client__token_endpoint client__userinfo_endpoint client__jwks_uri client__end_session_endpoint profile_url], auth_provider_mappings_attributes: [:id, :local_model, :local_field, :api_field, :api_endpoint, :api_data_type, :_destroy, transformation: [:type, :format, :true_value, :false_value, diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb index 6cf918af4..d01ae980f 100644 --- a/app/controllers/users/omniauth_callbacks_controller.rb +++ b/app/controllers/users/omniauth_callbacks_controller.rb @@ -73,7 +73,7 @@ class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController logger.error 'user already linked' redirect_to root_url, alert: t('omniauth.this_account_is_already_linked_to_an_user_of_the_platform', NAME: active_provider.name) rescue StandardError => e - logger.unknown "an expected error occurred: #{e}" + logger.error "an expected error occurred: #{e}" raise e end end diff --git a/app/frontend/src/javascript/components/authentication-provider/data-mapping-form.tsx b/app/frontend/src/javascript/components/authentication-provider/data-mapping-form.tsx index 6bb27b61d..64b4a12f7 100644 --- a/app/frontend/src/javascript/components/authentication-provider/data-mapping-form.tsx +++ b/app/frontend/src/javascript/components/authentication-provider/data-mapping-form.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'; import { UseFormRegister, useFieldArray, ArrayPath, useWatch, Path } from 'react-hook-form'; import { FieldValues } from 'react-hook-form/dist/types/fields'; import AuthProviderAPI from '../../api/auth-provider'; -import { MappingFields, mappingType } from '../../models/authentication-provider'; +import { MappingFields, mappingType, ProvidableType } from '../../models/authentication-provider'; import { Control } from 'react-hook-form/dist/types/form'; import { FormSelect } from '../form/form-select'; import { FormInput } from '../form/form-input'; @@ -10,11 +10,13 @@ import { useTranslation } from 'react-i18next'; import { FabButton } from '../base/fab-button'; import { TypeMappingModal } from './type-mapping-modal'; import { useImmer } from 'use-immer'; -import { HtmlTranslate } from '../base/html-translate'; +import { Oauth2DataMappingForm } from './oauth2-data-mapping-form'; +import { OpenidConnectDataMappingForm } from './openid-connect-data-mapping-form'; export interface DataMappingFormProps { register: UseFormRegister, control: Control, + providerType: ProvidableType, } type selectModelFieldOption = { value: string, label: string }; @@ -22,7 +24,7 @@ type selectModelFieldOption = { value: string, label: string }; /** * Partial form to define the mapping of the data between the API of the authentication provider and the application internals. */ -export const DataMappingForm = ({ register, control }: DataMappingFormProps) => { +export const DataMappingForm = ({ register, control, providerType }: DataMappingFormProps) => { const { t } = useTranslation('admin'); const [dataMapping, setDataMapping] = useState(null); const [isOpenTypeMappingModal, updateIsOpenTypeMappingModal] = useImmer>(new Map()); @@ -125,21 +127,8 @@ export const DataMappingForm =
- - - } - label={t('app.admin.authentication.data_mapping_form.api_field')} /> + {providerType === 'OAuth2Provider' && } + {providerType === 'OpenIdConnectProvider' && }
diff --git a/app/frontend/src/javascript/components/authentication-provider/oauth2-data-mapping-form.tsx b/app/frontend/src/javascript/components/authentication-provider/oauth2-data-mapping-form.tsx new file mode 100644 index 000000000..24ede2224 --- /dev/null +++ b/app/frontend/src/javascript/components/authentication-provider/oauth2-data-mapping-form.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { UseFormRegister } from 'react-hook-form'; +import { Control } from 'react-hook-form/dist/types/form'; +import { FieldValues } from 'react-hook-form/dist/types/fields'; +import { FormInput } from '../form/form-input'; +import { FormSelect } from '../form/form-select'; +import { HtmlTranslate } from '../base/html-translate'; +import { useTranslation } from 'react-i18next'; + +interface Oauth2DataMappingFormProps { + register: UseFormRegister, + control: Control, + index: number, +} + +export const Oauth2DataMappingForm = ({ register, control, index }: Oauth2DataMappingFormProps) => { + const { t } = useTranslation('admin'); + + return ( +
+ + + } + label={t('app.admin.authentication.oauth2_data_mapping_form.api_field')} /> +
+ ); +}; diff --git a/app/frontend/src/javascript/components/authentication-provider/openid-connect-data-mapping-form.tsx b/app/frontend/src/javascript/components/authentication-provider/openid-connect-data-mapping-form.tsx new file mode 100644 index 000000000..445016caa --- /dev/null +++ b/app/frontend/src/javascript/components/authentication-provider/openid-connect-data-mapping-form.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { UseFormRegister } from 'react-hook-form'; +import { FieldValues } from 'react-hook-form/dist/types/fields'; +import { FormInput } from '../form/form-input'; +import { HtmlTranslate } from '../base/html-translate'; +import { useTranslation } from 'react-i18next'; + +interface OpenidConnectDataMappingFormProps { + register: UseFormRegister, + index: number, +} + +export const OpenidConnectDataMappingForm = ({ register, index }: OpenidConnectDataMappingFormProps) => { + const { t } = useTranslation('admin'); + + return ( +
+ + + } + label={t('app.admin.authentication.openid_connect_data_mapping_form.api_field')} /> +
+ ); +}; diff --git a/app/frontend/src/javascript/components/authentication-provider/openid-connect-form.tsx b/app/frontend/src/javascript/components/authentication-provider/openid-connect-form.tsx index 49329b5a8..675c07b51 100644 --- a/app/frontend/src/javascript/components/authentication-provider/openid-connect-form.tsx +++ b/app/frontend/src/javascript/components/authentication-provider/openid-connect-form.tsx @@ -132,12 +132,6 @@ export const OpenidConnectForm = - = ({ action, provider, onError, onSuccess }) => { const { handleSubmit, register, control, formState, setValue } = useForm({ defaultValues: { ...provider } }); const output = useWatch({ control }); - const [providableType, setProvidableType] = useState(provider?.providable_type); + const [providableType, setProvidableType] = useState(provider?.providable_type); const [strategyName, setStrategyName] = useState(provider?.strategy_name); const { t } = useTranslation('admin'); @@ -72,7 +72,7 @@ export const ProviderForm: React.FC = ({ action, provider, on * Callback triggered when the providable type is changed. * Changing the providable type will change the form to match the new type. */ - const onProvidableTypeChange = (type: string) => { + const onProvidableTypeChange = (type: ProvidableType) => { setProvidableType(type); }; @@ -108,7 +108,7 @@ export const ProviderForm: React.FC = ({ action, provider, on currentFormValues={output.providable_attributes as OpenIdConnectProvider} formState={formState} setValue={setValue} />} - {providableType && providableType !== 'DatabaseProvider' && } + {providableType && providableType !== 'DatabaseProvider' && }
{t('app.admin.authentication.provider_form.save')}
diff --git a/app/frontend/src/javascript/models/authentication-provider.ts b/app/frontend/src/javascript/models/authentication-provider.ts index 3a4b400be..22f779174 100644 --- a/app/frontend/src/javascript/models/authentication-provider.ts +++ b/app/frontend/src/javascript/models/authentication-provider.ts @@ -1,8 +1,10 @@ +export type ProvidableType = 'DatabaseProvider' | 'OAuth2Provider' | 'OpenIdConnectProvider'; + export interface AuthenticationProvider { id?: number, name: string, status: 'active' | 'previous' | 'pending' - providable_type: 'DatabaseProvider' | 'OAuth2Provider' | 'OpenIdConnectProvider', + providable_type: ProvidableType, strategy_name: string auth_provider_mappings_attributes: Array, providable_attributes?: OAuth2Provider | OpenIdConnectProvider @@ -46,19 +48,11 @@ export interface OpenIdConnectProvider { discovery: boolean, client_auth_method?: 'basic' | 'jwks', scope?: string, - response_type?: 'code' | 'id_token', prompt?: 'none' | 'login' | 'consent' | 'select_account', send_scope_to_token_endpoint?: string, - post_logout_redirect_uri?: string, - uid_field?: string, - extra_authorize_params?: string, - allow_authorize_params?: string, client__identifier: string, client__secret: string, client__redirect_uri?: string, - client__scheme: 'http' | 'https', - client__host: string, - client__port: number, client__authorization_endpoint?: string, client__token_endpoint?: string, client__userinfo_endpoint?: string, diff --git a/app/frontend/src/stylesheets/modules/authentication-provider/data-mapping-form.scss b/app/frontend/src/stylesheets/modules/authentication-provider/data-mapping-form.scss index c1166ed03..8ccfa3092 100644 --- a/app/frontend/src/stylesheets/modules/authentication-provider/data-mapping-form.scss +++ b/app/frontend/src/stylesheets/modules/authentication-provider/data-mapping-form.scss @@ -3,7 +3,7 @@ margin-left: 20px; } .local-data, - .remote-data { + .remote-data > *{ display: flex; flex-direction: row; } diff --git a/app/models/auth_provider.rb b/app/models/auth_provider.rb index 9babff5a4..efa589618 100644 --- a/app/models/auth_provider.rb +++ b/app/models/auth_provider.rb @@ -23,7 +23,7 @@ class AuthProvider < ApplicationRecord validates :providable_type, inclusion: { in: PROVIDABLE_TYPES } validates :name, presence: true, uniqueness: true - validates_with OAuth2ProviderValidator, if: -> { providable_type == 'OAuth2Provider' } + validates_with UserUidMappedValidator, if: -> { %w[OAuth2Provider OpenIdConnectProvider].include?(providable_type) } before_create :set_initial_state diff --git a/app/models/concerns/amount_concern.rb b/app/models/concerns/amount_concern.rb index b2d07d050..b744f85ac 100644 --- a/app/models/concerns/amount_concern.rb +++ b/app/models/concerns/amount_concern.rb @@ -1,3 +1,6 @@ +# frozen_string_literal: true + +# Read and write the amount attribute, after converting to/from cents. module AmountConcern extend ActiveSupport::Concern diff --git a/app/models/concerns/single_sign_on_concern.rb b/app/models/concerns/single_sign_on_concern.rb new file mode 100644 index 000000000..a38b95bd9 --- /dev/null +++ b/app/models/concerns/single_sign_on_concern.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +# Add single sign on functionalities to the user model +module SingleSignOnConcern + extend ActiveSupport::Concern + require 'sso_logger' + + included do + # enable OmniAuth authentication only if needed + devise :omniauthable, omniauth_providers: [AuthProvider.active.strategy_name.to_sym] unless + AuthProvider.active.providable_type == DatabaseProvider.name + + ## Retrieve the requested data in the User and user's Profile tables + ## @param sso_mapping {String} must be of form 'user._field_' or 'profile._field_'. Eg. 'user.email' + def get_data_from_sso_mapping(sso_mapping) + parsed = /^(user|profile)\.(.+)$/.match(sso_mapping) + if parsed[1] == 'user' + self[parsed[2].to_sym] + elsif parsed[1] == 'profile' + case sso_mapping + when 'profile.avatar' + profile.user_avatar.remote_attachment_url + when 'profile.address' + invoicing_profile.address.address + when 'profile.organization_name' + invoicing_profile.organization.name + when 'profile.organization_address' + invoicing_profile.organization.address.address + when 'profile.gender' + statistic_profile.gender + when 'profile.birthday' + statistic_profile.birthday + else + profile[parsed[2].to_sym] + end + end + end + + ## Set some data on the current user, according to the sso_key given + ## @param sso_mapping {String} must be of form 'user._field_' or 'profile._field_'. Eg. 'user.email' + ## @param data {*} the data to put in the given key. Eg. 'user@example.com' + def set_data_from_sso_mapping(sso_mapping, data) + if sso_mapping.to_s.start_with? 'user.' + self[sso_mapping[5..-1].to_sym] = data unless data.nil? + elsif sso_mapping.to_s.start_with? 'profile.' + case sso_mapping.to_s + when 'profile.avatar' + profile.user_avatar ||= UserAvatar.new + profile.user_avatar.remote_attachment_url = data + when 'profile.address' + invoicing_profile ||= InvoicingProfile.new + invoicing_profile.address ||= Address.new + invoicing_profile.address.address = data + when 'profile.organization_name' + invoicing_profile ||= InvoicingProfile.new + invoicing_profile.organization ||= Organization.new + invoicing_profile.organization.name = data + when 'profile.organization_address' + invoicing_profile ||= InvoicingProfile.new + invoicing_profile.organization ||= Organization.new + invoicing_profile.organization.address ||= Address.new + invoicing_profile.organization.address.address = data + when 'profile.gender' + statistic_profile ||= StatisticProfile.new + statistic_profile.gender = data + when 'profile.birthday' + statistic_profile ||= StatisticProfile.new + statistic_profile.birthday = data + else + profile[sso_mapping[8..-1].to_sym] = data unless data.nil? + end + end + end + + ## used to allow the migration of existing users between authentication providers + def generate_auth_migration_token + update_attributes(auth_token: Devise.friendly_token) + end + + ## link the current user to the given provider (omniauth attributes hash) + ## and remove the auth_token to mark his account as "migrated" + def link_with_omniauth_provider(auth) + active_provider = AuthProvider.active + raise SecurityError, 'The identity provider does not match the activated one' if active_provider.strategy_name != auth.provider + + if User.where(provider: auth.provider, uid: auth.uid).size.positive? + raise DuplicateIndexError, "This #{active_provider.name} account is already linked to an existing user" + end + + update_attributes(provider: auth.provider, uid: auth.uid, auth_token: nil) + end + + ## Merge the provided User's SSO details into the current user and drop the provided user to ensure the unity + ## @param sso_user {User} the provided user will be DELETED after the merge was successful + def merge_from_sso(sso_user) + logger = SsoLogger.new + logger.debug "[User::merge_from_sso] initiated with parameter #{sso_user}" + # update the attributes to link the account to the sso account + self.provider = sso_user.provider + self.uid = sso_user.uid + + # remove the token + self.auth_token = nil + self.merged_at = DateTime.current + + # check that the email duplication was resolved + if sso_user.email.end_with? '-duplicate' + email_addr = sso_user.email.match(/^<([^>]+)>.{20}-duplicate$/)[1] + logger.error 'duplicate email was not resolved' + raise(DuplicateIndexError, email_addr) unless email_addr == email + end + + # update the user's profile to set the data managed by the SSO + auth_provider = AuthProvider.from_strategy_name(sso_user.provider) + logger.debug "found auth_provider=#{auth_provider.name}" + auth_provider.sso_fields.each do |field| + value = sso_user.get_data_from_sso_mapping(field) + logger.debug "mapping sso field #{field} with value=#{value}" + # we do not merge the email field if its end with the special value '-duplicate' as this means + # that the user is currently merging with the account that have the same email than the sso + set_data_from_sso_mapping(field, value) unless field == 'user.email' && value.end_with?('-duplicate') + end + + # run the account transfer in an SQL transaction to ensure data integrity + begin + User.transaction do + # remove the temporary account + logger.debug 'removing the temporary user' + sso_user.destroy + # finally, save the new details + logger.debug 'saving the updated user' + save! + end + rescue ActiveRecord::RecordInvalid => e + logger.error "error while merging user #{sso_user.id} into #{id}: #{e.message}" + raise e + end + end + end + + class_methods do + def from_omniauth(auth) + logger = SsoLogger.new + logger.debug "[User::from_omniauth] initiated with parameter #{auth}" + active_provider = AuthProvider.active + raise SecurityError, 'The identity provider does not match the activated one' if active_provider.strategy_name != auth.provider + + where(provider: auth.provider, uid: auth.uid).first_or_create.tap do |user| + # execute this regardless of whether record exists or not (-> User#tap) + # this will init or update the user thanks to the information retrieved from the SSO + logger.debug user.id.nil? ? 'no user found, creating a new one' : "found user id=#{user.id}" + user.profile ||= Profile.new + auth.info.mapping.each do |key, value| + logger.debug "mapping info #{key} with value=#{value}" + user.set_data_from_sso_mapping(key, value) + end + logger.debug 'generating a new password' + user.password = Devise.friendly_token[0, 20] + end + end + end +end diff --git a/app/models/concerns/stat_concern.rb b/app/models/concerns/stat_concern.rb index 58360ae3d..1743af6e4 100644 --- a/app/models/concerns/stat_concern.rb +++ b/app/models/concerns/stat_concern.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# Defines the standard statistics data model. module StatConcern extend ActiveSupport::Concern diff --git a/app/models/concerns/stat_reservation_concern.rb b/app/models/concerns/stat_reservation_concern.rb index d4981bacf..1911485e7 100644 --- a/app/models/concerns/stat_reservation_concern.rb +++ b/app/models/concerns/stat_reservation_concern.rb @@ -1,3 +1,6 @@ +# frozen_string_literal: true + +# Defines the reservation statistics data model module StatReservationConcern extend ActiveSupport::Concern diff --git a/app/models/user.rb b/app/models/user.rb index 706d9600b..929b56a10 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -3,20 +3,16 @@ # User is a physical or moral person with its authentication parameters # It is linked to the Profile model with hold information about this person (like address, name, etc.) class User < ApplicationRecord - require 'sso_logger' - include NotifyWith::NotificationReceiver include NotifyWith::NotificationAttachedObject + + include SingleSignOnConcern # Include default devise modules. Others available are: # :lockable, :timeoutable and :omniauthable devise :database_authenticatable, :registerable, :recoverable, :rememberable, :trackable, :validatable, :confirmable rolify - # enable OmniAuth authentication only if needed - devise :omniauthable, omniauth_providers: [AuthProvider.active.strategy_name.to_sym] unless - AuthProvider.active.providable_type == DatabaseProvider.name - extend FriendlyId friendly_id :username, use: :slugged @@ -204,26 +200,6 @@ class User < ApplicationRecord super && is_active? end - def self.from_omniauth(auth) - logger = SsoLogger.new - logger.debug "[User::from_omniauth] initiated with parameter #{auth}" - active_provider = AuthProvider.active - raise SecurityError, 'The identity provider does not match the activated one' if active_provider.strategy_name != auth.provider - - where(provider: auth.provider, uid: auth.uid).first_or_create.tap do |user| - # execute this regardless of whether record exists or not (-> User#tap) - # this will init or update the user thanks to the information retrieved from the SSO - logger.debug user.id.nil? ? 'no user found, creating a new one' : "found user id=#{user.id}" - user.profile ||= Profile.new - auth.info.mapping.each do |key, value| - logger.debug "mapping info #{key} with value=#{value}" - user.set_data_from_sso_mapping(key, value) - end - logger.debug 'generating a new password' - user.password = Devise.friendly_token[0, 20] - end - end - def need_completion? statistic_profile.gender.nil? || profile.first_name.blank? || profile.last_name.blank? || username.blank? || email.blank? || encrypted_password.blank? || group_id.nil? || statistic_profile.birthday.blank? || @@ -231,133 +207,6 @@ class User < ApplicationRecord (Setting.get('address_required') && invoicing_profile.address&.address&.blank?) end - ## Retrieve the requested data in the User and user's Profile tables - ## @param sso_mapping {String} must be of form 'user._field_' or 'profile._field_'. Eg. 'user.email' - def get_data_from_sso_mapping(sso_mapping) - parsed = /^(user|profile)\.(.+)$/.match(sso_mapping) - if parsed[1] == 'user' - self[parsed[2].to_sym] - elsif parsed[1] == 'profile' - case sso_mapping - when 'profile.avatar' - profile.user_avatar.remote_attachment_url - when 'profile.address' - invoicing_profile.address.address - when 'profile.organization_name' - invoicing_profile.organization.name - when 'profile.organization_address' - invoicing_profile.organization.address.address - when 'profile.gender' - statistic_profile.gender - when 'profile.birthday' - statistic_profile.birthday - else - profile[parsed[2].to_sym] - end - end - end - - ## Set some data on the current user, according to the sso_key given - ## @param sso_mapping {String} must be of form 'user._field_' or 'profile._field_'. Eg. 'user.email' - ## @param data {*} the data to put in the given key. Eg. 'user@example.com' - def set_data_from_sso_mapping(sso_mapping, data) - if sso_mapping.to_s.start_with? 'user.' - self[sso_mapping[5..-1].to_sym] = data unless data.nil? - elsif sso_mapping.to_s.start_with? 'profile.' - case sso_mapping.to_s - when 'profile.avatar' - profile.user_avatar ||= UserAvatar.new - profile.user_avatar.remote_attachment_url = data - when 'profile.address' - invoicing_profile ||= InvoicingProfile.new - invoicing_profile.address ||= Address.new - invoicing_profile.address.address = data - when 'profile.organization_name' - invoicing_profile ||= InvoicingProfile.new - invoicing_profile.organization ||= Organization.new - invoicing_profile.organization.name = data - when 'profile.organization_address' - invoicing_profile ||= InvoicingProfile.new - invoicing_profile.organization ||= Organization.new - invoicing_profile.organization.address ||= Address.new - invoicing_profile.organization.address.address = data - when 'profile.gender' - statistic_profile ||= StatisticProfile.new - statistic_profile.gender = data - when 'profile.birthday' - statistic_profile ||= StatisticProfile.new - statistic_profile.birthday = data - else - profile[sso_mapping[8..-1].to_sym] = data unless data.nil? - end - end - end - - ## used to allow the migration of existing users between authentication providers - def generate_auth_migration_token - update_attributes(auth_token: Devise.friendly_token) - end - - ## link the current user to the given provider (omniauth attributes hash) - ## and remove the auth_token to mark his account as "migrated" - def link_with_omniauth_provider(auth) - active_provider = AuthProvider.active - raise SecurityError, 'The identity provider does not match the activated one' if active_provider.strategy_name != auth.provider - - if User.where(provider: auth.provider, uid: auth.uid).size.positive? - raise DuplicateIndexError, "This #{active_provider.name} account is already linked to an existing user" - end - - update_attributes(provider: auth.provider, uid: auth.uid, auth_token: nil) - end - - ## Merge the provided User's SSO details into the current user and drop the provided user to ensure the unity - ## @param sso_user {User} the provided user will be DELETED after the merge was successful - def merge_from_sso(sso_user) - logger = SsoLogger.new - logger.debug "[User::merge_from_sso] initiated with parameter #{sso_user}" - # update the attributes to link the account to the sso account - self.provider = sso_user.provider - self.uid = sso_user.uid - - # remove the token - self.auth_token = nil - self.merged_at = DateTime.current - - # check that the email duplication was resolved - if sso_user.email.end_with? '-duplicate' - email_addr = sso_user.email.match(/^<([^>]+)>.{20}-duplicate$/)[1] - logger.error 'duplicate email was not resolved' - raise(DuplicateIndexError, email_addr) unless email_addr == email - end - - # update the user's profile to set the data managed by the SSO - auth_provider = AuthProvider.from_strategy_name(sso_user.provider) - logger.debug "found auth_provider=#{auth_provider.name}" - auth_provider.sso_fields.each do |field| - value = sso_user.get_data_from_sso_mapping(field) - logger.debug "mapping sso field #{field} with value=#{value}" - # we do not merge the email field if its end with the special value '-duplicate' as this means - # that the user is currently merging with the account that have the same email than the sso - set_data_from_sso_mapping(field, value) unless field == 'user.email' && value.end_with?('-duplicate') - end - - # run the account transfer in an SQL transaction to ensure data integrity - begin - User.transaction do - # remove the temporary account - logger.debug 'removing the temporary user' - sso_user.destroy - # finally, save the new details - logger.debug 'saving the updated user' - save! - end - rescue ActiveRecord::RecordInvalid => e - logger.error "error while merging user #{sso_user.id} into #{id}: #{e.message}" - raise e - end - end - def self.mapping # we protect some fields as they are designed to be managed by the system and must not be updated externally blacklist = %w[id encrypted_password reset_password_token reset_password_sent_at remember_created_at diff --git a/app/validators/o_auth2_provider_validator.rb b/app/validators/user_uid_mapped_validator.rb similarity index 71% rename from app/validators/o_auth2_provider_validator.rb rename to app/validators/user_uid_mapped_validator.rb index bf4b77743..6357fcf06 100644 --- a/app/validators/o_auth2_provider_validator.rb +++ b/app/validators/user_uid_mapped_validator.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true # Validates the presence of the User.uid mapping -class OAuth2ProviderValidator < ActiveModel::Validator +class UserUidMappedValidator < ActiveModel::Validator def validate(record) - return unless record.providable_type == 'OAuth2Provider' + return unless %w[OAuth2Provider OpenIdConnectProvider].include?(record.providable_type) return if record.auth_provider_mappings.any? do |mapping| mapping.local_model == 'user' && mapping.local_field == 'uid' diff --git a/app/views/api/auth_providers/show.json.jbuilder b/app/views/api/auth_providers/show.json.jbuilder index 53aaf6363..92f1ab6e7 100644 --- a/app/views/api/auth_providers/show.json.jbuilder +++ b/app/views/api/auth_providers/show.json.jbuilder @@ -13,8 +13,7 @@ end if @provider.providable_type == OpenIdConnectProvider.name json.providable_attributes do json.extract! @provider.providable, :id, :issuer, :discovery, :client_auth_method, :scope, - :prompt, :send_scope_to_token_endpoint, :post_logout_redirect_uri, :uid_field, :client__identifier, :client__secret, - :client__redirect_uri, :client__scheme, :client__host, :client__port, :client__authorization_endpoint, + :prompt, :send_scope_to_token_endpoint, :client__identifier, :client__secret, :client__authorization_endpoint, :client__token_endpoint, :client__userinfo_endpoint, :client__jwks_uri, :client__end_session_endpoint, :profile_url end end diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index 6011a81c7..6e98d8b4a 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -1072,11 +1072,15 @@ en: add_a_match: "Add a match" model: "Model" field: "Field" + data_mapping: "Data mapping" + oauth2_data_mapping_form: api_endpoint_url: "API endpoint or URL" api_type: "API type" api_field: "API field" api_field_help_html: 'JsonPath syntax is supported.
If many fields are selected, the first one will be used.
Example: $.data[*].name' - data_mapping: "Data mapping" + openid_connect_data_mapping_form: + api_field: "Userinfo claim" + api_field_help_html: 'Set the field providing the corresponding data through the userinfo endpoint.
JsonPath syntax is supported. If many fields are selected, the first one will be used.
Example: $.data[*].name' type_mapping_modal: data_mapping: "Data mapping" TYPE_expected: "{TYPE} expected" @@ -1120,8 +1124,6 @@ en: send_scope_to_token_endpoint_help: "Should the scope parameter be sent to the authorization token endpoint?" send_scope_to_token_endpoint_false: "No" send_scope_to_token_endpoint_true: "Yes" - uid_field: "UID field" - uid_field_help: "The field of the user info response to be used as a unique id." extra_authorize_params: "Extra authorize params" extra_authorize_params_help_html: "A list of extra fixed parameters that will be merged to the authorization request.
The list is expected to be in a JSON-like format.
Eg. {tenant: common, max_age: 3600}" profile_edition_url: "Profil edition URL" diff --git a/lib/omni_auth/data_mapping/base.rb b/lib/omni_auth/data_mapping/base.rb new file mode 100644 index 000000000..b6286721a --- /dev/null +++ b/lib/omni_auth/data_mapping/base.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +# Data mapping functions for SSO authentications (through OmniAuth) +module OmniAuth::DataMapping + # Type-dependant mapping functions + module Base + extend ActiveSupport::Concern + + included do + def local_sym(mapping) + (mapping.local_model + '.' + mapping.local_field).to_sym + end + + def map_transformation(transformation, raw_data) + value = nil + transformation['mapping'].each do |m| + if m['from'] == raw_data + value = m['to'] + break + end + end + # if no transformation had set any value, return the raw value + value || raw_data + end + + def map_boolean(transformation, raw_data) + return false if raw_data == transformation['false_value'] + + true if raw_data == transformation['true_value'] + end + + def map_date(transformation, raw_data) + case transformation['format'] + when 'iso8601' + DateTime.iso8601(raw_data) + when 'rfc2822' + DateTime.rfc2822(raw_data) + when 'rfc3339' + DateTime.rfc3339(raw_data) + when 'timestamp-s' + DateTime.strptime(raw_data, '%s') + when 'timestamp-ms' + DateTime.strptime(raw_data, '%Q') + else + DateTime.parse(raw_data) + end + end + end + end +end diff --git a/lib/omni_auth/data_mapping/mapper.rb b/lib/omni_auth/data_mapping/mapper.rb new file mode 100644 index 000000000..1e8222a72 --- /dev/null +++ b/lib/omni_auth/data_mapping/mapper.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module OmniAuth::DataMapping + # Build the data mapping for the given provider + module Mapper + extend ActiveSupport::Concern + + included do + require 'sso_logger' + require_relative 'base' + include OmniAuth::DataMapping::Base + + def mapped_info(mappings, raw_info) + logger = SsoLogger.new + @info ||= {} + + logger.debug "[mapped_info] @info = #{@info.to_json}" + + unless @info.size.positive? + mappings.each do |mapping| + + raw_data = ::JsonPath.new(mapping.api_field).on(raw_info[mapping.api_endpoint.to_sym]).first + logger.debug "@parsed_info[#{local_sym(mapping)}] mapped from #{raw_data}" + @info[local_sym(mapping)] = if mapping.transformation + case mapping.transformation['type'] + when 'integer' + map_transformation(mapping.transformation, raw_data) + when 'boolean' + map_boolean(mapping.transformation, raw_data) + when 'date' + map_date(mapping.transformation, raw_data) + when 'string' + map_transformation(mapping.transformation, raw_data) + else + # other unsupported transformation + raw_data + end + else + raw_data + end + end + end + @info + end + end + end +end diff --git a/lib/omni_auth/strategies/sso_oauth2_provider.rb b/lib/omni_auth/strategies/sso_oauth2_provider.rb index 8d9cfd9e7..70f3e89a5 100644 --- a/lib/omni_auth/strategies/sso_oauth2_provider.rb +++ b/lib/omni_auth/strategies/sso_oauth2_provider.rb @@ -3,10 +3,12 @@ require 'omniauth-oauth2' require 'jsonpath' require 'sso_logger' +require_relative '../data_mapping/mapper' module OmniAuth::Strategies # Authentication strategy provided trough oAuth 2.0 class SsoOauth2Provider < OmniAuth::Strategies::OAuth2 + include OmniAuth::DataMapping::Mapper def self.active_provider active_provider = AuthProvider.active @@ -73,82 +75,7 @@ module OmniAuth::Strategies end def parsed_info - logger = SsoLogger.new - - @parsed_info ||= {} - logger.debug "[parsed_info] @parsed_info = #{@parsed_info.to_json}" - unless @parsed_info.size.positive? - OmniAuth::Strategies::SsoOauth2Provider.active_provider.auth_provider_mappings.each do |mapping| - - raw_data = ::JsonPath.new(mapping.api_field).on(raw_info[mapping.api_endpoint.to_sym]).first - logger.debug "@parsed_info[#{local_sym(mapping)}] mapped from #{raw_data}" - if mapping.transformation - case mapping.transformation['type'] - ## INTEGER - when 'integer' - @parsed_info[local_sym(mapping)] = map_integer(mapping.transformation, raw_data) - - ## BOOLEAN - when 'boolean' - @parsed_info[local_sym(mapping)] = map_boolean(mapping.transformation, raw_data) - - ## DATE - when 'date' - @params[local_sym(mapping)] = map_date(mapping.transformation, raw_data) - - ## OTHER TRANSFORMATIONS (not supported) - else - @parsed_info[local_sym(mapping)] = raw_data - end - - ## NO TRANSFORMATION - else - @parsed_info[local_sym(mapping)] = raw_data - end - end - end - @parsed_info - end - - private - - def local_sym(mapping) - (mapping.local_model + '.' + mapping.local_field).to_sym - end - - def map_integer(transformation, raw_data) - value = nil - transformation['mapping'].each do |m| - if m['from'] == raw_data - value = m['to'] - break - end - end - # if no transformation had set any value, return the raw value - value || raw_data - end - - def map_boolean(transformation, raw_data) - return false if raw_data == transformation['false_value'] - - true if raw_data == transformation['true_value'] - end - - def map_date(transformation, raw_data) - case transformation['format'] - when 'iso8601' - DateTime.iso8601(raw_data) - when 'rfc2822' - DateTime.rfc2822(raw_data) - when 'rfc3339' - DateTime.rfc3339(raw_data) - when 'timestamp-s' - DateTime.strptime(raw_data, '%s') - when 'timestamp-ms' - DateTime.strptime(raw_data, '%Q') - else - DateTime.parse(raw_data) - end + mapped_info(OmniAuth::Strategies::SsoOauth2Provider.active_provider.auth_provider_mappings, raw_info) end end end diff --git a/lib/omni_auth/strategies/sso_openid_connect_provider.rb b/lib/omni_auth/strategies/sso_openid_connect_provider.rb index dbf69d5fe..5f6b0b3bf 100644 --- a/lib/omni_auth/strategies/sso_openid_connect_provider.rb +++ b/lib/omni_auth/strategies/sso_openid_connect_provider.rb @@ -1,10 +1,12 @@ # frozen_string_literal: true require 'omniauth_openid_connect' +require_relative '../data_mapping/mapper' module OmniAuth::Strategies # Authentication strategy provided trough OpenID Connect class SsoOpenidConnectProvider < OmniAuth::Strategies::OpenIDConnect + include OmniAuth::DataMapping::Mapper def self.active_provider active_provider = AuthProvider.active @@ -18,5 +20,17 @@ module OmniAuth::Strategies # Strategy name. option :name, active_provider.strategy_name + info do + { + mapping: parsed_info + } + end + + def parsed_info + mapped_info( + OmniAuth::Strategies::SsoOpenidConnectProvider.active_provider.auth_provider_mappings, + user_info: user_info.raw_attributes + ) + end end end