A very long time ago, I started (and abandoned) a series about ASP.NET development, titled Modernising a legacy application using ASP.NET 4.8, JWT, SQL Server, Entity Framework Data First and a React front-end - part 1. Within that post, I began the process of updating an fictional pseudo-legacy ASP.NET Framework application to ASP.NET 4.8. I implemented Entity Framework data-first migrations and implemented JWT token authentication. And that’s where I left it - I got busy.

Needs must

Fast forward 3 1/2 years (wow), and I’ve lost my job due to the entire team being restructured. That time spent behind a corporate NDA means I don’t have much to show for my time at the old company.

I’ve had a passion project in my periphery for the better part of 7 years, with not a lot of time to work on it. This is PetrolIQ. There will be other posts about that, but not yet.

Now’s the time, but first, I had to scaffold the basics.

A new project

I’ve spent the last 3 weeks implementing the basics for the project using an ASP.NET Core Web API. There was a caveat; I am an avid home-labber, so everything needed to be self-hosted in my garage on my 1U HPE server.

I needed/wanted to use the following:

  • A database.
    • I chose MongoDB because I’ve never worked with it before.
  • Passport auth.
    • Authentication with RBAC.
  • ASP.NET Core 8

Self hosting

As mentioned, I have a home lab. I am currently using Windows Server 2019 on bare-metal, which is running Hyper-V. I have a VM also running Windows Server 2019, configured with IIS running all of my web projects. There are other VMs running irrelevant things, however one important one is running a free Kemp Load Balancer.

The server itself is an HPE ProLiant DL360 G7 Server which has 2x 6 core (12 thread) Intel Xeon E5645 CPUs, 96GB of DDR3 RAM and 4x 300GB 10k SAS HDDs running in RAID 1 (system and data drives).

The Load Balancer allows for me to use a single TLD with subdomains to send all secure traffic to my home (static) IP address and direct it to whichever internal IP address is required for a specific service. This is used in conjunction with a Cloudflare proxy.

I currently have MongoDB running on bare metal alongside Hyper-V (will eventually be moved to a Docker container).

The Web API and React front-end are running on the IIS VM.

Project structure

The Petroliq solution is currently synced to a public Github repository. I’m a little precious about the PetroliqAlgorithm so that’s private.

📦 ./
├─ Petroliq
│  ├─ Petroliq_API
│  └─ React
└─ PetroliqAlgorithm

©generated by Project Tree Generator

A database

As mentioned previously; I have MongoDB running on bare metal. The schema of which is manually maintained.

Passport Auth

I originally implemented storage of the JWT in localStorage (prior to pushing it to the host), just to get it working. I knew that was insecure so I spent many hours researching how best to handle it.

To preface; yes, I’ve rolled my own auth. This was important as I wanted a deep dive into how to do so.

I feel something isn’t right with the implementation I’ve gone with - please feel free to pick it apart (this may be completely irrelevant now that Google has announced 3rd party token deprecation this year).

I settled on the following:

  • Upon initial authorisation.
    • Generate a refresh token.
      • Save that refresh token to the Users’ database record.
    • Set HttpOnly cookies with.
      • Access token.
      • Refresh token.
    • Hash the refresh token.
    • In the returned payload, provide.
      • User Id.
      • Expiry date of the Access token.
      • Hashed Refresh token.

The point of this is to:

  • Ensure that the access token is never sent back to the browser in the returned payload.
  • The reason for the hashed refresh token being sent back is because it’s compared with the non-hashed version to detect modification.
    • It’s validated against the one in the HttpOnly cookie for subsequent API calls, and what’s stored in the database for the User.
  • The reason that the UserId is sent back is to persist it for the user when they need to refresh their Access token.

Questions

I feel like I shouldn’t be passing the UserId and Refresh token back in the same response.

  • How’s my implementation?
    • Should the (hashed) Refresh token include the UserId?

Implementation

I’m not treating this as a tutorial, therefore I will highlight key configuration details specifically for problems or considerations I encountered.

Generating the DB schema and seeding initial data

My first task was to create my initial schema for the MongoDB database.

Users table

// MongoDB > Users (some data elided for brevity)

{
  "count": 0,
  "fields": [
    {
      "name": "_id",
      "path": [
        "_id"
      ],
      "count": 0,
      "type": "ObjectId",
      "probability": 1,
      "hasDuplicates": false
    },
    {
      "name": "AssignedRoles",
      "path": [
        "AssignedRoles"
      ],
      "count": 0,
      "type": [
        "Null",
        "String"
      ],
      "probability": 1,
      "hasDuplicates": false
    },
    {
      "name": "Email",
      "path": [
        "Email"
      ],
      "count": 0,
      "type": "String",
      "probability": 1,
      "hasDuplicates": false
    },
    {
      "name": "FirstName",
      "path": [
        "FirstName"
      ],
      "count": 0,
      "type": "String",
      "probability": 1,
      "hasDuplicates": false
    },
    {
      "name": "LastName",
      "path": [
        "LastName"
      ],
      "count": 0,
      "type": "String",
      "probability": 1,
      "hasDuplicates": false
    },
    {
      "name": "Password",
      "path": [
        "Password"
      ],
      "count": 0,
      "type": "String",
      "probability": 1,
      "hasDuplicates": false
    },
    {
      "name": "RefreshToken",
      "path": [
        "RefreshToken"
      ],
      "count": 0,
      "type": [
        "Null",
        "String"
      ],
      "probability": 1,
      "hasDuplicates": false
    },
    {
      "name": "RefreshTokenExpiryTime",
      "path": [
        "RefreshTokenExpiryTime"
      ],
      "count": 0,
      "type": [
        "Null",
        "Date"
      ],
      "probability": 1,
      "hasDuplicates": false      
    },
    {
      "name": "UserName",
      "path": [
        "UserName"
      ],
      "count": 0,
      "type": "String",
      "probability": 1,
      "hasDuplicates": false
    }
  ]
}

UserSettings table

// MongoDB > UserSettings (some data elided for brevity)

{
  "count": 0,
  "fields": [
    {
      "name": "_id",
      "path": [
        "_id"
      ],
      "count": 0,
      "type": "ObjectId",
      "probability": 1,
      "hasDuplicates": false
    },
    {
      "name": "AccruedDiscount",
      "path": [
        "AccruedDiscount"
      ],
      "count": 0,
      "type": [
        "Int32",
        "Double"
      ],
      "probability": 1,
      "hasDuplicates": false
    },
    {
      "name": "AvgCapacityUnitPerDistanceUnit",
      "path": [
        "AvgCapacityUnitPerDistanceUnit"
      ],
      "count": 0,
      "type": "Int32",
      "probability": 1,
      "hasDuplicates": false
    },
    {
      "name": "BaseDiscount",
      "path": [
        "BaseDiscount"
      ],
      "count": 0,
      "type": "Double",
      "probability": 1,
      "hasDuplicates": false
    },
    {
      "name": "CapacityUnit",
      "path": [
        "CapacityUnit"
      ],
      "count": 0,
      "type": "Int32",
      "probability": 1,
      "hasDuplicates": false
    },
    {
      "name": "CountryName",
      "path": [
        "CountryName"
      ],
      "count": 0,
      "type": "String",
      "probability": 1,
      "hasDuplicates": false
    },
    {
      "name": "CurrencyUnit",
      "path": [
        "CurrencyUnit"
      ],
      "count": 0,
      "type": "Int32",
      "probability": 1,
      "hasDuplicates": false
    },
    {
      "name": "CurrentBatchId",
      "path": [
        "CurrentBatchId"
      ],
      "count": 0,
      "type": "Int32",
      "probability": 1,
      "hasDuplicates": false
    },
    {
      "name": "DistanceUnit",
      "path": [
        "DistanceUnit"
      ],
      "count": 0,
      "type": "Int32",
      "probability": 1,
      "hasDuplicates": false
    },
    {
      "name": "IdealDiscount",
      "path": [
        "IdealDiscount"
      ],
      "count": 0,
      "type": "Int32",
      "probability": 1,
      "hasDuplicates": false
    },
    {
      "name": "LastPricePerCapacityUnit",
      "path": [
        "LastPricePerCapacityUnit"
      ],
      "count": 0,
      "type": "Double",
      "probability": 1,
      "hasDuplicates": false
    },
    {
      "name": "MinimumSpendForDiscount",
      "path": [
        "MinimumSpendForDiscount"
      ],
      "count": 0,
      "type": [
        "Int32",
        "Double"
      ],
      "probability": 1,
      "hasDuplicates": false
    },
    {
      "name": "NextFillId",
      "path": [
        "NextFillId"
      ],
      "count": 0,
      "type": "Int32",
      "probability": 1,
      "hasDuplicates": false
    },
    {
      "name": "RoundTo",
      "path": [
        "RoundTo"
      ],
      "count": 0,
      "type": "Int32",
      "probability": 1,
      "hasDuplicates": false
    },
    {
      "name": "RoundUnitCostTo",
      "path": [
        "RoundUnitCostTo"
      ],
      "count": 0,
      "type": "Int32",
      "probability": 1,
      "hasDuplicates": false
    },
    {
      "name": "UserId",
      "path": [
        "UserId"
      ],
      "count": 0,
      "type": "String",
      "probability": 1,
      "hasDuplicates": false
    }
  ]
}

CORS

CORS was always going to be a consideration throughout the build process, so I ended up configuring CORS as follows.

// ./Program.cs

public class Program
{
    public static void Main(string[] args)
    {
        // previous elided for brevity

        builder.Services.AddCors(options =>
        {
            options.AddPolicy("_Auth", policy =>
            {
                policy
                    .WithOrigins(
                        "http://localhost:3000",
                        [public end-points elided]
                    )
                    .AllowAnyMethod()
                    .AllowAnyHeader()
                    .SetPreflightMaxAge(TimeSpan.FromSeconds(3600))
                    .AllowCredentials();
            });
        });

        // remainder elided for brevity
    }
}

App settings

Due to my requirement for running this service on my own server, and having chosen IIS to do so, there were some funky issues encountered when using User Secrets, namely that it ended up causing me to scratch my head a little too much while attempting to target environment variables across IIS Application Pools.

Here is the structure of the secrets.json file.

// ./secrets.json

{
  "ConnectionString": "[elided]",
  "AuthKey": "[elided]"
}

And here is the structure of the appsettings.json file; note that there is crossover in these settings.

  • secrets/ConnectionString » appsettings/Database/ConnectionString
  • secrets/AuthKey » appsettings/Database/Auth:Key
// ./appsettings.json

{
  "Database": {
    "ConnectionString": "REPLACE_ME_REFER_README",
    "DatabaseName": "Petroliq",
    "UserSettingsCollectionName": "UserSettings",
    "UsersCollectionName": "Users"
  },
  "Auth": {
    "Audience": "[elided]/api",
    "Key": "REPLACE_ME_REFER_README",
    "Issuer": "[elided]",
    "TokenValidityMinutes": 5,
    "RefreshTokenValiditityDays":  7
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "App": {
    "KM_MI_FACTOR": 1.60934,
    "MI_KM_FACTOR": 0.62,
    "LTR_GAL_FACTOR": 4.54609,
    "DEFAULT_ROUND_TO": 2
  },
  "AllowedHosts": "*"
}

Base functionality

As previously eluded to, I encountered problems that blocked me more than I’d liked, therefore I resigned myself to using both secrets.json (in development) and appsettings.json (in “production”). Note the preprocessor directive in the snippet below.

After scaffolding the initial DB schema for a Users and UserSettings table and configuring settings, I set about building out the Authorisation and Authentication.

Passport Auth, entry point config

Here is the configuration of the entry point to the app.

// ./Program.cs

public class Program
{
    public static void Main(string[] args)
    {
        // previous elided for brevity

        #region Configure Auth
        string? jwtIssuer = string.Empty;
        string? jwtKey = string.Empty;
        string? jwtAudience = string.Empty;
        int? cookieTokenValidityMinutes = 5; // default, overridden by app settings
#if !DEBUG
        // from appsettings.json, for IIS usage
        jwtIssuer = authSettings.GetSection("Issuer").Value; // builder.Configuration["Auth:Issuer"];
        jwtKey = authSettings.GetSection("Key").Value;
        jwtAudience = authSettings.GetSection("Audience").Value;
        if (int.TryParse(authSettings.GetSection("TokenValidityMinutes").Value, out int cookieValidity))
        {
            cookieTokenValidityMinutes = cookieValidity;
        }
#else
        // from secrets.json, inaccessible when hosted on IIS
        jwtIssuer = builder.Configuration["Auth:Issuer"];
        jwtKey = builder.Configuration["AuthKey"];
        jwtAudience = builder.Configuration["Auth:Audience"];
        if (int.TryParse(builder.Configuration["Auth:TokenValidityMinutes"], out int cookieValidity))
        {
            cookieTokenValidityMinutes = cookieValidity;
        }
#endif

        if (!string.IsNullOrEmpty(jwtIssuer) && !string.IsNullOrEmpty(jwtKey) && !string.IsNullOrEmpty(jwtAudience))
        {
            builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
            .AddJwtBearer(options =>
            {
                options.TokenValidationParameters = new TokenValidationParameters
                {
                    ValidateIssuer = true,
                    ValidateAudience = true,
                    ValidateLifetime = true,
                    ValidateIssuerSigningKey = true,
                    ValidIssuer = jwtIssuer,
                    ValidAudience = jwtAudience,
                    IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
                    ClockSkew = TimeSpan.Zero
                };

                options.Authority = jwtIssuer;

                // configure pipleline so that the HttpOnly cookie is provided to all end-points
                options.Events = new JwtBearerEvents
                {
                    OnMessageReceived = context =>
                    {
                        if (context.Request.Cookies.ContainsKey("X-Access-Token"))
                        {
                            context.Token = context.Request.Cookies["X-Access-Token"];
                        }
                        return Task.CompletedTask;
                    },
                };
            });

            // configure RBAC roles for RoleHandler
            builder.Services.AddAuthorization(options =>
            {
                options.AddPolicy("admin", policy => policy.Requirements.Add(new HasRoleRequirement(jwtIssuer, ["administrator"])));
                options.AddPolicy("userAdmin", policy => policy.Requirements.Add(new HasRoleRequirement(jwtIssuer, ["administrator", "users.read", "users.write", "userSettings.read", "userSettings.write"])));
                options.AddPolicy("appUser", policy => policy.Requirements.Add(new HasRoleRequirement(jwtIssuer, ["appUser", "administrator", "users.read", "users.write", "userSettings.read", "userSettings.write"])));
            });

            builder.Services.AddSingleton<IAuthorizationHandler, HasRoleHandler>();
        }
        #endregion

        // remainder elided for brevity
    }
}

Data

Next up were the POCOs for a User.

// ./Model/User.cs

/// <summary>
/// User model
/// </summary>
public class User
{
    [BsonId]
    [BsonRepresentation(BsonType.ObjectId)]
    public string? Id { get; set; }
    public string? FirstName { get; set; }
    public string? LastName { get; set; } = string.Empty;
    public string? UserName { get; set; }
    public string? Email { get; set; }
    public string? Password { get; set; }
    public string? AssignedRoles { get; set; }

    public string? RefreshToken { get; set; }
    public DateTime? RefreshTokenExpiryTime { get; set; }

    public static List<string> ValidateFieldUpdates(User original, User updated)
    {
        List<string> updatedFields = [];
        // field validation elided for brevity
        return updatedFields;
    }
}

And for their settings.

// ./Model/UserSettings.cs

/// <summary>
/// User Settings model
/// </summary>
public class UserSettings
{
    [BsonId]
    [BsonRepresentation(BsonType.ObjectId)]
    public string? Id { get; set; }

    [BsonElement("UserId")]
    public string? UserId { get; set; }

    public string? CountryName { get; set; }

    public CurrencyUnit CurrencyUnit { get; set; }
    public CapacityUnit CapacityUnit { get; set; }
    public DistanceUnit DistanceUnit { get; set; }

    public decimal BaseDiscount { get; set; }
    public decimal MinimumSpendForDiscount { get; set; }
    public decimal LastPricePerCapacityUnit { get; set; }
    public decimal AccruedDiscount { get; set; }
    public decimal IdealDiscount { get; set; }

    public int RoundTo { get; set; }
    public int RoundUnitCostTo { get; set; }

    public int CurrentBatchId { get; set; }
    public int NextFillId { get; set; }
    public decimal AvgCapacityUnitPerDistanceUnit { get; set; }
    public decimal MaxVolumeQualifyingForDiscount { get; set; }

    public static List<string> ValidateFieldUpdates(UserSettings original, UserSettings updated)
    {
        List<string> updatedFields = [];
        // field validation elided for brevity
        return updatedFields;
    }
}

The Controllers for these Model classes utilise their ValidateFieldUpdates methods to provide a List of fields that have had updates.

Services

We’ve got a ServiceHelper class, which the Service classes use to derive from.

// ./Services/ServiceHelper.cs

public class ServiceHelper
{
    private readonly IConfiguration _configuration;
    public readonly string? _connectionString;
    public MongoClient _mongoClient;

    public ServiceHelper(IConfiguration configuration, IOptions<PetroliqDatabaseSettings> petroliqDatabaseSettings)
    {
        _configuration = configuration;
#if !DEBUG
        _connectionString = petroliqDatabaseSettings.Value.ConnectionString; // from appsettings.json, inaccessible when hosted on IIS
#else
        _connectionString = _configuration["ConnectionString"]; // from secrets.json, inaccessible when hosted on IIS
#endif
        _mongoClient = new MongoClient(_connectionString);
    }
}

The UserService class which abstracts away the DB CRUD functionality for the User table.

// ./Services/UserService.cs

public class UserService : ServiceHelper
{
    private readonly IMongoCollection<User>? _usersCollection;

    public UserService(IOptions<PetroliqDatabaseSettings> petroliqDatabaseSettings, IConfiguration configuration) : base(configuration, petroliqDatabaseSettings)
    {
        IMongoDatabase mongoDatabase;

        try
        {
            mongoDatabase = _mongoClient.GetDatabase(petroliqDatabaseSettings.Value.DatabaseName);
        }
        catch (Exception ex)
        {
            throw new Exception("MongoDB initialisation error, UserService", ex);
        }

        try
        {
            _usersCollection = mongoDatabase.GetCollection<User>(petroliqDatabaseSettings.Value.UsersCollectionName);
        }
        catch (Exception ex)
        {
            throw new Exception("Users Collection instantiation error", ex);
        }
    }

    /// <summary>
    /// Get all User objects
    /// </summary>
    /// <returns></returns>
    public async Task<List<User>> GetAsync() =>
        await _usersCollection.Find(_ => true).ToListAsync();

    /// <summary>
    /// Get a User object by their db Id value (User Id)
    /// </summary>
    /// <param name="dbId"></param>
    /// <returns></returns>
    public async Task<User?> GetAsync(string dbId) =>
        await _usersCollection.Find(x => x.Id == dbId).FirstOrDefaultAsync();

    /// <summary>
    /// Get a User object by their db Email value
    /// </summary>
    /// <param name="email"></param>
    /// <returns></returns>
    public async Task<User?> GetByEmailAsync(string email) => 
        await _usersCollection.Find(x => x.Email == email).FirstOrDefaultAsync();

    /// <summary>
    /// Create a new User object
    /// </summary>
    /// <param name="newUser"></param>
    /// <returns></returns>
    public async Task CreateAsync(User newUser)
    {
        if (newUser != null)
        {
            await _usersCollection.InsertOneAsync(newUser);
        }
    }        

    /// <summary>
    /// Update an existing User object
    /// </summary>
    /// <param name="id"></param>
    /// <param name="updatedUserSettings"></param>
    /// <returns></returns>
    public async Task UpdateAsync(string id, User updatedUserSettings) =>
        await _usersCollection.ReplaceOneAsync(x => x.Id == id, updatedUserSettings);

    /// <summary>
    /// Remove an existing User object
    /// </summary>
    /// <param name="id"></param>
    /// <returns></returns>
    public async Task RemoveAsync(string id)
    {
        await _usersCollection.DeleteOneAsync(x => x.Id == id);
    }
        
}

And the UserSettingsService class that does the same for the UserSettings DB table.

CRUD functionality for this Service class is intentionally limited as UserSettings lifecycle is handled by the UserController (see more below).

// ./Services/UserSettingsService.cs

public class UserSettingsService : ServiceHelper
{
    private readonly IMongoCollection<UserSettings> _userSettingsCollection;

    public UserSettingsService(IOptions<PetroliqDatabaseSettings> petroliqDatabaseSettings, IConfiguration configuration) : base(configuration, petroliqDatabaseSettings)
    {
        IMongoDatabase mongoDatabase;

        try
        {
            mongoDatabase = _mongoClient.GetDatabase(petroliqDatabaseSettings.Value.DatabaseName);
        }
        catch (Exception ex)
        {
            throw new Exception("MongoDB initialisation error, UserSettingsService", ex);
        }

        try
        {
            _userSettingsCollection = mongoDatabase.GetCollection<UserSettings>(petroliqDatabaseSettings.Value.UserSettingsCollectionName);
        }
        catch (Exception ex)
        {
            throw new Exception("UserSettings Collection instantiation error", ex);
        }
    }

    /// <summary>
    /// Get all UserForRegistration Settings objects
    /// </summary>
    /// <returns></returns>
    public async Task<List<UserSettings>> GetAsync() =>
        await _userSettingsCollection.Find(_ => true).ToListAsync();

    /// <summary>
    /// Get a UserForRegistration Settings object for a specific UserForRegistration
    /// </summary>
    /// <param name="id"></param>
    /// <param name="useUserId"></param>
    /// <returns></returns>
    public async Task<UserSettings?> GetForUserAsync(string id, bool useUserId)
    {
        if (useUserId)
        {
            return await _userSettingsCollection.Find(x => x.UserId == id).FirstOrDefaultAsync();
        }
        else
        {
            return await _userSettingsCollection.Find(x => x.Id == id).FirstOrDefaultAsync();
        }            
    }

    /// <summary>
    /// Create a UserForRegistration Settings object for a specific UserForRegistration
    /// </summary>
    /// <param name="newUserSettings"></param>
    /// <returns></returns>
    public async Task CreateForUserAsync(UserSettings newUserSettings) =>
        await _userSettingsCollection.InsertOneAsync(newUserSettings);

    /// <summary>
    /// Update a UserForRegistration Settings object for a specific UserForRegistration
    /// </summary>
    /// <param name="userId"></param>
    /// <param name="updatedUserSettings"></param>
    /// <returns></returns>
    public async Task UpdateForUserAsync(string userId, UserSettings updatedUserSettings) =>
        await _userSettingsCollection.ReplaceOneAsync(x => x.UserId == userId, updatedUserSettings);

    /// <summary>
    /// Remove a UserForRegistration Settings object for a specific UserForRegistration
    /// </summary>
    /// <param name="userId"></param>
    /// <returns></returns>
    public async Task RemoveForUserAsync(string userId) =>
        await _userSettingsCollection.DeleteOneAsync(x => x.UserId == userId);
}

Controllers

AuthController

The AuthController’s responsibility is all of the important stuff revolving around logging in, refreshing tokens and revocation of tokens. This Controller also handles propagation of the HttpOnly cookies to the client; note the presence of the X-Access-Token (JWT) and X-Fingerprint (hashed Refresh) cookies.

// ./Controllers/AuthController.cs

/// <summary>
/// Authentication Controller
/// </summary>
[Route("api/[controller]")]
[ApiController]
public class AuthController : ControllerBase
{
    private readonly IConfiguration _configuration;
    private readonly UserService _userService;
    private readonly IOptions<AuthSettings> _authSettings;

    private readonly string _jwtAuthKey = string.Empty;
    private readonly string _jwtIssuer = string.Empty;
    private readonly string _jwtAudience = string.Empty;
    private readonly int _refreshTokenValiditityDays = -1;
    private readonly int _tokenValidityMinutes = -1;

    /// <summary>
    /// Authentication Controller
    /// </summary>
    /// <param name="configuration"></param>
    /// <param name="userService"></param>
    /// <param name="authSettings"></param>
    public AuthController(IConfiguration configuration, UserService userService, IOptions<AuthSettings> authSettings)
    {
        _authSettings = authSettings;
        _configuration = configuration;
        _userService = userService;

#if !DEBUG
        _jwtAuthKey = _configuration["Auth:Key"]; // authSettings.Value.Key; // from appsettings.json, for IIS usage
#else
        _jwtAuthKey = _configuration["AuthKey"]; // from secrets.json, inaccessible when hosted on IIS            
#endif

        _jwtIssuer = _authSettings.Value.Issuer;
        _jwtAudience = _authSettings.Value.Audience;
        _refreshTokenValiditityDays = _authSettings.Value.RefreshTokenValiditityDays;
        _tokenValidityMinutes = _authSettings.Value.TokenValidityMinutes;
    }

    /// <summary>
    /// Authenticate with this Web API using a username and password, token is persisted to the HttpOnly cookie
    /// </summary>
    /// <param name="loginModel">LoginModel instance</param>
    /// <returns>Bearer and Refresh tokens</returns>
    /// <response code="200">Returns User Id, hashed Refresh token and Token expiry date if authentication was successful</response>
    /// <response code="400">Nothing is returned if authentication fails or required values not provided</response>
    /// <response code="401">Nothing is returned if the UserForRegistration is not authorised</response>
    /// <response code="404">Nothing is returned if no UserForRegistration is found</response>
    /// <response code="500">Nothing is returned if the internal configuration is incorrect; see message</response>
    [HttpPost]
    [Route("Login")]
    [AllowAnonymous]
    public async Task<IActionResult> Login([FromBody] LoginModel loginModel)
    {
        if (string.IsNullOrEmpty(loginModel.Email) || string.IsNullOrEmpty(loginModel.Password))
        {
            return BadRequest();
        }

        User? user = await _userService.GetByEmailAsync(loginModel.Email);

        if (user is null)
        {
            return NotFound();
        }
        else
        {
            bool passwordVerified = BCrypt.Net.BCrypt.Verify(loginModel.Password, user.Password);
            if (!string.IsNullOrEmpty(user.Password) && !passwordVerified)
            {
                return Unauthorized();
            }
        }

        List<Claim> userClaims = [];

        if (!string.IsNullOrEmpty(user.Id) && !string.IsNullOrEmpty(user.Email))
        {
            userClaims.Add(new Claim("Id", user.Id));
            userClaims.Add(new Claim(JwtRegisteredClaimNames.Sub, user.Email));
            userClaims.Add(new Claim(JwtRegisteredClaimNames.Email, user.Email));
            userClaims.Add(new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()));

            if (!string.IsNullOrEmpty(user.AssignedRoles))
            {
                var roles = user.AssignedRoles.Split(",");

                if (roles.Length > 0)
                {
                    foreach (var role in roles)
                    {
                        userClaims.Add(new Claim(ClaimTypes.Role, role));
                    }
                }
            }

            if (string.IsNullOrEmpty(_jwtAuthKey))
            {
                return StatusCode(500, "Auth Key missing from configuration");
            }
            else if (string.IsNullOrEmpty(_jwtIssuer))
            {
                return StatusCode(500, "Auth Issuer missing from configuration");
            }
            else if (string.IsNullOrEmpty(_jwtAudience))
            {
                return StatusCode(500, "Auth Audience missing from configuration");
            }
            else
            {
                // generate tokens
                var token = AuthHelpers.GenerateAuthToken(userClaims, _jwtAuthKey, _jwtIssuer, _jwtAudience, _tokenValidityMinutes);
                var refreshToken = AuthHelpers.GenerateRefreshToken();

                // generate a hashedFingerprint to use for validation of the Refresh token
                string hashedFingerprint = BCrypt.Net.BCrypt.HashPassword(refreshToken);

                // assess token validity
                if (_refreshTokenValiditityDays != -1 && token != null)
                {
                    user.RefreshToken = refreshToken;
                    user.RefreshTokenExpiryTime = DateTime.Now.AddDays(_refreshTokenValiditityDays);

                    // update User record in db
                    await _userService.UpdateAsync(user.Id, user);

                    string tokenString = new JwtSecurityTokenHandler().WriteToken(token);
                    Response.Cookies.Append("X-Access-Token", tokenString, new CookieOptions() { HttpOnly = true, Secure = true, SameSite = SameSiteMode.None, IsEssential = true });
                    Response.Cookies.Append("X-Fingerprint", refreshToken, new CookieOptions() { HttpOnly = true, Secure = true, SameSite = SameSiteMode.None, IsEssential = true });

                    return Ok(new
                    {
                        UserId = user.Id,
                        RefreshToken = hashedFingerprint,
                        Expiration = token.ValidTo
                    });
                }
                else
                {
                    return BadRequest("Failed to generate Refresh Token");
                }
            }
        }
        else
        {
            return BadRequest("Required data is not present on User entity");
        }
    }

    /// <summary>
    /// Refresh an Access Token using a provided Refresh Token, token is persisted to the HttpOnly cookie
    /// </summary>
    /// <param name="tokenModel"></param>
    /// <returns>Refreshed Bearer and Refresh tokens</returns>
    /// <response code="200">Returns User Id, hashed Refresh token and Token expiry date if authentication was successful</response>
    /// <response code="400">Nothing is returned if authentication fails, required values are not provided or fingerprint is missing</response>
    /// <response code="401">Nothing is returned if fingerprint validation fails</response>
    /// <response code="404">Nothing is returned if no User is not found or is in an invalid state</response>
    /// <response code="500">Nothing is returned if the internal configuration is incorrect; see message</response>
    [HttpPost]
    [Route("Refresh")]
    [AllowAnonymous]
    public async Task<IActionResult> Refresh([FromBody] TokenModel tokenModel)
    {
        if (tokenModel == null)
        {
            return BadRequest("Invalid request, token model is null");
        }

        string? tokenCookie = Request.Cookies["X-Access-Token"];
        string? refreshTokenCookie = Request.Cookies["X-Fingerprint"]; // unhashed refresh token

        if (refreshTokenCookie != null && !string.IsNullOrEmpty(refreshTokenCookie))
        {
            string? refreshTokenFingerprint = tokenModel.RefreshTokenFingerprint;

            bool fingerprintsMatch = BCrypt.Net.BCrypt.Verify(refreshTokenCookie, refreshTokenFingerprint);

            if (!fingerprintsMatch)
            {
                return Unauthorized("Fingerprints don't match");
            }
            else if (string.IsNullOrEmpty(_jwtAuthKey))
            {
                return StatusCode(500, "Auth Key missing from configuration");
            }
            else if (string.IsNullOrEmpty(_jwtIssuer))
            {
                return StatusCode(500, "Auth Issuer missing from configuration");
            }
            else if (string.IsNullOrEmpty(_jwtAudience))
            {
                return StatusCode(500, "Auth Audience missing from configuration");
            }
            else
            {
                if (!string.IsNullOrEmpty(tokenModel.PrincipalId))
                {
                    bool userAndPrincipalMatch = false;

                    // get User from database matching provided PrincipalId
                    User? user = await _userService.GetAsync(tokenModel.PrincipalId);

                    if (user == null || string.IsNullOrEmpty(user.Id))
                    {
                        return NotFound("User record state is invalid");
                    }

                    if (string.IsNullOrEmpty(user.RefreshToken) || !user.RefreshToken.Equals(refreshTokenCookie))
                    {
                        return Unauthorized("Refresh token cookie doens't match User record");
                    }

                    // get expired Principal from cookie if available
                    ClaimsPrincipal? expiredPrincipal = null;
                    if (tokenCookie != null && !string.IsNullOrEmpty(tokenCookie))
                    {
                        expiredPrincipal = AuthHelpers.GetPrincipalFromExpiredToken(tokenCookie, _jwtAuthKey);
                    }

                    if (expiredPrincipal != null)
                    {
                        var principalClaimId = expiredPrincipal.Claims.FirstOrDefault(c => c.Type == "Id");
                        if (principalClaimId != null)
                        {
                            userAndPrincipalMatch = principalClaimId.Value == user.Id;
                        }
                    }

                    if (!userAndPrincipalMatch)
                    {
                        return BadRequest("Mismatch in token data");
                    }

                    if (user.RefreshToken != refreshTokenCookie)
                    {
                        user.RefreshToken = null;
                        await _userService.UpdateAsync(user.Id, user);

                        return BadRequest("Invalid refresh token, User's Refresh Token has been revoked, they will need to log in again");
                    }

                    // force revocation of refresh tokens after expiry
                    if (user.RefreshTokenExpiryTime <= DateTime.Now)
                    {
                        user.RefreshToken = null;
                        await _userService.UpdateAsync(user.Id, user);

                        return BadRequest("Expired refresh token, User's Refresh Token has been revoked for rotation, they'll need to log in again");
                    }

                    var newToken = AuthHelpers.GenerateAuthToken(expiredPrincipal.Claims.ToList(), _jwtAuthKey, _jwtIssuer, _jwtAudience, _tokenValidityMinutes);

                    // generate a new hashedFingerprint to use for validation of the Refresh token, to replace the existing cookie
                    string newHashedFingerprint = BCrypt.Net.BCrypt.HashPassword(refreshTokenCookie);

                    string tokenString = new JwtSecurityTokenHandler().WriteToken(newToken);
                    Response.Cookies.Delete("X-Access-Token");
                    Response.Cookies.Append("X-Access-Token", tokenString, new CookieOptions() { HttpOnly = true, Secure = true, SameSite = SameSiteMode.None, IsEssential = true });
                    Response.Cookies.Delete("X-Fingerprint");
                    Response.Cookies.Append("X-Fingerprint", refreshTokenCookie, new CookieOptions() { HttpOnly = true, Secure = true, SameSite = SameSiteMode.None, IsEssential = true });

                    return Ok(new
                    {
                        UserId = user.Id,
                        RefreshToken = newHashedFingerprint,
                        Expiration = newToken.ValidTo
                    });
                }
                else
                {
                    return BadRequest("Tokens or Auth Key invalid");
                }
            }
        }
        else
        {
            return BadRequest("Fingerprint missing");
        }            
    }

    /// <summary>
    /// Revoke Refresh Tokens from a specific User in the database, this is an administrative action
    /// </summary>
    /// <param name="userIdOrEmailAddress"></param>
    /// <returns>Nothing</returns>
    /// <response code="204">Nothing is returned if this action is successful</response>
    /// <response code="400">Nothing is returned if no userIdOrEmailAddress is provided</response>
    /// <response code="404">Nothing is returned if no User record is found or the User record is in a bad state</response>
    [HttpPost]
    [Route("Revoke")]
    [Authorize(Policy = "userAdmin")]
    public async Task<IActionResult> Revoke(string userIdOrEmailAddress)
    {
        if (string.IsNullOrEmpty(userIdOrEmailAddress))
        {
            return BadRequest("User Id or Email Address null or empty");
        }

        User? user = await _userService.GetAsync(userIdOrEmailAddress);

        if (user == null)
        {
            user = await _userService.GetByEmailAsync(userIdOrEmailAddress);

            if (user == null)
            {
                return NotFound("Invalid User Id or Email Address");
            }
        }

        if (string.IsNullOrEmpty(user.Id))
        {
            return NotFound("User record state is invalid");
        }

        user.RefreshToken = null;
        await _userService.UpdateAsync(user.Id, user);

        return NoContent();
    }

    /// <summary>
    /// Revoke Refresh Tokens from all Users in the database, this is an administrative action
    /// </summary>
    /// <returns>Nothing</returns>
    /// <response code="204">Nothing is returned if this action is successful</response>
    /// <response code="404">Nothing is returned if no User records are found</response>
    [HttpPost]
    [Route("RevokeAll")]
    [Authorize(Policy = "userAdmin")]
    public async Task<IActionResult> RevokeAll()
    {
        List<User>? users = await _userService.GetAsync();

        if (users == null || users.Count == 0)
        {
            return NotFound("No User records found");
        }

        users.ForEach(async u =>
        {
            if (!string.IsNullOrEmpty(u.Id))
            {
                u.RefreshToken = null;
                await _userService.UpdateAsync(u.Id, u);
            }
        });

        return NoContent();
    }
}
Auth helper functionality

There’s also additonal helper functionality to allow the AuthController to do its job.

Roles are validated using a RoleHandler pattern, which I learned about from Auth0.com > Core RBAC. I’ve modified it quite a bit to fit my purposes.

First, functionality to validate whether a role is present.

// ./Authorisation/HasRoleRequirement.cs

public class HasRoleRequirement(string issuer, string[] roles) : IAuthorizationRequirement
{
    public string Issuer { get; } = issuer ?? throw new ArgumentNullException(nameof(issuer));
    public string[] Role { get; } = roles ?? throw new ArgumentNullException(nameof(roles));
}

And then a handler to invoke the functionality.

// ./Authorisation/HasRoleHandler.cs

public class HasRoleHandler : AuthorizationHandler<HasRoleRequirement>
{
    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, HasRoleRequirement requirement)
    {
        // Check if UserForRegistration has a Role claim, if not exit
        if (!context.User.HasClaim(c => c.Type.Contains("role") && c.Issuer == requirement.Issuer))
        {
            return Task.CompletedTask;
        }

        // Split the requirement roles if delimited
        List<string>? requirementRoles = [.. requirement.Role];

        // Split the roles string into an array
        List<string>? roles = [];
        if (context.User != null)
        {
            Claim? claim = context.User.FindFirst(c => c.Type.Contains("role") && c.Issuer == requirement.Issuer);
            if (claim != null)
            {
                roles = [.. claim.Value.Split(' ')];
            }

            foreach (var role in requirementRoles)
            {
                if (context.User.IsInRole(role))
                {
                    context.Succeed(requirement);

                    break;
                }
            }
        }

        return Task.CompletedTask;
    }
}

Remember this snippet from the entry point detailed above? These Authorization Policies filter the incoming requests and ensure that a particular User can only access a Controller Action if they have the appropriate Role.

// ./Program.cs (rest elided for brevity)

// configure RBAC roles for RoleHandler
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("admin", policy => policy.Requirements.Add(new HasRoleRequirement(jwtIssuer, ["administrator"])));
    options.AddPolicy("userAdmin", policy => policy.Requirements.Add(new HasRoleRequirement(jwtIssuer, ["administrator", "users.read", "users.write", "userSettings.read", "userSettings.write"])));
    options.AddPolicy("appUser", policy => policy.Requirements.Add(new HasRoleRequirement(jwtIssuer, ["appUser", "administrator", "users.read", "users.write", "userSettings.read", "userSettings.write"])));
});

builder.Services.AddSingleton<IAuthorizationHandler, HasRoleHandler>();

Which is controlled by way of the Authorize Attribute; e.g. Only an appUser can access the Delete User Action.

// ./UserController.cs > Delete Action

[Authorize(Policy = "appUser")]
public async Task<IActionResult> Delete([FromBody] DeleteUserModel deleteUserModel) { /* implementation elided */ }

Next up are more helper methods. These provide role validation functionality for the User in the HttpContext, Access token generation, retrieval of Principal from expired Access tokens and Refresh token generation.

// ./Authorisation/AuthHelpers.cs

public class AuthHelpers
{
    public static (bool validateAppUserOnly, string loggedInUserId) ValidateAppUserRole(HttpContext context)
    {
        bool appUserOnly = false;
        string loggedInUserId = string.Empty;
        var loggedInUserClaim = context.User.Claims.FirstOrDefault(c => c.Type == "Id");

        if (loggedInUserClaim != null)
        {
            loggedInUserId = loggedInUserClaim.Value;
        }

        if (context.User.IsInRole("appUser") && !string.IsNullOrEmpty(loggedInUserId))
        {
            appUserOnly = true;
        }

        return (appUserOnly, loggedInUserId);
    }

    public static JwtSecurityToken GenerateAuthToken(List<Claim> userClaims, string jwtAuthKey, string jwtIssuer, string jwtAudience, int expiryOffsetMinutes)
    {
        var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtAuthKey));
        var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);

        var securityToken = new JwtSecurityToken(
                issuer: jwtIssuer,
                audience: jwtAudience,
                expires: DateTime.Now.AddMinutes(expiryOffsetMinutes),
                signingCredentials: credentials,
                claims: userClaims
            );

        return securityToken;
    }

    public static ClaimsPrincipal? GetPrincipalFromExpiredToken(string token, string jwtAuthKey)
    {
        var tokenValidationParameters = new TokenValidationParameters
        {
            ValidateAudience = false,
            ValidateIssuer = false,
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtAuthKey)),
            ValidateLifetime = false
        };

        var tokenHandler = new JwtSecurityTokenHandler();
        var claimsPrincipal = tokenHandler.ValidateToken(token, tokenValidationParameters, out SecurityToken securityToken);
        
        if (securityToken is not JwtSecurityToken jwtSecurityToken || !jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.InvariantCultureIgnoreCase))
        {
            throw new SecurityTokenException("Invalid token");
        }

        return claimsPrincipal;
    }

    public static string GenerateRefreshToken()
    {
        var randomNumber = new byte[64];
        using var rng = RandomNumberGenerator.Create();
        rng.GetBytes(randomNumber);
        return Convert.ToBase64String(randomNumber);
    }
}

UserController

The UserController is a simple implementation providing the functionality that you’d expect, though note that the RegisterNewUser Action creates both the User and UserSettings objects in the database.

The Update and Delete Actions also manipulate both.

// ./Controllers/UserController.cs

/// <summary>
/// User Controller
/// </summary>
[ApiController]
[Route("api/[Controller]")]
[Produces("application/json")]
public class UserController(UserService userService, UserSettingsService userSettingsService) : ControllerBase
{
    private readonly UserService _userService = userService;
    private readonly UserSettingsService _userSettingsService = userSettingsService;

    /// <summary>
    /// Get all User objects
    /// </summary>
    /// <returns>All User objects</returns>
    /// <response code="200">Returns all User Settings objects</response>
    /// <response code="204">Returns no content if no objects can be found</response>
    /// <response code="400">Nothing is returned if the objects are null</response>
    /// <response code="401">Nothing is returned if the user is unauthorised</response>
    /// <response code="403">Nothing is returned if the user is Forbidden</response>
    [HttpGet]
    [ProducesResponseType(StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    [ProducesResponseType(StatusCodes.Status401Unauthorized)]
    [Authorize(Policy = "userAdmin")]
    public async Task<List<User>> Get()
    {
        var users = await _userService.GetAsync();
        // validation elided for brevity
        return users;
    }

    /// <summary>
    /// Get a User object by Id string
    /// </summary>
    /// <param name="fetchUserModel"></param>
    /// <returns>The specified User object</returns>
    /// <response code="200">Returns the User object</response>
    /// <response code="401">Nothing is returned if the user is unauthorised or trying to access a record they're not allowed to access</response>
    /// <response code="404">Nothing is returned if the object is null</response>
    /// <response code="500">Nothing is returned if there is no User in the context (should be impossible)</response>
    [HttpPost]
    [Route("FetchById")]
    [ProducesResponseType(StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status401Unauthorized)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    [ProducesResponseType(StatusCodes.Status500InternalServerError)]
    [Authorize(Policy = "appUser")]
    public async Task<ActionResult<User>> FetchById([FromBody] FetchUserModel fetchUserModel)
    {
        if (HttpContext.User == null)
        {
            return StatusCode(500, "No User in Context");
        }

        (bool retrieveAppUserOnly, string loggedInUserId) = AuthHelpers.ValidateAppUserRole(HttpContext);

        if (fetchUserModel != null && !string.IsNullOrEmpty(fetchUserModel.Id))
        {
            var user = await _userService.GetAsync(fetchUserModel.Id);

            if (user is null)
            {
                return NotFound();
            }
            else if (retrieveAppUserOnly && !loggedInUserId.Equals(user.Id))
            {
                return Unauthorized("Your assigned role means that you cannot search for other Users' data");
            }
            else
            {
                user.Password = string.Empty;
                user.RefreshToken = string.Empty;
                user.RefreshTokenExpiryTime = null;
                return Ok(user);
            }
        }
        else
        {
            return BadRequest("User Id malformed or not supplied");
        }
    }

    /// <summary>
    /// Create a new User and associated Settings
    /// </summary>
    /// <param name="userRegistrationModel">UserRegistrationModel DTO excluding Id fields</param>
    /// <returns>New User object</returns>
    /// <response code="201">Returns the newly created User object</response>
    /// <response code="400">Nothing is returned if the object is null</response>
    [HttpPost]
    [ProducesResponseType(StatusCodes.Status201Created)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    [Route("RegisterNewUser")]
    [AllowAnonymous]
    public async Task<IActionResult> RegisterNewUser([FromBody] UserRegistrationModel userRegistrationModel)
    {
        if (userRegistrationModel.User != null && userRegistrationModel.UserSettings != null)
        {
            string passwordHash = string.Empty;
            if (string.IsNullOrEmpty(userRegistrationModel.User.Password)) // TODO flesh out password complexity requirements
            {
                return base.BadRequest("Password was not provided or did not meet complexity requirements");
            }
            else
            {
                passwordHash = BCrypt.Net.BCrypt.HashPassword((string)userRegistrationModel.User.Password);

                // create a new UserForRegistration object
                User user = new()
                {
                    FirstName = userRegistrationModel.User.FirstName,
                    LastName = userRegistrationModel.User.LastName,
                    UserName = userRegistrationModel.User.UserName,
                    Email = userRegistrationModel.User.Email,
                    Password = passwordHash,
                    AssignedRoles = "appUser"
                };

                await _userService.CreateAsync(user);

                // add the Settings object for this UserForRegistration
                UserSettings userSettings = new ()
                {
                    UserId = user.Id,
                    CountryName = userRegistrationModel.UserSettings.CountryName,
                    CurrencyUnit = (CurrencyUnit)Convert.ToInt64(userRegistrationModel.UserSettings.CurrencyUnit),
                    CapacityUnit = (CapacityUnit)Convert.ToInt64(userRegistrationModel.UserSettings.CapacityUnit),
                    DistanceUnit = (DistanceUnit)Convert.ToInt64(userRegistrationModel.UserSettings.DistanceUnit),
                    BaseDiscount = userRegistrationModel.UserSettings.BaseDiscount,
                    MinimumSpendForDiscount = userRegistrationModel.UserSettings.MinimumSpendForDiscount,
                    LastPricePerCapacityUnit = userRegistrationModel.UserSettings.LastPricePerCapacityUnit,
                    AccruedDiscount = userRegistrationModel.UserSettings.AccruedDiscount,
                    RoundTo = userRegistrationModel.UserSettings.RoundTo
                };

                await _userSettingsService.CreateForUserAsync(userSettings);

                return base.CreatedAtAction(nameof(Get), new { id = user.Id }, $"{user.Email}");
            }
        }

        return BadRequest("User to malformed or not supplied");
    }

    /// <summary>
    /// Update an existing User
    /// </summary>
    /// <param name="userUpdateModel"></param>
    /// <returns>Updated User object</returns>
    /// <response code="200">Returns the updated User object</response>
    /// <response code="401">Nothing is returned if the user is unauthorised</response>
    /// <response code="404">Returns 404 if a User object couldn't be found for the User</response>
    /// <response code="500">Nothing is returned if there is no User in the context (should be impossible)</response>
    [HttpPut]
    [ProducesResponseType(StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status401Unauthorized)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    [ProducesResponseType(StatusCodes.Status500InternalServerError)]
    [Authorize(Policy = "appUser")]
    public async Task<IActionResult> Update([FromBody] UserUpdateModel userUpdateModel)
    {
        if (HttpContext.User == null)
        {
            return StatusCode(500, "No User in Context");
        }

        (bool retrieveAppUserOnly, string loggedInUserId) = AuthHelpers.ValidateAppUserRole(HttpContext);

        if (userUpdateModel != null && !string.IsNullOrEmpty(userUpdateModel.Id) && userUpdateModel.UpdatedUser != null && userUpdateModel.UpdatedUserSettings != null)
        {
            User? user = await _userService.GetAsync(userUpdateModel.Id);
            UserSettings? userSettings = await _userSettingsService.GetForUserAsync(userUpdateModel.Id, true);

            if (user == null || userSettings == null)
            {
                return NotFound();
            }
            else if (retrieveAppUserOnly && !loggedInUserId.Equals(user.Id))
            {
                return Unauthorized("Your assigned role means that you cannot update other Users' data");
            }
            else
            {
                userUpdateModel.UpdatedUser.Id = user.Id;
                userUpdateModel.UpdatedUser.Password = user.Password;
                userUpdateModel.UpdatedUser.AssignedRoles = user.AssignedRoles;
                userUpdateModel.UpdatedUser.RefreshToken = user.RefreshToken;
                userUpdateModel.UpdatedUser.RefreshTokenExpiryTime = user.RefreshTokenExpiryTime;

                await _userService.UpdateAsync(userUpdateModel.Id, userUpdateModel.UpdatedUser);
                List<string> updatedUserFields = Model.User.ValidateFieldUpdates(user, userUpdateModel.UpdatedUser);

                userUpdateModel.UpdatedUserSettings.Id = userSettings.Id;
                userUpdateModel.UpdatedUserSettings.UserId = userSettings.UserId;

                await _userSettingsService.UpdateForUserAsync(userUpdateModel.Id, userUpdateModel.UpdatedUserSettings);
                List<string> updatedUserSettingsFields = UserSettings.ValidateFieldUpdates(userSettings, userUpdateModel.UpdatedUserSettings);

                userUpdateModel.UpdatedUser.Password = string.Empty;
                userUpdateModel.UpdatedUser.RefreshToken = string.Empty;
                userUpdateModel.UpdatedUser.RefreshTokenExpiryTime = null;

                return Ok(new
                {
                    User = userUpdateModel.UpdatedUser,
                    UpdatedUserFields = updatedUserFields.ToArray(),
                    UserSettings = userUpdateModel.UpdatedUserSettings,
                    UpdatedUserSettingsFields = updatedUserSettingsFields
                });
            }
        }
        else
        {
            return BadRequest("User or UserSettings to update malformed or not provided");
        }            
    }

    /// <summary>
    /// Change the Password for the specified User by userId
    /// </summary>
    /// <param name="passwordChangeModel"></param>
    /// <response code="200">Returns a 200 response without payload if the password was successfully updated</response>
    /// <response code="400">Nothing is returned if required values not provided</response>
    /// <response code="401">Nothing is returned if the Password for the User doesn't match what they've provided</response>
    /// <response code="404">Nothing is returned if User is not found</response>
    /// <response code="500">Nothing is returned if there is no User in the context (should be impossible)</response>
    [HttpPost]
    [Route("ChangePassword")]
    [Authorize(Policy = "appUser")]
    public async Task<IActionResult> ChangePassword([FromBody] PasswordChangeModel passwordChangeModel)
    {
        if (HttpContext.User == null)
        {
            return StatusCode(500, "No User in Context");
        }

        (bool retrieveAppUserOnly, string loggedInUserId) = AuthHelpers.ValidateAppUserRole(HttpContext);

        if (string.IsNullOrEmpty(passwordChangeModel.UserId) || string.IsNullOrEmpty(passwordChangeModel.OldPassword) || string.IsNullOrEmpty(passwordChangeModel.NewPassword))
        {
            return BadRequest();
        }

        var user = await _userService.GetAsync(passwordChangeModel.UserId);

        if (user is null)
        {
            return NotFound();
        }
        else
        {
            bool passwordVerified = BCrypt.Net.BCrypt.Verify(passwordChangeModel.OldPassword, user.Password);
            if (!string.IsNullOrEmpty(user.Password) && !passwordVerified)
            {
                return Unauthorized();
            }
            else if (string.IsNullOrEmpty(user.Id))
            {
                return BadRequest();
            }
            else if (retrieveAppUserOnly && !loggedInUserId.Equals(user.Id))
            {
                return Unauthorized("Your assigned role means that you cannot update other Users' data");
            }
            else
            {
                string passwordHash = BCrypt.Net.BCrypt.HashPassword(passwordChangeModel.NewPassword);
                user.Password = passwordHash;

                await _userService.UpdateAsync(user.Id, user);

                user.Password = string.Empty;
                user.RefreshToken = string.Empty;
                user.RefreshTokenExpiryTime = null;
                string[] updatedFields = ["Password"];

                return Ok(new
                {
                    User = user,
                    UpdatedUserFields = updatedFields
                });
            }
        }
    }

    /// <summary>
    /// Delete an existing User, including their Settings
    /// </summary>
    /// <param name="deleteUserModel"></param>
    /// <returns>No Content</returns>
    /// <response code="204">Returns nothing upon User and Settings object deletion</response>
    /// <response code="401">Nothing is returned if the user is unauthorised</response>
    /// <response code="404">Returns 404 if a User Settings object couldn't be found for the User</response>
    /// <response code="500">Nothing is returned if there is no User in the context (should be impossible)</response>
    [HttpDelete]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    [ProducesResponseType(StatusCodes.Status401Unauthorized)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    [ProducesResponseType(StatusCodes.Status500InternalServerError)]
    [Authorize(Policy = "appUser")]
    public async Task<IActionResult> Delete([FromBody] DeleteUserModel deleteUserModel)
    {
        if (HttpContext.User == null)
        {
            return StatusCode(500, "No User in Context");
        }

        (bool retrieveAppUserOnly, string loggedInUserId) = AuthHelpers.ValidateAppUserRole(HttpContext);

        if (deleteUserModel == null || deleteUserModel.Id == null)
        {
            return BadRequest("DeleteUserModel malformed or missing required data");
        }

        var user = await _userService.GetAsync(deleteUserModel.Id);

        if (user is not null && user.Id is not null)
        {
            if (retrieveAppUserOnly && !loggedInUserId.Equals(user.Id))
            {
                return Unauthorized("Your assigned role means that you cannot delete other Users' data");
            }

            var userSettings = await _userSettingsService.GetForUserAsync(user.Id, true);

            if (userSettings is null)
            {
                return NotFound();
            }

            // attempt to delete this User's Settings object
            await _userSettingsService.RemoveForUserAsync(deleteUserModel.Id);

            // attempt to delete this User
            await _userService.RemoveAsync(deleteUserModel.Id);
        }
        else
        {
            return NotFound();
        }            

        return NoContent();
    }
}

UserSettingsController

The UserSettingsController is a simple implementation providing the functionality that you’d expect, though note that only retrieval and update functionality is available as creation and deletion functionality is provided by the UserController.

// ./Controllers/UserSettingsController.cs

/// <summary>
/// User Settings Controller
/// </summary>
[ApiController]
[Route("api/[Controller]")]
public class UserSettingsController(UserSettingsService userSettingsService) : Controller
{
    private readonly UserSettingsService _userSettingsService = userSettingsService;

    /// <summary>
    /// Get all Users Settings objects
    /// </summary>
    /// <returns>All User Settings objects</returns>
    /// <response code="200">Returns all User Settings objects</response>
    /// <response code="204">Returns no content if no objects can be found</response>
    /// <response code="400">Nothing is returned if the objects are null</response>
    /// <response code="401">Nothing is returned if the user is unauthorised</response>
    [HttpGet]
    [ProducesResponseType(StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    [ProducesResponseType(StatusCodes.Status401Unauthorized)]
    [Authorize(Policy = "userAdmin")]
    public async Task<List<UserSettings>> Get() => await _userSettingsService.GetAsync();

    /// <summary>
    /// Get Settings object for the specified User
    /// </summary>
    /// <param name="fetchUserSettingsModel"></param>
    /// <returns>The Settings object for the specified User</returns>
    /// <response code="200">Returns the User Settings object</response>
    /// <response code="401">Nothing is returned if the user is unauthorised</response>
    /// <response code="404">Nothing is returned if the object is null</response>
    /// <response code="500">Nothing is returned if there is no User in the context (should be impossible)</response>
    [HttpPost]
    [Route("FetchById")]
    [ProducesResponseType(StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status401Unauthorized)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    [ProducesResponseType(StatusCodes.Status500InternalServerError)]
    [Authorize(Policy = "appUser")]
    public async Task<ActionResult<UserSettings>> FetchById([FromBody] FetchUserSettingsModel fetchUserSettingsModel)
    {
        if (HttpContext.User == null)
        {
            return StatusCode(500, "No User in Context");
        }

        if (fetchUserSettingsModel == null || fetchUserSettingsModel.Id == null)
        {
            return BadRequest("FetchUserSettingsModel malformed or missing required data");
        }

        (bool retrieveAppUserOnly, string loggedInUserId) = AuthHelpers.ValidateAppUserRole(HttpContext);

        var userSettings = await _userSettingsService.GetForUserAsync(fetchUserSettingsModel.Id, fetchUserSettingsModel.UseUserId);

        if (userSettings is null)
        {
            return NotFound();
        }
        else if (retrieveAppUserOnly && !loggedInUserId.Equals(userSettings.UserId))
        {
            return Unauthorized("Your assigned role means that you cannot search for other Users' data");
        }
        else
        {
            return Ok(userSettings);
        }
    }

    /// <summary>
    /// Update an existing User Settings object for the specific User
    /// </summary>
    /// <param name="updatedUserSettingsModel"></param>
    /// <returns>Updated User Settings object</returns>
    /// <response code="200">Returns the updated User Settings object</response>
    /// <response code="401">Nothing is returned if the user is unauthorised</response>
    /// <response code="404">Returns 404 if a User Settings object couldn't be found for the User</response>
    /// <response code="500">Nothing is returned if there is no User in the context (should be impossible)</response>
    [HttpPut]
    [ProducesResponseType(StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status401Unauthorized)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    [ProducesResponseType(StatusCodes.Status500InternalServerError)]
    [Authorize(Policy = "appUser")]
    public async Task<IActionResult> Update([FromBody] UpdateUserSettingsModel updatedUserSettingsModel)
    {
        if (HttpContext.User == null)
        {
            return StatusCode(500, "No User in Context");
        }

        if (updatedUserSettingsModel == null || updatedUserSettingsModel.Id == null || updatedUserSettingsModel.UpdatedUserSettings == null)
        {
            return BadRequest("UpdateUserSettingsModel malformed or missing required data");
        }

        (bool retrieveAppUserOnly, string loggedInUserId) = AuthHelpers.ValidateAppUserRole(HttpContext);

        var userSettings = await _userSettingsService.GetForUserAsync(updatedUserSettingsModel.Id, updatedUserSettingsModel.UseUserId);

        if (userSettings is null)
        {
            return NotFound();
        }
        else if (retrieveAppUserOnly && !loggedInUserId.Equals(userSettings.UserId))
        {
            return Unauthorized("Your assigned role means that you cannot update other Users' data");
        }
        else
        {
            updatedUserSettingsModel.UpdatedUserSettings.Id = userSettings.Id;
            updatedUserSettingsModel.UpdatedUserSettings.UserId = userSettings.UserId;

            await _userSettingsService.UpdateForUserAsync(updatedUserSettingsModel.Id, updatedUserSettingsModel.UpdatedUserSettings);

            return Ok(updatedUserSettingsModel.UpdatedUserSettings);
        }
    }
}

So where does that leave us?

That’s an overview of some of the configuration and how I’ve structured my Web API solution for my passion project. This wasn’t meant to be a tutorial, however perhaps if you end up here and you’re having problems, drop me a comment and I’ll do my best to help.

PetrolIQ Swagger UI

What’s next?

I’ve not touched on the structure of the React App that is utilising this back end, suffice to say, I’ve already implemented the following.

  • User registration.
  • Access token refresh.
  • Password change.
  • Profile update.
  • Settings update.

All nice and secure; sets the stage for the fun stuff to start.

PetrolIQ front end app

There will be additional blog posts as this shapes up and I get close to implementing some actually useful functionality.

Thanks for reading, and stay tuned.