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

