2020/01/31, ASP.NET Core 3.1, VS2019, Microsoft.AspNetCore.Authentication.JwtBearer 3.1.1
摘要:基於ASP.NET Core 3.1 WebApi搭建后端多層網站架構【10-使用JWT進行授權驗證】
使用JWT給網站做授權驗證
本章節介紹了使用JWT給網站做授權驗證
添加包引用
向MS.Component.Jwt
類庫中添加Microsoft.AspNetCore.Authentication.JwtBearer
包引用:
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="3.1.1" />
</ItemGroup>
在MS.Component.Jwt
類庫中引用MS.Entities
、MS.WebCore
項目
MS.Models
類庫中確保已引用MS.Component.Jwt
項目
添加jwt配置
appsettings.json
在MS.WebApi
應用程序的appsettings.json
中增加JwtSetting節點:
"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.cs
、IClaimsAccessor.cs
、ClaimsAccessor.cs
、UserData.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分鍾(也就是去掉了緩沖時間)
- IssuerSigningKey定義了加密密鑰,而
注冊Jwt服務
在MS.WebApi
應用程序的Startup.cs
類中,ConfigureServices加上services.AddJwtService(Configuration);
:
開啟認證中間件
在MS.WebApi
應用程序的Startup.cs
類中,中間件配置加上app.UseAuthentication();
以開啟認證中間件:
- 注意
app.UseAuthentication()
是認證中間件,而app.UseAuthorization()
是授權中間件 - 中間件的順序不能隨意調整!
至此關於開啟jwt授權驗證、開啟認證中間件、jwt服務注冊都已完成
- 網站設定好JWT配置,例如頒發者、密鑰、token的過期時間
- 用戶輸入賬號密碼進行登錄,網站驗證成功后調用JwtService生成並返回一個token給前端
- 用戶在之后的請求中都會攜帶好這個token,而用戶的信息就存在token中
- ASP.NET Core中有個IHttpContextAccessor接口,可以訪問每次請求的上下文,從而可以讓后端獲取到當前請求的token中的用戶信息
- 我這里對IHttpContextAccessor接口做了一個封裝,叫IClaimsAccessor,所以可以直接通過IClaimsAccessor獲取到用戶信息
- 如果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.cs
、AccountService.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中如下圖獲取和使用用戶信息:
項目完成后,如下圖: