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.
User account management is fundamental to any web API. This article is a concise discussion and implementation of the basics:
- Creating / deleting user accounts
- Login/Logout
- Token authentication
- Token refresh
- 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.
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.
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 controller implements the following endpoints.
Anyone can attempt to log in. This POST
endpoint does not require authentication.
[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;
}
Using the refresh token, the user can log back in with a valid access token.
[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;
}
Only an authenticated user can log out.
[Authorize]
[HttpPost("Logout")]
public ActionResult Logout()
{
var token = GetToken();
svc.Logout(token);
return Ok();
}
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.
[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;
}
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.
[Authorize]
[HttpDelete()]
public ActionResult DeleteAccount()
{
ActionResult ret = Ok();
var token = GetToken();
svc.DeleteAccount(token);
return ret;
}
Only the user can change their own username and/or password. They cannot change their username to an existing user.
[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;
}
Two endpoints are implemented for integration testing to force the expiration of the token and the refresh token:
#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
class implementation implements the methods for setting the various fields as part of the login and logout process:
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 implements the behavior required by the controller endpoints.
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.
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()
.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;
}
This is a similar process to login but uses the refresh token:
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();
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;
}
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.
public void Logout(string token)
{
var user = context.User.Single(u => u.AccessToken == token);
user.Logout();
context.SaveChanges();
}
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:
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);
}
Only the user can delete their own account, and having been authenticated, we know the User
record exists.
public void DeleteAccount(string token)
{
var user = context.User.Single(u => u.AccessToken == token);
user.Logout();
user.Deleted = true;
context.SaveChanges();
}
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.
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;
}
Two service methods are used to support integration testing:
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();
}
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:
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;
}
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.
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
Task<AuthenticateResult> result =
Task.FromResult(AuthenticateResult.Fail("Not authorized."));
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),
};
var claimsIdentity = new ClaimsIdentity(claims, nameof(TokenAuthenticationService));
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.
The integration tests rely on an extension method for calling the login endpoint:
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
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.
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:
[TestClass]
public class AccountTests : Setup
...
We want to verify that we can log in with the SysAdmin
account created in the migration:
[TestMethod]
public void SysAdminLoginTest()
{
ClearAllTables();
new WorkflowPacket(URL)
.Login()
.AndOk()
.IShouldSee<LoginResponse>(r => r.AccessToken.Should().NotBeNull());
}
Here's a simple test for a user that is not known to the account manager:
[TestMethod]
public void BadLoginTest()
{
ClearAllTables();
new WorkflowPacket(URL)
.Post<LoginResponse>("account/login", new { Username = "baad", Password = "f00d" })
.AndUnauthorized();
}
In this test:
- The admin creates a new user account.
- We verify that we can log in with that account.
- Then we change "our" password and verify that we can't login with the old password.
- Then we verify we can log in with the new password.
[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();
}
Here, we forcibly expire the token and verify that the user can't do something that requires a valid token.
[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()
.Post("account/logout", null)
.AndUnauthorized();
}
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.
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!
- 6th February, 2022: Initial version