1. 程式人生 > >開源專案葫蘆藤:IdentityServer4的實現及其運用

開源專案葫蘆藤:IdentityServer4的實現及其運用

[TOC] # 前言 本篇文章主要是講解葫蘆藤專案中對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 ```cs //頒發者DN var issuer = new X509Name( new ArrayList{X509Name.C,X509Name.O,X509Name.OU,X509Name.L,X509Name.ST}, new Hashtable{[X509Name.C] = "CN",[X509Name.O] = "Fulu Newwork",[X509Name.OU] = "Fulu RSA CA 2020",[X509Name.L] = "Wuhan",[X509Name.ST] = "Hubei"}); //使用者DN var subject = new X509Name(new ArrayList{X509Name.C,X509Name.O,X509Name.CN}, new Hashtable {[X509Name.C] = "CN",[X509Name.O] = "ICH",[X509Name.CN] = "*.fulu.com"}); //生成證書檔案 CertificateGenerator.GenerateCertificate(newCertificateGenerator.GenerateCertificateOptions { Path = "mypfx.pfx",Issuer = issuer, Subject = subject }); ``` 執行程式碼後,在專案編譯輸出目錄中,會看到一個mypfx.pfx的檔案,此時我們的證書就建立成功啦。 接著怎麼使用呢,看下面程式碼: ```cs var certificate2 = new X509Certificate2("mypfx.pfx", "password", X509KeyStorageFlags.Exportable); identityServerBuilder.AddSigningCredential(certificate2); ``` 大家可能會問,葫蘆藤中怎麼不是這麼寫的呢,其實葫蘆藤專案中是將證書檔案的流資料轉成了二進位制字串,這樣就可以寫在配置檔案中了: ```cs 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); } ``` 然後在這麼使用: ```cs identityServerBuilder.AddSigningCredential(new X509Certificate2(Hex.Decode(appSettings.X509RawCertData), appSettings.X509CertPwd)); ``` # 客戶端儲存(Client Store) 在葫蘆藤專案中,我們建立了一個ClientStore類,繼承自介面IClientStore,實現其方法程式碼如下: ```cs public class ClientStore : IClientStore { private readonly IClientCacheStrategy _clientInCacheRepository; public ClientStore(IClientCacheStrategy clientInCacheRepository) { _clientInCacheRepository = clientInCacheRepository; } public async Task 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個方法: ```cs /// Interface for persisting any type of grant.
public interface IPersistedGrantStore { /// Stores the grant. /// The grant. /// Task StoreAsync(PersistedGrant grant); /// Gets the grant. /// The key. /// Task GetAsync(string key); /// Gets all grants for a given subject id. /// The subject identifier. /// Task> GetAllAsync(string subjectId); /// Removes the grant by key.
/// The key. /// Task RemoveAsync(string key); /// /// Removes all grants for a given subject id and client id combination. /// /// The subject identifier. /// The client identifier. /// Task RemoveAllAsync(string subjectId, string clientId); /// /// Removes all grants of a give type for a given subject id and client id combination. ///
/// The subject identifier. /// The client identifier. /// The type. /// Task RemoveAllAsync(string subjectId, string clientId, string type); } ``` PersistedGrant的結構如下: ```cs /// A model for a persisted grant public class PersistedGrant { /// Gets or sets the key. /// The key. public string Key { get; set; } /// Gets the type. /// The type. public string Type { get; set; } /// Gets the subject identifier. /// The subject identifier. public string SubjectId { get; set; } /// Gets the client identifier. /// The client identifier. public string ClientId { get; set; } /// Gets or sets the creation time. /// The creation time. public DateTime CreationTime { get; set; } /// Gets or sets the expiration. /// The expiration. public DateTime? Expiration { get; set; } /// Gets or sets the data. /// The data. public string Data { get; set; } } ``` 可以看出主要是針對PersistedGrant物件的操作,通過觀察GetAsync和RemoveAsync方法的入參均為key,我們在StoreAsync中將PersistedGrant中的Key作為快取key,將PersistedGrant物件以hash的方式存入快取中,並設定過期時間(注意將UTC時間轉換為本地時間) ```cs 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的程式碼填上: ```cs public async Task 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集合。 ```cs 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> GetAllAsync(string subjectId) { if (string.IsNullOrWhiteSpace(subjectId)) return new List(); var db = await _redisCache.GetDatabaseAsync(); var keys = await db.ListRangeAsync(subjectId); var list = new List(); 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集合,刪除快取即可。 ```cs 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物件如下: ![Dingtalk_20201207162726](https://fulu-item11-zjk.oss-cn-zhangjiakou.aliyuncs.com/images/Dingtalk_20201207162726.jpg) # 資源擁有者驗證器(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的校驗。 ```cs public class RedirectUriValidator : IRedirectUriValidator { public Task 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 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 中的基於角色的授權](https://docs.microsoft.com/zh-cn/aspnet/core/security/authorization/roles?view=aspnetcore-5.0)》。 葫蘆藤中定義了兩種角色Claim(宣告),客戶端和使用者,使用客戶端授權模式(client credentials)頒發的令牌,ClaimRole為Client,使用授權碼模式(authorization code)、密碼模式(resource owner password credentials)、自定義授權模式(簡訊、第三方)頒發的使用者令牌,ClaimRole為User ``` public static class ClaimRoles { /// /// 客戶端 /// public const string Client = "Client"; /// /// 使用者 /// 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 { ...省略部分程式碼... /// /// 獲取應用列表 /// /// [HttpGet] [ProducesResponseType(typeof(ActionObjectResult, Statistic>), 200)] public async Task GetClients() { var clients = await _clientRepository.TableNoTracking.Where(c => c.Enabled).ToListAsync(); return ObjectResponse.Ok(clients); } ...省略部分程式碼... ``` ![7d9fabdf-deec-125e-96b0-042fef959955](https://fulu-item11-zjk.oss-cn-zhangjiakou.aliyuncs.com/images/7d9fabdf-deec-125e-96b0-042fef959955.gif) ## 客戶端授權模式(client credentials) 通過客戶端授權模式頒發的令牌,可以實現對服務資源進行保護。步驟如下: ``` (A)客戶端10000001向葫後進行身份認證,並要求一個訪問令牌。 (B)葫後驗證客戶端身份後,向客戶端10000001提供訪問令牌。 ``` A步驟中,客戶端10000001發出的HTTP請求,包含以下引數: - grant_type:表示授權型別,此處的值固定為"clientcredentials",必選項。 - client_id:表示客戶端的ID,必選項。 - client_secret:表示客戶端金鑰,必選項。 ``` POST https://account-web.suuyuu.cn/oauth/token HTTP/1.1 Host: www.xxx.com Content-Type: application/x-www-form-urlencoded grant_type=client_credentials&client_id=10000001&client_secret=14p9ao1gxu4q3sp8ogk8bq4gkct59t9w ``` B步驟中,葫蘆藤向客戶端10000001發放令牌,下面是一個例子。 ``` HTTP/1.1 200 OK Content-Type: application/json;charset=UTF-8 Cache-Control: no-store, no-cache, max-age=0 Pragma: 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" } ``` ![5c894611-a780-bb6d-487b-e339f16f5c4c](https://fulu-item11-zjk.oss-cn-zhangjiakou.aliyuncs.com/images/5c894611-a780-bb6d-487b-e339f16f5c4c.gif) ## 授權碼模式(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)使用者訪問“百前”,“百前”將使用者導向“葫後”。 (B)“葫後”檢查使用者是否需要登入(是否攜帶了有效的登入Cookie),如需登入跳轉到“葫前”。 (C)使用者登入後,“葫後”將使用者導向百度事先指定的"重定向URI"(redirection URI),同時附上一個授權碼。 (D)“百前”收到授權碼,附上早先的"重定向URI",向“百後”申請令牌,“百後”拿到授權碼之後攜帶金鑰client_secret向“葫後”申請令牌。 (E)“葫後”核對了授權碼和重定向URI,確認無誤後,向“百後”頒發訪問令牌(access token)。 (F)“百後”將令牌返回給“百前”。 ``` 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=STATE ``` D步驟中,我們通過臨時授權碼向“葫後”索取令牌,包含以下引數: - grant_type:表示使用的授權模式,必選項,此處的值固定為"authorization_code"。 - code:表示上一步獲得的授權碼,必選項。 - redirect_uri:表示重定向URI,必選項,且必須與A步驟中的該引數值保持一致。 - client_id:表示應用ID,必選項。 - client_secret:表示應用金鑰,必選項。 ``` POST https://account-web.suuyuu.cn/oauth/token HTTP/1.1 Host: account-web.suuyuu.cn Content-Type: application/x-www-form-urlencoded grant_type=authorization_code&code=1MlxrvXuD7TfH-s4dLzcw9ymO0SKDbf5xAlh3ZEHlMo&redirect_uri=https%3A%2F%2Fwww.baidu.com&client_id=10000002&client_secret=14p9ao1gxu4q3sp8ogk8bq4gkct59t9w ``` ``` { "access_token":"eyJhbGciOiJSUzI1NiIsImtpZCI6IjcwQzQ3OUY1QUIyQTFERjM2QzE0MkNEQjQ3NjQ1QkEwMzQ1MTg1NUEiLCJ0eXAiOiJKV1QiLCJ4NXQiOiJjTVI1OWFzcUhmTnNGQ3piUjJSYm9EUlJoVm8ifQ.eyJuYmYiOjE2MDc0MjY0MjcsImV4cCI6MTYwNzQzMzYyNywiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MCIsImF1ZCI6ImFwaSIsImNsaWVudF9pZCI6IjEwMDAwMDAxIiwic3ViIjoiZGYwOWVmZmYtMDA3NC00ZGNhLTkxYzMtZTM4MTgwYzVlNGFjIiwiYXV0aF90aW1lIjoxNjA3NDI2MTk2LCJpZHAiOiJsb2NhbCIsImlkIjoiZGYwOWVmZmYtMDA3NC00ZGNhLTkxYzMtZTM4MTgwYzVlNGFjIiwib3Blbl9pZCI6IjA3RThFMzBCNTZEMjU2RUY4QzQ0MDAxOUFCNkFBQTg5IiwibmFtZSI6IjEwNTFkZmQxLTczZTUtNGU2Zi05MzI2LTM0MjNiYzliNzFhMyIsIm5pY2tuYW1lIjoibGFvd2FuZyIsInBob25lX251bWJlciI6IjE4NjI3MTMxMzkwIiwiZW1haWwiOiIiLCJyb2xlIjoiVXNlciIsImh0dHA6Ly9zY2hlbWFzLm1pY3Jvc29mdC5jb20vd3MvMjAwOC8wNi9pZGVudGl0eS9jbGFpbXMvcm9sZSI6IlVzZXIiLCJsb2dpbl9pcCI6IjExMy41Ny4xMTguNTEiLCJsb2dpbl9hZGRyZXNzIjoi5rmW5YyX55yB5q2m5rGJ5biCIiwibGFzdF9sb2dpbl9pcCI6IjExMy41Ny4xMTguNTEiLCJsYXN0X2xvZ2luX2FkZHJlc3MiOiLmuZbljJfnnIHmrabmsYnluIIiLCJzY29wZSI6WyJhcGkiXSwiYW1yIjpbIm1mYSJdfQ.ElnHr5Niknq7kzGL8iv1TH0F6NQ21yPrswzSTIZuvetUxztYgQpD-RfgBW2HL6b_rRyQxFjE23gU4lBIEayM8k3M9_sUzZq8E_dFT8LwpsU76-CxepxHft4hn1YG0a5C6QRyjFQoSFVUZXIp663Es7vwRQ6PgsfkHZKXxAqXL-obHj_QLbv6OeciTIRGwYrL9-1_SDQ4esFR2n8LkGGOug55j9QuQEKMCufQLJ-nB3y7A2-0mnNoiuF2BBYSPLamcvMcLe8LbhCITLrHkcUSc6tsSdnEeisS6BMIoiyRq-LR2jJwDD30swTPFd85v6kUBJ3ZnWjeCqsluGGKHrwDLA", "expires_in":7200, "token_type":"Bearer", "scope":"api" } ``` ![87a20dc0-bd01-ef6d-524e-2403f1a9f263](https://fulu-item11-zjk.oss-cn-zhangjiakou.aliyuncs.com/images/87a20dc0-bd01-ef6d-524e-2403f1a9f263.gif) ## 密碼模式(resource owner password credentials) 密碼模式主要用於給可信應用頒發使用者令牌,此類應用有個性化的登入頁(不依賴單點登入,葫蘆藤的登入頁面),如app、小程式、h5等。 - grant_type:表示授權型別,此處的值固定為"password",必選項。 - client_id:表示客戶端的ID,必選項。 - client_secret:表示客戶端金鑰,必選項。 - username:使用者名稱,必選項。 - password:密碼,必選項。(基於密碼原文的rsa加密串) ``` POST https://account-web.suuyuu.cn/oauth/token HTTP/1.1 Host: account-web.suuyuu.cn Content-Type: application/x-www-form-urlencoded grant_type=password&client_id=10000001&client_secret=14p9ao1gxu4q3sp8ogk8bq4gkct59t9w&username=18627131390&password=0200f6389afbcbc624811785c9fbbf5c1b6d7b53b1315a1a43021c0733323fab7625bb9e6594cd30758fa700798421bc189dc223bf696d2438530ffab337809b96bb47ee38f3416bf4b57222050d5f4ad66ee052598ea62ff5ec6f991729956cb692f6f48b758564a46aeff86208581cad9063d3ccd71b551fa4b4b4b983fc1a ``` ``` { "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjcwQzQ3OUY1QUIyQTFERjM2QzE0MkNEQjQ3NjQ1QkEwMzQ1MTg1NUEiLCJ0eXAiOiJKV1QiLCJ4NXQiOiJjTVI1OWFzcUhmTnNGQ3piUjJSYm9EUlJoVm8ifQ.eyJuYmYiOjE2MDc1MTE2NTEsImV4cCI6MTYwNzUxODg1MSwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MCIsImF1ZCI6ImFwaSIsImNsaWVudF9pZCI6IjEwMDAwMDAxIiwic3ViIjoiZGYwOWVmZmYtMDA3NC00ZGNhLTkxYzMtZTM4MTgwYzVlNGFjIiwiYXV0aF90aW1lIjoxNjA3NTExNjUxLCJpZHAiOiJsb2NhbCIsImlkIjoiZGYwOWVmZmYtMDA3NC00ZGNhLTkxYzMtZTM4MTgwYzVlNGFjIiwib3Blbl9pZCI6IjA3RThFMzBCNTZEMjU2RUY4QzQ0MDAxOUFCNkFBQTg5IiwibmFtZSI6IjEwNTFkZmQxLTczZTUtNGU2Zi05MzI2LTM0MjNiYzliNzFhMyIsIm5pY2tuYW1lIjoibGFvd2FuZyIsInBob25lX251bWJlciI6IjE4NjI3MTMxMzkwIiwiZW1haWwiOiIiLCJyb2xlIjoiVXNlciIsImh0dHA6Ly9zY2hlbWFzLm1pY3Jvc29mdC5jb20vd3MvMjAwOC8wNi9pZGVudGl0eS9jbGFpbXMvcm9sZSI6IlVzZXIiLCJsb2dpbl9pcCI6IjExMy41Ny4xMTguNjEiLCJsb2dpbl9hZGRyZXNzIjoi5rmW5YyX55yB5q2m5rGJ5biCIiwibGFzdF9sb2dpbl9pcCI6IjExMy41Ny4xMTguNjEiLCJsYXN0X2xvZ2luX2FkZHJlc3MiOiLmuZbljJfnnIHmrabmsYnluIIiLCJzY29wZSI6WyJhcGkiLCJnZXRfdXNlcl9pbmZvIl0sImFtciI6WyJwd2QiLCJtZmEiXX0.d3qvhX6KSdm5EgWpUzbjJX2bB1OiUo-285nZ1qsGKpqTQJUH1VHQoJogB0NI-uVYdgIV-y3CMBhFY_fDYQJto43zDf0gDvYxa2eWnX5MWL7Augigi59Icp0YvNDCGd2iT5ztAWpxk1Jww815TtCFtFFGiQfQC75bKLrTW9QvdXr8t4VHcFKGmz92m8g3WL-0eWqAyvk0YuSBvxOd8P8zoocEiiOgVKTSylphSIQxuC8B4MFNf2DoFWDQjNZmDCs7PLh7sniMmLdfilo7T7gAlq9qjUrmQmav4wbDMT8WZqa01WY-LsWq6mZUnbCytgSu7Xrr90b6LAEGn-hxdQ5VHg", "expires_in": 7200, "token_type": "Bearer", "scope": "api get_user_info" } ``` ![82b99de6-bf25-71ad-ad8e-0fd04fafe274](https://fulu-item11-zjk.oss-cn-zhangjiakou.aliyuncs.com/images/82b99de6-bf25-71ad-ad8e-0fd04fafe274.gif) ## 自定義授權模式(簡訊、第三方)(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.1 Host: account-web.suuyuu.cn Content-Type: application/x-www-form-urlencoded grant_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" } ``` ![7aec2234-e725-4898-aa1f-db2d6d668059](https://fulu-item11-zjk.oss-cn-zhangjiakou.aliyuncs.com/images/7aec2234-e725-4898-aa1f-db2d6d668059.gif) ## 第三方授權登入的編寫與使用 在葫蘆藤專案中我們提供了釘釘、微信的OAuth元件,並實現了功能,演示地址在 https://account.suuyuu.cn,下面我們以微信為例簡單介紹下如何編寫元件及使用。 首先咱們閱讀一下[網站應用微信登入開發指南](https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html),瞭解一下接入流程。要使用微信登入,先得在[微信·開放平臺](https://open.weixin.qq.com/)註冊成為開發者,並進行資質認證。 ``` 微信開放平臺帳號的開發者資質認證提供更安全、更嚴格的真實性認證、也能夠更好的保護企業及使用者的合法權益 開發者資質認證通過後,微信開放平臺帳號下的應用,將獲得微信登入、智慧介面、第三方平臺開發等高階能力 稽核費用:中國大陸地區:300元,非中國大陸地區:99美元 ``` 然後在管理中心建立網站應用 ![20201217161633](https://fulu-item11-zjk.oss-cn-zhangjiakou.aliyuncs.com/images/20201217161633.png) 對照微信開發指南將需要用到的地址定義到WeChatDefaults.cs中 ```cs public static class WeChatDefaults { public const string AuthenticationScheme = "wechat"; public static readonly string DisplayName = "wechat"; //第一步:請求CODE public static readonly string AuthorizationEndpoint = "https://open.weixin.qq.com/connect/qrconnect"; //第二步:通過code獲取access_token public 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集合的引數來自微信返回的欄位 ```cs public class WeChatOptions : OAuthOptions { /// /// Initializes a new . /// 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"); } /// /// access_type. Set to 'offline' to request a refresh token. /// public string AccessType { get; set; } } ``` ```cs public static class WeChatExtensions { public static AuthenticationBuilder AddWeChat(this AuthenticationBuilder builder) => builder.AddWeChat(WeChatDefaults.AuthenticationScheme, _ => { }); public static AuthenticationBuilder AddWeChat(this AuthenticationBuilder builder, Action configureOptions) => builder.AddWeChat(WeChatDefaults.AuthenticationScheme, configureOptions); public static AuthenticationBuilder AddWeChat(this AuthenticationBuilder builder, string authenticationScheme, Action configureOptions) => builder.AddWeChat(authenticationScheme, WeChatDefaults.DisplayName, configureOptions); public static AuthenticationBuilder AddWeChat(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action configureOptions) => builder.AddOAuth(authenticationScheme, displayName, configureOptions); } ``` 新增一個類WeChatHandler,繼承自OAuthHandler ### BuildChallengeUrl(構造客戶端申請認證的URI) ```cs 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(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(向認證伺服器申請令牌獲取使用者資訊並建立票據) ```cs protected override async Task 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 { 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) ```cs protected override async Task ExchangeCodeAsync(OAuthCodeExchangeContext context) { var tokenRequestParameters = new List> { new KeyValuePair("appid", Options.ClientId), new KeyValuePair("secret", Options.ClientSecret), new KeyValuePair("code", context.Code), new KeyValuePair("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 CreateTicketAsync(ClaimsIdentity identity,AuthenticationProperties properties,OAuthTokenResponse tokens) { var openId = tokens.Response.RootElement.GetString("openid"); var parameters = new Dictionary { { "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檔案中新增程式碼如下: ```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中新增如下程式碼: ```cs /// /// 外部賬號登入 /// /// /// [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); } /// /// 外部登入回撥 /// /// [HttpGet] [AllowAnonymous] public async Task 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 Cookie await HttpContext.SignOutAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme); //寫入 .AspNetCore.Cookies await SignIn(userEntity, UserLoginModel.External); return Redirect(returnUrl); } ``` ![c21353bc-0e1d-1183-7a54-0a77c7b2772a](https://fulu-item11-zjk.oss-cn-zhangjiakou.aliyuncs.com/images/c21353bc-0e1d-1183-7a54-0a77c7b2772a.gif) 福祿ICH·架構組 福祿娃