https://github.com/dotnet-presentations
This is walk through for a ASP.NET Core Authentication Lab, targeted against ASP.NET Core 2.1 and VS2017/VS Code.
This lab uses the Model-View-Controller template as that's what everyone has been using up until now and it's the most familiar starting point for the vast majority of people.
Official authentication documentation is at https://docs.microsoft.com/en-us/aspnet/core/security/authentication/.
An authorization lab is available at https://github.com/blowdart/AspNetAuthorizationWorkshop/tree/core2
- Visual Studio 2017 (Community edition is free) or
- Visual Studio Code (Code is free) and
- .NET Core 2.1 SDK.
We're going to start with the command line.
- Create a directory on your computer somewhere, call it authenticationlab
- Open a command line/shell and change to the directory you created
- At the command line type
dotnet new console
- Once that completes type
dotnet run
and you will see "Hello World"
Let's examine what's been created. The directory contains two files
authenticationlab.csproj
Program.cs
Type code .
and VS Code will open the directory. It may install some things that are missing. Explore the two files.
- The CS proj file is the project file, it contains the instructions on how the project should be built and what should be included
program.cs
contains the instructions to output 'Hello World'.
Let's turn this into a web application. .NET Core is the core libraries and runtime. ASP.NET Core adds support for web applications, including Kestrel a web server.
First let's add ASP.NET Core to the application.
- Edit the
csproj
file and add the following after the closingPropertyGroup
element
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.All" Version="2.1" />
</ItemGroup>
- Your
csproj
should now look like this
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp2.1</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.All" Version="2.1" />
</ItemGroup>
</Project>
- Save the
csproj
file. If you're using Visual Studio or VS Code you may be prompted to restore packages, choose yes. If you're using the command line and an editor that makes you reconsider your life choices like VIM enterdotnet restore
at the command line. No, I can't help you exit VIM. - Now switch your editor to the
program.cs
file. Change the contents of this file to be as follows
using System;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
namespace authenticationlab
{
class Program
{
public static void Main(string[] args)
{
CreateWebHostBuilder(args).Build().Run();
}
public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
WebHost.CreateDefaultBuilder(args)
.UseStartup<Startup>();
}
}
- Note that
.UseStartup<Startup>()
is having a bad time, it's looking for a class calledstartup
. So let's add that; create a new file calledstartup.cs
and paste the following into it
using System;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
namespace authenticationlab
{
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
app.Run(async (context) =>
{
await context.Response.WriteAsync("Hello World!");
});
}
}
}
- Build the application using your IDE
dotnet build
- Run the application using your IDE or using
dotnet run
- Open a browser and browser to http://localhost:5000/
- Marvel at seeing Hello World!
- Congratulations you have a web application in .NET Core.
For this lab we're going to setup an app which uses Google for login.
First we need to configure HTTPS.
-
Check if you have a developer certificate run
dotnet dev-certs https -c -v
in your command line. If you have a certificate you will see "A valid certificate was found." -
If no certificate was fund run
dotnet dev-certs https --trust
. You will see a popup from Windows asking you if you want to trust a certificate forlocalhost
. Click yes and you will now have a certificate. If you're on Linux trust will not work at certificate generation time, you'll have to trust it in whatever browser you use. If you're using Firefox you will also have to trust it in the browser as Firefox does not honour the OS certificate settings. -
Run your application again, but this time browser to https://localhost:5001 and you should be able to connect over HTTPS.
-
You can force HTTP connections up to HTTPS by adding
app.UseHttpsRedirection();
at the start of yourConfigure()
method. -
Next we will create an app with Google to support Google sign in
-
Navigate to https://developers.google.com/identity/sign-in/web/sign-in and click the
Configure A Project
button. Create a new project called CoreAuthenticationLab. -
At the Configure your OAuth client dialog select
Web Server
from theWhere are you calling from?
drop down and enter https://localhost:5001/signin-google as the authorized redirect URI. -
Click
Create
. -
Make a note of your Client ID and Client secret from the resulting screen then click the API Console link.
-
Click the
Enable APIs and services
button and in the search box enterGoogle+ API
then select it. ClickEnable
. -
Navigate to https://console.developers.google.com/apis/dashboard and in the drop down at the top of the screen choose your
-
Return to your code and replace
startup.cs
with the following code, putting your Client ID and Secret in the options properties.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.Google;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
namespace authenticationlab
{
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services
.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = GoogleDefaults.AuthenticationScheme;
})
.AddCookie()
.AddGoogle(options =>
{
options.ClientId = "**CLIENT ID**";
options.ClientSecret = "**CLIENT SECRET**";
});
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
app.UseHttpsRedirection();
app.UseAuthentication();
app.Run(async (context) =>
{
if (!context.User.Identity.IsAuthenticated)
{
await context.ChallengeAsync();
}
await context.Response.WriteAsync("Hello "+context.User.Identity.Name+"!\r");
});
}
}
}
- Run the code, go through the google login screens, and you should see "Hello yourGoogleUserName!"
- There is an XSS attack in the sample if the browser decided the page was HTML, so to address this, add the following before the
WriteAsync
call
context.Response.Headers.Add("Content-Type", "text/plain");
- Let's add some logging to see what's going on.
- Create a private class level variable of type
ILogger
called_logger
inside theStartup
class. - Add a constructor which takes a parameter of
ILoggerFactory
and creates and assigns a logger to_logger
. (You will need to add ausing
reference toMicrosoft.Extensions.Logging
if your editor isn't doing that work for you.) - This should look something like the following;
private ILogger _logger;
public Startup(ILoggerFactory loggerFactory)
{
_logger = loggerFactory.CreateLogger<Startup>();
}
- Now let's look at the events on the Google authentication service by adding some logging inside the events that authentication fires.
- Events are part of the options class (you may need to add
using Microsoft.AspNetCore.Authentication.OAuth;
if your editor doesn't prompt you to do this).
options.Events = new OAuthEvents()
{
OnRedirectToAuthorizationEndpoint = context =>
{
_logger.LogInformation("Redirecting to {0}", context.RedirectUri);
return Task.CompletedTask;
},
OnRemoteFailure = context =>
{
_logger.LogInformation("Something went horribly wrong.");
return Task.CompletedTask;
},
OnTicketReceived = context =>
{
_logger.LogInformation("Ticket received.");
return Task.CompletedTask;
},
OnCreatingTicket = context =>
{
_logger.LogInformation("Creating tickets.");
return Task.CompletedTask;
}
};
- Make sure your authentication cookie is deleted (close your browser, or manually cull it) then browse to the web site again and watch the logging in the console.
- Did you notice any difference? Why isn't your user name greeting you any more?
- Some events need things returned. Look at the documentation for the events, or the source.
- Note that the
OnRedirectToAuthorizationEndpoint
default implementation calls the redirect - this isn't happening in your code any more, so add it back;
OnRedirectToAuthorizationEndpoint = context =>
{
_logger.LogInformation("Redirecting to {0}", context.RedirectUri);
context.Response.Redirect(context.RedirectUri);
return Task.CompletedTask;
},
- Now, take a look at the context properties inside
OnCreatingTicket()
. There's some useful stuff in there, like the Google access token. What if I want to save that? - Let's store the access token and refresh token google gives us in the identity we're creating inside
OnCreatingTicket
as claims. - First we need names for the claims, so define some
const
values in theStartup
class like so
private const string AccessTokenClaim = "urn:tokens:google:accesstoken";
- Now inside the
OnCreatingTicket
event let's use these names to create some new claims, with the appropriate values
OnCreatingTicket = context =>
{
var identity = (ClaimsIdentity)context.Principal.Identity;
identity.AddClaim(new Claim(AccessTokenClaim, context.AccessToken));
_logger.LogInformation("Creating tickets.");
return Task.CompletedTask;
}
- And finally to check they persisted add some code inside the
app.Run()
lambda after the greeting;
var claimsIdentity = (ClaimsIdentity)context.User.Identity;
var accessTokenClaim = claimsIdentity.Claims.FirstOrDefault(x => x.Type == AccessTokenClaim);
if (accessTokenClaim != null)
{
await context.Response.WriteAsync("Google access claims have persisted\r");
}
- Make sure your cookies have been cleared, run the application and browse to it.
- How safe is this? Can you figure out from the cookie what the access token details are?
- Let's do something interesting with what google sends us.
- Go back to the Google API dashboard, make sure your project is selected and click "Credentials".
- Pull down the Create Credentials drop down and choose API key. Copy the value you're given somewhere safe.
- Replace the
app.Run()
lambda with the following, adding your API key in the appropriate place;
app.UseHttpsRedirection();
app.UseAuthentication();
app.Run(async (context) =>
{
if (!context.User.Identity.IsAuthenticated)
{
await context.ChallengeAsync();
}
context.Response.Headers.Add("Content-Type", "text/html");
await context.Response.WriteAsync("<html><body>\r");
var claimsIdentity = (ClaimsIdentity)context.User.Identity;
var nameIdentifier = claimsIdentity.Claims.FirstOrDefault(x => x.Type == ClaimTypes.NameIdentifier).Value;
var googleApiKey = "**API KEY**";
if (!string.IsNullOrEmpty(nameIdentifier))
{
string jsonUrl = $"https://www.googleapis.com/plus/v1/people/{nameIdentifier}?fields=image&key={googleApiKey}";
using (var httpClient = new HttpClient())
{
var s = await httpClient.GetStringAsync(jsonUrl);
dynamic deserializeObject = JsonConvert.DeserializeObject(s);
var thumbnailUrl = (string)deserializeObject.image.url;
if (thumbnailUrl != null && !string.IsNullOrWhiteSpace(thumbnailUrl))
{
await context.Response.WriteAsync(
string.Format($"<img src=\"{thumbnailUrl}\"></img>"));
}
}
}
await context.Response.WriteAsync("</body></html>\r");
});
}
}
}
- Rerun the application and look at how wonderful your Google profile image is.
-
How is this all hanging together?
- Why does Google authentication need cookie authentication too?
- What's a scheme?
-
Remote authentication is just that. Remote. There is no persistence mechanism to allow it to be reused.
-
Persistent authentication is authentication whose information is sent with every request, like Basic authentication, Digest authentication, Certificate authentication or cookie based tokens.
-
Examine the authentication configuration in
ConfigureServices()
services
.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = GoogleDefaults.AuthenticationScheme;
})
- There are multiple
Default*
options on theAuthenticationService
configuration;- DefaultAuthenticateScheme
- DefaultChallengeScheme
- DefaultForbidScheme
- DefaultScheme
- DefaultSignInScheme
- DefaultSignOutScheme
- When you have multiple authentication types you use these configuration settings to decide which authentication type is responsible for what
- Everything is based off a scheme. A scheme is simply a string you give an authentication provider when you configure it.
DefaultScheme
is, well, the default. It provides every event handler, unless you override the other events.AuthenticationScheme
is the provider that runs on every request and attempts to construction an identity from information in the request.ChallengeScheme
is the provider that will handle challenges, the event that happens when authorization is required and there's no identity on the request.ForbidScheme
is the provider that handles forbid events, which fire when authorization happens and the current identity fails the authorization check.DefaultSignInScheme
andDefaultSignOutScheme
indicate the provider which will handleSignIn
andSignOut
calls.
- So what does the configuration in your application do? Could it be different?
-
Fire up your browser and look at the asp.net cookie that's been issued, '.AspNetCore.Cookies'
-
What does the cookie contain?
-
What's the expiry on the cookie?
-
How do you think the cookie is protected?
-
Let's configure that cookie; first let's set a permanent expiry date on it so it persist over browser closes
.AddCookie(options =>
{
options.Cookie.Expiration = new System.TimeSpan(0, 15, 0);
})
- What other options are in the cookie builder? Why is expiration also on Options? (it's going away)
- What about sliding expiration? Sliding expiration is outside of the cookie builder; note we need to remove the expiration timespan from the cookie builder
.AddCookie(options =>
{
options.ExpireTimeSpan = new System.TimeSpan(0, 15, 0);
options.SlidingExpiration = true;
})
- Why can't I set the expiration in the cookie builder in options? (The cookie authentication service has its own setting to support sliding expirations and also to embed the expiry in the cookie value itself. Why would it embed it in the value?)
- After changing the cookie from a session cookie to a persistent one you'll now notice that you don't bounce through Google authentication again, cookie authentication is the single source of truth.
So what would happen if you populated a cookie from a database and the values change? For this we have the
OnValidatePrincipal
event. - Create a validator in your startup.cs
private static int RequestCount = 0;
public static async Task ValidateAsync(CookieValidatePrincipalContext context)
{
if (context.Request.Path.HasValue && context.Request.Path == "/")
{
System.Threading.Interlocked.Increment(ref RequestCount);
}
if (RequestCount % 5 == 0)
{
context.RejectPrincipal();
await context.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
}
}
- Every 5 requests to / will now reject the current principal and trigger sign out. If you just wanted to change the principal you could use
context.ReplacePrincipal(newPrincipal);
context.ShouldRenew = true;
- Now let's talk about how cookies are protected, because that value is not plain text.
- ASP.NET Core has a Data Protection service which creates and rotates keys used throughout the stack.
- Data Protection has two concepts, key persistence and key protection.
public void ConfigureServices(IServiceCollection services)
{
services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(@"\\server\share\directory\"))
.ProtectKeysWithCertificate("thumbprint");
}
- In some environments we can figure out what to do automatically. Check your startup logs to see what we're trying to do.
- Linux and MacOS need specific choices.
- Applications get isolation by default, to share cookies share a keyring and set a static application name
public void ConfigureServices(IServiceCollection services)
{
services.AddDataProtection()
.SetApplicationName("shared app name");
}
- Claims transformation allows you to add, delete or even replace the principal that's constructed during
AuthenticateAsync()
. - Claims transformation is a service, implementing
IClaimsTransformation
. - Let's write one that adds a claim to a resource during authentication, without having to use the authentication specific events.
class ClaimsTransformer : IClaimsTransformation
{
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
((ClaimsIdentity)principal.Identity).AddClaim(
new Claim("transformedOn", DateTime.Now.ToString()));
return Task.FromResult(principal);
}
}
- Then it gets added in
ConfigureServices()
public void ConfigureServices(IServiceCollection services)
{
// Other service config removed
services.AddTransient<IClaimsTransformation, ClaimsTransformer>();
}
- Note that, as discussed, this gets called any time
AuthenticateAsync()
is called, so it would add the "now" claim every time it runs, which is probably not what you want - claims transformers need to be defensive, for example
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
var claimsIdentity = (ClaimsIdentity)principal.Identity;
if (claimsIdentity.Claims.FirstOrDefault(x => x.Type == "transformedOn") == null)
{
((ClaimsIdentity)principal.Identity).AddClaim(
new Claim("transformedOn", System.DateTime.Now.ToString()));
}
return Task.FromResult(principal);
}
- You can add a line into your
app.Run()
to see the claim; note that it updates on every run, it's not persisted into the cookie, it runs after the cookie has been written.
- So obviously putting your entire website logic inside the
app.Run()
lambda isn't really a sustainable development strategy, so let's add ASP.NET MVC to the mix. - First at the end of
ConfigureServices()
add a call toservices.AddMvc();
- Next let's add developer error pages, and MVC into the application configuration, replace the
Configure()
method with the following;
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Home/Error");
}
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseMvc(routes =>
{
routes.MapRoute(
name: "default",
template: "{controller=Home}/{action=Index}/{id?}");
});
}
}
- Now we need to add a controller. Create a
Controllers
folder and inside the new folder create a new fileHomeController.cs
. Put the following code in the file.
using Microsoft.AspNetCore.Mvc;
namespace authenticationlab.Controllers
{
public class HomeController : Controller
{
public IActionResult Index()
{
return View();
}
}
}
- Next we need to add a view. Create a
Views
folder in your application folder, then create aHome
folder inside theViews
folder. - Inside this new
Home
folder create a file calledIndex.cshtml
and change the contents to be
<!DOCTYPE html>
<html>
<head>
<title>ASP.NET MVC</title>
<meta charset="utf-8" />
</head>
<body>
<p>
Hello world!
</p>
</body>
</html>
-
Finally clean your project by executing
dotnet clean
in the project directory, or by using Visual Studio'sClean
context menu item on the project file. -
Note if you're using Visual Studio you will notice it now tries to be clever and hooks up IIS Express to host your application, as well as assigning random ports. This can be controlled via the
launchsettings.json
file it created. Change theapplicationURL
property in the authenticationLab profile to be"https://localhost:5001;http://localhost:5000"
and delete theIIS Express
profile. -
Run your project and browse to the site and you should see
Hello World
. -
Now, let's get back to where we were, first let's see who the current user is. Open up
Index.cshtml
and replaceHello World
withHello @User.Identity.Name
. Build and run your application and browse to it. This time you will only seeHello
. Why? -
We need to add the authentication process back to the application.
-
Open your
HomeController.cs
file and add an[Authorize]
attribute to the Index action. You will also need to add ausing
statement forMicrosoft.AspNetCore.Authorization
. Your controller should look like this
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace authenticationlab.Controllers
{
public class HomeController : Controller
{
[Authorize]
public IActionResult Index()
{
return View();
}
}
}
- Then change the contents of
index.cshtml
to be
<!DOCTYPE html>
<html>
<head>
<title>ASP.NET MVC</title>
<meta charset="utf-8" />
</head>
<body>
<p>
Hello @User.Identity.Name
</p>
</body>
</html>
- Rebuild and rerun your application.
- The addition of the
[Authorize]
attribute tells MVC that all requests to the action need to be authorized. The default authorization rule is any authenticated user, in fact any authorization rule requires an authenticated user so before authorization can happen authentication must take place, so it's now doing what we manually previous in these lines of code
if (!context.User.Identity.IsAuthenticated)
{
await context.ChallengeAsync();
}
-
One thing to note is that any output into a view is HTML attribute encoded by default, so even if we got a user name which had
<script>
in it, it's going to end up as&lt;script&gt;
in the view output. -
If we wanted to get out pretty google profile picture back again we could go and grab it in the controller, shove it in a model, and pass it into the view. So let's do that.
-
Create a
Models
folder in the root of your project and inside the folder create a new file,IndexViewModel.cs
. Paste the following code into the file
namespace authenticationlab.Models
{
public class IndexViewModel
{
public string ProfilePictureUri { get; set; }
}
}
- Edit the
Index
action in theHomeController
to create an instance of the model, and assign the profile Uri to the property on the model using the code that you had inapp.Run()
. Note that inside on theController
base class are a number of properties, includingUser
so you don't need to go looking for the request context. Your controller code should look some this;
using System.Linq;
using System.Net.Http;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
namespace authenticationlab.Controllers
{
[Authorize]
public class HomeController : Controller
{
public async Task<IActionResult> Index()
{
var model = new Models.IndexViewModel();
var claimsIdentity = (ClaimsIdentity)User.Identity;
var nameIdentifier = claimsIdentity.Claims.FirstOrDefault(x => x.Type == ClaimTypes.NameIdentifier).Value;
var googleApiKey = "AIzaSyBZyzSW3_6G1DsvTtWF4PTSOy5ENcmvnMg";
if (!string.IsNullOrEmpty(nameIdentifier))
{
string jsonUrl = $"https://www.googleapis.com/plus/v1/people/{nameIdentifier}?fields=image&key={googleApiKey}";
using (var httpClient = new HttpClient())
{
var s = await httpClient.GetStringAsync(jsonUrl);
dynamic deserializeObject = JsonConvert.DeserializeObject(s);
var thumbnailUrl = (string)deserializeObject.image.url;
if (thumbnailUrl != null && !string.IsNullOrWhiteSpace(thumbnailUrl))
{
model.ProfilePictureUri = thumbnailUrl;
}
}
}
return View(model);
}
}
}
- Finally go to your view and change it to take in a model and use the contents within it;
@model authenticationlab.Models.IndexViewModel;
<!DOCTYPE html>
<html>
<head>
<title>ASP.NET MVC</title>
<meta charset="utf-8" />
</head>
<body>
<p>
Hello @User.Identity.Name
</p>
@if (!string.IsNullOrEmpty(Model.ProfilePictureUri))
{
<p>
<img src="@Model.ProfilePictureUri" />
</p>
}
</body>
</html>
- Recompile your application, run it, and browse to it and there's your Google profile picture back again.
- Finally for this workshop what if you have a weird authentication protocol? Tokens in a weird format in a random header, Basic Authentication, something ASP.NET Core doesn't cater for? Well you need to write an authentication handler.
- As this is a workshop we need a simple example, one that you would never, ever, ever write in real life, one that stands alone, and doesn't use encryption or signing or anything useful you would use. So, let's put authentication information in the query string and, well, let's never admit we did this to anyone.
- Let's start by resetting our authentication pieces in the application we've been working on
- In
startup.cs
remove add the.AddAuthentication()
call and the handlers hanging off it. - Remove the ClaimsTransformation service registration, and the cookie validator as well.
- Your
startup.cs
should now look like
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace authenticationlab
{
public class Startup
{
private ILogger _logger;
public Startup(ILoggerFactory loggerFactory)
{
_logger = loggerFactory.CreateLogger<Startup>();
}
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc();
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Home/Error");
}
app.UseAuthentication();
app.UseMvc(routes =>
{
routes.MapRoute(
name: "default",
template: "{controller=Home}/{action=Index}/{id?}");
});
}
}
}
- Next take out all the calls to retrieve the google profile in your controller, and reset it to just returning a view.
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace authenticationlab.Controllers
{
public class HomeController : Controller
{
[Authorize]
public IActionResult Index()
{
return View();
}
}
}
- Remove the code that displayed the picture from the view,
<!DOCTYPE html>
<html>
<head>
<title>ASP.NET MVC</title>
<meta charset="utf-8" />
</head>
<body>
<p>
Hello @User.Identity.Name
</p>
</body>
</html>
-
If you want you can also delete the model class.
-
Now if you run the application the
[Authorize]
attribute will cause an error,InvalidOperationException: No authenticationScheme was specified, and there was no DefaultChallengeScheme found.
because there is no authentication handlers in the pipeline. So let's write one. -
At its simplest an authentication handler is an implementation of
AuthenticationHandler
, an associated options class and, if you're feeling helpful an events class, a helper to supportapp.Add*
and a default class to hold a scheme name. -
Let's start with the handler itself. Add a new file,
AwfulQueryStringAuthenticationHandler.cs
to your project. -
Make the class inherit from
AuthenticationHandler
. but, this needs an options class. -
As we're not going to implement options let's just use the base class
AuthenticationSchemeOptions
. -
A handler needs to implement
HandleAuthenticateAsync()
, so we can put in a default implementation;
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
namespace authenticationlab
{
public class AwfulQueryStringAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
throw new NotImplementedException();
}
}
}
- We still can't compile at this point, as we need some bits for DI handlers rely on. Add the following constructor to the class;
public AwfulQueryStringAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock) : base(options, logger, encoder, clock)
{
}
- After you've added the using references your class should look like
using System;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace authenticationlab
{
public class AwfulQueryStringAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public AwfulQueryStringHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock) : base(options, logger, encoder, clock)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
throw new NotImplementedException();
}
}
}
- Remember schemes, and how you have constants for the default scheme for each handler?
Let's create that now. Add a new file,
AwfulQueryStringAuthenticationDefaults.cs
and add the following code
namespace authenticationlab
{
public static class AwfulQueryStringAuthenticationDefaults
{
public const string AuthenticationScheme = "Awful";
}
}
- Finally we want to add that nice
services.AddX()
support. This is provided by extension methods onAuthenticationBuilder
. Create a new file calledAwfulQueryStringAuthenticationExtensions.cs
and put the following code into it.
using System;
using Microsoft.AspNetCore.Authentication;
using authenticationlab;
namespace Microsoft.AspNetCore.Builder
{
public static class AwfulQueryStringAuthenticationAppBuilderExtensions
{
public static AuthenticationBuilder AddAwfulQueryString(
this AuthenticationBuilder builder)
=> builder.AddAwfulQueryString(
AwfulQueryStringAuthenticationDefaults.AuthenticationScheme);
public static AuthenticationBuilder AddAwfulQueryString(
this AuthenticationBuilder builder,
string authenticationScheme)
=> builder.AddAwfulQueryString(
authenticationScheme,
configureOptions: null);
public static AuthenticationBuilder AddAwfulQueryString(
this AuthenticationBuilder builder,
Action<AuthenticationSchemeOptions> configureOptions)
=> builder.AddAwfulQueryString(
AwfulQueryStringAuthenticationDefaults.AuthenticationScheme,
configureOptions);
public static AuthenticationBuilder AddAwfulQueryString(
this AuthenticationBuilder builder,
string authenticationScheme,
Action<AuthenticationSchemeOptions> configureOptions)
{
return builder.AddScheme<
AuthenticationSchemeOptions,
AwfulQueryStringAuthenticationHandler>(
authenticationScheme,
configureOptions);
}
}
}
- Note the
namespace
for this class isMicrosoft.AspNetCore.Builder
which will allow it to appear inConfigureServices()
. - Now we have everything together and compiling we can add our new handler to
ConfigureServices()
;
public void ConfigureServices(IServiceCollection services)
{
services.AddAuthentication(AwfulQueryStringAuthenticationDefaults.AuthenticationScheme)
.AddAwfulQueryString();
services.AddMvc();
}
-
If you now run your application and browse to the web page you will see your handler gets called, and throws the
NotImplementedException
. -
So, let's add an implementation.
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
string usernameParameter = Request.Query["username"];
if (!string.IsNullOrEmpty(usernameParameter))
{
var identity = new ClaimsIdentity(Scheme.Name);
identity.AddClaim(
new Claim(
ClaimTypes.Name,
usernameParameter,
ClaimValueTypes.String,
Options.ClaimsIssuer));
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return AuthenticateResult.Success(ticket);
}
return AuthenticateResult.NoResult();
}
-
Run your code and browse to the site. You'll see that there's nothing being rendered when you visit the home page. Open your browser tooling and turn on network capture and refresh - you're getting a 401 back because there's no
username
query string parameter and there are no other handlers which could construct an identity. -
Try adding a
username
parameter to the query string, with a value. and browse. -
You have an authenticated user. Let's never talk of this again
-
If you wanted to map status codes to error messages there is a built in middleware for that, just add
app.UseStatusCodePages();
into your app configuration.
The inbox authorization handlers have events in them. Add an event to your query string handler which allows the handler to pass the inbound user name somewhere else for validation, and then allows or forbids the request based on the results of that call.
Now why not look at the Authorization Workshop?