开源项目葫芦藤:IdentityServer4的实现及其运用
前言
本篇文章主要是講解葫蘆藤項目中對IdentityServer的實踐使用,為了使您對本篇文章中所講述的內容有深刻的認識,并且在閱讀時避免感到乏味,文中的內容不會涉及太多的基礎理論知識,而更多的是采用動手實踐的方式進行講解,所以在閱讀此篇文章前假定您已經掌握了OAuth2.0的基礎知識,如您事先并未了解OAuth2.0,請參閱一下阮一峰老師的文章《理解OAuth2.0》(http://www.ruanyifeng.com/blog/2014/05/oauth_2_0.html), ASP.NET Core 認證與授權,可以看看博客?雨夜朦朧(https://www.cnblogs.com/RainingNight),另外IdentityServer的相關文章也可以參考博客?曉晨Master(https://www.cnblogs.com/stulzq/)。
葫蘆藤前端地址:https://account.suuyuu.cn (驗證碼獲取后,輸入123456即可)
葫蘆藤后端地址:https://account-web.suuyuu.cn
葫蘆藤源碼地址:https://github.com/fuluteam/fulusso (幫忙點個小星星哦)
團隊博文地址:https://www.cnblogs.com/fulu
簽名證書(Signing Credential)
IdentityServer支持X.509證書(包括原始文件和對Windows證書存儲庫的引用)、RSA密鑰和EC密鑰,用于令牌簽名和驗證。每個密鑰都可以配置一個(兼容的)簽名算法,如RS256、RS384、RS512、PS256、PS384、PS512、ES256、ES384或ES512。
通常情況下,我們使用的是針對開發場景創建的臨時證書 AddDeveloperSigningCredential,
生產環境怎么辦呢?IdentityServer還提供了AddSigningCredential用來裝載證書文件,
為此我們需要準備一個X.509證書,下面是在控制臺項目中用于生成證書的代碼,完整代碼請參考項目:https://github.com/fuluteam/ICH.BouncyCastle
執行代碼后,在項目編譯輸出目錄中,會看到一個mypfx.pfx的文件,此時我們的證書就創建成功啦。
接著怎么使用呢,看下面代碼:
var certificate2 = new X509Certificate2("mypfx.pfx", "password", X509KeyStorageFlags.Exportable);identityServerBuilder.AddSigningCredential(certificate2);大家可能會問,葫蘆藤中怎么不是這么寫的呢,其實葫蘆藤項目中是將證書文件的流數據轉成了二進制字符串,這樣就可以寫在配置文件中了:
using (var fs = new FileStream(options.Path, FileMode.Open)){var bytes = new byte[fs.Length];fs.Read(bytes, 0, bytes.Length);var pfxHexString = Hex.ToHexString(bytes);}然后在這么使用:
identityServerBuilder.AddSigningCredential(new X509Certificate2(Hex.Decode(appSettings.X509RawCertData), appSettings.X509CertPwd));客戶端存儲(Client Store)
在葫蘆藤項目中,我們創建了一個ClientStore類,繼承自接口IClientStore,實現其方法代碼如下:
public class ClientStore : IClientStore{private readonly IClientCacheStrategy _clientInCacheRepository;public ClientStore(IClientCacheStrategy clientInCacheRepository){_clientInCacheRepository = clientInCacheRepository;}public async Task<Client> FindClientByIdAsync(string clientId){var clientEntity = await _clientInCacheRepository.GetClientByIdAsync(clientId.ToInt32());if (clientEntity == null){return null;}return new Client{ClientId = clientId,AllowedScopes = new[] { "api", "get_user_info" },ClientSecrets = new[] { new Secret(clientEntity.ClientSecret.Sha256()) },AllowedGrantTypes = new[]{GrantType.AuthorizationCode, //授權碼模式GrantType.ClientCredentials, //客戶端模式GrantType.ResourceOwnerPassword, //密碼模式CustomGrantType.External, //自定義模式——三方(移動端)模式CustomGrantType.Sms //自定義——短信模式},AllowOfflineAccess = false,RedirectUris = string.IsNullOrWhiteSpace(clientEntity.RedirectUri) ? null : clientEntity.RedirectUri.Split(';'),RequireConsent = false,AccessTokenType = AccessTokenType.Jwt,AccessTokenLifetime = 7200,ClientClaimsPrefix = "",Claims = new[] { new Claim(JwtClaimTypes.Role, "Client") }};}}通過代碼可以看到,通過clientId從緩存中讀取Client的相關信息構建并返回,這里我們為所有的Client簡單的設置了統一的AllowedGrantTypes,這是一種偷懶的做法,應當按需授予GrantType,例如通常情況下我們只應默認給應用分配AuthorizationCode或者ClientCredentials,ResourceOwnerPassword需要謹慎授予(需要用戶對Client高度信任)。
資源存儲(Resource Store)
由于歷史原因,在葫蘆藤中,我們并沒有通過IdentityServer對api資源進行訪問保護(后續會提供我們的實現方式),我們為所有Client設置了相同的Scope。
持久化授權存儲(Persisted Grant Store)
葫蘆藤中,我們使用了Redis來持久化數據,
通過EntityFramework Core持久化配置和操作數據,請參考
https://www.cnblogs.com/stulzq/p/8120518.html
https://github.com/IdentityServer/IdentityServer4.EntityFramework
IPersistedGrantStore接口中定義了如下6個方法:
/// <summary>Interface for persisting any type of grant.</summary>public interface IPersistedGrantStore{/// <summary>Stores the grant.</summary>/// <param name="grant">The grant.</param>/// <returns></returns>Task StoreAsync(PersistedGrant grant);/// <summary>Gets the grant.</summary>/// <param name="key">The key.</param>/// <returns></returns>Task<PersistedGrant> GetAsync(string key);/// <summary>Gets all grants for a given subject id.</summary>/// <param name="subjectId">The subject identifier.</param>/// <returns></returns>Task<IEnumerable<PersistedGrant>> GetAllAsync(string subjectId);/// <summary>Removes the grant by key.</summary>/// <param name="key">The key.</param>/// <returns></returns>Task RemoveAsync(string key);/// <summary>/// Removes all grants for a given subject id and client id combination./// </summary>/// <param name="subjectId">The subject identifier.</param>/// <param name="clientId">The client identifier.</param>/// <returns></returns>Task RemoveAllAsync(string subjectId, string clientId);/// <summary>/// Removes all grants of a give type for a given subject id and client id combination./// </summary>/// <param name="subjectId">The subject identifier.</param>/// <param name="clientId">The client identifier.</param>/// <param name="type">The type.</param>/// <returns></returns>Task RemoveAllAsync(string subjectId, string clientId, string type);}PersistedGrant的結構如下:
/// <summary>A model for a persisted grant</summary>public class PersistedGrant{/// <summary>Gets or sets the key.</summary>/// <value>The key.</value>public string Key { get; set; }/// <summary>Gets the type.</summary>/// <value>The type.</value>public string Type { get; set; }/// <summary>Gets the subject identifier.</summary>/// <value>The subject identifier.</value>public string SubjectId { get; set; }/// <summary>Gets the client identifier.</summary>/// <value>The client identifier.</value>public string ClientId { get; set; }/// <summary>Gets or sets the creation time.</summary>/// <value>The creation time.</value>public DateTime CreationTime { get; set; }/// <summary>Gets or sets the expiration.</summary>/// <value>The expiration.</value>public DateTime? Expiration { get; set; }/// <summary>Gets or sets the data.</summary>/// <value>The data.</value>public string Data { get; set; }}可以看出主要是針對PersistedGrant對象的操作,通過觀察GetAsync和RemoveAsync方法的入參均為key,我們在StoreAsync中將PersistedGrant中的Key作為緩存key,將PersistedGrant對象以hash的方式存入緩存中,并設置過期時間(注意將UTC時間轉換為本地時間)
public async Task StoreAsync(PersistedGrant grant){//var expiresIn = grant.Expiration - DateTimeOffset.UtcNow;var db = await _redisCache.GetDatabaseAsync();var trans = db.CreateTransaction();var expiry = grant.Expiration.Value.ToLocalTime();db.HashSetAsync(grant.Key, GetHashEntries(grant)); //GetHashEntries是將對象PersistedGrant轉換為HashEntry數組db.KeyExpireAsync(grant.Key, expiry);await trans.ExecuteAsync();}同時,把GetAsync和RemoveAsync的代碼填上:
public async Task<PersistedGrant> GetAsync(string key){var db = await _redisCache.GetDatabaseAsync();var items = await db.HashGetAllAsync(key);return GetPersistedGrant(items); //將HashEntry數組轉換為PersistedGrant對象}public async Task RemoveAsync(string key){var db = await _redisCache.GetDatabaseAsync();await db.KeyDeleteAsync(key);}接著,GetAllAsync方法,通過subjectId查詢PersistedGrant集合,1對n,因此,我們在StoreAsync中補上這一層關系,以subjectId為緩存key,grant.Key為緩存值存入list集合中;GetAllAsync方法中,通過subjectId取出grant.Key的集合,最終得到PersistedGrant集合。
public async Task StoreAsync(PersistedGrant grant){//var expiresIn = grant.Expiration - DateTimeOffset.UtcNow;var db = await _redisCache.GetDatabaseAsync();var trans = db.CreateTransaction();var expiry = grant.Expiration.Value.ToLocalTime();db.HashSetAsync(grant.Key, GetHashEntries(grant)); //GetHashEntries是將對象PersistedGrant轉換為HashEntry數組db.KeyExpireAsync(grant.Key, expiry);db.ListLeftPushAsync(grant.SubjectId, grant.Key);db.KeyExpireAsync(grant.SubjectId, expiry);await trans.ExecuteAsync();}public async Task<IEnumerable<PersistedGrant>> GetAllAsync(string subjectId){if (string.IsNullOrWhiteSpace(subjectId))return new List<PersistedGrant>();var db = await _redisCache.GetDatabaseAsync();var keys = await db.ListRangeAsync(subjectId);var list = new List<PersistedGrant>();foreach (string key in keys){var items = await db.HashGetAllAsync(key);list.Add(GetPersistedGrant(items));}return list;}類似的,StoreAsync方法中我們只需StoreAsync方法中根據RemoveAllAsync方法參數組裝緩存key,grant.Key為緩存值寫入緩存,對應的RemoveAllAsync中根據參數組裝的key查詢出grant.Key集合,刪除緩存即可。
public async Task StoreAsync(PersistedGrant grant){var db = await _redisCache.GetDatabaseAsync();var trans = db.CreateTransaction();var expiry = grant.Expiration.Value.ToLocalTime();db.HashSetAsync(grant.Key, GetHashEntries(grant));db.KeyExpireAsync(grant.Key, expiry);if (!string.IsNullOrEmpty(grant.SubjectId)){db.ListLeftPushAsync(grant.SubjectId, grant.Key);db.KeyExpireAsync(grant.SubjectId, expiry);var key1 = $"{grant.SubjectId}:{grant.ClientId}";db.ListLeftPushAsync(key1, grant.Key);db.KeyExpireAsync(key1, expiry);var key2 = $"{grant.SubjectId}:{grant.ClientId}:{grant.Type}";db.ListLeftPushAsync(key2, grant.Key);db.KeyExpireAsync(key2, expiry);}await trans.ExecuteAsync();}public async Task RemoveAllAsync(string subjectId, string clientId){if (string.IsNullOrEmpty(subjectId) || string.IsNullOrEmpty(clientId))return;var db = await _redisCache.GetDatabaseAsync();var key = $"{subjectId}:{clientId}";var keys = await db.ListRangeAsync(key);if (!keys.Any()) return;var trans = db.CreateTransaction();db.KeyDeleteAsync(keys.ToRedisKeys());db.KeyDeleteAsync(key);await trans.ExecuteAsync();}public async Task RemoveAllAsync(string subjectId, string clientId, string type){if (string.IsNullOrEmpty(subjectId) || string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(type)) return;var db = await _redisCache.GetDatabaseAsync();var key = $"{subjectId}:{clientId}:{type}";var keys = await db.ListRangeAsync(key);if (!keys.Any()) return;var trans = db.CreateTransaction();db.KeyDeleteAsync(keys.ToRedisKeys());db.KeyDeleteAsync(key);await trans.ExecuteAsync();}至此,持久化的代碼填寫完畢;啟動并調試項目,可以看到PersistedGrant對象如下:
資源擁有者驗證器(Resource Owner Validator)
如果要使用OAuth 2.0 密碼模式(Resource Owner Password Credentials Grant),則需要實現并注冊IResourceOwnerPasswordValidator接口:
public async Task ValidateAsync(ResourceOwnerPasswordValidationContext context){var result = await _userService.LoginByPasswordAsync(context.UserName, context.Password);if (result.Code == 0){var claims = await _userService.SaveSuccessLoginInfo(context.Request.ClientId.ToInt32(), result.Data.Id,_contextAccessor.HttpContext.GetIp(), UserLoginModel.Password);context.Result = new GrantValidationResult(result.Data.Id, OidcConstants.AuthenticationMethods.Password, claims);}else{context.Result = new GrantValidationResult(TokenRequestErrors.InvalidRequest, result.Message);}}重定向地址驗證器(Redirect Uri Validator)
用于驗證重定向(授權碼模式)和注銷后重定向Uri的校驗,葫蘆藤項目中重定向地址驗證只驗證域名(不驗證完整的requestedUri地址),且未進行注銷重定向Uri的校驗。
public class RedirectUriValidator : IRedirectUriValidator{public Task<bool> IsRedirectUriValidAsync(string requestedUri, Client client){if (client.RedirectUris == null || !client.RedirectUris.Any()){return Task.FromResult(false);}var uri = new Uri(requestedUri);return Task.FromResult(client.RedirectUris.Any(x => x.Contains(uri.Host)));}public Task<bool> IsPostLogoutRedirectUriValidAsync(string requestedUri, Client client){return Task.FromResult(true);}}擴展授權驗證器(Extension Grant Validator)
在IdentityServer4中,通過實現IExtensionGrantValidator接口,可以實現自定義授權。在葫蘆藤項目中,我們有兩個場景需要用到自定義授權:
通過第三方(QQ、微信)的用戶標識(OpenId)進行登錄(頒發用戶令牌)
通過短信驗證碼進行登錄(頒發用戶令牌)
在IdentityServer4中實現短信驗證碼授權模式,我們創建了一個SmsGrantValidator類,繼承自IExtensionGrantValidator接口,然后給屬性GrantType取一個名字,此處名稱為“sms”,實現ValidateAsync方法,方法內進行入參校驗,然后驗證短信驗證碼,驗證通過后取出用戶信息,下面代碼中,當用戶不存在時也可以自動注冊。代碼如下:
public class SmsGrantValidator : IExtensionGrantValidator{private readonly IHttpContextAccessor _contextAccessor;private readonly IValidationComponent _validationComponent;private readonly IUserService _userService;public SmsGrantValidator(IHttpContextAccessor contextAccessor, IValidationComponent validationComponent, IUserService userService){_contextAccessor = contextAccessor;_validationComponent = validationComponent;_userService = userService;GrantType = CustomGrantType.Sms;}public async Task ValidateAsync(ExtensionGrantValidationContext context){var phone = context.Request.Raw.Get("phone");var code = context.Request.Raw.Get("code");if (string.IsNullOrEmpty(phone) || Regex.IsMatch(phone, RegExp.PhoneNumber) == false){context.Result = new GrantValidationResult(TokenRequestErrors.InvalidRequest, "phone is not valid");return;}if (string.IsNullOrEmpty(code)){context.Result = new GrantValidationResult(TokenRequestErrors.InvalidRequest, "code is not valid");return;}try{var validSms = await _validationComponent.ValidSmsAsync(phone, code);if (!validSms.Data){context.Result = new GrantValidationResult(TokenRequestErrors.InvalidRequest, validSms.Message);return;}var userEntity = await _userService.GetUserByPhoneAsync(phone);if (userEntity == null){context.Result = new GrantValidationResult(TokenRequestErrors.InvalidRequest, "用戶不存在或未注冊");return;}if (userEntity.Enabled == false){context.Result = new GrantValidationResult(TokenRequestErrors.InvalidRequest, "您的賬號已被禁止登錄");return;}await _userService.SaveSuccessLoginInfo(context.Request.ClientId.ToInt32(), userEntity.Id, _contextAccessor.HttpContext.GetIp(),UserLoginModel.SmsCode);}catch (Exception ex){context.Result = new GrantValidationResult(TokenRequestErrors.InvalidRequest, ex.Message);}}public string GrantType { get; }}OAuth2.0的實踐運用場景
基于角色的授權(role-based authorization)
基于角色的授權檢查是聲明性的,開發人員將其嵌入到代碼中、控制器或控制器內的操作,指定當前用戶必須是其成員的角色才能訪問請求的資源,文檔參考《ASP.NET Core 中的基于角色的授權》。
葫蘆藤中定義了兩種角色Claim(聲明),客戶端和用戶,使用客戶端授權模式(client credentials)頒發的令牌,ClaimRole為Client,使用授權碼模式(authorization code)、密碼模式(resource owner password credentials)、自定義授權模式(短信、第三方)頒發的用戶令牌,ClaimRole為User
public static class ClaimRoles{/// <summary>/// 客戶端/// </summary>public const string Client = "Client";/// <summary>/// 用戶/// </summary>public const string User = "User";}在ClientStore中增加返回Client的Claims,JwtClaimTypes.Role為ClaimRoles.Client,下面是客戶端令牌,可以看到 “role”:”Client”
{"alg":"RS256","kid":"99AA0C1236097972F29789562761D38AAE301918","typ":"JWT","x5t":"maoMEjYJeXLyl4lWJ2HTiq4wGRg"}{"nbf":1608522625,"exp":1608529825,"iss":"http://localhost:80","aud":"api","client_id":"10000001","role":"Client","scope":["api","get_user_info"]}在用戶登錄成功后返回的Claims中增加JwtClaimTypes.Role為ClaimRoles.User,下面是用戶令牌,可以看到 “role”:”User”
{"alg":"RS256","kid":"99AA0C1236097972F29789562761D38AAE301918","typ":"JWT","x5t":"maoMEjYJeXLyl4lWJ2HTiq4wGRg"}{"nbf":1608522576,"exp":1608529776,"iss":"http://localhost:80","aud":"api","client_id":"10000001","sub":"df09efff-0074-4dca-91c3-e38180c5e4ac","auth_time":1608522576,"idp":"local","id":"df09efff-0074-4dca-91c3-e38180c5e4ac","open_id":"07E8E30B56D256EF8C440019AB6AAA89","name":"1051dfd1-73e5-4e6f-9326-3423bc9b71a3","nickname":"laowang","phone_number":"18627131390","email":"","role":"User","login_ip":"0.0.0.1","login_address":"保留地址","last_login_ip":"0.0.0.1","last_login_address":"保留地址","scope":["api","get_user_info"],"amr":["pwd","mfa"]}在項目Fulu.Passport.API的Startup文件中,添加對組件Fulu.Service.Authorize的服務注入
services.AddServiceAuthorize(o =>...代碼省略...);services.AddAuthentication(x =>...代碼省略...).AddJwtBearer(o =>{...代碼省略...o.TokenValidationParameters = new TokenValidationParameters{NameClaimType = JwtClaimTypes.Name,RoleClaimType = ClaimTypes.Role, //注意,這里不能使用JwtClaimTypes.Role...代碼省略...}}接著,只需在Controller或Action上指定屬性即可
[Route("api/[controller]/[action]")][ApiController][Authorize(Roles = ClaimRoles.Client)]public class ClientController : ControllerBase{...省略部分代碼.../// <summary>/// 獲取應用列表/// </summary>/// <returns></returns>[HttpGet][ProducesResponseType(typeof(ActionObjectResult<List<ClientEntity>, Statistic>), 200)]public async Task<IActionResult> GetClients(){var clients = await _clientRepository.TableNoTracking.Where(c => c.Enabled).ToListAsync();return ObjectResponse.Ok(clients);}...省略部分代碼...客戶端授權模式(client credentials)
通過客戶端授權模式頒發的令牌,可以實現對服務資源進行保護。步驟如下:
(A)客戶端10000001向葫后進行身份認證,并要求一個訪問令牌。(B)葫后驗證客戶端身份后,向客戶端10000001提供訪問令牌。A步驟中,客戶端10000001發出的HTTP請求,包含以下參數:
grant_type:表示授權類型,此處的值固定為”clientcredentials”,必選項。
client_id:表示客戶端的ID,必選項。
client_secret:表示客戶端密鑰,必選項。
B步驟中,葫蘆藤向客戶端10000001發放令牌,下面是一個例子。
HTTP/1.1 200 OKContent-Type: application/json;charset=UTF-8Cache-Control: no-store, no-cache, max-age=0Pragma: no-cache{"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ijk5QUEwQzEyMzYwOTc5NzJGMjk3ODk1NjI3NjFEMzhBQUUzMDE5MTgiLCJ0eXAiOiJKV1QiLCJ4NXQiOiJtYW9NRWpZSmVYTHlsNGxXSjJIVGlxNHdHUmcifQ.eyJuYmYiOjE2MDc0MTQ2MjUsImV4cCI6MTYwNzQyMTgyNSwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MCIsImF1ZCI6ImFwaSIsImNsaWVudF9pZCI6IjEwMDAwMDAxIiwicm9sZSI6IkNsaWVudCIsImh0dHA6Ly9zY2hlbWFzLm1pY3Jvc29mdC5jb20vd3MvMjAwOC8wNi9pZGVudGl0eS9jbGFpbXMvcm9sZSI6IkNsaWVudCIsInNjb3BlIjpbImFwaSIsImdldF91c2VyX2luZm8iXX0.ilu1qMxDiXVxsqU6aO-xuyYaLvvj2mxONjYkXtpMs46K7O3_Qc5VsY0ZZaYPoLROAqPulxsWWpxjEiQd10OdRh4IziGAcpYfAfoD80CZxrcuWrWloB5aWncv_PMZcjzKw7Vt3G3g-WkJl4amTta498hZJ3B-N-ReLhl-3ICSMFU8PU_ZVtEB-2lRx93rVyPIaQu_DWmpyW4Bdf2ocYm4RPQAEsvBToEFObbWPG6paLWIjrSN2aQPvsRWziorvlIhyFV5L6oyFIGIrZxdLJTOsvRQaevpV1sbv9pD_Z9PZDbSQiQDbWQv0MfrYB0Npc6VQlIMkL2GPNlQ8NgwyGT1sQ","expires_in": 7200,"token_type": "Bearer","scope": "api get_user_info"}授權碼模式(authorization code)
葫蘆藤項目通過授權碼模式(authorization code)實現了單點登錄,通過授權碼模式拿到用戶令牌。目前葫蘆藤只有一個應用(葫蘆藤安全中心),這里為了不把概念搞混淆,我們假定百度(客戶端10000002,redirect_uri 為 http://www.baidu.com)接入了咱們的授權體系,當然,百度的前端肯定沒有寫如何構造請求步驟的邏輯代碼,因此,我們下面通過人工模擬請求步驟。
名詞定義
葫蘆藤的client_id是10000001,百度的client_id是10000002
葫蘆藤前端服務,簡稱“葫前”(https://account.suuyuu.cn)
葫蘆藤后端服務,簡稱“葫后”(https://account-web.suuyuu.cn)
百度前端服務,簡稱“百前”(https://www.baidu.com)
百度后端服務,簡稱“百后”(假定地址為?https://api.baidu.com)
A步驟中,構造的請求地址包含以下參數:
response_type:表示授權類型,必選項,此處的值固定為”code”
client_id:表示客戶端的ID,必選項
redirect_uri:表示重定向URI,可選項
scope:表示申請的權限范圍,可選項
state:表示客戶端的當前狀態,可以指定任意值,認證服務器會原封不動地返回這個值
步驟A中開發人員需向前端人員提供client_id,即上面的client_id,下面是一個例子。
構造如下地址,復制到瀏覽器地址欄中并回車,如果跳轉到登錄頁,請進行登錄。
https://account-web.suuyuu.cn/connect/authorize?client_id=10000002&redirect_uri=https%3A%2F%2Fwww.baidu.com&response_type=code&scope=api&state=STATE登錄后會重定向redirect_uri到如下地址:
https://www.baidu.com/?code=1MlxrvXuD7TfH-s4dLzcw9ymO0SKDbf5xAlh3ZEHlMo&scope=api&state=STATED步驟中,我們通過臨時授權碼向“葫后”索取令牌,包含以下參數:
grant_type:表示使用的授權模式,必選項,此處的值固定為”authorization_code”。
code:表示上一步獲得的授權碼,必選項。
redirect_uri:表示重定向URI,必選項,且必須與A步驟中的該參數值保持一致。
client_id:表示應用ID,必選項。
client_secret:表示應用密鑰,必選項。
密碼模式(resource owner password credentials)
密碼模式主要用于給可信應用頒發用戶令牌,此類應用有個性化的登錄頁(不依賴單點登錄,葫蘆藤的登錄頁面),如app、小程序、h5等。
grant_type:表示授權類型,此處的值固定為”password”,必選項。
client_id:表示客戶端的ID,必選項。
client_secret:表示客戶端密鑰,必選項。
username:用戶名,必選項。
password:密碼,必選項。(基于密碼原文的rsa加密串)
自定義授權模式(短信、第三方)(extension grant)
客戶端通過用戶手機號短信驗證碼或第三方用戶(QQ、WeChat)的用戶唯一標識(OpenId)向認證服務器索要用戶令牌。
以短信驗證碼方式為例,我們定義的流程如下:
用戶向客戶端提供自己的手機號和短信驗證碼。客戶端使用這些信息,向認證服務器索要授權。步驟如下:
(A)用戶向客戶端提供手機號和短信驗證碼。
(B)客戶端將手機號和短信碼發給認證服務器,向后者請求令牌。
(C)認證服務器確認無誤后,向客戶端提供用戶令牌。
B步驟中,客戶端發出的HTTP請求,包含以下參數:
grant_type:表示授權類型,此處的值固定為”sms”,必選項。
client_id:表示客戶端的ID,必選項。
client_secret:表示客戶端的密鑰,必選項。
phone:表示手機號,必選項。
code:表示短信驗證碼,必選項。
下面是一個請求示例。
POST https://account-web.suuyuu.cn/oauth/token HTTP/1.1Host: account-web.suuyuu.cnContent-Type: application/x-www-form-urlencodedgrant_type=sms&phone=18627131390&code=123456&client_id=10000001&client_secret=14p9ao1gxu4q3sp8ogk8bq4gkct59t9w{"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ijk5QUEwQzEyMzYwOTc5NzJGMjk3ODk1NjI3NjFEMzhBQUUzMDE5MTgiLCJ0eXAiOiJKV1QiLCJ4NXQiOiJtYW9NRWpZSmVYTHlsNGxXSjJIVGlxNHdHUmcifQ.eyJuYmYiOjE2MDczOTU4NTIsImV4cCI6MTYwNzQwMzA1MiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MCIsImF1ZCI6ImFwaSIsImNsaWVudF9pZCI6IjEwMDAwMDAxIiwic3ViIjoiMTg2MjcxMzEzOTAiLCJhdXRoX3RpbWUiOjE2MDczOTU4NTIsImlkcCI6ImxvY2FsIiwiaWQiOiJkZjA5ZWZmZi0wMDc0LTRkY2EtOTFjMy1lMzgxODBjNWU0YWMiLCJvcGVuX2lkIjoiMDdFOEUzMEI1NkQyNTZFRjhDNDQwMDE5QUI2QUFBODkiLCJuYW1lIjoiMTA1MWRmZDEtNzNlNS00ZTZmLTkzMjYtMzQyM2JjOWI3MWEzIiwibmlja25hbWUiOiJsYW93YW5nIiwicGhvbmVfbnVtYmVyIjoiMTg2MjcxMzEzOTAiLCJlbWFpbCI6IiIsInJvbGUiOiJVc2VyIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9yb2xlIjoiVXNlciIsImxvZ2luX2lwIjoiMC4wLjAuMSIsImxvZ2luX2FkZHJlc3MiOiLkv53nlZnlnLDlnYAiLCJsYXN0X2xvZ2luX2lwIjoiMC4wLjAuMSIsImxhc3RfbG9naW5fYWRkcmVzcyI6IuS_neeVmeWcsOWdgCIsInNjb3BlIjpbImFwaSIsImdldF91c2VyX2luZm8iXSwiYW1yIjpbInBhc3N3b3JkIiwibWZhIl19.ZQklMJMXObc3vL-gMOWnWIS56ck5_XbDfXjw9Vm6BeYjG4dyz05JTN_YHgU-EIJoM04nmFyjNgGYtqL-28-3MQeHfWhvQf_5dyY1w-DBBCKo1EMEm_ujKTDB1QQTN1XmVTgW7bBkEiv4NK5v3uYqh_s7pv8Csusm4oWZThWPlKLtxWVDtawFzvz4Un-2WATytsLNfluutiLVnpN7INhkdglansTTOCUOdCOLBEEbDzTuLyCnhm00xYtg5GrMAkDohqXLKYD2jSFzIyYTA_oryTFXcJpkGYwIRqRX7bXvAlMR5yE_CTtNWpSnaLJ2GtFv_QFe-YItCtSO-bBd6XQBRA","expires_in": 7200,"token_type": "Bearer","scope": "api get_user_info"}第三方授權登錄的編寫與使用
在葫蘆藤項目中我們提供了釘釘、微信的OAuth組件,并實現了功能,演示地址在 https://account.suuyuu.cn,下面我們以微信為例簡單介紹下如何編寫組件及使用。
首先咱們閱讀一下網站應用微信登錄開發指南,了解一下接入流程。要使用微信登錄,先得在微信·開放平臺注冊成為開發者,并進行資質認證。
微信開放平臺帳號的開發者資質認證提供更安全、更嚴格的真實性認證、也能夠更好的保護企業及用戶的合法權益開發者資質認證通過后,微信開放平臺帳號下的應用,將獲得微信登錄、智能接口、第三方平臺開發等高級能力審核費用:中國大陸地區:300元,非中國大陸地區:99美元然后在管理中心創建網站應用
對照微信開發指南將需要用到的地址定義到WeChatDefaults.cs中
public static class WeChatDefaults{public const string AuthenticationScheme = "wechat";public static readonly string DisplayName = "wechat";//第一步:請求CODEpublic static readonly string AuthorizationEndpoint = "https://open.weixin.qq.com/connect/qrconnect";//第二步:通過code獲取access_tokenpublic static readonly string TokenEndpoint = "https://api.weixin.qq.com/sns/oauth2/access_token";//第三步:獲取用戶個人信息public static readonly string UserInformationEndpoint = "https://api.weixin.qq.com/sns/userinfo";}此處唯一要注意的地方,ClaimActions集合的參數來自微信返回的字段
public class WeChatOptions : OAuthOptions{/// <summary>/// Initializes a new <see cref="WeChatOptions"/>./// </summary>public WeChatOptions(){CallbackPath = new PathString("/signin-wechat");AuthorizationEndpoint = WeChatDefaults.AuthorizationEndpoint;TokenEndpoint = WeChatDefaults.TokenEndpoint;UserInformationEndpoint = WeChatDefaults.UserInformationEndpoint;Scope.Add("snsapi_login");ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "openid");ClaimActions.MapJsonKey(ClaimTypes.Name, "nickname");}/// <summary>/// access_type. Set to 'offline' to request a refresh token./// </summary>public string AccessType { get; set; }}public static class WeChatExtensions{public static AuthenticationBuilder AddWeChat(this AuthenticationBuilder builder)=> builder.AddWeChat(WeChatDefaults.AuthenticationScheme, _ => { });public static AuthenticationBuilder AddWeChat(this AuthenticationBuilder builder, Action<WeChatOptions> configureOptions)=> builder.AddWeChat(WeChatDefaults.AuthenticationScheme, configureOptions);public static AuthenticationBuilder AddWeChat(this AuthenticationBuilder builder, string authenticationScheme, Action<WeChatOptions> configureOptions)=> builder.AddWeChat(authenticationScheme, WeChatDefaults.DisplayName, configureOptions);public static AuthenticationBuilder AddWeChat(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action<WeChatOptions> configureOptions)=> builder.AddOAuth<WeChatOptions, WeChatHandler>(authenticationScheme, displayName, configureOptions);}新增一個類WeChatHandler,繼承自OAuthHandler
BuildChallengeUrl(構造客戶端申請認證的URI)
protected override string BuildChallengeUrl(AuthenticationProperties properties, string redirectUri){var state = Options.StateDataFormat.Protect(properties);var baseUri = $"{Request.Scheme}{Uri.SchemeDelimiter}{Request.Host}{Request.PathBase}";var currentUri = $"{baseUri}{Request.Path}{Request.QueryString}";if (string.IsNullOrEmpty(properties.RedirectUri)){properties.RedirectUri = currentUri;}var queryStrings = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase){{"response_type", "code"},{"appid", Uri.EscapeDataString(Options.ClientId)},{"redirect_uri", redirectUri},{"state", Uri.EscapeDataString(state)}};var scope = string.Join(",", Options.Scope);queryStrings.Add("scope", Uri.EscapeDataString(scope));var authorizationEndpoint = QueryHelpers.AddQueryString(Options.AuthorizationEndpoint, queryStrings);return authorizationEndpoint;}HandleRemoteAuthenticateAsync(向認證服務器申請令牌獲取用戶信息并創建票據)
protected override async Task<HandleRequestResult> HandleRemoteAuthenticateAsync(){var state = Request.Query["state"];var properties = Options.StateDataFormat.Unprotect(state);if (properties == null)return HandleRequestResult.Fail("The oauth state was missing or invalid.");if (!ValidateCorrelationId(properties))return HandleRequestResult.Fail("Correlation failed.", properties);var code = Request.Query["code"];if (StringValues.IsNullOrEmpty(code))return HandleRequestResult.Fail("Code was not found.", properties);var redirectUri = !string.IsNullOrEmpty(Options.CallbackPath) ?Options.CallbackPath.Value : BuildRedirectUri(Options.CallbackPath);var context = new OAuthCodeExchangeContext(properties, code, redirectUri);var tokens = await ExchangeCodeAsync(context);if (tokens.Error != null)return HandleRequestResult.Fail(tokens.Error, properties);if (string.IsNullOrEmpty(tokens.AccessToken))return HandleRequestResult.Fail("Failed to retrieve access token.", properties);var identity = new ClaimsIdentity(ClaimsIssuer);if (Options.SaveTokens){var authenticationTokenList = new List<AuthenticationToken>{new AuthenticationToken{Name = "access_token",Value = tokens.AccessToken}};if (!string.IsNullOrEmpty(tokens.RefreshToken)){authenticationTokenList.Add(new AuthenticationToken{Name = "refresh_token",Value = tokens.RefreshToken});}if (!string.IsNullOrEmpty(tokens.TokenType)){authenticationTokenList.Add(new AuthenticationToken{Name = "token_type",Value = tokens.TokenType});}if (!string.IsNullOrEmpty(tokens.ExpiresIn) && int.TryParse(tokens.ExpiresIn, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result)){var dateTimeOffset = Clock.UtcNow + TimeSpan.FromSeconds(result);authenticationTokenList.Add(new AuthenticationToken(){Name = "expires_at",Value = dateTimeOffset.ToString("o", CultureInfo.InvariantCulture)});}properties.StoreTokens(authenticationTokenList);}var ticket = await CreateTicketAsync(identity, properties, tokens);return ticket == null ? HandleRequestResult.Fail("Failed to retrieve user information from remote server.", properties) : HandleRequestResult.Success(ticket);}此步驟中包含兩個子步驟
ExchangeCodeAsync(交換授權碼Code)
protected override async Task<OAuthTokenResponse> ExchangeCodeAsync(OAuthCodeExchangeContext context){var tokenRequestParameters = new List<KeyValuePair<string, string>>{new KeyValuePair<string, string>("appid", Options.ClientId),new KeyValuePair<string, string>("secret", Options.ClientSecret),new KeyValuePair<string, string>("code", context.Code),new KeyValuePair<string, string>("grant_type", "authorization_code"),};var urlEncodedContent = new FormUrlEncodedContent(tokenRequestParameters);var response =await Backchannel.PostAsync(Options.TokenEndpoint, urlEncodedContent, Context.RequestAborted);return response.IsSuccessStatusCode ? OAuthTokenResponse.Success(JsonDocument.Parse(await response.Content.ReadAsStringAsync())) : OAuthTokenResponse.Failed(new Exception("OAuth token failure"));}CreateTicketAsync(創建票據)
protected override async Task<AuthenticationTicket> CreateTicketAsync(ClaimsIdentity identity,AuthenticationProperties properties,OAuthTokenResponse tokens){var openId = tokens.Response.RootElement.GetString("openid");var parameters = new Dictionary<string, string>{{ "openid", openId},{ "access_token", tokens.AccessToken }};var userInfoEndpoint = QueryHelpers.AddQueryString(Options.UserInformationEndpoint, parameters);var response = await Backchannel.GetAsync(userInfoEndpoint, Context.RequestAborted);if (!response.IsSuccessStatusCode){throw new HttpRequestException($"An error occurred when retrieving WeChat user information ({response.StatusCode}). Please check if the authentication information is correct.");}using (var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync())){var context = new OAuthCreatingTicketContext(new ClaimsPrincipal(identity), properties, Context, Scheme,Options, Backchannel, tokens, payload.RootElement);context.RunClaimActions();await Events.CreatingTicket(context);context.Properties.ExpiresUtc = DateTimeOffset.Now.AddMinutes(15);return new AuthenticationTicket(context.Principal, context.Properties, Scheme.Name);}}組件寫好了,怎么使用呢?在Fulu.Passport.Web項目的Startup.cs文件中添加代碼如下:
public void ConfigureServices(IServiceCollection services){......省略部分代碼......services.AddAuthentication().AddWeChat(o =>{o.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme;o.ClientId = Configuration["ExternalWeChat:AppId"];o.ClientSecret = Configuration["ExternalWeChat:Secret"];})}接著,在UserController.cs中添加如下代碼:
/// <summary>/// 外部賬號登錄/// </summary>/// <param name="model"></param>/// <returns></returns>[HttpGet, AllowAnonymous]public IActionResult ExternalLogin([FromQuery] ExternalLoginModel model){var authenticationProperties = new AuthenticationProperties(){RedirectUri = Url.Action(nameof(ExternalLoginCallback)),Items ={{ "returnUrl", model.ReturnUrl },{ "scheme", model.Provider },}};return Challenge(authenticationProperties, model.Provider);}/// <summary>/// 外部登錄回調/// </summary>/// <returns></returns>[HttpGet][AllowAnonymous]public async Task<IActionResult> ExternalLoginCallback(){//獲取idsrv.external Cookie 對象var result = await HttpContext.AuthenticateAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme);var returnUrl = result.Properties.Items["returnUrl"];if (result.Succeeded == false){return await RedirectErrorResult("error", "External authentication error", returnUrl);}......省略部分代碼......//刪除 idsrv.external Cookieawait HttpContext.SignOutAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme);//寫入 .AspNetCore.Cookiesawait SignIn(userEntity, UserLoginModel.External);return Redirect(returnUrl);}總結
以上是生活随笔為你收集整理的开源项目葫芦藤:IdentityServer4的实现及其运用的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Abp vNext异常处理的缺陷/改造方
- 下一篇: Dotnet Core多版本API共存的