认证服务用来验证用户登录并颁发JWT,也提供用户管理等API。认证服务的实现代码都基于Authentication与Authorization。

开发认证服务的领域层

IdentityService.Domain是认证服务的领域层项目。ASP.NET Core的标识框架中已经提供了IdentityRole、IdentityUser等基础的实体类,我们只要编写它们的子类,然后根据需要再添加自定义的属性即可。

首先创建Role和User类进行编写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
namespace IdentityService.Domain.Entities
{
/// <summary>
/// 表示系统中的角色实体,继承自 ASP.NET Core Identity 的 IdentityRole,主键类型为 Guid。
/// </summary>
public class Role : IdentityRole<Guid>
{
/// <summary>
/// 构造函数。创建角色实例时自动生成唯一的 Guid 作为角色的主键 Id。
/// </summary>
public Role()
{
// 为角色分配一个新的唯一标识符(Guid),确保每个角色的 Id 唯一。
this.Id = Guid.NewGuid();
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
namespace IdentityService.Domain.Entities
{
/// <summary>
/// 用户实体,继承自 ASP.NET Core Identity 的泛型用户类,
/// 并实现了创建时间、删除时间和软删除相关接口。
/// </summary>
public class User : IdentityUser<Guid>, IHasCreationTime, IHasDeletionTime, ISoftDelete
{
/// <summary>
/// 用户的创建时间。
/// </summary>
public DateTime CreationTime { get; init; }

/// <summary>
/// 用户被删除的时间(如果已删除,否则为 null)。
/// </summary>
public DateTime? DeletionTime { get; private set; }

/// <summary>
/// 用户是否已被软删除。
/// </summary>
public bool IsDeleted { get; private set; }

/// <summary>
/// 创建用户实例时,指定用户名,并自动生成唯一 Id 和创建时间。
/// </summary>
/// <param name="userName">用户名</param>
public User(string userName) : base(userName)
{
Id = Guid.NewGuid();
CreationTime = DateTime.Now;
}

/// <summary>
/// 执行软删除操作,将用户标记为已删除,并记录删除时间。
/// </summary>
public void SoftDelete()
{
this.IsDeleted = true;
this.DeletionTime = DateTime.Now;
}
}
}

在根据手机号加短信验证码进行登录的时候,我们需要实现发送短信的功能,而短信验证码发送服务的提供商也比较多,为了屏蔽不同提供商的代码,我们开发了一个防腐层接口ISmsSender。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
namespace IdentityService.Domain
{
/// <summary>
/// 短信发送服务接口。
/// </summary>
public interface ISmsSender
{
/// <summary>
/// 异步发送短信。
/// </summary>
/// <param name="phoneNum">接收短信的手机号码。</param>
/// <param name="args">短信内容参数。</param>
/// <returns>表示异步操作的任务。</returns>
public Task SendAsync(string phoneNum, params string[] args);
}
}

接下来,我们定义仓储接口IIdRepository,这个接口提供了很多方法,比如:根据Id获取用户,重置密码,删除密码等等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
namespace IdentityService.Domain
{
/// <summary>
/// 用户身份仓储接口,定义了用户相关的查找、创建、修改、角色管理等操作。
/// 主要用于对用户实体的持久化操作和身份验证相关功能。
/// </summary>
public interface IIdRepository
{
/// <summary>
/// 根据用户唯一标识查找用户。
/// </summary>
/// <param name="id">用户的唯一标识(GUID)。</param>
/// <returns>找到的用户对象或 null。</returns>
Task<User?> FindByIdAsync(Guid id);

/// <summary>
/// 根据用户名查找用户。
/// </summary>
/// <param name="userName">用户名。</param>
/// <returns>找到的用户对象或 null。</returns>
Task<User?> FindByNameAsync(string userName);

/// <summary>
/// 根据手机号查找用户。
/// </summary>
/// <param name="phoneNum">手机号。</param>
/// <returns>找到的用户对象或 null。</returns>
Task<User?> FindByPhoneNumberAsync(string phoneNum);

/// <summary>
/// 创建新用户并设置密码。
/// </summary>
/// <param name="user">用户对象。</param>
/// <param name="password">用户密码。</param>
/// <returns>创建结果。</returns>
Task<IdentityResult> CreateAsync(User user, string password);

/// <summary>
/// 记录用户登录失败。
/// </summary>
/// <param name="user">用户对象。</param>
/// <returns>操作结果。</returns>
Task<IdentityResult> AccessFaailedAsync(User user);

/// <summary>
/// 生成更换手机号的验证码令牌。
/// </summary>
/// <param name="user">用户对象。</param>
/// <param name="phoneNumber">新手机号。</param>
/// <returns>验证码令牌字符串。</returns>
Task<string> GenerateChangePhoneNumberTokenAsync(User user, string phoneNumber);

/// <summary>
/// 使用验证码令牌更换用户手机号。
/// </summary>
/// <param name="userId">用户唯一标识。</param>
/// <param name="phoneNum">新手机号。</param>
/// <param name="token">验证码令牌。</param>
/// <returns>更换结果。</returns>
Task<SignInResult> ChangePasswordAsync(Guid userId, string phoneNum, string token);

/// <summary>
/// 修改用户密码。
/// </summary>
/// <param name="userId">用户唯一标识。</param>
/// <param name="password">新密码。</param>
/// <returns>修改结果。</returns>
Task<IdentityResult> ChangePasswordAsync(Guid userId, string password);

/// <summary>
/// 获取用户所属的所有角色。
/// </summary>
/// <param name="user">用户对象。</param>
/// <returns>角色名称列表。</returns>
Task<IList<string>> GetRolesAsync(User user);

/// <summary>
/// 将用户添加到指定角色。
/// </summary>
/// <param name="user">用户对象。</param>
/// <param name="role">角色名称。</param>
/// <returns>添加结果。</returns>
Task<IdentityResult> AddToRoleAsync(User user, string role);

/// <summary>
/// 检查用户登录信息是否正确。
/// </summary>
/// <param name="user">用户对象。</param>
/// <param name="password">密码。</param>
/// <param name="lockoutOnFailure">登录失败是否锁定账户。</param>
/// <returns>登录检查结果。</returns>
public Task<SignInResult> CheckForSignInAsync(User user, string password, bool lockoutOnFailure);

/// <summary>
/// 确认用户手机号已验证。
/// </summary>
/// <param name="id">用户唯一标识。</param>
/// <returns>异步操作。</returns>
public Task ConfirmPhoneNumberAsync(Guid id);

/// <summary>
/// 更新用户手机号。
/// </summary>
/// <param name="id">用户唯一标识。</param>
/// <param name="phoneNum">新手机号。</param>
/// <returns>异步操作。</returns>
public Task UpdatePhoneNumberAsync(Guid id, string phoneNum);

/// <summary>
/// 删除用户(软删除)。
/// </summary>
/// <param name="id">用户唯一标识。</param>
/// <returns>删除结果。</returns>
public Task<IdentityResult> RemoveUserAsync(Guid id);

/// <summary>
/// 添加管理员用户,并返回创建结果、用户对象和初始密码。
/// </summary>
/// <param name="userName">用户名。</param>
/// <param name="phoneNum">手机号。</param>
/// <returns>创建结果、用户对象和初始密码。</returns>
public Task<(IdentityResult, User?, string? password)> AddAdminUserAsync(string userName, string phoneNum);

/// <summary>
/// 重置用户密码,并返回操作结果、用户对象和新密码。
/// </summary>
/// <param name="id">用户唯一标识。</param>
/// <returns>重置结果、用户对象和新密码。</returns>
public Task<(IdentityResult, User?, string? password)> ResetPasswordAsync(Guid id);
}
}

最后,我们开发认证领域服务IdDomainService。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
namespace IdentityService.Domain
{
/// <summary>
/// 身份领域服务,负责用户登录、身份校验、令牌生成等核心身份相关业务逻辑。
/// </summary>
public class IdDomainService
{
private readonly IIdRepository repository; // 用户数据仓储接口
private readonly ITokenService tokenService; // JWT 令牌服务
private readonly IOptions<JWTOptions> optJWT; // JWT 配置选项

/// <summary>
/// 构造函数,注入用户仓储、令牌服务和JWT配置。
/// </summary>
public IdDomainService(IIdRepository repository,ITokenService tokenService,IOptions<JWTOptions> optJWT)
{
this.repository = repository;
this.tokenService = tokenService;
this.optJWT = optJWT;
}

/// <summary>
/// 校验用户名和密码是否正确。
/// </summary>
/// <param name="userName">用户名</param>
/// <param name="password">密码</param>
/// <returns>登录结果</returns>
private async Task<SignInResult> CheckUserNameAndPwdAsync(string userName,string password)
{
var user = await repository.FindByNameAsync(userName);
if (user == null)
{
// 用户不存在
return SignInResult.Failed;
}
// 校验密码
var result= await repository.CheckForSignInAsync(user, password,true);
return result;
}

/// <summary>
/// 校验手机号和密码是否正确。
/// </summary>
/// <param name="phoneNum">手机号</param>
/// <param name="password">密码</param>
/// <returns>登录结果</returns>
private async Task<SignInResult> CheckPhoneNumAndPwdAsync(string phoneNum,string password)
{
var user = await repository.FindByPhoneNumberAsync(phoneNum);
if(user == null)
{
// 用户不存在
return SignInResult.Failed;
}
// 校验密码
var result=await repository.CheckForSignInAsync(user, password,true);
return result;
}

/// <summary>
/// 通过手机号和密码登录,成功则返回JWT令牌。
/// </summary>
/// <param name="phoneNum">手机号</param>
/// <param name="password">密码</param>
/// <returns>登录结果和令牌</returns>
public async Task<(SignInResult Result, string? Token)> LoginByPhoneAndPwdAsync(string phoneNum, string password)
{
var checkResult = await CheckPhoneNumAndPwdAsync(phoneNum, password);
if (checkResult.Succeeded)
{
// 登录成功,生成令牌
var user = await repository.FindByPhoneNumberAsync(phoneNum);
string token = await BuildTokenAsync(user);
return (SignInResult.Success,token);
}
else
{
// 登录失败
return (checkResult, null);
}
}

/// <summary>
/// 通过用户名和密码登录,成功则返回JWT令牌。
/// </summary>
/// <param name="userName">用户名</param>
/// <param name="password">密码</param>
/// <returns>登录结果和令牌</returns>
public async Task<(SignInResult Result,string? Token)> LoginByUserNameAndPwdAsync(string userName,string password)
{
var checkResult=await CheckUserNameAndPwdAsync(userName,password);
if (checkResult.Succeeded)
{
// 登录成功,生成令牌
var user=await repository.FindByNameAsync(userName);
string token=await BuildTokenAsync(user);
return (SignInResult.Success, token);
}
else
{
// 登录失败
return (checkResult, null);
}
}

/// <summary>
/// 构建JWT令牌,包含用户Id和角色等声明。
/// </summary>
/// <param name="user">用户实体</param>
/// <returns>JWT令牌字符串</returns>
private async Task<string> BuildTokenAsync(User user)
{
var roles=await repository.GetRolesAsync(user);
List<Claim> claims = new List<Claim>();
// 添加用户唯一标识
claims.Add(new Claim(ClaimTypes.NameIdentifier,user.Id.ToString()));
// 添加用户角色
foreach(string role in roles)
{
claims.Add(new Claim(ClaimTypes.Role, role));
}
// 调用令牌服务生成JWT
return tokenService.BuildToken(claims, optJWT.Value);
}
}
}

LoginByUserNameAndPwdAsync方法根据用户名、密码进行登录。这个方法需要返回是否执行成功和登录成功后生成的JWT两个值。

开发认证服务的基础设施层

认证服务的基础设施层项目IdentityService.Infrastructure,提供模板通过手机号进行短信发送,方便开发,我们用日志输出来模拟短信发送。
开发一个MockSmsSender类,它用日志输出来模拟短信发送。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
namespace IdentityService.Infrastructure.Services
{
/// <summary>
/// 模拟邮件发送服务,实现 IEmailSender 接口。
/// 该实现仅用于开发或测试环境,不实际发送邮件,而是记录日志。
/// </summary>
public class MockEmailSender : IEmailSender
{
private readonly ILogger<MockEmailSender> logger;

/// <summary>
/// 构造函数,注入日志记录器。
/// </summary>
/// <param name="logger">日志记录器实例</param>
public MockEmailSender(ILogger<MockEmailSender> logger)
{
this.logger = logger;
}

/// <summary>
/// 模拟发送邮件,将邮件内容写入日志。
/// </summary>
/// <param name="toEmail">收件人邮箱</param>
/// <param name="subject">邮件主题</param>
/// <param name="body">邮件正文</param>
/// <returns>已完成的任务</returns>
public Task SendAsync(string toEmail, string subject, string body)
{
logger.LogInformation("Send Email to {0},title:{1},body:{2}", toEmail, subject, body);
return Task.CompletedTask;
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
namespace IdentityService.Infrastructure.Services
{
/// <summary>
/// 模拟短信发送服务,实现 <see cref="ISmsSender"/> 接口。
/// 用于开发或测试环境,记录短信内容而不实际发送。
/// </summary>
public class MockSmsSender : ISmsSender
{
private readonly ILogger<MockSmsSender> logger;

/// <summary>
/// 构造函数,注入日志记录器。
/// </summary>
/// <param name="logger">日志记录器。</param>
public MockSmsSender(ILogger<MockSmsSender> logger)
{
this.logger = logger;
}

/// <summary>
/// 模拟异步发送短信,将短信内容写入日志。
/// </summary>
/// <param name="phoneNum">接收短信的手机号码。</param>
/// <param name="args">短信内容参数。</param>
/// <returns>表示异步操作的已完成任务。</returns>
public Task SendAsync(string phoneNum, params string[] args)
{
logger.LogInformation("Send SMS to {0},args:{1}", phoneNum, string.Join(",", args));
return Task.CompletedTask;
}
}
}

在IdDbContext类中的EnableSoftDeletionGlobalFilter()扩展方法,进行全局软删除过滤。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
namespace IdentityService.Infrastructure
{
/// <summary>
/// 表示应用程序的 Identity 数据库上下文。
/// 继承自 <see cref="IdentityDbContext{TUser,TRole,TKey}"/>, 并将用户类型设为 <see cref="User"/>,
/// 角色类型设为 <see cref="Role"/>, 主键类型为 <see cref="Guid"/>
/// </summary>
public class IdDbContext : IdentityDbContext<User, Role, Guid>
{
/// <summary>
/// 使用指定的 DbContextOptions 构造一个新的 <see cref="IdDbContext"/> 实例。
/// </summary>
/// <param name="options">用于配置上下文行为(连接字符串、提供程序、日志等)的选项。</param>
public IdDbContext(DbContextOptions<IdDbContext> options) : base(options)
{
}

/// <summary>
/// 在 EF Core 构建模型时调用,允许配置实体映射、约束和索引等。
/// 重写本方法以在模型创建期间执行自定义配置。
/// </summary>
/// <param name="modelBuilder">用于构建 EF Core 模型的 <see cref="ModelBuilder"/> 实例。</param>
/// <remarks>
/// 执行步骤:
/// 1. 调用 <c>base.OnModelCreating(modelBuilder)</c> 以确保 ASP.NET Core Identity 的默认映射被应用。
/// 2. 调用 <c>modelBuilder.ApplyConfigurationsFromAssembly(this.GetType().Assembly)</c>
/// 从当前程序集扫描并应用所有实现了 IEntityTypeConfiguration 的类型,
/// 使实体的 Fluent API 配置集中、可维护。
/// 3. 调用扩展方法 <c>EnableSoftDeletionGlobalFilter()</c>
/// 为实现软删除接口(例如 <c>ISoftDelete</c>)的实体添加全局查询过滤器,
/// 以便在默认查询中排除被标记为已删除的记录(软删除)。
/// </remarks>
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// 保证 Identity 的默认配置先被应用
base.OnModelCreating(modelBuilder);

// 扫描并应用当前程序集中的所有实体配置(IEntityTypeConfiguration 实现)
modelBuilder.ApplyConfigurationsFromAssembly(this.GetType().Assembly);

// 启用软删除全局过滤器:对实现 ISoftDelete 的实体自动添加 IsDeleted == false 的过滤逻辑
modelBuilder.EnableSoftDeletionGlobalFilter();
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
namespace YU.Infrastructure.EFCore
{
/// <summary>
/// EF Core 相关扩展方法集合。
/// </summary>
public static class EFCoreExtensions
{
/// <summary>
/// 为实现 <see cref="ISoftDelete"/> 的实体启用全局软删除过滤器。
/// 会为每个实现 <see cref="ISoftDelete"/> 的实体自动添加一个查询过滤器,过滤条件为 <c>IsDeleted == false</c>
/// 从而在默认查询中排除已软删除的记录。
/// </summary>
/// <param name="modelBuilder">要配置的 <see cref="ModelBuilder"/> 实例。</param>
/// <remarks>
/// - 该方法在应用启动时(通常在 DbContext 的 OnModelCreating 中)调用一次即可。
/// - 对于未找到名为 "IsDeleted" 的属性或其 PropertyInfo 为 null 的实体类型,方法会跳过该实体以保证健壮性。
/// </remarks>
public static void EnableSoftDeletionGlobalFilter(this ModelBuilder modelBuilder)
{
// 获取所有实体类型,筛选出实现 ISoftDelete 的类型
var entityTypesHasSoftDeletion = modelBuilder.Model.GetEntityTypes()
.Where(e => e.ClrType.IsAssignableTo(typeof(ISoftDelete)));

foreach (var entityType in entityTypesHasSoftDeletion)
{
// 尝试查找名为 IsDeleted 的属性元数据
var isDeletedProperty = entityType.FindProperty(nameof(ISoftDelete.IsDeleted));

// 防御性检查:确保找到了属性并且有可用的 PropertyInfo
if (isDeletedProperty?.PropertyInfo == null)
{
// 如果没有找到属性信息,跳过该实体类型
continue;
}

// 创建一个参数表达式,代表查询中的实体实例: (TEntity p) => ...
var parameter = Expression.Parameter(entityType.ClrType, "p");

// 生成对属性的访问表达式: p.IsDeleted
var propertyAccess = Expression.Property(parameter, isDeletedProperty.PropertyInfo);

// 生成过滤条件: !p.IsDeleted (即 IsDeleted == false)
var notDeletedExpression = Expression.Not(propertyAccess);

// 将条件封装为 LambdaExpression: p => !p.IsDeleted
var filter = Expression.Lambda(notDeletedExpression, parameter);

// 将 LambdaExpression 设置为该实体的全局查询过滤器
entityType.SetQueryFilter(filter);
}
}

/// <summary>
/// 获取指定实体类型的查询集,使用 <see cref="AsNoTracking"/> 以禁用 EF Core 的跟踪(只读场景推荐)。
/// </summary>
/// <typeparam name="T">实体类型,必须为类并实现 <see cref="IEntity"/></typeparam>
/// <param name="ctx">当前的 <see cref="DbContext"/> 实例。</param>
/// <returns>返回一个无跟踪的 <see cref="IQueryable{T}"/> 用于查询。</returns>
public static IQueryable<T> Query<T>(this DbContext ctx) where T : class, IEntity
{
// 返回 DbSet 并禁用更改跟踪,适合只读查询以减少性能开销
return ctx.Set<T>().AsNoTracking();
}
}
}

IdReppsitory是实现仓储接口的类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
namespace IdentityService.Infrastructure
{
/// <summary>
/// `IdRepository` 实现 `IIdRepository`,封装 `IdUserManager` 与 `RoleManager<Role>` 的常用操作。
/// 类中的方法以清晰的异常和返回值处理为目标,便于调用者理解与使用。
/// </summary>
public class IdRepository : IIdRepository
{
private readonly IdUserManager userManager;
private readonly RoleManager<Role> roleManager;
private readonly ILogger<IdRepository> logger;

/// <summary>
/// 通过依赖注入构造仓储实例。
/// </summary>
public IdRepository(IdUserManager userManager, RoleManager<Role> roleManager, ILogger<IdRepository> logger)
{
this.userManager = userManager ?? throw new ArgumentNullException(nameof(userManager));
this.roleManager = roleManager ?? throw new ArgumentNullException(nameof(roleManager));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}

/// <summary>
/// 将一次登录失败计入用户失败次数(可能触发锁定),委托到 `IdUserManager`。
/// </summary>
public Task<IdentityResult> AccessFailedAsync(User user)
{
if (user == null) throw new ArgumentNullException(nameof(user));
return this.userManager.AccessFailedAsync(user);
}

/// <summary>
/// 快速构建一个包含单条错误描述的失败 `IdentityResult`。
/// 用于返回自定义错误场景。
/// </summary>
private static IdentityResult ErrorResult(string msg)
{
IdentityError idError = new IdentityError { Description = msg ?? string.Empty };
return IdentityResult.Failed(idError);
}

/// <summary>
/// 根据当前 Identity 密码策略生成一个随机密码,尽量满足策略中的各种要求(数字/大小写/特殊字符)。
/// 说明:使用 `RandomNumberGenerator` 来获得更好的随机性(比 `Random` 更安全)。
/// </summary>
private string GeneratePassword()
{
var options = userManager.Options.Password;
int requiredLength = Math.Max(1, options.RequiredLength);
bool requireNonAlphanumeric = options.RequireNonAlphanumeric;
bool requireDigit = options.RequireDigit;
bool requireLower = options.RequireLowercase;
bool requireUpper = options.RequireUppercase;

// 常用字符集
const string digits = "0123456789";
const string lowers = "abcdefghijklmnopqrstuvwxyz";
const string uppers = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const string specials = "!@#$%^&*()-_=+[]{};:,.<>/?";

StringBuilder sb = new StringBuilder(requiredLength + 4);

// 辅助函数:从给定字符集中随机取一个字符
static char GetRandomChar(string charset)
{
var buffer = new byte[4];
RandomNumberGenerator.Fill(buffer);
uint idx = BitConverter.ToUInt32(buffer, 0);
return charset[(int)(idx % (uint)charset.Length)];
}

// 先填充到最低长度,字符来源混合考虑
string allChars = digits + lowers + uppers + (specials);
for (int i = 0; i < requiredLength; i++)
{
sb.Append(GetRandomChar(allChars));
}

// 检查是否满足各类需求,如果没有则追加相应字符
if (requireDigit && !sb.ToString().Any(char.IsDigit))
sb.Append(GetRandomChar(digits));
if (requireLower && !sb.ToString().Any(char.IsLower))
sb.Append(GetRandomChar(lowers));
if (requireUpper && !sb.ToString().Any(char.IsUpper))
sb.Append(GetRandomChar(uppers));
if (requireNonAlphanumeric && !sb.ToString().Any(ch => !char.IsLetterOrDigit(ch)))
sb.Append(GetRandomChar(specials));

// 最终结果
return sb.ToString();
}

/// <summary>
/// 将用户加入指定角色;若角色不存在则先创建角色。
/// 成功返回 Success,否则返回失败的 IdentityResult(包含错误信息)。
/// </summary>
public async Task<IdentityResult> AddToRoleAsync(User user, string roleName)
{
if (user == null) throw new ArgumentNullException(nameof(user));
if (string.IsNullOrWhiteSpace(roleName)) throw new ArgumentException("roleName 不能为空", nameof(roleName));

// 如果角色不存在则尝试创建
if (!await roleManager.RoleExistsAsync(roleName))
{
var role = new Role { Name = roleName };
var createResult = await roleManager.CreateAsync(role);
if (!createResult.Succeeded)
{
logger.LogWarning("创建角色 {Role} 失败:{Errors}", roleName, string.Join(", ", createResult.Errors.Select(e => e.Description)));
return createResult;
}
}

// 将用户添加到角色
var addResult = await userManager.AddToRoleAsync(user, roleName);
if (!addResult.Succeeded)
{
logger.LogWarning("将用户 {UserId} 加入角色 {Role} 失败:{Errors}", user?.Id, roleName, string.Join(", ", addResult.Errors.Select(e => e.Description)));
}
return addResult;
}

/// <summary>
/// 添加一个管理员账号(快速初始化用)。
/// - 若用户名或手机号已存在则直接返回错误结果。
/// - 创建用户并生成初始密码,然后把用户放入 "Admin" 角色。
/// 返回三元组:(操作结果, 创建的 User 对象 或 null, 初始密码 或 null)。
/// </summary>
public async Task<(IdentityResult, User?, string? password)> AddAdminUserAsync(string userName, string phoneNum)
{
if (string.IsNullOrWhiteSpace(userName)) return (ErrorResult("用户名不能为空"), null, null);
if (string.IsNullOrWhiteSpace(phoneNum)) return (ErrorResult("手机号不能为空"), null, null);

if (await FindByNameAsync(userName) != null)
{
return (ErrorResult($"已经存在用户名 {userName}"), null, null);
}
if (await FindByPhoneNumberAsync(phoneNum) != null)
{
return (ErrorResult($"已经存在手机号 {phoneNum}"), null, null);
}

User user = new User(userName)
{
PhoneNumber = phoneNum,
PhoneNumberConfirmed = true
};

string password = GeneratePassword();
var createResult = await CreateAsync(user, password);
if (!createResult.Succeeded)
{
logger.LogWarning("创建管理员用户失败:{Errors}", string.Join(", ", createResult.Errors.Select(e => e.Description)));
return (createResult, null, null);
}

var roleResult = await AddToRoleAsync(user, "Admin");
if (!roleResult.Succeeded)
{
// 如果加入角色失败,返回失败并将用户作为 null(以便调用者感知未完全初始化)
logger.LogWarning("将管理员用户加入 Admin 角色失败:{Errors}", string.Join(", ", roleResult.Errors.Select(e => e.Description)));
return (roleResult, null, null);
}

return (IdentityResult.Success, user, password);
}

/// <summary>
/// 使用验证码令牌更换用户手机号并确认手机号。
/// - 找不到用户抛出 ArgumentException(调用方应该保证传入合法 id)。
/// - 若更换失败,记录一次登录失败并返回 SignInResult.Failed。
/// - 如果成功,则设置 PhoneNumberConfirmed 并返回 SignInResult.Success。
/// </summary>
public async Task<SignInResult> ChangePhoneNumAsync(Guid userId, string phoneNum, string token)
{
var user = await userManager.FindByIdAsync(userId.ToString());
if (user == null)
{
throw new ArgumentException($"用户 {userId} 不存在", nameof(userId));
}

var changeResult = await userManager.ChangePhoneNumberAsync(user, phoneNum, token);
if (!changeResult.Succeeded)
{
// 增加一次失败计数,记录日志并返回失败
await this.userManager.AccessFailedAsync(user);
string errMsg = changeResult.Errors.Any() ? string.Join("; ", changeResult.Errors.Select(e => e.Description)) : "未知错误";
this.logger.LogWarning("用户 {UserId} 更换手机号到 {PhoneNum} 失败:{Error}", userId, phoneNum, errMsg);
return SignInResult.Failed;
}

// 成功则确认手机号(标记 PhoneNumberConfirmed 并保存)
await ConfirmPhoneNumberAsync(userId);
return SignInResult.Success;
}

/// <summary>
/// 直接为用户设置新密码(通过生成重置令牌并调用 ResetPasswordAsync)。
/// - 简单检查密码长度(小于 6 视为无效并返回错误结果)。
/// - 若用户不存在或重置失败,返回对应的 IdentityResult。
/// </summary>
public async Task<IdentityResult> ChangePasswordAsync(Guid userId, string password)
{
if (password == null) throw new ArgumentNullException(nameof(password));
if (password.Length < 6)
{
var err = new IdentityError { Code = "PasswordInvalid", Description = "密码长度不能少于6位" };
return IdentityResult.Failed(err);
}

var user = await userManager.FindByIdAsync(userId.ToString());
if (user == null)
{
return ErrorResult($"用户 {userId} 不存在");
}

var token = await userManager.GeneratePasswordResetTokenAsync(user);
var resetResult = await userManager.ResetPasswordAsync(user, token, password);
if (!resetResult.Succeeded)
{
logger.LogWarning("为用户 {UserId} 更改密码失败:{Errors}", userId, string.Join(", ", resetResult.Errors.Select(e => e.Description)));
}
return resetResult;
}

/// <summary>
/// 检查给定用户和密码是否能够登录。
/// - 若用户被锁定,返回 LockedOut。
/// - 密码校验通过返回 Success。
/// - 否则视 lockoutOnFailure 决定是否将失败计入锁定策略(并在 AccessFailed 失败时抛出异常)。
/// </summary>
public async Task<SignInResult> CheckForSignInAsync(User user, string password, bool lockoutOnFailure)
{
if (user == null) throw new ArgumentNullException(nameof(user));
if (password == null) throw new ArgumentNullException(nameof(password));

if (await userManager.IsLockedOutAsync(user))
{
return SignInResult.LockedOut;
}

bool passwordOk = await userManager.CheckPasswordAsync(user, password);
if (passwordOk)
{
return SignInResult.Success;
}

if (lockoutOnFailure)
{
var r = await AccessFailedAsync(user);
if (!r.Succeeded)
{
// AccessFailedAsync 失败通常是存储层问题,我们把它暴露为异常以便上层捕获与调查
throw new ApplicationException("调用 AccessFailedAsync 失败,请检查用户存储或数据库连接。");
}
}
return SignInResult.Failed;
}

/// <summary>
/// 将指定用户的手机号标记为已确认(PhoneNumberConfirmed = true)。
/// 若找不到用户抛出 ApplicationException(这是原实现的行为)。
/// </summary>
public async Task ConfirmPhoneNumberAsync(Guid id)
{
var user = await userManager.Users.FirstOrDefaultAsync(u => u.Id == id);
if (user == null)
{
throw new ApplicationException($"用户 {id} 不存在");
}
user.PhoneNumberConfirmed = true;
await userManager.UpdateAsync(user);
}

/// <summary>
/// 创建用户并设置密码(直接委托给 `userManager.CreateAsync`)。
/// </summary>
public Task<IdentityResult> CreateAsync(User user, string password)
{
if (user == null) throw new ArgumentNullException(nameof(user));
if (password == null) throw new ArgumentNullException(nameof(password));
return this.userManager.CreateAsync(user, password);
}

/// <summary>
/// 根据用户 Id 查找用户(返回可能为 null)。
/// </summary>
public Task<User?> FindByIdAsync(Guid userId)
{
return userManager.FindByIdAsync(userId.ToString());
}

/// <summary>
/// 根据用户名查找用户(返回可能为 null)。
/// </summary>
public Task<User?> FindByNameAsync(string userName)
{
return userManager.FindByNameAsync(userName);
}

/// <summary>
/// 根据手机号查找用户(返回可能为 null)。
/// 使用 `userManager.Users` 以便在底层数据库上执行高效查询。
/// </summary>
public Task<User?> FindByPhoneNumberAsync(string phoneNum)
{
return userManager.Users.FirstOrDefaultAsync(u => u.PhoneNumber == phoneNum);
}

/// <summary>
/// 生成用于更换手机号的验证码令牌(委托给 `userManager`)。
/// </summary>
public Task<string> GenerateChangePhoneNumberTokenAsync(User user, string phoneNumber)
{
if (user == null) throw new ArgumentNullException(nameof(user));
return this.userManager.GenerateChangePhoneNumberTokenAsync(user, phoneNumber);
}

/// <summary>
/// 获取用户所属的所有角色名称(委托到 `userManager`)。
/// </summary>
public Task<IList<string>> GetRolesAsync(User user)
{
if (user == null) throw new ArgumentNullException(nameof(user));
return userManager.GetRolesAsync(user);
}

/// <summary>
/// 对用户做软删除:
/// - 移除所有外部登录信息(如 social logins)
/// - 调用用户实体的 `SoftDelete()` 标记为已删除
/// - 保存更新到存储
/// 返回 `IdentityResult` 表示操作成功或失败。
/// </summary>
public async Task<IdentityResult> RemoveUserAsync(Guid id)
{
var user = await FindByIdAsync(id);
if (user == null)
{
// 如果用户不存在,作为一种合理的惯例返回成功或失败取决于需求。
// 这里返回失败并说明用户不存在。
return ErrorResult($"用户 {id} 不存在");
}

// 访问底层的登录信息存储接口
var userLoginStore = userManager.UserLoginStore;
var ct = CancellationToken.None;
var logins = await userLoginStore.GetLoginsAsync(user, ct);

// 移除每个外部登录记录
foreach (var login in logins)
{
await userLoginStore.RemoveLoginAsync(user, login.LoginProvider, login.ProviderKey, ct);
}

// 标记软删除并保存
user.SoftDelete();
var result = await userManager.UpdateAsync(user);
if (!result.Succeeded)
{
logger.LogWarning("软删除用户 {UserId} 失败:{Errors}", id, string.Join(", ", result.Errors.Select(e => e.Description)));
}
return result;
}

/// <summary>
/// 为指定用户重置密码并返回新密码。
/// 返回三元组 (IdentityResult, User? , password?):
/// - 若用户不存在则返回失败结果与 null。
/// - 若重置成功则返回 Success、用户实例与新密码。
/// </summary>
public async Task<(IdentityResult, User?, string? password)> ResetPasswordAsync(Guid id)
{
var user = await FindByIdAsync(id);
if (user == null)
{
return (ErrorResult($"用户 {id} 不存在"), null, null);
}

string password = GeneratePassword();
string token = await userManager.GeneratePasswordResetTokenAsync(user);
var result = await userManager.ResetPasswordAsync(user, token, password);
if (!result.Succeeded)
{
logger.LogWarning("重置用户 {UserId} 密码失败:{Errors}", id, string.Join(", ", result.Errors.Select(e => e.Description)));
return (result, null, null);
}

return (IdentityResult.Success, user, password);
}

/// <summary>
/// 直接更新用户的手机号字段(不走验证码流程),并保存更改。
/// 若用户不存在抛出 ArgumentException。
/// </summary>
public async Task UpdatePhoneNumberAsync(Guid id, string phoneNum)
{
var user = await userManager.Users.FirstOrDefaultAsync(u => u.Id == id);
if (user == null)
{
throw new ArgumentException($"用户 {id} 不存在", nameof(id));
}
user.PhoneNumber = phoneNum;
await userManager.UpdateAsync(user);
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
namespace IdentityService.Domain
{
/*
计划(伪代码):
- 为 IdentityHelper 类添加注释,说明用途:对来自 Identity 的错误集合进行汇总为可读字符串。
- 为 SumErrors 扩展方法添加详细注释,说明参数、返回值、行为(如何处理 null、格式化规则)。
- 在方法内部保留现有实现:将每个 IdentityError 格式化为 "code={Code},message={Description} ",并用换行符连接。
- 为提高健壮性:当输入为 null 时返回空字符串;当集合为空时返回空字符串。
- 保持方法为扩展方法,便于在 IEnumerable<IdentityError> 上直接调用。
*/

/// <summary>
/// 提供与 ASP.NET Core Identity 错误处理相关的辅助方法。
/// </summary>
public static class IdentityHelper
{
/// <summary>
///<see cref="IdentityError"/> 的集合汇总为单个可读字符串,便于日志记录或调试。
/// </summary>
/// <param name="errors">要汇总的错误集合。允许为 <c>null</c></param>
/// <returns>
/// 如果 <paramref name="errors"/><c>null</c> 或为空集合,则返回空字符串。
/// 否则返回每个错误的行表示,每行格式为 "code={Code},message={Description} ",行与行之间用换行符分隔。
/// </returns>
/// <example>
/// var summary = errors.SumErrors();
/// // 可能的输出示例:
/// // code=DuplicateUserName,message=Username 'alice' is already taken
/// // code=PasswordTooShort,message=Passwords must be at least 6 characters.
/// </example>
public static string SumErrors(this IEnumerable<IdentityError> errors)
{
// 处理 null 安全:如果 errors 为 null,返回空字符串,避免调用方抛出 NullReferenceException。
if (errors is null)
{
return string.Empty;
}

// 将每个错误格式化为指定的文本表示,并使用换行符连接。
var strs = errors.Select(e => $"code={e.Code},message={e.Description} ");
return string.Join("\n", strs);
}
}
}

开发认证服务的应用服务层