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

20

我正在编写一个Express应用程序,它通过一个单一的管理员凭据来程序化地从我们的Dynamics CRM实例中获取数据。这个Express应用程序托管在我们网络之外的一个独立服务器上,而CRM托管在另一个服务器上。应用程序将请求、处理和服务CRM数据返回给任何有权限的已登录用户(由应用程序内角色/权限控制),这意味着终端用户只需登录到Express应用程序,而不必再通过ADFS登录以便应用程序能够访问CRM实例。

我们的CRM设置是一个配置为面向互联网的本地服务器(IFD)。这使用Active Directory Federation Services。我们在网络边界运行联合服务的Web应用程序代理服务器与内部网络上的ADFS服务器进行通信。ADFS对连接到外部网络(即互联网)的用户(根据on prem AD)进行身份验证。一旦认证,代理就允许用户通过到达CRM。

我们的on prem active directory与Azure AD同步,因为我们有混合部署。任何O365服务(exchange online, sharepoint等)都在后台使用Azure AD。我们同步Active directory,这样我们只需要在一个地方管理用户。

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

问题将应用程序的主页设置为 https://my.crm.endpoint 是否足以将其“链接”到我们的本地CRM实例?

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

示例Token

eyJ0dWNyIjoiMSIsImlkcCI6Imh0dHBzOi8vc3RzLndpbmRvd3MubmV0LzE5ZTk1...

接着使用bearer token,我尝试通过通常的终端点从Dynamics中获取一些联系人:

https://my.crm.endpoint/api/data/v8.2/contacts?$select=fullname,contactid

然而这一步失败了,我得到了一个401未授权的错误信息。

问题:有人能提出可能的问题吗?或者提供有关如何将Web应用程序(在我的情况下为Express)连接到运行在本地服务器上(IFD)并使用ADFS的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://learn.microsoft.com/en-us/azure/active-directory/develop/active-directory-protocols-oauth-service-to-service

更新

根据@andresm53的评论,我认为我确实需要直接对ADFS进行身份验证。我找到了这篇博客文章,介绍了在ADFS中生成可与OAuth一起使用的共享密钥。

"使用此形式的客户端身份验证,您将向STS端点POST您的客户端标识符(作为client_id)和您的客户端密钥(作为client_secret)。以下是这样一个HTTP POST的示例(仅为了可读性添加换行符):"

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 POST了上述有效负载。

但是,我收到了一个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进行身份验证?

NB: 当我在浏览器中访问https://my.crm.endpoint时,我会被提示输入用户名和密码。输入我的凭据后,我就可以访问CRM。注意到在网络选项卡中,它使用NTLM来完成这个过程?这会改变我需要采取的方法吗?

更新3

请参见新问题 这里


"https://my.crm.endpoint" 只是一个例子。实际上,它将是我们动态实例的终结点。我在这篇文章中更改了它 ;) - An0nC0d3r
我没有注意到错误信息已经改变了.. :) 在帖子中保留更改历史记录,当您在分析方面有进展时,可以在底部进行编辑和添加更多内容.. 不要替换它。 - Arun Vinoth-Precog Tech - MVP
1
您确定启用了凭据登录吗?它似乎想将您重定向到Windows身份验证登录。 - Tschallacka
1
https://msdn.microsoft.com/en-us/library/gg327838.aspx 似乎可以在您的动态机器上展示您所需的内容。此外,它似乎执行了一个重定向请求到脚本中定义的新URL。因此,这可能是身份验证脚本中定义的行为。 - Tschallacka
1
不确定您是否考虑使用Microsoft Hybrid Connection Manager- https://learn.microsoft.com/en-us/azure/app-service/app-service-hybrid-connections。我们也在Azure上托管了一个Node JS,通过它与On-prem数据库通信。如果需要,我可以提供更多详细信息。 - Souvik Ghosh
显示剩余7条评论
2个回答

2

所以...我通过反向工程浏览器的身份验证方法成功地将其启动 :) 没有代理或Azure的麻烦!

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

NB:下面的代码只是在我的Node scratch pad上创建的,所以它很混乱。我可能会整理它并在某个时候发布完整的写作,但现在,如果您使用任何此代码,则需要适当地重构;)

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

  })
}

1
我们曾经遇到过类似的情况。 我们的组织使用的是OnPrem 8.2版本,可以通过VPN或家庭网络访问。如果用非常基本的方式来看待这个问题,我们的CRM无法从外部访问。
我们所做的是:
  1. 创建了一个用于CRM操作的WebAPI。
  2. 通过额外的端口将此WebAPI开放给外部世界。
  3. 将此WebAPI作为服务添加到IIS中。
  4. 但是,我们确保只能通过我们在Web.config文件中创建的特定用户名和密码访问此WebAPI。
  5. 在后台,我们创建了Action。
  6. Action会运行插件,并根据要求返回数据,即WebAPI url可以进行修改。例如:.../acounts将返回账户实体,前提是您在插件中构建了逻辑。
  7. 请不要将此与Dynamics CRM OOB WebAPI混淆。我的意思是创建我们自己的API,并将其作为带有自己用户名和密码的服务添加到IIS中。

我认为这至少会给你一些方向上的提示。

如果你感兴趣的话,可以看看我的答案中提供的另一种方法。 - An0nC0d3r

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接