Tip: 此篇已加入.NET Core微服务基础系列文章索引
一、案例结构总览
这里,假设我们有两个客户端(一个Web网站,一个移动App),他们要使用系统,需要通过API网关(这里API网关始终作为客户端的统一入口)先向IdentityService进行Login以进行验证并获取Token,在IdentityService的验证过程中会访问数据库以验证。然后再带上Token通过API网关去访问具体的API Service。这里我们的IdentityService基于IdentityServer4开发,它具有统一登录验证和授权的功能。
二、改写API Gateway
这里主要基于前两篇已经搭好的API Gateway进行改写,如不熟悉,可以先浏览前两篇文章:Part 1和Part 2。
2.1 配置文件的改动
1
2
3
4
5
6
7
8
9
10
11
12 1 ......
2 "AuthenticationOptions": {
3 "AuthenticationProviderKey": "ClientServiceKey",
4 "AllowedScopes": []
5 }
6 ......
7 "AuthenticationOptions": {
8 "AuthenticationProviderKey": "ProductServiceKey",
9 "AllowedScopes": []
10 }
11 ......
12
上面分别为两个示例API Service增加Authentication的选项,为其设置ProviderKey。下面会对不同的路由规则设置的ProviderKey设置具体的验证方式。
2.2 改写StartUp类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31 1 public void ConfigureServices(IServiceCollection services)
2 {
3 // IdentityServer
4 #region IdentityServerAuthenticationOptions => need to refactor
5 Action<IdentityServerAuthenticationOptions> isaOptClient = option =>
6 {
7 option.Authority = Configuration["IdentityService:Uri"];
8 option.ApiName = "clientservice";
9 option.RequireHttpsMetadata = Convert.ToBoolean(Configuration["IdentityService:UseHttps"]);
10 option.SupportedTokens = SupportedTokens.Both;
11 option.ApiSecret = Configuration["IdentityService:ApiSecrets:clientservice"];
12 };
13
14 Action<IdentityServerAuthenticationOptions> isaOptProduct = option =>
15 {
16 option.Authority = Configuration["IdentityService:Uri"];
17 option.ApiName = "productservice";
18 option.RequireHttpsMetadata = Convert.ToBoolean(Configuration["IdentityService:UseHttps"]);
19 option.SupportedTokens = SupportedTokens.Both;
20 option.ApiSecret = Configuration["IdentityService:ApiSecrets:productservice"];
21 };
22 #endregion
23
24 services.AddAuthentication()
25 .AddIdentityServerAuthentication("ClientServiceKey", isaOptClient)
26 .AddIdentityServerAuthentication("ProductServiceKey", isaOptProduct);
27 // Ocelot
28 services.AddOcelot(Configuration);
29 ......
30 }
31
这里的ApiName主要对应于IdentityService中的ApiResource中定义的ApiName。这里用到的配置文件定义如下:
1
2
3
4
5
6
7
8
9 1 "IdentityService": {
2 "Uri": "http://localhost:5100",
3 "UseHttps": false,
4 "ApiSecrets": {
5 "clientservice": "clientsecret",
6 "productservice": "productsecret"
7 }
8 }
9
这里的定义方式,我暂时还没想好怎么重构,不过肯定是需要重构的,不然这样一个一个写比较繁琐,且不利于配置。
三、新增IdentityService
这里我们会基于之前基于IdentityServer的两篇文章,新增一个IdentityService,不熟悉的朋友可以先浏览一下Part 1和Part 2。
3.1 准备工作
新建一个ASP.NET Core Web API项目,绑定端口5100,NuGet安装IdentityServer4。配置好证书,并设置其为“较新则复制”,以便能够在生成目录中读取到。
3.2 定义一个InMemoryConfiguration用于测试
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90 1 /// <summary>
2 /// One In-Memory Configuration for IdentityServer => Just for Demo Use
3 /// </summary>
4 public class InMemoryConfiguration
5 {
6 public static IConfiguration Configuration { get; set; }
7 /// <summary>
8 /// Define which APIs will use this IdentityServer
9 /// </summary>
10 /// <returns></returns>
11 public static IEnumerable<ApiResource> GetApiResources()
12 {
13 return new[]
14 {
15 new ApiResource("clientservice", "CAS Client Service"),
16 new ApiResource("productservice", "CAS Product Service"),
17 new ApiResource("agentservice", "CAS Agent Service")
18 };
19 }
20
21 /// <summary>
22 /// Define which Apps will use thie IdentityServer
23 /// </summary>
24 /// <returns></returns>
25 public static IEnumerable<Client> GetClients()
26 {
27 return new[]
28 {
29 new Client
30 {
31 ClientId = "cas.sg.web.nb",
32 ClientName = "CAS NB System MPA Client",
33 ClientSecrets = new [] { new Secret("websecret".Sha256()) },
34 AllowedGrantTypes = GrantTypes.ResourceOwnerPassword,
35 AllowedScopes = new [] { "clientservice", "productservice",
36 IdentityServerConstants.StandardScopes.OpenId,
37 IdentityServerConstants.StandardScopes.Profile }
38 },
39 new Client
40 {
41 ClientId = "cas.sg.mobile.nb",
42 ClientName = "CAS NB System Mobile App Client",
43 ClientSecrets = new [] { new Secret("mobilesecret".Sha256()) },
44 AllowedGrantTypes = GrantTypes.ResourceOwnerPassword,
45 AllowedScopes = new [] { "productservice",
46 IdentityServerConstants.StandardScopes.OpenId,
47 IdentityServerConstants.StandardScopes.Profile }
48 },
49 new Client
50 {
51 ClientId = "cas.sg.spa.nb",
52 ClientName = "CAS NB System SPA Client",
53 ClientSecrets = new [] { new Secret("spasecret".Sha256()) },
54 AllowedGrantTypes = GrantTypes.ResourceOwnerPassword,
55 AllowedScopes = new [] { "agentservice", "clientservice", "productservice",
56 IdentityServerConstants.StandardScopes.OpenId,
57 IdentityServerConstants.StandardScopes.Profile }
58 },
59 new Client
60 {
61 ClientId = "cas.sg.mvc.nb.implicit",
62 ClientName = "CAS NB System MVC App Client",
63 AllowedGrantTypes = GrantTypes.Implicit,
64 RedirectUris = { Configuration["Clients:MvcClient:RedirectUri"] },
65 PostLogoutRedirectUris = { Configuration["Clients:MvcClient:PostLogoutRedirectUri"] },
66 AllowedScopes = new [] {
67 IdentityServerConstants.StandardScopes.OpenId,
68 IdentityServerConstants.StandardScopes.Profile,
69 "agentservice", "clientservice", "productservice"
70 },
71 //AccessTokenLifetime = 3600, // one hour
72 AllowAccessTokensViaBrowser = true // can return access_token to this client
73 }
74 };
75 }
76
77 /// <summary>
78 /// Define which IdentityResources will use this IdentityServer
79 /// </summary>
80 /// <returns></returns>
81 public static IEnumerable<IdentityResource> GetIdentityResources()
82 {
83 return new List<IdentityResource>
84 {
85 new IdentityResources.OpenId(),
86 new IdentityResources.Profile(),
87 };
88 }
89 }
90
这里使用了上一篇的内容,不再解释。实际环境中,则应该考虑从NoSQL或数据库中读取。
3.3 定义一个ResourceOwnerPasswordValidator
在IdentityServer中,要实现自定义的验证用户名和密码,需要实现一个接口:IResourceOwnerPasswordValidator
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35 1 public class ResourceOwnerPasswordValidator : IResourceOwnerPasswordValidator
2 {
3 private ILoginUserService loginUserService;
4
5 public ResourceOwnerPasswordValidator(ILoginUserService _loginUserService)
6 {
7 this.loginUserService = _loginUserService;
8 }
9
10 public Task ValidateAsync(ResourceOwnerPasswordValidationContext context)
11 {
12 LoginUser loginUser = null;
13 bool isAuthenticated = loginUserService.Authenticate(context.UserName, context.Password, out loginUser);
14 if (!isAuthenticated)
15 {
16 context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, "Invalid client credential");
17 }
18 else
19 {
20 context.Result = new GrantValidationResult(
21 subject : context.UserName,
22 authenticationMethod : "custom",
23 claims : new Claim[] {
24 new Claim("Name", context.UserName),
25 new Claim("Id", loginUser.Id.ToString()),
26 new Claim("RealName", loginUser.RealName),
27 new Claim("Email", loginUser.Email)
28 }
29 );
30 }
31
32 return Task.CompletedTask;
33 }
34 }
35
这里的ValidateAsync方法中(你也可以把它写成异步的方式,这里使用的是同步的方式),会调用EF去访问数据库进行验证,数据库的定义如下(密码应该做加密,这里只做demo,没用弄):
至于EF部分,则是一个典型的简单的Service调用Repository的逻辑,下面只贴Repository部分:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 1 public class LoginUserRepository : RepositoryBase<LoginUser, IdentityDbContext>, ILoginUserRepository
2 {
3 public LoginUserRepository(IdentityDbContext dbContext) : base(dbContext)
4 {
5 }
6
7 public LoginUser Authenticate(string _userName, string _userPassword)
8 {
9 var entity = DbContext.LoginUsers.FirstOrDefault(p => p.UserName == _userName &&
10 p.Password == _userPassword);
11
12 return entity;
13 }
14 }
15
其他具体逻辑请参考示例代码。
3.4 改写StarUp类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24 1 public void ConfigureServices(IServiceCollection services)
2 {
3 // IoC - DbContext
4 services.AddDbContextPool<IdentityDbContext>(
5 options => options.UseSqlServer(Configuration["DB:Dev"]));
6 // IoC - Service & Repository
7 services.AddScoped<ILoginUserService, LoginUserService>();
8 services.AddScoped<ILoginUserRepository, LoginUserRepository>();
9 // IdentityServer4
10 string basePath = PlatformServices.Default.Application.ApplicationBasePath;
11 InMemoryConfiguration.Configuration = this.Configuration;
12 services.AddIdentityServer()
13 .AddSigningCredential(new X509Certificate2(Path.Combine(basePath,
14 Configuration["Certificates:CerPath"]),
15 Configuration["Certificates:Password"]))
16 //.AddTestUsers(InMemoryConfiguration.GetTestUsers().ToList())
17 .AddInMemoryIdentityResources(InMemoryConfiguration.GetIdentityResources())
18 .AddInMemoryApiResources(InMemoryConfiguration.GetApiResources())
19 .AddInMemoryClients(InMemoryConfiguration.GetClients())
20 .AddResourceOwnerValidator<ResourceOwnerPasswordValidator>()
21 .AddProfileService<ProfileService>();
22 ......
23 }
24
这里高亮的是新增的部分,为了实现自定义验证。关于ProfileService的定义如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14 1 public class ProfileService : IProfileService
2 {
3 public async Task GetProfileDataAsync(ProfileDataRequestContext context)
4 {
5 var claims = context.Subject.Claims.ToList();
6 context.IssuedClaims = claims.ToList();
7 }
8
9 public async Task IsActiveAsync(IsActiveContext context)
10 {
11 context.IsActive = true;
12 }
13 }
14
3.5 新增统一Login入口
这里新增一个LoginController:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35 1 [Produces("application/json")]
2 [Route("api/Login")]
3 public class LoginController : Controller
4 {
5 private IConfiguration configuration;
6 public LoginController(IConfiguration _configuration)
7 {
8 configuration = _configuration;
9 }
10
11 [HttpPost]
12 public async Task<ActionResult> RequestToken([FromBody]LoginRequestParam model)
13 {
14 Dictionary<string, string> dict = new Dictionary<string, string>();
15 dict["client_id"] = model.ClientId;
16 dict["client_secret"] = configuration[$"IdentityClients:{model.ClientId}:ClientSecret"];
17 dict["grant_type"] = configuration[$"IdentityClients:{model.ClientId}:GrantType"];
18 dict["username"] = model.UserName;
19 dict["password"] = model.Password;
20
21 using (HttpClient http = new HttpClient())
22 using (var content = new FormUrlEncodedContent(dict))
23 {
24 var msg = await http.PostAsync(configuration["IdentityService:TokenUri"], content);
25 if (!msg.IsSuccessStatusCode)
26 {
27 return StatusCode(Convert.ToInt32(msg.StatusCode));
28 }
29
30 string result = await msg.Content.ReadAsStringAsync();
31 return Content(result, "application/json");
32 }
33 }
34 }
35
这里假设客户端会传递用户名,密码以及客户端ID(ClientId,比如上面InMemoryConfiguration中的cas.sg.web.nb或cas.sg.mobile.nb)。然后构造参数再调用connect/token接口进行身份验证和获取token。这里将client_secret等机密信息封装到了服务器端,无须客户端传递(对于机密信息一般也不会让客户端知道):
1
2
3
4
5
6
7
8
9
10
11 1 "IdentityClients": {
2 "cas.sg.web.nb": {
3 "ClientSecret": "websecret",
4 "GrantType": "password"
5 },
6 "cas.sg.mobile.nb": {
7 "ClientSecret": "mobilesecret",
8 "GrantType": "password"
9 }
10 }
11
3.6 加入API网关中
在API网关的Ocelot配置文件中加入配置,配置如下(这里我是开发用,所以没有用服务发现,实际环境建议采用服务发现):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35 1 // --> Identity Service Part
2 {
3 "UseServiceDiscovery": false, // do not use Consul service discovery in DEV env
4 "DownstreamPathTemplate": "/api/{url}",
5 "DownstreamScheme": "http",
6 "DownstreamHostAndPorts": [
7 {
8 "Host": "localhost",
9 "Port": "5100"
10 }
11 ],
12 "ServiceName": "CAS.IdentityService",
13 "LoadBalancerOptions": {
14 "Type": "RoundRobin"
15 },
16 "UpstreamPathTemplate": "/api/identityservice/{url}",
17 "UpstreamHttpMethod": [ "Get", "Post" ],
18 "RateLimitOptions": {
19 "ClientWhitelist": [ "admin" ], // 白名单
20 "EnableRateLimiting": true, // 是否启用限流
21 "Period": "1m", // 统计时间段:1s, 5m, 1h, 1d
22 "PeriodTimespan": 15, // 多少秒之后客户端可以重试
23 "Limit": 10 // 在统计时间段内允许的最大请求数量
24 },
25 "QoSOptions": {
26 "ExceptionsAllowedBeforeBreaking": 2, // 允许多少个异常请求
27 "DurationOfBreak": 5000, // 熔断的时间,单位为秒
28 "TimeoutValue": 3000 // 如果下游请求的处理时间超过多少则视如该请求超时
29 },
30 "HttpHandlerOptions": {
31 "UseTracing": false // use butterfly to tracing request chain
32 },
33 "ReRoutesCaseSensitive": false // non case sensitive
34 }
35
四、改写业务API Service
4.1 ClientService
(1)安装IdentityServer4.AccessTokenValidation
NuGet>Install-Package IdentityServer4.AccessTokenValidation
(2)改写StartUp类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 1 public IServiceProvider ConfigureServices(IServiceCollection services)
2 {
3 ......
4
5 // IdentityServer
6 services.AddAuthentication(Configuration["IdentityService:DefaultScheme"])
7 .AddIdentityServerAuthentication(options =>
8 {
9 options.Authority = Configuration["IdentityService:Uri"];
10 options.RequireHttpsMetadata = Convert.ToBoolean(Configuration["IdentityService:UseHttps"]);
11 });
12
13 ......
14 }
15
这里配置文件的定义如下:
1
2
3
4
5
6
7 1 "IdentityService": {
2 "Uri": "http://localhost:5100",
3 "DefaultScheme": "Bearer",
4 "UseHttps": false,
5 "ApiSecret": "clientsecret"
6 }
7
4.2 ProductService
与ClientService一致,请参考示例代码。
五、测试
5.1 测试Client: cas.sg.web.nb
(1)统一验证&获取token (by API网关)
(2)访问clientservice (by API网关)
(3)访问productservice(by API网关)
5.2 测试Client: cas.sg.mobile.nb
由于在IdentityService中我们定义了一个mobile的客户端,但是其访问权限只有productservice,所以我们来测试一下:
(1)统一验证&获取token
(2)访问ProductService(by API网关)
(3)访问ClientService(by API网关) => 401 Unauthorized
六、小结
本篇主要基于前面Ocelot和IdentityServer的文章的基础之上,将Ocelot和IdentityServer进行结合,通过建立IdentityService进行统一的身份验证和授权,最后演示了一个案例以说明如何实现。不过,本篇实现的Demo还存在诸多不足,比如需要重构的代码较多如网关中各个Api的验证选项的注册,没有对各个请求做用户角色和权限的验证等等,相信随着研究和深入的深入,这些都可以逐步解决。后续会探索一下数据一致性的基本知识以及框架使用,到时再做一些分享。
示例代码
Click Here => 点我进入GitHub
参考资料
杨中科,《.NET Core微服务介绍课程》
作者:周旭龙
出处:http://edisonchou.cnblogs.com
本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文链接。