5

IdentityServer4 - v4.x .Net中的实践应用 - Sol·wang

 1 year ago
source link: https://www.cnblogs.com/Sol-wang/p/17010169.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

续上篇;先理解其中的概念,可访问上篇<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();
    }
}

认证授权服务请求效果

2320729-20221228232109163-1099906806.png

从上图看出:用户密码验证成功、客户端及密钥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 取得的身份信息图例:
2320729-20221228232209138-490279315.png

上图结果显示:Client.Scope(IdentityResource.Claims) 匹配到的 ApiResources.UserClaims 合并的结果

解析Token数据图例:

2320729-20221228232931588-82422561.png

上图显示:

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取得当前身份(用户)部分信息效果图:

2320729-20221228233207752-616663557.png

授权失败的测试

按产品服务的创建过程,再创建一个API会员服务member;

ApiResource数据源会员服务Scopes中不存在sopping;以上过程 Token 的 aud 只有 product/order,不存在会员服务member;

用以上 Token 访问会员服务的测试,预期结果:授权失败。如下图:

2320729-20221228234950320-454568868.png

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK