Creating Project Templates for dotnet – Part 3 – Optional Code/Content

This is part 3 of a 4 part series of posts

In my last two posts I explored how to set up and build a template and then how to add support for optional files. Now, building on what we have already done we can add another parameter to our template to add in authentication conditionally (opt-in). First, let’s start be adding in the authentication code. Once that is in place we can circle back and make it optional in our template by adding and configuring a new parameter.

First we’ll need to add the Microsoft.AspNetCore.Authentication.Cookies & Microsoft.AspNetCore.Authentication.OpenIdConnect NuGet packages into our project file. Normally I would do this using the package manager window or console, but in this case I will just add the relevant lines to the project file directly so we can see the content we will be making optional later on.

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <Folder Include="Models\" />
    <Folder Include="wwwroot\lib\" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="ChaosMonkey.Guards.NetStandard" Version="1.0.23" />
    <PackageReference Include="DotNetNinja.AutoBoundConfiguration" Version="1.1.0" />
    <PackageReference Include="Microsoft.AspNetCore.Authentication.Cookies" Version="2.2.0" />
    <PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="5.0.2" />
  </ItemGroup>

</Project>

Then we are going to need a class (~/Configuration/AuthenticationSettings.cs) to hold all of the configuration information for our identity provider (Auth0). We’ll use my DotNetNinja.AutoBoundConfigurations library to bind this class to our configuration at start up.

using System.Collections.Generic;
using DotNetNinja.AutoBoundConfiguration;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;

namespace SampleWeb.Configuration
{
    [AutoBind("Authentication")]
    public class AuthenticationSettings
    {
        public string Authority { get; set; }
        public string ClientId { get; set; }
        public string ClientSecret { get; set; }
        public string NameClaimType { get; set; }
        public string RoleClaimType { get; set; }
        public List<string> Scopes { get; set; } = new List<string> {"openid"};

        public string ClaimsIssuer { get; set; }
        public string CallbackPath { get; set; } = "/signin-oidc";
        public string ResponseType { get; set; } = OpenIdConnectResponseType.Code;

        public string DefaultAuthenticationScheme { get; set; } = CookieAuthenticationDefaults.AuthenticationScheme;
        public string DefaultSignInScheme { get; set; } = CookieAuthenticationDefaults.AuthenticationScheme;
        public string DefaultChallengeScheme { get; set; } = CookieAuthenticationDefaults.AuthenticationScheme;

        public bool SaveTokens { get; set; } = true;
        
        public string ScopesValue => string.Join(' ', Scopes ?? new List<string>()).Trim();
    }
}

Next we’ll add a class (~/Configuration/CustomAuthenticationExtensions.cs) for an extension method to configure authentication during application start up.

using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;

namespace SampleWeb.Configuration
{
    public static class CustomAuthenticationExtensions
    {
        public static IServiceCollection AddCustomAuthentication(this IServiceCollection services, AuthenticationSettings settings)
        {
            return services
                .AddAuthentication(options => {
                    options.DefaultAuthenticateScheme = settings.DefaultAuthenticationScheme;
                    options.DefaultSignInScheme = settings.DefaultSignInScheme;
                    options.DefaultChallengeScheme = settings.DefaultChallengeScheme;
                })
                .AddCookie()
                .AddOpenIdConnect("Auth0", options => {
                    options.Authority = $"https://{settings.Authority}";
                    options.ClientId = settings.ClientId;
                    options.ClientSecret = settings.ClientSecret;
                    options.ResponseType = settings.ResponseType;
                    options.Scope.Clear();
                    options.Scope.Add(settings.ScopesValue);
                    options.CallbackPath = new PathString(settings.CallbackPath);
                    options.ClaimsIssuer = settings.ClaimsIssuer;
                    options.MapInboundClaims = true;
                    options.SaveTokens = settings.SaveTokens;
                    options.TokenValidationParameters = new TokenValidationParameters
                    {
                        NameClaimType = settings.NameClaimType,
                        RoleClaimType = settings.RoleClaimType
                    };
                    options.Events = new OpenIdConnectEvents
                    {
                        OnRedirectToIdentityProviderForSignOut = (context) =>
                        {
                            var logoutUri = $"https://{settings.Authority}/v2/logout?client_id={settings.ClientId}";
                            var postLogoutUri = context.Properties.RedirectUri;
                            if (!string.IsNullOrEmpty(postLogoutUri))
                            {
                                if (postLogoutUri.StartsWith("/"))
                                {
                                    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;
                        }
                    };
                }).Services;
        }
    }
}

Finally, we’ll also need to add an AccountController class (~/Controllers/AccountController.cs) so we can handle sign in & sign out requests.

using Microsoft.AspNetCore.Mvc;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;

namespace SampleWeb.Controllers
{
    public class AccountController: Controller
    {
        [HttpGet]
        public virtual async Task SignIn(string returnUrl = "/")
        {
            await HttpContext.ChallengeAsync("Auth0", new AuthenticationProperties() { RedirectUri = returnUrl });
        }

        [HttpGet]
        [Authorize]
        public virtual async Task SignOut()
        {
            await HttpContext.SignOutAsync("Auth0", new AuthenticationProperties
            {
                RedirectUri = Url.Content("~/")
            });
            await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
        }
    }
}

Now that those files are in place, let’s add our parameter to the template.json. We’ll need to define the parameter setting the default value to false so that a user of our template has to explicitly opt into adding in this feature:

...
    "symbols": {
        ...
        "authentication": {
            "type": "parameter",
            "datatype":"bool",
            "defaultValue": "false"            
        }
    }
...

Then we will want to add in a new condition to exclude the files above when authentication is not requested:

...
    "sources": [
        {
            "modifiers": [
                ...
                {
                    "condition": "(!authentication)",
                    "exclude": [
                        "src/DotNetNinja.Templates.Mvc/Configuration/AuthenticationSettings.cs",
                        "src/DotNetNinja.Templates.Mvc/Configuration/CustomAuthenticationExtensions.cs",
                        "src/DotNetNinja.Templates.Mvc/Controllers/AccountController.cs"
                    ]
                }
            ]
        }
...

When authentication is enabled we add in lines to get our configuration settings and invoke our extension method. If not, we add in the original line of code to initialize AutoBoundConfiguration without getting the settings

         public void ConfigureServices(IServiceCollection services)
        {
#if(authentication)
            var oidc = services.AddAutoBoundConfigurations(Configuration)
                .FromAssembly(typeof(Program).Assembly).Provider.Get<AuthenticationSettings>();
            services.AddCustomAuthentication(oidc);
#endif
#if(!authentication)
            services.AddAutoBoundConfigurations(Configuration).FromAssembly(typeof(Program).Assembly);
#endif
            services.AddControllersWithViews();
        }

We’ll also need to add a using statement to our class when authentication is enabled and we’ll need to invoke the UseAuthentication method in the Configure method.

using System.Diagnostics.CodeAnalysis;
using DotNetNinja.AutoBoundConfiguration;
#if(authentication)
using DotNetNinja.Templates.Mvc.Configuration;
#endif
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseExceptionHandler("/Error");
                // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
                app.UseHsts();
            }
            app.UseHttpsRedirection();
            app.UseStaticFiles();

            app.UseRouting();

#if(authentication)
            app.UseAuthentication();
#endif            
            app.UseAuthorization();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllerRoute(
                    name: "default",
                    pattern: "{controller=Home}/{action=Index}/{id?}");
            });
        }

At this point we have all the code in place, but there is no way for a user to sign in. To enable that we’ll conditionally add some new menu options to our _Layout.cshtml template page.

@*#if(authentication)
@{
    var isAuthenticated = User.Identity.IsAuthenticated;
    var userName = (isAuthenticated) ? User.Identity.Name : "Anonymous";
}
#endif*@
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>@ViewData[ViewKey.Title] | DotNetNinja.Templates.Mvc</title>
    <link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" />
    <link rel="stylesheet" href="~/css/site.css" />
</head>
<body>
    <header>
        <nav class="navbar navbar-expand-sm navbar-dark bg-dark fixed-top mb-3">
            <div class="container">
                <a class="navbar-brand" asp-area="" asp-controller="Home" asp-action="Index">DotNetNinja.Templates.Mvc</a>
                <button class="navbar-toggler" type="button" data-toggle="collapse" data-target=".navbar-collapse" aria-controls="navbarSupportedContent"
                        aria-expanded="false" aria-label="Toggle navigation">
                    <span class="navbar-toggler-icon"></span>
                </button>
                <div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">
                    <ul class="navbar-nav flex-grow-1">
                        <li class="nav-item">
                            <a class="nav-link" asp-area="" asp-controller="Home" asp-action="Index">Home</a>
                        </li>
                    </ul>
@*#if(authentication)
                    <ul class="navbar-nav ml-auto">
                        @if (!isAuthenticated)
                        {
                            <li class="nav-item">
                                <a class="nav-link" asp-controller="Account" asp-action="SignIn">Sign In</a>
                            </li> 
                        }
                        else
                        {
                            <li class="nav-item">
                                <a class="nav-link" asp-controller="Account" asp-action="SignOut">Sign Out | @userName</a>
                            </li>
                        }
                    </ul>
#endif*@
                </div>
            </div>
        </nav>
    </header>
    <main role="main" class="pb-3">
        <div class="container">
            @RenderBody()
        </div>
    </main>
    <footer class="border-top footer p-2 text-muted">
        <div class="container">
            <div class="row">
                <div class="col-sm-9">
                    &copy; @DateTime.Now.Year DotNetNinja.Templates.Mvc
                </div>
            </div>
        </div>
    </footer>
    <script src="~/lib/jquery/jquery.min.js"></script>
    <script src="~/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
    <script src="~/js/site.js" asp-append-version="true"></script>
    @await RenderSectionAsync("Scripts", required: false)
</body>
</html>

And lastly let’s remove the references to the NuGet packages we added for authentication when authentication is not enabled.

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
    <UserSecretsId>73fd3ede-b11d-40ed-b023-aaef44aad0b8</UserSecretsId>
  </PropertyGroup>

  <ItemGroup>
    <Folder Include="Models\" />
    <Folder Include="wwwroot\lib\" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="ChaosMonkey.Guards.NetStandard" Version="1.0.23" />
    <PackageReference Include="DotNetNinja.AutoBoundConfiguration" Version="1.1.0" />
<!--#if(authentication)-->
    <PackageReference Include="Microsoft.AspNetCore.Authentication.Cookies" Version="2.2.0" />
    <PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="5.0.2" />
<!--#endif-->
  </ItemGroup>

</Project>

Everything should be in place now. Let’s rebuild the template package and try it out.

.\Test-Template.ps1
# don't forget to do this n a different "test" folder!
dotnet new ninjamvc -n SampleWeb -au

This should generate a new application named SampleWeb and if you dig into the generated code you should see that all of our optional files and code are in place. If you run the command without the -au argument, you should get the same output as before our changes.

In the final article in this series we’ll take a look at how to enable support for your new template in Visual Studio.

Resources

Leave a Reply

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

This site uses Akismet to reduce spam. Learn how your comment data is processed.