为一次性密码发送事件配置自定义电子邮件提供程序(预览版)

适用于白色圆圈,带灰色 X 符号。 员工租户 绿色圆圈,带白色复选标记符号。 外部租户(了解详细信息

本文提供有关配置和设置一次性密码 (OTP) 发送事件类型的自定义电子邮件提供程序的指南。 激活 OTP 电子邮件时触发该事件,它允许通过调用 REST API 调用 REST API 来使用自己的电子邮件提供程序。

提示

立即试用

若要试用此功能,请转到 Woodgrove Groceries 演示,并启动“使用一次性代码的自定义电子邮件提供程序”用例。

先决条件

步骤 1:创建 Azure 函数应用

本部分介绍如何在 Azure 门户中设置 Azure 函数应用。 函数 API 是电子邮件提供商的网关。 创建一个 Azure 函数应用来托管 HTTP 触发器函数,并在函数中配置设置。

提示

本文中的步骤可能因开始使用的门户而略有不同。

  1. 以至少应用程序管理员身份验证管理员身份登录到 Azure 门户

  2. 在 Azure 门户菜单或“主页”页中,选择“创建资源”

  3. 搜索并选择“函数应用”,然后选择“创建”

  4. 在“创建函数应用”页面上,依次选择“消耗”、“选择”

  5. 在“创建函数应用(消耗)”页面的“基本信息”选项卡,使用下表指定的设置创建函数应用

    设置 建议的值 说明
    订阅 订阅 在其下创建此新函数应用的订阅。
    资源组 myResourceGroup 选择用于设置 Azure 通信服务和电子邮件通信服务资源的资源组作为先决条件的一部分
    函数应用名称 全局唯一名称 用于标识新函数应用的名称。 有效字符为 a-z(不区分大小写)、0-9-
    部署代码或容器映像 代码 用于发布代码文件或 Docker 容器的选项。 对于本教程,请选择“代码”
    运行时堆栈 .NET 你的首选编程语言。 对于本教程,请选择“.NET”
    版本 8 (LTS) 进程内 .NET 运行时的版本。 进程内表示可以在门户中创建和修改函数,这是本指南建议的操作
    地区 首选区域 选择一个靠近你或靠近函数可以访问的其他服务 的区域
    操作系统 Windows操作系统 系统会根据你选择的运行时堆栈预先选择操作系统。
  6. 选择“查看 + 创建”以查看应用配置选择,然后选择“创建”。 部署需要几分钟时间。

  7. 部署后,选择“转到资源”查看新函数应用

1.1 创建 HTTP 触发器函数

创建 Azure 函数应用后,创建一个 HTTP 触发器函数。 借助 HTTP 触发器,可以使用 HTTP 请求调用函数。 Microsoft Entra 自定义身份验证扩展插件链接到此 HTTP 触发器函数。

  1. 在你的函数应用中,从菜单中选择“函数”
  2. 选择“创建函数”
  3. 在“创建函数”窗口中,在“选择模板”下,搜索并选择“HTTP 触发器”模板。 选择“下一步”
  4. 在“模板详细信息”下,为“函数名称”属性输入 CustomAuthenticationExtensionsAPI
  5. 对于“授权级别”,请选择“函数”
  6. 选择“创建”

1.2 编辑函数

该代码首先读取传入的 JSON 对象。 Microsoft Entra ID 将 JSON 对象 发送到 API。 在此示例中,它会读取电子邮件地址(标识符)和 OTP。 然后,代码将详细信息发送到通信服务,以使用 动态模板发送电子邮件。

本操作指南演示了使用 Azure 通信服务和 SendGrid 的 OTP 发送事件。 使用选项卡选择实现。

  1. 在菜单中,选择“代码 + 测试”

  2. 请将整个代码替换为以下代码片段。

    using System.Dynamic;
    using System.Text.Json;
    using System.Text.Json.Nodes;
    using System.Text.Json.Serialization;
    using Azure.Communication.Email;
    using Microsoft.AspNetCore.Http;
    using Microsoft.AspNetCore.Http.HttpResults;
    using Microsoft.AspNetCore.Mvc;
    using Microsoft.Azure.Functions.Worker;
    using Microsoft.Extensions.Logging;
    
    namespace Company.AuthEvents.OnOtpSend.CustomEmailACS
    {
        public class CustomEmailACS
        {
            private readonly ILogger<CustomEmailACS> _logger;
    
            public CustomEmailACS(ILogger<CustomEmailACS> logger)
            {
                _logger = logger;
            }
    
            [Function("OnOtpSend_CustomEmailACS")]
            public async Task<IActionResult> RunAsync([HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequest req)
            {
                _logger.LogInformation("C# HTTP trigger function processed a request.");
    
                // Get the request body
                string requestBody = await new StreamReader(req.Body).ReadToEndAsync();
                JsonNode jsonPayload = JsonNode.Parse(requestBody)!;
    
                // Get OTP and mail to
                string emailTo = jsonPayload["data"]!["otpContext"]!["identifier"]!.ToString();
                string otp = jsonPayload["data"]!["otpContext"]!["onetimecode"]!.ToString();
    
                // Send email
                await SendEmailAsync(emailTo, otp);
    
                // Prepare response
                ResponseObject responseData = new ResponseObject("microsoft.graph.OnOtpSendResponseData");
                responseData.Data.Actions = new List<ResponseAction>() { new ResponseAction(
                    "microsoft.graph.OtpSend.continueWithDefaultBehavior") };
    
                return new OkObjectResult(responseData);
            }
    
            private async Task SendEmailAsync(string emailTo, string code)
            {
                // Get app settings
                var connectionString = Environment.GetEnvironmentVariable("mail_connectionString");
                var sender = Environment.GetEnvironmentVariable("mail_sender");
                var subject = Environment.GetEnvironmentVariable("mail_subject");
    
                try
                {
                    if (!string.IsNullOrEmpty(connectionString))
                    {
                        var emailClient = new EmailClient(connectionString);
                        var body = EmailTemplate.GenerateBody(code);
    
                        _logger.LogInformation($"Sending OTP to {emailTo}");
    
                        EmailSendOperation emailSendOperation = await emailClient.SendAsync(
                        Azure.WaitUntil.Started,
                        sender,
                        emailTo,
                        subject,
                        body);
                    }
                }
                catch (System.Exception ex)
                {
                    _logger.LogError(ex.Message);
                }
            }
        }
    
        public class ResponseObject
        {
            [JsonPropertyName("data")]
            public Data Data { get; set; }
    
            public ResponseObject(string dataType)
            {
                Data = new Data(dataType);
            }
        }
    
        public class Data
        {
            [JsonPropertyName("@odata.type")]
            public string DataType { get; set; }
            [JsonPropertyName("actions")]
            public List<ResponseAction> Actions { get; set; }
    
            public Data(string dataType)
            {
                DataType = dataType;
            }
        }
    
        public class ResponseAction
        {
            [JsonPropertyName("@odata.type")]
            public string DataType { get; set; }
    
            public ResponseAction(string dataType)
            {
                DataType = dataType;
            }
        }
    
        public class EmailTemplate
        {
            public static string GenerateBody(string oneTimeCode)
            {
                return @$"<html><body>
                <div style='background-color: #1F6402!important; padding: 15px'>
                    <table>
                    <tbody>
                        <tr>
                            <td colspan='2' style='padding: 0px;font-family: "Segoe UI Semibold", "Segoe UI Bold", "Segoe UI", "Helvetica Neue Medium", Arial, sans-serif;font-size: 17px;color: white;'>Woodgrove Groceries live demo</td>
                        </tr>
                        <tr>
                            <td colspan='2' style='padding: 15px 0px 0px;font-family: "Segoe UI Light", "Segoe UI", "Helvetica Neue Medium", Arial, sans-serif;font-size: 35px;color: white;'>Your Woodgrove verification code</td>
                        </tr>
                        <tr>
                            <td colspan='2' style='padding: 25px 0px 0px;font-family: "Segoe UI", Tahoma, Verdana, Arial, sans-serif;font-size: 14px;color: white;'> To access <span style='font-family: "Segoe UI Bold", "Segoe UI Semibold", "Segoe UI", "Helvetica Neue Medium", Arial, sans-serif; font-size: 14px; font-weight: bold; color: white;'>Woodgrove Groceries</span>'s app, please copy and enter the code below into the sign-up or sign-in page. This code is valid for 30 minutes. </td>
                        </tr>
                        <tr>
                            <td colspan='2' style='padding: 25px 0px 0px;font-family: "Segoe UI", Tahoma, Verdana, Arial, sans-serif;font-size: 14px;color: white;'>Your account verification code:</td>
                        </tr>
                        <tr>
                            <td style='padding: 0px;font-family: "Segoe UI Bold", "Segoe UI Semibold", "Segoe UI", "Helvetica Neue Medium", Arial, sans-serif;font-size: 25px;font-weight: bold;color: white;padding-top: 5px;'>
                            {oneTimeCode}</td>
                            <td rowspan='3' style='text-align: center;'>
                                <img src='https://woodgrovedemo.com/custom-email/shopping.png' style='border-radius: 50%; width: 100px'>
                            </td>
                        </tr>
                        <tr>
                            <td style='padding: 25px 0px 0px;font-family: "Segoe UI", Tahoma, Verdana, Arial, sans-serif;font-size: 14px;color: white;'> If you didn't request a code, you can ignore this email. </td>
                        </tr>
                        <tr>
                            <td style='padding: 25px 0px 0px;font-family: "Segoe UI", Tahoma, Verdana, Arial, sans-serif;font-size: 14px;color: white;'> Best regards, </td>
                        </tr>
                        <tr>
                            <td>
                                <img src='https://woodgrovedemo.com/Company-branding/headerlogo.png' height='20'>
                            </td>
                            <td style='font-family: "Segoe UI", Tahoma, Verdana, Arial, sans-serif;font-size: 14px;color: white; text-align: center;'>
                                <a href='https://woodgrovedemo.com/Privacy' style='color: white; text-decoration: none;'>Privacy Statement</a>
                            </td>
                        </tr>
                    </tbody>
                    </table>
                </div>
                </body></html>";
            }
        }
    }
    
  3. 选择“获取函数 URL”,并复制“函数密钥” URL,该 URL 自此使用并称为 {Function_Url}。 关闭函数。

步骤 2:将连接字符串添加到 Azure 函数

连接字符串使函数应用能够连接到邮件中继服务并进行身份验证。 对于 Azure 通信服务和 SendGrid,请将这些连接字符串作为环境变量添加到 Azure 函数应用。

2.1:从 Azure 通信服务资源中提取连接字符串和服务终结点

你可以从 Azure 门户或使用 Azure 资源管理器 API 以编程方式访问通信服务连接字符串和服务终结点。

  1. Azure 门户主页中,打开门户菜单,搜索并选择“所有资源”。

  2. 搜索并选择作为本文先决条件的一部分创建的 Azure 通信服务

  3. 在左窗格中,选择“设置”下拉列表,然后选择“键”

  4. 复制“终结点”,然后从“主密钥”复制“密钥”和“连接字符串”的值。

    Azure 通信服务密钥页的屏幕截图,其中显示了终结点和密钥位置。

2.2:将连接字符串添加到 Azure 函数

  1. 导航回到在 “创建 Azure 函数”应用中创建的 Azure 函数

  2. 在函数应用的“概述”页的左侧菜单中,选择“设置”>“环境变量”添加以下应用设置。 添加所有设置后,选择“应用”,然后选择“确认”

    设置 值(示例) 说明
    mail_connectionString https://ciamotpcommsrvc.unitedstates.communication.azure.com/:accesskey= Azure 通信服务终结点
    mail_sender from.email@myemailprovider.com 发件人电子邮件地址。
    mail_subject 帐户验证码 电子邮件主题。

步骤 3:注册自定义身份验证扩展

在此步骤中,你将配置自定义身份验证扩展,供 Microsoft Entra ID 用来调用你的 Azure 函数。 该自定义身份验证扩展包含有关 REST API 终结点、它从 REST API 分析的声明以及如何对 REST API 进行身份验证的信息。 使用 Azure 门户或 Microsoft Graph 注册应用程序,对 Azure 函数验证自定义身份验证扩展身份。

注册自定义身份验证扩展

  1. 以至少应用程序管理员身份验证管理员身份登录到 Azure 门户

  2. 搜索并选择“Microsoft Entra ID”,然后选择“企业应用程序”

  3. 选择“自定义身份验证扩展”,然后选择“创建自定义扩展”

  4. 在“基本信息”中选择“EmailOtpSend”事件类型,然后选择“下一步”

    Azure 门户的屏幕截图,其中突出显示了电子邮件 OTP 发送事件。

  5. 在“终结点配置”选项卡中,填写以下属性,然后选择“下一步”继续

    • 名称 - 自定义身份验证扩展的名称。 例如:Email OTP Send
    • 目标 URL - Azure 函数 URL 的 {Function_Url}。 导航到 Azure 函数应用的“概述”页,然后选择创建的函数。 在函数“概述”页中,选择“获取函数 URL”,并使用复制图标复制 customauthenticationextension_extension(系统密钥)URL
    • 说明 - 自定义身份验证扩展的说明
  6. 在“API 身份验证”选项卡中,选择“创建新的应用注册”选项以创建代表你的函数应用的应用注册

  7. 为应用命名,例如“Azure Functions 身份验证事件 API”,并选择“下一步”

  8. 在“应用程序”选项卡中,选择要与自定义身份验证扩展关联的应用程序。 选择“下一步”。 通过选中此框,可以选择在整个租户中应用它。 选择“下一步”继续

  9. 在“查看”选项卡中,检查自定义身份验证扩展的详细信息是否正确。 请注意API 身份验证下的应用 ID,这在 Azure 函数应用中为Azure 函数配置身份验证时是需要的。 选择“创建”

创建自定义身份验证扩展后,在“应用注册”下从门户打开应用程序,并选择“API 权限”

在“API 权限”页面,选择“为“YourTenant”授予权限”按钮,向已注册的应用授予管理员同意,从而允许自定义身份验证扩展对你的 API 进行身份验证。 自定义身份验证扩展凭借 client_credentials 权限使用 Receive custom authentication extension HTTP requests 对 Azure 函数应用进行身份验证。

以下屏幕截图显示了如何授予权限。

Azure 门户的屏幕截图,以及如何授予管理员同意。

步骤 4:配置用于测试的 OpenID Connect 应用

要获取令牌并测试自定义身份验证扩展,可以使用 https://jwt.ms 应用。 它是 Microsoft 拥有的一个 Web 应用程序,可显示令牌的解码内容(令牌内容永远不会离开你的浏览器)。

按照以下步骤注册 jwt.ms Web 应用程序

4.1 注册测试 Web 应用

  1. 以至少应用程序管理员身份登录到 Microsoft Entra 管理中心
  2. 浏览到 Entra ID>应用注册
  3. 选择“新注册”
  4. 输入应用程序的“名称”。 例如:My Test application
  5. 在“支持的帐户类型”下,选择“仅此组织目录中的帐户”。
  6. 在“重定向 URI”中的“选择平台”下拉列表中选择“Web”,然后在“URL”文本框中输入 https://jwt.ms
  7. 选择“注册”以完成应用注册
  8. 在应用注册中的“概述”下,复制“应用程序(客户端) ID”,稍后将使用它,并称为 {App_to_sendotp_ID}。 在 Microsoft Graph 中,appId 属性引用了它

以下屏幕截图显示如何注册 My Test application

显示如何选择支持的帐户类型和重定向 URI 的屏幕截图。

4.1 获取应用程序 ID

在应用注册中的“概述”下,复制“应用程序(客户端) ID”。 在后续步骤中,该应用 ID 将引用为 {App_to_sendotp_ID}。 在 Microsoft Graph 中,appId 属性引用了它

4.2 启用隐式流

jwt.ms 测试应用程序使用隐式流。 在 My Test application 注册中启用隐式流

重要说明

Microsoft 建议使用最安全的可用身份验证流。 在此过程中用于测试的身份验证流需要高度信任应用程序,并且存在其他流中不存在的风险。 此方法不应用于向生产应用(了解详细信息)对用户进行身份验证。

  1. 在“管理”下,选择“身份验证”。
  2. 在“隐式授权和混合流”下,选中“ID 令牌(用于隐式流和混合流)”复选框
  3. 选择“保存”

步骤 5:保护 Azure 函数

Microsoft Entra 自定义身份验证扩展使用服务器到服务器的流来获取在 HTTP Authorization 标头中发送到 Azure 函数的访问令牌。 将函数发布到 Azure 时(尤其是在生产环境中),需要验证在授权标头中发送的令牌。

要保护 Azure 函数,请按照以下步骤集成 Microsoft Entra 身份验证,以便使用 Azure Functions 身份验证事件 API 应用程序注册来验证传入的令牌

注意

如果 Azure 函数应用托管在与注册自定义身份验证扩展的租户不同的 Azure 租户中,请跳到 使用 OpenID Connect 标识提供者 步骤。

  1. 登录到 Azure 门户
  2. 导航到之前发布的函数应用并选择它。
  3. 在左侧菜单中选择“身份验证”
  4. 选择“添加标识提供者”
  5. 从下拉菜单中选择 Microsoft 作为标识提供者。
  6. “应用注册->应用注册类型”下,选择此目录中的现有应用注册,然后选择注册自定义电子邮件提供程序时之前创建的Azure Functions 身份验证事件 API 应用注册。
  7. 为应用添加客户端密码到期日期
  8. 在“未经身份验证的请求”下,选择“HTTP 401 未授权”作为标识提供者
  9. 取消选择“令牌存储”选项
  10. 选择“添加”以将身份验证添加到 Azure 函数

显示如何将身份验证添加到函数应用的屏幕截图。

5.1 使用 OpenID Connect 标识提供者

如果配置了Microsoft标识提供者,请跳过此步骤。 否则,如果 Azure 函数所在的租户不同于注册自定义身份验证扩展的租户,请执行以下步骤保护函数:

  1. 登录到 Azure 门户,然后导航并选择之前发布的函数应用。

  2. 选择左窗格的“身份验证”

  3. 选择“添加标识提供者”

  4. 选择“OpenID Connect”作为标识提供者

  5. 提供名称,例如 Contoso Microsoft Entra ID

  6. 在“元数据条目”下的“文档 URL”中输入以下 URL。 将 {tenantId} 替换为 Microsoft Entra 租户 ID,并将 {tenantname} 替换为租户的名称,而不使用“onmicrosoft.com”。

    https://{tenantname}.ciamlogin.com/{tenantId}/v2.0/.well-known/openid-configuration
    
  7. 应用注册下,输入之前创建的Azure Functions 身份验证事件 API 应用注册的应用程序 ID(客户端 ID)。

  8. 在 Microsoft Entra 管理中心:

    1. 选择您之前创建的Azure Functions 身份验证事件 API 应用注册。
    2. 选择“证书和机密”“客户端密码”>“新建客户端密码”>
    3. 添加客户端密码的说明。
    4. 选择密码的过期时间,或指定自定义的生存期。
    5. 选择“添加”
    6. 请记下机密值,以便在客户端应用程序代码中使用。 退出此页面后,此密码值永不再显示。
  9. 返回 Azure 函数,在“应用注册”下输入客户端密码

  10. 取消选择“令牌存储”选项

  11. 选择“添加”以添加 OpenID Connect 标识提供者

步骤 6:测试应用程序

若要测试自定义电子邮件提供程序,请执行以下步骤:

  1. 打开新的隐私浏览器,并通过以下 URL 进行导航和登录。

    https://{tenantname}.ciamlogin.com/{tenant-id}/oauth2/v2.0/authorize?client_id={App_to_sendotp_ID}&response_type=id_token&redirect_uri=https://jwt.ms&scope=openid&state=12345&nonce=12345
    
  2. {tenant-id} 替换为你的租户 ID、租户名称或已验证的域名之一。 例如 contoso.onmicrosoft.com

  3. {tenantname} 替换为没有“onmicrosoft.com”的租户名称。

  4. {App_to_sendotp_ID} 替换为 My Test application 的注册 ID

  5. 确保使用 电子邮件一次性密码帐户登录。 然后选择“发送代码”。 确保发送到已注册电子邮件地址的代码使用上面注册的自定义提供程序。

步骤 7:回退到 Microsoft 提供程序

如果扩展 API 中发生错误,默认情况下,Entra ID 不会向用户发送 OTP。 可以改为将错误上的行为设置为回退到 Microsoft 提供程序。

若要启用此功能,请运行以下请求。 将 {customListenerObjectId} 替换为前面记下的自定义身份验证侦听器 ID。

  • 需要 EventListener.ReadWrite.All 委托权限
PATCH https://graph.microsoft.com/beta/identity/authenticationEventListeners/{customListenerOjectId}

{
    "@odata.type": "#microsoft.graph.onEmailOtpSendListener",
    "handler": {
        "@odata.type": "#microsoft.graph.onOtpSendCustomExtensionHandler",
        "configuration": {
            "behaviorOnError": {
                "@odata.type": "#microsoft.graph.fallbackToMicrosoftProviderOnError"
            }
        }
    }
}

另请参阅