1. 基礎配置
在我們構建身份管理服務時,ABP vNext框架已幫我們創建身份認證服務,項目名稱為Demo.Identity.IdentityServer
按我們原定的代碼結構設置,我們在解決方案目錄下添加文件夾identityserver並將該項目移動到該文件夾,之后我們調整解決中的項目結構,如下圖:
Demo.Identity.IdentityServer項目依賴身份管理服務中的Demo.Identity.EntityFrameworkCore項目,調整后其相對路徑會發生變化,需重新引用或修改項目引用路徑。
在IdentityIdentityServerModule類ConfigureServices方法中,我們可以找到如下代碼:
if (hostingEnvironment.IsDevelopment()) { Configure<AbpVirtualFileSystemOptions>(options => { options.FileSets.ReplaceEmbeddedByPhysical<IdentityDomainSharedModule>(Path.Combine(hostingEnvironment.ContentRootPath, $"..{Path.DirectorySeparatorChar}Demo.Identity.Domain.Shared")); options.FileSets.ReplaceEmbeddedByPhysical<IdentityDomainModule>(Path.Combine(hostingEnvironment.ContentRootPath, $"..{Path.DirectorySeparatorChar}Demo.Identity.Domain")); }); }
此處為虛擬文件系統所加載的文件路徑,此路徑已發生變化,我們可以對其進行修改,但在目前的使用場景中,我們可以直接將此代碼注釋或刪除。
修改Demo.Identity.IdentityServer項目的配置文件appsettings.json,將數據庫鏈接指向與身份管理服務相同的數據庫,並配置Redis鏈接字符串。
按規划,我們將該服務端口號設置為4100,即在appsettings.json中添加配置如下:
"urls": "http://*:4100"
以selfhost方式運行Demo.Identity.IdentityServer,並使用瀏覽器訪問http://localhost:4100,可顯示登錄頁面,即服務基礎配置成功。
2.自定義客戶端
ABP vNext框架使用Application方式模板生成時,已經給我們生成了默認IdentityServer服務所需要的API、Client等數據並在數據初始化時存入數據庫中,這里,我們可以在身份管理服務的Demo.Identity.Domain項目中IdentityServer文件夾下的IdentityServerDataSeedContributor類中找到想對應的代碼,在運行Demo.Identity.DbMigrator項目時,會將數據存入Demo_Identity數據庫中以IdentityServer開頭的對應表中。
這里我們可以添加自己所需要的客戶端認證方式,在IdentityServerDataSeedContributor類的CreateClientsAsync方法最后,我們加入以下代碼:
await CreateClientAsync( name: "Demo_App", scopes: commonScopes, grantTypes: new[] { "password" }, secret: "1q2w3e*".Sha256(), requireClientSecret: false, redirectUri: "", postLogoutRedirectUri: "" );
這樣我們就添加了一個名稱為Demo_App的client,密碼為1q2w3e*,可使用用戶名密碼登錄。
如果我們需要使用刷新密碼機制,在IdentityServerDataSeedContributor類的CreateClientsAsync方法開頭位置我們可以找到commonScopes的定義,在其中添加一項“offline_access”,如下:
var commonScopes = new[] { "email", "openid", "profile", "role", "phone", "address", "Identity", "offline_access" };
在IdentityServerDataSeedContributor類的CreateClientAsync方法中,我們會找到如下代碼:
client = await _clientRepository.InsertAsync( new Client( _guidGenerator.Create(), name ) { ClientName = name, ProtocolType = "oidc", Description = name, AlwaysIncludeUserClaimsInIdToken = true, AllowOfflineAccess = true, AbsoluteRefreshTokenLifetime = 31536000, //365 days AccessTokenLifetime = 31536000, //365 days AuthorizationCodeLifetime = 300, IdentityTokenLifetime = 300, RequireConsent = false, FrontChannelLogoutUri = frontChannelLogoutUri, RequireClientSecret = requireClientSecret, RequirePkce = requirePkce }, autoSave: true );
這里AbsoluteRefreshTokenLifetime和AccessTokenLifetime分別為刷新令牌和訪問令牌的有效期,單位為秒。ABP默認為一年,通常我們希望令牌有效期更短一些,可依據我們的業務對這兩個值做出修改,例如刷新令牌有效期為七天,訪問令牌有效期為兩個小時。
完成以上修改后,我們運行Demo.Identity.DbMigrator項目,將新定義的客戶端寫入數據庫中。
3.添加登錄相關接口
在Demo.Identity.IdentityServer項目中添加文件夾Dto存放各接口數據傳輸對象
在Dto文件夾中添加用戶名密碼登錄請求類PasswordLoginRequest如下:
using System.ComponentModel.DataAnnotations; namespace Demo.Identity.Dto; /// <summary> /// 用戶名密碼登錄請求 /// </summary> public class PasswordLoginRequest { /// <summary> /// 用戶名 /// </summary> [Required] public string UserName { get; set; } /// <summary> /// 密碼 /// </summary> [Required] public string Password { get; set; } }
在Dto文件夾下添加登錄響應LoginResponse,這里我們使用C#9.0中的record實現,也可以使用普通類實現,代碼如下:
namespace Demo.Identity.Dto; /// <summary> /// 登錄響應實體 /// </summary> /// <param name="AccessToken">訪問令牌</param> /// <param name="RefreshToken">刷新令牌</param> /// <param name="ExpireInSeconds">有效期(秒)</param> /// <param name="HasError">是否異常</param> /// <param name="Message">異常消息</param> public record LoginResponse( string AccessToken = "", string RefreshToken = "", int ExpireInSeconds = 0, bool HasError = false, string Message = "" );
如果我們使用刷新Token機制,則需要添加刷新Token請求實體,在Dto文件夾下添加RefreshTokenRequest類如下:
namespace Demo.Identity.Dto; /// <summary> /// 刷新令牌請求 /// </summary> public class RefreshTokenRequest { /// <summary> /// 刷新令牌 /// </summary> public string RefreshToken { get; set; } }
這里為了方便各類登錄情況異常統一處理,並返回401錯誤,我定義了登錄異常並使用異常過濾器對其進行處理,方式為在Demo.Identity.IdentityServer項目中下添加LoginException.cs文件如下:
using System; using Demo.Identity.Dto; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; namespace Demo.Identity; /// <summary> /// 自定義登錄異常 /// </summary> public class LoginException : Exception { /// <summary> /// 自定義登錄異常 /// </summary> /// <param name="message">異常信息</param> public LoginException(string message) : base(message) { } } /// <summary> /// 登錄異常過濾器 /// </summary> public class LoginExceptionFilter : ExceptionFilterAttribute { /// <summary> /// 自定義登錄異常處理 /// </summary> /// <param name="context"></param> public override void OnException(ExceptionContext context) { if (context.Exception is LoginException) { context.Result = new JsonResult( new LoginResponse { HasError = true, Message = context.Exception.Message } ) { StatusCode = 401 }; context.ExceptionHandled = true; } } }
之后,在Demo.Identity.IdentityServer項目中添加Controllers文件夾,並添加控制器AuthController代碼如下:
using System; using System.Net.Http; using System.Threading.Tasks; using Demo.Identity.Dto; using IdentityModel.Client; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; using Volo.Abp.AspNetCore.Mvc; namespace Demo.Identity.Controllers; /// <summary> /// 用戶登錄控制器 /// </summary> [ApiController] [Route("api/[controller]/[action]")] public class AuthController : AbpController { private readonly IConfiguration _configuration; public AuthController(IConfiguration configuration) { _configuration = configuration; } /// <summary> /// 檢查IdentityServer服務鏈接並獲取相關接口地址 /// </summary> /// <returns></returns> /// <exception cref="Exception"></exception> private async Task<DiscoveryDocumentResponse> CheckIdsService() { var client = new HttpClient(); var disco = await client.GetDiscoveryDocumentAsync(new DiscoveryDocumentRequest { Address = _configuration["AuthService"], Policy = { RequireHttps = false } }); if (disco.IsError) { throw new LoginException("身份認證服務鏈接失敗"); } return disco; } /// <summary> /// 用戶名密碼登錄 /// </summary> /// <param name="request">請求實體</param> /// <returns>登錄響應</returns> [HttpPost] [LoginExceptionFilter] public async Task<LoginResponse> Login([FromBody] PasswordLoginRequest request) { var disco = await CheckIdsService(); var tokenResponse = await new HttpClient().RequestPasswordTokenAsync(new PasswordTokenRequest { Address = disco.TokenEndpoint, ClientId = "Demo_App", ClientSecret = "1q2w3e*", UserName = request.UserName, Password = request.Password, Scope = "openid offline_access" }); if (tokenResponse.IsError) { throw new LoginException("用戶名密碼錯誤"); } return new LoginResponse { AccessToken = tokenResponse.AccessToken, RefreshToken = tokenResponse.RefreshToken, ExpireInSeconds = tokenResponse.ExpiresIn }; } /// <summary> /// 刷新令牌 /// </summary> /// <param name="request"></param> /// <returns></returns> [HttpPost] [LoginExceptionFilter] public async Task<LoginResponse> RefreshToken([FromBody] Dto.RefreshTokenRequest request) { var disco = await CheckIdsService(); var tokenResponse = await new HttpClient().RequestRefreshTokenAsync( new IdentityModel.Client.RefreshTokenRequest() { Address = disco.TokenEndpoint, ClientId = "Demo_App", ClientSecret = "1q2w3e*", RefreshToken = request.RefreshToken, Scope = "openid offline_access" }); if (tokenResponse.IsError) { throw new LoginException("RefreshToken已過期"); } return new LoginResponse { AccessToken = tokenResponse.AccessToken, RefreshToken = tokenResponse.RefreshToken, ExpireInSeconds = tokenResponse.ExpiresIn }; } }
這里我們讀取appsettings.json配置,需要再applications.json添加配置如下:
"AuthService": "http://localhost:4100"
添加后運行,我們使用PostMan測試,Post方式訪問/api/auth/login接口,Body使用JSON格式並寫入以下數據:
{ "UserName":"admin", "Password":"1q2w3E*" }
測試訪問成功,可得到AccessToken和RefreshToken。
將此處得到的RefreshToken作為刷新令牌接口測試參數,測試如下:Post方式訪問/api/auth/refreshtoken接口,Body使用JSON格式並寫入以下數據:
{ "RefreshToken":"E4AEAE2……" }
測試得到一組新的AccessToken和RefreshToken,則表示所有接口測試通過。
附:刷新令牌機制用法
在以上服務中,客戶端通過用戶名密碼,可以拿到AccessToken和RefreshToken,即訪問令牌和刷新令牌。正常訪問接口時,我們只需要使用AccessToken用於身份認證。但AccessToken有效期較短,超過有效期后會報401錯誤。這時,我們可以使用之前拿到的RefreshToken使用刷新令牌接口,換取一組新的AccessToken和RefreshToken,同時AccessToken和RefreshToken的有效期將被重置。若此時RefreshToken已過期,則需要重新登錄。
例如在手機端APP使用場景中,我們設置AccessToken有效期為兩個小時,RefreshToken有效期為7天。正常使用情況下,用戶不需要每天重新登錄,如果用戶連續7天未使用,則RefreshToken過期,需重新登錄,若經常使用則不會出現這個情況。
如果傳輸中AccessToken被惡意截取,其他人也只有兩個小時的使用時間。同時,這個機制也在保證用戶不需要反復登錄的同時客戶端不需要保留用戶名密碼信息。