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啦!