65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.94/5 (20投票s)

2022年2月6日

CPOL

6分钟阅读

viewsIcon

47971

downloadIcon

940

用户账户管理是任何 Web API 的基础

引言

用户账户管理是任何 Web API 的基础。本文是对其基本原理的简明讨论和实现。

  1. 创建/删除用户账户
  2. 登录/登出
  3. 令牌认证
  4. 刷新令牌
  5. 更改用户名/密码

本文打算讨论用户角色和权限——我在这里展示的只是一个 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();
}

更改密码测试

在这个测试中:

  1. 管理员创建一个新用户账户。
  2. 我们验证能用该账户登录。
  3. 然后我们更改“我们自己”的密码,并验证不能用旧密码登录。
  4. 接着我们验证能用新密码登录。
[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日:初版
© . All rights reserved.