Copied to clipboard

Flag this post as spam?

This post will be reported to the moderators as potential spam to be looked at


  • Keith Lawrence 8 posts 82 karma points
    Oct 06, 2023 @ 15:59
    Keith Lawrence
    2

    External Login without creating local Members

    I have a v8 instance that I'm upgrading to v12 and am struggling to match the OpenID Connect authentication setup which I had working in v8.

    I am referencing https://docs.umbraco.com/umbraco-cms/reference/security/external-login-providers/ and have implemented the excellent open-source OIDC starter found at https://github.com/jbreuer/Umbraco-OpenIdConnect-Example.

    A lot more happens 'out of the box' in .NET Core when authentication with OIDC which is great but part of the custom Umbraco process is to implicitly create a local Member record in the Umbraco Member store. I have no need for this and tying my IdP to Umbraco makes no sense. Is there a way to authenticate users via OpenID Connect without 'auto-linking' or having to create a local Member record? I purely want users to be authenticated for the duration of their session. I only have a single trusted IAM/IdP provider that will handle all authentication.

  • Thomas 7 posts 87 karma points
    Jan 31, 2024 @ 14:44
    Thomas
    0

    Did you manage to solve this? I'm having the same problem.

  • Keith Lawrence 8 posts 82 karma points
    Jan 31, 2024 @ 14:58
    Keith Lawrence
    1

    Yes.. it was complex but actually wiring up Umbraco and switching out the membership manager and stripping out the auto-link stuff is well supported. I had a lot more issues trying to integrate with a BFF framework after the fact.

    Here's some sample code that may point you in the right direction.

    using ...
    namespace Umbraco12.Core.Auth.Member;
    public class CustomMemberManager : MemberManager
    {
        private IHttpContextAccessor _httpContextAccessor;
    
        public CustomMemberManager(
            IIpResolver ipResolver,
            IMemberUserStore store,
            IOptions<IdentityOptions> optionsAccessor,
            IPasswordHasher<MemberIdentityUser> passwordHasher,
            IEnumerable<IUserValidator<MemberIdentityUser>> userValidators,
            IEnumerable<IPasswordValidator<MemberIdentityUser>> passwordValidators,
            IdentityErrorDescriber errors,
            IServiceProvider services,
            ILogger<UserManager<MemberIdentityUser>> logger,
            IOptionsSnapshot<MemberPasswordConfigurationSettings> passwordConfiguration,
            IPublicAccessService publicAccessService,
            IHttpContextAccessor httpContextAccessor) : base(
            ipResolver,
            store,
            optionsAccessor,
            passwordHasher,
            userValidators,
            passwordValidators,
            errors,
            services,
            logger,
            passwordConfiguration,
            publicAccessService,
            httpContextAccessor)
        {
            _httpContextAccessor = httpContextAccessor;
        }
    
        public override async Task<MemberIdentityUser?> GetUserAsync(ClaimsPrincipal principal)
        {
            var id = GetUserId(principal);
    
            // In the default implementation, the member is fetched from the database.
            // Revert to this behaviour if you have any in-built accounts
            if (principal?.Identity?.Name == "some-concrete-user")
            {
                return await base.GetUserAsync(principal);
            }
    
            // Since our member is from an external login provider we just build a virtual member.
            var member = await CreateVirtualMember(id, principal.Claims);
            return member;
        }
    
        public override Task<IList<string>> GetRolesAsync(MemberIdentityUser user)
        {
            var roles = user.Claims
                .Where(x => x.ClaimType is ClaimTypes.Role or "role")
                .Select(x => x.ClaimValue)
                .ToList();
    
            return Task.FromResult((IList<string>)roles);
        }
    
        public async Task<MemberIdentityUser> CreateVirtualMember(string? id, IEnumerable<Claim> claims)
        {
            id ??= claims.FirstOrDefault(x => x.Type == "sub")?.Value;
    
            var email = claims.FirstOrDefault(x => x.Type == ClaimTypes.Email)?.Value
                        ?? claims.FirstOrDefault(x => x.Type == "email")?.Value;
    
            var member = new MemberIdentityUser
            {
                Id = id,
                Name = email,
                UserName = email,
                IsApproved = true,
                Email = email
            };
    
            foreach (var claim in claims)
            {
                member.Claims.Add(new IdentityUserClaim<string>
                {
                    ClaimType = claim.Type,
                    ClaimValue = claim.Value
                });
            }
    
            var idToken = claims.FirstOrDefault(x => x.Type == "id_token")?.Value;
            idToken ??= await _httpContextAccessor.HttpContext?.GetTokenAsync("id_token");
            if (!string.IsNullOrEmpty(idToken))
            {
                var loginIdToken = new IdentityUserToken(
                    loginProvider: ClaimConstants.ClientConfig.UmbracoMembersOpenIdConnectScheme,
                    name: "id_token",
                    value: idToken,
                    userId: null);
                member.LoginTokens.Add(loginIdToken);
            }
    
            return member;
        }
    }
    
    
    using ...    
    namespace Umbraco12.Core.Auth.Member;
    
    /// <summary>
    ///     The sign in manager for members
    /// </summary>
    public class CustomMemberSignInManager : MemberSignInManager
    {
        private readonly IMemberManager _memberManager;
    
        public CustomMemberSignInManager(
            UserManager<MemberIdentityUser> memberManager,
            IHttpContextAccessor contextAccessor,
            IUserClaimsPrincipalFactory<MemberIdentityUser> claimsFactory,
            IOptions<IdentityOptions> optionsAccessor,
            ILogger<SignInManager<MemberIdentityUser>> logger,
            IAuthenticationSchemeProvider schemes,
            IUserConfirmation<MemberIdentityUser> confirmation,
            IMemberExternalLoginProviders memberExternalLoginProviders,
            IEventAggregator eventAggregator
            )
            : base(memberManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation)
        {
            _memberManager = (IMemberManager)memberManager;
        }
    
        [Obsolete("Use ctor with all params")]
        public CustomMemberSignInManager(
            UserManager<MemberIdentityUser> memberManager,
            IHttpContextAccessor contextAccessor,
            IUserClaimsPrincipalFactory<MemberIdentityUser> claimsFactory,
            IOptions<IdentityOptions> optionsAccessor,
            ILogger<SignInManager<MemberIdentityUser>> logger,
            IAuthenticationSchemeProvider schemes,
            IUserConfirmation<MemberIdentityUser> confirmation
            )
            : this(
                memberManager,
                contextAccessor,
                claimsFactory,
                optionsAccessor,
                logger,
                schemes,
                confirmation,
                StaticServiceProvider.Instance.GetRequiredService<IMemberExternalLoginProviders>(),
                StaticServiceProvider.Instance.GetRequiredService<IEventAggregator>()
                )
        {
        }
    
        public override async Task<SignInResult> ExternalLoginSignInAsync(
            ExternalLoginInfo loginInfo,
            bool isPersistent,
            bool bypassTwoFactor = false)
        {
            // In the default implementation, the member is fetched from the database.
            // The default implementation also tries to create the member with the auto link feature if it doesn't exist.
            // The auto link feature has been removed here because the member is from an external login provider.
    
            // NB // the loginInfo.AuthenticationProperties contains the items used for processing the OIDC response
            // need to copy over only those properties which are required for maintaining the session
    
            var props = new AuthenticationProperties
            {
                Items = ... // this bit will change depending on whether you trust your external IDP or not
            };
    
            props.IsPersistent = true; // set to true to avoid a sticky, non-expiring session cookie and explicitly expire/refresh instead
            props.AllowRefresh = true; // sliding cookie expiration time
            props.ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(20);
    
            await Context.SignOutAsync(IdentityConstants.ExternalScheme);
            await Context.SignInAsync(IdentityConstants.ApplicationScheme, loginInfo.Principal, props); // you might not want to re-use your loginInfo.Principal here, better to create your own curated version if you're using Google, Facebook, whatever
    
            return SignInResult.Success;
        }
    }
    
  • iNETZO 133 posts 496 karma points c-trib
    Feb 01, 2024 @ 20:36
    iNETZO
    0

    Jeroen Breumer (who also made the greate OpenIdConnect example) also wrote an artical about this: https://www.jeroenbreuer.nl/blog/virtual-members-in-umbraco/

  • Keith Lawrence 8 posts 82 karma points
    Feb 09, 2024 @ 08:39
    Keith Lawrence
    0

    Thanks - i couldn't find the link when I posted but I referenced that blog heavily, very useful info.

  • Chris Spanellis 45 posts 189 karma points
    Feb 15, 2024 @ 03:56
    Chris Spanellis
    0

    First of all, this is a super helpful post. I also started off with Jeroen's article, but I've been stuck on something for a while now... none of my ValidateSecurityStampAsync (or ValidateTwoFactorSecurityStampAsync even though I'm not using 2FA) overrides are ever getting called.

    This causes the auth cookie value to be blanked out from the server (via a SetCookie I'm assuming). I see when it happenes since I set the log level for Microsoft.AspNetCore.Authentication to Verbose:

    AuthenticationScheme: Identity.Application signed out.

    AuthenticationScheme: Identity.External signed out.

    AuthenticationScheme: Identity.TwoFactorUserId signed out.

    AuthenticationScheme: Identity.TwoFactorRememberMe signed out.

    That landed me to this article, and I'm starting to think I need my explicitly set my own ISecurityStampValidator?

    Any guidance would be greatly appreciated.

    Thanks in advance!

  • Chris Spanellis 45 posts 189 karma points
    Feb 15, 2024 @ 04:33
    Chris Spanellis
    0

    I know this is a hack, b/c the validation overrides should get called, but I was able to increase the validation interval via configuring the MemberSecurityStampValidatorOptions just to get it going (this article inspired that). I validated this by setting it to one minute, which caused my cookie to get blanked out much faster than the default 30 minutes while debugging.

    builder.Services.Configure<MemberSecurityStampValidatorOptions>(options => {
        options.ValidationInterval = TimeSpan.FromDays(30);
        options.OnRefreshingPrincipal = context =>
        {
            return Task.CompletedTask;
        };
    });
    
Please Sign in or register to post replies

Write your reply to:

Draft