快速掌握 ASP.NET 身份认证框架 Identity - 登录与登出

这是 ASP.NET Core Identity 系列的第三篇文章,上一篇文章讲解了如何在 ASP.NET Core Identity 中实现用户注册

那么,这篇文章讲一讲如何在 ASP.NET Core Identity 中实现用户的登录与登出。

本篇文章的示例项目:https://github.com/zilor-net/IdentitySample/tree/main/Sample03.

身份认证

说到用户登录,就很容易的想到身份认证,这是确认用户身份的过程。

这个过程通过一系列操作,根据数据库中用户留存的凭证,去验证用户提交的凭证,这里的凭证一般就是账号和密码。

为了使用户能够提供凭证,应用程序就需要一个登陆页面,通过提供登录表单,与用户进行交互。

为了实现登陆操作,我们要做的第一件事,就是禁止未经认证的用户,访问 Home 控制器中的 Employees 操作方法。

为此,我们必须为这个操作,添加[Authorize]特性:

[Authorize]
public async Task<IActionResult> Employees()

然后,在启动类中注册身份认证与授权中间件:

// 认证
app.UseAuthentication();
// 授权
app.UseAuthorization();

需要注意的是,由于管道中间件有执行顺序,所以身份认证中间件,必须在授权中间件之前注册。

如果此时运行应用程序,并单击 Employees 链接,我们会看到一个 404 Not Found 的响应。

快速掌握 ASP.NET 身份认证框架 Identity - 登录与登出

之所以会出现这种情况,是因为默认情况下,ASP.NET Core Identity 会尝试将未认证的用户,重定向到 /Account/Login 以引导用户登录,然而这个路由对应的操作我们并没有提供。

另外,我们还可以在地址栏中看到一个 ReturnUrl 的查询参数,它提供了用户重定向到登录页面之前的操作路径。

也就是说,当用户登陆成功后,会重定向回登陆之前的页面。

登录

现在,让我们创建与登录相关的东西。

首先是用户登录模型,用来接受登录表单中用户提交的登录凭证。

在 「Models」 文件夹中,创建一个 「UserLoginModel」 类:

public class UserLoginModel
{
   [Display(Name = "电子邮箱")]
   [Required(ErrorMessage = "电子邮箱不能为空")]
   [EmailAddress(ErrorMessage = "电子邮箱格式不正确")]
   public string Email { get; set; }

   [Display(Name = "密码")]
   [Required(ErrorMessage = "密码不能为空")]
   [DataType(DataType.Password)]
   public string Password { get; set; }

   [Display(Name = "记住账号")]
   public bool RememberMe { get; set; }
}

接下来,在 「AccountController」 控制器中,创建 「Login」 操作方法:

[HttpGet]
public IActionResult Login()
{
    return View();
}

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Login(UserLoginModel userModel)
{
    return View();
}

以及包含登录表单的 「Login」 视图:

快速掌握 ASP.NET 身份认证框架 Identity - 登录与登出

现在这个登录视图,只有通过访问受保护的操作,才能够被访问。

但这不符合常理,我们必须提供一个单独的登录链接,让我们修改 _LoginPartial 分部视图:

<ul class="navbar-nav">

    <li class="nav-item">
        <a class="nav-link text-dark" asp-controller="Account"
           asp-action="Login">登录</a>
    </li>
    <li class="nav-item">
        <a class="nav-link text-dark" asp-controller="Account"
           asp-action="Register">注册</a>
    </li>

</ul>

启动应用,可以重复上一次的操作,验证登录页面是否会被打开:

快速掌握 ASP.NET 身份认证框架 Identity - 登录与登出

当我们点击登录按钮时,表单数据会被提交到 POST 请求的 「Login」 操作,但是现在还没有任何登录逻辑。

接下来,让我们修改 Login 方法,实现登录逻辑:

using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Login(UserLoginModel userModel)
{
    if(!ModelState.IsValid)
    {
        return View(userModel);
    }

    var user = await _userManager.FindByEmailAsync(userModel.Email);
    if(user != null && 
       await _userManager.CheckPasswordAsync(user, userModel.Password))
    {
        var identity = new ClaimsIdentity(IdentityConstants.ApplicationScheme);
        identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, user.Id));
        identity.AddClaim(new Claim(ClaimTypes.Name, user.UserName));
        // 可以添加更多自定义用户信息
        identity.AddClaim(new Claim("firstname", user.FirstName));
        identity.AddClaim(new Claim("lastname", user.LastName));
        var roles = await _userManager.GetRolesAsync(user);
        foreach (var role in roles)
        {
            identity.AddClaim(new Claim(ClaimTypes.Role, role));
        }

        await HttpContext.SignInAsync(IdentityConstants.ApplicationScheme,
                                      new ClaimsPrincipal(identity));

        return RedirectToAction(nameof(HomeController.Index), "Home");
    }
    else
    {
        ModelState.AddModelError("", "无效的用户名或密码");
        return View();
    }
}

解释一下这段代码,验证模型是否无效,如果无效,就直接返回视图。

之后,使用 「UserManager」 中的 「FindByEmailAsync」 方法,通过电子邮件查询用户。

使用 「CheckPasswordAsync」 方法,检查用户密码是否与数据库中的哈希密码匹配。

如果用户存在,并且密码验证通过,就创建一个 「ClaimsIdentity」 对象。

ClaimsIdentity 代表身份对象,其中包含两个声明:ID和用户名。

当然,你也可以添加更多的自定义用户信息,比如姓名、角色等。

ApplicationScheme 表示身份方案的名称,这是一个预定义好的静态变量,最终体现为一个名称为 「Identity.Application」 的 Cookie。

之后,通过 「SignInAsync」 方法进行登录,第一个参数就是刚才的方案名称,第二个参数是一个身份持有对象,也就是真正代表用户的对象,我们需要给它提供一个身份。

这个方法会在我们的浏览器中,创建名为 「Identity.Application」 的 Cookie 数据,其值就是身份对象中的信息。

登陆成功后,会将用户重定向到之前的 「Index」 页面。

如果数据库中不存在该用户,或密码不匹配,那就返回一个带有错误消息的视图。

接着,修改一下 「Employees」 视图,让我们登录后可以看到身份信息:

<h2>Claim details</h2>
<ul>
    @foreach (var claim in User.Claims)
    {
        <li><strong>@claim.Type</strong>: @claim.Value</li>
    }
</ul>

现在启动应用,点击 「Employees」 连接,由于我们现在通过认证,所以会跳转到登录页面。

使用刚才注册的用户登录,然后再次点击 「Employees」 连接,可以看到 「Employees」 的数据表格,以及下方的身份信息了。

快速掌握 ASP.NET 身份认证框架 Identity - 登录与登出

我们还可以查看浏览器里的 Cookies ,可以看到有两个 Cookie:

快速掌握 ASP.NET 身份认证框架 Identity - 登录与登出

「.AspNetCore.Identity.Application」 中保存了身份信息;

「.AspNetCore.Antiforgery.xxxxx」 中保存了验证表单的令牌。

跳转源地址

不过现在还有个小问题,前面我说过,如果用户未经授权访问受保护的操作,就会将被重定向到 Login 页面。

此时,URL 中会包含一个 「ReturnUrl」 查询参数,该参数显示用户来自的源页面。

但在我们的示例中,我们直接将用户导航到了 「/Home/Index」,而不是跳转到 ReturnUrl 中的源页面。

想要实现这个功能,我们需要修改 Get 请求的 「Login」 操作:

[HttpGet]
public IActionResult Login(string returnUrl = null)
{
    ViewData["ReturnUrl"] = returnUrl;
    return View();
}

然后,修改 「Login.cshtml」 视图文件:

<form asp-action="Login" asp-route-returnUrl="@ViewData["ReturnUrl"]">

通过 ViewData 将 「returnUrl」 的值,传到视图的表单中的路由参数。

当提交表单时,「ReturnUrl」 就会通过路由参数,提交给 POST 请求的 「Login」 操作。

所以,我们还需要修改 POST 请求的 「Login」 操作:

public async Task<IActionResult> Login(UserLoginModel userModel, string returnUrl = null)

先添加一个 returnUrl 参数,然后创建一个用来重定向的普通方法:

private IActionResult RedirectToLocal(string returnUrl)
{
    if (Url.IsLocalUrl(returnUrl))
        return Redirect(returnUrl);
    else
        return RedirectToAction(nameof(HomeController.Index), "Home");

}

这个方法会先检查 「returnUrl」 是不是本地 URL,如果是,就将用户重定向到该地址,否则,就将用户重定向到主页。

最后,修改 「Login」 操作的返回值,调用刚才添加的方法:

return RedirectToLocal(returnUrl);

启动应用,可以看到,现在已经可以正确跳转到源地址了。

需要说明的是,我们的登录操作位于 「/Account/Login」 路由地址,这是 ASP.NET Core Identity 的默认登录路由地址。

如果你不想使用默认的地址,可以在服务配置方法中进行配置,比如:

builder.Services.ConfigureApplicationCookie(o => o.LoginPath = "/Authentication/Login");

简化登录

前面我们演示的身份认证是完全版的。但是,如果你不需要完全控制身份认证的逻辑,那么有一个更简单的方法:

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Login([FromServices]SignInManager<User> signInManager, UserLoginModel userModel, string returnUrl = null)
{
    if(!ModelState.IsValid)
    {
        return View(userModel);
    }

    var result = await signInManager.PasswordSignInAsync(
        userModel.Email, userModel.Password, 
        userModel.RememberMe, false);
    
    if (result.Succeeded)
    {
        return RedirectToLocal(returnUrl);
    }

    ModelState.AddModelError("", "无效的用户名或密码");
    return View();
}

在登录操作中注入 「SignInManager」 服务,使用 「PasswordSignInAsync」 方法,代替之前的验证逻辑。

这个方法接受四个参数:用户名、密码、持久化标志和登录锁定标志。

关于登录锁定功能后面我们再详说,这里先把它设置为false。

这个方法,完成了我们在前面演示的所有登录逻辑。

此外,它还返回具有四个属性值的结果,其中 「Succeeded」 属性代表是否成功。

登出

使用 ASP.NET Core Identity 实现登录就是如此简单,登出就更简单了。

首先,修改 「_LoginPartial」 登录分部视图:

@using Microsoft.AspNetCore.Identity
@using IdentitySample.Entites
@inject SignInManager<User> _signInManager

@{
    var lastname = User.Claims.SingleOrDefault(claim => claim.Type == "lastname")?.Value;
}

<ul class="navbar-nav">
    @if (_signInManager.IsSignedIn(User))
    {
        <li class="nav-item">
            <a class="nav-link text-dark" asp-controller="Home" asp-action="Index" 
               title="Welcome">欢迎 @lastname!
            </a>
        </li>
        <li class="nav-item">
            <a class="nav-link text-dark" asp-controller="Account"
               asp-action="Logout">登出</a>
        </li>
    }
    else
    {
        <li class="nav-item">
            <a class="nav-link text-dark" asp-controller="Account"
               asp-action="Login">登录</a>
        </li>
        <li class="nav-item">
            <a class="nav-link text-dark" asp-controller="Account"
               asp-action="Register">注册</a>
        </li>
    }
</ul>

在视图中注入 「SignInManager」 服务,使用它来判断用户是否登录,然后渲染不同的片段。

已登录就显示欢迎语与登出按钮,未登录和之前一样。

接下来,实现 「Logout」 登出操作:

public async Task<IActionResult> Logout([FromServices]SignInManager<User> signInManager)
{
    await signInManager.SignOutAsync();

    return RedirectToAction(nameof(HomeController.Index), "Home");
}

「SignOutAsync」 方法会通过删除 Cookies,来实现用户的登出。

小结

现在,我们已经实现了用户的登录与登出,具体的代码可以参看示例项目,下篇文章将会讲解用户在忘记密码后,如何通过邮件服务重置密码。