Click here to Skip to main content
15,394,707 members
Articles / Programming Languages / C#
Article
Posted 6 Feb 2022

Tagged as

Stats

21K views
522 downloads
52 bookmarked

A Simple Web API Account Manager in .NET 6

Rate me:
Please Sign up or sign in to vote.
4.94/5 (19 votes)
6 Feb 2022CPOL6 min read
User account management is fundamental to any web API
This article is a concise discussion and implementation of the basics of user account management. This article is not intended to discuss user roles and permissions. The code here is essentially a template and needs to be modified according to your use case requirements.

Introduction

User account management is fundamental to any web API. This article is a concise discussion and implementation of the basics:

  1. Creating / deleting user accounts
  2. Login/Logout
  3. Token authentication
  4. Token refresh
  5. Changing username/password

This article is not intended to discuss user roles and permissions - all I'm presenting here is how an example web API the management of user accounts by and administrator or by the user themselves. The code here is essentially a template and needs to be modified according to your use case requirements.

There is no front-end to go along with this article, instead, I implemented 12 integration tests to verify the functionality of the Account Manager:

The integration tests are written in a fluent manner and utilize Fluent Assertions. A discussion of writing fluent integration tests and using Fluent Assertions can by found in my article, Fluent Web API Integration Testing.

The demo application also uses Fluent Migrator to create a sample database. A discussion of using Fluent Migrator can be found in my article, A FluentMigrator Controller and Service for .NET Core.

The code is implemented in the .NET 6 Framework.

Setting up the User Table

The User table is created with a migration and a "SysAdmin" user is inserted, as we need at least one user to authenticate with for creating other users.

The migration can be run from the browser with the URL http://localhost:5000/migrator/migrateup.

C#
using FluentMigrator;

namespace Clifton
{
  [Migration(202201011202)]
  public class _202201011202_CreateTables : Migration
  {
    public override void Up()
    {
      Create.Table("User")
        .WithColumn("Id").AsInt32().PrimaryKey().Identity().NotNullable()
        .WithColumn("Username").AsString().NotNullable()
        .WithColumn("Password").AsString().NotNullable()
        .WithColumn("Salt").AsString().Nullable()
        .WithColumn("AccessToken").AsString().Nullable()
        .WithColumn("RefreshToken").AsString().Nullable()
        .WithColumn("IsSysAdmin").AsBoolean().NotNullable()
        .WithColumn("LastLogin").AsDateTime().Nullable()
        .WithColumn("ExpiresIn").AsInt32().Nullable()
        .WithColumn("ExpiresOn").AsInt64().Nullable()
        .WithColumn("Deleted").AsBoolean().NotNullable();

      var salt = Hasher.GenerateSalt();
      var pwd = "SysAdmin";
      var hashedPassword = Hasher.HashPassword(salt, pwd);

      Insert.IntoTable("User").Row(
        new 
        { 
          Username = "SysAdmin", 
          Password = hashedPassword, 
          Salt = salt, 
          IsSysAdmin = true, 
          Deleted = false 
        });
    }

    public override void Down()
    {
      Delete.Table("User");
    }
  }
}

The Account Controller

The controller implements the following endpoints.

Login

Anyone can attempt to log in. This POST endpoint does not require authentication.

C#
[AllowAnonymous]
[HttpPost("Login")]
public ActionResult Login(AccountRequest req)
{
  var resp = svc.Login(req);
  var ret = resp == null ? (ActionResult)Unauthorized("User not found.") : Ok(resp);

  return ret;
}

Refresh Login

Using the refresh token, the user can log back in with a valid access token.

C#
[AllowAnonymous]
[HttpPost("Refresh/{refreshToken}")]
public ActionResult Refresh(string refreshToken)
{
  var resp = svc.Refresh(refreshToken);
  var ret = resp == null ? (ActionResult)Unauthorized("User not found.") : Ok(resp);

  return ret;
}

Logout

Only an authenticated user can log out.

C#
[Authorize]
[HttpPost("Logout")]
public ActionResult Logout()
{
  var token = GetToken();
  svc.Logout(token);

  return Ok();
}

Create an Account

Creating an account requires an authenticated user. As user roles/permissions are beyond the scope of this article, in this implementation, any authenticated user can create accounts.

Accounts must be unique by username. Technically, it would be possible to allow accounts unique by username and password, but this adds complexity to testing for uniqueness which I did not implement.

C#
[Authorize]
[HttpPost()]
public ActionResult CreateAccount(AccountRequest req)
{
  ActionResult ret;

  var res = svc.CreateAccount(req);

  if (!res.ok)
  {
    ret = BadRequest($"Username {req.Username} already exists.");
  }
  else
  {
    ret = Ok(new { Id = res.id });
  }

  return ret;
}

Deleting an Account

Only the user can delete their own account. The ability for an admin to delete other people's accounts is not implemented. Again, this should be implemented with roles and permission.

C#
/// <summary>
/// A user can only delete their own account. 
/// This logs out the user.
/// </summary>
[Authorize]
[HttpDelete()]
public ActionResult DeleteAccount()
{
  ActionResult ret = Ok();

  var token = GetToken();
  svc.DeleteAccount(token);

  return ret;
}

Changing Username and/or Password

Only the user can change their own username and/or password. They cannot change their username to an existing user.

C#
/// <summary>
/// A user can only change their own username and/or password.
/// This logs out the user.
/// </summary>
[Authorize]
[HttpPatch()]
public ActionResult ChangeUsernameAndPassword(AccountRequest req)
{
  ActionResult ret;

  var token = GetToken();
  bool ok = svc.ChangeUsernameAndPassword(token, req);
  ret = ok ? Ok() : BadRequest($"Username {req.Username} already exists.");

  return ret;
}

Expire Token and Expire Refresh Token Test Endpoints

Two endpoints are implemented for integration testing to force the expiration of the token and the refresh token:

C#
 // ---- for integration tests ----
#if DEBUG
[Authorize]
[HttpPost("expireToken")]
public ActionResult ExpireToken()
{
  var token = GetToken();
  svc.ExpireToken(token);

  return Ok();
}

[Authorize]
[HttpPost("expireRefreshToken")]
public ActionResult ExpireRefreshToken()
{
  var token = GetToken();
  svc.ExpireRefreshToken(token);

  return Ok();
}
#endif

The User Model

The User class implementation implements the methods for setting the various fields as part of the login and logout process:

C#
using System.ComponentModel.DataAnnotations;

namespace Clifton
{
  public class User
  {
    [Key]
    public int Id { get; set; }

    public string UserName { get; set; }
    public string Password { get; set; }
    public string Salt { get; set; }
    public string AccessToken { get; set; }
    public string RefreshToken { get; set; }
    public bool IsSysAdmin { get; set; }
    public DateTime? LastLogin { get; set; }
    public int? ExpiresIn { get; set; }
    public long? ExpiresOn { get; set; }
    public bool Deleted { get; set; }

    public void Login(long ts)
    {
      AccessToken = Guid.NewGuid().ToString();
      RefreshToken = Guid.NewGuid().ToString();
      ExpiresIn = Constants.ONE_DAY_IN_SECONDS;
      ExpiresOn = ts + ExpiresIn;
      LastLogin = DateTime.Now;
    }

    public void Logout()
    {
      AccessToken = null;
      RefreshToken = null;
      ExpiresIn = null;
      ExpiresOn = null;
    }
  }
}

The Account Service

The account service implements the behavior required by the controller endpoints.

Login

Here, on a successful match of the hashed password, the access token, refresh token, and expiring values are returned. See my article, A Third Incarnation of DiponRoy's Simple Model/Entity Mapper in C#, for a discussion of the Mapper function.

C#
public LoginResponse Login(AccountRequest req)
{
  LoginResponse response = null;

  var users = context.User.Where
              (u => u.UserName == req.Username && u.Deleted == false).ToList();

  var user = context.User
    .Where(u => u.UserName == req.Username && u.Deleted == false)
    .ToList() // Because Hasher would otherwise be evaluated in the generated SQL expression.
    .SingleOrDefault(u => Hasher.HashPassword(u.Salt, req.Password) == u.Password);

  if (user != null)
  {
    var ts = GetEpoch();
    user.Login(ts);
    context.SaveChanges();
    response = user.CreateMapped<LoginResponse>();
  }

  return response;
}

Refresh Login

This is a similar process to login but uses the refresh token:

C#
public LoginResponse Refresh(string refreshToken)
{
  LoginResponse response = null;

  var user = context.User
    .Where(u => u.RefreshToken == refreshToken && u.Deleted == false).SingleOrDefault();

  if (user != null)
  {
    var ts = GetEpoch();

    // Refresh token expires 90 days after when user logged in, 
    // thus ExpiresOn + (90 - 1) days
    if (user.ExpiresOn + (Constants.REFRESH_VALID_DAYS - 1) * 
                          Constants.ONE_DAY_IN_SECONDS > ts)
    {
      user.Login(ts);
      context.SaveChanges();
      response = user.CreateMapped<LoginResponse>();
    }
  }

  return response;
}

Logout

Remember that by the time the service gets called, the user has been authenticated to perform the logout, so we know the User record is valid.

C#
public void Logout(string token)
{
  var user = context.User.Single(u => u.AccessToken == token);
  user.Logout();
  context.SaveChanges();
}

Create An Account

As mentioned earlier, creating an account should either be handled by an administrator or, for general public consumption, the implementation should include some kind of two factor authentication. Given that this is beyond the scope of this article, the implementation is straight-forward here:

C#
public (bool ok, int id) CreateAccount(AccountRequest req)
{
  bool ok = false;
  int id = -1;

  var existingUsers = context.User.Where(u => u.UserName == req.Username && !u.Deleted);

  if (existingUsers.Count() == 0)
  {
    var salt = Hasher.GenerateSalt();
    var hashedPassword = Hasher.HashPassword(salt, req.Password);
    var user = new User() { UserName = req.Username, Password = hashedPassword, Salt = salt };
    context.User.Add(user);
    context.SaveChanges();
    ok = true;
    id = user.Id;
  }

  return (ok, id);
}

Delete An Account

Only the user can delete their own account, and having been authenticated, we know the User record exists.

C#
public void DeleteAccount(string token)
{
  var user = context.User.Single(u => u.AccessToken == token);
  user.Logout();
  user.Deleted = true;
  context.SaveChanges();
}

Change Username and/or Password

Here, the authenticated user can change their username to a username that does not already exist, and/or change their password. When the user changes their username and/or password, they must log back in.

C#
public bool ChangeUsernameAndPassword(string token, AccountRequest req)
{
  bool ok = false;
  var existingUsers = context.User.Where(u => u.UserName == req.Username && !u.Deleted);

  if (existingUsers.Count() == 0 || existingUsers.First().UserName == req.Username)
  {
    var user = context.User.Single(u => u.AccessToken == token);
    user.Logout();
    user.Salt = Hasher.GenerateSalt();
    user.UserName = req.Username ?? user.UserName;
    user.Password = Hasher.HashPassword(user.Salt, req.Password);
    context.SaveChanges();
    ok = true;
  }

  return ok;
}

Expire Tokens

Two service methods are used to support integration testing:

C#
public void ExpireToken(string token)
{
  var ts = GetEpoch();
  var user = context.User.SingleOrDefault(u => u.AccessToken == token);
  user.ExpiresOn = ts - Constants.ONE_DAY_IN_SECONDS;
  context.SaveChanges();
}

public void ExpireRefreshToken(string token)
{
  var ts = GetEpoch();
  var user = context.User.SingleOrDefault(u => u.AccessToken == token);
  user.ExpiresOn = ts - Constants.REFRESH_VALID_DAYS * Constants.ONE_DAY_IN_SECONDS;
  context.SaveChanges();
}

Authenticating the User

The service also provides a method for the Authentication service to use to verify that the user's token is both valid and still current:

C#
public bool VerifyAccount(string token)
{
  var user = context.User.Where(u => u.AccessToken == token).SingleOrDefault();
  var ts = GetEpoch();
  bool ok = (user?.ExpiresOn ?? 0) > ts;

  return ok;
}

The Authentication Service

This is a straight-forward implementation that determines whether the token in the header is for a valid user and is current. The code here is just the HandleAuthenticateAsync method of the TokenAuthenticationService class.

C#
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
  Task<AuthenticateResult> result = 
                   Task.FromResult(AuthenticateResult.Fail("Not authorized."));

  // Authentication confirms that users are who they say they are.
  // Authorization gives those users permission to access a resource.

  if (Request.Headers.ContainsKey(Constants.AUTHORIZATION))
  {
    var token = Request.Headers[Constants.AUTHORIZATION][0].RightOf
                               (Constants.TOKEN_PREFIX).Trim();
    bool verified = acctSvc.VerifyAccount(token);

    if (verified)
    {
      var claims = new[]
      {
        new Claim("token", token),
      };

      // Generate claimsIdentity on the name of the class:
      var claimsIdentity = new ClaimsIdentity(claims, nameof(TokenAuthenticationService));

      // Generate AuthenticationTicket from the Identity
      // and current authentication scheme.
      var ticket = new AuthenticationTicket(new ClaimsPrincipal(claimsIdentity), Scheme.Name);

      result = Task.FromResult(AuthenticateResult.Success(ticket));
    }
  }

  return result;
}

As can be inferred from the comments, authorization is not implemented in this example.

Integration Tests

The integration tests rely on an extension method for calling the login endpoint:

C#
public static WorkflowPacket Login
(this WorkflowPacket wp, string username = "SysAdmin", string password = "SysAdmin")
{
  string token = null;

  wp
    .Post<LoginResponse>("account/login", new { username, password })
    .AndOk()
    .Then(wp => token = wp.GetObject<LoginResponse>().AccessToken)
    .UseHeader("Authorization", $"Bearer {token}");

    return wp;
}

Please refer to Fluent Web API Integration Testing to understand better how these tests are written. These are not unit tests. They are integration tests, meaning that they call the web API endpoints.

The Setup Class and Clearing Tables for each Integration Test

The Setup base class is where the URL and database connection is configured and where tables, in this case just the User table, is cleared, except for the admin account.

C#
public class Setup
{
  protected string URL = "http://localhost:5000";
  private string connectionString = 
          "Server=localhost;Database=Test;Integrated Security=True;";

  public void ClearAllTables()
  {
    using (var conn = new SqlConnection(connectionString))
    {
      conn.Execute("delete from [User] where IsSysAdmin = 0");
    }
  }
}

All tests are derived from the Setup base class:

C#
[TestClass]
public class AccountTests : Setup
...

A Basic Admin Login Test

We want to verify that we can log in with the SysAdmin account created in the migration:

C#
[TestMethod]
public void SysAdminLoginTest()
{
  ClearAllTables();

  new WorkflowPacket(URL)
    .Login()
    .AndOk()
    .IShouldSee<LoginResponse>(r => r.AccessToken.Should().NotBeNull());
}

Testing a Bad Account

Here's a simple test for a user that is not known to the account manager:

C#
[TestMethod]
public void BadLoginTest()
{
  ClearAllTables();

  new WorkflowPacket(URL)
    .Post<LoginResponse>("account/login", new { Username = "baad", Password = "f00d" })
    .AndUnauthorized();
}

Changing the Password Test

In this test:

  1. The admin creates a new user account.
  2. We verify that we can log in with that account.
  3. Then we change "our" password and verify that we can't login with the old password.
  4. Then we verify we can log in with the new password.
C#
[TestMethod]
public void ChangePasswordOnlyTest()
{
  ClearAllTables();

  new WorkflowPacket(URL)
    .Login()
    .Post("account", new { Username = "Marc", Password = "fizbin" })
    .AndOk()
    .Login("Marc", "fizbin")
    .AndOk()
    .IShouldSee<LoginResponse>(r => r.AccessToken.Should().NotBeNull())
    .Patch("account", new { Password = "texasHoldem" })
    .AndOk()
    .Post<LoginResponse>("account/login", new { Username = "Marc", Password = "fizbin" })
    .AndUnauthorized()
    .Login("Marc", "texasHoldem")
    .AndOk();
}

Expired Token Test

Here, we forcibly expire the token and verify that the user can't do something that requires a valid token.

C#
[TestMethod]
public void ExpiredTokenTest()
{
  ClearAllTables();

  new WorkflowPacket(URL)
    .Login()
    .Post("account", new { Username = "Marc", Password = "fizbin" })
    .AndOk()
    .Login("Marc", "fizbin")
    .AndOk()
    .Post("account/expireToken", null)
    .AndOk()

    // Do something that requires authentication.
    .Post("account/logout", null)
    .AndUnauthorized();
}

Other Tests and Behind the Scenes

There are other tests as well which the reader can peruse in the source code, as they are all similar in nature. There's a lot of extension methods in play here as well as RestSharp for the API calls, and the way the responses are stored in a dictionary is worth looking into as well. Keep in mind that the code here is improved over what I wrote in my article on fluent web API integration.

Conclusion

Hopefully, this provides the reader with a basic template for an account management web API. There are, of course, other ways, possibly better ways, of doing this - if you have a preferred approach, post it in the article comments!

History

  • 6th February, 2022: Initial version

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

About the Author

Marc Clifton
Architect Interacx
United States United States
Blog: https://marcclifton.wordpress.com/
Home Page: http://www.marcclifton.com
Research: http://www.higherorderprogramming.com/
GitHub: https://github.com/cliftonm

All my life I have been passionate about architecture / software design, as this is the cornerstone to a maintainable and extensible application. As such, I have enjoyed exploring some crazy ideas and discovering that they are not so crazy after all. I also love writing about my ideas and seeing the community response. As a consultant, I've enjoyed working in a wide range of industries such as aerospace, boatyard management, remote sensing, emergency services / data management, and casino operations. I've done a variety of pro-bono work non-profit organizations related to nature conservancy, drug recovery and women's health.

Comments and Discussions

 
PraiseMessage Closed Pin
12-Apr-22 7:30
Memberdebabrata pattanaik12-Apr-22 7:30 
QuestionWhy Not? Pin
Ger Hayden14-Mar-22 5:12
MemberGer Hayden14-Mar-22 5:12 
QuestionMessage Closed Pin
8-Feb-22 5:17
MemberMember 134373238-Feb-22 5:17 
AnswerRe: Use ASPNET Identity and/or IdentityServer/OpenIddict Pin
seankearon8-Feb-22 12:08
Memberseankearon8-Feb-22 12:08 
AnswerRe: Use ASPNET Identity and/or IdentityServer/OpenIddict Pin
lmoelleb9-Feb-22 4:30
Memberlmoelleb9-Feb-22 4:30 
AnswerRe: Use ASPNET Identity and/or IdentityServer/OpenIddict Pin
Marc Clifton9-Mar-22 11:35
mvaMarc Clifton9-Mar-22 11:35 
GeneralRe: Use ASPNET Identity and/or IdentityServer/OpenIddict Pin
Member 134373239-Mar-22 19:48
MemberMember 134373239-Mar-22 19:48 
GeneralRe: Use ASPNET Identity and/or IdentityServer/OpenIddict Pin
Marc Clifton10-Mar-22 2:40
mvaMarc Clifton10-Mar-22 2:40 
Well, I will definitely investigate further! And thank you for your comments as it's important for readers to know that there are other professional alternatives out there.

GeneralMy vote of 5 Pin
Arturo Cedeño Carbonell7-Feb-22 14:32
professionalArturo Cedeño Carbonell7-Feb-22 14:32 
GeneralMy vote of 5 Pin
ArielR7-Feb-22 7:40
MemberArielR7-Feb-22 7:40 
GeneralMy vote of 5 Pin
Carl Edwards In SA7-Feb-22 3:04
professionalCarl Edwards In SA7-Feb-22 3:04 
GeneralRe: My vote of 5 Pin
Marc Clifton8-Feb-22 4:51
mvaMarc Clifton8-Feb-22 4:51 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.