/Solar.API

An example .NET 6 API project that uses .NET Identity to manage users and roles, as well as JWTs for session authentication and authorisation. Also some Vue.js advanced techniques such as typescript client generation and environment variables.

Primary LanguageTypeScript

Setup .NET Identity

Add the following packages

  • Microsoft.AspNetCore.Identity.EntityFrameworkCore
  • Microsoft.EntityFrameworkCore
  • Microsoft.EntityFrameworkCore.Design
  • Microsoft.EntityFrameworkCore.SqlServer
  • Microsoft.EntityFrameworkCore.Tools

Create your database and add the connection string to your appsettings.json:

{
  "ConnectionStrings": {
    "Default": "Data Source=localhost\\SQLEXPRESS...."
  }
}

Create your DbContext class, extending IdentityDbContext:

using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

namespace Solar.Data
{
    public class SolarDbContext : IdentityDbContext<IdentityUser>
    {
        public SolarDbContext(DbContextOptions options) : base (options)
        {
        }
    }
}

Then add the following to your startup class - Program

// Add the database connection
builder.Services.AddDbContext<SolarDbContext>(options => options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));

// Setup identity
builder.Services.AddIdentity<IdentityUser, IdentityRole>(options =>
{
    options.Password.RequireDigit = true;
    options.Password.RequireLowercase = true;
    options.Password.RequireNonAlphanumeric = true;
    options.Password.RequiredLength = 8;
}).AddEntityFrameworkStores<SolarDbContext>();

Then run the following commands in the package manager console

add-migration init
update-database

You will notice 5 or 6 tables have been created to store all the User and Role information.

We can now login and register using the UserManager and SignInManager as seen in the AccountController:

using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Solar.DTOs.Inbound;

namespace Solar.API.Controllers
{
    [ApiController]
    [Route("user")]
    public class AccountController : Controller
    {
        private readonly UserManager<IdentityUser> _userManager;
        private readonly SignInManager<IdentityUser> _signInManager;

        public AccountController(UserManager<IdentityUser> userManager, SignInManager<IdentityUser> signInManager)
        {
            _userManager = userManager;
            _signInManager = signInManager;
        }

        [HttpPost]
        [Route("register")]
        public async Task<IActionResult> Register([FromBody] RegisterDto model)
        {
            var user = new IdentityUser
            {
                UserName = model.Email,
                Email = model.Email
            };

            // Create the user
            var result = await _userManager.CreateAsync(user, model.Password);

            if (result.Succeeded)
            {
                return Ok();
            }

            return new BadRequestObjectResult(result.Errors);
        }

        [HttpPost]
        [Route("login")]
        public async Task<IActionResult> Login([FromBody] LoginDto model)
        {
            // Login
            var result = await _signInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, false);

            if (result.Succeeded)
            {
                return Ok();
            }

            return BadRequest("Incorrect email or password");
        }
    }
}

Based on:

.NET Identity Roles

To add roles first of all add this line to your startup code:

builder.Services.AddIdentity<IdentityUser, IdentityRole>(options =>
{
    options.Password.RequireDigit = true;
    options.Password.RequireLowercase = true;
    options.Password.RequireNonAlphanumeric = true;
    options.Password.RequiredLength = 8;
})
.AddRoles<IdentityRole>() // <--- add this line
.AddEntityFrameworkStores<SolarDbContext>();

Then, it is probably a good idea to make sure the roles are added to the db when your application starts up, which can you do in the same file using:

using (var scope = app.Services.CreateScope())
{
    var services = scope.ServiceProvider;

    // Migrate the database
    var db = services.GetRequiredService<SolarDbContext>();
    db.Database.Migrate();

    // Add the roles
    var roleManager = services.GetRequiredService<RoleManager<IdentityRole>>();
    if (!await roleManager.RoleExistsAsync(Roles.Admin))
    {
        await roleManager.CreateAsync(new IdentityRole(Roles.Admin));
    }
    if (!await roleManager.RoleExistsAsync(Roles.User))
    {
        await roleManager.CreateAsync(new IdentityRole(Roles.User));
    }
}

Where Roles looks like this:

namespace Solar.Common.Roles
{
    public static class Roles
    {
        public const string Admin = "Admin";
        public const string User = "User";
    }
}

You can then assign a user to a role using the user manager like so:

var user = new IdentityUser
{
    UserName = model.Email,
    Email = model.Email
};

var createResult = await _userManager.CreateAsync(user, model.Password);

if (!createResult.Succeeded)
{
    return new BadRequestObjectResult(createResult.Errors);
}

// Assign the role
var assignRoleResult = await _userManager.AddToRoleAsync(user, Roles.User);

if (!assignRoleResult.Succeeded)
{
    return new BadRequestObjectResult(assignRoleResult.Errors);
}

return Ok();

Based on:

Adding JWTs

Install the following package:

  • Microsoft.AspNetCore.Authentication.JwtBearer

Add the following lines to your startup class:

// Add JWTs
builder.Services.AddAuthentication(auth =>
{
    auth.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    auth.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(options =>
{
    options.SaveToken = true;
    options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidateLifetime = true,
        ValidateIssuerSigningKey = true,
        ValidIssuer = builder.Configuration["Jwt:Issuer"],
        ValidAudience = builder.Configuration["Jwt:Audience"],
        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]))
    };
});

...

// Must be in this order
app.UseAuthentication();
app.UseAuthorization();

With the following values in your appsettings.json

{
  "Jwt": {
    "Key": "ThisIsMySecretKey",
    "Issuer": "https://localhost:7234/",
    "Audience": "https://localhost:7234/"
  }
}

Now we need to create a service that will accept an Identity user and create a token for them to use on our site:

// TokenService.cs

using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;

namespace Solar.Services.Token
{
    public class TokenService : ITokenService
    {
        private readonly UserManager<IdentityUser> _userManager;
        private readonly IConfiguration _config;

        public TokenService(UserManager<IdentityUser> userManager, IConfiguration config)
        {
            _userManager = userManager;
            _config = config;
        }

        async public Task<string> GenerateJwtToken(IdentityUser user, TimeSpan expiration)
        {
            var claims = new List<Claim>
            {
                new Claim(JwtRegisteredClaimNames.Sub, user.UserName),
                new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
            };

            foreach(var role in await _userManager.GetRolesAsync(user))
            {
                claims.Add(new Claim("role", role));
            }

            var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Jwt:Key"]));
            var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

            var token = new JwtSecurityToken(
                issuer: _config["Jwt:Issuer"],
                audience: _config["Jwt:Audience"],
                expires: DateTime.UtcNow.Add(expiration),
                claims: claims,
                signingCredentials: creds
            );

            return new JwtSecurityTokenHandler().WriteToken(token);
        }
    }
}

Which can be used in the Login action like so:

[HttpPost]
[Route("login")]
[AllowAnonymous]
public async Task<ActionResult<string>> Login([FromBody] LoginDto model)
{
    var result = await _signInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, false);

    if (!result.Succeeded)
    {
        return BadRequest("Incorrect email or password");
    }

    // Generate JWT
    var user = await _userManager.FindByNameAsync(model.Email);
    var token = await _tokenService.GenerateJwtToken(user, TimeSpan.FromMinutes(30));

    return Ok(token);
}

The roles stored in the JWT are then correctly loaded in on each request, meaning you can use the Authorize attributes like normal:

[HttpGet]
[Route("one")]
[Authorize(Roles = "User")]
public ActionResult<string> GetRandomMoon()
{
    return Moons[Random.Next(Moons.Count)];
}

[HttpGet]
[Route("two")]
[Authorize(Roles = "Admin")]
public ActionResult<string> GetTwoRandomMoons()
{
    return $"{Moons[Random.Next(Moons.Count)]}, {Moons[Random.Next(Moons.Count)]}";
}

You can also get the username we injected into the sub of the JWT using:

[HttpGet]
[Route("user")]
[Authorize(Roles = "Admin, User")]
public async Task<ActionResult<IdentityUser>> GetLoggedInUser()
{
    var username = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
    var user = await _userManager.FindByNameAsync(username);
    return new OkObjectResult(user);
}

And finally, if you'd like to support the Authorize window in Swagger (adds the ability to pass the Bearer token with each subsequent request), add the following to your startup class:

// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
    var jwtSecurityScheme = new OpenApiSecurityScheme
    {
        Name = "Authorization",
        Type = SecuritySchemeType.Http,
        Scheme = JwtBearerDefaults.AuthenticationScheme,
        BearerFormat = "JWT",
        In = ParameterLocation.Header,
        Reference = new OpenApiReference
        {
            Type = ReferenceType.SecurityScheme,
            Id = JwtBearerDefaults.AuthenticationScheme
        }
    };

    options.AddSecurityDefinition(JwtBearerDefaults.AuthenticationScheme, jwtSecurityScheme);
    options.AddSecurityRequirement(new OpenApiSecurityRequirement(){{ jwtSecurityScheme, new string[] {} }});
});

Based on:

JWT Storage

We can't store JWTs in local storage when we're consuming the API via an SPA.

Add cookie name to app settings:

"Jwt": {
    "Key": "ThisIsMySecretKey",
    "Issuer": "https://localhost:7234/",
    "Audience": "https://localhost:7234/",
    "CookieName": "solar-access-token"
}

Add this section to your AddJwtBearer options:

options.Events = new JwtBearerEvents
    {
        OnMessageReceived = context =>
        {
            context.Token = context.Request.Cookies[builder.Configuration["Jwt:CookieName"]];
            return Task.CompletedTask;
        },
    };

Change the login action to return a cookie with the token (must be HttpOnly), and only return account info in the body (not the token):

Response.Cookies.Append(_config["Jwt:CookieName"], token, new CookieOptions
{
    HttpOnly = true,
    IsEssential = true,
    MaxAge = TimeSpan.FromMinutes(30),
    SameSite = SameSiteMode.None,
    Secure = true,
});

return new OkObjectResult(
    new LoginResultDto(user.Email, await _userManager.GetRolesAsync(user))
);

Remove all the Swagger integration for the Bearer token - the browser will handle this for us now:

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
    var jwtSecurityScheme = new OpenApiSecurityScheme
    {
        Name = "Authorization",
        Type = SecuritySchemeType.Http,
        Scheme = JwtBearerDefaults.AuthenticationScheme,
        BearerFormat = "JWT",
        In = ParameterLocation.Header,
        Reference = new OpenApiReference
        {
            Type = ReferenceType.SecurityScheme,
            Id = JwtBearerDefaults.AuthenticationScheme
        }
    };

    options.AddSecurityDefinition(JwtBearerDefaults.AuthenticationScheme, jwtSecurityScheme);
    options.AddSecurityRequirement(new OpenApiSecurityRequirement(){{ jwtSecurityScheme, new string[] {} }});
});

becomes

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

Now when you test in swagger, and login, you will see a cookie is automtically put into the browser - which will be used on subsequent requests

As HttpOnly cookies cannot be accessed by javascript (hence why we use them!), we need to trigger a logout by calling a new endpoint. This will simply return the cookie with no token in it, and an instant expiry so the browser loses that cookie.

[HttpPost]
[Route("logout")]
[AllowAnonymous]
public IActionResult Logout()
{
    Response.Cookies.Append(_config["Jwt:CookieName"], string.Empty, new CookieOptions
    {
        HttpOnly = true,
        IsEssential = true,
        MaxAge = TimeSpan.Zero,
        SameSite = SameSiteMode.None,
        Secure = true,
    });

    return Ok();
}

Cookies work when using a system such as swagger, but when using them in Vue.js we need a bit more configuration.

First of all we need to define a default cors policy with a named origin:

// Add cors
builder.Services.AddCors(options => options.AddDefaultPolicy(
    policy => policy
        .WithOrigins("http://localhost:8080")
        .AllowCredentials()
        .AllowAnyHeader()
        .AllowAnyMethod()
));

...

app.UseCors();

And then we need to tell axios to send the cookie in the requests:

const axiosInstance = axios.create({
  withCredentials: true,
});

When using vue.js it will work exactly like swagger - just hit login and the cookie will take of the rest till the next time you need to login

https://javascript.plainenglish.io/how-to-secure-jwt-in-a-single-page-application-6a46e69fc393 https://spin.atomicobject.com/2020/07/25/net-core-jwt-cookie-authentication/ https://dotnetcoretutorials.com/2017/01/15/httponly-cookies-asp-net-core/ https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.http.cookieoptions?view=aspnetcore-6.0#properties https://blog.logrocket.com/jwt-authentication-best-practices/ https://stackoverflow.com/questions/71419379/set-cookie-not-working-properly-in-axios-call