修改外部登录用户组后Umbraco后台加载异常问题排查
问题:Umbraco集成AAD外部登录后后台加载异常,刷新恢复
问题场景
我已将Azure Active Directory(AAD)作为Umbraco后台的外部登录提供商,能根据用户在AAD中的角色自动分配Umbraco用户组,并且在用户自动关联账号及每次登录时同步角色(覆盖角色被撤销的场景)。多数情况下逻辑正常,但用户登录后Umbraco后台无法完全加载,刷新页面后恢复正常(此时用户组已按预期更新)。
错误信息
控制台抛出如下错误:
Possibly unhandled rejection: The user object is invalid, the remainingAuthSeconds is required.
异常界面

相关代码片段(角色同步逻辑)
OnExternalLogin = (user, loginInfo) => { // You can customize the user before it's saved whenever they have // logged in with the external provider. // i.e. Sync the user's name based on the Claims returned // in the externalLogin info var roles = loginInfo.Principal.FindAll(ClaimTypes.Role); IList<IReadOnlyUserGroup> groups = new List<IReadOnlyUserGroup>(); // remove all groups and add them to ensure they don't have any groups which have since been removed in Azure user.SetGroups(groups as IReadOnlyCollection<IReadOnlyUserGroup>); var member = _userService.GetByUsername(user.UserName); member.ClearGroups(); if (roles is not null && roles.Any()) { foreach (var role in roles) { var group = _userService.GetUserGroupByAlias(role.Value) as IReadOnlyUserGroup; member.AddGroup(group); } _userService.Save(member); } return true; //returns a boolean indicating if sign in should continue or not. }
问题核心
Umbraco似乎不允许在OnExternalLogin阶段直接修改用户对象,需明确问题原因及可行解决办法。
完整代码
UmbracoBuilder扩展类
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Example.Api.Features.Configuration; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Extensions; namespace Example.Api.Features.Authentication.Extensions; public static class UmbracoBuilderExtensions { public static IUmbracoBuilder AddOpenIdConnectAuthentication(this IUmbracoBuilder builder) { // Register OpenIdConnectBackOfficeExternalLoginProviderOptions here rather than require it in startup builder.Services.ConfigureOptions<OpenIdConnectBackOfficeExternalLoginProviderOptions>(); builder.AddBackOfficeExternalLogins(logins => { logins.AddBackOfficeLogin( backOfficeAuthenticationBuilder => { backOfficeAuthenticationBuilder.AddOpenIdConnect( // The scheme must be set with this method to work for the back office backOfficeAuthenticationBuilder.SchemeForBackOffice(OpenIdConnectBackOfficeExternalLoginProviderOptions.SchemeName), options => { options.CallbackPath = "/umbraco-signin-microsoft/"; // use cookies options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; // pass configured options along options.Authority = "https://login.microsoftonline.com/{tenantId}/v2.0"; options.ClientId = "{clientId}"; options.ClientSecret = "{clientSecret}"; // Use the authorization code flow options.ResponseType = OpenIdConnectResponseType.Code; options.AuthenticationMethod = OpenIdConnectRedirectBehavior.RedirectGet; // map claims options.TokenValidationParameters.NameClaimType = "name"; options.TokenValidationParameters.RoleClaimType = "role"; options.RequireHttpsMetadata = true; options.GetClaimsFromUserInfoEndpoint = true; options.SaveTokens = true; options.UsePkce = true; options.Scope.Add("email"); }); }); }); return builder; } }
OpenIdConnect配置类
using System.Security.Claims; using Microsoft.Extensions.Options; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.BackOffice.Security; namespace Example.Api.Features.Configuration; public class OpenIdConnectBackOfficeExternalLoginProviderOptions : IConfigureNamedOptions<BackOfficeExternalLoginProviderOptions> { public const string SchemeName = "OpenIdConnect"; private readonly IUserService _userService; public OpenIdConnectBackOfficeExternalLoginProviderOptions(IUserService userService) { _userService = userService; } public void Configure(string name, BackOfficeExternalLoginProviderOptions options) { if (name != "Umbraco." + SchemeName) { return; } Configure(options); } public void Configure(BackOfficeExternalLoginProviderOptions options) { options.AutoLinkOptions = new ExternalSignInAutoLinkOptions( // must be true for auto-linking to be enabled autoLinkExternalAccount: true, // assign in the OnAutoLinking callback // (default is editor) defaultUserGroups: new[] { Constants.Security.EditorGroupAlias }, // Optionally you can disable the ability to link/unlink // manually from within the back office. Set this to false // if you don't want the user to unlink from this external // provider. allowManualLinking: false ) { // Optional callback OnAutoLinking = (autoLinkUser, loginInfo) => { // You can customize the user before it's linked. // i.e. Modify the user's groups based on the Claims returned // in the externalLogin info var roles = loginInfo.Principal.FindAll(ClaimTypes.Role); IList<IReadOnlyUserGroup> groups = new List<IReadOnlyUserGroup>(); if (roles is not null && roles.Any()) { foreach (var role in roles) { groups.Add(_userService.GetUserGroupByAlias(role.Value) as IReadOnlyUserGroup); } autoLinkUser.SetGroups(groups as IReadOnlyCollection<IReadOnlyUserGroup>); } autoLinkUser.IsApproved = true; }, OnExternalLogin = (user, loginInfo) => { // You can customize the user before it's saved whenever they have // logged in with the external provider. // i.e. Sync the user's name based on the Claims returned // in the externalLogin info var roles = loginInfo.Principal.FindAll(ClaimTypes.Role); IList<IReadOnlyUserGroup> groups = new List<IReadOnlyUserGroup>(); // remove all groups and add them to ensure they don't have any groups which have since been removed in Azure user.SetGroups(groups as IReadOnlyCollection<IReadOnlyUserGroup>); var member = _userService.GetByUsername(user.UserName); member.ClearGroups(); if (roles is not null && roles.Any()) { foreach (var role in roles) { var group = _userService.GetUserGroupByAlias(role.Value) as IReadOnlyUserGroup; member.AddGroup(group); } _userService.Save(member); } return true; //returns a boolean indicating if sign in should continue or not. } }; // Optionally you can disable the ability for users // to login with a username/password. If this is set // to true, it will disable username/password login // even if there are other external login providers installed. options.DenyLocalLogin = false; // Optionally choose to automatically redirect to the // external login provider so the user doesn't have // to click the login button. This is options.AutoRedirectLoginToExternalProvider = false; } }
问题原因分析
- 认证流程中用户对象状态异常:
OnExternalLogin回调中的user实例是Umbraco用于生成认证票据的临时对象,直接调用user.SetGroups()修改后,Umbraco后续生成认证Cookie时,该对象的remainingAuthSeconds属性未正确初始化,导致前端校验失败。 - 重复修改用户实例:同时操作回调参数的
user对象和数据库中获取的member实例,导致内存对象与数据库数据不一致,进一步引发认证票据生成异常。
解决办法
方案1:仅修改数据库用户实例并重新生成认证票据
修改OnExternalLogin逻辑,移除对user.SetGroups()的调用,只操作从_userService获取的实例,再通过IAuthenticationService重新生成认证票据,确保Cookie信息与数据库同步:
OnExternalLogin = async (user, loginInfo) => { var roles = loginInfo.Principal.FindAll(ClaimTypes.Role); var member = _userService.GetByUsername(user.UserName); if (member == null) return true; member.ClearGroups(); if (roles is not null && roles.Any()) { foreach (var role in roles) { var group = _userService.GetUserGroupByAlias(role.Value) as IReadOnlyUserGroup; if (group != null) { member.AddGroup(group); } } _userService.Save(member); } // 重新生成认证票据,确保Cookie中的用户信息更新 var authenticationService = loginInfo.HttpContext.RequestServices.GetRequiredService<IAuthenticationService>(); await authenticationService.SignInAsync(loginInfo.HttpContext, member.Id.ToString(), member.Username, new AuthenticationProperties()); return true; }
方案2:通过Umbraco事件同步角色(推荐)
放弃在OnExternalLogin中修改用户,改为监听UserSaved事件处理角色同步,避免在认证流程中直接操作用户对象:
- 实现事件处理器:
public class UserGroupSyncEventHandler : INotificationHandler<UserSavedNotification> { private readonly IUserService _userService; private readonly IHttpContextAccessor _httpContextAccessor; public UserGroupSyncEventHandler(IUserService userService, IHttpContextAccessor httpContextAccessor) { _userService = userService; _httpContextAccessor = httpContextAccessor; } public void Handle(UserSavedNotification notification) { var httpContext = _httpContextAccessor.HttpContext; if (httpContext == null || !httpContext.User.Identity.IsAuthenticated) return; // 仅处理AAD外部登录后的用户更新 if (!httpContext.User.HasClaim(c => c.Type == ClaimTypes.AuthenticationMethod && c.Value == OpenIdConnectBackOfficeExternalLoginProviderOptions.SchemeName)) { return; } var roles = httpContext.User.FindAll(ClaimTypes.Role); var user = notification.SavedEntity; user.ClearGroups(); if (roles != null && roles.Any()) { foreach (var role in roles) { var group = _userService.GetUserGroupByAlias(role.Value); if (group != null) { user.AddGroup(group); } } _userService.Save(user); } } }
- 注册事件处理器:
builder.AddNotificationHandler<UserSavedNotification, UserGroupSyncEventHandler>();
方案3:移除user.SetGroups()调用
直接删除OnExternalLogin中的user.SetGroups(groups as IReadOnlyCollection<IReadOnlyUserGroup>);代码行,仅操作member实例。Umbraco在认证后会从数据库加载最新用户信息,刷新页面即可获取正确用户组,同时消除前端错误。
内容的提问来源于stack exchange,提问作者0Neji
相关产品推荐
相关产品推荐

