一个简单的 .NET 6 Web API 账户管理器






4.94/5 (20投票s)
用户账户管理是任何 Web API 的基础
目录
引言
用户账户管理是任何 Web API 的基础。本文是对其基本原理的简明讨论和实现。
- 创建/删除用户账户
- 登录/登出
- 令牌认证
- 刷新令牌
- 更改用户名/密码
本文不打算讨论用户角色和权限——我在这里展示的只是一个 Web API 示例,说明如何由管理员或用户自己管理用户账户。此处的代码本质上是一个模板,需要根据您的用例需求进行修改。
本文没有配套的前端,相反,我实现了12个集成测试来验证账户管理器的功能。
集成测试以流式方式编写,并利用了 Fluent Assertions。关于编写流式集成测试和使用 Fluent Assertions 的讨论,可以在我的文章 流式 Web API 集成测试中找到。
该演示应用程序还使用了 Fluent Migrator 来创建示例数据库。关于使用 Fluent Migrator 的讨论,可以在我的文章 一个用于 .NET Core 的 FluentMigrator 控制器和服务中找到。
代码是在 .NET 6 框架中实现的。
设置用户表
用户表是通过迁移创建的,并插入了一个“SysAdmin
”用户,因为我们至少需要一个用户来进行身份验证以创建其他用户。
迁移可以通过浏览器使用 URL https://: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;
}
Logout
只有经过身份验证的用户才能登出。
[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;
}
删除账户
只有用户本人可以删除自己的账户。管理员删除他人账户的功能没有实现。同样,这应该通过角色和权限来实现。
/// <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;
}
更改用户名和/或密码
只有用户本人可以更改自己的用户名和/或密码。他们不能将用户名更改为已存在的用户名。
/// <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;
}
用于测试的令牌过期和刷新令牌过期端点
为集成测试实现了两个端点,以强制令牌和刷新令牌过期。
// ---- 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;
}
}
}
账户服务
账户服务实现了控制器端点所需的行为。
登录
在这里,当哈希密码成功匹配时,会返回访问令牌、刷新令牌和过期时间等值。关于 Mapper
函数的讨论,请参阅我的文章 DiponRoy 的 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;
}
刷新登录
这个过程与登录类似,但使用的是刷新令牌。
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
请记住,当服务被调用时,用户已经通过身份验证来执行登出操作,所以我们知道这个 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<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;
}
正如注释所示,本示例中未实现授权(authorization)。
集成测试
集成测试依赖于一个用于调用登录端点的扩展方法。
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;
}
请参考流式 Web API 集成测试以更好地理解这些测试是如何编写的。这些不是单元测试,而是集成测试,意味着它们会调用 Web API 端点。
为每个集成测试设置类和清理数据表
Setup
基类是配置 URL 和数据库连接的地方,也是清理表(在此案例中只有 User
表,但会保留管理员账户)的地方。
public class Setup
{
protected string URL = "https://: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<LoginResponse>(r => r.AccessToken.Should().NotBeNull());
}
测试无效账户
这是一个针对账户管理器中未知用户的简单测试。
[TestMethod]
public void BadLoginTest()
{
ClearAllTables();
new WorkflowPacket(URL)
.Post<LoginResponse>("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<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();
}
令牌过期测试
在这里,我们强制使令牌过期,并验证用户无法执行需要有效令牌的操作。
[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,将响应存储在字典中的方式也值得一看。请记住,这里的代码比我那篇关于流式 Web API 集成的文章中所写的有所改进。
结论
希望这能为读者提供一个账户管理 Web API 的基础模板。当然,还有其他方法,可能更好的方法,可以实现这一点——如果您有偏好的方法,请在文章评论中分享!
历史
- 2022年2月6日:初版