PAYATHON 2026

如何使用 Microsoft Identity 处理登录后逻辑与用户数据?

技术老齐

外部身份提供方负责验证用户身份,应用仍需要自己的用户资料库。主题、评论作者、业务权限等数据可以保存在本地数据库中,通过稳定的外部身份标识与登录用户关联。应用无须保存密码,也无须引入完整的 ASP.NET Core Identity。

对于 Blazor WebAssembly 与 ASP.NET Core API,建议在前端登录后调用受保护的初始化接口,由后端查找或创建本地用户,返回个人资料和偏好,再由前端更新界面。

先区分认证、用户资料和业务权限

名称主要职责
Microsoft identity platform、Azure AD(现称 Microsoft Entra ID)提供身份认证、令牌签发等服务
ASP.NET Core Identity管理应用用户、密码、外部登录关联等,可按需采用
Microsoft.Identity.Web帮助 ASP.NET Core 应用接入 Microsoft identity platform
应用自己的用户表保存昵称、主题、头像引用和业务数据关系

使用 Microsoft 账户登录的应用,也可以只建立所需的业务表来保存本地用户数据,无须自行处理密码。

协议上,OAuth 2.0 处理授权;登录通常使用建立在 OAuth 2.0 之上的 OpenID Connect(OIDC)。ID token 用于客户端确认登录身份,access token 用于调用目标 API。后端应验证发给自己 API 的 access token,不能把前端传来的用户资料当作认证依据。Microsoft OIDC 文档

用稳定的外部身份标识关联本地用户

ClaimTypes.NameIdentifier 是 .NET 中的 claim 类型名称,实际来源取决于认证处理程序和 claims 映射配置。仅凭这个名称,无法确定它是否为 Azure AD 的用户对象 ID。

关联键通常这样选择:

  • Microsoft Entra ID 使用 tid(租户 ID)与 oid(该租户中的对象 ID)的组合定位用户。
  • 通用 OIDC 提供方使用 iss(签发方)与 sub(主体标识)的组合,并保持应用注册范围一致。
  • 邮箱、姓名、UPN 都可能变化,不适合作为永久用户主键。

Microsoft identity platform 的 sub 具有应用范围,同一个人在不同应用注册中可能得到不同的 sub。如果前端和 API 使用不同的应用注册,不能假定双方的 sub 相同。在同一租户上下文中,oid 与 tid 更适合用于跨服务关联用户;同一个人在不同租户中的账户仍应分别处理。Microsoft 用户标识说明

应用内部再生成自己的 AppUserId,让评论、订单等业务表引用这个内部 ID。外部身份关联集中存放,方便以后增加其他登录方式。

初期可以采用以下模型:

表主要字段
AppUsersId、DisplayName、AvatarPath、Theme、CreatedAt
ExternalLoginsAppUserId、Provider、TenantId、ObjectId
CommentsId、ArticleId、AppUserId、CommentContent、CreatedAt

这里的 ExternalLogins 针对 Entra ID 设计,需要为 (Provider, TenantId, ObjectId) 建立唯一约束。接入通用 OIDC 时,可以使用对应的 Issuer、Subject 字段和唯一约束。

如果只接入一个身份提供方,也可以先把外部身份字段放在用户表中。独立的关联表更方便让一个本地用户绑定多个登录账户。绑定时必须验证账户控制权,不能仅凭邮箱相同就自动合并账户。

登录后在后端初始化用户

在 WebAssembly 架构中,用户完成浏览器登录时,后端 API 未必会收到“登录成功”的请求。通常要等到前端携带 access token 调用 API,后端才会验证并识别当前用户。

可以提供一个 POST /api/me/bootstrap 接口,完成以下步骤:

  1. 验证 access token,并检查所需的 delegated scope。
  2. 从后端已验证的 ClaimsPrincipal 读取外部身份标识。
  3. 查找本地用户;首次访问时创建用户和外部身份关联。
  4. 返回应用需要的资料和偏好。

以下控制器示例要求 API 已配置 Microsoft.Identity.Web 的 Bearer 认证、可信签发方及 audience 验证。access_as_user 是示例 scope,必须替换为应用实际公开并授权的值。Microsoft API 授权说明

using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Identity.Web.Resource;

[ApiController]
[Route("api/me")]
[Authorize]
[RequiredScope("access_as_user")]
public sealed class MeController : ControllerBase
{
    private readonly IAppUserService _users;

    public MeController(IAppUserService users)
    {
        _users = users;
    }

    [HttpPost("bootstrap")]
    public async Task<ActionResult<ProfileDto>> Bootstrap(
        CancellationToken cancellationToken)
    {
        // 同时兼容原始 claim 名称和常见的映射名称。
        var tenantId =
            User.FindFirstValue("tid") ??
            User.FindFirstValue(
                "http://schemas.microsoft.com/identity/claims/tenantid");

        var objectId =
            User.FindFirstValue("oid") ??
            User.FindFirstValue(
                "http://schemas.microsoft.com/identity/claims/objectidentifier");

        if (string.IsNullOrWhiteSpace(tenantId) ||
            string.IsNullOrWhiteSpace(objectId))
        {
            return Forbid();
        }

        var suggestedName =
            User.FindFirstValue("name") ??
            User.FindFirstValue(ClaimTypes.Name);

        var profile = await _users.GetOrCreateAsync(
            "MicrosoftEntra",
            tenantId,
            objectId,
            suggestedName,
            cancellationToken);

        return Ok(profile);
    }
}

IAppUserService、ProfileDto 和 GetOrCreateAsync 都需要由应用自行定义。服务负责数据库查询、用户创建和资料同步,并通过依赖注入注册。

实现时需要处理并发创建和重复调用:

  • 多个请求可能同时触发首次创建。应在事务中保存用户及关联记录,通过数据库唯一约束防止重复,遇到关联键冲突后重新查询。
  • 刷新页面、重试或重新登录都可能再次调用初始化接口。重复执行应返回同一个用户,不能重复发放注册奖励或创建业务资源。

评论等业务接口也应复用创建本地用户的逻辑,避免依赖浏览器一定先调用初始化接口。对于需要邀请、审批或租户准入检查的企业系统,应先检查准入条件,再创建或启用业务账户。

如果后端本身承接 OIDC 登录重定向,可以在相应的认证事件中处理用户初始化。纯 WebAssembly 加 Bearer API 的架构则更适合显式调用初始化接口。

在 Blazor 中加载偏好并应用主题

使用 RemoteAuthenticatorView 时,可以通过 OnLogInSucceeded 执行登录成功后的客户端逻辑,例如调用资料加载服务。RemoteAuthenticatorView API

刷新页面或恢复已有登录状态时,也需要重新加载主题。可以把资料加载封装为可复用的客户端服务,在登录成功和应用恢复认证状态后调用,方便维护。

需要授权的页面可以这样初始化:

@page "/account"
@attribute [Authorize]
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
@inject HttpClient Http

@if (_profile is null)
{
    <p>正在加载个人资料……</p>
}
else
{
    <div class="@_themeClass">
        <p>@_profile.DisplayName</p>
    </div>
}

@code {
    private ProfileDto? _profile;
    private string _themeClass = "theme-light";

    protected override async Task OnInitializedAsync()
    {
        try
        {
            using var response =
                await Http.PostAsync("api/me/bootstrap", null);

            response.EnsureSuccessStatusCode();

            _profile =
                await response.Content.ReadFromJsonAsync<ProfileDto>();

            _themeClass = _profile?.Theme == "dark"
                ? "theme-dark"
                : "theme-light";
        }
        catch (AccessTokenNotAvailableException exception)
        {
            exception.Redirect();
        }
    }

    public sealed class ProfileDto
    {
        public string DisplayName { get; set; } = "";
        public string Theme { get; set; } = "light";
    }
}

此示例要求路由使用 AuthorizeRouteView,注入的 HttpClient 已配置面向该 API 的 AuthorizationMessageHandler,能够获取并附加对应的 access token。普通 HttpClient 不会自动附带登录令牌。Blazor 出站请求令牌处理

配合以下 CSS 即可更新界面:

.theme-light {
    color: #222;
    background-color: #fff;
}

.theme-dark {
    color: #eee;
    background-color: #181818;
}

这个例子只影响包裹元素及其子元素。全站主题可以由布局组件持有,通过 CSS 变量传递给页面。

如果必须修改 document.documentElement,可以通过 JS interop 设置属性。需要访问已渲染 DOM 的操作应放在 OnAfterRenderAsync 中,并在主题值变化时执行。仅检查 firstRender,可能漏掉异步加载资料后才获取到的主题。Blazor 组件生命周期

WebAssembly 前端应通过 AuthenticationStateProvider 读取当前认证状态。HttpContext.User 属于后端请求上下文,不用于前端。

读取评论时查询作者资料

其他人浏览 Taylor 的评论时,后端应通过评论中的 AppUserId 查询本地作者资料,无须重新获取 Taylor 的 token。当前访问者的 claims 也无法提供 Taylor 的信息。

例如,可以使用 Entity Framework Core 投影出展示评论所需的字段:

var comments = await db.Comments
    .AsNoTracking()
    .Where(comment => comment.ArticleId == articleId)
    .Select(comment => new
    {
        comment.Id,
        comment.CommentContent,
        Author = new
        {
            comment.AppUser.DisplayName,
            comment.AppUser.AvatarPath
        }
    })
    .ToListAsync(cancellationToken);

其中,AppUser 是评论实体到用户实体的导航属性。实际接口还应添加排序、分页,以及文章和租户的访问权限检查。

写评论时,后端必须根据当前已验证的身份解析作者 ID。请求体可以包含文章 ID 和评论内容,评论作者不能由客户端自行指定。

作者资料的显示规则也需要确定:查询当前用户资料时,旧评论会显示更新后的姓名和头像。如果业务要求保留评论发布时的署名,应在评论中额外保存署名快照。

姓名、照片和 claims 应保存哪些?

只保存业务需要的数据,并明确每个字段由谁维护:

  • 姓名可以在首次创建用户时从 claims 初始化。如果用户能在应用内修改昵称,后续登录就不应无条件覆盖。
  • 主题属于应用偏好,保存在本地数据库即可,无须写入身份提供方的 claims。
  • 邮箱可能缺失或变化,不能因为它出现在 token 中就直接公开给评论读者。
  • 头像通常需要另行获取,不能假定登录 token 一定包含照片或可公开访问的图片地址。

Microsoft Graph 提供了获取照片的接口,例如:

GET https://graph.microsoft.com/v1.0/me/photo/$value

调用该接口需要面向 Microsoft Graph 的 access token 和相应权限,发给自有 API 的 token 不能直接用于调用 Graph。具体权限与支持情况取决于账户类型和调用方式。Microsoft Graph 照片接口

评论页适合读取应用自己的头像地址或缓存,避免每展示一条评论就调用 Graph。用户没有照片、未授权读取或外部服务暂时不可用时,显示默认头像即可。

token 有有效期,claims 也可能变化,没有必要把整个 token 或全部 claims 当作用户档案保存。应用资料应有自己的更新规则。公开评论接口只返回允许公开的字段,租户隔离、账户停用和业务权限仍由后端执行。

备注:内容仅供参考。