開源專案葫蘆藤:IdentityServer4的實現及其運用
阿新 • • 發佈:2020-12-23
[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