目录
Microsoft.AspNetCore.Identity Namespace中的类的扩展
Microsoft.AspNetCore.Identity.EntityFrameworkCore命名空间中的类的扩展
介绍
如果您决定在ASP.NET Core应用程序中托管自己的身份验证机制,那么有许多优秀的文章演示了如何使用ASP.NET Core Identity、OAuth和JWT令牌。假设您在使用ASP.NET Core Identity方面有一些经验。本文重点介绍ASP.NET Core身份、身份验证和数据库引擎之间的解耦。
如果您不熟悉OAuth或JWT,GitHub存储库中包含的集成测试套件演示了使用OAuth的常见身份验证对话。
这是一篇简短的文章,不会涵盖构建复杂业务应用程序的每个方面和每个级别的安全问题,但是,提供的提示基于一些假设/上下文:
- 您正在构建一个绿地项目。
- 绿地项目中的身份验证机制将由将来使用的其他业务应用程序共享。
- 一些应用程序将使oAuth与Google,Microsoft,Apple和Facebook等,以及其他专有身份验证。
- 某些前端应用程序支持Push,实现为SignalR。
- 实体(包括用户实体)的首选ID类型是GUID而不是字符串。
- Entity Framework Core用于生成DAL。
- 该应用程序对数据库引擎是中立的。也就是说,您可以轻松切换到MySQL,SQLite,MS SQL和Oracle等。
- 您希望同一用户John Smith在多个设备上保持登录状态,并且每个设备都有多个应用程序或多个浏览器选项卡。
ASP.NET应用的要求可能有很大不同,但是,通过以下代码示例描述的概念可能会大量应用。
引用
- ASP.NET Core Identity 中的身份模型自定义,支持使用OAuth 2.0的外部登录提供商,包括Google、Microsoft Account和Facebook等。
- 在.NET Core Web应用中实现多个标识
- ASP.NET Core Identity的自定义存储提供程序
背景
如果在ASP.NET Core之前从未使用过 ASP.NET Identity,则可以跳过此背景部分,了解.NET Framework中提供的功能。
带有.NET Framework的Visual Studio为没有经验的Web应用程序开发人员提供了相当容易的起点。创建新的ASP.NET MVC项目时,将具有这样的结构和基架代码:
Web.config:
<configuration>
<configSections>
<!-- For more information on Entity Framework configuration, visit http://go.microsoft.com/fwlink/?LinkID=237468 -->
<section name="entityFramework" type="System.Data.Entity.Internal.ConfigFile.EntityFrameworkSection, EntityFramework, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" requirePermission="false" />
</configSections>
<connectionStrings>
<add name="DefaultConnection" connectionString="Data Source=(LocalDb)\MSSQLLocalDB;AttachDbFilename=|DataDirectory|\aspnet-WebApplication1-20240328045515.mdf;Initial Catalog=aspnet-WebApplication1-20240328045515;Integrated Security=True"
providerName="System.Data.SqlClient" />
</connectionStrings>
....
<entityFramework>
<providers>
<provider invariantName="System.Data.SqlClient" type="System.Data.Entity.SqlServer.SqlProviderServices, EntityFramework.SqlServer" />
</providers>
</entityFramework>
...
您可以在生成或部署后切换到其他数据库引擎,因为运行时将在应用启动期间读取连接字符串和EF提供程序的配置。
对于企业应用程序,这种规定的结构可能有几个主要缺陷:
- 对于单元测试和集成测试来说并不容易。为了分离关注点,您可能会将文件夹“Models”中的内容和其他内容移动到另一个package/assembly/ csproj。上帝集合没有错,就像上帝类没有错一样,但是,对TDD不友好,因此对构建企业应用程序没有效率。
- SQL数据库需要需要root或DBA角色的连接字符串,这对于像Orchard和Umbraco这样的CMS应用程序来说还不错,但对于常见的业务应用程序来说,就不好了。
- 整个架构针对MS SQL进行了优化/耦合/偏向于MS SQL。
- ...
自从OAuth流行以来,多年来从头开始设计ASP.NET Core时,Microsoft对MS SQL以外的数据库引擎变得更加友好,并且对于可能没有深入OAuth经验的应用程序开发人员来说,利用ASP.NET Core Identity和Owin以及Entity Framework Core变得更加容易。
新ASP.NET Core项目的基架代码用于身份验证和帐户管理较少,这对企业应用程序来说可能是一件好事,因为:
- 还有更多的身份验证方案,以及Auth0和okta等第三方授权提供商。有时,您、IT团队或安全团队可能希望更改身份验证和授权机制,那么您肯定希望迁移尽可能顺利。ASP.NET Core为这些需求提供了相当优雅的建筑设计。
使用代码
如果要托管自己的用户标识和授权,则希望使用Microsoft.AspNetCore.Identity命名空间和Microsoft.AspNetCore.Identity.EntityFrameworkCore命名空间中的类提供的内容。
代码示例主要介绍以下主题:
- 将ID类型从字符串更改为GUID,并使用更多列扩展某些数据库表。
- 将数据库引擎与主要应用程序代码分离。也就是说,您可以在部署后切换数据库引擎。
- 集成测试套件,用于验证实施的身份验证是否实际有效。
Microsoft.AspNetCore.Identity Namespace中的类的扩展
这是为了在Microsoft.AspNetCore.Identity命名空间中扩展类。
命名空间包含IdentityRole<TKey >和IdentityUser<TKey>等泛型类以及用于标识模型的内置具体类如IdentityRole:IdentityRole<string>和IdentityUser:IdentityUser<string>。
如果使用内置的具体类IdentityRole或类似类,则Id的类型将是DB模式“VARCHAR(255)”的字符串。对于大多数应用程序来说,这样的数据库模式显然是一个安全的选择。但是,如果您确定uuid/GUID在长度方面足够好,并且更喜欢像GUID这样的强类型ID,则可以创建一个具体的类,例如:
public class ApplicationIdentityRole : IdentityRole<Guid>
{
public ApplicationIdentityRole()
{
Id = Guid.NewGuid();
}
public ApplicationIdentityRole(string roleName) : this()
{
Name = roleName;
}
}
public class ApplicationUser : IdentityUser<Guid>, ITrackableEntity
{
/// <summary>
/// Full name of the user. And this could be used as a filter or logical foreight key with a user.
/// </summary>
[MaxLength(128)]
public string FullName { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime ModifiedUtc { get; set; }
}
public class ApplicationUserToken : IdentityUserToken<Guid>, INewEntity
{
public DateTime CreatedUtc { get; set; }
}
public interface INewEntity{
DateTime CreatedUtc { get; set; }
}
对于用户配置文件,您可能需要跟踪创建日期和修改日期,以及显示的全名。
提示
- 即使你只使用 IdentityRole:IdentityRole<string>和 IdentityUser:IdentityUser<string>或它们的派生类来制作标识模型,本文中介绍的技巧可能会启发你进行对TDD和未来扩展和扩展友好的解耦组件设计。使用字符串作为ID的基本类型可以很好地处理需要处理遗留ID和多个唯一ID方案的遗留或棕地项目。
- 典型的业务应用程序可能有一个小型CRM,其中可能包含内部用户和外部用户的信息,并且身份验证数据库的用户配置文件中的全名可能与CRM中的全名不同。
- 即使通过向类“ApplicationUser”添加更多属性可以很容易地向表“aspnetusers”添加更多列,但在此表中存储用户的个人信息并不好,因为:
- 身份验证的属性很少更改,而个人信息的属性可能会随时间而更改。
- 个人信息的更新可能会更频繁。
- 您希望身份验证数据库运行得非常快,而个人信息的CURD可能会相对较慢。
- ...
Microsoft.AspNetCore.Identity.EntityFrameworkCore命名空间中的类的扩展
此命名空间包含一些泛型类,用于在标识模型上创建DbContext架构和数据库架构。
public class ApplicationRoleStore : RoleStore<ApplicationIdentityRole, ApplicationDbContext, Guid>
{
public ApplicationRoleStore(ApplicationDbContext context, IdentityErrorDescriber describer = null) : base(context, describer) { }
}
public class ApplicationUserStore : UserStore<ApplicationUser, ApplicationIdentityRole, ApplicationDbContext, Guid>
{
public ApplicationUserStore(ApplicationDbContext context, IdentityErrorDescriber describer = null) : base(context, describer) { }
}
public class ApplicationDbContext : IdentityDbContext<ApplicationUser, ApplicationIdentityRole, Guid>
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
/// <summary>
/// make table aspnetuserroles visible in context
/// </summary>
public override DbSet<IdentityUserRole<Guid>> UserRoles { get; set; }
/// <summary>
/// For shorter key length of MySQL
/// </summary>
/// <param name="modelBuilder"></param>
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.UseCollation("utf8_general_ci"); //case insensitive
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<ApplicationIdentityRole>()
.Property(c => c.Name).HasMaxLength(128).IsRequired();
modelBuilder.Entity<ApplicationUser>()//.ToTable("AspNetUsers")//I have to declare the table name, otherwise IdentityUser will be created
.Property(c => c.UserName).HasMaxLength(128).IsRequired();
}
...
public class ApplicationUserManager : UserManager<ApplicationUser>
{
public ApplicationUserManager(IUserStore<ApplicationUser> store,
IOptions<IdentityOptions> optionsAccessor,
IPasswordHasher<ApplicationUser> passwordHasher,
IEnumerable<IUserValidator<ApplicationUser>> userValidators,
IEnumerable<IPasswordValidator<ApplicationUser>> passwordValidators,
ILookupNormalizer keyNormalizer,
IdentityErrorDescriber errors,
IServiceProvider services,
ILogger<UserManager<ApplicationUser>> logger)
: base(store, optionsAccessor, passwordHasher, userValidators, passwordValidators, keyNormalizer, errors, services, logger)
{
}
...
用于存储身份信息、身份验证和角色的数据库
通过上面的2组扩展,您已经自定义了与内置模型略有不同的EF模型。根据您的具体需求,如果默认架构不够,您可以添加更多信息。使用ASP.NET(Core)标识几乎要求实体框架(Core)的代码优先方法。
public async Task DropAndCreate()
{
using ApplicationDbContext context = new(options);
if (await context.Database.EnsureDeletedAsync())
{
Console.WriteLine("Old db is deleted.");
}
await context.Database.EnsureCreatedAsync();
Console.WriteLine(String.Format("Database is initialized, created, or altered through connection string: {0}", context.Database.GetDbConnection().ConnectionString));
}
提示
- 通常,我会调用一个控制台应用程序或脚本的DropAndCreate()来创建空白数据库,通常使用一些预定义的角色和管理员帐户。这类似于通过具有DBA权限的SQL脚本创建数据库,并在具有CRUD权限的应用程序中使用数据库的良好旧做法。
支持同一用户登录多个设备和浏览器选项卡
由于JWT是无状态的,因此ASP.NET Core Identity以及Microsoft.AspNetCore.Authorization支持同一用户登录多个设备和浏览器选项卡,除非:
- 访问令牌的到期时间很短,例如5分钟或1小时等。
- 使用刷新令牌。
应用程序开发人员通常使用IdentityUserToken(在表aspnetusertokens中)来存储刷新令牌,这是一种正统的方式。但是,表aspnetusertokens的IdentityUserToken设计使用UserId+LoginProvider+Name作为主键。虽然从技术上讲,更改主键的组成很容易,但是,UserManager用于用户令牌的内置函数固有地使用UserId+LoginProvider+Name作为主键,因此,更改主键将破坏UserManager。
我想出了一个有点笨拙的解决方案,如下所述:
public class UserTokenHelper
{
/// <summary>
///
/// </summary>
/// <param name="userManager"></param>
/// <param name="tokenProviderName">Your app token provider name, or oAuth2 token provider name.</param>
public UserTokenHelper(ApplicationUserManager userManager, string tokenProviderName)
{
this.userManager = userManager;
this.tokenProviderName = tokenProviderName;
}
readonly ApplicationUserManager userManager;
readonly string tokenProviderName;
/// <summary>
/// Add or update a token of an existing connection.
/// </summary>
/// <returns></returns>
public async Task<IdentityResult> UpsertToken(ApplicationUser user, string tokenName, string newTokenValue, Guid connectionId)
{
string composedTokenName = $"{tokenName}_{connectionId.ToString("N")}";
await userManager.RemoveAuthenticationTokenAsync(user, tokenProviderName, composedTokenName); // need to remove it first, otherwise, Set won't work. Apparently by design the record is immutable.
return await userManager.SetAuthenticationTokenAsync(user, tokenProviderName, composedTokenName, newTokenValue);
}
/// <summary>
/// Lookup user tokens and find
/// </summary>
/// <returns></returns>
public async Task<bool> MatchToken(ApplicationUser user, string tokenName, string tokenValue, Guid connectionId)
{
string composedTokenName = $"{tokenName}_{connectionId.ToString("N")}";
string storedToken = await userManager.GetAuthenticationTokenAsync(user, tokenProviderName, composedTokenName);
return tokenValue == storedToken;
}
}
诀窍和技巧是使令牌名称成为基本令牌名称加上连接ID。
用户令牌的各种内务管理功能可以很容易地实现。
评论:
- 在代码示例中,我使用“UserManager.SetAuthenticationTokenAsync()”来更新“aspnetusertokens”表,而不是使用Entity Framework Core,因为这更抽象、更灵活。如果将其他custom存储提供程序用于ASP.NET Core Identity,则所需的代码更改将是最少的。
- 在数据库表中存储刷新令牌显然不是唯一的方法,因为我看到其他程序员(在线帖子)使用了一对“UserManager.GenerateUserTokenAsync()”和“userManager.VerifyUserTokenAsync()”。显然,不需要数据库操作,但是,刷新令牌不会过期。
- 您不必使用刷新令牌的概念,如果您的IT安全专家根据您的业务上下文达成一致,那么在令牌到期之前通过HTTP客户端拦截器通过用户凭据获取新的访问令牌可能就足够了。您可能有一些使用某些在线服务的经验,这些服务会在几分钟不活动后将您注销,显然他们并没有故意使用刷新令牌的概念。
ASP.NET安全启动代码
建立对身份验证数据库的访问
builder.Services.AddDbContext<ApplicationDbContext>(dcob =>
{
ConfigApplicationDbContext(dcob);
});
builder.Services.AddIdentity<ApplicationUser, ApplicationIdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddUserManager<ApplicationUserManager>()
.AddDefaultTokenProviders()
.AddTokenProvider(authSettings.TokenProviderName, typeof(DataProtectorTokenProvider<ApplicationUser>));
设置JWT持有者令牌身份验证
builder.Services.AddAuthentication(
options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
}
).AddJwtBearer(options =>
{
options.SaveToken = true;
options.RequireHttpsMetadata = false;
options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters()
{
ValidateIssuer = false,
ValidateAudience = false,
ValidAudience = authSettings.Audience,
ValidIssuer = authSettings.Issuer,
IssuerSigningKey = issuerSigningKey,
};
});
IssuerSigningKey的值在生产过程中应得到很好的保护,并且该值是由一个常量生成的,该常量应安全地存储以供生产使用。在本文中,使用纯文本字符串:
var issuerSigningKey = new Microsoft.IdentityModel.Tokens.SymmetricSecurityKey(System.Text.Encoding.UTF8.GetBytes(authSetupSettings.SymmetricSecurityKeyString));
builder.Services.AddSingleton(issuerSigningKey);
讨论如何为生产存储这样的秘密超出了本文的范围,但有很多很好的参考资料:
使应用与数据库引擎无关
这个想法是在appsetting.json中更改连接字符串和相应的插件,就像ASP.NET能够做到的那样。
在服务启动代码中:
var dbEngineDbContext = DbEngineDbContextLoader.CreateDbEngineDbContextFromAssemblyFile(dbEngineDbContextPlugins[0] + ".dll");
if (dbEngineDbContext == null)
{
Console.Error.WriteLine("No dbEngineDbContext");
throw new ArgumentException("Need dbEngineDbContextPlugin");
}
Console.WriteLine($"DB Engine: {dbEngineDbContext.DbEngineName}");
builder.Services.AddDbContext<ApplicationDbContext>(dcob =>
{
dbEngineDbContext.ConnectDatabase(dcob, identityConnectionString); // called by runtime everytime an instance of ApplicationDbContext is created.
});
namespace Fonlow.EntityFrameworkCore.Sqlite
{
public class SqliteDbEngineDbContext : Fonlow.EntityFrameworkCore.Abstract.IDbEngineDbContext
{
public string DbEngineName => "Sqlite";
public void ConnectDatabase(DbContextOptionsBuilder dcob, string connectionString)
{
dcob.UseSqlite(connectionString);
}
}
}
在appsettings.json中:
"ConnectionStrings": {
"IdentityConnection": "Data Source=./DemoApp_Data/auth.db"
},
"appSettings": {
"environment": "test",
"dbEngineDbContextPlugins": [ "Fonlow.EntityFrameworkCore.Sqlite" ]
},
集成测试
身份验证是用户在典型业务应用程序中使用的第一个功能,因此,确保身份验证的以下质量属性对于整体用户体验和营销至关重要:
- 正确性
- 可靠性
- 鲁棒性
- 速度
当然,其他业务功能也应该具有这些质量属性,但是,对于身份验证,额外的努力更值得。
GitHub上的 AuthTests代码:
[Fact]
public void TestRefreshTokenWithNewHttpClient()
{
var tokenText = GetTokenWithNewClient(baseUri, "admin", "Pppppp*8");
Assert.NotEmpty(tokenText);
var tokenModel = System.Text.Json.JsonSerializer.Deserialize<TokenResponseModel>(tokenText);
Assert.NotNull(tokenModel.RefreshToken);
var newTokenModel = GetTokenResponseModelByRefreshTokenWithNewClient(baseUri, tokenModel.RefreshToken, tokenModel.Username, tokenModel.ConnectionId);
Assert.Equal(tokenModel.Username, newTokenModel.Username);
TestAuthorizedConnection(newTokenModel.TokenType, newTokenModel.AccessToken);
}
测试套件将启动“Core3WebApi”并检索JWT访问令牌和刷新令牌。为了方便测试,Web服务具有以下设置:
- JWT访问令牌将在5秒后过期。
- 时钟偏差为2秒,因此访问令牌将在5+2=7秒后过期
您应该能够签出存储库、构建和运行测试,因为数据库引擎默认为SQLite。
提示
- 通过测试套件,可以看到使用刷新令牌获取新的访问令牌的速度比登录快10倍左右。除了性能之外,这还减少了通过网络发送客户端凭据的需要。
- 调整测试套件的设置,您可以在团队测试环境和暂存环境中测试身份验证系统的性能。
兴趣点
在“Introduction to Identity on ASP.NET Core”中,Microsoft建议:
ASP.NET Core Identity向ASP.NET Core Web应用添加了用户界面(UI)登录功能。若要保护Web API和SPA,请使用以下方法之一:
但是,ASP.NET Identity 2和ASP.NET Core Identity都支持良好的SPA,这些SPA只是在Web浏览器上运行的独立客户端,否则,这些SPA如何在这些“拯救生命”的身份验证提供商出现之前幸存下来?
如果您将来决定使用Auth0/Okta或Microsoft Entra Id作为身份验证提供程序,只要您的ASP.NET应用程序的架构设计符合ASP.NET Core的架构设计,迁移就相当容易和直接。如果您有兴趣使用Entra,只需在谷歌上搜索“ASP.NET Core AddAuthentication Entra”,并确保查看2024-01-01之后编写的代码示例。
毕竟,请咨询IT团队中的安全专家,尤其是当您拥有内部软件应用程序以及一些Microsoft Office 365产品等时,并且人们希望在授权中获得无缝的用户体验。
如果你是Web API供应商,并且提供各种形式的API密钥来保护API是相当简单的。在不久的将来,我可能会写一篇关于这些的文章。
如果监视auth数据库中的aspnet*表,您可能会注意到登录期间很少有表刷新令牌和授权,并且唯一更新的表是“aspnetusertokens”。显然,ASP.NET(Core)身份的安全库将访问令牌存储在某些“秘密”地方甚至“无效”中。
延伸阅读:
- Access Token和Refresh Token :通俗易懂,用于解释这2个令牌背后的设计背景,作者是Roman Imankulov、catchdave和laalaguer。
https://www.codeproject.com/Articles/5378824/Decouple-ASP-NET-Core-Identity-Authentication-and