您当前的位置: 首页 >  .net

寒冰屋

暂无认证

  • 1浏览

    0关注

    2286博文

    0收益

  • 0浏览

    0点赞

    0打赏

    0留言

私信
关注
热门博文

.NET 6中的简单Web API帐户管理器

寒冰屋 发布时间:2022-06-10 19:00:00 ,浏览量:1

 

目录

介绍

设置用户表

帐户控制器

登录

刷新登录

登出

创建一个帐户

删除账户

更改用户名和/或密码

过期令牌和过期刷新令牌测试端点

用户模型

账户服务

登录

刷新登录

登出

创建一个帐户

删除帐户

更改用户名和/或密码

过期令牌

验证用户

身份验证服务

集成测试

每个集成测试的设置类和清除表

基本的管理员登录测试

测试错误的帐户

更改密码测试

过期令牌测试

其他测试和幕后花絮

结论

  • 下载源代码 - 22.5 KB
介绍

用户帐户管理是任何Web API的基础。本文是对基础知识的简明讨论和实现:

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

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

更改密码测试

在这个测试中:

  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(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

 

关注
打赏
1665926880
查看更多评论
立即登录/注册

微信扫码登录

0.0476s