如何从 Web 应用程序查询本地 Dynamics CRM (Node/Express)

How to query an on-premises Dynamics CRM from a Web App (Node/Express)

我一直在用这个问题撞墙,所以希望 CRM/Dynamics 专家能帮帮我!

我正在尝试以编程方式从我们的 Dynamics CRM 实例中获取数据,在 Node 驱动的 Express 应用程序中使用一组管理员凭据。此 Express 应用程序托管在我们托管 CRM 的网络之外的单独服务器上。然后,该应用程序将请求、处理 CRM 数据并将其返回给任何具有访问权限的登录用户(由应用程序内的 roles/permissions 控制),这意味着最终用户只需登录 Express 应用程序,而不必也通过 ADFS 登录,以便应用程序访问 CRM 实例。

我们的 CRM 设置是配置为面向 Internet (IFD) 的本地服务器。这使用 Active Directory 联合服务。我们在网络外围有 Web 应用程序代理服务器 运行 联合服务,与内部网络上的 ADFS 服务器通信。 ADFS 根据本地 AD 对从网络外部(从互联网)连接的用户进行身份验证。一旦通过代理,用户就可以连接到 CRM。

我们的本地活动目录与 Azure AD 同步,因为我们有混合部署。任何 O365 服务(在线交换、sharepoint 等)都在后台使用 Azure AD。我们同步了 Active Directory,因此我们只需在一个地方管理用户。

CRM 有一个端点,例如https://my.crm.endpoint 并且我在 Azure 门户中注册了一个应用程序(称为 CRM 应用程序),主页设置为 CRM 端点 https://my.crm.endpoint

问题 将应用程序的主页设置为 https://my.crm.endpoint 是否足以 "link" 它到我们的内部 CRM 实例?

我编写了一个脚本 (crm.js),成功 为我的 CRM 应用程序 请求访问令牌Azure 门户,使用它的 App ID。

示例令牌

eyJ0dWNyIjoiMSIsImlkcCI6Imh0dHBzOi8vc3RzLndpbmRvd3MubmV0LzE5ZTk1...

使用不记名令牌,然后我尝试通过通常的端点从 Dynamics 中获取一些联系人:https://my.crm.endpoint/api/data/v8.2/contacts?$select=fullname,contactid

这失败了,我收到一条 401 Unauthorised 错误消息。

问题 谁能告诉我问题出在哪里? And/or 提供有关如何连接 Web 应用程序(在我的例子中是 Express)以向使用 ADFS 的本地服务器 (IFD) 上的 Dynamics CRM 运行 发出经过身份验证的请求的详细信息?

crm.js

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

let test = {
    username: '<my.email@address.com>',
    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 进行身份验证。我发现 this blog post 描述了在 ADFS 中生成可与 OAuth 一起使用的共享机密。

"Using this form of Client Authentication, you would POST your client identifier (as client_id) and your client secret (as client_secret) to the STS endpoint. Here is an example of such an HTTP POST (using Client Credentials Grant, added line breaks only for readability):"

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="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">here</a>.</h2>\r\n</body></html>\r\n'

问题 任何人都可以描述我做错了什么以及我应该做什么才能正确验证 ADFS/CRM 吗?

注意:当我在浏览器中访问 https://my.crm.endpoint 时,系统会提示我输入用户名和密码。输入我的信用有效,我可以访问 CRM。在网络选项卡中注意到它正在使用 NTLM 来执行此操作吗?这会改变我需要采取的方法吗?

更新 3

请看新问题here

我们遇到过类似的情况。 我们的组织是 OnPrem 8.2。它可以通过 VPN 或家庭网络访问。 如果您以非常基本的外行方式看待问题,我们的 CRM 无法从外部访问。

我们所做的是

  1. 我们为 CRM 中的操作创建了 WebAPI。

  2. 我们通过额外的端口向外部世界暴露了这个 WebAPI。

  3. 我们在 IIS 中将此 WebAPI 添加为服务。

  4. 但我们确保只能通过我们在 Web.config 文件中创建的特定用户名和密码访问此网站API。

  5. 在后台我们所做的是创建动作。

  6. Action in Turn 将 运行 插件并将 Return 数据按要求修改,即 WebAPI url 可以修改。例如:.../acounts 将 return 用于帐户实体,前提是您在插件中构建了逻辑。

    请不要将此与 Dynamics CRM OOB WebAPI 混淆。我的意思是创建我们自己的 API 并使用它自己的用户名和密码将其添加为 IIS 中的服务。

我想这至少会给你一些提示,让你知道该往哪个方向看。

所以...我设法通过对浏览器的身份验证方法进行逆向工程来实现这一目标:) 没有代理或 Azure 废话!

我现在直接使用我们的 fs 端点进行身份验证并解析生成的 SAML 响应并使用它提供的 cookie...这很有用。

注意:下面的代码刚刚在我的 Node 便签本中敲定,所以一团糟。我可能会整理它并 post 在某个时候写一篇完整的文章,但是现在,如果您使用这些代码中的任何一个,您将需要适当地重构 ;)

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)
    })

  })
}