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:
parent
0f183e7af6
commit
1960c7139f
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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"
|
||||
|
@ -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>
|
||||
|
@ -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,
|
||||
|
@ -3,7 +3,7 @@
|
||||
margin-left: 20px;
|
||||
}
|
||||
.local-data,
|
||||
.remote-data {
|
||||
.remote-data > *{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
@ -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
|
||||
|
||||
|
@ -1,3 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Read and write the amount attribute, after converting to/from cents.
|
||||
module AmountConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
|
162
app/models/concerns/single_sign_on_concern.rb
Normal file
162
app/models/concerns/single_sign_on_concern.rb
Normal 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
|
@ -1,5 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Defines the standard statistics data model.
|
||||
module StatConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
|
@ -1,3 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Defines the reservation statistics data model
|
||||
module StatReservationConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
|
@ -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
|
||||
|
@ -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'
|
@ -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
|
||||
|
@ -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"
|
||||
|
50
lib/omni_auth/data_mapping/base.rb
Normal file
50
lib/omni_auth/data_mapping/base.rb
Normal 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
|
47
lib/omni_auth/data_mapping/mapper.rb
Normal file
47
lib/omni_auth/data_mapping/mapper.rb
Normal 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
|
@ -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
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user