🌑

小羊儿的心情天空

ASP.NET Core中使用Basic Authentication

Feb 25, 2020 由 小羊

前段时间需要给某个项目接入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啦!