IdentityServer4

2022-12-29 06:00:35

續上篇;先理解其中的概念,可存取上篇<IdentityServer4 - v4.x 概念理解及執行過程>,本篇以程式碼為主的實現過程。

 

認證授權服務的建立

以下內容以密碼授權方式為例。

 

模擬存取DB各資料來源

以下為模擬測試準備的資料來源包括:Scope / ApiResource / IdentityResource / Client / 使用者資訊

為模擬準備的資料來源類
/// 假設的使用者模型
public class TestUser
{
    public string id { get; set; } = string.Empty;
    public string username { get; set; } = string.Empty;
    public string password { get; set; } = string.Empty;
    public string nickname { get; set; } = string.Empty;
    public string gender { get; set; } = string.Empty;
    public string email { get; set; } = string.Empty;
    public string phone { get; set; } = string.Empty;
    public string address { get; set; } = string.Empty;
}
/// 假設的DB資料
public class DB
{
	/// Scope資料來源方法(4.x 時 很重要!!!)
	public static IEnumerable<ApiScope> ApiScopes => new ApiScope[]
	{
		new ApiScope("add","新增"),
		new ApiScope("search","查詢"),
		new ApiScope("shopping","購物"),
	};
        /// ApiResource 資料來源方法
	/// 需要被認證授權的資源(服務站點)資料來源
	public static IEnumerable<ApiResource> GetApiResources => new ApiResource[]
	{
	    new ApiResource("member", "會員服務")
	    {
	        // v4.x 時 很重要!!!
	        Scopes = { "add", "search" },
                // 指定此資源中,需要的身份(使用者)資訊(因此後續會存於Token中)
	        UserClaims={ JwtClaimTypes.NickName }
	    },
	    new ApiResource("product", "產品服務")
            {
                Scopes = { "add", "shopping" },
                UserClaims = { JwtClaimTypes.Name, JwtClaimTypes.NickName, "email", "depart", "role"}
            },
	    new ApiResource("order", "訂單服務")
            {
                Scopes = { "add", "shopping"},
                UserClaims = { JwtClaimTypes.Gender, "zip" }
            }
	};
	/// 身份資源設定資料來源方法
        /// 它定義了一個身份可以具備的所有屬性
        /// 一個 IdentityResource = 一組 Claim;如下的:Profile、org等
	public static IEnumerable<IdentityResource> IdentityResources => new IdentityResource[]
	{
		// 必須項
		new IdentityResources.OpenId(),
		new IdentityResources.Profile(),
		// 擴充套件項
		new IdentityResources.Email(),
		new IdentityResources.Phone(),
		new IdentityResources.Address(),
		// 自定義追加項
		new IdentityResource("org",new string[]{"depart","role"}),
		new IdentityResource("zip",new string[]{"zip"}),
		new IdentityResource("_test",new string[]{"_test"})
	};
	/// 使用者端資料來源方法
	public static IEnumerable<Client> Clients => new Client[]
	{
	    new Client
	    {
	        ClientId = "Cli-c",
	        ClientName="使用者端-C-密碼方式認證",
	        AllowedGrantTypes = GrantTypes.ResourceOwnerPassword,
	        ClientSecrets = { new Secret("secret_code".Sha256()) },
	        // 支援token過期後自動重新整理token,增強體驗
	        AllowOfflineAccess = true,
	        AccessTokenLifetime = 360000,
	        AllowedScopes = {    // Client.Scopes = Scope + IdentityResource
                    // 以下為Scope資料來源中必須具備的
                    "add", "search", "shopping",
                    // 以下為IdentityResource資料來源中必須具備的
                    IdentityServerConstants.StandardScopes.OpenId,
                    IdentityServerConstants.StandardScopes.Profile,
                    JwtClaimTypes.Email, "org","zip","_test",
                    // 為配合 AllowOfflineAccess 屬性
                    IdentityServerConstants.StandardScopes.OfflineAccess
	        }
	    }
	};
	/// 使用者資料來源方法
	public static IEnumerable<TestUser> Users => new TestUser[] {
	    new TestUser{
	        id = "10001", username = "sol", password = "123", nickname = "Sol",
                email = "[email protected]", phone="13888888888", gender = "男", address="jingan"
	    },
	    new TestUser{
	        id = "10002", username = "song", password = "123", nickname = "Song",
                email = "[email protected]", phone="13888888888", gender = "女", address="jingan"
	    }
	};
	/// 使用者是否啟用方法
	public static bool GetUserActive(string userid)
	{
	    return Users.Any(a => a.id == userid);
	}
}

 

為 Client 實現 IClientStore 介面

/// 使用者端資料查詢
public class ClientStore : IClientStore
{
        // 使用者端驗證方法
	public Task<Client> FindClientByIdAsync(string clientId)
	{
		// 資料庫查詢 Client 資訊
		var client = DB.Clients.FirstOrDefault(c => c.ClientId == clientId) ?? new Client();
		client.AccessTokenLifetime = 36000;
		return Task.FromResult(client);
	}
}

為 ApiResource 實現 IResourceStore 介面

從中可以理出 IdentityResource、ApiResource、ApiScope 三者的關係。

/// <summary>
/// 各個資源資料的查詢方法
/// 包括:IdentityResource、ApiResource、ApiScope 三項資源
/// </summary>
public class ResourceStore : IResourceStore
{
    public Task<IEnumerable<ApiResource> FindApiResourcesByNameAsync(IEnumerable<string> apiResourceNames)
    {
        if (apiResourceNames == null) throw new ArgumentNullException(nameof(apiResourceNames));
        var result = DB.GetApiResources.Where(r => apiResourceNames.Contains(r.Name));
        return Task.FromResult(result);
    }
    public Task<IEnumerable<ApiResource> FindApiResourcesByScopeNameAsync(IEnumerable<string> scopeNames)
    {
        if (scopeNames == null) throw new ArgumentNullException(nameof(scopeNames));
        var result = DB.GetApiResources.Where(t => t.Scopes.Any(item => scopeNames.Contains(item)));
        return Task.FromResult(result);
    }
    public Task<IEnumerable<ApiScope> FindApiScopesByNameAsync(IEnumerable<string> scopeNames)
    {
        if (scopeNames == null) throw new ArgumentNullException(nameof(scopeNames));
        var result = DB.ApiScopes.Where(w => scopeNames.Contains(w.Name));
        return Task.FromResult(result);
    }
    public Task<IEnumerable<IdentityResource> FindIdentityResourcesByScopeNameAsync(IEnumerable<string> scopeNames)
    {
        if (scopeNames == null) throw new ArgumentNullException(nameof(scopeNames));
        var result = DB.IdentityResources.Where(w => scopeNames.Contains(w.Name));
        return Task.FromResult(result);
    }
    public Task<Resources> GetAllResourcesAsync()
    {
        return Task.FromResult(new Resources(DB.IdentityResources, DB.GetApiResources, DB.ApiScopes));
    }
}

密碼方式驗證使用者,實現 IResourceOwnerPasswordValidator 介面

/// <summary>
/// 密碼方式認證過程
/// </summary>
public class ResourceOwnerPasswordValidator : IResourceOwnerPasswordValidator
{
    /// <summary>
    /// 1、驗證 使用者是否合法
    /// 2、設定 身份基本資訊
    /// 3、設定 返回給呼叫者的 Response 結果資訊
    /// </summary>
    /// <param name="context"></param>
    /// <returns></returns>
    public Task ValidateAsync(ResourceOwnerPasswordValidationContext context)
    {
        try
        {
            //驗證使用者,使用者名稱和密碼是否正確
            var user = DB.Users.FirstOrDefault(u => u.username == context.UserName && u.password == context.Password);

            if (user != null)
            {
                #region 設定 身份(使用者)基本資訊
                // 身份資訊的相關屬性,帶入到ids4中
                var claimList = new List<Claim>()
                {
                    // Claim 多(自定義)屬性
                    new Claim(JwtClaimTypes.Name,user.username),
                    new Claim(JwtClaimTypes.NickName,user.nickname),
                    new Claim(JwtClaimTypes.Email,user.email),
                    new Claim(JwtClaimTypes.Gender,user.gender),
                    new Claim(JwtClaimTypes.PhoneNumber,user.phone),
                    new Claim("zip","200000"),
                    new Claim("_test","_測試")
                };
                
                // 追加Claim自定義使用者屬性(角色/所屬部門)
                string[] roles = new string[] { "SupperManage", "manage", "admin", "member" };
                string[] departs = new string[] { "銷售部", "人事部", "總經理辦公室" };
                foreach (var rolename in roles)
                {
                    claimList.Add(new Claim(JwtClaimTypes.Role, rolename));
                }
                foreach (var departname in departs)
                {
                    claimList.Add(new Claim("depart", departname));
                }
                #endregion


                #region 設定 返回給呼叫者的Response資訊

                // 在以下 GrantValidationResult 類中
                // 1、通過以上已組裝的 ClaimList,再追加上系統必須的Claim項,組裝成最終的Claims
                // 2、用 Claims ==> 建立出 ClaimsIdentity ==> 再建立出 ClaimsPrincipal
                // 以完成 Response 的 json 結果 返回給 呼叫者

                context.Result = new GrantValidationResult(
                    subject: user.id,
                    claims: claimList,
                    authenticationMethod: "db_pwdmode",
                    // Response 的 json 自定義追加項
                    customResponse: new Dictionary<string, object> {
                        { "custom_append_author", "認證授權請求的Response自定義追加效果" },
                        { "custom_append_discription", "認證授權請求的Response自定義追加效果" }
                    }
                );
                #endregion
            }
            else if (user == null)
            {
                context.Result = new GrantValidationResult(
                    TokenRequestErrors.InvalidGrant,
                    "使用者認證失敗,賬號或密碼不存在;無效的自定義證書。"
                );
            }
        }
        catch (Exception ex)
        {
            context.Result = new GrantValidationResult()
            {
                IsError = true,
                Error = ex.Message
            };
        }
        return Task.CompletedTask;
    }
}

使用者資訊 Profile 的介面實現

/// <summary>
/// 認證通過的使用者資料資訊 的處理,後續公佈到Token中
/// </summary>
public class UserProfileService : IProfileService
{
    // 把需要公開到Token中的使用者claim資訊,放到指定的IssuedClaims中,為後續生成 Token 所用
    public Task GetProfileDataAsync(ProfileDataRequestContext context)
    {
        var userid = context.Subject.GetSubjectId();
        if (userid != null)
        {
            var claims = context.Subject.Claims.ToList();

            // 此方法,會依據Client請求的Scope(IdentityResource.Claims),過濾Claim後的集合放入到 IssuedClaims 中
            // 1、Client.Scope(IdentityResource)找到身份中的Claims
            // 2、與使用者資訊生成的Claims匹配,將結果放入IssuedClaims中
            context.AddRequestedClaims(claims);

            // 不按 Client.Scope(IdentityResource.Claims) 的過濾,所有的使用者claim全部放入
            // context.IssuedClaims = claims.ToList();
        }
        return Task.CompletedTask;
    }

    public Task IsActiveAsync(IsActiveContext context)
    {
        string userid = context.Subject.GetSubjectId();
        // 查詢 DB,ids4需要知道 使用者是否已啟用
        context.IsActive = DB.GetUserActive(userid);
        return Task.CompletedTask;
    }
}

認證授權服務設定

public class Program
{
    public static void Main(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);
        builder.Services.AddControllers();

        #region IdentityServer 的設定
        builder.Services.AddIdentityServer()
            // 支援開發環境的簽名證書
            .AddDeveloperSigningCredential()
            // 分別註冊各自介面的實現類
            .AddResourceStore().AddClientStore().AddResourceOwnerValidator().AddProfileService();
            // 可追加的擴充套件
            //.AddExtensionGrantValidator<微信自定義擴充套件模式>();
        #endregion
        builder.Services.AddEndpointsApiExplorer();
        builder.Services.AddSwaggerGen();



        var app = builder.Build();
        if (app.Environment.IsDevelopment())
        {
            app.UseSwagger();
            app.UseSwaggerUI();
        }
        app.UseRouting();
        
        #region 使用 ids4 服務
        // 它需要在 [路由] 之後,[授權] 之前。
        app.UseIdentityServer();
        app.UseAuthorization();
        #endregion


        app.UseEndpoints(endpoints =>
        {
            endpoints.MapControllers();
        });
        app.Run();
    }
}

認證授權服務請求效果

從上圖看出:使用者密碼驗證成功、使用者端及金鑰Secret驗證成功。

這裡重點解釋下Scope:

Client引數Scope中包含了: Scope(shopping) + IdentityResource(openid+profile+org+email+zip)

ApiResource 資料來源中的產品服務、訂單服務都包含了shopping,所以access_token可以存取這兩個服務。

Client/IdentityResource/ApiResource 資料來源中已定義了 openid+profile+org+email+zip,所以access_token中包含了此使用者資訊。

認證授權服務 /connect/userinfo 取得的身份資訊圖例:

上圖結果顯示:Client.Scope(IdentityResource.Claims) 匹配到的 ApiResources.UserClaims 合併的結果

解析Token資料圖例:

上圖顯示:

aud:已授權的(Client.Scope匹配到的)ApiResource服務名稱集合(product/order)

name/email/role/zip/...的Claims:已授權服務(product/order)下的UserClaims合併的結果

client_id:申請的使用者端標識

nbf/exp:認證授權時間/token過期時間

 

Token存取授權服務

授權成功的測試

建立一個API產品服務,設定產品服務

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();


#region Authentication 授權認證
builder.Services.AddAuthorization();
builder.Services.AddAuthentication(options =>
{
    // 資料格式設定,以 IdentityServer 風格為準
    options.DefaultScheme = IdentityServerAuthenticationDefaults.AuthenticationScheme;
    options.DefaultAuthenticateScheme = IdentityServerAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = IdentityServerAuthenticationDefaults.AuthenticationScheme;
    options.DefaultForbidScheme = IdentityServerAuthenticationDefaults.AuthenticationScheme;
    options.DefaultSignInScheme = IdentityServerAuthenticationDefaults.AuthenticationScheme;
    options.DefaultSignOutScheme = IdentityServerAuthenticationDefaults.AuthenticationScheme;
})
.AddIdentityServerAuthentication(options =>
{
    options.Authority = "http://localhost:5007";    // IdentityServer 授權服務地址
    options.RequireHttpsMetadata = false;           // 不需要https
    options.ApiName = "product";                    // 當前服務名稱(與認證授權服務中 ApiResources 的名稱對應)
});
#endregion



builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}
app.UseRouting();


#region IdentityServer4 註冊
// 放在路由之後,授權之前
app.UseAuthentication();
app.UseAuthorization();
#endregion


app.MapControllers();
app.Run();

Product產品服務中設定Authorize必須授權並且角色為SupperManage的Action:

/// 獲取當前身份資訊
[HttpGet, Authorize(Roles = "SupperManage")]
public IEnumerable<object> Get()
{
    /// 授權後的身份(使用者)資訊(從Token中提取的使用者屬性資訊)
    var Principal = HttpContext.User;
    
    /// 返回 獲取到的身份(使用者)資訊
    return new List<object> { new
    {
        product_service_claims = new {
            UserId = Principal.Claims.FirstOrDefault(oo => oo.Type == "sub")?.Value,
            UserName = Principal.Claims.FirstOrDefault(oo => oo.Type == JwtClaimTypes.Name)?.Value,
            NickName = Principal.Claims.FirstOrDefault(oo => oo.Type == JwtClaimTypes.NickName)?.Value,
            Email = Principal.Claims.FirstOrDefault(oo => oo.Type == JwtClaimTypes.Email)?.Value
        },
        order_service_claims = new {
            Gender = Principal.Claims.FirstOrDefault(oo => oo.Type == JwtClaimTypes.Gender)?.Value,
            Zip = Principal.Claims.FirstOrDefault(oo => oo.Type == "zip")?.Value
        },
        ApiResource中不存在的Claim = new {
            _Test = Principal.Claims.FirstOrDefault(oo => oo.Type == "_test")?.Value
        }
    }};
}

以上Product產品服務中Action取得當前身份(使用者)部分資訊效果圖:

 

授權失敗的測試

按產品服務的建立過程,再建立一個API會員服務member;

ApiResource資料來源會員服務Scopes中不存在sopping;以上過程 Token 的 aud 只有 product/order,不存在會員服務member;

 

用以上 Token 存取會員服務的測試,預期結果:授權失敗。如下圖: