ASP.NET Core搭建多層網站架構【10-使用JWT進行受權驗證】

2020/01/31, ASP.NET Core 3.1, VS2019, Microsoft.AspNetCore.Authentication.JwtBearer 3.1.1html

摘要:基於ASP.NET Core 3.1 WebApi搭建後端多層網站架構【10-使用JWT進行受權驗證】
使用JWT給網站作受權驗證前端

文章目錄git

此分支項目代碼github

本章節介紹了使用JWT給網站作受權驗證數據庫

添加包引用

MS.Component.Jwt類庫中添加Microsoft.AspNetCore.Authentication.JwtBearer包引用:json

<ItemGroup>
  <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="3.1.1" />
</ItemGroup>

MS.Component.Jwt類庫中引用MS.EntitiesMS.WebCore項目
MS.Models類庫中確保已引用MS.Component.Jwt項目後端

添加jwt配置

appsettings.json

MS.WebApi應用程序的appsettings.json中增長JwtSetting節點:api

"JwtSetting": {
  "Issuer": "MS.WebHost",
  "Audience": "MS.Audience",
  "SecurityKey": "MS.WebHost SecurityKey", //more than 16 chars
  "LifeTime": 1440 //(minutes) token life time default:1440 m=1 day
}
  • Issuer是頒發者
  • Audience是受衆
  • SecurityKey是安全密鑰,至少要16個字符
  • LifeTime是token的存活時間,這裏指定了時間單位是分鐘,注意JWT有本身默認的緩衝過時時間(五分鐘)

JwtSetting.cs

MS.Component.Jwt類庫中添加JwtSetting.cs類:安全

namespace MS.Component.Jwt
{
    public class JwtSetting
    {
        /// <summary>
        /// 頒發者
        /// </summary>
        public string Issuer { get; set; }

        /// <summary>
        /// 受衆
        /// </summary>
        public string Audience { get; set; }

        /// <summary>
        /// 安全密鑰
        /// </summary>
        public string SecurityKey { get; set; }

        /// <summary>
        /// 過時時間
        /// </summary>
        public double LifeTime { get; set; }
    }
}

能夠使用選擇性粘貼,將json直接粘貼爲類架構

添加UserClaim

MS.Component.Jwt類庫中新建UserClaim文件夾,在該文件夾中新建UserClaimType.csIClaimsAccessor.csClaimsAccessor.csUserData.cs類:

UserClaimType.cs

namespace MS.Component.Jwt.UserClaim
{
    public static class UserClaimType
    {
        public const string Id = "http://schemas.microsoft.com/ws/2008/06/identity/claims/primarysid";
        public const string Account = "http://schemas.microsoft.com/ws/2008/06/identity/claims/serialnumber";
        public const string Name = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name";
        public const string Email = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress";
        public const string Phone = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/mobilephone";
        public const string RoleName = "http://schemas.microsoft.com/ws/2008/06/identity/claims/role";
        public const string RoleDisplayName = "http://schemas.xmlsoap.org/ws/2009/09/identity/claims/actor";
    }
}

這個類是聲明用戶信息的
裏面的值都是從System.Security.Claims.ClaimTypes裏挑選出來的值,也能夠自行定義

ClaimsAccessor.cs

IClaimsAccessor接口:

namespace MS.Component.Jwt.UserClaim
{
    public interface IClaimsAccessor
    {
        string UserName { get; }
        long UserId { get; }
        string UserAccount { get; }
        string UserRole { get; }
        string UserRoleDisplayName { get; }
    }
}

ClaimsAccessor實現:

using Microsoft.AspNetCore.Http;
using System;
using System.Linq;
using System.Security.Claims;

namespace MS.Component.Jwt.UserClaim
{
    public class ClaimsAccessor : IClaimsAccessor
    {
        private readonly IHttpContextAccessor _httpContextAccessor;

        public ClaimsAccessor(IHttpContextAccessor httpContextAccessor)
        {
            _httpContextAccessor = httpContextAccessor;
        }

        public ClaimsPrincipal UserPrincipal
        {
            get
            {
                ClaimsPrincipal user = _httpContextAccessor.HttpContext.User;
                if (user.Identity.IsAuthenticated)
                {
                    return user;
                }
                else
                {
                    throw new Exception("用戶未認證");
                }
            }
        }
        public string UserName
        {
            get
            {
                return UserPrincipal.Claims.First(x => x.Type == UserClaimType.Name).Value;
            }
        }
        public long UserId
        {
            get
            {
                return long.Parse(UserPrincipal.Claims.First(x => x.Type == UserClaimType.Id).Value);
            }

        }
        public string UserAccount
        {
            get
            {
                return UserPrincipal.Claims.First(x => x.Type == UserClaimType.Account).Value;
            }
        }
        public string UserRole
        {
            get
            {
                return UserPrincipal.Claims.First(x => x.Type == UserClaimType.RoleName).Value;
            }
        }
        public string UserRoleDisplayName
        {
            get
            {
                return UserPrincipal.Claims.First(x => x.Type == UserClaimType.RoleDisplayName).Value;
            }
        }
    }
}

定義用戶信息訪問接口,開發時經過獲取IClaimsAccessor接口來獲取登陸用戶的信息。

UserData.cs

namespace MS.Component.Jwt.UserClaim
{
    public class UserData
    {
        public long Id { get; set; }
        public string Account { get; set; }
        public string Name { get; set; }
        public string Email { get; set; }
        public string Phone { get; set; }
        public string RoleName { get; set; }
        public string RoleDisplayName { get; set; }

        public string Token { get; set; } 
    }
}

定義用戶數據類

jwt服務

MS.Component.Jwt類庫中新建JwtService.cs類:

using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using MS.Component.Jwt.UserClaim;
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;

namespace MS.Component.Jwt
{
    public class JwtService
    {
        private readonly JwtSetting _jwtSetting;
        private readonly TimeSpan _tokenLifeTime;

        public JwtService(IOptions<JwtSetting> options)
        {
            _jwtSetting = options.Value;
            _tokenLifeTime = TimeSpan.FromMinutes(options.Value.LifeTime);
        }
        /*
             iss (issuer):簽發人
             exp (expiration time):過時時間
             sub (subject):主題
             aud (audience):受衆
             nbf (Not Before):生效時間
             iat (Issued At):簽發時間
             jti (JWT ID):編號
             */

        /// <summary>
        /// 生成身份信息
        /// </summary>
        /// <param name="userName">用戶名</param>
        /// <param name="roleName">登陸時的角色</param>
        /// <returns></returns>
        public Claim[] BuildClaims(UserData userData)
        {
            // 配置用戶標識
            var userClaims = new Claim[]
            {
                new Claim(UserClaimType.Id,userData.Id.ToString()),//id
                new Claim(UserClaimType.Account,userData.Account),//account
                new Claim(UserClaimType.Name,userData.Name),//name
                new Claim(UserClaimType.RoleName,userData.RoleName),//rolename
                new Claim(UserClaimType.RoleDisplayName,userData.RoleDisplayName),//roledisplayname
                new Claim(JwtRegisteredClaimNames.Jti,userData.Id.ToString()),
                new Claim(JwtRegisteredClaimNames.Iat, DateTime.Now.ToString()),
                //new Claim(JwtRegisteredClaimNames.Iss,_jwtSetting.Issuer),
                //new Claim(JwtRegisteredClaimNames.Aud,_jwtSetting.Audience),
                //new Claim(JwtRegisteredClaimNames.Nbf,$"{new DateTimeOffset(DateTime.Now).ToUnixTimeSeconds()}") ,
                //這個就是過時時間,可自定義,注意JWT有本身的緩衝過時時間
                //new Claim (JwtRegisteredClaimNames.Exp,$"{new DateTimeOffset(DateTime.Now.Add(_tokenLifeTime)).ToUnixTimeSeconds()}"),
            };
            return userClaims;
        }

        /// <summary>
        /// 生成jwt令牌
        /// </summary>
        /// <param name="claims">自定義的claim</param>
        /// <returns></returns>
        public string BuildToken(Claim[] claims)
        {
            var nowTime = DateTime.Now;
            var creds = new SigningCredentials(new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSetting.SecurityKey)), SecurityAlgorithms.HmacSha256);
            JwtSecurityToken tokenkey = new JwtSecurityToken(
                issuer: _jwtSetting.Issuer,
                audience: _jwtSetting.Audience,
                claims: claims,
                notBefore: nowTime,
                expires: nowTime.Add(_tokenLifeTime),
                signingCredentials: creds);

            return new JwtSecurityTokenHandler().WriteToken(tokenkey);
        }
    }
}
  • 這個是jwt核心的生成token服務類,能夠把它以單例的形式註冊在ioc容器中
  • 調用的時候,先生成用戶身份信息
  • 再將用戶身份信息生成token,此時在JwtSecurityToken中定義了token的過時時間、頒發時間、加密方式等

封裝Ioc註冊

MS.Component.Jwt類庫中新建JwtServiceExtensions.cs類:

using MS.Component.Jwt.UserClaim;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using System;
using System.Text;

namespace MS.Component.Jwt
{
    public static class JwtServiceExtensions
    {
        public static IServiceCollection AddJwtService(this IServiceCollection services, IConfiguration configuration)
        {
            //綁定appsetting中的jwtsetting
            services.Configure<JwtSetting>(configuration.GetSection(nameof(JwtSetting)));

            //註冊jwtservice
            services.AddSingleton<JwtService>();
            //註冊IHttpContextAccessor
            services.AddScoped<IHttpContextAccessor, HttpContextAccessor>();
            services.AddScoped<IClaimsAccessor, ClaimsAccessor>();

            var jwtConfig = configuration.GetSection("JwtSetting");

            services
                .AddAuthentication(options =>
                {
                    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
                    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
                    options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
                })
                .AddJwtBearer(o =>
                {
                    o.TokenValidationParameters = new TokenValidationParameters
                    {
                        ValidateIssuerSigningKey = true,
                        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtConfig["SecurityKey"])),

                        ValidateIssuer = true,
                        ValidIssuer = jwtConfig["Issuer"],

                        ValidateAudience = true,
                        ValidAudience = jwtConfig["Audience"],

                        //總的Token有效時間 = JwtRegisteredClaimNames.Exp + ClockSkew ;
                        RequireExpirationTime = true,
                        ValidateLifetime = true,// 是否驗證Token有效期,使用當前時間與Token的Claims中的NotBefore和Expires對比.同時啓用ClockSkew 
                        ClockSkew = TimeSpan.Zero //注意這是緩衝過時時間,總的有效時間等於這個時間加上jwt的過時時間,若是不配置,默認是5分鐘

                    };
                });
            return services;
        }
    }
}
  • 綁定appsetting中的jwtsetting
  • 以單例形式註冊jwtservice
  • 註冊IHttpContextAccessor和IClaimsAccessor爲Scoped生命週期(網上不少文章都把IHttpContextAccessor的生命週期定義爲單例,我不是很理解,我認爲Scoped更好,若是有明白的小夥伴能夠給我指點下)
  • IHttpContextAccessor是ASP.NET Core自帶的接口,而IClaimsAccessor是我本身對IHttpContextAccessor的一個封裝,因此這兩個接口的註冊生命週期保持了一致
  • 根據appsettings.json中的配置,啓用jwt驗證服務AddJwtBearer:
    • IssuerSigningKey定義了加密密鑰,而ValidateIssuerSigningKey = true啓用了密鑰驗證
    • ValidateIssuer、ValidIssuer和ValidateAudience、ValidAudience這兩對同上
    • 注意token有效時間的計算方法,總的Token有效時間 = JwtRegisteredClaimNames.Exp + ClockSkew
    • 這裏把ClockSkew緩衝時間改爲了0,默認是5分鐘(也就是去掉了緩衝時間)

註冊Jwt服務

MS.WebApi應用程序的Startup.cs類中,ConfigureServices加上services.AddJwtService(Configuration);

開啓認證中間件

MS.WebApi應用程序的Startup.cs類中,中間件配置加上app.UseAuthentication();以開啓認證中間件:

  • 注意app.UseAuthentication()是認證中間件,而app.UseAuthorization()是受權中間件
  • 中間件的順序不能隨意調整!

至此關於開啓jwt受權驗證、開啓認證中間件、jwt服務註冊都已完成

  1. 網站設定好JWT配置,例如頒發者、密鑰、token的過時時間
  2. 用戶輸入帳號密碼進行登陸,網站驗證成功後調用JwtService生成並返回一個token給前端
  3. 用戶在以後的請求中都會攜帶好這個token,而用戶的信息就存在token中
  4. ASP.NET Core中有個IHttpContextAccessor接口,能夠訪問每次請求的上下文,從而可讓後端獲取到當前請求的token中的用戶信息
  5. 我這裏對IHttpContextAccessor接口作了一個封裝,叫IClaimsAccessor,因此能夠直接經過IClaimsAccessor獲取到用戶信息
  6. 若是token過時、用戶未登陸,api接口調用會返回錯誤代碼401未認證

用戶登陸

LoginViewModel.cs

MS.Models類庫中,在ViewModel文件夾下新建LoginViewModel.cs類:

using AutoMapper;
using Microsoft.EntityFrameworkCore;
using MS.Common.Security;
using MS.Component.Jwt.UserClaim;
using MS.DbContexts;
using MS.Entities;
using MS.Entities.Core;
using MS.UnitOfWork;
using MS.WebCore;
using MS.WebCore.Core;
using System;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;

namespace MS.Models.ViewModel
{
    public class LoginViewModel
    {
        [Display(Name = "用戶名")]
        [Required(ErrorMessage = "{0}必填")]
        [StringLength(16, ErrorMessage = "不能超過{0}個字符")]
        [RegularExpression(@"^[a-zA-Z0-9_]{4,16}$", ErrorMessage = "只能包含字符、數字和下劃線")]
        public string Account { get; set; }
        [Display(Name = "密碼")]
        [Required(ErrorMessage = "{0}必填")]
        public string Password { get; set; }

        public async Task<ExecuteResult<UserData>> LoginValidate(IUnitOfWork<MSDbContext> unitOfWork, IMapper mapper, SiteSetting siteSetting)
        {
            ExecuteResult<UserData> result = new ExecuteResult<UserData>();
            //將登陸用戶查出來
            var loginUserInDB = await unitOfWork.GetRepository<UserLogin>().FindAsync(Account);

            //用戶不存在
            if (loginUserInDB is null)
            {
                return result.SetFailMessage("用戶不存在");
            }

            //用戶被鎖定
            if (loginUserInDB.IsLocked &&
                loginUserInDB.LockedTime.HasValue &&
                (DateTime.Now - loginUserInDB.LockedTime.Value).Minutes < siteSetting.LoginLockedTimeout)
            {
                return result.SetFailMessage(string.Format("用戶已被鎖定,請{0}分鐘後再試!", siteSetting.LoginLockedTimeout.ToString()));
            }

            //密碼正確
            if (Crypto.VerifyHashedPassword(loginUserInDB.HashedPassword, Password))
            {
                //密碼正確後才加載用戶信息、角色信息
                var userInDB = await unitOfWork.GetRepository<User>().GetFirstOrDefaultAsync(
                    predicate: a => a.Id == loginUserInDB.UserId,
                    include: source => source
                     .Include(u => u.Role));

                //若是用戶已失效
                if (userInDB.StatusCode != StatusCode.Enable)
                {
                    return result.SetFailMessage("用戶已失效,請聯繫管理員!");
                }

                //用戶正常、密碼正確,更新相應字段
                loginUserInDB.IsLocked = false;
                loginUserInDB.AccessFailedCount = 0;
                loginUserInDB.LastLoginTime = DateTime.Now;
                //提交到數據庫
                await unitOfWork.SaveChangesAsync();

                //獲得userdata
                UserData userData = mapper.Map<UserData>(userInDB);
                return result.SetData(userData);
            }
            //密碼錯誤
            else
            {
                loginUserInDB.AccessFailedCount++;//失敗次數累加
                result.SetFailMessage("用戶名或密碼錯誤!");
                //超出失敗次數限制
                if (loginUserInDB.AccessFailedCount >= siteSetting.LoginFailedCountLimits)
                {
                    loginUserInDB.IsLocked = true;
                    loginUserInDB.LockedTime = DateTime.Now;
                    result.SetFailMessage(string.Format("用戶已被鎖定,請{0}分鐘後再試!", siteSetting.LoginLockedTimeout.ToString()));
                }
                //提交到數據庫
                await unitOfWork.SaveChangesAsync();
                return result;
            }
        }
    }
}

在LoginViewModel中作了核心的登陸驗證,除了驗證密碼,還會校驗用戶密碼錯誤次數,失敗次數(LoginFailedCountLimits)過多會鎖定帳號,在指定時間(LoginLockedTimeout)後才能繼續登陸,這兩個配置在SiteSetting中

UserProfile.cs映射配置

MS.Models類庫中,在Automapper文件夾下新建UserProfile.cs類:

using AutoMapper;
using MS.Component.Jwt.UserClaim;
using MS.Entities;

namespace MS.Models.Automapper
{
    public class UserProfile : Profile
    {
        public UserProfile()
        {
            CreateMap<User, UserData>()
                .ForMember(a => a.Id, t => t.MapFrom(b => b.Id))
                .ForMember(a => a.RoleName, t => t.MapFrom(b => b.Role.Name))
                .ForMember(a => a.RoleDisplayName, t => t.MapFrom(b => b.Role.DisplayName))
                ;
        }
    }
}

創建了User到UserData的映射配置

帳號服務

MS.Services類庫下新建Account文件夾,在該文件夾下新建IAccountService.csAccountService.cs類:
IAccountService.cs:

using MS.Component.Jwt.UserClaim;
using MS.Models.ViewModel;
using MS.WebCore.Core;
using System.Threading.Tasks;

namespace MS.Services
{
    public interface IAccountService : IBaseService
    {
        Task<ExecuteResult<UserData>> Login(LoginViewModel viewModel);
    }
}

AccountService.cs:

using AutoMapper;
using Microsoft.Extensions.Options;
using MS.Common.IDCode;
using MS.Component.Jwt;
using MS.Component.Jwt.UserClaim;
using MS.DbContexts;
using MS.Models.ViewModel;
using MS.UnitOfWork;
using MS.WebCore;
using MS.WebCore.Core;
using System.Threading.Tasks;

namespace MS.Services
{
    public class AccountService : BaseService, IAccountService
    {
        private readonly JwtService _jwtService;
        private readonly SiteSetting _siteSetting;

        public AccountService(JwtService jwtService, IOptions<SiteSetting> options, IUnitOfWork<MSDbContext> unitOfWork, IMapper mapper, IdWorker idWorker) : base(unitOfWork, mapper, idWorker)
        {
            _jwtService = jwtService;
            _siteSetting = options.Value;
        }

        public async Task<ExecuteResult<UserData>> Login(LoginViewModel viewModel)
        {
            var result = await viewModel.LoginValidate(_unitOfWork, _mapper, _siteSetting);
            if (result.IsSucceed)
            {
                result.Result.Token = _jwtService.BuildToken(_jwtService.BuildClaims(result.Result));
                return new ExecuteResult<UserData>(result.Result);
            }
            else
            {
                return new ExecuteResult<UserData>(result.Message);
            }
        }
    }
}
  • 目前就實現了Login邏輯,密碼驗證成功後,將用戶信息交給JwtService生成token
  • 以後還有修改密碼等行爲,也都寫在這個接口裏

登陸接口

MS.WebApi應用程序的Controllers文件夾下新建Base文件夾,在該文件夾下新建AuthorizeController.cs類:

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace MS.WebApi.Controllers
{
    [Route("[controller]")]
    [Authorize]
    public class AuthorizeController : ControllerBase
    {
    }
}
  • 注意命名空間依然是MS.WebApi.Controllers
  • AuthorizeController類上打上了[Authorize]特性,表示須要認證受權後才能訪問

Controllers文件夾下新建AccountController.cs類:

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MS.Component.Jwt.UserClaim;
using MS.Models.ViewModel;
using MS.Services;
using MS.WebCore.Core;
using System.Threading.Tasks;

namespace MS.WebApi.Controllers
{
    [Route("[controller]")]
    [ApiController]
    public class AccountController : AuthorizeController
    {
        private readonly IAccountService _accountService;

        public AccountController(IAccountService accountService)
        {
            _accountService = accountService;
        }

        [HttpPost]
        [AllowAnonymous]
        public async Task<ExecuteResult<UserData>> Login(LoginViewModel viewModel)
        {
            return await _accountService.Login(viewModel);
        }
    }
}
  • 能夠看到,AccountController已經繼承了剛剛的AuthorizeController,因此AccountController內的資源也都要受權後才能訪問
  • Login方法上打了[AllowAnonymous]特性,因此Login未受權也能夠訪問(用戶登陸的接口確定不能有認證限制)

將RoleController.cs的基類也修改成AuthorizeController:

訪問受權接口

至此全部的受權驗證已經完成了,啓動項目,打開Postman,依舊是訪問role接口,會提示401:

在Postman的MSDemo中,新建一個Login請求localhost:5000/account,json參數爲(這是種子數據中的默認超級管理員帳號):

{
	"Account":"admin",
	"Password":"admin"
}

點擊發送,能夠看到登陸成功,返回了用戶信息及token:

咱們複製這段token,右擊MSDemo-Edit-Authorization-TYPE(Bearer Token)-把複製的token粘貼進去:

此時,MSDemo裏全部的接口請求時,都會帶上這段token,就不須要每一個請求單獨添加一次token了

也能夠看到添加上token後,接口訪問又請求成功了

補全RoleService

以前作角色增刪改的時候,建立者和修改者都是臨時代碼,不是當前用戶真實Id,這會兒登陸作好了能夠補全了:

BaseService中添加公開類型的IClaimsAccessor成員,AccountService和RoleService的構造函數都要重構一下
在RoleService中以下圖獲取和使用用戶信息:

項目完成後,以下圖:

相關文章
相關標籤/搜索