本篇文章主要是讲解葫芦藤项目中对IdentityServer的实践使用,为了使您对本篇文章中所讲述的内容有深入的认识,而且在阅读时避免感到乏味,文中的内容不会涉及太多的基础理论知识,而更多的是采用动手实践的方式进行讲解,因此在阅读此篇文章前假定您已经掌握了OAuth2.0的基础知识,如您事先并未了解OAuth2.0,请参阅一下阮一峰老师的文章《理解OAuth2.0》, ASP.NET Core 认证与受权,能够看看博客 雨夜朦胧,另外IdentityServer的相关文章也能够参考博客 晓晨Master。html
葫芦藤前端地址:https://account.suuyuu.cn (验证码获取后,输入123456便可)前端
葫芦藤后端地址:https://account-web.suuyuu.cngit
葫芦藤源码地址:https://github.com/fuluteam/fulusso (帮忙点个小星星哦)github
团队博文地址:https://www.cnblogs.com/fuluweb
IdentityServer支持X.509证书(包括原始文件和对Windows证书存储库的引用)、RSA密钥和EC密钥,用于令牌签名和验证。每一个密钥均可以配置一个(兼容的)签名算法,如RS25六、RS38四、RS5十二、PS25六、PS38四、PS5十二、ES25六、ES384或ES512。redis
一般状况下,咱们使用的是针对开发场景建立的临时证书 AddDeveloperSigningCredential,
生产环境怎么办呢?IdentityServer还提供了AddSigningCredential用来装载证书文件,
为此咱们须要准备一个X.509证书,下面是在控制台项目中用于生成证书的代码,完整代码请参考项目:https://github.com/fuluteam/ICH.BouncyCastle算法
//颁发者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的文件,此时咱们的证书就建立成功啦。
接着怎么使用呢,看下面代码:json
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));
在葫芦藤项目中,咱们建立了一个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高度信任)。
因为历史缘由,在葫芦藤中,咱们并无经过IdentityServer对api资源进行访问保护(后续会提供咱们的实现方式),咱们为全部Client设置了相同的Scope。
葫芦藤中,咱们使用了Redis来持久化数据,
经过EntityFramework Core持久化配置和操做数据,请参考
http://www.javashuo.com/article/p-obrxclck-bm.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对象以下:
若是要使用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); } }
用于验证重定向(受权码模式)和注销后重定向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); } }
在IdentityServer4中,经过实现IExtensionGrantValidator接口,能够实现自定义受权。在葫芦藤项目中,咱们有两个场景须要用到自定义受权:
在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; } }
基于角色的受权检查是声明性的,开发人员将其嵌入到代码中、控制器或控制器内的操做,指定当前用户必须是其成员的角色才能访问请求的资源,文档参考《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); } ...省略部分代码...
经过客户端受权模式颁发的令牌,能够实现对服务资源进行保护。步骤以下:
(A)客户端10000001向葫后进行身份认证,并要求一个访问令牌。 (B)葫后验证客户端身份后,向客户端10000001提供访问令牌。
A步骤中,客户端10000001发出的HTTP请求,包含如下参数:
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" }
葫芦藤项目经过受权码模式(authorization code)实现了单点登陆,经过受权码模式拿到用户令牌。目前葫芦藤只有一个应用(葫芦藤安全中心),这里为了避免把概念搞混淆,咱们假定百度(客户端10000002,redirect_uri 为 http://www.baidu.com)接入了我们的受权体系,固然,百度的前端确定没有写如何构造请求步骤的逻辑代码,所以,咱们下面经过人工模拟请求步骤。
名词定义
(A)用户访问“百前”,“百前”将用户导向“葫后”。 (B)“葫后”检查用户是否须要登陆(是否携带了有效的登陆Cookie),如需登陆跳转到“葫前”。 (C)用户登陆后,“葫后”将用户导向百度事先指定的"重定向URI"(redirection URI),同时附上一个受权码。 (D)“百前”收到受权码,附上早先的"重定向URI",向“百后”申请令牌,“百后”拿到受权码以后携带密钥client_secret向“葫后”申请令牌。 (E)“葫后”核对了受权码和重定向URI,确认无误后,向“百后”颁发访问令牌(access token)。 (F)“百后”将令牌返回给“百前”。
A步骤中,构造的请求地址包含如下参数:
步骤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步骤中,咱们经过临时受权码向“葫后”索取令牌,包含如下参数:
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" }
密码模式主要用于给可信应用颁发用户令牌,此类应用有个性化的登陆页(不依赖单点登陆,葫芦藤的登陆页面),如app、小程序、h5等。
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" }
客户端经过用户手机号短信验证码或第三方用户(QQ、WeChat)的用户惟一标识(OpenId)向认证服务器索要用户令牌。
以短信验证码方式为例,咱们定义的流程以下:
用户向客户端提供本身的手机号和短信验证码。客户端使用这些信息,向认证服务器索要受权。 步骤以下:
(A)用户向客户端提供手机号和短信验证码。 (B)客户端将手机号和短信码发给认证服务器,向后者请求令牌。 (C)认证服务器确认无误后,向客户端提供用户令牌。
B步骤中,客户端发出的HTTP请求,包含如下参数:
下面是一个请求示例。
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" }
在葫芦藤项目中咱们提供了钉钉、微信的OAuth组件,并实现了功能,演示地址在 https://account.suuyuu.cn,下面咱们以微信为例简单介绍下如何编写组件及使用。
首先我们阅读一下网站应用微信登陆开发指南,了解一下接入流程。要使用微信登陆,先得在微信·开放平台注册成为开发者,并进行资质认证。
微信开放平台账号的开发者资质认证提供更安全、更严格的真实性认证、也可以更好的保护企业及用户的合法权益 开发者资质认证经过后,微信开放平台账号下的应用,将得到微信登陆、智能接口、第三方平台开发等高级能力 审核费用:中国大陆地区:300元,非中国大陆地区:99美圆
而后在管理中心建立网站应用
对照微信开发指南将须要用到的地址定义到WeChatDefaults.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集合的参数来自微信返回的字段
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
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; }
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); }
此步骤中包含两个子步骤
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")); }
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 Cookie await HttpContext.SignOutAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme); //写入 .AspNetCore.Cookies await SignIn(userEntity, UserLoginModel.External); return Redirect(returnUrl); }