1. 程式人生 > 其它 >用NetCore + ReactJS 實現一個前後端分離的網站 (4) 使用者登入與授權

用NetCore + ReactJS 實現一個前後端分離的網站 (4) 使用者登入與授權

用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. 依葫蘆畫瓢新增IUserRepository, UserRepository, 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. 在Common中建立工具類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.3. 在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.4 在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.5. 建立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
            });
            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 });
        }
    }
}