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

修改外部登录用户组后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;
    }
}

问题原因分析

  1. 认证流程中用户对象状态异常:OnExternalLogin回调中的user实例是Umbraco用于生成认证票据的临时对象,直接调用user.SetGroups()修改后,Umbraco后续生成认证Cookie时,该对象的remainingAuthSeconds属性未正确初始化,导致前端校验失败。
  2. 重复修改用户实例:同时操作回调参数的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事件处理角色同步,避免在认证流程中直接操作用户对象:

  1. 实现事件处理器:
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);
        }
    }
}
  1. 注册事件处理器:
builder.AddNotificationHandler<UserSavedNotification, UserGroupSyncEventHandler>();

方案3:移除user.SetGroups()调用

直接删除OnExternalLogin中的user.SetGroups(groups as IReadOnlyCollection<IReadOnlyUserGroup>);代码行,仅操作member实例。Umbraco在认证后会从数据库加载最新用户信息,刷新页面即可获取正确用户组,同时消除前端错误。


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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.31 02:31:32