Handling new user registration with Auth0 and ASP.NET Core 3

new user registration with Auth0

When creating a new site that will have users logging into it, you’ll invariably have to choose an identity provider to use. You can use the the inbuilt ASP.NET Core Identity but that has it limitation with repect to authenticating to API’s and other SSO benefits.

If you choose a third party identity provider like Auth0 or Azure B2C, then the implementation of the user sign in is made easy by the built in templates and code snippets that these providers give you, but it detaches the new user registration process from your own code.

When you receive the login tokens back from the IDP (Identity Provider), you will need to check your own database to determine if that user has been seen before. If not, then you can trigger your own onboarding process.

Some IDP’s may give you the option to add a claim to the token to let you know that it’s the first time this user has logged in. Azure AD B2C can do this, I’m not sure about Auth0. At first glance, this may seen like a good way to go. You can intecept the callback within the OpenIDConnectEvents but I wasn’t able to find a way to properly redirect to a my new user onboarding action without the middleware failing to log the user in correctly.

The other drawback to using this method is that if your onboarding process has an error or the user closes the site prior to completeing it, you won’t be given that new user token by the IDP on the second login.

The login callback

The way I’m handling this is to check my app’s UserService after each login to see if I have a local user record for this person. If not, I set an isNewUser claim to the user. I’m doing this in the OpenIDConnectEvents OnTokenValidated event

options.Events = new OpenIdConnectEvents
                {
                    // handle the logout redirection
                    OnRedirectToIdentityProviderForSignOut = (context) =>
                    {
                        var logoutUri = $"https://{Configuration["Auth0:Domain"]}/v2/logout?client_id={Configuration["Auth0:ClientId"]}";

                        var postLogoutUri = context.Properties.RedirectUri;
                        if (!string.IsNullOrEmpty(postLogoutUri))
                        {
                            if (postLogoutUri.StartsWith("/"))
                            {
                                // transform to absolute
                                var request = context.Request;
                                postLogoutUri = request.Scheme + "://" + request.Host + request.PathBase + postLogoutUri;
                            }
                            logoutUri += $"&returnTo={ Uri.EscapeDataString(postLogoutUri)}";
                        }

                        context.Response.Redirect(logoutUri);
                        context.HandleResponse();

                        return Task.CompletedTask;
                    },
                    OnTokenValidated = (context) =>
                    {
                        IServiceProvider serviceProvider = context.HttpContext.RequestServices;
                        var userService = serviceProvider.GetService<IUserService>();

                        var identity = (ClaimsIdentity)context.Principal.Identity;
                        var userId = UserHelper.GetUserIdFromClaims(context.Principal);
                        var user = userService.GetUserAsync(userId).Result;
                        if (user == null)
                        {                            
                            identity.AddClaim(new Claim("isNewUser", "true"));
                        }
                                                
                        return Task.CompletedTask;
                    }
                };

Once I have this claim set on the user, I can configure a Global filter that checks for this claim and forwards the user to my onboarding action. This ensures that if the user starts moving around my site, they’re continually taken back to the required onboarding area to complete the process. The action filter is as below

The Action Filter

public class NewUserActionFilter : ActionFilterAttribute
    {
        public override void OnActionExecuting(ActionExecutingContext context)
        {
            if (context.HttpContext.User.Identity.IsAuthenticated && context.RouteData.Values["controller"].ToString() != "Account" )
            {
                var newUserClaim = context.HttpContext.User.Claims.FirstOrDefault(c => c.Type == "isNewUser");
                if (newUserClaim != null && newUserClaim.Value == "true")
                {
                    //Redirect to new user setup page
                    context.Result = new RedirectToActionResult("newuser", "account", null);
                }
            }
        }
    }

It’s important to check that you have an Authenticated user as this filter will be triggered globally, including on actions with the [AllowAnonymous] and ensure that you exlude the controller that contains the action you want to get to or you’ll end up in a redirect loop. You should probably also be specifically checking actions on the Account controller that are not “newuser” and are not set to [AllowAnonymous] to make this completely bulletproof.

Once you have your filter, you need to register it in the Startup.cs in the ConfigureServices function within the AddControllersWithViews call as below

Registering the filter

services.AddControllersWithViews(config =>
            {
                config.Filters.Add(new NewUserActionFilter());
            })
            .AddJsonOptions(options => options.JsonSerializerOptions.PropertyNamingPolicy = null);

The last piece of this puzzle is to handle the onboarding in your newuser action and clearing the IsNewUser claim from the users cookie once they’ve completed the onboarding process, otherwise they’ll continue to be redirected back to this page.

The onboarding controller action

public IActionResult NewUser()
        {
            //Verify that it's a new user
            var identity = (ClaimsIdentity)User.Identity;
            var newUserClaim = identity.FindFirst("isNewUser");
            if (newUserClaim != null)
            {

                NewUserViewModel model = new NewUserViewModel();

                model.EmailAddress = User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value;
                model.DisplayName = User.Claims.FirstOrDefault(c => c.Type == "name")?.Value;

                ViewBag.TimeZones = ListHelper.GetTimeZonesAsSelectList();

                return View(model);
            }
            else
            {
                return RedirectToAction("index", "home");
            }
        }

        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> NewUser(NewUserViewModel model)
        {
            if (ModelState.IsValid)
            {
                var user = new UTSUser();

                user.UserId = UserHelper.GetUserIdFromClaims(User);
                user.AccountCreated = DateTime.UtcNow;
                user.DisplayName = model.DisplayName;
                user.EmailAddress = model.EmailAddress;
                user.TimeZone = model.TimeZone;

                try
                {
                    await _userService.AddUserAsync(user);

                    var result = await HttpContext.AuthenticateAsync();
                    var identity = (ClaimsIdentity)User.Identity;
                    var claimToRemove = identity.FindFirst("isNewUser");
                    if (claimToRemove != null)
                    {
                        identity.RemoveClaim(claimToRemove);
                    }

                    await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, User, result.Properties);

                    await _emailService.SendEmail(_notificationEmailAddress, _notificationEmailAddress, "New user registration", EmailHelper.GetNewUserEmailHtmlBody(user));

                    return RedirectToAction("onboarding", "home");

                }
                catch (Exception)
                {
                    throw;
                }

            }

            ViewBag.TimeZones = ListHelper.GetTimeZonesAsSelectList();
            return View(model);
        }

As you can see from the code above, I’m requiring the user to fill out an email address and display name (which I’m getting from their identity claims if possible), and their local timezone.

The key to this process is removing the isNewUser claim and then calling await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, User, result.Properties); again to relog the user is without the isNewUser claim.

This is the way I’ve found to manage this process that works best for my app. I you have alternate methods, please let me know in the comments.

If you got benefit from this article, feel free to:

ABOUT THE AUTHOR

I’m the Technical Director at Expeed Technology in Adelaide, South Australia. In my day job I work on both Windows and Linux web hosting technologies and Windows and Web .NET development. In my spare time I tinker with video production, photograpy, and all things Azure, including IAAS, PAAS and Serverless. You can find me on Twitter over at @simonholman 

Share

Share on facebook
Facebook
Share on twitter
Twitter
Share on linkedin
LinkedIn

Related

Comment

Leave a Comment

Your email address will not be published. Required fields are marked *

Do NOT follow this link or you will be banned from the site! Scroll to Top