yzk学英语项目-认证服务

102 阅读10分钟

yzk学英语项目-认证服务

功能

1、验证用户登录并颁发JWT令牌,也提供用户管理等API。

2、基于Authentication与Authorization。

领域层

1、实体类。

实体类

用户

public class User : IdentityUser<Guid>, IHasCreationTime, IHasDeletionTime, ISoftDelete
{
    public DateTime CreationTime { get; init; }

    public DateTime? DeletionTime { get; private set; }

    public bool IsDeleted { get; private set; }

    public User(string userName) : base(userName)
    {
        Id = Guid.NewGuid();
        CreationTime = DateTime.Now;
    }

    public void SoftDelete()
    {
        this.IsDeleted = true;
        this.DeletionTime = DateTime.Now;
    }
}

角色

public class Role : IdentityRole<Guid>
    {
        public Role()
        {
            this.Id = Guid.NewGuid();
        }
    }

2、防腐层

防腐层

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

public interface ISmsSender
    {
        //不同服务商用法不一样,所以添加可选参数,具体含义由实现类实现
        public Task SendAsync(string phoneNum, params string[] args);
    }

邮件发送接口

public interface IEmailSender
    {
        public Task SendAsync(string toEmail, string subject, string body);
    }

3、仓储接口

仓储接口

  • FindByIdAsync:根据Id获取用户
  • FindByNameAsync:根据用户名获取用户
  • FindByPhoneNumberAsync:根据手机号获取用户
  • CreateAsync:创建用户
  • AccessFailedAsync:记录一次登陆失败
  • GenerateChangePhoneNumberTokenAsync:生成重置密码的令牌
  • ChangePhoneNumAsync:修改用户手机号
  • ChangePasswordAsync:修改密码
  • GetRolesAsync:获取用户角色
  • AddToRoleAsync:把用户加入角色
  • CheckForSignInAsync:为了登录而检查用户名、密码是否正确
  • ConfirmPhoneNumberAsync:确认手机号是否正确
  • UpdatePhoneNumberAsync:修改手机号
  • RemoveUserAsync:删除用户
  • AddAdminUserAsync:添加管理员
  • ResetPasswordAsync:重置密码
public interface IIdRepository
    {
        Task<User?> FindByIdAsync(Guid userId);//根据Id获取用户
        Task<User?> FindByNameAsync(string userName);//根据用户名获取用户
        Task<User?> FindByPhoneNumberAsync(string phoneNum);//根据手机号获取用户
        Task<IdentityResult> CreateAsync(User user, string password);//创建用户
        Task<IdentityResult> AccessFailedAsync(User user);//记录一次登陆失败

        /// <summary>
        /// 生成重置密码的令牌
        /// </summary>
        /// <param name="user"></param>
        /// <param name="phoneNumber"></param>
        /// <returns></returns>
        Task<string> GenerateChangePhoneNumberTokenAsync(User user, string phoneNumber);
        /// <summary>
        /// 检查VCode,然后设置用户手机号为phoneNum
        /// </summary>
        /// <param name="userId"></param>
        /// <param name="phoneNum"></param>
        /// <param name="code"></param>
        /// <returns></returns>
        Task<SignInResult> ChangePhoneNumAsync(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>
        /// 把用户user加入角色role
        /// </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);
    }

4、认证领域服务IIdDomainService

领域服务

  • CheckUserNameAndPwdAsync:检查用户名、密码是否正确
  • CheckPhoneNumAndPwdAsync:检查手机号、密码是否正确
  • LoginByPhoneAndPwdAsync:根据手机号、密码登录,发放令牌
  • BuildTokenAsync:发放令牌
public class IdDomainService
    {
        private readonly IIdRepository repository;
        private readonly ITokenService tokenService;
        private readonly IOptions<JWTOptions> optJWT;

        public IdDomainService(IIdRepository repository,
             ITokenService tokenService, IOptions<JWTOptions> optJWT)
        {
            this.repository = repository;
            this.tokenService = tokenService;
            this.optJWT = optJWT;
        }

        private async Task<SignInResult> CheckUserNameAndPwdAsync(string userName, string password)
        {
            var user = await repository.FindByNameAsync(userName);
            if (user == null)
            {
                return SignInResult.Failed;
            }
            //CheckPasswordSignInAsync会对于多次重复失败进行账号禁用
            var result = await repository.CheckForSignInAsync(user, password, true);
            return result;
        }
        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;
        }

        //<(SignInResult Result, string? Token)>  元组的语法
        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);
            }
        }

        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);
            }
        }

        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));
            }
            return tokenService.BuildToken(claims, optJWT.Value);
        }
    }

5、Identity框架的帮助类

IdentityHelper

SumErrors:将Identity框架的错误信息汇总,返回一条完整的字符串。只适合开发环境,生产环境不要直接把报错信息返回给客户端。可以记录到日志中

public static class IdentityHelper
{
    public static string SumErrors(this IEnumerable<IdentityError> errors)
    {
        var strs = errors.Select(e => $"code={e.Code},message={e.Description}");
        return string.Join('\n', strs);
    }
}

基础设施层

1、实体配置类

实体配置类

角色

class RoleConfig : IEntityTypeConfiguration<Role>
    {
        public void Configure(EntityTypeBuilder<Role> builder)
        {
            builder.ToTable("T_Roles");
        }
    }

用户

class UserConfig : IEntityTypeConfiguration<User>
    {
        public void Configure(EntityTypeBuilder<User> builder)
        {
            builder.ToTable("T_Users");
        }
    }

2、发送邮件的实现

邮件发送

配置类

public class SendCloudEmailSettings
    {
        public string ApiUser { get; set; }
        public string ApiKey { get; set; }
        public string From { get; set; }
    }

模拟发送邮件

public class MockEmailSender : IEmailSender
    {
        private readonly ILogger<MockEmailSender> logger;
        public MockEmailSender(ILogger<MockEmailSender> logger)
        {
            this.logger = logger;
        }

        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;
        }
    }

真实发送邮件

public class SendCloudEmailSender : IEmailSender
    {
        private readonly ILogger<SendCloudEmailSender> logger;
        private readonly IHttpClientFactory httpClientFactory;
        private readonly IOptionsSnapshot<SendCloudEmailSettings> sendCloudSettings;
        public SendCloudEmailSender(ILogger<SendCloudEmailSender> logger,
            IHttpClientFactory httpClientFactory,
            IOptionsSnapshot<SendCloudEmailSettings> sendCloudSettings)
        {
            this.logger = logger;
            this.httpClientFactory = httpClientFactory;
            this.sendCloudSettings = sendCloudSettings;
        }

        public async Task SendAsync(string toEmail, string subject, string body)
        {
            logger.LogInformation("SendCloud Email to {0},subject:{1},body:{2}", toEmail, subject, body);
            var postBody = new Dictionary<string, string>();
            postBody["apiUser"] = sendCloudSettings.Value.ApiUser;
            postBody["apiKey"] = sendCloudSettings.Value.ApiKey;
            postBody["from"] = sendCloudSettings.Value.From;
            postBody["to"] = toEmail;
            postBody["subject"] = subject;
            postBody["html"] = body;

            using (FormUrlEncodedContent httpContent = new FormUrlEncodedContent(postBody))
            {
                var httpClient = httpClientFactory.CreateClient();
                var responseMsg = await httpClient.PostAsync("https://api.sendcloud.net/apiv2/mail/send", httpContent);
                if (!responseMsg.IsSuccessStatusCode)
                {
                    throw new Exception($"发送邮件响应码错误:{responseMsg.StatusCode}");
                }
                var respBody = await responseMsg.Content.ReadAsStringAsync();
                var respModel = respBody.ParseJson<SendCloudResponseModel>();
                if (!respModel.Result)
                {
                    throw new Exception($"发送邮件响应返回失败,状态码:{respModel.StatusCode},消息:{respModel.Message}");
                }
            }
        }
    }

发送短信的实现

短信发送

配置类

public class SendCloudSmsSettings
    {
        public string SmsUser { get; set; }
        public string SmsKey { get; set; }
    }

模拟发送短信,打印到日志

public class MockSmsSender : ISmsSender
    {
        private readonly ILogger<MockSmsSender> logger;
        public MockSmsSender(ILogger<MockSmsSender> logger)
        {
            this.logger = logger;
        }
        public Task SendAsync(string phoneNum, params string[] args)
        {
            logger.LogInformation("Send Sms to {0},args:{1}", phoneNum,
                 string.Join(",", args));
            return Task.CompletedTask;
        }
    }

云服务发送短信。SendCloudSmsSender是使用SendCloud公司的短信接口来发送短信的实现类。按照接口要求拼接http请求报文

public class SendCloudSmsSender : ISmsSender
    {
        private readonly ILogger<SendCloudSmsSender> logger;
        private readonly IHttpClientFactory httpClientFactory;
        private readonly IOptionsSnapshot<SendCloudSmsSettings> smsSettings;

        public SendCloudSmsSender(ILogger<SendCloudSmsSender> logger,
            IHttpClientFactory httpClientFactory,
            IOptionsSnapshot<SendCloudSmsSettings> smsSettings)
        {
            this.logger = logger;
            this.httpClientFactory = httpClientFactory;
            this.smsSettings = smsSettings;
        }
        public async Task SendAsync(string phoneNum, params string[] args)
        {
            logger.LogInformation("Send Sms to {0},args:{1}", phoneNum, string.Join("|", args));
            var postBody = new Dictionary<string, string>();
            postBody["smsUser"] = this.smsSettings.Value.SmsUser;
            postBody["templateId"] = "10010";
            postBody["phone"] = phoneNum;
            postBody["vars"] = args.ToJsonString();

            var signature = CalcSignature(postBody);
            postBody["signature"] = signature;
            using (FormUrlEncodedContent httpContent = new FormUrlEncodedContent(postBody))
            {
                var httpClient = httpClientFactory.CreateClient();
                var responseMsg = await httpClient.PostAsync("http://www.sendcloud.net/smsapi/send", httpContent);
                if (!responseMsg.IsSuccessStatusCode)
                {
                    throw new ApplicationException($"发送短信响应码错误:{responseMsg.StatusCode}");
                }
                var respBody = await responseMsg.Content.ReadAsStringAsync();
                var respModel = respBody.ParseJson<SendCloudResponseModel>();
                if (!respModel.Result)
                {
                    throw new ApplicationException($"发送短信失败:{respModel.Message}");
                }
            }
        }

        private string CalcSignature(IEnumerable<KeyValuePair<string, string>> parameters)
        {
            var smsKey = this.smsSettings.Value.SmsKey;
            var orderedItems = parameters.OrderBy(kv => kv.Key).Select(kv => $"{kv.Key}={kv.Value}");
            var orginParams = string.Join('&', orderedItems);
            string signStr = $"{smsKey}&{orginParams}&{smsKey}";
            string signature = HashHelper.ComputeMd5Hash(signStr);
            return signature;
        }
    }

3、云服务响应类

调用发送邮件、短信的云服务时,都可以以这个作为响应类

class SendCloudResponseModel
    {
        public bool Result { get; set; }
        public string Message { get; set; }
        public int StatusCode { get; set; }
    }

4、DbContext

public class IdDbContext : IdentityDbContext<User, Role, Guid>
    {
        public IdDbContext(DbContextOptions<IdDbContext> options)
            : base(options)
        {
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            base.OnModelCreating(modelBuilder);
        	//启用软删除
            modelBuilder.ApplyConfigurationsFromAssembly(this.GetType().Assembly);
            modelBuilder.EnableSoftDeletionGlobalFilter();
        }
    }

5、仓储接口实现

class IdRepository : IIdRepository
    {
        private readonly IdUserManager userManager;
        private readonly RoleManager<Role> roleManager;
        private readonly ILogger<IdRepository> logger;


        public IdRepository(IdUserManager userManager, RoleManager<Role> roleManager, ILogger<IdRepository> logger)
        {
            this.userManager = userManager;
            this.roleManager = roleManager;
            this.logger = logger;
        }

        public Task<User?> FindByPhoneNumberAsync(string phoneNum)
        {
            return userManager.Users.FirstOrDefaultAsync(u => u.PhoneNumber == phoneNum);
        }

        public Task<User?> FindByIdAsync(Guid userId)
        {
            return userManager.FindByIdAsync(userId.ToString());
        }

        public Task<User?> FindByNameAsync(string userName)
        {
            return userManager.FindByNameAsync(userName);
        }
        public Task<IdentityResult> CreateAsync(User user, string password)
        {
            return this.userManager.CreateAsync(user, password);
        }

        public Task<IdentityResult> AccessFailedAsync(User user)
        {
            return userManager.AccessFailedAsync(user);
        }

        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}的用户不存在");
            }
            var changeResult = await this.userManager.ChangePhoneNumberAsync(user, phoneNum, token);
            if (!changeResult.Succeeded)
            {
                await this.userManager.AccessFailedAsync(user);
                string errMsg = changeResult.Errors.SumErrors();
                this.logger.LogWarning($"{phoneNum}ChangePhoneNumberAsync失败,错误信息{errMsg}");
                return SignInResult.Failed;
            }
            else
            {
                await ConfirmPhoneNumberAsync(user.Id);//确认手机号
                return SignInResult.Success;
            }
        }
        public async Task<IdentityResult> ChangePasswordAsync(Guid userId, string password)
        {
            if (password.Length < 6)
            {
                IdentityError err = new IdentityError();
                err.Code = "Password Invalid";
                err.Description = "密码长度不能少于6";
                return IdentityResult.Failed(err);
            }
            var user = await userManager.FindByIdAsync(userId.ToString());
            var token = await userManager.GeneratePasswordResetTokenAsync(user);
            var resetPwdResult = await userManager.ResetPasswordAsync(user, token, password);
            return resetPwdResult;
        }

        public Task<string> GenerateChangePhoneNumberTokenAsync(User user, string phoneNumber)
        {
            return this.userManager.GenerateChangePhoneNumberTokenAsync(user, phoneNumber);
        }

        public Task<IList<string>> GetRolesAsync(User user)
        {
            return userManager.GetRolesAsync(user);
        }

        public async Task<IdentityResult> AddToRoleAsync(User user, string roleName)
        {
            if (!await roleManager.RoleExistsAsync(roleName))
            {
                Role role = new Role { Name = roleName };
                var result = await roleManager.CreateAsync(role);
                if (result.Succeeded == false)
                {
                    return result;
                }
            }
            return await userManager.AddToRoleAsync(user, roleName);
        }
        /// <summary>
        /// 尝试登录,如果lockoutOnFailure为true,则登录失败还会自动进行lockout计数
        /// </summary>
        /// <param name="user"></param>
        /// <param name="password"></param>
        /// <param name="lockoutOnFailure"></param>
        /// <returns></returns>
        /// <exception cref="ApplicationException"></exception>
        public async Task<SignInResult> CheckForSignInAsync(User user, string password, bool lockoutOnFailure)
        {
            if (await userManager.IsLockedOutAsync(user))
            {
                return SignInResult.LockedOut;
            }
            var success = await userManager.CheckPasswordAsync(user, password);
            if (success)
            {
                return SignInResult.Success;
            }
            else
            {
                if (lockoutOnFailure)
                {
                    var r = await AccessFailedAsync(user);
                    if (!r.Succeeded)
                    {
                        throw new ApplicationException("AccessFailed failed");
                    }
                }
                return SignInResult.Failed;
            }
        }
        public async Task ConfirmPhoneNumberAsync(Guid id)
        {
            var user = await userManager.Users.FirstOrDefaultAsync(u => u.Id == id);
            if (user == null)
            {
                throw new ArgumentException($"用户找不到,id={id}", nameof(id));
            }
            user.PhoneNumberConfirmed = true;
            await userManager.UpdateAsync(user);
        }

        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={id}", nameof(id));
            }
            user.PhoneNumber = phoneNum;
            await userManager.UpdateAsync(user);
        }

        /// <summary>
        /// 软删除
        /// </summary>
        /// <param name="id"></param>
        /// <returns></returns>
        public async Task<IdentityResult> RemoveUserAsync(Guid id)
        {
            var user = await FindByIdAsync(id);
            var userLoginStore = userManager.UserLoginStore;
            var noneCT = default(CancellationToken);
            //一定要删除aspnetuserlogins表中的数据,否则再次用这个外部登录登录的话
            //就会报错:The instance of entity type 'IdentityUserLogin<Guid>' cannot be tracked because another instance with the same key value for {'LoginProvider', 'ProviderKey'} is already being tracked.
            //而且要先删除aspnetuserlogins数据,再软删除User
            var logins = await userLoginStore.GetLoginsAsync(user, noneCT);
            foreach (var login in logins)
            {
                await userLoginStore.RemoveLoginAsync(user, login.LoginProvider, login.ProviderKey, noneCT);
            }
            user.SoftDelete();
            var result = await userManager.UpdateAsync(user);
            return result;
        }

        private static IdentityResult ErrorResult(string msg)
        {
            IdentityError idError = new IdentityError { Description = msg };
            return IdentityResult.Failed(idError);
        }

        public async Task<(IdentityResult, User?, string? password)> AddAdminUserAsync(string userName, string phoneNum)
        {
            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);
            user.PhoneNumber = phoneNum;
            user.PhoneNumberConfirmed = true;
            string password = GeneratePassword();
            var result = await CreateAsync(user, password);
            if (!result.Succeeded)
            {
                return (result, null, null);
            }
            result = await AddToRoleAsync(user, "Admin");
            if (!result.Succeeded)
            {
                return (result, null, null);
            }
            return (IdentityResult.Success, user, password);
        }

        public async Task<(IdentityResult, User?, string? password)> ResetPasswordAsync(Guid id)
        {
            var user = await FindByIdAsync(id);
            if (user == null)
            {
                return (ErrorResult("用户没找到"), null, null);
            }
            string password = GeneratePassword();
            string token = await userManager.GeneratePasswordResetTokenAsync(user);
            var result = await userManager.ResetPasswordAsync(user, token, password);
            if (!result.Succeeded)
            {
                return (result, null, null);
            }
            return (IdentityResult.Success, user, password);
        }

        private string GeneratePassword()
        {
            var options = userManager.Options.Password;
            int length = options.RequiredLength;
            bool nonAlphanumeric = options.RequireNonAlphanumeric;
            bool digit = options.RequireDigit;
            bool lowercase = options.RequireLowercase;
            bool uppercase = options.RequireUppercase;
            StringBuilder password = new StringBuilder();
            Random random = new Random();
            while (password.Length < length)
            {
                char c = (char)random.Next(32, 126);
                password.Append(c);
                if (char.IsDigit(c))
                    digit = false;
                else if (char.IsLower(c))
                    lowercase = false;
                else if (char.IsUpper(c))
                    uppercase = false;
                else if (!char.IsLetterOrDigit(c))
                    nonAlphanumeric = false;
            }

            if (nonAlphanumeric)
                password.Append((char)random.Next(33, 48));
            if (digit)
                password.Append((char)random.Next(48, 58));
            if (lowercase)
                password.Append((char)random.Next(97, 123));
            if (uppercase)
                password.Append((char)random.Next(65, 91));
            return password.ToString();
        }
    }

6、服务自注册

class ModuleInitializer : IModuleInitializer
    {
        public void Initialize(IServiceCollection services)
        {
            services.AddScoped<IdDomainService>();
            services.AddScoped<IIdRepository, IdRepository>();
        }
    }

7、Identity框架的工具,提供获取IUserLoginStore的方法

UserManager用于存储将 Microsoft 帐户、Facebook 等提供的外部登录信息映射到用户帐户的信息。IdUserManager对identity的UserManager又做了一步封装,正常使用UserManager对user操作,现在使用IdUserManager

public class IdUserManager : UserManager<User>
{
    public IdUserManager(IUserStore<User> store, IOptions<IdentityOptions> optionsAccessor, IPasswordHasher<User> passwordHasher, IEnumerable<IUserValidator<User>> userValidators, IEnumerable<IPasswordValidator<User>> passwordValidators, ILookupNormalizer keyNormalizer, IdentityErrorDescriber errors, IServiceProvider services, ILogger<UserManager<User>> logger) :
        base(store, optionsAccessor, passwordHasher, userValidators, passwordValidators, keyNormalizer, errors, services, logger)
    {
    }

    public IUserLoginStore<User> UserLoginStore
    {
        get
        {
            return (IUserLoginStore<User>)this.Store;
        }
    }
}

应用层

普通用户

普通用户(请求及校验类)

public record ChangeMyPasswordRequest(string Password, string Password2);
public class ChangePasswordRequestValidator : AbstractValidator<ChangeMyPasswordRequest>
{
    public ChangePasswordRequestValidator()
    {
        RuleFor(e => e.Password).NotNull().NotEmpty()
            .Equal(e => e.Password2);
        RuleFor(e => e.Password2).NotNull().NotEmpty();
    }
}
public record LoginByPhoneAndCodeRequest(string PhoneNum, string Code);
public class LoginByPhoneAndCodeRequestValidator : AbstractValidator<LoginByPhoneAndCodeRequest>
{
    public LoginByPhoneAndCodeRequestValidator()
    {
        RuleFor(e => e.PhoneNum).NotNull().NotEmpty();
        RuleFor(e => e.Code).NotNull().NotEmpty();
    }
}
public record LoginByPhoneAndPwdRequest(string PhoneNum, string Password);
public class LoginByPhoneAndPwdRequestValidator : AbstractValidator<LoginByPhoneAndPwdRequest>
{
    public LoginByPhoneAndPwdRequestValidator()
    {
        RuleFor(e => e.PhoneNum).NotNull().NotEmpty();
        RuleFor(e => e.Password).NotNull().NotEmpty();
    }
}
public record LoginByUserNameAndPwdRequest(string UserName, string Password);
public class LoginByUserNameAndPwdRequestValidator : AbstractValidator<LoginByUserNameAndPwdRequest>
{
    public LoginByUserNameAndPwdRequestValidator()
    {
        RuleFor(e => e.UserName).NotNull().NotEmpty();
        RuleFor(e => e.Password).NotNull().NotEmpty();
    }
}
public record SendCodeByPhoneRequest(string PhoneNumber);
public class SendCodeByPhoneRequestValidator : AbstractValidator<SendCodeByPhoneRequest>
{
    public SendCodeByPhoneRequestValidator()
    {
        RuleFor(e => e.PhoneNumber).NotNull().NotEmpty();
    }
}

普通用户(响应类):获取用户信息的响应类

public record UserResponse(Guid Id, string PhoneNumber, DateTime CreationTime);

普通用户(控制器)

LoginController是用来处理登录的控制器

如果登录失败,服务器端只是告诉前端“登录失败”,而没有给出“用户名不存在”、“密码错误”等详细的错误信息,这样可以避免恶意用户通过详细的报错信息来尝试找到系统中存在的用户名等漏洞

  • CreateWorld:创建初始用户(管理员)
  • GetUserInfo:获取当前登录用户的信息,不要把实体对象直接返回给客户端,而是返回UserResponse对象(只包含部分信息)
  • LoginByPhoneAndPwd:根据手机号和密码登录
  • LoginByUserNameAndPwd:根据用户名和密码登录,基本是调用领域服务的方法,把领域服务的返回值做适当的格式输出
  • ChangeMyPassword:修改密码
[Route("[controller]/[action]")]
[ApiController]
public class LoginController : ControllerBase
{
    private readonly IIdRepository repository;
    private readonly IdDomainService idService;

    public LoginController(IdDomainService idService, IIdRepository repository)
    {
        this.idService = idService;
        this.repository = repository;
    }

    [HttpPost]
    [AllowAnonymous]//运行匿名用户
    //创建上帝用户 
    public async Task<ActionResult> CreateWorld()
    {
        if (await repository.FindByNameAsync("admin") != null)
        {
            return StatusCode((int)HttpStatusCode.Conflict, "已经初始化过了");
        }
        User user = new User("admin");
        var r = await repository.CreateAsync(user, "123456");
        Debug.Assert(r.Succeeded);
        var token = await repository.GenerateChangePhoneNumberTokenAsync(user, "18918999999");
        var cr = await repository.ChangePhoneNumAsync(user.Id, "18918999999", token);
        Debug.Assert(cr.Succeeded);
        r = await repository.AddToRoleAsync(user, "User");
        Debug.Assert(r.Succeeded);
        r = await repository.AddToRoleAsync(user, "Admin");
        Debug.Assert(r.Succeeded);
        return Ok();
    }

    [HttpGet]
    [Authorize]
    public async Task<ActionResult<UserResponse>> GetUserInfo()
    {
        string userId = this.User.FindFirstValue(ClaimTypes.NameIdentifier);
        var user = await repository.FindByIdAsync(Guid.Parse(userId));
        if (user == null)//可能用户注销了
        {
            return NotFound();
        }
        //出于安全考虑,不要机密信息传递到客户端
        //除非确认没问题,否则尽量不要直接把实体类对象返回给前端
        return new UserResponse(user.Id, user.PhoneNumber, user.CreationTime);
    }

    //书中的项目只提供根据用户名登录的功能,以及管理员增删改查,像用户主动注册、手机验证码登录等功能都不弄。

    [AllowAnonymous]
    [HttpPost]
    public async Task<ActionResult<string?>> LoginByPhoneAndPwd(LoginByPhoneAndPwdRequest req)
    {
        //todo:要通过行为验证码、图形验证码等形式来防止暴力破解
        (var checkResult, string? token) = await idService.LoginByPhoneAndPwdAsync(req.PhoneNum, req.Password);
        if (checkResult.Succeeded)
        {
            return token;
        }
        else if (checkResult.IsLockedOut)
        {
            //尝试登录次数太多
            return StatusCode((int)HttpStatusCode.Locked, "此账号已经锁定");
        }
        else
        {
            string msg = "登录失败";
            return StatusCode((int)HttpStatusCode.BadRequest, msg);
        }
    }

    [AllowAnonymous]
    [HttpPost]
    public async Task<ActionResult<string>> LoginByUserNameAndPwd(
        LoginByUserNameAndPwdRequest req)
    {
        (var checkResult, var token) = await idService.LoginByUserNameAndPwdAsync(req.UserName, req.Password);
        if (checkResult.Succeeded) return token!;
        else if (checkResult.IsLockedOut)//尝试登录次数太多
            return StatusCode((int)HttpStatusCode.Locked, "用户已经被锁定");
        else
        {
            string msg = checkResult.ToString();
            return BadRequest("登录失败" + msg);
        }
    }

    [HttpPost]
    [Authorize]
    public async Task<ActionResult> ChangeMyPassword(ChangeMyPasswordRequest req)
    {
        Guid userId = Guid.Parse(this.User.FindFirstValue(ClaimTypes.NameIdentifier));
        var resetPwdResult = await repository.ChangePasswordAsync(userId, req.Password);
        if (resetPwdResult.Succeeded)
        {
            return Ok();
        }
        else
        {
            return BadRequest(resetPwdResult.Errors.SumErrors());
        }
    }
}

管理员

管理员(请求及校验类)

public record AddAdminUserRequest(string UserName, string PhoneNum);
public class AddAdminUserRequestValidator : AbstractValidator<AddAdminUserRequest>
{
    public AddAdminUserRequestValidator()
    {
        RuleFor(e => e.PhoneNum).NotNull().NotEmpty().MaximumLength(11);
        RuleFor(e => e.UserName).NotEmpty().NotEmpty().MaximumLength(20).MinimumLength(2);
    }
}
public record EditAdminUserRequest(string PhoneNum);
public class EditAdminUserRequestValidator : AbstractValidator<EditAdminUserRequest>
{
    public EditAdminUserRequestValidator()
    {
        RuleFor(e => e.PhoneNum).NotNull().NotEmpty();
    }
}

管理员(响应类)

public record UserDTO(Guid Id, string UserName, string PhoneNumber, DateTime CreationTime)
{
    public static UserDTO Create(User user)
    {
        return new UserDTO(user.Id, user.UserName, user.PhoneNumber, user.CreationTime);
    }
}

管理员(控制器)

  • FindAllUsers:查找所有用户
  • FindById:查找某一个用户
  • AddAdminUser:增加管理员,发出领域事件(没有在实体类中发布领域事件,因为密码是后来才知道的,把密码也发布出去了,但是尽量在模型中完成)
  • DeleteAdminUser:删除管理员
  • UpdateAdminUser:更新管理员用户
  • ResetAdminUserPassword:重置密码,同样发出事件
[Route("[controller]/[action]")]
[ApiController]
[Authorize(Roles = "Admin")]
public class UserAdminController : ControllerBase
{
    private readonly IdUserManager userManager;
    private readonly IIdRepository repository;
    private readonly IEventBus eventBus;

    public UserAdminController(IdUserManager userManager, IEventBus eventBus, IIdRepository repository)
    {
        this.userManager = userManager;
        this.eventBus = eventBus;
        this.repository = repository;
    }

    [HttpGet]
    public Task<UserDTO[]> FindAllUsers()
    {
        return userManager.Users.Select(u => UserDTO.Create(u)).ToArrayAsync();
    }

    [HttpGet]
    [Route("{id}")]
    public async Task<UserDTO> FindById(Guid id)
    {
        var user = await userManager.FindByIdAsync(id.ToString());
        return UserDTO.Create(user);
    }

    [Authorize(Roles = "Admin")]
    [HttpPost]
    public async Task<ActionResult> AddAdminUser(AddAdminUserRequest req)
    {
        (var result, var user, var password) = await repository
            .AddAdminUserAsync(req.UserName, req.PhoneNum);
        if (!result.Succeeded)
        {
            return BadRequest(result.Errors.SumErrors());
        }
        //生成的密码短信发给对方
        //可以同时或者选择性的把新增用户的密码短信/邮件/打印给用户
        //体现了领域事件对于代码“高内聚、低耦合”的追求
        var userCreatedEvent = new UserCreatedEvent(user.Id, req.UserName, password, req.PhoneNum);
        eventBus.Publish("IdentityService.User.Created", userCreatedEvent);
        return Ok();
    }

    [HttpDelete]
    [Route("{id}")]
    public async Task<ActionResult> DeleteAdminUser(Guid id)
    {
        await repository.RemoveUserAsync(id);
        return Ok();
    }

    [HttpPut]
    [Route("{id}")]
    public async Task<ActionResult> UpdateAdminUser(Guid id, EditAdminUserRequest req)
    {
        var user = await repository.FindByIdAsync(id);
        if (user == null)
        {
            return NotFound("用户没找到");
        }
        user.PhoneNumber = req.PhoneNum;
        await userManager.UpdateAsync(user);
        return Ok();
    }

    [HttpPost]
    [Route("{id}")]
    public async Task<ActionResult> ResetAdminUserPassword(Guid id)
    {
        (var result, var user, var password) = await repository.ResetPasswordAsync(id);
        if (!result.Succeeded)
        {
            return BadRequest(result.Errors.SumErrors());
        }
        //生成的密码短信发给对方
        var eventData = new ResetPasswordEvent(user.Id, user.UserName, password, user.PhoneNumber);
        eventBus.Publish("IdentityService.User.PasswordReset", eventData);
        return Ok();
    }
}

领域事件

领域事件

  • UserCreatedEventHandler:监听到管理员创建的事件后,将密码发送给邮箱
  • ResetPasswordEventHandler:监听重置密码事件
public record ResetPasswordEvent(Guid Id, string UserName, string Password, string PhoneNum);
[EventName("IdentityService.User.PasswordReset")]
public class ResetPasswordEventHandler : JsonIntegrationEventHandler<ResetPasswordEvent>
{
    private readonly ILogger<ResetPasswordEventHandler> logger;
    private readonly ISmsSender smsSender;

    public ResetPasswordEventHandler(ILogger<ResetPasswordEventHandler> logger, ISmsSender smsSender)
    {
        this.logger = logger;
        this.smsSender = smsSender;
    }

    public override Task HandleJson(string eventName, ResetPasswordEvent? eventData)
    {
        //发送密码给被用户的手机
        return smsSender.SendAsync(eventData.PhoneNum, eventData.Password);
    }
}

响应用户创建的事件然后发短信通知用户:UserCreatedEventHandler

public record UserCreatedEvent(Guid Id, string UserName, string Password, string PhoneNum);
[EventName("IdentityService.User.Created")]
public class UserCreatedEventHandler : JsonIntegrationEventHandler<UserCreatedEvent>
{
    private readonly ISmsSender smsSender;

    public UserCreatedEventHandler(ISmsSender smsSender)
    {
        this.smsSender = smsSender;
    }

    public override Task HandleJson(string eventName, UserCreatedEvent? eventData)
    {
        //发送初始密码给被创建用户的手机
        return smsSender.SendAsync(eventData.PhoneNum, eventData.Password);
    }
}

服务及管道模型

Program.cs

数据加密保护

builder.Services.AddDataProtection();

登录、注册的项目除了要启用WebApplicationBuilderExtensions中的初始化之外,还要如下的初始化

//不要用AddIdentity,而是用AddIdentityCore
//因为用AddIdentity会导致JWT机制不起作用,AddJwtBearer中回调不会被执行,因此总是Authentication校验失败
//https://github.com/aspnet/Identity/issues/1376
IdentityBuilder idBuilder = builder.Services.AddIdentityCore<User>(options =>
    {
        options.Password.RequireDigit = false;
        options.Password.RequireLowercase = false;
        options.Password.RequireNonAlphanumeric = false;
        options.Password.RequireUppercase = false;
        options.Password.RequiredLength = 6;
        //不能设定RequireUniqueEmail,否则不允许邮箱为空
        //options.User.RequireUniqueEmail = true;
        //以下两行,把GenerateEmailConfirmationTokenAsync验证码缩短
        options.Tokens.PasswordResetTokenProvider = TokenOptions.DefaultEmailProvider;
        options.Tokens.EmailConfirmationTokenProvider = TokenOptions.DefaultEmailProvider;
    }
);
idBuilder = new IdentityBuilder(idBuilder.UserType, typeof(Role), builder.Services);
idBuilder.AddEntityFrameworkStores<IdDbContext>().AddDefaultTokenProviders()
    //.AddRoleValidator<RoleValidator<Role>>()
    .AddRoleManager<RoleManager<Role>>()
    .AddUserManager<IdUserManager>();

根据是否开发环境,注册不同服务

if (builder.Environment.IsDevelopment())
{
    builder.Services.AddScoped<IEmailSender, MockEmailSender>();
    builder.Services.AddScoped<ISmsSender, MockSmsSender>();
}
else
{
    builder.Services.AddScoped<IEmailSender, SendCloudEmailSender>();
    builder.Services.AddScoped<ISmsSender, SendCloudSmsSender>();
}

完整代码

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.ConfigureDbConfiguration();
builder.ConfigureExtraServices(new InitializerOptions
{
    EventBusQueueName = "IdentityService.WebAPI",
    LogFilePath = "e:/temp/IdentityService.log"
});
builder.Services.AddControllers();
builder.Services.AddSwaggerGen(c =>
{
    c.SwaggerDoc("v1", new() { Title = "IdentityService.WebAPI", Version = "v1" });
    //c.AddAuthenticationHeader();
});

builder.Services.AddDataProtection();
//登录、注册的项目除了要启用WebApplicationBuilderExtensions中的初始化之外,还要如下的初始化
//不要用AddIdentity,而是用AddIdentityCore
//因为用AddIdentity会导致JWT机制不起作用,AddJwtBearer中回调不会被执行,因此总是Authentication校验失败
//https://github.com/aspnet/Identity/issues/1376
IdentityBuilder idBuilder = builder.Services.AddIdentityCore<User>(options =>
    {
        options.Password.RequireDigit = false;
        options.Password.RequireLowercase = false;
        options.Password.RequireNonAlphanumeric = false;
        options.Password.RequireUppercase = false;
        options.Password.RequiredLength = 6;
        //不能设定RequireUniqueEmail,否则不允许邮箱为空
        //options.User.RequireUniqueEmail = true;
        //以下两行,把GenerateEmailConfirmationTokenAsync验证码缩短
        options.Tokens.PasswordResetTokenProvider = TokenOptions.DefaultEmailProvider;
        options.Tokens.EmailConfirmationTokenProvider = TokenOptions.DefaultEmailProvider;
    }
);
idBuilder = new IdentityBuilder(idBuilder.UserType, typeof(Role), builder.Services);
idBuilder.AddEntityFrameworkStores<IdDbContext>().AddDefaultTokenProviders()
    //.AddRoleValidator<RoleValidator<Role>>()
    .AddRoleManager<RoleManager<Role>>()
    .AddUserManager<IdUserManager>();

if (builder.Environment.IsDevelopment())
{
    builder.Services.AddScoped<IEmailSender, MockEmailSender>();
    builder.Services.AddScoped<ISmsSender, MockSmsSender>();
}
else
{
    builder.Services.AddScoped<IEmailSender, SendCloudEmailSender>();
    builder.Services.AddScoped<ISmsSender, SendCloudSmsSender>();
}
var app = builder.Build();

// Configure the HTTP request pipeline.
if (builder.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
    app.UseSwagger();
    app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "IdentityService.WebAPI v1"));
}
app.UseZackDefault();
app.MapControllers();
app.Run();

数据库迁移DesignTimeDbContextFactory

public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<IdDbContext>
{
    public IdDbContext CreateDbContext(string[] args)
    {
        var optionsBuilder = DbContextOptionsBuilderFactory.Create<IdDbContext>();
        return new IdDbContext(optionsBuilder.Options);
    }
}