Как запросить локальную Dynamics CRM из веб-приложения (Node/Express)

Я стучал головой о несколько стен, так что некоторые эксперты CRM/Dynamics могут дать мне руку!

Я пытаюсь программно получить данные из нашего экземпляра Dynamics CRM, используя один набор учетных данных администратора в приложении Express для приложения с узлом. Это приложение Express размещено на отдельном сервере за пределами нашей сети, где размещается CRM. Приложение будет запрашивать, обрабатывать и обслуживать CRM-данные обратно любому зарегистрированному пользователю, который имеет доступ (контролируется ролями/разрешениями в приложении), что означает, что конечный пользователь должен войти в приложение Express, и не должен также войти в систему через ADFS, чтобы приложение могло получить доступ к экземпляру CRM.

Наш CRM настроен на локальном сервере, настроенном на интернет-выход (IFD). Это использует службы федерации Active Directory. У нас есть прокси-серверы веб-приложений, на которых запущены службы федерации по периметру сети, которые взаимодействуют с серверами ADFS во внутренней сети. ADFS проверяет подлинность пользователей, подключающихся извне сети (из Интернета) против предварительного AD. После аутентификации прокси позволяет пользователям подключаться к CRM.

Наш предварительный активный каталог синхронизируется с Azure AD, поскольку у нас есть гибридное развертывание. Любой сервис O365 (обмен онлайн, sharepoint и т.д.) Использует Azure AD в фоновом режиме. Мы синхронизируем Active Directory, поэтому нам нужно управлять пользователями только в одном месте.

CRM имеет конечную точку, например https://my.crm.endpoint и я зарегистрировал приложение (называемое CRM-приложением) на Azure Portal, а домашняя страница установлена на конечную точку CRM https://my.crm.endpoint.

Вопрос. Устанавливает ли домашняя страница приложения https://my.crm.endpoint достаточно, чтобы "связать" ее с нашим экземпляром CRM на месте?

Я написал сценарий (crm.js), который успешно запрашивает токен доступа для моего приложения CRM, зарегистрированного на Azure Portal, используя его ID приложения.

Пример маркера

eyJ0dWNyIjoiMSIsImlkcCI6Imh0dHBzOi8vc3RzLndpbmRvd3MubmV0LzE5ZTk1...

Используя токен-носитель, я затем пытаюсь получить некоторые контакты из Dynamics с помощью обычной конечной точки: https://my.crm.endpoint/api/data/v8.2/contacts? $ Select = fullname, contactid

Это не удается, и я получаю сообщение об ошибке 401 Unauthorised.

Вопрос: Может ли кто-нибудь предложить проблему? И/или предоставить подробную информацию о том, как вы можете подключить веб-приложение (Express в моем случае), чтобы сделать аутентифицированные запросы к CRM Dynamics, работающим на локальном сервере (IFD), который использует ADFS?

crm.js

let util = require('util');
let request = require("request");

let test = {
    username: '<[email protected]>',
    password: '<my_password>',
    app_id: '<app_id>',
    secret: '<secret>',
    authenticate_url: 'https://login.microsoftonline.com/<tenant_id>/oauth2/token',
    crm_url: 'https://<my.crm.endpoint>'
};
function CRM() { }

CRM.prototype.authenticate = function () {
    return new Promise((resolve, reject) => {
        let options = {
            method: 'POST',
            url: test.authenticate_url,
            formData: {
                grant_type: 'client_credentials',
                client_id: test.app_id,         // application id
                client_secret: test.secret,     // secret
                username: test.username,        // on premise windows login (admin)
                password: test.password,        // password
                resource: test.app_id           // application id
            }
        };

        // ALWAYS RETURNS AN ACCESS_TOKEN
        request(options, function (error, response, body) {
            console.log('AUTHENTICATE RESPONSE', body);
            resolve(body);
        });
    })
};

CRM.prototype.getContacts = function (token) {
    return new Promise((resolve, reject) => {

        let options = {
            method: 'GET',
            url: '${test.crm_url}/api/data/v8.2/contacts?$select=fullname,contactid',
            headers: {
                'Authorization': 'Bearer ${token}',
                'Accept': 'application/json',
                'OData-MaxVersion': 4.0,
                'OData-Version': 4.0,
                'Content-Type': 'application/json; charset=utf-8'
            }
        };

        request(options, (error, response, body) => {
            console.log('getContacts', util.inspect(error), util.inspect(body));
            resolve(body);
        });

    });
};

let API = new CRM();    // instantiate the CRM object

API.authenticate()      // call authenticate function
    .then(response => {
        if (response) {

            let json = JSON.parse(response);
            let token = json.access_token;

            console.log('TOKEN', token);

            API.getContacts('token')
            .then(contacts => {
                // DO SOMETHING WITH THE CONTACTS
                console.log('CONTACTS', contacts);
            })
        }
    });


module.exports = CRM;

Ошибка ответа

HTTP Error 401 - Unauthorized: Access is denied

ДОПОЛНИТЕЛЬНАЯ ИНФОРМАЦИЯ

Мое текущее решение основано на этих документах...

https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-protocols-oauth-service-to-service

ОБНОВИТЬ

Следуя комментарию @andresm53, я думаю, мне нужно пройти аутентификацию непосредственно с ADFS. Я нашел это сообщение в блоге, которое описывает создание общего секрета в ADFS, которое можно использовать с OAuth.

"Используя эту форму аутентификации клиента, вы бы поставили свой клиентский идентификатор (как client_id) и ваш клиентский секрет (как client_secret) на конечную точку STS. Вот пример такой HTTP-POST (с использованием Credentials Client Credentials, добавленных разрывов строк для удобства чтения): "

resource=https%3a%2f%2fmy.crm.endpoint
&client_id=**2954b462-a5de-5af6-83bc-497cc20bddde ** ???????
&client_secret=56V0RnQ1COwhf4YbN9VSkECTKW9sOHsgIuTl1FV9
&grant_type=client_credentials

ОБНОВЛЕНИЕ 2

Теперь я создал серверное приложение в ADFS и отправлю вышеуказанную полезную нагрузку с помощью правильных client_id и client_secret.

Однако я получаю сообщение с Object moved.

RESOLVED BODY: '<html><head><title>Object moved</title></head><body>\r\n<h2>Object moved to <a href="#" onclick="location.href='https://fs.our.domain.name/adfs/ls/?wa=wsignin1.0&amp;wtrealm=https%3a%2f%2fmy.crm.endpoint%2f&amp;wctx=http%253a%252f%252f2954b462-a5de-5af6-83bc-497cc20bddde%252f&amp;wct=2018-04-16T13%3a17%3a29Z&amp;wauth=urn%3afederation%3aauthentication%3awindows'; return false;">here</a>.</h2>\r\n</body></html>\r\n'

ВОПРОС Кто-нибудь может описать, что я делаю неправильно, и что я должен делать, чтобы правильно аутентифицировать ADFS/CRM?

NB: Когда я нахожусь в своем браузере и посещаю https://my.crm.endpoint, мне будет предложено ввести мое имя пользователя и пароль. Ввод моих кредитов работает, и я получаю доступ к CRM. Заметили на вкладке сети, что для этого используется NTLM? Означает ли это, какой подход мне нужно предпринять?

ОБНОВЛЕНИЕ 3

Пожалуйста, смотрите новый вопрос здесь

Ответы

Ответ 1

Итак... Мне удалось добиться этого с помощью обратного инжиниринга браузерного подхода к аутентификации :) Никаких прокси или чепухи Azure!

Теперь я напрямую аутентифицируюсь с нашей конечной точкой fs и анализирую полученный ответ SAML и использую предоставляемый им cookie файл... который работает.

NB: Код ниже был только что вырублен в блокноте Node, так что это беспорядок. Я мог бы привести его в порядок и опубликовать полную запись в какой-то момент, но сейчас, если вы используете какой-либо из этого кода, вы захотите соответствующим образом провести рефакторинг;)

let ADFS_USERNAME = '<YOUR_ADFS_USERNAME>'
let ADFS_PASSWORD = '<YOUR_ADFS_PASSWORD>'

let httpntlm = require('httpntlm')
let ntlm = httpntlm.ntlm
let lm = ntlm.create_LM_hashed_password(ADFS_PASSWORD)
let nt = ntlm.create_NT_hashed_password(ADFS_PASSWORD)
let cookieParser = require('set-cookie-parser')
let request = require('request')

let Entity = require('html-entities').AllHtmlEntities
let entities = new Entity()

let uri = 'https://<YOUR_ORGANISATIONS_DOMAIN>/adfs/ls/wia?wa=wsignin1.0&wtrealm=https%3a%2f%2f<YOUR_ORGANISATIONS_CRM_URL>%2f&wctx=rm%3d1%26id%3d1fdab91a-41e8-4100-8ddd-ee744be19abe%26ru%3d%252fdefault.aspx%26crmorgid%3d00000000-0000-0000-0000-000000000000&wct=2019-03-12T11%3a26%3a30Z&wauth=urn%3afederation%3aauthentication%3awindows&client-request-id=e737595a-8ac7-464f-9136-0180000000e1'
let apiUrl = 'https://<YOUR_ORGANISATIONS_CRM_URL>/api/data/v8.2/'
let crm = 'https://<YOUR_ORGANISATIONS_CRM_URL>'

let endpoints = {
  INCIDENTS: '${apiUrl}/incidents?$select=ticketnumber,incidentid,prioritycode,description',
  CONTACTS: '${apiUrl}/contacts?$select=fullname,contactid'
}

httpntlm.get({
  url: uri,
  username: ADFS_USERNAME,
  lm_password: lm,
  nt_password: nt,
  workstation: '',
  domain: ''
}, function (err, res) {
  if (err) return err
  // this looks messy but is getting the SAML1.0 response ready to pass back as form data in the next request
  let reg = new RegExp('&lt;t:RequestSecurityTokenResponse([\\s\\S]*?)&lt;\/t:RequestSecurityTokenResponse>')
  let result = res.body.match(reg)
  let wresult = entities.decode(result[ 0 ])

  reg = new RegExp('name="wctx" value="([\\s\\S]*?)" /><noscript>')
  result = res.body.match(reg)

  let wctx = entities.decode(result[ 1 ])
  let payload = {
    wctx: wctx,
    wresult: wresult
  }
  getValidCookies(payload)
    .then(cookies => {

      getIncidents(cookies)
        .then(contacts => {
          console.log('GOT INCIDENTS', contacts)
        })
    })
})

getValidCookies = function (payload) {
  return new Promise((resolve, reject) => {

    let options = {
      method: 'POST',
      url: crm,
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded'
      },
      form: {
        'wa': 'wsignin1.0',
        'wresult': payload.wresult,
        'wctx': payload.wctx
      }
    }

    request(options, (error, response, body) => {
      let requiredCookies = []
      let cookies = cookieParser.parse(response)

      cookies.forEach(function (cookie) {
        if (cookie.name === 'MSISAuth' || cookie.name === 'MSISAuth1') {
          requiredCookies.push('${cookie.name}=${cookie.value}')
        }
      })
      resolve(requiredCookies)
    })

  })
}

getIncidents = function (cookies) {
  return new Promise((resolve, reject) => {

    let options = {
      method: 'GET',
      url: endpoints.INCIDENTS,
      headers: {
        'Cookie': cookies.join(';')
      }
    }

    request(options, (error, response, body) => {
      resolve(body)
    })

  })
}

getContacts = function (cookies) {
  return new Promise((resolve, reject) => {

    let options = {
      method: 'GET',
      url: endpoints.CONTACTS,
      headers: {
        'Cookie': cookies.join(';')
      }
    }

    request(options, (error, response, body) => {
      resolve(body)
    })

  })
}

Ответ 2

У нас была похожая ситуация. Наша организация OnPrem 8.2. Он доступен через VPN или из домашней сети. Если вы посмотрите на проблему очень простым образом, наш CRM не может быть доступен извне.

То, что мы сделали, было

  1. Мы создали WebAPI для действий из CRM.
  2. Мы выставили этот WebAPI через дополнительный порт для внешнего мира.
  3. Мы добавили этот WebAPI в IIS как сервис.
  4. Но мы убедились, что этот WebAPI доступен только через определенные userName и Passoword, которые мы создали в нашем файле Web.config.
  5. В фоновом режиме было создано действие.
  6. Действие, в свою очередь, запустит плагин и вернет данные в соответствии с запросом, т.е. URL WebAPI может быть изменен. Например:.../acounts будет возвращаться для учетной записи при условии, что в вашем плагине встроена логика.

    Пожалуйста, не путайте это с Dynamics CRM OOB WebAPI. Я имею в виду, что создаем наш собственный API и добавляем его в качестве службы в IIS с собственным именем пользователя и паролем.

Я предполагаю, что это даст вам хоть какой-то намек в каком направлении смотреть.