Eli Weinstock-Herman

Custom Authentication in ASP.Net Core (without Identity)

Original post posted on Friday, September 8, 2017 at LessThanDot.com

Performing Authentication and Authorization has changed from ASP.Net to ASP.Net Core. Rather than relying on attributes, ASP.Net Core uses middleware similar to NancyFX and Rails. This is a short, step-by-step approach to implementing custom Authentication in ASP.Net Core without the overhead (and assumptions) of the new Identity model.

The goal is to support basic necessities like a Login page with cookie-based authentication tickets that properly require HTTPS in production, but gracefully fail back to HTTP in local development.

Cookie Middleware

ASP.Net Core has Cookie Middleware we can use out of the box: Using Cookie Authentication without ASP.NET Core Identity

This middleware provides support for a number of things we want: directing unauthenticated users to a LoginPath, redirecting access denied requests, authentication tickets with sliding expirations and encryption, and hooks to tie into the process for additional custom logic.

Add the Cookie Authentication middleware in the Startup.Configure method:

APIProject/Startup.cs

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
 public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
    // ... other setup ...
 
    app.UseCookieAuthentication(new CookieAuthenticationOptions() {
 
        // A string to identify the authentication scheme we used, useful for filtering in Authorize attributes
        AuthenticationScheme = "NAME_OF_YOUR_COOKIE_SCHEME",
 
        // Location to send people too to log in
        LoginPath = new PathString("/Account/Login"),
 
        // Location to send people who fail to authenticate when required
        AccessDeniedPath = new PathString("/Account/Forbidden"),
 
        // Automatically check authentication on every call?
        AutomaticAuthenticate = true,
 
        // Challenge every request (enforce requirement to have this auth scheme if it got this far)
        AutomaticChallenge = true,
 
        // Expiration of the authentication ticket 
        ExpireTimeSpan = TimeSpan.FromMinutes(30),
 
        // Update the timeout each time a new request comes in
        SlidingExpiration = true,
 
        Events = new CookieAuthenticationEvents() {
 
            // Overwrite the attempt to redirect API calls to login page w/ 401 response instead
            OnRedirectToLogin = (ctx) => {
                // return true 401 to API calls, continue redirect to login for interactive
                if (ctx.Request.Path.StartsWithSegments("/api") && ctx.Response.StatusCode == 200)
                {
                    ctx.Response.StatusCode = 401;
                    return Task.FromResult<object>(null);
                }
 
                ctx.Response.Redirect(ctx.RedirectUri);
                return Task.FromResult<object>(null);
            },
 
        }
    });
 
    // ... other setup ...
}
 public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
	// ... other setup ...

	app.UseCookieAuthentication(new CookieAuthenticationOptions() {

		// A string to identify the authentication scheme we used, useful for filtering in Authorize attributes
		AuthenticationScheme = "NAME_OF_YOUR_COOKIE_SCHEME",

		// Location to send people too to log in
		LoginPath = new PathString("/Account/Login"),

		// Location to send people who fail to authenticate when required
		AccessDeniedPath = new PathString("/Account/Forbidden"),

		// Automatically check authentication on every call?
		AutomaticAuthenticate = true,

		// Challenge every request (enforce requirement to have this auth scheme if it got this far)
		AutomaticChallenge = true,

		// Expiration of the authentication ticket 
		ExpireTimeSpan = TimeSpan.FromMinutes(30),

		// Update the timeout each time a new request comes in
		SlidingExpiration = true,

		Events = new CookieAuthenticationEvents() {

			// Overwrite the attempt to redirect API calls to login page w/ 401 response instead
			OnRedirectToLogin = (ctx) => {
				// return true 401 to API calls, continue redirect to login for interactive
				if (ctx.Request.Path.StartsWithSegments("/api") && ctx.Response.StatusCode == 200)
				{
					ctx.Response.StatusCode = 401;
					return Task.FromResult<object>(null);
				}

				ctx.Response.Redirect(ctx.RedirectUri);
				return Task.FromResult<object>(null);
			},

		}
	});

	// ... other setup ...
}

Note the OnRedirectToLogin logic, this causes the middleware to return basic 401 HTTP errors for calls through the “/api” path instead of redirects to the login page

Account Controller

To support the endpoints above, and some others that may be useful, we need a simple Account controller:

APIProject/Controllers/InteractiveAccountController.cs

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
[ApiExceptionFilter]
[Route("Account")]
public class InteractiveAccountController : Controller
{
    [HttpGet("Unauthorized")]
    public async Task<string> GetUnauthorizedAsync(string returnUrl)
    {
        return "You really should login first.";
    }
 
    [HttpGet("Forbidden")]
    public string GetForbidden()
    {
        return "Forbidden";
    }
 
    [HttpGet("TimedOut")]
    public string GetTimedOut()
    {
        return "TimedOut";
    }
 
    [HttpGet("Login")]
    public async Task<string> GetLogin()
    {
        var principal = new ClaimsPrincipal(new StandardUser());
        await HttpContext.Authentication.SignInAsync("NAME_OF_YOUR_COOKIE_SCHEME", principal);
        return "Success!";
    }
 
    [HttpGet("Logout")]
    public async Task<string> GetLogout()
    {
        await HttpContext.Authentication.SignOutAsync("NAME_OF_YOUR_COOKIE_SCHEME");
        // TODO replace with redirect to login once it exists
        return "Goodbye!";
    }
}
[ApiExceptionFilter]
[Route("Account")]
public class InteractiveAccountController : Controller
{
	[HttpGet("Unauthorized")]
	public async Task<string> GetUnauthorizedAsync(string returnUrl)
	{
		return "You really should login first.";
	}

	[HttpGet("Forbidden")]
	public string GetForbidden()
	{
		return "Forbidden";
	}

	[HttpGet("TimedOut")]
	public string GetTimedOut()
	{
		return "TimedOut";
	}

	[HttpGet("Login")]
	public async Task<string> GetLogin()
	{
		var principal = new ClaimsPrincipal(new StandardUser());
		await HttpContext.Authentication.SignInAsync("NAME_OF_YOUR_COOKIE_SCHEME", principal);
		return "Success!";
	}

	[HttpGet("Logout")]
	public async Task<string> GetLogout()
	{
		await HttpContext.Authentication.SignOutAsync("NAME_OF_YOUR_COOKIE_SCHEME");
		// TODO replace with redirect to login once it exists
		return "Goodbye!";
	}
}

Authorize

Once we have an authenticated user, we can add the standard [Authorize] attribute on any controllers or methods to ensure access will require authentication (or challenge if it’s not present). If we had multiple methods of authentication (interactive user, some API access, etc), we could further restrict these actions to just the cookie-based authenticated requests by specifying that authentication scheme in the attribute:
[Authorize(ActiveAuthenticationSchemes = "NAME_OF_YOUR_COOKIE_SCHEME")]

An easy way to verify things are in working order is to decorate another Controller/Action with Authorize and attempt to visit it. We should get redirected to Unauthorized on the Account controller above. If we visit Login first, then visit our sample Action we should be allowed in. Visit Logout and then our sample Action, we’re back at Unauthorized.

Ensure Endpoints have Authorization

Now that we have the middleware in place, the Account pages, and a sample page, we should add some protection to make sure we don’t leave any pages exposed accidentally. This step is optional, but it’s something I prefer to do for every application I work on.

Using the sample code from a prior ASP.Net post, we can write a quick unit test that will inspect all Actions and require them to either have explicit [Authorize] or [AllowAnonymous] attributes. This will protect us from accidentally pushing an unprotected endpoint.

Unfortunately, APIExplorer in ASP.Net Core is even less documented so we have to rely on Reflection instead:

APIProject.Tests/SecurityTests.cs

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
    [TestFixture]
    public class SecurityTests
    {
        Func<object, bool> IsMVCAttributeAuth = (o) => (o is AuthorizeAttribute || o is AllowAnonymousAttribute);
 
        [Test]
        public void AllMvcActionsHaveExplicitAuthorizationDefined_UsingStandardReflection()
        {
            var actionsMissingAuth = new List<string>();
 
            // 1
            var controllers = Assembly.GetAssembly(typeof(InteractiveAccountController)).GetLoadableTypes()
                          .Where(t => typeof(Controller).IsAssignableFrom(t));
 
            foreach (var controller in controllers)
            {
                // 2
                // if the controller has it, all it's actions are covered also
                if (controller.GetCustomAttributes().Any(a => IsMVCAttributeAuth(a)))
                    continue;
 
                var actions = controller.GetMethods(BindingFlags.Instance |
                                                    BindingFlags.DeclaredOnly |
                                                    BindingFlags.Public);
                foreach (var action in actions)
                {
                    // 3
                    // if the action has a defined authorization filter, it's covered
                    if (action.GetCustomAttributes().Any(a => IsMVCAttributeAuth(a)))
                        continue;
 
                    // no controller or action defined, add it to the list
                    actionsMissingAuth.Add(String.Format("{0}.{1}", controller.Name, action.Name));
                }
            }
 
            // 4
            if (actionsMissingAuth.Any())
            {
                Assert.Fail(String.Format("{0} action(s) do not have explicit authorization: {1}",
                              actionsMissingAuth.Count,
                              String.Join(",", actionsMissingAuth)));
            }
        }
    }
 
 
    public static class AssemblyExtensions
    {
        public static IEnumerable<Type> GetLoadableTypes(this Assembly assembly)
        {
            try
            {
                return assembly.GetTypes();
            }
            catch (ReflectionTypeLoadException e)
            {
                return e.Types.Where(t => t != null);
            }
        }
    }
    [TestFixture]
    public class SecurityTests
    {
        Func<object, bool> IsMVCAttributeAuth = (o) => (o is AuthorizeAttribute || o is AllowAnonymousAttribute);

        [Test]
        public void AllMvcActionsHaveExplicitAuthorizationDefined_UsingStandardReflection()
        {
            var actionsMissingAuth = new List<string>();

            // 1
            var controllers = Assembly.GetAssembly(typeof(InteractiveAccountController)).GetLoadableTypes()
                          .Where(t => typeof(Controller).IsAssignableFrom(t));

            foreach (var controller in controllers)
            {
                // 2
                // if the controller has it, all it's actions are covered also
                if (controller.GetCustomAttributes().Any(a => IsMVCAttributeAuth(a)))
                    continue;

                var actions = controller.GetMethods(BindingFlags.Instance |
                                                    BindingFlags.DeclaredOnly |
                                                    BindingFlags.Public);
                foreach (var action in actions)
                {
                    // 3
                    // if the action has a defined authorization filter, it's covered
                    if (action.GetCustomAttributes().Any(a => IsMVCAttributeAuth(a)))
                        continue;

                    // no controller or action defined, add it to the list
                    actionsMissingAuth.Add(String.Format("{0}.{1}", controller.Name, action.Name));
                }
            }

            // 4
            if (actionsMissingAuth.Any())
            {
                Assert.Fail(String.Format("{0} action(s) do not have explicit authorization: {1}",
                              actionsMissingAuth.Count,
                              String.Join(",", actionsMissingAuth)));
            }
        }
    }


    public static class AssemblyExtensions
    {
        public static IEnumerable<Type> GetLoadableTypes(this Assembly assembly)
        {
            try
            {
                return assembly.GetTypes();
            }
            catch (ReflectionTypeLoadException e)
            {
                return e.Types.Where(t => t != null);
            }
        }
    }

Add Real Login Pages and Logic

I’m not going to push a particular storage solution on you, so I have two interfaces I’ll be using as stand-ins for all of my “storage” needs:

C#
1
2
3
4
5
6
7
8
9
10
11
12
public interface IUserStorage
{
    Task<User> GetUserByUsernameAsync(string username);
    Task<User> GetUserAsync(Guid userId);
}
 
public interface ISessionManager
{
    Task<IPrincipal> CreateSessionAsync(Guid userId);
    Task<bool> IsSessionValidAsync(IPrincipal principal);
    bool IsUserValidForSession(User user);
}
public interface IUserStorage
{
    Task<User> GetUserByUsernameAsync(string username);
    Task<User> GetUserAsync(Guid userId);
}

public interface ISessionManager
{
    Task<IPrincipal> CreateSessionAsync(Guid userId);
    Task<bool> IsSessionValidAsync(IPrincipal principal);
    bool IsUserValidForSession(User user);
}

I am also using BCrypt for password hashing. BCrypt is a slow hashing function that makes brute force attempts computationally expensive. To verify a hashed BCrypt password, we have to pull it out of our store and ask BCrypt to verify it (instead of some methods that allow you to hash a new value and compare at the storage level).

APIProject/Controllers/InteractiveAccountController.cs

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
...
 
[HttpGet("Login")]
public async Task<IActionResult> GetLoginAsync(string returnUrl)
{
    return View("Login", new LoginModel() { ReturnURL = returnUrl });
}
 
[HttpPost("Login")]
public async Task<IActionResult> PostLoginAsync(string username, string password, string returnUrl)
{
    // Did they provide all the details?
    if (string.IsNullOrEmpty(username) || String.IsNullOrEmpty(password))
        return View("Login", new LoginModel() { ReturnURL = returnUrl, UserName = username, Error = "Please provide both a username and password." });
 
    // Load the User and see if BCrypt can verify the entered password matches the stored hash
    var secrets = await _userStorage.GetUserByUsernameAsync(username);
    if (secrets == null || !BCrypt.Net.BCrypt.Verify(password, secrets.PasswordHash))
        return View("Login", new LoginModel() { ReturnURL = returnUrl, UserName = username, Error = "Sorry, could not find a user with that name and password." });
   
    // Verify the user is allowed to start a session (record is enabled, etc)
    if(!_sessionManager.IsUserValidForSession(user))
        return View("Login", new LoginModel() { ReturnURL = returnUrl, UserName = username, Error = "Sorry, your account is currently disabled." });
 
    // Create a principal for the session/user and put it in the cookie
    var principal = await _sessionManager.CreateSessionAsync(user);
    await HttpContext.Authentication.SignInAsync("NAME_OF_YOUR_COOKIE_SCHEME", principal);
 
    // Redirect the user
    if (String.IsNullOrWhiteSpace(returnUrl) || returnUrl.ToLower().StartsWith("/account/login"))
        returnUrl = "/";
 
    return Redirect(returnUrl);
}
 
[HttpGet("Logout")]
public async Task<IActionResult> GetLogout()
{
    await HttpContext.Authentication.SignOutAsync("NAME_OF_YOUR_COOKIE_SCHEME");
    return Redirect("/account/login");
}
 
...
...

[HttpGet("Login")]
public async Task<IActionResult> GetLoginAsync(string returnUrl)
{
    return View("Login", new LoginModel() { ReturnURL = returnUrl });
}

[HttpPost("Login")]
public async Task<IActionResult> PostLoginAsync(string username, string password, string returnUrl)
{
    // Did they provide all the details?
    if (string.IsNullOrEmpty(username) || String.IsNullOrEmpty(password))
        return View("Login", new LoginModel() { ReturnURL = returnUrl, UserName = username, Error = "Please provide both a username and password." });

    // Load the User and see if BCrypt can verify the entered password matches the stored hash
    var secrets = await _userStorage.GetUserByUsernameAsync(username);
    if (secrets == null || !BCrypt.Net.BCrypt.Verify(password, secrets.PasswordHash))
        return View("Login", new LoginModel() { ReturnURL = returnUrl, UserName = username, Error = "Sorry, could not find a user with that name and password." });
   
    // Verify the user is allowed to start a session (record is enabled, etc)
    if(!_sessionManager.IsUserValidForSession(user))
        return View("Login", new LoginModel() { ReturnURL = returnUrl, UserName = username, Error = "Sorry, your account is currently disabled." });

    // Create a principal for the session/user and put it in the cookie
    var principal = await _sessionManager.CreateSessionAsync(user);
    await HttpContext.Authentication.SignInAsync("NAME_OF_YOUR_COOKIE_SCHEME", principal);

    // Redirect the user
    if (String.IsNullOrWhiteSpace(returnUrl) || returnUrl.ToLower().StartsWith("/account/login"))
        returnUrl = "/";

    return Redirect(returnUrl);
}

[HttpGet("Logout")]
public async Task<IActionResult> GetLogout()
{
    await HttpContext.Authentication.SignOutAsync("NAME_OF_YOUR_COOKIE_SCHEME");
    return Redirect("/account/login");
}

...

Inside the ISessionManager.CreateSessionAsync, we create a session in storage with an id and then when the Principal is created we add that Id as a claim in the Principal. This is how we’ll look up the session on later requests to get the user information.

The Cookie middleware will take care of built-in timeouts, but we also need to add a check in case someone disables the user (or whatever criteria is necessary in your system). Add this to the Startup.cs setup.

APIProject/Startup.cs

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
app.UseCookieAuthentication(new CookieAuthenticationOptions() {
 
    ...
 
    Events = new CookieAuthenticationEvents() {
 
        ...
 
        // Use my ISessionManager to ensure session is still valid (user not disabled) or reject the principal if it is no longer valid
        OnValidatePrincipal = async (ctx) => {
            var sessionManager = ctx.HttpContext.RequestServices.GetRequiredService<ISessionManager>();
            var isSessionValid = await sessionManager.IsSessionValidAsync(ctx.Principal);
            if (!isSessionValid) {
                ctx.RejectPrincipal();
            }
        }
    }
});
app.UseCookieAuthentication(new CookieAuthenticationOptions() {

	...

	Events = new CookieAuthenticationEvents() {

		...

		// Use my ISessionManager to ensure session is still valid (user not disabled) or reject the principal if it is no longer valid
		OnValidatePrincipal = async (ctx) => {
			var sessionManager = ctx.HttpContext.RequestServices.GetRequiredService<ISessionManager>();
			var isSessionValid = await sessionManager.IsSessionValidAsync(ctx.Principal);
			if (!isSessionValid) {
				ctx.RejectPrincipal();
			}
		}
	}
});

ISessionManager.IsSessionValidAsync can now pull that Id claim we created above, get the associated user, and do any number of additional validations on the session length, user enabled state, and so on.

Accessing the User/Session

The last step is to the ability to access the User in other controllers. Controllers have a User property that grants access to the ClaimsPrincipal created during login. Assuming we had also stored a property like “UserId” in ISessionManager.CreateSessionAsync then we can access it like this:

APIProject/Controllers/AnyOldController.cs

C#
1
2
var userIdClaim = User.FindFirst("UserId");
var userId = Guid.Parse(userIdClaim.Value);
var userIdClaim = User.FindFirst("UserId");
var userId = Guid.Parse(userIdClaim.Value);

The second time I jumped through this particular hoop, I added a method to my SessionManager that accepted a Controller and looked the user up from the database, so I could do this instead:

APIProject/Controllers/AnyOldController.cs

C#
1
var user = await _sessionManager.GetCurrentUserAsync(this);
var user = await _sessionManager.GetCurrentUserAsync(this);

The Session Id mentioned in the prior section is handled the same way.

Final Checklist

If you’re using this to follow along and implement, here are the pieces you should have:

  • Startup.cs: The CookieMiddleware configured with expiration, automatic challenge, 401 redirects for APIs, and re-validation of the session
  • InteractiveAccountController: GET/POST for Login, GET for Logout, pages for Forbidden plus the Views for these pages
  • UserStore and SessionManager implementations, with just a few functions needed and ability to extend the ClaimsPrincipal with more fields as you grow

You can go as light or heavy as you need to with storage, relational or non-relational, as needed. You have BCrypt, one of the small set of recommended options from OWASP (or upgrade to Argon2). You have cookies with encrypted contents (including the expiration date) that automatically default to secure for HTTPS websites.

Comments are available on the original post at lessthandot.com