Skip to content

[Netcore] 用户注册启用Email认证时需要注意的问题

Jinxin Chen edited this page Dec 11, 2019 · 1 revision

Asp.Net core启用Email认证时,如果没有特殊处理此过程,则会有安全问题,其他人可以无需密码就可以以该Email登录系统

用户注册启用Email认证

此步骤可以参考微软官方文档:https://docs.microsoft.com/zh-cn/aspnet/core/security/authentication/accconfirm

Asp.Net core默认生成的带账号认证的Register代码模板,默认是没有启用Email验证功能,代码如下:

public async Task<IActionResult> Register(RegisterViewModel model, string returnUrl = null)
{
    ViewData["ReturnUrl"] = returnUrl;
    if (ModelState.IsValid)
    {
        var user = new ApplicationUser { UserName = model.Email, Email = model.Email };
        var result = await _userManager.CreateAsync(user, model.Password);
        if (result.Succeeded)
        {
            // For more information on how to enable account confirmation and password reset please visit https://go.microsoft.com/fwlink/?LinkID=532713
            // Send an email with this link
            //var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
            //var callbackUrl = Url.Action(nameof(ConfirmEmail), "Account", new { userId = user.Id, code = code }, protocol: HttpContext.Request.Scheme);
            //await _emailSender.SendEmailAsync(model.Email, "Confirm your account",
            //    $"Please confirm your account by clicking this link: <a href='{callbackUrl}'>link</a>");
            await _signInManager.SignInAsync(user, isPersistent: false);
            _logger.LogInformation(3, "User created a new account with password.");
            return RedirectToLocal(returnUrl);
        }
        AddErrors(result);
    }

    // If we got this far, something failed, redisplay form
    return View(model);
}

想要启用Email认证,要做如下几件事情:

  1. 调整Register方法的注册逻辑,将登陆替换为发送验证Email
  2. 在startup类中声明,Email没有经过验证的账号不能登陆
  3. 使用自己的邮件服务或者注册在线的邮件服务,发送Email

1. 调整Register方法

if (result.Succeeded)
{
    // For more information on how to enable account confirmation and password reset please visit https://go.microsoft.com/fwlink/?LinkID=532713
    // Send an email with this link
    var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
    var callbackUrl = Url.Action(nameof(ConfirmEmail), "Account", new { userId = user.Id, code = code }, protocol: HttpContext.Request.Scheme);
    await _emailSender.SendEmailAsync(model.Email, "Confirm your account",
    //    $"Please confirm your account by clicking this link: <a href='{callbackUrl}'>link</a>");
    //await _signInManager.SignInAsync(user, isPersistent: false);
    //_logger.LogInformation(3, "User created a new account with password.");
    //return RedirectToLocal(returnUrl);

    ViewData["RegisterMessage"] = "请登录您的邮箱验证账号!";

    return View(model);
}

2. 设置Email没有经过验证的账号不能登陆

public void ConfigureServices(IServiceCollection services)
{
    services.Configure<IdentityOptions>(options =>
    {
        options.SignIn.RequireConfirmedEmail = true;
    });

3. 使用邮件服务

可以参照微软官方文档:

https://docs.microsoft.com/zh-cn/aspnet/core/security/authentication/accconfirm

潜在的问题

先看看默认验证Email code的代码:

public async Task<IActionResult> ConfirmEmail(string userId, string code)
{
    if (userId == null || code == null)
    {
        return View("Error");
    }
    var user = await _userManager.FindByIdAsync(userId);
    if (user == null)
    {
        return View("Error");
    }
    var result = await _userManager.ConfirmEmailAsync(user, code);
    return View(result.Succeeded ? "ConfirmEmail" : "Error");
}

这里并没有判断 code 是否已经被验证过,只是单纯的验证 code 是否和 _userManager.GenerateEmailConfirmationTokenAsync(user) 生成的 code 对应起来。

这会有什么问题?

如果有人能够拿到这个code,无需密码即可以该账号的身份登录系统。

如何防止此问题的发生?

有如下两种方法:

  1. 在 ConfirmEmail 方法中,判断 user 的email是否已经处于验证通过状态,如果是则返回错误。
  2. 在 ConfirmEmail 方法中,user email 首次验证通过之后,生成新的 code,让之前验证的 code作废。

1. 判断 user 的email是否已经处于验证通过状态

public async Task<IActionResult> ConfirmEmail(string userId, string code)
{
    if (userId == null || code == null)
    {
        return View("Error");
    }
    var user = await _userManager.FindByIdAsync(userId);
    if (user == null)
    {
        return View("Error");
    }
    // add for blocking old email validation code.
    if (user.EmailConfirmed)
    {
        ViewData["ErrorMessage"] = "验证码已经过期!";
        return View();
    }

    var result = await _userManager.ConfirmEmailAsync(user, code);
    return View(result.Succeeded ? "ConfirmEmail" : "Error");
}

2. 生成新的 code

如果仅仅是调用 _userManager.GenerateEmailConfirmationTokenAsync(user) 方法,会发现无论调用多少次,返回的code均不会变化。

查看ASP.Net core的源码可以看到,此方法生成的code依赖于user的 SecurityStamp 属性:

https://github.com/aspnet/Identity/blob/dev/src/Microsoft.AspNetCore.Identity/DataProtectionTokenProvider.cs#L82

public virtual async Task<string> GenerateAsync(string purpose, UserManager<TUser> manager, TUser user)
{
    if (user == null)
    {
        throw new ArgumentNullException(nameof(user));
    }
    var ms = new MemoryStream();
    var userId = await manager.GetUserIdAsync(user);
    using (var writer = ms.CreateWriter())
    {
        writer.Write(DateTimeOffset.UtcNow);
        writer.Write(userId);
        writer.Write(purpose ?? "");
        string stamp = null;
        if (manager.SupportsUserSecurityStamp)
        {
            stamp = await manager.GetSecurityStampAsync(user);
        }
        writer.Write(stamp ?? "");
    }
    var protectedBytes = Protector.Protect(ms.ToArray());
    return Convert.ToBase64String(protectedBytes);
}

SecurityStamp不会变化,除非 a users credentials change (password changed, login removed)

可以呼叫 _userManager.UpdateSecurityStampAsync(user); 方法来改变 user 的 SecurityStamp。

更多的应用场景

通常情况下,站点会提供“重新发送验证Email”功能,利用上面生成新的 code 的方法,可以保证每次发送的验证Email code不同,并且让旧的code失效,保证安全性。

Clone this wiki locally