ASP.NET Core中使用Basic Authentication

前段时间需要给某个项目接入Web API,并且使用的是基于HTTP Header的Basic Authorzation。

最开始的写法是在appsettings.json里开辟字段存储用户名和密码,然后给对应的Controller加IActionFilter,后来随着项目的升级,想着接入ASP.NET Core自带的用户系统。

翻了翻Microsoft Docs,并没有找到一些科学的内容。后来在NuGet上找到了idunno.Authentication.Basic这样的一个NuGet包。

这是其GitHub Repo上提供的代码示例。

public void ConfigureServices(IServiceCollection services)
{
    services.AddAuthentication(BasicAuthenticationDefaults.AuthenticationScheme)
            .AddBasic(options =>
            {
                options.Realm = "idunno";
                options.Events = new BasicAuthenticationEvents
                {
                    OnValidateCredentials = context =>
                    {
                        if (context.Username == context.Password)
                        {
                            var claims = new[]
                            {
                                new Claim(
                                    ClaimTypes.NameIdentifier, 
                                    context.Username, 
                                    ClaimValueTypes.String, 
                                    context.Options.ClaimsIssuer),
                                new Claim(
                                    ClaimTypes.Name, 
                                    context.Username, 
                                    ClaimValueTypes.String, 
                                    context.Options.ClaimsIssuer)
                            };

                            context.Principal = new ClaimsPrincipal(
                                new ClaimsIdentity(claims, context.Scheme.Name));
                            context.Success();
                        }

                        return Task.CompletedTask;
                    }
                };
            });

    // All the other service configuration.
}

在此之后,给需要使用Basic的Controller添加

[Authorize(AuthenticationSchemes = "Basic")]

即可。


众所周知,ASP.NET Core使用的是基于Claim的认证与授权。

如果想使用自带的用户系统(services.AddIdentity()那套),则直接使用SignInManager就好啦。

那么大致的验证代码(OnValidateCredentials)就是

private static async Task ValidateAsyncOld(ValidateCredentialsContext context)
{
    var signInManager = context.HttpContext.RequestServices
        .GetRequiredService<SignInManager<TUser>>();
    var userManager = signInManager.UserManager;
    var user = await userManager.FindByNameAsync(context.Username);

    if (user == null)
    {
        context.Fail("User not found.");
        return;
    }

    var checkPassword = await signInManager.CheckPasswordSignInAsync(user, context.Password, false);
    if (!checkPassword.Succeeded)
    {
        context.Fail("Login failed, password not match.");
        return;
    }

    context.Principal = await signInManager.CreateUserPrincipalAsync(user);
    context.Success();
}

此后发现验证的性能问题。

显然每次访问API接口都会进行四次数据库查询!(Users一次,UserClaims一次,Roles一次,RoleClaims一次)

在轮询的接口中出现了非常严重的性能问题,毕竟主要程序代码只有一个查询,而用户信息就要查询四次。


不过不要紧,我们还有MemoryCache。

将用户信息缓存在内存中,并且定期清除,让几百次登录同一账户查询才生成一次ClaimsIdentity就会将查询用户信息的时间均摊的更低了。

同时由于我的这个项目不使用UserClaims和RoleClaims,也可以在ClaimsIdentity生成过程中忽略这两个步骤。在用户信息的查询中也可以省略掉一堆一堆一堆一堆不需要的字段。

根据SignInManager、UserManager等的源码,最后挖掘出来了四个接口需要使用

  • IPasswordHasher<TUser>
  • IOptions<IdentityOptions>
  • IdentityDbContext<...>
  • IUserClaimsPrincipalFactory<TUser> (已经展开到最后代码中)

这样就不需要使用UserManager、SignInManager而底层的直接生成ClaimsIdentity和ClaimsPrincipal。

private readonly static IMemoryCache _cache =
    new MemoryCache(new MemoryCacheOptions()
    {
        Clock = new Microsoft.Extensions.Internal.SystemClock()
    });

private static async Task ValidateAsync(ValidateCredentialsContext context)
{
    var dbContext = context.HttpContext.RequestServices
        .GetRequiredService<TContext>();
    var normusername = context.Username.ToUpper();

    var user = await _cache.GetOrCreateAsync("`" + normusername.ToLower(), async entry =>
    {
        var value = await dbContext.Users
            .Where(u => u.NormalizedUserName == normusername)
            .Select(u => new { u.Id, u.UserName, u.PasswordHash, u.SecurityStamp })
            .FirstOrDefaultAsync();
        entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
        return value;
    });

    if (user == null)
    {
        context.Fail("User not found.");
        return;
    }

    var passwordHasher = context.HttpContext.RequestServices
        .GetRequiredService<IPasswordHasher<TUser>>();

    var attempt = passwordHasher.VerifyHashedPassword(
        user: default, // assert that hasher don't need TUser
        hashedPassword: user.PasswordHash,
        providedPassword: context.Password);

    if (attempt == PasswordVerificationResult.Failed)
    {
        context.Fail("Login failed, password not match.");
        return;
    }

    var principal = await _cache.GetOrCreateAsync(normusername, async entry =>
    {
        var uid = user.Id;
        var ur = await dbContext.UserRoles
            .Where(r => r.UserId.Equals(uid))
            .Join(dbContext.Roles, r => r.RoleId, r => r.Id, (_, r) => r.Name)
            .ToListAsync();

        var options = context.HttpContext.RequestServices
            .GetRequiredService<IOptions<IdentityOptions>>().Value;

        // REVIEW: Used to match Application scheme
        var id = new ClaimsIdentity("Identity.Application",
            options.ClaimsIdentity.UserNameClaimType,
            options.ClaimsIdentity.RoleClaimType);
        id.AddClaim(new Claim(options.ClaimsIdentity.UserIdClaimType, $"{user.Id}"));
        id.AddClaim(new Claim(options.ClaimsIdentity.UserNameClaimType, user.UserName));
        id.AddClaim(new Claim(options.ClaimsIdentity.SecurityStampClaimType, user.SecurityStamp));
        foreach (var roleName in ur)
            id.AddClaim(new Claim(options.ClaimsIdentity.RoleClaimType, roleName));
        var value = new ClaimsPrincipal(id);

        entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(20);
        return value;
    });

    context.Principal = principal;
    context.Success();
}

缓存的5分钟/20分钟可以根据需求来修改。

这样就有一个似乎“完美”一点的Basic Authentication啦!

发表评论

电子邮件地址不会被公开。 必填项已用*标注