You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

ASP.NET Core Identity集成Microsoft Graph:用户登录时令牌丢失问题

问题描述

我们的ASP.NET Core Web应用包含两种认证流程:

  • 基于ASP.NET Core Identity的标准用户认证
  • 基于Microsoft 365的外部认证

目前正在开发新功能,允许通过M365外部认证登录的用户借助Microsoft Graph发送邮件。采用委托权限,并将Microsoft Graph令牌存储在SQL数据库(分布式缓存)中,避免应用重启或崩溃时丢失令牌。

外部登录流程及初始邮件发送均正常,但一段时间后,即使用户仍处于登录状态,数据库中的Graph令牌会被移除,导致发送邮件时出现令牌缺失相关错误。预期后台会自动刷新令牌,但该逻辑未按预期生效。

以下是当前实现的相关代码:

appsettings.json 配置

"AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "Domain": "[Enter the domain of your tenant, e.g. contoso.onmicrosoft.com]",
    "CallbackPath": "/signin-oidc",
    "SignedOutCallbackPath": "/signout-callback-oidc",
    "IsEnabled": true
},
"MicrosoftGraph": {
    "BaseUrl": "https://graph.microsoft.com/v1.0",
    "Scopes": "mail.send"
},

Startup.cs 核心代码

services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
    .AddRoles<IdentityRole>()
    .AddEntityFrameworkStores<ApplicationDbContext>()
    .AddErrorDescriber<CustomIdentityErrorDescriber>();

services.Configure<IdentityOptions>(options =>
{
    // Password settings
    options.Password.RequireDigit = true;
    options.Password.RequiredLength = 8;
    options.Password.RequireNonAlphanumeric = true;
    options.Password.RequireUppercase = true;
    options.Password.RequireLowercase = true;

    // Lockout settings
    options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromHours(8);
    options.Lockout.MaxFailedAccessAttempts = 10;
    options.Lockout.AllowedForNewUsers = true;

    // User settings
    options.User.RequireUniqueEmail = false;
});

services.ConfigureApplicationCookie(config =>
{
    config.LoginPath = "/Home/Login";
});

services.AddDistributedSqlServerCache(options =>
{
    options.ConnectionString = connectionString;
    options.SchemaName = "dbo";
    options.TableName = "TbDistributedTokenCache";
});

services.AddSession(options =>
{
    options.IdleTimeout = TimeSpan.FromMinutes(20);
    options.Cookie.HttpOnly = false;
    options.Cookie.IsEssential = false;
});

try
{
    var conf = Configuration.GetSection("AzureAd");

    if (conf != null && Boolean.Parse(conf["IsEnabled"]))
    {
        var credentials = GetAzureAuthCredentials();

        if (credentials != null)
        {
            var initialScopes = Configuration["MicrosoftGraph:Scopes"]?.Split(' ');
            services.AddAuthentication()
                    .AddMicrosoftIdentityWebApp(opt =>
                    {
                        opt.ClaimActions.MapAll(); 
                        opt.GetClaimsFromUserInfoEndpoint = true;
                        opt.ClientId = credentials.ClientId;
                        opt.TenantId = credentials.TenantId;
                        opt.ClientSecret = credentials.ClientSecret;
                        opt.CallbackPath = conf["CallbackPath"];
                        opt.SignedOutCallbackPath = conf["SignedOutCallBackPath"];
                        opt.Domain = conf["Domain"];
                        opt.Instance = conf["Instance"];

                    }, cookieScheme: null)
            .EnableTokenAcquisitionToCallDownstreamApi(initialScopes)
            .AddMicrosoftGraph(Configuration.GetSection("MicrosoftGraph"))
            .AddDistributedTokenCaches();
            services.AddScoped<IO365Manager, DelegatedO365Manager>();

        }
    }

}
catch (Exception) { }

DelegatedO365Manager 类

public class DelegatedOffice365Manager : IOffice365Manager
{
    private readonly GraphServiceClient _graphClient;

    private Recipient[] ParseRecipients(string recipientString)
    {
        if (string.IsNullOrWhiteSpace(recipientString))
        {
            return Array.Empty<Recipient>();
        }

        return recipientString
            .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
            .Select(email => new Recipient
            {
                EmailAddress = new EmailAddress
                {
                    Address = email.Trim()
                }
            })
            .ToArray();
    }

    public DelegatedOffice365Manager(GraphServiceClient graphClient)
    {
        _graphClient = graphClient;
    }

    public async Task<(bool Success, string ErrorMessage)> SendEmailAsync(Office365EmailMessage email)
    {
        try
        {
            var message = new Message
            {
                From = new Recipient
                {
                    EmailAddress = new EmailAddress
                    {
                        Address = email.FromAddress
                    }
                },
                Subject = email.Subject,
                Body = new ItemBody
                {
                    ContentType = BodyType.Html,
                    Content = email.BodyContent
                },
                ToRecipients = ParseRecipients(email.ToRecipients),
                CcRecipients = ParseRecipients(email.CcRecipients),
                BccRecipients = ParseRecipients(email.BccRecipients),
            };

            await _graphClient.Me
                .SendMail(message, email.SaveToSentItems)
                .Request()
                .WithAuthenticationScheme(OpenIdConnectDefaults.AuthenticationScheme)
                .PostAsync();

            return (true, string.Empty);
        }
        catch (Exception ex)
        {
            return (false, ex.Message);
        }
    }
}

控制器操作方法

[AuthorizeForScopes(Scopes = new string[] { "mail.send" }, AuthenticationScheme = OpenIdConnectDefaults.AuthenticationScheme)]
public async Task<JsonResult> SendMailWithOffice365(O365EmailMessage email)
{
    if (_o365Manager == null)
    {
        return new JsonResult(new { statusCode = -1, message = "Graph client not initialized" });
    }

    if (User.GetMsalAccountId() == null)
    {
        return new JsonResult(new { statusCode = -2, message = "User not authenticated with Office 365" });
    }

    var result = await _o365Manager.SendEmailAsync(email);

    if (result.Success)
    {
        return new JsonResult(new { statusCode = 0, message = "OK" });
    }
    else
    {
        return new JsonResult(new { statusCode = -3, message = result.ErrorMessage });
    }
}

排查与解决建议

1. 补充offline_access权限

要实现令牌自动刷新,必须请求offline_access范围(获取刷新令牌的必要条件)。修改配置和初始化代码:

  • 更新appsettings.json:
    "MicrosoftGraph": {
        "BaseUrl": "https://graph.microsoft.com/v1.0",
        "Scopes": "mail.send offline_access"
    }
    
  • 确保Startup.cs中初始化范围时包含该权限(现有代码已通过Split(' ')处理,无需额外修改)。

2. 配置分布式缓存的过期策略

当前AddDistributedSqlServerCache未显式设置过期时间,默认缓存过期可能早于令牌有效期。添加滑动过期配置,避免活跃用户的令牌被过早清理:

services.AddDistributedSqlServerCache(options =>
{
    options.ConnectionString = connectionString;
    options.SchemaName = "dbo";
    options.TableName = "TbDistributedTokenCache";
    // 设置7天滑动过期,用户活跃时自动延长缓存有效期
    options.SlidingExpiration = TimeSpan.FromDays(7);
});

3. 替换为SQL专用令牌缓存配置

使用AddSqlServerTokenCache替代通用的AddDistributedTokenCaches,更适配MSAL的令牌缓存需求:

.AddMicrosoftIdentityWebApp(opt =>
{
    // 现有配置...
})
.EnableTokenAcquisitionToCallDownstreamApi(initialScopes)
.AddMicrosoftGraph(Configuration.GetSection("MicrosoftGraph"))
// 替换为SQL专用令牌缓存
.AddSqlServerTokenCache(options =>
{
    options.ConnectionString = connectionString;
    options.SchemaName = "dbo";
    options.TableName = "TbDistributedTokenCache";
});

4. 优化Graph请求的令牌获取逻辑

移除WithAuthenticationScheme的显式指定,让GraphServiceClient自动关联当前用户的认证上下文:

await _graphClient.Me
    .SendMail(message, email.SaveToSentItems)
    .Request()
    // 注释掉该行,依赖AuthorizeForScopes自动处理令牌刷新
    // .WithAuthenticationScheme(OpenIdConnectDefaults.AuthenticationScheme)
    .PostAsync();

5. 验证令牌缓存的生命周期

直接查询SQL数据库的TbDistributedTokenCache表,查看ExpiresAtTime字段的取值,确认令牌记录是否被提前标记为过期。如果缓存被意外清理,调整缓存过期配置或检查分布式缓存的后台清理机制。

6. 启用MSAL日志排查

添加日志记录,跟踪令牌获取、刷新和缓存的全流程:

services.AddLogging(builder =>
{
    builder.AddConsole();
    builder.AddDebug();
    builder.SetMinimumLevel(LogLevel.Trace);
});

// 在AddMicrosoftIdentityWebApp中添加事件日志
.AddMicrosoftIdentityWebApp(opt =>
{
    // 现有配置...
    opt.Events = new OpenIdConnectEvents
    {
        OnTokenValidated = context =>
        {
            context.Logger.LogInformation("Token validated for user {UserId}", context.Principal.Identity.Name);
            return Task.CompletedTask;
        },
        OnAuthenticationFailed = context =>
        {
            context.Logger.LogError(context.Exception, "Authentication failed");
            return Task.CompletedTask;
        }
    };
})

通过日志可定位令牌是否成功刷新、缓存是否正确写入/读取。


内容的提问来源于stack exchange,提问作者creativemind

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.17 06:58:10