做 Azure AD B2C 身份接入久了,你会发现一个特别有意思的现象:登录、注册两个页面,大家还愿意花力气美化,等到了“忘记密码”这种边缘流程,基本就是默认界面直接上。但密码重置恰恰是用户最容易在深夜情绪崩溃时打开的页面,界面如果太敷衍,用户的第一反应不是“这系统有点丑”,而是“我的账号是不是要丢了”。这篇文章就专门聊聊 Azure AD B2C 的密码重置界面怎么定制,从方案选型、模板编写到 CORS 配置、用户流与自定义策略绑定,再到我一路上踩过的坑,完整过一遍。适合正在做 B2C 租户品牌化、打算把密码找回流程做像样的开发者和 IT 管理员参考。
1. 为什么要把默认密码重置界面换掉
1.1 默认界面到底差在哪
Azure AD B2C 自带的密码重置页面,技术上没什么毛病,但它有几个天然短板,放在真实业务环境里非常扎眼。
第一,视觉完全不统一。企业的登录页通常已经做了品牌化,有专属 logo、配色和字体,结果用户点“忘记密码”之后,一下子跳到一版“微软默认风”的页面,这种割裂感在客户演示时尤其尴尬。第二,文案没法随心改。默认页面上的提示语是微软写好的,你只能整体换模板,想在输入框下面加一句“密码至少 8 位,需包含大小写字母和数字”这种业务说明,默认界面根本不给入口。第三,交互有点僵硬。验证码按钮点完之后没有任何反馈倒计时,用户手一快就连点好几次,反而把验证码消费掉了。第四,错误提示不友好。用户输错验证码,页面显示一串像“AADB2C90080”的编号,普通用户看了只会更慌。
所以,所谓“定制密码重置界面”,不只是换个皮肤,而是要把这个高压力、低容错的流程,在产品视觉和引导文案上都拉回到自家产品的体验水准。这也是我把这件事单独拎出来写一篇的原因。
1.2 定制的三条路线,怎么选
Azure AD B2C 里做界面定制,大致有三条路线,按侵入程度从低到高排列:
| 路线 | 做法 | 定制能力 | 改造成本 | 适合场景 |
|---|---|---|---|---|
| 用户流 + 页面布局模板 | 在现有密码重置用户流里,给“本地帐户密码重置页面”配置自定义 HTML/CSS | 中,可换视觉、改提示、加简单脚本 | 低,门户操作即可 | 项目本身用用户流,且不需要改后端逻辑 |
| 自定义策略 + ContentDefinition | 修改 TrustFrameworkBase.xml 里的 LoadUri,把页面协定指向自己的模板 | 高,页面、文案、用户旅程都可以控制 | 中,需要熟悉 IEF 策略语法 | 项目已经用自定义策略,或需要精细控制验证流程 |
| 完全脱离标准页面做前置系统 | 用 API 连接器 / REST API 自己实现密码重置前端,B2C 只做后端认证 | 极高,前后端完全自定义 | 高,相当于自建一个小型重置系统 | 有特殊合规要求,或前端团队想把所有身份流程都收口 |
我这次的项目最终选了第二条路线,因为客户早就从用户流迁移到了自定义策略,登录、注册、资料编辑全都跑在 Identity Experience Framework 上,密码重置如果单独用用户流,会多出一套配置要维护。但如果你的项目只是用用户流,那第一条路完全够用,我后面会两条路都讲到。
提示:不管你走哪条路线,底层渲染原理和 CORS 要求基本一致。先把原理搞懂,后面配置就不会出奇奇怪怪的问题。
2. 先搞懂密码重置页面背后的渲染机制
2.1 密码重置用户旅程大致长什么样
在进行定制之前,得先搞清楚一个完整的密码重置流程在 B2C 里到底分几步。以最常见的“本地账户验证码重置”为例,完整链路是:
- 用户点击“忘记密码”,进入密码重置用户流。
- 页面要求输入邮箱地址。
- B2C 发送一封含验证码的邮件到该邮箱。
- 用户把邮件里的验证码填到页面上。
- 验证通过后,进入设置新密码的页面。
- 新密码提交成功,流程结束,可以跳转回登录页。
在这个流程里,第 2、4、5 步都是需要渲染页面的节点。B2C 不是刷新整个网页,而是在同一个 HTML 模板容器里,根据流程进度切换页面内容。这就引出了页面协定(Page Contract)的概念,也是做界面定制时必须理解的核心机制。
2.2 页面协定(Page Contract)是什么
你可以把页面协定理解成一份“B2C 和前端模板之间的渲染协议”。B2C 官方要求自定义模板里必须留出一个特定 ID 的容器,通常是:
<div id="api"><!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>重置密码</title> <style> :root { --brand-primary: #0067b8; --brand-bg: #f5f7fa; } body { margin: 0; font-family: "Segoe UI", "Microsoft YaHei", sans-serif; background: var(--brand-bg); } .login-container { max-width: 420px; margin: 60px auto; padding: 32px; background: #fff; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,.08); } .brand-logo { text-align: center; margin-bottom: 24px; } .brand-logo img { max-height: 48px; } #api { width: 100%; } .footer { text-align: center; margin-top: 24px; color: #888; font-size: 13px; } </style> </head> <body> <div class="login-container"> <div class="brand-logo"> <img src="https://your-cdn.example.com/logo.png" alt="公司 Logo" /> </div> <div id="api">az storage account create --name b2cuiassets --resource-group rg-b2cui --location eastasia --sku Standard_LRS --kind StorageV2 az storage blob service-properties update --account-name b2cuiassets --static-website true --index-document index.html --404-document error.html上传文件可以用:
az storage blob upload --account-name b2cuiassets --container-name $web --name index.html --file ./password-reset.html上传完成后,先直接在浏览器里打开静态网站地址,确认模板能正常显示。这里有一个容易忽略的细节:静态网站的 URL 是根路径https://<账户>.z13.web.core.windows.net/,如果你把模板文件命名为password-reset.html,那完整地址就是对应的子路径,绑定到 B2C 时一定要写全。
3.3 配置 CORS 让 B2C 能加载模板
这一步是决定成败的关键,也最容易被漏掉。回到存储账户,左侧找到“设置”下面的“资源分享(CORS)”,针对 Blob 服务添加一条规则。
| 配置项 | 推荐值 |
|---|---|
| 允许的源 | https://<你的租户名>.b2clogin.com |
| 允许的方法 | GET, OPTIONS |
| 允许的标头 | * |
| 公开的标头 | * |
| 最大期限 | 3600 |
如果你还在使用旧的login.microsoftonline.com域名,或者你所在的环境自定义了域名,需要把对应的来源也加进去。允许的来源可以填多个,但不建议写成*,因为 B2C 页面承载的是身份认证流程,CORS 开得越窄越安全。
规则保存后,一般一两分钟内就会生效。怎么确认 CORS 生效了?打开浏览器开发者工具,访问一次 B2C 的密码重置页面,在 Network 面板里看模板请求,如果 Response Headers 里出现了Access-Control-Allow-Origin,就说明配置成功。
3.4 绑定用户流并预览
模板托管好、CORS 配好后,就可以绑定到用户流了。如果你用的是用户流方式,操作路径是:
- 在 Azure AD B2C 租户里找到“用户流”,选择你的密码重置用户流。
- 点击左侧“页面布局(预览)”,找到“本地帐户密码重置页面”。
- 把“使用自定义页面内容”开关打开,在 URI 或 HTML 内容里填入你的模板地址。
- 保存后,点击“运行用户流”进行预览,或者直接发起一次真实的密码重置流程测试。
这里有个技巧:同一个用户流里通常还有“登录页面”等其他布局,如果只想改密码重置,就只在这个入口配置,别的不用动。如果想让视觉统一,可以把登录、注册页面的模板也指向同一套 CSS 风格。预览时如果发现模板没生效,优先检查 CORS 和 URL 是否填写完整,不要急着怀疑代码。
4. 进阶:在自定义策略里接入自定义模板
4.1 自定义策略和用户流的区别
如果你的项目已经切换到自定义策略(也就是 Identity Experience Framework),那么用户流那条路就基本不用了。策略文件本质上是一堆 XML,定义了用户旅程、技术配置文件和页面协定。定制界面的方式,是在 XML 里找到对应的ContentDefinition,把LoadUri改成你的模板地址。
自定义策略的优点是控制力强,密码重置这种多步骤流程,每个步骤对应的页面都可以单独指定模板,甚至可以针对不同语言、不同品牌加载不同版本。缺点也很明显,学习曲线陡,一个语法错误整个流程就会挂掉。所以我的建议是:如果还在用用户流,能用页面布局解决就别动策略文件;只有当你确实需要细粒度控制时再上手自定义策略。
4.2 改 ContentDefinition 把 LoadUri 指向模板
在实际项目中,密码重置相关的内容定义通常会在基础策略文件TrustFrameworkBase.xml里。你可以找一个类似的 ContentDefinition,然后做如下修改:
<ContentDefinition Id="api.localaccountpasswordreset"> <LoadUri>https://b2cuiassets.z13.web.core.windows.net/password-reset.html</LoadUri> <RecoveryUri>~/common/default_page_template.html</RecoveryUri> <DataUri>urn:com:microsoft:aad:b2c:elements:contract:selfasserted:2.1.7</DataUri> <Metadata> <Item Key="DisplayName">本地账户密码重置页面</Item> </Metadata> </ContentDefinition>这里有三个属性需要留意:
LoadUri:你的自定义模板地址。建议不要直接指向index.html根路径,而是指向明确的模板文件名,方便后续多页面管理和版本回退。RecoveryUri:回退模板地址。当自定义模板加载失败时,B2C 会使用这个默认模板,它是保命底牌,不要删。DataUri:页面协定的版本标识,它决定 B2C 注入表单的脚本行为。如果后面要开 JavaScript,需要把版本号升到支持脚本的版本,这一点我在第 5 部分详细说。
修改完 XML 后,通过“Identity Experience Framework -> 自定义策略 -> 上传”把策略包上传,然后重新运行密码重置用户旅程即可。注意:自定义策略通常分基础文件和扩展文件,界面定制相关改动建议放在扩展文件里,避免升级基础策略时被覆盖。
4.3 多步骤密码重置是怎么复用模板的
很多人在自定义策略里容易疑惑:密码重置明明有“输入邮箱”、“输入验证码”、“设置新密码”三个页面,为什么我只看到一个 ContentDefinition 就把界面改了?
实际情况是,这三个步骤确实会生成多个页面,但它们默认可以复用同一个模板文件。B2C 的页面协定会根据当前步骤动态切换div#api里的内容,你的模板不用为每个步骤单独准备一份 HTML,只要把>#api input[type="text"], #api input[type="password"], #api input[type="email"] { width: 100%; padding: 10px 12px; border: 1px solid var(--brand-primary); border-radius: 6px; box-sizing: border-box; }
这段样式的作用是,无论 B2C 注入什么结构,输入框至少能保持统一的宽度和间距,不会出现按钮和输入框“各长各的”的尴尬情况。
5.2 JavaScript 能用但有限制
很多第一次接触 B2C 模板的人以为模板里不能写 JavaScript,其实是可以的,只是默认关闭,而且只在特定版本下可用。
如果你在用用户流,需要在用户流的“属性”里手动打开“启用 JavaScript”开关;如果你在用自定义策略,需要把 ContentDefinition 的DataUri版本号提高到支持 JS 的版本,比如2.1.7或更高。打开后,模板里的<script>标签就能正常执行。
一个常见需求是给“发送验证码”按钮加倒计时。B2C 不会自动保存你的倒计时状态,所以刷新页面后会重新开始。我一般这样处理:
<script> window.onload = function () { var sendBtn = document.querySelector('[data-role="send-code"]') || document.querySelector('input[type="button"]'); if (sendBtn) { sendBtn.addEventListener("click", function () { countdown(sendBtn, 60); }); } }; function countdown(btn, seconds) { var origin = btn.value; btn.disabled = true; var timer = setInterval(function () { btn.value = seconds + "s 后可重发"; if (seconds === 0) { clearInterval(timer); btn.value = origin; btn.disabled = false; } seconds--; }, 1000); } </script>注意,B2C 的表单元素类名和属性在不同版本里可能变化,写脚本前一定要先打开开发者工具,实际确认一下按钮的选择器是什么,不要照抄网上的旧代码。脚本尽量做容错,用document.querySelector找不到目标时直接 return,不要抛错误影响整个页面。
5.3 多语言和错误提示优化
如果你的产品面向多个地区,模板需要支持多语言。B2C 在加载页面时会在 URL 上带ui_locales参数,模板可以直接从location.search里解析这个参数,然后切换对应语言的文案。
错误提示优化是我觉得性价比最高的定制点。默认的验证码错误提示虽然可用,但文案偏“官方化”。我通常会在脚本里监听错误消息容器,把特定错误码转换成更容易理解的话:
<script> window.onload = function () { var errorBox = document.querySelector("#api .error, #api .alert"); if (errorBox) { var text = errorBox.innerText || ""; if (text.indexOf("AADB2C90080") !== -1) { errorBox.innerText = "验证码不正确,请重新输入。"; } } }; </script>这个方法不一定覆盖所有场景,因为错误信息是在 B2C 脚本执行后异步注入的,直接在onload里获取可能太早。稳妥的做法是用MutationObserver监听div#api的内容变化,看到错误消息出现后再替换文案。这里我只提供思路,实际实现要以你抓到的 DOM 结构为准。
6. 我踩过的坑:常见问题与排查实录
6.1 模板不加载、样式丢失,先查 CORS
这个问题出现的频率最高。现象是模板文件在浏览器地址栏里能打开,但接到 B2C 页面后一片空白,或者样式全部丢失。排查顺序:
- 打开浏览器开发者工具,切到 Network 面板,过滤 Doc / Fetch 请求。
- 找到加载模板的请求,看 Response Headers 里有没有
Access-Control-Allow-Origin。 - 如果没有,就是存储端的 CORS 没配好,去存储账户的“资源分享(CORS)”里补充规则。
如果 CORS 已经配置了还不生效,检查允许的源是否写对了域名。注意b2clogin.com的租户名是 B2C 租户的完整名称,比如contoso.b2clogin.com,不要只写contoso。还有,CORS 规则保存后需要一点时间生效,刚改完立刻刷新测试有可能还是旧的,等一两分钟再试。
6.2 页面出来了,但按钮没反应
模板加载正常,页面也显示了 logo 和文案,但用户点击“发送验证码”或“验证”按钮没有任何反应。这种情况多半是模板结构有问题。
最常见的元凶是模板里自己写了<form>标签。B2C 在div#api里注入的表单有自己的提交逻辑,你再在外层包一个 form,会导致事件绑定错乱。解决办法很简单:删掉模板里所有的<form>,只保留div#api作为容器。
另一个原因是div#api里预先放了东西。有些人为了让页面看起来“丰富”,在容器里写死了按钮,结果 B2C 注入内容时和静态内容冲突。记住,div#api必须是一个空容器,页面上的静态元素都应该放在它的外面。
6.3 修改模板后看不到变化
这是一个老生常谈但每次都有人踩的坑,包括我自己也吃过亏。第一次上传模板后调试,改了几个样式,刷新 B2C 页面,发现还是老样子,于是开始怀疑是不是策略文件没生效。
排查要点:
- 先按 F12 打开开发者工具看 Network,确认模板请求是不是命中了你的托管地址。
- 如果请求地址正确但内容没变,那就是缓存问题。浏览器、CDN、B2C 端都可能缓存模板文件。
- 最简单的办法是在模板 URL 后面加查询参数版本号,比如
password-reset.html?v=20250401,强制绕过缓存。 - 我给存储服务设置 Cache-Control 时的经验是:开发阶段用
no-cache,生产环境再默认缓存。
6.4 错误码很“官方”,怎么换成大白话
用户输入错误验证码时,B2C 默认会显示一条包含技术错误码的提示,比如 “AADB2C90080” 之类的文字。这种提示在测试阶段还好,真正上线后很容易被用户截图吐槽。
除了前面提到的在前端用 MutationObserver 替换文案之外,还有一种更干净的做法是处理 B2C 服务端的本地化资源。在自定义策略里,可以针对不同错误码配置本地化消息,把默认文案替换成“验证码不正确,请检查后重新输入”。前端替换适合快速迭代,服务端本地化适合正式交付,两种方式可以结合使用。
另外,排查时注意区分两类错误码:一类是“交互失败”,比如验证码错、账户不存在,这类适合做友好提示;另一类是“系统配置错误”,比如策略文件写错,这类应该直接暴露出来方便技术人员定位,不要强行转换文案,否则会掩盖真正的配置问题。
6.5 验证码邮件迟迟不到,可以先自查这几个环节
界面定制完成后,最后一步往往是联调邮件发送。邮件收不到的原因可能很多,我常用的排查顺序是:
- 确认邮箱地址没有输错,且该用户确实存在于当前 B2C 租户。
- 查看 B2C 的“运行日志”或 Application Insights,确认验证码邮件是否已经触发发送。
- 检查邮件是否进了垃圾箱。B2C 默认发送的邮件发件人比较通用,很多邮箱服务可能拦截。
- 如果项目使用的是第三方邮件连接器(比如 SendGrid),去第三方后台看投递记录,确认是否被退信。
这方面有个经验:验证码邮件本身不属于“界面定制”的范畴,但用户感知里它和密码重置界面是同一个流程,如果邮件样式和语气都很突兀,前面做的界面定制分数会大打折扣。有条件的话,建议用 API 连接器或自定义邮件模板,把邮件发件人和内容也品牌化,整套体验才完整。
6.6 登录和密码重置页面像两套系统
这是品牌化过程中最容易出现的“局部最优”问题:密码重置模板做得再漂亮,如果登录页还停留在默认风格,整体品牌体验还是割裂的。
我处理客户项目时,会先建一套统一的 CSS 变量和几个基础组件(logo 容器、输入框、按钮、页脚),然后把它复用到登录、注册、密码重置等所有 B2C 页面里。每个页面的模板只做差异化调整,不重写整套样式。这样后续改品牌色时,只需要改一个地方即可,不至于在维护中迷失方向。
提示:在用模板前,可以先在本地把 HTML 用浏览器静态打开,快速确认布局是否正常。虽然 B2C 注入的内容在静态预览时看不到,但外层样式、响应式布局这些基础项是可以提前检查的。
最后再分享一点体会
界面定制这件事,平时没人夸,但一出问题大家都会骂。密码重置又是一个用户在慌乱中进行的流程,一套看起来“安心”的界面,能明显减少用户的恐慌感。我在做这类定制时,优先把三件事做扎实:验证码重发有倒计时、错误提示说人话、按钮点击有状态反馈。这三件事做到位,比任何花哨动画都管用。
另外还有一个后端层面的建议,虽然和界面无关,但容易被前端同事忽略:密码规则这类校验,一定要用自定义策略在服务端做兜底,不要只依赖前端脚本。界面定制是体验层的事,安全校验是服务端的事,两条线分开推进,页面才会既好看又稳靠。