认证服务用来验证用户登录并颁发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;
}
}
}