1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2024-12-01 12:24:28 +01:00

(ui)(api) configure data mapping for openid

This commit is contained in:
Sylvain 2022-04-20 14:12:22 +02:00
parent 0f183e7af6
commit 1960c7139f
22 changed files with 386 additions and 280 deletions

View File

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

View File

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

View File

@ -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<TFieldValues, TContext extends object> {
register: UseFormRegister<TFieldValues>,
control: Control<TFieldValues, TContext>,
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 = <TFieldValues extends FieldValues, TContext extends object>({ register, control }: DataMappingFormProps<TFieldValues, TContext>) => {
export const DataMappingForm = <TFieldValues extends FieldValues, TContext extends object>({ register, control, providerType }: DataMappingFormProps<TFieldValues, TContext>) => {
const { t } = useTranslation('admin');
const [dataMapping, setDataMapping] = useState<MappingFields>(null);
const [isOpenTypeMappingModal, updateIsOpenTypeMappingModal] = useImmer<Map<number, boolean>>(new Map());
@ -125,21 +127,8 @@ export const DataMappingForm = <TFieldValues extends FieldValues, TContext exten
label={t('app.admin.authentication.data_mapping_form.field')} />
</div>
<div className="remote-data">
<FormInput id={`auth_provider_mappings_attributes.${index}.api_endpoint`}
register={register}
rules={{ required: true }}
placeholder="/api/resource..."
label={t('app.admin.authentication.data_mapping_form.api_endpoint_url')} />
<FormSelect id={`auth_provider_mappings_attributes.${index}.api_data_type`}
options={[{ label: 'JSON', value: 'json' }]}
control={control} rules={{ required: true }}
label={t('app.admin.authentication.data_mapping_form.api_type')} />
<FormInput id={`auth_provider_mappings_attributes.${index}.api_field`}
register={register}
rules={{ required: true }}
placeholder="field_name..."
tooltip={<HtmlTranslate trKey="app.admin.authentication.data_mapping_form.api_field_help_html" />}
label={t('app.admin.authentication.data_mapping_form.api_field')} />
{providerType === 'OAuth2Provider' && <Oauth2DataMappingForm register={register} control={control} index={index} />}
{providerType === 'OpenIdConnectProvider' && <OpenidConnectDataMappingForm register={register} index={index} />}
</div>
</div>
<div className="actions">

View File

@ -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<TFieldValues, TContext extends object> {
register: UseFormRegister<TFieldValues>,
control: Control<TFieldValues, TContext>,
index: number,
}
export const Oauth2DataMappingForm = <TFieldValues extends FieldValues, TContext extends object>({ register, control, index }: Oauth2DataMappingFormProps<TFieldValues, TContext>) => {
const { t } = useTranslation('admin');
return (
<div className="oauth2-data-mapping-form">
<FormInput id={`auth_provider_mappings_attributes.${index}.api_endpoint`}
register={register}
rules={{ required: true }}
placeholder="/api/resource..."
label={t('app.admin.authentication.oauth2_data_mapping_form.api_endpoint_url')} />
<FormSelect id={`auth_provider_mappings_attributes.${index}.api_data_type`}
options={[{ label: 'JSON', value: 'json' }]}
control={control} rules={{ required: true }}
label={t('app.admin.authentication.oauth2_data_mapping_form.api_type')} />
<FormInput id={`auth_provider_mappings_attributes.${index}.api_field`}
register={register}
rules={{ required: true }}
placeholder="field_name..."
tooltip={<HtmlTranslate trKey="app.admin.authentication.oauth2_data_mapping_form.api_field_help_html" />}
label={t('app.admin.authentication.oauth2_data_mapping_form.api_field')} />
</div>
);
};

View File

@ -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<TFieldValues> {
register: UseFormRegister<TFieldValues>,
index: number,
}
export const OpenidConnectDataMappingForm = <TFieldValues extends FieldValues>({ register, index }: OpenidConnectDataMappingFormProps<TFieldValues>) => {
const { t } = useTranslation('admin');
return (
<div className="openid-connect-data-mapping-form">
<FormInput id={`auth_provider_mappings_attributes.${index}.api_endpoint`}
type="hidden"
register={register}
rules={{ required: true }}
defaultValue="user_info" />
<FormInput id={`auth_provider_mappings_attributes.${index}.api_data_type`}
type="hidden"
register={register}
rules={{ required: true }}
defaultValue="json" />
<FormInput id={`auth_provider_mappings_attributes.${index}.api_field`}
register={register}
rules={{ required: true }}
placeholder="claim..."
tooltip={<HtmlTranslate trKey="app.admin.authentication.openid_connect_data_mapping_form.api_field_help_html" />}
label={t('app.admin.authentication.openid_connect_data_mapping_form.api_field')} />
</div>
);
};

View File

@ -132,12 +132,6 @@ export const OpenidConnectForm = <TFieldValues extends FieldValues, TContext ext
]}
valueDefault={true}
control={control} />
<FormInput id="providable_attributes.uid_field"
label={t('app.admin.authentication.openid_connect_form.uid_field')}
tooltip={t('app.admin.authentication.openid_connect_form.uid_field_help')}
defaultValue="sub"
placeholder="user_id"
register={register} />
<FormInput id="providable_attributes.profile_url"
register={register}
placeholder="https://sso.exemple.com/my-account"

View File

@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react';
import { useForm, SubmitHandler, useWatch } from 'react-hook-form';
import { react2angular } from 'react2angular';
import { debounce as _debounce } from 'lodash';
import { AuthenticationProvider, OpenIdConnectProvider } from '../../models/authentication-provider';
import { AuthenticationProvider, OpenIdConnectProvider, ProvidableType } from '../../models/authentication-provider';
import { Loader } from '../base/loader';
import { IApplication } from '../../models/application';
import { FormInput } from '../form/form-input';
@ -39,7 +39,7 @@ type selectProvidableTypeOption = { value: string, label: string };
export const ProviderForm: React.FC<ProviderFormProps> = ({ action, provider, onError, onSuccess }) => {
const { handleSubmit, register, control, formState, setValue } = useForm<AuthenticationProvider>({ defaultValues: { ...provider } });
const output = useWatch<AuthenticationProvider>({ control });
const [providableType, setProvidableType] = useState<string>(provider?.providable_type);
const [providableType, setProvidableType] = useState<ProvidableType>(provider?.providable_type);
const [strategyName, setStrategyName] = useState<string>(provider?.strategy_name);
const { t } = useTranslation('admin');
@ -72,7 +72,7 @@ export const ProviderForm: React.FC<ProviderFormProps> = ({ 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<ProviderFormProps> = ({ action, provider, on
currentFormValues={output.providable_attributes as OpenIdConnectProvider}
formState={formState}
setValue={setValue} />}
{providableType && providableType !== 'DatabaseProvider' && <DataMappingForm register={register} control={control} />}
{providableType && providableType !== 'DatabaseProvider' && <DataMappingForm register={register} control={control} providerType={providableType} />}
<div className="main-actions">
<FabButton type="submit" className="submit-button">{t('app.admin.authentication.provider_form.save')}</FabButton>
</div>

View File

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

View File

@ -3,7 +3,7 @@
margin-left: 20px;
}
.local-data,
.remote-data {
.remote-data > *{
display: flex;
flex-direction: row;
}

View File

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

View File

@ -1,3 +1,6 @@
# frozen_string_literal: true
# Read and write the amount attribute, after converting to/from cents.
module AmountConcern
extend ActiveSupport::Concern

View File

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

View File

@ -1,5 +1,6 @@
# frozen_string_literal: true
# Defines the standard statistics data model.
module StatConcern
extend ActiveSupport::Concern

View File

@ -1,3 +1,6 @@
# frozen_string_literal: true
# Defines the reservation statistics data model
module StatReservationConcern
extend ActiveSupport::Concern

View File

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

View File

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

View File

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

View File

@ -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: '<a href="https://jsonpath.com/" target="_blank">JsonPath</a> syntax is supported.<br> If many fields are selected, the first one will be used.<br> 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 <a href="https://openid.net/specs/openid-connect-core-1_0.html#Claims" target="_blank">the userinfo endpoint</a>.<br> <a href="https://jsonpath.com/" target="_blank">JsonPath</a> syntax is supported. If many fields are selected, the first one will be used.<br> <b>Example</b>: $.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.<br>The list is expected to be in a JSON-like format.<br> <b>Eg.</b> {tenant: common, max_age: 3600}"
profile_edition_url: "Profil edition URL"

View File

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

View File

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

View File

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

View File

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