SP.IdentityService 项目为微服务架构中的核心认证中心,采用 OpenIddict
框架实现 OAuth2.0 和 OpenID Connect 协议,提供完整的身份认证和授权解决方案。项目集成了 ASP.NET Core Identity 框架,实现了用户管理、角色权限控制等基础功能,并通过 Entity Framework Core 与 MySQL 数据库进行数据持久化。在安全方面,项目实现了严格的密码策略、账户锁定机制,并支持 Token 认证和刷新令牌机制。作为微服务架构的一部分,项目使用 Nacos 进行服务注册与发现,并集成了 Redis 缓存和 RabbitMQ 消息队列,提升了系统性能和可靠性。同时,项目提供了完整的 Swagger API 文档,支持 OAuth2.0 认证流程,方便开发人员进行接口调试和集成。项目采用模块化设计,包含用户管理、角色权限、消息服务等核心模块,并提供了邮件通知、消息队列等扩展功能。通过合理的代码组织和最佳实践,确保了系统的可维护性和可扩展性,为其他微服务提供统一的身份认证和授权服务。
从这篇文章开始,我们将逐步实现微服务架构中的身份认证服务。我们将使用 ASP.NET Core Identity 框架和 OpenIddict 库来实现 OAuth2.0 和 OpenID Connect 协议。以下是我们将要实现的功能用户认证和令牌管理、用户信息管理、角色管理、权限管理 以及 邮件通知功能。这篇文章中我们一起实现用户认证和令牌管理。
Tip:文章只讲解核心代码和实现思路。
一、OpenIddict服务扩展
在 SP.IdentityService
项目中,我们需要配置 OpenIddict 服务。我们将创建一个新的扩展方法 AddOpenIddict
,用于配置 OpenIddict 服务。
在实现 OpenIddict 服务扩展时,我们首先需要定义一个扩展方法 AddOpenIddict
,这样在 Program.cs
里就能直接通过 services.AddOpenIddict(configuration)
来注册相关服务了。这个扩展方法的参数包括 IServiceCollection
(用于依赖注入)和 IConfiguration
(用来读取配置信息,比如密钥等)。在方法内部,我们会先从配置文件(我们的项目的配置文件是在nacos
中)里读取 JWT 的签名密钥和加密密钥,这里要注意的是这两个密钥都需要是 Base64 格式的字符串,否则后续配置会报错。代码如下:
using Microsoft.IdentityModel.Tokens;
using OpenIddict.Abstractions;
using OpenIddict.Validation.AspNetCore;
using SP.IdentityService.DB;
namespace SP.IdentityService;
/// <summary>
/// OpenIddict服务扩展
/// </summary>
public static class OpenIddictServiceExtensions
{
/// <summary>
/// 添加OpenIddict服务
/// </summary>
/// <param name="services"></param>
/// <param name="configuration"></param>
/// <returns></returns>
public static IServiceCollection AddOpenIddict(this IServiceCollection services, IConfiguration configuration)
{
// 从配置文件中读取密钥
string signingKey = configuration["Jwt:SigningKey"];
string encryptionKey = configuration["Jwt:EncryptionKey"];
// more code ...
}
}
接下来就是 OpenIddict 的核心配置了。我们通过 services.AddOpenIddict()
开始链式调用,首先用 .AddCore
配置数据存储,这里选择 Entity Framework Core,并指定了 IdentityServerDbContext
作为上下文,这样所有 OpenIddict 相关的数据(比如授权、令牌等)都会存到数据库里。代码如下:
services.AddOpenIddict()
.AddCore(options =>
{
// 使用 EntityFrameworkCore作为数据源
options.UseEntityFrameworkCore()
.UseDbContext<IdentityServerDbContext>();
})
然后是服务器端的配置部分。我们设置了令牌端点为 connect/token
,并且支持密码模式、客户端凭证模式和刷新令牌模式,这样无论是用户登录还是服务间通信都能灵活支持。作用域方面,注册了 api
和离线访问(也就是刷新令牌),同时还注册了常用的声明,比如用户名、角色和邮箱。令牌的有效期也做了详细设置,访问令牌 30 分钟,刷新令牌 14 天,并且有 2 分钟的重用宽限期,防止并发刷新时出现问题。
安全方面,开发环境下我们用的是临时证书(AddDevelopmentEncryptionCertificate
和 AddDevelopmentSigningCertificate
),但生产环境一定要换成正式证书。签名和加密密钥则是用配置文件里的对称密钥(Base64 格式),分别通过 AddSigningKey
和 AddEncryptionKey
加入。这里还允许了匿名客户端,也就是说 client_id 和 client_secret 可以不传,这在内部服务间通信时很方便,但如果是对外的应用就要谨慎使用。我们还启用了引用刷新令牌(令牌本身只是一个引用,实际内容存在服务端,更安全),并且关闭了访问令牌加密(这样令牌内容可见,调试更方便,生产环境可以根据需要开启)。最后,集成了 ASP.NET Core,允许 HTTP(开发环境下),并启用了令牌端点直通,方便调试。代码如下:
.AddServer(options =>
{
// 设置令牌端点
options.SetTokenEndpointUris("connect/token");
// 启用密码模式
options.AllowPasswordFlow() // 开启密码模式
.AllowClientCredentialsFlow() // 开启客户端令牌模式
.AllowRefreshTokenFlow(); // 开启刷新令牌
// 注册授权范围
options.RegisterScopes("api", OpenIddictConstants.Scopes.OfflineAccess);
// 注册所有资源
options.RegisterClaims(
OpenIddictConstants.Claims.Name,
OpenIddictConstants.Claims.Role,
OpenIddictConstants.Claims.Email);
// 配置令牌属性
options.SetAccessTokenLifetime(TimeSpan.FromMinutes(30));
options.SetRefreshTokenLifetime(TimeSpan.FromDays(14));
options.SetRefreshTokenReuseLeeway(TimeSpan.FromMinutes(2));
// 使用开发环境下的临时密钥(生产环境请使用持久化证书)
options.AddDevelopmentEncryptionCertificate()
.AddDevelopmentSigningCertificate();
options.AddSigningKey(
new SymmetricSecurityKey(
Convert.FromBase64String(signingKey)));
options.AddEncryptionKey(
new SymmetricSecurityKey(Convert.FromBase64String(encryptionKey)));
// 允许接收表单数据
options.AcceptAnonymousClients();
// 配置令牌选项 - 使用引用刷新令牌使令牌更短
options.UseReferenceRefreshTokens();
options.DisableAccessTokenEncryption();
// 集成 ASP.NET Core
options.UseAspNetCore()
.EnableTokenEndpointPassthrough()
.DisableTransportSecurityRequirement(); // 开发模式下禁用HTTPS
})
验证部分也很简单,直接用 .AddValidation
配置,指定使用本地 OpenIddict 服务器和 ASP.NET Core 集成,这样后续的令牌验证就都走本地服务了。最后别忘了注册认证方案,直接用 services.AddAuthentication(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)
,这样在控制器或者中间件里就能用 [Authorize]
进行权限控制了。代码如下:
.AddValidation(options =>
{
// 使用本地服务器进行验证
options.UseLocalServer();
// 使用 AspNetCore 进行验证
options.UseAspNetCore();
});
services.AddAuthentication(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme);
return services;
这一套配置下来,OpenIddict 就能支持多种 OAuth2 授权模式,非常适合微服务架构下的认证服务。但是需要注意的是,开发环境下允许 HTTP 和用开发证书没问题,但上线后一定要加强安全配置,比如强制 HTTPS、换成正式证书、密钥管理等。另外,密钥一定要用 Base64 格式,开发证书只适合本地调试,允许匿名客户端也只建议在内部服务间使用,外部应用要严格校验身份。
二、Program.cs配置
接下来,我们需要在 Program.cs
文件中配置 ASP.NET Core Identity 和 OpenIddict 服务。我们将使用 AddIdentity
方法来添加身份服务,并配置密码策略、锁定设置等。同时,我们还需要注册 OpenIddict 服务,以便实现 OAuth2.0 和 OpenID Connect 协议,代码如下:
// 添加ASP.NET Core Identity服务
builder.Services.AddIdentity<SpUser, SpRole>(options =>
{
// 密码策略配置
options.Password.RequireDigit = true; // 要求数字
options.Password.RequireLowercase = true; // 要求小写字母
options.Password.RequireUppercase = true; // 要求大写字母
options.Password.RequireNonAlphanumeric = true; // 要求特殊字符
options.Password.RequiredLength = 6; // 最小长度
options.Password.RequiredUniqueChars = 4; // 要求不同字符的最小数量
// 锁定设置
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15); // 锁定15分钟
options.Lockout.MaxFailedAccessAttempts = 5; // 5次失败尝试后锁定
options.Lockout.AllowedForNewUsers = true; // 对新用户启用锁定
// 禁用用户名和邮箱规范化
options.User.RequireUniqueEmail = false;
options.User.AllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+";
})
.AddEntityFrameworkStores<IdentityServerDbContext>()
.AddDefaultTokenProviders();
// 替换默认的 UserStore
builder.Services.AddScoped<IUserStore<SpUser>, SPUserStore>();
builder.Services.AddOpenIddict(builder.Configuration);
这段代码首先通过 builder.Services.AddIdentity<SpUser, SpRole>(...)
这行代码将身份服务注册到依赖注入容器中,指定了用户类型 SpUser
和角色类型 SpRole
。在配置委托中,详细设置了密码策略,密码必须包含数字、小写字母、大写字母、特殊字符,最小长度为 6,并且至少包含 4 个不同的字符,这样可以提升账户安全性。接着,配置了账户锁定策略:如果用户连续 5 次登录失败,账户会被锁定 15 分钟,并且新用户也适用这个锁定策略。options.User.RequireUniqueEmail = false
表示不强制邮箱唯一,AllowedUserNameCharacters
则限制了用户名只能包含指定的字符集。
随后 .AddEntityFrameworkStores<IdentityServerDbContext>()
指定了 Identity 使用的数据库上下文,这样用户和角色等信息会存储在 IdentityServerDbContext
管理的数据库中。.AddDefaultTokenProviders()
则为身份系统添加了默认的令牌生成器,比如用于密码重置、邮箱确认等场景。最后 builder.Services.AddOpenIddict(builder.Configuration);
这行代码将 OpenIddict 服务注册到容器中,并通过配置文件进行初始化。
三、配置数据库上下文
接下来,我们需要配置数据库上下文 IdentityServerDbContext
,它将用于存储用户、角色和 OpenIddict 的相关数据。我们将创建一个新的类 IdentityServerDbContext
,继承自 IdentityDbContext<SpUser, SpRole>
,并实现 OpenIddict 的数据存储接口。代码如下:
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using SP.Common;
using SP.IdentityService.Models.Entity;
namespace SP.IdentityService.DB;
/// <summary>
/// IdentityServer数据库上下文
/// </summary>
public class IdentityServerDbContext : IdentityDbContext<SpUser, SpRole, long>
{
/// <summary>
/// 数据库连接配置
/// </summary>
IConfiguration _dbConfig;
/// <summary>
/// 构造函数
/// </summary>
/// <param name="dbConfig"></param>
public IdentityServerDbContext(IConfiguration dbConfig)
{
_dbConfig = dbConfig;
}
/// <summary>
/// 配置数据库模型
/// </summary>
/// <param name="modelBuilder"></param>
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// 配置 OpenIddict
modelBuilder.UseOpenIddict();
// 修改Users表
modelBuilder.Entity<SpUser>(b =>
{
b.Property(x => x.UserName).IsRequired().HasMaxLength(50);
b.Property(x => x.Email).HasMaxLength(100);
b.Property(x => x.LockoutEnd);
b.Property(x => x.PasswordHash).IsRequired();
b.Ignore(x => x.NormalizedUserName);
b.Ignore(x => x.NormalizedEmail);
b.Ignore(x => x.SecurityStamp);
b.Ignore(x => x.ConcurrencyStamp);
b.Ignore(x => x.TwoFactorEnabled);
b.Ignore(x => x.PhoneNumberConfirmed);
b.Ignore(x => x.AccessFailedCount);
});
SeedData(modelBuilder);
}
/// <summary>
/// 数据库连接配置
/// </summary>
/// <param name="optionsBuilder"></param>
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
var serverVersion = ServerVersion.AutoDetect(_dbConfig.GetConnectionString("MySQLConnection"));
optionsBuilder.UseMySql(_dbConfig.GetConnectionString("MySQLConnection"), serverVersion);
}
private void SeedData(ModelBuilder builder)
{
// 添加默认角色
SpRole adminRole = new SpRole { Id = Snow.GetId(), Name = "Admin", NormalizedName = "ADMIN" };
SpRole userRole = new SpRole { Id = Snow.GetId(), Name = "User", NormalizedName = "USER" };
builder.Entity<SpRole>().HasData(adminRole,userRole);
// 添加默认用户
var hasher = new PasswordHasher<SpUser>();
SpUser adminUser = new SpUser
{
Id = Snow.GetId(),
UserName = "admin",
Email = "494324190@qq.com",
EmailConfirmed = true,
PasswordHash = hasher.HashPassword(null, "123*asdasd")
};
builder.Entity<SpUser>().HasData(adminUser);
// 添加用户角色
builder.Entity<IdentityUserRole<long>>().HasData(
new IdentityUserRole<long>
{
UserId = adminUser.Id,
RoleId = adminRole.Id
}
);
}
}
这段代码定义了一个名为 IdentityServerDbContext
的类,它继承自 IdentityDbContext<SpUser, SpRole, long>
,用于管理和操作与身份认证相关的数据表。这个类主要服务于基于 ASP.NET Core Identity 和 OpenIddict 的认证微服务,负责与数据库进行交互。构造函数通过依赖注入的方式接收 IConfiguration
,用于获取数据库连接字符串,这样可以灵活地根据不同环境配置数据库连接。
在 OnConfiguring
方法中,代码通过 _dbConfig.GetConnectionString("MySQLConnection")
获取名为 MySQLConnection
的连接字符串,并利用 ServerVersion.AutoDetect
自动检测 MySQL 服务器版本,最后调用 optionsBuilder.UseMySql
配置 EF Core 使用 MySQL 作为数据库。这种写法让数据库配置更加灵活,便于后续维护和环境切换。
OnModelCreating
方法是 Entity Framework Core 的一个重要扩展点,用于自定义数据模型的结构。首先调用 base.OnModelCreating(modelBuilder)
保证父类的默认配置被应用。接着,modelBuilder.UseOpenIddict()
这行代码集成了 OpenIddict 的数据模型,确保认证相关表结构被正确创建。随后对 SpUser
实体进行了详细配置,将 UserName
字段设置为必填且最大长度为 50,Email
最大长度为 100,PasswordHash
也被标记为必填。通过 b.Ignore
忽略了一些不需要映射到数据库的属性,比如 NormalizedUserName
、SecurityStamp
等,这样可以减少表结构冗余,只保留业务需要的字段。
在 SeedData
方法中,代码实现了数据库的初始化种子数据。首先创建了两个默认角色 Admin 和 User,并通过 builder.Entity<SpRole>().HasData
方法插入到数据库。然后使用 PasswordHasher<SpUser>
对象对默认管理员用户的密码进行加密,创建一个用户名为 admin 的用户,并设置邮箱、邮箱确认等属性,最后同样通过 HasData 方法插入数据库。最后,代码还为管理员用户分配了管理员角色,确保系统初始化后就有一个可用的管理员账号和角色权限。
这个类不仅负责数据库的连接和模型配置,还通过种子数据保证了系统的可用性和安全性。通过继承和重写 EF Core 的相关方法,实现了高度自定义和灵活的身份认证数据管理,适合微服务架构下的认证服务需求。
四、配置用户存储
在微服务架构中,我们需要自定义用户存储,以便更好地管理用户数据。我们将创建一个新的类 SPUserStore
,实现 IUserStore<SpUser>
接口,并提供用户的增删改查等操作。代码如下:
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using SP.IdentityService.Models.Entity;
namespace SP.IdentityService.DB;
/// <summary>
/// 用户存储类
/// </summary>
public class SPUserStore : UserStore<SpUser, SpRole, IdentityServerDbContext, long>
{
/// <summary>
/// 用户存储类构造函数
/// </summary>
/// <param name="context"></param>
/// <param name="describer"></param>
public SPUserStore(IdentityServerDbContext context, IdentityErrorDescriber describer = null)
: base(context, describer)
{
}
/// <summary>
/// 查找用户
/// </summary>
/// <param name="normalizedUserName"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public override Task<SpUser> FindByNameAsync(string normalizedUserName,
CancellationToken cancellationToken = default)
{
return Users.FirstOrDefaultAsync(u => u.UserName == normalizedUserName, cancellationToken);
}
/// <summary>
/// 查找用户通过邮箱
/// </summary>
/// <param name="normalizedEmail"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public override Task<SpUser> FindByEmailAsync(string normalizedEmail, CancellationToken cancellationToken = default)
{
return Users.FirstOrDefaultAsync(u => u.Email == normalizedEmail, cancellationToken);
}
}
这段代码实现了一个自定义的用户存储类 SPUserStore
,它主要用于在 ASP.NET Core Identity 框架下管理用户数据。SPUserStore
继承自 UserStore<SpUser, SpRole, IdentityServerDbContext, long>
,它基于泛型参数指定了用户类型为 SpUser
,角色类型为 SpRole
,数据库上下文为 IdentityServerDbContext
,主键类型为 long
。通过继承 UserStore
,SPUserStore
自动获得了许多用户管理的基础功能,比如用户的创建、删除、更新、查找等操作,无需手动实现所有接口方法。
在构造函数中,SPUserStore
接收一个 IdentityServerDbContext
实例作为参数,这个上下文类通常继承自 IdentityDbContext
,用于与数据库进行交互。可选的 IdentityErrorDescriber
参数允许自定义错误信息的描述方式,便于本地化或个性化错误提示。构造函数内部直接调用了基类的构造函数,将参数传递下去,确保父类能够正常初始化。
SPUserStore
还重写了两个查找用户的方法。第一个是 FindByNameAsync
,它根据标准化后的用户名normalizedUserName
异步查找用户。这里通过 Users.FirstOrDefaultAsync
查询数据库中的用户集合,查找用户名等于传入参数的用户。需要注意的是,这里直接用 u.UserName == normalizedUserName
进行比较,是因为我们前面将NormalizedUserName
列屏蔽掉了。第二个方法 FindByEmailAsync 的实现方式类似,根据标准化后的邮箱查找用户,同样也是因为我们前面将NormalizedEmail
屏蔽掉了。
通过自定义 SPUserStore,你可以灵活扩展用户存储逻辑,比如增加自定义的查找条件、支持多租户、集成第三方数据源等。整体来看,这段代码为微服务架构下的认证服务提供了基础的数据访问层,既复用了 Identity 框架的能力,又为后续扩展留出了空间。
五、编写授权控制器
在微服务架构中,我们需要一个控制器来处理用户认证和令牌管理。我们将创建一个新的控制器 AuthorizationController
,用于处理用户登录、获取令牌等操作。代码如下:
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity.Data;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
using SP.Common.ExceptionHandling.Exceptions;
using SP.IdentityService.Models.Request;
using SP.IdentityService.Service;
namespace SP.IdentityService.Controllers;
/// <summary>
/// 授权控制器
/// </summary>
[Route("connect")]
[ApiController]
public class AuthorizationController : ControllerBase
{
private readonly IAuthorizationService _authorizationService;
private readonly ILogger<AuthorizationController> _logger;
/// <summary>
/// 授权控制器构造函数
/// </summary>
/// <param name="authorizationService"></param>
/// <param name="logger"></param>
public AuthorizationController(IAuthorizationService authorizationService, ILogger<AuthorizationController> logger)
{
_logger = logger;
_authorizationService = authorizationService;
}
/// <summary>
/// 令牌端点 - 获取访问令牌
/// </summary>
/// <remarks>
/// 请求示例:
///
/// POST /connect/token
/// Content-Type: application/x-www-form-urlencoded
///
/// grant_type=password&username=admin&password=123*asdasd&scope=api offline_access
///
/// 或者刷新令牌:
///
/// grant_type=refresh_token&refresh_token=YOUR_REFRESH_TOKEN&scope=api
///
/// 或者客户端凭证模式:
///
/// grant_type=client_credentials&client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET&scope=api
///
/// 注意:
/// 1. 必须使用表单(form-data)方式提交,Content-Type为application/x-www-form-urlencoded
/// 2. 不要将参数放在URL查询字符串中
/// 3. 在刷新令牌模式下,refresh_token必须放在请求体中,不能放在URL中
/// 4. 客户端凭证模式适用于服务器到服务器的API调用,不关联特定用户
/// </remarks>
/// <returns>返回访问令牌信息</returns>
/// <response code="200">返回访问令牌</response>
/// <response code="400">请求格式不正确或不支持的授权类型</response>
/// <response code="403">认证失败</response>
[HttpPost("token")]
[Consumes("application/x-www-form-urlencoded")]
[Produces("application/json")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> Token()
{
// 检查是否通过查询参数传递敏感信息
if (Request.Query.Count > 0 &&
(Request.Query.ContainsKey("refresh_token") || Request.Query.ContainsKey("password") ||
Request.Query.ContainsKey("client_secret")))
{
throw new BadRequestException("不要在URL中包含敏感信息,请使用表单提交方式");
}
// 检查请求头中是否包含敏感信息
if (Request.Headers.Any(h => h.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase) &&
h.Value.ToString().Contains("Basic")))
{
// 记录警告日志,但允许继续处理,因为某些客户端可能使用Basic认证
_logger.LogWarning("检测到使用Basic认证,建议改用表单提交方式");
}
var request = HttpContext.GetOpenIddictServerRequest();
if (request == null)
{
throw new BadRequestException("请求格式不正确,请使用表单(application/x-www-form-urlencoded)提交");
}
// 处理资源所有者密码模式
if (request.IsPasswordGrantType())
{
// 验证用户名和密码
if (string.IsNullOrEmpty(request.Username) || string.IsNullOrEmpty(request.Password))
{
throw new BusinessException("用户名或密码不能为空");
}
var principal =
await _authorizationService.LoginByPasswordAsync(request.Username, request.Password,
request.GetScopes());
// 确保 SignIn 方法只在授权端点调用
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
// 处理刷新令牌
if (request.IsRefreshTokenGrantType())
{
// 从 request 上下文中获取之前存储的身份验证票据
var principal =
(await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme))
?.Principal;
var newPrincipal =
await _authorizationService.RefreshTokenAsync(request.RefreshToken, request.GetScopes(), principal);
return SignIn(newPrincipal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
// 处理客户端凭证模式
if (request.IsClientCredentialsGrantType())
{
var clientId = request.ClientId;
if (string.IsNullOrEmpty(clientId))
{
throw new BusinessException("client_id不能为空");
}
var principal =
await _authorizationService.HandleClientCredentialsAsync(clientId, request.GetScopes());
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
// 不支持的授权类型
return BadRequest(new
{
error = OpenIddictConstants.Errors.UnsupportedGrantType,
error_description = "不支持的授权类型。"
});
}
/// <summary>
/// 注册用户
/// </summary>
/// <param name="user"></param>
[HttpPost("register")]
public async Task<ActionResult<long>> Register([FromBody] UserAddRequest user)
{
var result = await _authorizationService.AddUserAsync(user);
return Ok(result);
}
/// <summary>
/// 发送邮件
/// </summary>
/// <param name="email"></param>
[HttpPost("emails/send")]
public async Task<ActionResult> SendEmail([FromBody] SendEmailRequest email)
{
await _authorizationService.SendEmailAsync(email);
return Ok();
}
/// <summary>
/// 绑定邮箱
/// </summary>
/// <param name="verifyCode"></param>
[HttpPost("email/bind")]
public async Task<ActionResult> BindEmail([FromBody] VerifyCodeRequest verifyCode)
{
await _authorizationService.AddEmailAsync(verifyCode);
return Ok();
}
/// <summary>
/// 重置密码
/// </summary>
/// <param name="resetPasswordRequest"></param>
[HttpPut("password/reset")]
public async Task<ActionResult> ResetPassword([FromBody] ResetPasswordRequest resetPasswordRequest)
{
await _authorizationService.ResetPasswordAsync(resetPasswordRequest);
return Ok();
}
}
这段代码实现了一个名为 AuthorizationController
的控制器,主要用于处理用户认证和令牌管理相关的操作。控制器使用了 ASP.NET Core 的特性路由,通过 [Route("connect")]
定义了基础路由前缀,所有方法都以 /connect
开头。控制器的构造函数接收两个依赖项:IAuthorizationService
和 ILogger<AuthorizationController>
。IAuthorizationService
是一个自定义的服务接口,用于处理用户认证、令牌生成等逻辑;ILogger<AuthorizationController>
则用于记录日志。
控制器中定义了多个 API 端点,主要包括:
- Token:处理令牌端点的 POST 请求,支持多种授权类型,包括资源所有者密码模式、刷新令牌和客户端凭证模式。通过
HttpContext.GetOpenIddictServerRequest()
获取请求信息,并根据不同的授权类型调用相应的服务方法进行处理。返回的结果是一个身份验证票据,使用SignIn
方法将其签名并返回给客户端。 - Register:处理用户注册的 POST 请求,接收一个
UserAddRequest
对象,调用IAuthorizationService.AddUserAsync
方法进行用户注册,并返回新用户的 ID。 - SendEmail:处理发送邮件的 POST 请求,接收一个
SendEmailRequest
对象,调用IAuthorizationService.SendEmailAsync
方法发送邮件。 - BindEmail:处理绑定邮箱的 POST 请求,接收一个
VerifyCodeRequest
对象,调用IAuthorizationService.AddEmailAsync
方法进行邮箱绑定。 - ResetPassword:处理重置密码的 PUT 请求,接收一个
ResetPasswordRequest
对象,调用IAuthorizationService.ResetPasswordAsync
方法进行密码重置。
这些方法都使用了 ASP.NET Core 的特性来定义请求类型、响应类型和错误处理。比如,[Consumes("application/x-www-form-urlencoded")]
指定了请求的内容类型,[Produces("application/json")]
指定了响应的内容类型。每个方法还定义了不同的响应状态码,比如 200 OK、400 Bad Request 和 403 Forbidden 等。
这里我们重点讲解一下 Token
方法的实现。这个方法是处理令牌请求的核心逻辑,首先检查请求中是否包含敏感信息(如密码、刷新令牌等),如果有则抛出异常。接着,通过 HttpContext.GetOpenIddictServerRequest()
获取 OpenIddict 的请求对象,并根据授权类型进行不同的处理。
如果是资源所有者密码模式(Password Grant Type),则验证用户名和密码,并调用 IAuthorizationService.LoginByPasswordAsync
方法进行登录,最后返回签名的身份验证票据。如果是刷新令牌模式(Refresh Token Grant Type),则从请求中获取之前存储的身份验证票据,并调用 IAuthorizationService.RefreshTokenAsync
方法进行刷新,返回新的身份验证票据。如果是客户端凭证模式(Client Credentials Grant Type),则根据 client_id
调用 IAuthorizationService.HandleClientCredentialsAsync
方法处理。最后,如果请求的授权类型不被支持,则返回 400 Bad Request 错误。
通过这种方式,AuthorizationController
实现了一个完整的用户认证和令牌管理功能,支持多种授权模式,适用于微服务架构下的身份认证服务。
六、编写授权服务
在微服务架构中,我们需要一个服务来处理用户认证和令牌管理的业务逻辑。我们将创建一个新的服务 IAuthorizationService
,用于处理用户登录、注册、令牌生成等操作,这里我们只看LoginByPasswordAsync
、RefreshTokenAsync
和HandleClientCredentialsAsync
方法的实现。代码如下:
/// <summary>
/// 密码登录
/// </summary>
/// <param name="userName"></param>
/// <param name="password"></param>
/// <param name="scopes"></param>
/// <returns></returns>
public async Task<ClaimsPrincipal> LoginByPasswordAsync(string userName, string password,
ImmutableArray<string> scopes)
{
// 使用ASP.NET Core Identity验证用户
SpUser? user = await _userManager.FindByNameAsync(userName);
if (user == null)
{
throw new BusinessException("用户不存在");
}
// 验证密码
var result = await _signInManager.CheckPasswordSignInAsync(user, password, lockoutOnFailure: true);
if (!result.Succeeded)
{
if (result.IsLockedOut)
{
throw new BusinessException("账户已锁定,请稍后再试。");
}
throw new BusinessException("用户名或密码错误。");
}
// 创建用户身份并添加必要的声明
var identity = new ClaimsIdentity(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
identity.AddClaim(OpenIddictConstants.Claims.Subject, await _userManager.GetUserIdAsync(user));
identity.AddClaim(OpenIddictConstants.Claims.Name, user.UserName);
identity.AddClaim(OpenIddictConstants.Claims.Email, user.Email);
identity.AddClaim(OpenIddictConstants.Claims.Audience, "api"); // 添加 aud 声明
// 添加用户角色
foreach (var role in await _userManager.GetRolesAsync(user))
{
identity.AddClaim(ClaimTypes.Role, role);
}
// 创建 ClaimsPrincipal,并设置请求的范围
var principal = new ClaimsPrincipal(identity);
// 正确设置范围
if (scopes.Any())
{
// 验证范围是否有效
var validScopes = new[] { "api", OpenIddictConstants.Scopes.OfflineAccess };
var filteredScopes = scopes.Intersect(validScopes).ToList();
if (filteredScopes.Any())
{
principal.SetScopes(filteredScopes);
}
else
{
// 如果没有有效范围,默认设置为 api
principal.SetScopes("api");
}
}
else
{
// 默认设置为 api
principal.SetScopes("api");
}
var roles = await _userManager.GetRolesAsync(user);
// 根据用户角色或请求来源调整令牌生命周期
if (roles.Contains("Admin"))
{
// 管理员令牌生命周期较短
principal.SetAccessTokenLifetime(TimeSpan.FromMinutes(15));
principal.SetRefreshTokenLifetime(TimeSpan.FromDays(7));
}
else if (_httpContextAccessor?.HttpContext?.Request.Headers.TryGetValue("User-Agent", out var userAgent) ==
true &&
userAgent.ToString().Contains("Mobile"))
{
// 移动设备令牌生命周期较长
principal.SetAccessTokenLifetime(TimeSpan.FromHours(1));
principal.SetRefreshTokenLifetime(TimeSpan.FromDays(30));
}
else
{
// 默认设置
principal.SetAccessTokenLifetime(TimeSpan.FromMinutes(30));
principal.SetRefreshTokenLifetime(TimeSpan.FromDays(14));
}
return principal;
}
LoginByPasswordAsync
方法实现了基于用户名和密码的登录认证流程,通常用于用户通过账号密码登录系统时的场景。首先,它通过 _userManager.FindByNameAsync(userName) 查找用户对象 SpUser,如果找不到用户,则抛出用户不存在的业务异常。接下来,使用 _signInManager.CheckPasswordSignInAsync
验证密码是否正确,并且支持账户锁定机制。如果密码错误且账户被锁定,会抛出账户已锁定的异常,否则抛出用户名或密码错误。认证通过后,方法会创建一个 ClaimsIdentity
,并为其添加一系列声明(Claims),包括用户ID(Subject)、用户名(Name)、邮箱(Email)和受众(Audience,通常用于标识令牌的目标API)。此外,还会遍历用户的角色列表,将每个角色作为 ClaimTypes.Role
添加到身份中。
然后,方法将 ClaimsIdentity
封装为 ClaimsPrincipal
,并根据传入的 scopes
参数设置令牌的作用域。它会校验作用域是否合法(只允许 “api” 和 “offline_access”),如果没有合法作用域则默认设置为 “api”。接下来,根据用户角色和请求来源(比如是否为移动端),动态调整访问令牌和刷新令牌的有效期。例如,管理员的令牌有效期较短,移动端的有效期较长,普通用户则为默认值。最后返回构建好的 ClaimsPrincipal,用于后续生成令牌。
/// <summary>
/// 刷新token
/// </summary>
/// <param name="refreshToken"></param>
/// <param name="scopes"></param>
/// <param name="principal"></param>
/// <returns></returns>
public async Task<ClaimsPrincipal> RefreshTokenAsync(string? refreshToken, ImmutableArray<string> scopes,
ClaimsPrincipal? principal)
{
if (principal == null)
{
throw new BusinessException("提供的刷新令牌无效或已过期");
}
// 检索用户身份
var userId = principal.GetClaim(OpenIddictConstants.Claims.Subject);
var user = await _userManager.FindByIdAsync(userId);
if (user == null)
{
throw new BusinessException("用户不存在");
}
// 如果用户被禁用或锁定,返回错误
if (!await _userManager.IsEmailConfirmedAsync(user) || await _userManager.IsLockedOutAsync(user))
{
throw new BusinessException("用户已被禁用或锁定");
}
// 创建新的ClaimsPrincipal
var identity = new ClaimsIdentity(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
identity.AddClaim(OpenIddictConstants.Claims.Subject, await _userManager.GetUserIdAsync(user));
identity.AddClaim(OpenIddictConstants.Claims.Name, user.UserName);
identity.AddClaim(OpenIddictConstants.Claims.Email, user.Email);
identity.AddClaim(OpenIddictConstants.Claims.Audience, "api");
// 添加角色声明
foreach (var role in await _userManager.GetRolesAsync(user))
{
identity.AddClaim(ClaimTypes.Role, role);
}
var newPrincipal = new ClaimsPrincipal(identity);
// 在设置新范围之前,保存原始令牌的离线访问范围
bool hasOfflineAccess = principal.HasScope(OpenIddictConstants.Scopes.OfflineAccess);
// 正确设置范围
if (scopes.Any())
{
// 验证范围是否有效
var validScopes = new[] { "api", OpenIddictConstants.Scopes.OfflineAccess };
var filteredScopes = scopes.Intersect(validScopes).ToList();
if (filteredScopes.Any())
{
newPrincipal.SetScopes(filteredScopes);
}
else
{
// 如果没有有效范围,默认设置为 api
var defaultScopes = new List<string> { "api" };
// 如果原始令牌有离线访问范围,保留它
if (hasOfflineAccess)
{
defaultScopes.Add(OpenIddictConstants.Scopes.OfflineAccess);
}
newPrincipal.SetScopes(defaultScopes);
}
}
else
{
// 默认设置为 api
var defaultScopes = new List<string> { "api" };
// 如果原始令牌有离线访问范围,保留它
if (hasOfflineAccess)
{
defaultScopes.Add(OpenIddictConstants.Scopes.OfflineAccess);
}
newPrincipal.SetScopes(defaultScopes);
}
// 设置令牌生命周期
newPrincipal.SetRefreshTokenLifetime(TimeSpan.FromDays(14));
newPrincipal.SetAccessTokenLifetime(TimeSpan.FromMinutes(30));
return newPrincipal;
}
RefreshTokenAsync
这个方法用于刷新令牌(refresh token),即用户在 access token 过期后,通过 refresh token 换取新的 access token。首先会校验传入的 ClaimsPrincipal
是否有效,如果无效则抛出刷新令牌无效或已过期的异常。然后通过 principal.GetClaim(OpenIddictConstants.Claims.Subject)
获取用户ID,并查找用户对象。如果用户不存在,或者被禁用/锁定,则抛出相应的业务异常。
接下来,方法会重新构建一个新的 ClaimsIdentity
,并为其添加用户ID、用户名、邮箱、受众等声明,以及用户的所有角色。然后创建新的 ClaimsPrincipal
。在设置作用域时,方法会优先使用传入的 scopes
,但如果没有合法作用域,则会保留原始令牌中的 “offline_access” 范围(如果原始令牌有的话),确保刷新令牌的能力不会丢失。否则,默认作用域为 “api”。最后为新的 principal 设置刷新令牌和访问令牌的有效期(分别为14天和30分钟),然后返回新的 ClaimsPrincipal
,用于生成新的令牌。
/// <summary>
/// 处理客户端凭证模式
/// </summary>
/// <param name="clientId"></param>
/// <param name="scopes"></param>
/// <returns></returns>
public async Task<ClaimsPrincipal> HandleClientCredentialsAsync(string clientId, ImmutableArray<string> scopes)
{
var application = await _applicationManager.FindByClientIdAsync(clientId) ??
throw new BuildAbortedException("找不到应用");
// 创建一个新的ClaimsIdentity,包含将用于创建id_token、token或code的声明。
var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType,
OpenIddictConstants.Claims.Name, OpenIddictConstants.Claims.Role);
// 使用client_id作为主体标识符。
identity.SetClaim(OpenIddictConstants.Claims.Subject,
await _applicationManager.GetClientIdAsync(application));
identity.SetClaim(OpenIddictConstants.Claims.Name,
await _applicationManager.GetDisplayNameAsync(application));
// 添加受众声明
identity.SetClaim(OpenIddictConstants.Claims.Audience, "api");
// 设置声明的目标
identity.SetDestinations(static claim => claim.Type switch
{
// 当授予"profile"范围时(通过调用principal.SetScopes(...)),
// 允许"name"声明同时存储在访问令牌和身份令牌中。
OpenIddictConstants.Claims.Name when claim.Subject.HasScope(OpenIddictConstants.Permissions.Scopes
.Profile)
=> [OpenIddictConstants.Destinations.AccessToken, OpenIddictConstants.Destinations.IdentityToken],
// 否则,仅将声明存储在访问令牌中。
_ => [OpenIddictConstants.Destinations.AccessToken]
});
var principal = new ClaimsPrincipal(identity);
// 正确设置范围
if (scopes.Any())
{
// 验证范围是否有效
var validScopes = new[] { "api" };
var filteredScopes = scopes.Intersect(validScopes).ToList();
if (filteredScopes.Any())
{
principal.SetScopes(filteredScopes);
}
else
{
// 如果没有有效范围,默认设置为 api
principal.SetScopes("api");
}
}
else
{
// 默认设置为 api
principal.SetScopes("api");
}
// 设置令牌生命周期
principal.SetAccessTokenLifetime(TimeSpan.FromHours(1)); // 客户端凭证默认1小时有效期
return principal;
}
HandleClientCredentialsAsync
这个方法实现了 OAuth2 的客户端凭证(Client Credentials)模式,主要用于服务与服务之间的认证(比如后端服务调用 API)。首先通过 _applicationManager.FindByClientIdAsync(clientId)
查找客户端应用信息,如果找不到则抛出异常。然后创建一个新的 ClaimsIdentity
,并设置主体(Subject)为客户端ID,名称(Name)为客户端显示名,受众(Audience)为 “api”。方法还设置了声明的目标(Destination),比如当请求了 “profile” 范围时,“name” 声明会同时出现在访问令牌和身份令牌中,否则只出现在访问令牌中。接着,将 ClaimsIdentity
封装为 ClaimsPrincipal
,并根据传入的 scopes
校验和设置作用域(只允许 “api”),如果没有合法作用域则默认设置为 “api”。最后将访问令牌的有效期设置为1小时,返回构建好的 ClaimsPrincipal
。
七、总结
通过以上步骤,我们实现了一个基于 ASP.NET Core Identity 和 OpenIddict 的认证微服务。这个微服务支持用户注册、登录、令牌生成和刷新等功能,并且可以灵活地扩展和定制。我们使用了 Entity Framework Core 作为数据访问层,MySQL 作为数据库存储用户和角色信息,同时通过自定义的用户存储类和授权服务,实现了对用户数据的高效管理。
ccessTokenLifetime(TimeSpan.FromHours(1)); // 客户端凭证默认1小时有效期
return principal;
}
`HandleClientCredentialsAsync`这个方法实现了 OAuth2 的客户端凭证(Client Credentials)模式,主要用于服务与服务之间的认证(比如后端服务调用 API)。首先通过 `_applicationManager.FindByClientIdAsync(clientId)` 查找客户端应用信息,如果找不到则抛出异常。然后创建一个新的 `ClaimsIdentity`,并设置主体(Subject)为客户端ID,名称(Name)为客户端显示名,受众(Audience)为 "api"。方法还设置了声明的目标(Destination),比如当请求了 "profile" 范围时,"name" 声明会同时出现在访问令牌和身份令牌中,否则只出现在访问令牌中。接着,将 `ClaimsIdentity` 封装为 `ClaimsPrincipal`,并根据传入的 `scopes` 校验和设置作用域(只允许 "api"),如果没有合法作用域则默认设置为 "api"。最后将访问令牌的有效期设置为1小时,返回构建好的 `ClaimsPrincipal`。
### 七、总结
通过以上步骤,我们实现了一个基于 ASP.NET Core Identity 和 OpenIddict 的认证微服务。这个微服务支持用户注册、登录、令牌生成和刷新等功能,并且可以灵活地扩展和定制。我们使用了 Entity Framework Core 作为数据访问层,MySQL 作为数据库存储用户和角色信息,同时通过自定义的用户存储类和授权服务,实现了对用户数据的高效管理。
在微服务架构中,这个认证服务可以作为其他服务的基础组件,提供统一的用户认证和授权功能。通过 OpenIddict,我们还可以轻松地集成 OAuth2 和 OpenID Connect 协议,为前端应用和第三方服务提供安全的访问控制。