用NetCore + ReactJS 实现一个前后端分离的网站 (4) 用户登录与授权

1. 前言

这几天学了一些前端的知识,用Ant Design Pro的脚手架搭建了一个前端项目->这里
登录界面是现成的,所以回到后端来完成相应的API。

2. 登录与授权

2.1. 首先利用EFCore的Migration功能创建数据表,并添加种子数据。

User.cs
using Microsoft.EntityFrameworkCore.Metadata.Internal;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;

namespace NovelTogether.Core.Model.Models
{
    public class User
    {
        [Key]
        public int ID { get; set; }
        [Required]
        [Column(TypeName = "varchar")]
        [MaxLength(128)]
        [JsonPropertyName("username")]
        public string? UserName { get; set; }
        [Required]
        public string? Password { get; set; }
        [Required]
        public string? PasswordSalt { get; set; }
        [Required]
        [Column(TypeName = "nvarchar")]
        [MaxLength(128)]
        public string? NickName { get; set; }
        [Column(TypeName = "nvarchar")]
        [MaxLength(128)]
        public string? FirstName { get; set; }
        [Column(TypeName = "nvarchar")]
        [MaxLength(128)]
        public string? LastName { get; set; }
        public string? IDNumber { get; set; }
        public string? Email { get; set; }
        public string? PhoneNumner { get; set; }
        public string? Address { get; set; }
        [Required]
        public DateTime Birthday { get; set; }
        [Required]
        public DateTime CreatedDate { get; set; }
        public DateTime? ModifiedTime { get; set; }
    }
}

ModelBuilderExtension.cs
public static void AddUserSeed(this ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<User>().HasData(
                new User() { ID = 1, UserName = "SuperAdmin", Password = string.Empty, PasswordSalt = string.Empty, NickName = "我是超级管理员", Birthday = new DateTime(1970, 1, 1), CreatedDate = DateTime.Now },
                new User() { ID = 2, UserName = "Wright", Password = "ddcddb33bd354212e2c2404fbf079a84", PasswordSalt = "qwe123!@#", NickName = "我是普通管理员", Birthday = new DateTime(1970, 1, 1), CreatedDate = DateTime.Now }
                );
        }

2.2. 依葫芦画瓢添加IUserService, UserService,为后面的API提供服务。

2.3. 用户身份验证和Jwt Token授权。

先说一下登录以及API授权访问的原理:

  • 前端对用户输入的密码和从后端获取的密码盐一起用MD5加密,然后传到后端验证。

为什么要加盐:因为常见的密码的MD5值是固定的,数据库要是泄露了,很容易被别人猜出来,所以数据的密码是MD5(密码+盐)。
为什么不在后端生成随机数保护密码的传输:前后端分离,所有信息都在网络上传输,随机数也不安全,只有用Https来保护数据,所以不需要加额外的数据安全措施。

  • 后端密码验证通过之后,生成一个Jwt Token,并在Payload中存放一个userid通过SetCookie的方式在前端写入HttpOnly的Cookie,确保这个Cookie前端JS的无法访问和修改的。
  • 前端后续的API请求会自动带上这个Cookie,后端接收到请求后,通过中间件取出Jwt Token,验证通过后取出其中存放的userid放入上下文中。
  • 所有的API就可以从上下文中得到当前用户的id。
    下面是相应的代码:

2.3.1. 在ViewModels目录下创建登录接口用的request以及response的实体类,以及一般response的实体类。

LoginModel.cs
using System.Text.Json.Serialization;

namespace NovelTogether.Core.Model.ViewModels
{
    public class LoginModel
    {
        [JsonPropertyName("username")]
        public string? UserName { get; set; }
        public string? Password { get; set; }
        public bool AutoLogin { get; set; }
    }
}

LoginResultModel.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace NovelTogether.Core.Model.ViewModels
{
    public class LoginResultModel
    {
        public string? Status { get; set; }
        public string? Type { get; set; }
        public string? CurrentAuthority { get;set; }

        public LoginResultModel Success(LoginType loginType)
        {
            Status = "success";
            Type = loginType.ToString().ToLower();

            return this;    
        }

        public LoginResultModel Error(LoginType loginType)
        {
            Status = "error";
            Type = loginType.ToString().ToLower();

            return this;
        }
    }

    public enum LoginType
    {
        Account,
        Mobile
    }
}

ResponseModel.cs
using System.Text.Json;
using System.Text.Json.Serialization;

namespace NovelTogether.Core.Model.ViewModels
{
    public class ResponseModel<TEntity> where TEntity : class
    {
        public bool Success { get; set; }
        public TEntity Data { get; set; }
        public int ErrorCode { get; set; }
        public string ErrorMessage { get; set; }
        public ErrorShowType ErrorShowType { get; set; }

        public ResponseModel<TEntity> Ok(TEntity entity)
        {
            Success = true;
            Data = entity;
            
            return this;
        }

        public ResponseModel<TEntity> Failed(int statusCode)
        {
            Success = false;
            ErrorCode = statusCode;
            ErrorMessage = "error";

            return this;
        }

        public ResponseModel<TEntity> Failed(int statusCode, TEntity entity)
        {
            Success = false;
            ErrorCode = statusCode;
            ErrorMessage = JsonSerializer.Serialize(entity);

            return this;
        }
    }

    public enum ErrorShowType
    {
        SILENT = 0,
        WARN_MESSAGE = 1,
        ERROR_MESSAGE = 2,
        NOTIFICATION = 3,
        REDIRECT = 9,
    }
}

2.3.2. 在API层创建文件夹Utils,添加一些帮助实体。

AuthToken.cs
namespace NovelTogether.Core.API.Utils
{
    public class AuthToken
    {
        public string? Token { get; set; }
        public string? RefreshToken { get; set; }
    }
}

Consts.cs
namespace NovelTogether.Core.API.Utils
{
    public class Consts
    {
        public static readonly string TOKEN_NAME = "auth-token";
        public static readonly string REFRESH_TOKEN_NAME = "auth-refresh-token";
        public static readonly string USER_ID = "USER_ID";

        public static readonly string UNIQUE_ID = "UNIQUE_ID";
        public static readonly string CACHE_KEY = "CACHE_KEY";
        public static readonly string CACHE_DURATION_SECONDS = "CACHE_DURATION_SECONDS";
    }
}

JwtOption.cs
namespace NovelTogether.Core.API.Utils
{
    public class JwtOption
    {
        public string? Secret { get; set; }
        public string? Issuer { get; set; }
        public string? Audience { get; set; }
    }
}

2.3.3. 在API层中创建工具类JwtHelper.cs,用来生成Jwt Token。

JwtHelper.cs
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;

namespace NovelTogether.Core.API.Helpers.Jwt
{
    public class JwtHelper
    {
        public static string CreateToken(string secret, string issuer, string audience, int expiredHours, List<Claim> claims)
        {
            //秘钥
            byte[] secretBytes = Encoding.UTF8.GetBytes(secret);
            //生成秘钥
            var key = new SymmetricSecurityKey(secretBytes);
            //生成数字签名的签名密钥、签名密钥标识符和安全算法
            var credential = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
            //构建JwtSecurityToken类实例
            var token = new JwtSecurityToken(
                //添加颁发者
                issuer: issuer,
                //添加受众
                audience: audience,
                 //添加其他需要加密的信息
                claims,
                //自定义过期时间
                expires: DateTime.UtcNow.AddHours(expiredHours),
                signingCredentials: credential);

            //签发token
            var jwtToken = new JwtSecurityTokenHandler().WriteToken(token);

            return jwtToken;
        }
    }
}

2.3.4. 在API中添加Token验证的中间件

AuthMiddleware.cs
using Microsoft.IdentityModel.Tokens;
using NovelTogether.Core.API.Utils;
using System.IdentityModel.Tokens.Jwt;
using System.Text;

namespace NovelTogether.Core.API.Middlewares
{
    public class AuthMiddleware
    {
        private readonly RequestDelegate _next;
        private readonly JwtOption _jwtOption;

        public AuthMiddleware(RequestDelegate next, JwtOption jwtOption)
        {
            _next = next;
            _jwtOption = jwtOption;
        }

        public async Task Invoke(HttpContext context)
        {
            //Get the upload token, which can be customized and extended
            var token = context.Request.Cookies[Consts.TOKEN_NAME]?.Split(" ").Last();

            if (token != null)
                AttachTokenToContext(context, token);

            await _next(context);
        }

        private void AttachTokenToContext(HttpContext context, string token)
        {
            try
            {
                var tokenHandler = new JwtSecurityTokenHandler();
                tokenHandler.ValidateToken(token, new TokenValidationParameters
                {
                    ValidAudience = _jwtOption.Audience,
                    ValidIssuer = _jwtOption.Issuer,
                    ValidateIssuer = true,
                    ValidateAudience = true,
                    IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOption.Secret)),
                    ClockSkew = new System.TimeSpan(0, 0, 30)
                }, out SecurityToken validatedToken);

                var jwtToken = (JwtSecurityToken)validatedToken;

                // attach user id to context on successful jwt validation
                context.Items[Consts.USER_ID] = jwtToken.Claims.First(x => x.Type == Consts.USER_ID).Value;
            }
            catch
            {
            }
        }
    }
}

2.3.5. 在API中添加自定义属性用替换默认的[Authorize]属性。

ApiAuthorizeAttribute.cs
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc;
using System.Net;
using NovelTogether.Core.Model.ViewModels;
using NovelTogether.Core.API.Utils;

namespace NovelTogether.Core.API.Attributes
{
    public class ApiAuthorizeAttribute : Attribute, IAuthorizationFilter
    {
        public void OnAuthorization(AuthorizationFilterContext context)
        {
            var accountId = context.HttpContext.Items[Consts.USER_ID];
            if (accountId == null)
            { 
                //context.HttpContext.Response.StatusCode = HttpStatusCode.Unauthorized.GetHashCode();
                context.Result = new UnauthorizedObjectResult(new ResponseModel<string>().Failed(HttpStatusCode.Unauthorized.GetHashCode()));
            }
        }
    }
}

2.3.6. 创建UserController,下面的代码中实现了三个方法:登录、登出、获取当前用户信息。

获取用户信息的接口受[ApiAuthorize]属性保护。

UserController.cs
using Microsoft.AspNetCore.Mvc;
using NovelTogether.Core.API.Attributes;
using NovelTogether.Core.API.Helpers.Jwt;
using NovelTogether.Core.API.Utils;
using NovelTogether.Core.IService;
using NovelTogether.Core.Model.ViewModels;
using System.Net;
using System.Security.Claims;

namespace NovelTogether.Core.API.Controllers
{
    [ApiController]
    [Route("api/[controller]")]
    public class UserController : Controller
    {
        private readonly IUserService _userService;
        private readonly IConfiguration _configuration;

        public UserController(IUserService userService, IConfiguration configuration)
        {
            _userService = userService;
            _configuration = configuration;
        }

        [HttpPost("Login")]
        public async Task<LoginResultModel> Login(LoginModel loginModel)
        {
            // 验证用户名、密码
            var userName = loginModel.UserName;
            var password = loginModel.Password;

            if (string.IsNullOrEmpty(userName) || string.IsNullOrWhiteSpace(password))
            {
                return new LoginResultModel().Error(LoginType.Account);
            }

            var user = await _userService.SelectAsync(x => x.UserName != null && x.UserName.ToLower() == userName.ToLower() && x.Password == password);
            if (user == null)
            {
                return new LoginResultModel().Error(LoginType.Account);
            }

            var token = JwtHelper.CreateToken(
                _configuration.GetValue<string>("Config:JWT:Secret"),
                _configuration.GetValue<string>("Config:JWT:Issuer"),
                _configuration.GetValue<string>("Config:JWT:Audience"),
                _configuration.GetValue<int>("Config:JWT:ExpiredHours"),
                new List<Claim>()
                {
                    new Claim(ClaimTypes.NameIdentifier, userName.ToLower()),
                    new Claim(Consts.USER_ID, user.ID.ToString())
                });

            // 返回JWT Token
            Response.Cookies.Append(Consts.TOKEN_NAME, token, new CookieOptions
            {
                 HttpOnly = true,
                Expires = new DateTimeOffset(DateTime.Now.AddHours(_configuration.GetValue<int>("Config:JWT:TokenExpiredHours")))
            });
            return new LoginResultModel().Success(LoginType.Account);
        }

        [HttpPost("Logout")]
        public ResponseModel<string> Logout()
        {
            Response.Cookies.Delete(Consts.TOKEN_NAME);

            return new ResponseModel<string>().Ok("Logout");
        }

        [HttpGet]
        [ApiAuthorize]
        public async Task<ResponseModel<UserModel>> Get()
        {
            var userId = int.Parse(HttpContext.Items[Consts.USER_ID].ToString());
            var user = await _userService.SelectAsync(x => x.ID == userId);
            if (user == null)
            {
                return new ResponseModel<UserModel>().Failed(HttpStatusCode.NotFound.GetHashCode());
            }

            // 返回用户信息
            return new ResponseModel<UserModel>().Ok(new UserModel() { UserName = user.UserName, NickName = user.NickName });
        }
    }
}

3. 刷新Jwt Token

Jwt Token是有时效性的,一般都很短,是为了防止Token被别人知道,拿来假冒用户的身份访问API。
但是也不能说,用户才登上网站10分钟,就把他踹下线,说“来,重新登录一下!”
所以,网站需要能够自动续期Token。
这个项目是后台来管理Token的,前台感知不到,所以刷新Token也由后台来负责。
原理如下:

  • 登录的时候同时生成Jwt Token和Guid类型的Refresh Token,存到客户端的Cookie中,并随着每次的请求传递到后台。Refresh Token在数据库中存有一个过期时间。
  • 后台验证Jwt Token的时候,先不验证LifeTime,获取到Token的Payload,然后手动判断是否过期。
  • 如果Token过期了,判断Refresh Token是否过期了,如果没有,重新生成两个Token给客户端。如果过期了,返回401 Unauthorized

3.1. 建表来维护用户的Refresh Token

UserAccessToken.cs
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace NovelTogether.Core.Model.Models
{
    public class UserAccessToken
    {
        [Key]
        public int ID { get; set; }
        [Required]
        [ForeignKey("User")]
        public int UserID { get;set; }
        [Required]
        public string? RefreshToken { get; set; }
        public DateTime ExpiredTime { get; set; }
        [Required]
        public DateTime CreatedTime { get; set; }
        public DateTime? ModifiedTime { get; set; }
    }
}

3.2 新建工具类来创建两种Token

TokenHelper.cs
using NovelTogether.Core.API.Helpers.Jwt;
using NovelTogether.Core.API.Utils;
using NovelTogether.Core.IService;
using NovelTogether.Core.Model.Models;
using System.Security.Claims;

namespace NovelTogether.Core.API.Helpers
{
    public class TokenHelper
    {
        public static async Task<AuthToken> Generate(IConfiguration configuration, IUserAccessTokenService userAccessTokenService, User user)
        {
            // 生成Jwt Token
            var token = JwtHelper.CreateToken(
                configuration.GetValue<string>("Config:JWT:Secret"),
                configuration.GetValue<string>("Config:JWT:Issuer"),
                configuration.GetValue<string>("Config:JWT:Audience"),
                configuration.GetValue<int>("Config:JWT:TokenExpiredHours"),
                new List<Claim>()
                {
                    new Claim(ClaimTypes.NameIdentifier, user.UserName.ToLower()),
                    new Claim(Consts.USER_ID, user.ID.ToString())
                });

            // 生成Refresh Token
            var refreshToken = Guid.NewGuid().ToString("N");

            var userAccessToken = await userAccessTokenService.SelectAsync(x => x.UserID == user.ID);

            // 写入数据库
            var expiredHours = configuration.GetValue<int>("Config:JWT:RefreshTokenExpiredHours");
            if (userAccessToken == null)
            {
                await userAccessTokenService.AddAsync(
                    new UserAccessToken()
                    {
                        UserID = user.ID,
                        RefreshToken = refreshToken,
                        ExpiredTime = DateTime.Now.AddHours(expiredHours),
                        CreatedTime = DateTime.Now
                    });
            }
            else
            {
                userAccessToken.RefreshToken = refreshToken;
                userAccessToken.ExpiredTime = DateTime.Now.AddHours(expiredHours);
                userAccessToken.ModifiedTime = DateTime.Now;
                await userAccessTokenService.UpdateAsync(userAccessToken);
            }

            return new AuthToken() { Token = token, RefreshToken = refreshToken };
        }
    }
}

3.3 修改Login方法,往Cookie中写入两种Token

UserController.cs
var authToken = await TokenHelper.Generate(_configuration, _userAccessTokenService, user);

// 通过SetCookie往客户端吸入HttpOnly类型的Cookie,这样的Cookie客户端JS无法读取和修改。
context.Response.Cookies.Append(Consts.TOKEN_NAME, token, new CookieOptions
{
    // 这里把过期时间设的和refresh token一样长,因为jwt token是用来确认用户身份的,如果它从cookie中消失了,那refresh token也就失去了作用。
    HttpOnly = true,
    Expires = new DateTimeOffset(DateTime.Now.AddHours(configuration.GetValue<int>("Config:JWT:RefreshTokenExpiredHours")))
});
context.Response.Cookies.Append(Consts.REFRESH_TOKEN_NAME, refreshToken, new CookieOptions
{
    HttpOnly = true,
    Expires = new DateTimeOffset(DateTime.Now.AddHours(configuration.GetValue<int>("Config:JWT:RefreshTokenExpiredHours")))
});

3.4 修改中间件,提供自动续期的功能

注意:服务通过注入从IServiceProvider中获取。

AuthMiddleware.cs
private void AttachTokenToContext(HttpContext context, string token)
{
    try
    {
        var tokenHandler = new JwtSecurityTokenHandler();

        // 如果无法解析,直接会抛错误,然后被catch住,最后由ApiAuthorizeAttribute返回401
        
        tokenHandler.ValidateToken(token, new TokenValidationParameters
        {
            ValidAudience = _jwtOption.Audience,
            ValidIssuer = _jwtOption.Issuer,
            ValidateIssuer = true,
            ValidateAudience = true,
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOption.Secret)),
            ValidateLifetime = false
        }, out SecurityToken validatedToken);

        var jwtToken = (JwtSecurityToken)validatedToken;

        var userId = jwtToken.Claims.First(x => x.Type == Consts.USER_ID).Value;

        // 如果Jwt Token过期了,注意这里是UTC时间
        if ((DateTime.UtcNow - jwtToken.ValidTo).TotalSeconds > 0)
        {
            
            // 从Cookie中获取Refresh Token来刷新Token
            var refreshToken = context.Request.Cookies[Consts.REFRESH_TOKEN_NAME];

            var _configuration = _provider.GetService<IConfiguration>();
            var _userService = _provider.GetService<IUserService>();
            var _userAccessTokenService = _provider.GetService<IUserAccessTokenService>();

            var userAccessToken = _userAccessTokenService.SelectAsync(x => x.UserID.ToString() == userId).Result;

            // 如果Refresh Token没过期
            if (userAccessToken.RefreshToken == refreshToken && (userAccessToken.ExpiredTime - DateTime.Now).TotalSeconds > 0)
            {
                var user = _userService.SelectAsync(x => x.ID.ToString() == userId).Result;
                var authToken = TokenHelper.Generate(_configuration, _userAccessTokenService, user).Result;

                // 通过SetCookie往客户端吸入HttpOnly类型的Cookie,这样的Cookie客户端JS无法读取和修改。
                context.Response.Cookies.Append(Consts.TOKEN_NAME, token, new CookieOptions
                {
                    // 这里把过期时间设的和refresh token一样长,因为jwt token是用来确认用户身份的,如果它从cookie中消失了,那refresh token也就失去了作用。
                    HttpOnly = true,
                    Expires = new DateTimeOffset(DateTime.Now.AddHours(configuration.GetValue<int>("Config:JWT:RefreshTokenExpiredHours")))
                });
                context.Response.Cookies.Append(Consts.REFRESH_TOKEN_NAME, refreshToken, new CookieOptions
                {
                    HttpOnly = true,
                    Expires = new DateTimeOffset(DateTime.Now.AddHours(configuration.GetValue<int>("Config:JWT:RefreshTokenExpiredHours")))
                });

                // attach user id to context on successful jwt validation
                context.Items[Consts.USER_ID] = userId;
            }
            else
            {
                // refresh token也过期了,不往Context中添加user id,由ApiAuthorizeAttribute返回401
            }
        }
        else
        {
            // attach user id to context on successful jwt validation
            context.Items[Consts.USER_ID] = userId;
        }
    }
    catch
    {
    }
}

4. 总结

前后端已然可以安全地交互,那么用日志跟踪用户的行为、程序的运转就随之而来。

posted @ 2022-12-02 12:06  王一乙  阅读(371)  评论(0编辑  收藏  举报