目录
介绍
设置用户表
帐户控制器
登录
刷新登录
登出
创建一个帐户
删除账户
更改用户名和/或密码
过期令牌和过期刷新令牌测试端点
用户模型
账户服务
登录
刷新登录
登出
创建一个帐户
删除帐户
更改用户名和/或密码
过期令牌
验证用户
身份验证服务
集成测试
每个集成测试的设置类和清除表
基本的管理员登录测试
测试错误的帐户
更改密码测试
过期令牌测试
其他测试和幕后花絮
结论
- 下载源代码 - 22.5 KB
用户帐户管理是任何Web API的基础。本文是对基础知识的简明讨论和实现:
- 创建/删除用户帐户
- 登入/登出
- 令牌认证
- 令牌刷新
- 更改用户名/密码
本文无意讨论用户角色和权限——我在这里展示的只是一个示例Web API如何由管理员或用户自己管理用户帐户。这里的代码本质上是一个模板,需要根据你的用例需求进行修改。
这篇文章没有前端,相反,我实现了12个集成测试来验证客户管理器的功能:
集成测试以流畅的方式编写并使用Fluent Assertions。可以在我的文章Fluent Web API集成测试中找到有关编写流畅集成测试和使用Fluent Assertions的讨论。
演示应用程序还使用Fluent Migrator创建示例数据库。
该代码在.NET 6 Framework中实现。
设置用户表User表是通过迁移创建的,并插入了一个“SysAdmin”用户,因为我们需要至少一个用户进行身份验证才能创建其他用户。
可以使用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");
}
}
}
控制器实现以下端点。
登录任何人都可以尝试登录。此POST端点不需要身份验证。
[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;
}
使用刷新令牌,用户可以使用有效的访问令牌重新登录。
[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;
}
只有经过身份验证的用户才能注销。
[Authorize]
[HttpPost("Logout")]
public ActionResult Logout()
{
var token = GetToken();
svc.Logout(token);
return Ok();
}
创建帐户需要经过身份验证的用户。由于用户角色/权限超出了本文的范围,在此实现中,任何经过身份验证的用户都可以创建帐户。
帐户的用户名必须是唯一的。从技术上讲,可以通过用户名和密码允许帐户唯一,但这增加了我没有实现的唯一性测试的复杂性。
[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;
}
只有用户可以删除自己的帐户。管理员删除其他人帐户的功能未实现。同样,这应该通过角色和权限来实现。
///
/// A user can only delete their own account.
/// This logs out the user.
///
[Authorize]
[HttpDelete()]
public ActionResult DeleteAccount()
{
ActionResult ret = Ok();
var token = GetToken();
svc.DeleteAccount(token);
return ret;
}
只有用户可以更改自己的用户名和/或密码。他们无法将用户名更改为现有用户。
///
/// A user can only change their own username and/or password.
/// This logs out the 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;
}
为集成测试实现了两个端点来强制令牌过期和刷新令牌:
// ---- 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
User类实现实现了将各种字段设置为登录和注销过程的一部分的方法:
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;
}
}
}
帐户服务实现控制器端点所需的行为。
登录在这里,如果哈希密码成功匹配,则返回访问令牌、刷新令牌和过期值。
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();
}
return response;
}
这是与登录类似的过程,但使用了刷新令牌:
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();
}
}
return response;
}
请记住,在调用服务时,用户已通过身份验证以执行注销,因此我们知道User记录是有效的。
public void Logout(string token)
{
var user = context.User.Single(u => u.AccessToken == token);
user.Logout();
context.SaveChanges();
}
如前所述,创建帐户应由管理员处理,或者对于一般公众消费,实现应包括某种两因素身份验证。鉴于这超出了本文的范围,这里的实现很简单:
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);
}
只有用户可以删除自己的帐户,并且经过身份验证,我们知道该User记录存在。
public void DeleteAccount(string token)
{
var user = context.User.Single(u => u.AccessToken == token);
user.Logout();
user.Deleted = true;
context.SaveChanges();
}
在这里,经过身份验证的用户可以将他们的用户名更改为尚不存在的用户名,和/或更改他们的密码。当用户更改其用户名和/或密码时,他们必须重新登录。
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;
}
两种服务方法用于支持集成测试:
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();
}
该服务还为身份验证服务提供了一种方法,用于验证用户的令牌是否有效且仍然是最新的:
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;
}
这是一个直接的实现,它确定标头中的令牌是否适用于有效用户并且是最新的。这里的代码只是TokenAuthenticationService类的HandleAuthenticateAsync方法。
protected override Task HandleAuthenticateAsync()
{
Task 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;
}
从注释中可以推断,在这个例子中没有实现授权。
集成测试集成测试依赖于调用登录端点的扩展方法:
public static WorkflowPacket Login
(this WorkflowPacket wp, string username = "SysAdmin", string password = "SysAdmin")
{
string token = null;
wp
.Post("account/login", new { username, password })
.AndOk()
.Then(wp => token = wp.GetObject().AccessToken)
.UseHeader("Authorization", $"Bearer {token}");
return wp;
}
请参阅Fluent Web API集成测试以更好地了解这些测试是如何编写的。这些不是单元测试。它们是集成测试,这意味着它们调用Web API端点。
每个集成测试的设置类和清除表Setup基类是配置URL和数据库连接的位置,以及清除表(在本例中只是User表)的位置,管理员帐户除外。
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");
}
}
}
所有测试都派生自Setup基类:
[TestClass]
public class AccountTests : Setup
...
我们要验证是否可以使用迁移中创建的SysAdmin帐户登录:
[TestMethod]
public void SysAdminLoginTest()
{
ClearAllTables();
new WorkflowPacket(URL)
.Login()
.AndOk()
.IShouldSee(r => r.AccessToken.Should().NotBeNull());
}
以下是针对客户管理中无法识别的用户的简单测试:
[TestMethod]
public void BadLoginTest()
{
ClearAllTables();
new WorkflowPacket(URL)
.Post("account/login", new { Username = "baad", Password = "f00d" })
.AndUnauthorized();
}
在这个测试中:
- 管理员创建一个新的用户帐户。
- 我们验证我们可以使用该帐户登录。
- 然后我们更改“我们的”密码并验证我们无法使用旧密码登录。
- 然后我们验证我们可以使用新密码登录。
[TestMethod]
public void ChangePasswordOnlyTest()
{
ClearAllTables();
new WorkflowPacket(URL)
.Login()
.Post("account", new { Username = "Marc", Password = "fizbin" })
.AndOk()
.Login("Marc", "fizbin")
.AndOk()
.IShouldSee(r => r.AccessToken.Should().NotBeNull())
.Patch("account", new { Password = "texasHoldem" })
.AndOk()
.Post("account/login", new { Username = "Marc", Password = "fizbin" })
.AndUnauthorized()
.Login("Marc", "texasHoldem")
.AndOk();
}
在这里,我们强制使令牌过期,并验证用户不能做需要有效令牌的事情。
[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();
}
读者可以在源代码中仔细阅读其他测试,因为它们在本质上都是相似的。这里有很多扩展方法以及用于API调用的RestSharp,响应存储在字典中的方式也值得研究。请记住,这里的代码比我在关于fluent Web API集成的文章中所写的有所改进。
结论希望这为读者提供了一个帐户管理Web API的基本模板。
https://www.codeproject.com/Articles/5324452/A-Simple-Web-API-Account-Manager-in-NET-6