分离 ASP.NET Core Identity、身份验证和数据库引擎
用于企业应用程序 OAuth 安全的可重用代码
引言
如果您决定在 ASP.NET Core 应用程序中托管自己的身份验证机制,有许多有用的文章演示了如何使用 ASP.NET Core Identity、OAuth 和 JWT 令牌。在本文中,我假设您已具备 ASP.NET Core Identity 的一些经验。我们的重点是分离 ASP.NET Core Identity、身份验证和数据库引擎。
如果您不熟悉 OAuth 或 JWT,GitHub 存储库中包含的集成测试套件展示了使用 OAuth 的常见身份验证流程。
虽然本文不会涵盖构建复杂业务应用程序的所有安全方面的考虑因素,但提供的技巧基于特定的假设和上下文。
- 您正在构建一个全新的项目。
- 该全新项目的身份验证机制将由未来要使用的其他业务应用程序共享。
- 一些应用程序将使用 OAuth2 以及 Google、Microsoft、Apple 和 Facebook 等提供商,以及其他专有身份验证。
- 某些应用程序支持推送,其实现方式是 SignalR。
- 实体(包括用户实体)的首选 ID 类型是 GUID 而不是字符串。
- Entity Framework Core 用于构建 DAL。
- 应用程序对数据库引擎是中立的。也就是说,您可以轻松切换到 MySQL、SQLite、MS SQL 和 Oracle 等。
- 您希望同一个用户 John Smith 在多个设备上保持登录状态,并且每个设备都有多个应用程序或多个浏览器标签页。
虽然 ASP.NET 应用程序的具体要求可能差异很大,但以下代码示例中讨论的基本概念可以作为替代解决方案。
参考文献
- 在ASP.NET Core Identity中定制Identity 模型,支持使用 OAuth 2.0 的外部登录提供商,包括 Google、Microsoft Account 和 Facebook 等。
- 在 .NET Core Web App 中实现多个身份
- ASP.NET Core Identity 的自定义存储提供程序
背景
如果您以前从未使用过 ASP.NET Core 之前的 ASP.NET Identity,则可以跳过关于 .NET Framework 中可用功能的背景介绍部分。
Visual Studio 和 .NET Framework 为缺乏经验的 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”文件夹中的内容移到另一个包/程序集/csproj 中。上帝程序集没有错,就像上帝类没有错一样,但是对 TDD 不友好,因此对于构建企业应用程序来说效率不高。
- SQL 数据库需要一个需要 root 或 DBA 角色的连接字符串,这对于 Orchard 和 Umbraco 等 CMS 应用程序来说是可以接受的,但对于通用业务应用程序来说则不理想。
- 整个架构针对/耦合/偏向于 MS SQL。
- ...
随着OAuth的普及,多年来在从头开始设计 ASP.NET Core 时,Microsoft 已经对除 MS SQL 之外的其他数据库引擎变得更加友好,并且使用ASP.NET Core Identity和Owin以及 Entity Framework Core,对于那些在 OAuth 方面没有深入经验的应用程序开发人员来说变得更加容易。
新的 ASP.NET Core 项目的脚手架代码在身份验证和帐户管理方面较少,这对于企业应用程序来说可能是件好事,因为:
- 有更多的身份验证方案,以及 Auth0 和 Okta 等第三方授权提供商。并且不时地,您、IT 团队或安全团队可能希望更改身份验证和授权机制,那么您肯定希望迁移尽可能顺利。ASP.NET Core 为这些需求提供了相当优雅的架构设计。
Using the Code
如果您要托管自己的用户身份和授权,您将希望使用Microsoft.AspNetCore.Identity
命名空间和Microsoft.AspNetCore.Identity.EntityFrameworkCore
命名空间中的类提供的功能。
代码示例集中在以下主题:
- 将 ID 类型从 string 更改为 GUID,并为一些 DB 表添加几个列。
- 将 DB 引擎与主应用程序代码分离。也就是说,您可以在部署后切换 DB 引擎。
- 集成测试套件,以验证身份验证是否确实有效。
GUID 作为 Identity ID
Microsoft.AspNetCore.Identity 命名空间中类的扩展
这是为了对 Microsoft.AspNetCore.Identity 命名空间中的类进行扩展。
该命名空间包含通用类,如 IdentityRole<TKey> 和 IdentityUser<TKey>,以及内置的具体类,如 IdentityRole : IdentityRole<string> 和 IdentityUser : IdentityUser<string>,用于身份模型。
如果您使用内置的具体类IdentityRole
或类似的类,ID 的类型将是 string,数据库架构为“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 和未来扩展友好。使用 string 作为 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)
{
}
...
用于存储身份信息、身份验证和角色的数据库
通过上述两组扩展,您已经对 EF 模型进行了一些与内置模型略有不同的自定义。根据您的具体需求,如果默认架构不够,您可以添加更多信息。并且使用 ASP.NET (Core) Identity 几乎强制要求使用 Entity Framework (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 表中)来存储刷新令牌,这是一种传统的方法。但是,IdentityUserToken
表中的IdentityUserToken
设计上是以UserId+LoginProvider+Name
作为主键。虽然技术上很容易更改主键的组成,但是UserManager
关于用户令牌的内置函数本质上使用UserId+LoginProvider+Name
作为主键,因此,更改主键会破坏UserManager
。
我提出了一个稍微有些“ hack ”的解决方案,如下所述。
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;
}
}
技巧和“ hack ”在于使令牌名称成为基本令牌名称加上连接 ID。
用户令牌的各种清理功能可以轻松实现。
备注
- 在代码示例中,我使用“
UserManager.SetAuthenticationTokenAsync()
”来更新“aspnetusertokens
”表,而不是使用 Entity Framework Core,因为这样更抽象、更灵活。如果您使用其他 ASP.NET Core Identity 的自定义存储提供程序,所需的代码更改将最小。 - 将刷新令牌存储在数据库表中显然不是唯一的方法,因为我看到其他程序员(在线帖子)使用了一对“
UserManager.GenerateUserTokenAsync()
”和“userManager.VerifyUserTokenAsync()
”。显然,不需要数据库操作,但是也无法处理刷新令牌的过期。 - 您不必使用刷新令牌的概念,并且在令牌过期之前通过 HTTP 客户端拦截器获取新的访问令牌可能就足够了,前提是您的 IT 安全专家根据您的业务背景同意。您可能在使用一些在线服务时有过一些经验,这些服务会在您不活动几分钟后将您注销,显然它们故意不使用刷新令牌的概念。
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 Bearer 令牌身份验证
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);
讨论如何在生产环境中存储此类密钥超出了本文的范围,但有许多很好的参考资料。
使应用程序对数据库引擎保持中立
其想法是修改 connection string 和 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.
});
在 SQLite 插件代码中
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" ]
},
集成测试
身份验证是典型业务应用程序中用户将使用的第一个功能,因此,确保身份验证的以下质量属性对于整体 UX 和营销至关重要。
- 正确性
- 可靠性
- 健壮性
- 速度
当然,其他业务功能也应该具有这些质量属性,但是对于身份验证,额外的努力是值得的。
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 倍。除了性能之外,这还减少了通过线路发送客户端凭据的需求。
- 调整测试套件的设置,您可以在团队测试环境和暂存环境中测试身份验证系统的性能。
关注点
在“ASP.NET Core 中的身份验证简介”中,Microsoft 建议:
ASP.NET Core Identity 为 ASP.NET Core Web 应用程序添加了用户界面(UI)登录功能。要保护 Web API 和 SPA,请使用以下任一项:
- Microsoft Entra ID
- Azure Active Directory B2C (Azure AD B2C)
- Duende Identity Server
然而,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 非常简单。在不久的将来,我可能会写一篇关于这些的文章。
如果您监控身份验证数据库中的 aspnet* 表,您可能会注意到在登录过程中,很少有表在刷新令牌和授权,唯一更新的表是“aspnetusertokens”。显然,ASP.NET (Core) Identity 的安全库将访问令牌存储在某些“秘密”位置,甚至“虚无”中。
延伸阅读
- 访问令牌和刷新令牌:Roman Imankulov、catchdave 和 laalaguer 以通俗易懂的方式解释了这两个令牌背后的设计背景。