DEV Community

Dennis
Dennis

Posted on • Updated on

How to skip login screens during development for Umbraco 13 Users and Members

When you do development in Umbraco, it's kind of annoying to have to log in on your local development environment. You might be developing with a local Sqlite database and you're really not dealing with any confidential data, or you're developing a plugin and you just have a small website running locally to test on. In those cases it's convenient if you can just skip the login. In this small tutorial, I will show you how to create a "developer login" for backoffice users and for members, so you can log in with just the press of a button.

⚠️ Warning
Be extra careful when following this tutorial. This code is meant to be used for local development only! Make sure you don't accidentally take this code into production.

How does it work?

The result of this tutorial is a button in the backoffice that says "Log in with Developer login". When you press the button, you automatically get logged in as one specific user, specified in code or in your user secrets.

We make this work by pretending as if our developer login is an external login provider. This external login provider loops back to a local authentication handler, which selects a user using a config and logs you in as if you're logged in with an external provider. Umbraco's internal logic will then automatically log you in as that specific user.

To do this, we need the following components:

  • A remote authentication options model
  • An authentication handler
  • Login provider options for Umbraco
  • Extension method to register the necessary components in the dependency injection container

Developer login for the backoffice

We'll start by defining a model, like so:

AutoLoginOptions.cs

internal sealed class AutoLoginOptions
    : RemoteAuthenticationOptions
{
    public const string AuthenticationScheme = "AutoLogin";

    // 👇 This will be the email adres of the user that we want to log in as
    public string? UserEmail { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Then, we add an authentication handler to handle this type of authentication. Here is where the actual magic happens:

BackofficeAutologinAuthenticationHandler.cs

internal sealed class BackofficeAutologinAuthenticationHandler(
    IOptionsMonitor<AutoLoginOptions> options,
    ILoggerFactory logger,
    UrlEncoder encoder,
    IHttpContextAccessor httpContextAccessor,
    IBackOfficeUserManager backOfficeUserManager,
    IBackOfficeSignInManager backOfficeSignInManager,
    IWebHostEnvironment webHostEnvironment)
        : RemoteAuthenticationHandler<AutoLoginOptions>(options, logger, encoder)
{
    // 👇 The challenge is to redirect to our authentication handler.
    protected override Task HandleChallengeAsync(AuthenticationProperties properties)
    {
        HttpContext httpContext = httpContextAccessor.GetRequiredHttpContext();
        httpContext.Response.Redirect(Options.CallbackPath);

        return Task.CompletedTask;
    }

    protected override async Task<HandleRequestResult> HandleRemoteAuthenticateAsync()
    {
        const string AuthenticationScheme = "Umbraco." + AutoLoginOptions.AuthenticationScheme;
        HttpContext httpContext = httpContextAccessor.GetRequiredHttpContext();

        // 👇 Using guard clauses, we make sure that this logic is only run in development mode and only for requests from localhost.
        if (!webHostEnvironment.IsDevelopment()) return HandleRequestResult.NoResult();
        if (!httpContext.Request.IsLocal()) return HandleRequestResult.NoResult();

        // 👇 The return URL must ALWAYS be a relative local URL and must always start with /umbraco.
        string originalReturnUrl = httpContext.Request.Query["returnUrl"].FirstOrDefault() ?? "/umbraco";
        if (!originalReturnUrl.StartsWith("/umbraco", StringComparison.OrdinalIgnoreCase)) originalReturnUrl = "/umbraco";
        string returnUrl = originalReturnUrl;

        // 👇 We need to make sure that a user is configured for logging in. Alternatively, you can hardcode a user email or user id here
        if (string.IsNullOrWhiteSpace(Options.UserEmail))
            throw new InvalidOperationException("Unable to log in with auto login, because no user email has been specified in config");

        BackOfficeIdentityUser identityUser = await backOfficeUserManager.FindByEmailAsync(Options.UserEmail)
            ?? throw new InvalidOperationException("The user with the configured email address could not be found");

        // 👇 Everything below here is what is required to let Umbraco know that we've logged in with an external provider.
        AuthenticationProperties properties = backOfficeSignInManager.ConfigureExternalAuthenticationProperties(AuthenticationScheme, returnUrl, Constants.System.RootString);
        System.Security.Claims.ClaimsPrincipal principal = await backOfficeSignInManager.CreateUserPrincipalAsync(identityUser);

        AuthenticationTicket ticket = new(principal, properties, Constants.Security.BackOfficeExternalAuthenticationType);

        return HandleRequestResult.Success(ticket);
    }
}
Enter fullscreen mode Exit fullscreen mode

Next, we create an options provider model so that Umbraco knows that this login method exists:

BackofficeAutologinProviderOptions.cs

public class BackofficeAutologinProviderOptions
    : IConfigureNamedOptions<BackOfficeExternalLoginProviderOptions>
{
    public void Configure(string? name, BackOfficeExternalLoginProviderOptions options)
    {
        if (!string.Equals(name, "Umbraco." + AutoLoginOptions.AuthenticationScheme, StringComparison.Ordinal))
        {
            return;
        }

        Configure(options);
    }

    public void Configure(BackOfficeExternalLoginProviderOptions options)
    {
        // 👇 You may choose to enable auto-redirect. In that case the login menu is skipped entirely
        options.AutoRedirectLoginToExternalProvider = true;
        options.AutoLinkOptions = new ExternalSignInAutoLinkOptions(true);
    }
}
Enter fullscreen mode Exit fullscreen mode

Finally, we need to register our custom authentication method in the dependency injection container. For that, we'll make an extension method and then call that extension method in our application startup:

BackofficeAutologinExtensions.cs

internal static class BackofficeAutologinExtensions
{
    public static IUmbracoBuilder AddAutoLogin(this IUmbracoBuilder builder)
    {
        builder.Services.ConfigureOptions<BackofficeAutologinProviderOptions>();

        // 👇 You want to make sure that this value is configured in your secrets
        string? userEmail = builder.Config.GetValue<string>("Autologin:Backoffice:Email");

        // 👇 If no user email is configured, it's no use setting this up. In that case: do an early return
        // Thanks to Sven Geusens for the suggestion
        if (string.IsNullOrWhiteSpace(userEmail))
            return builder;

        builder.AddBackOfficeExternalLogins(logins =>
        {
            logins.AddBackOfficeLogin(authBuilder =>
            {
                authBuilder.AddRemoteScheme<AutoLoginOptions, BackofficeAutologinAuthenticationHandler>(authBuilder.SchemeForBackOffice(AutoLoginOptions.AuthenticationScheme)!, "developer login", alOptions =>
                {
                    alOptions.CallbackPath = new PathString("/umbraco-auto-login");
                    alOptions.UserEmail = userEmail;
                });
            });
        });

        return builder;
    }
}
Enter fullscreen mode Exit fullscreen mode

Program.cs

builder.CreateUmbracoBuilder()
    .AddBackOffice()
    .AddWebsite()
    .AddDeliveryApi()
    .AddComposers()
#if DEBUG
    .AddAutoLogin()
#endif
    .Build();
Enter fullscreen mode Exit fullscreen mode

The call is wrapped inside #if DEBUG to ensure that autologin is only added to your collection when running debug builds. This is yet another layer to ensure that your autologin doesn't accidentally makes its way into production.

If you've done everything correctly, you should now find an additional button in your Umbraco backoffice login screen like this:

Screenshot of Umbraco's login screen with the auto-login button

Developer login for members

You can do the same trick for a login with members. The principles are the same:

  1. Pretend to be an external login provider
  2. Challenge by redirecting to a custom authentication provider
  3. Create an external login cookie
  4. Let Umbraco handle the magic

So let's go through the components once more:

MembersAutologinAuthenticationHandler.cs

internal sealed class MembersAuthologinAuthenticationHandler(
    IOptionsMonitor<AutoLoginOptions> options,
    ILoggerFactory logger,
    UrlEncoder encoder,
    IHttpContextAccessor httpContextAccessor,
    IWebHostEnvironment webHostEnvironment,
    IMemberManager memberManager,
    IMemberSignInManager memberSignInManager,
    IUmbracoContextFactory umbracoContextFactory)
        // 👇 Notice that we're re-using the same options model. We do this, because the options are the same
        : RemoteAuthenticationHandler<AutoLoginOptions>(options, logger, encoder)
{
    protected override Task HandleChallengeAsync(AuthenticationProperties properties)
    {
        // 👇 The challenge is to redirect to our authentication handler url
        HttpContext httpContext = httpContextAccessor.GetRequiredHttpContext();
        httpContext.Response.Redirect(Options.CallbackPath + "?returnUrl=" + HttpUtility.UrlEncode(properties.RedirectUri));

        return Task.CompletedTask;
    }

    protected override async Task<HandleRequestResult> HandleRemoteAuthenticateAsync()
    {
        const string AuthenticationScheme = Constants.Security.MemberExternalAuthenticationTypePrefix + AutoLoginOptions.AuthenticationScheme;
        HttpContext httpContext = httpContextAccessor.GetRequiredHttpContext();

        // 👇 We ensure that we're running in development mode and the request is from a local source so that we know for certain that this is not used outside of a development environment
        if (!webHostEnvironment.IsDevelopment()) return HandleRequestResult.NoResult();
        if (!httpContext.Request.IsLocal()) return HandleRequestResult.NoResult();
        if (string.IsNullOrWhiteSpace(Options.UserEmail)) return HandleRequestResult.NoResult();

        // 👇 In this case, the return URL depends on your application. Make sure you cannot do open redirects to external domains
        string? originalReturnUrl = httpContext.Request.Query["returnUrl"].FirstOrDefault();
        if (originalReturnUrl is null || !originalReturnUrl.StartsWith("/", StringComparison.OrdinalIgnoreCase)) originalReturnUrl = GetDefaultReturnUrl();
        string returnUrl = originalReturnUrl;

        MemberIdentityUser identityUser = await memberManager.FindByEmailAsync(Options.UserEmail)
            ?? throw new InvalidOperationException("It's not possible to automatically log in because the member with the given email adres doesn't exist.");

        // 👇 When everything checks out, we create a login cookie for external logins and call the authentication a success
        AuthenticationProperties properties = memberSignInManager.ConfigureExternalAuthenticationProperties(AuthenticationScheme, returnUrl, identityUser.Id);
        System.Security.Claims.ClaimsPrincipal principal = await memberSignInManager.CreateUserPrincipalAsync(identityUser);

        AuthenticationTicket ticket = new(principal, properties, IdentityConstants.ExternalScheme);

        return HandleRequestResult.Success(ticket);
    }

    private string GetDefaultReturnUrl()
    {
        // 👇 In my scenario, the root URL is the default, in case no redirect URL is provided
        return "/";
    }
}
Enter fullscreen mode Exit fullscreen mode

MembersAutologinProviderOptions.cs

public class MembersAutologinProviderOptions
    : IConfigureNamedOptions<MemberExternalLoginProviderOptions>
{
    public void Configure(string? name, MemberExternalLoginProviderOptions options)
    {
        // 👇 Using the "UmbracoMembers." prefix, is a signal for Umbraco that this signin method is for members.
        // Umbraco prefixes your external logins during registration
        if (!string.Equals(name, "UmbracoMembers." + AutoLoginOptions.AuthenticationScheme, StringComparison.Ordinal))
        {
            return;
        }

        Configure(options);
    }

    public void Configure(MemberExternalLoginProviderOptions options)
    {
        // 👇 Autolinking is the easiest way to set this up. Now you don't have to explicitly enable developer login for your member (which cannot be done in the backoffice, unlike backoffice users)
        options.AutoLinkOptions = new MemberExternalSignInAutoLinkOptions(true);
    }
}
Enter fullscreen mode Exit fullscreen mode

MembersAutologinExtensions.cs

internal static class MembersAutologinExtensions
{
    public static IUmbracoBuilder AddMemberAutoLogin(this IUmbracoBuilder builder)
    {
        builder.Services.ConfigureOptions<MembersAutologinProviderOptions>();

        string? memberEmail = builder.Config.GetValue<string>("Autologin:Member:Email");

        // 👇 If no user email is configured, it's no use setting this up. In that case: do an early return
        // Thanks to Sven Geusens for the suggestion
        if (string.IsNullOrWhiteSpace(userEmail))
            return builder;

        builder.AddMemberExternalLogins(logins =>
        {
            logins.AddMemberLogin(authBuilder =>
            {
                authBuilder.AddRemoteScheme<AutoLoginOptions, MembersAuthologinAuthenticationHandler>(authBuilder.SchemeForMembers(AutoLoginOptions.AuthenticationScheme)!, "developer login", alOptions =>
                {
                    alOptions.CallbackPath = new PathString("/member-auto-login");
                    alOptions.UserEmail = memberEmail;
                });
            });
        });

        return builder;
    }
}
Enter fullscreen mode Exit fullscreen mode

Program.cs

builder.CreateUmbracoBuilder()
    .AddBackOffice()
    .AddWebsite()
    .AddDeliveryApi()
    .AddComposers()
#if DEBUG
    .AddAutoLogin()
    .AddMemberAutoLogin() // 👈 also added only in debug builds
#endif
    .Build();
Enter fullscreen mode Exit fullscreen mode

Now you have a working auto-login for members. Now this doesn't "just work" in the same way as the backoffice login. If you haven't already, you need to set up external logins in your frontend login form. Fortunately, Umbraco's code snippets already contain the necessary code to get this up and running:

MyLoginForm.cshtml

@{
    var loginProviders = await memberExternalLoginProviders.GetMemberProvidersAsync();
    var externalSignInError = ViewData.GetExternalSignInProviderErrors();

    if (loginProviders.Any())
    {
        if (externalSignInError?.AuthenticationType is null && externalSignInError?.Errors?.Any() == true)
        {
            @Html.DisplayFor(x => externalSignInError.Errors);
        }

        @foreach (var login in loginProviders)
        {
            @using (Html.BeginUmbracoForm<UmbExternalLoginController>(nameof(UmbExternalLoginController.ExternalLogin)))
            {
                <button type="submit" name="provider" value="@login.ExternalLoginProvider.AuthenticationType">
                    Sign in with @login.AuthenticationScheme.DisplayName
                </button>

                if (externalSignInError?.AuthenticationType == login.ExternalLoginProvider.AuthenticationType)
                {
                    @Html.DisplayFor(x => externalSignInError.Errors);
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Now you should be able to log in by just pressing a button on both the backoffice and your frontend! 🎉

Closing thoughts

Figuring this out, turned out to be easier than I expected. The solution turned out quite elegant in my opinion and it nicely integrates into our Umbraco solution, simply by using native dotnet and Umbraco features. Obviously don't enable this feature when running in any mode other than Development and test your authentication solutions well to make sure you don't accidentally open up your backoffice to strangers.

I would love to hear it if you have any suggestions or concerns and if this has been helpful for you. That's all for now, I'll see you in my next blog! 😊

Top comments (5)

Collapse
 
sven_geusens_b99c538b58cf profile image
Sven Geusens • Edited

A quick note for anyone trying this out. This code only works for v10-v13 (maybe v9 too, not sure)
Changes to made to make the user example work in v14
remove using Umbraco.Cms.Web.BackOffice.Security;
add using Umbraco.Cms.Web.Common.Security; using Umbraco.Cms.Api.Management.Security; to the relevant files

remove options.AutoRedirectLoginToExternalProvider = true; from BackofficeAutologinProviderOptions.Configure this has been moved to the client side, see the umbraco-package.json file below

change authBuilder.SchemeForBackOffice to BackOfficeAuthenticationBuilder.SchemeForBackOffice in BackofficeAutologinExtensions

tell the frontend about the login provider. Example file /App_Plugins/auto-login/umbraco-package.json

{
  "$schema": "../../umbraco-package-schema.json",
  "name": "Auto Login",
  "allowPublicAccess": true,
  "extensions": [
    {
      "type": "authProvider",
      "alias": "My.AuthProvider.Auto",
      "name": "Auto Login",
      "forProviderName": "Umbraco.AutoLogin",
      "meta": {
        "label": "Login with developer account",
        "behavior": {
          "autoRedirect": true
        }
      }
    }
  ]
}

Enter fullscreen mode Exit fullscreen mode
Collapse
 
sven_geusens_b99c538b58cf profile image
Sven Geusens • Edited

This is a beautiful use of some of the lesser known extension points that Umbraco provides and wraps. I will probably start using this when sharing reproduction projects with my colleagues so I don't have to implicitly share the test username/password.

Regarding concerns: since this should only ever be used on dev builds, I would wrap all classes in #IF DEBUG conditionals. This way, none of the code is ever present in a release/production build.
You could also consider checking for the setting inside MembersAutologinExtensions before you register the fake provider. No use showing the button if the system behind it isn't configured.

@jpkeisala Regarding wrapping it up as an extension: You would have to remove the #IF DEBUG conditionals which makes the whole setup more risky as when you package up code for nuget is normally done in a release build.
By moving the startup logic into a composer, you could move all the different classes, including the composer into 1 file making it very easy to include into another project.

Regarding the skip setup. This is not feasible as Umbraco always needs a user to perform operations for the backoffice client.
A way to do the setup faster is by running an unattended install
docs.umbraco.com/umbraco-cms/refer...
You can also setup these parameters during project creation with the template
docs.umbraco.com/umbraco-cms/funda...

Collapse
 
d_inventor profile image
Dennis

Good recommendations! I'll add the additional checks in the sample code for MembersAutologinExtensions.cs, because it's valuable.

If this were to be wrapped up in a package, I'd say a conditional in your csproj file could also prevent the reference from making it beyond debug builds. Something like this perhaps:

<ItemGroup Condition="'$(Configuration)' == 'Debug'">
    <PackageReference Include="My.Autologin.Package" Version="*" />
</ItemGroup>
Enter fullscreen mode Exit fullscreen mode

I would almost argue that this is better than wrapping all code in #if DEBUG statements. Just place the code in a separate library and conditionally include it in your project. No more lingering risky code in your project

Collapse
 
jpkeisala profile image
Jukka-Pekka Keisala

Could this be wrapped as an extension? Something that would also allow me to skip whole setting up first user when installing clean Umbraco?

Collapse
 
d_inventor profile image
Dennis

Hi @jpkeisala ! I see no obstacles to put this in an extension. You could also conditionally include the package in your projects to ensure that the package is only included on local builds, like I wrote in my response to Sven Geusens.

I have no plans to do this myself though, as I have no interest in the maintainance of a package. I'm just sharing my experience as I go.