|
@@ -0,0 +1,451 @@
|
|
1
|
+# 了解IdentityServer4的原理
|
|
2
|
+
|
|
3
|
+首先在请求管道里有一个有关IdentityServer4的中间件,通过这个中间件来设施所有有关IdentityServer4的逻辑。
|
|
4
|
+```
|
|
5
|
+app.UseIdentityServer();
|
|
6
|
+```
|
|
7
|
+
|
|
8
|
+在DI容器里通过`services.AddDbContext<IdentityDbContext>()`有了上下文,通过`services.AddIdentity<IdentityUser, IdentityRole>`有了Identity,通过`services.AddIdentityServer()`有了IdentityServer4。其中的关系用图表示就是:
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+IdentityServer4掌管着所有的用户。
|
|
13
|
+
|
|
14
|
+```
|
|
15
|
+public static IENumerable<TestUser> GetUsers()
|
|
16
|
+{
|
|
17
|
+ return enw List<TestUser>{
|
|
18
|
+ new TestUser{
|
|
19
|
+ SubjectId="1",
|
|
20
|
+ Username = "",
|
|
21
|
+ Passowrd=""
|
|
22
|
+ Claims = {
|
|
23
|
+ new Claim(JwtClaimTypes.Name,""),
|
|
24
|
+ new Claim(JwtClaimTypes.GivenName,""),
|
|
25
|
+ new Claim(JwtClaimTypes.FamilyName,""),
|
|
26
|
+ new Claim(JwtClaimTypes.Email, ""),
|
|
27
|
+ new Claim(JwtClaimTypes.EmailVerified, "true", ClaimValueTypes.Boolean),
|
|
28
|
+ new Claim(JwtClaimTypes.WebSite, ""),
|
|
29
|
+ new Claim(JwtClaimTypes.Address,@"", IdentityServer4.IdentityServerConstancts.ClaimValueTypes.Json),
|
|
30
|
+ new Claim(JwtClaimTypes.Role, GlobalSetting.Temp_Role_Manager),
|
|
31
|
+ new Claim("GroupId","1")
|
|
32
|
+ }
|
|
33
|
+ }
|
|
34
|
+ };
|
|
35
|
+}
|
|
36
|
+```
|
|
37
|
+以上`GroupId`是如何加上的呢?是通过`IProfileService`这个接口加上的。
|
|
38
|
+
|
|
39
|
+```
|
|
40
|
+public class ProfielService : IProfileService
|
|
41
|
+{
|
|
42
|
+ private readonly IUserClaimsPrincipalFactory<ApplicationUser> _claimsFactory;
|
|
43
|
+ private readonly UserManager<ApplicationUser> _userManager;
|
|
44
|
+ private readonly IHostingEnvironment _hostingEnvironment;
|
|
45
|
+
|
|
46
|
+ //构造函数略
|
|
47
|
+
|
|
48
|
+ public async Task GetProfileDataAsync(ProfileDataRequest context)
|
|
49
|
+ {
|
|
50
|
+ if(_hostingEnvironment.IsDevelopment())
|
|
51
|
+ {
|
|
52
|
+ context.IssuedClaims.AddRange(context.Subject.Claims);
|
|
53
|
+ }
|
|
54
|
+ else
|
|
55
|
+ {
|
|
56
|
+ //获取用户的SubjectId
|
|
57
|
+ var sub = context.Subject.GetSubjectId();
|
|
58
|
+ //获取用户
|
|
59
|
+ var user = await _userManager.FindByIdAsync(sub);
|
|
60
|
+ //获取用户的ClaimsPrincipal
|
|
61
|
+ var claims = await _claimsFactory.CreateAsync(user);
|
|
62
|
+ //获取用户的所有claim
|
|
63
|
+ var claims = principal.Claims.ToList();
|
|
64
|
+ claims = claims.Where(claim => context.RequestedClaimTypes.Contains(claim.Type)).ToList();
|
|
65
|
+ claims.Add(new Claim("GroupId", user.GroupId));
|
|
66
|
+ context.IssuedClaims = claims;
|
|
67
|
+ }
|
|
68
|
+ }
|
|
69
|
+
|
|
70
|
+ public async Task IsActiveAsync(IsActiveContext context)
|
|
71
|
+ {
|
|
72
|
+ if(!_hostingEnvironment.IsDevelopment())
|
|
73
|
+ {
|
|
74
|
+ var sub = context.Subject.GetSubjectId();
|
|
75
|
+ var user = await _userManager.FindByIdAsync(sub);
|
|
76
|
+ context.IsActive = user != null;
|
|
77
|
+ }
|
|
78
|
+ }
|
|
79
|
+}
|
|
80
|
+```
|
|
81
|
+
|
|
82
|
+以上,在上下文中有一个类型为`ClaimsPrincipal`的`Subject`属性,从`ClaimsPricipal`的`GetSubjectId`方法可以获取string类型的编号,把这个编号交给`UserManager`就获取到用户,把用户交给`IUserClaimsPrincipalFactory`获取`ClaimsPrincipal`,在其中包含所有的`Claim`。也就是:
|
|
83
|
+
|
|
84
|
+- 从上下文获取SubjectId
|
|
85
|
+- 从`UserManager`获取User
|
|
86
|
+- 从`IUserClaimsPrincipalFactory`获取`ClaimsPrincipal`
|
|
87
|
+- 从`ClaimsPrincipal`获取`Claims`
|
|
88
|
+
|
|
89
|
+IdentityServer4管理着所有的`IdentityResource`
|
|
90
|
+```
|
|
91
|
+public static IEnumerable<IdentityResource> GetIdentityResources()
|
|
92
|
+{
|
|
93
|
+ return new List<IdentityResource>{
|
|
94
|
+ new IdentityResource.OpenId(),
|
|
95
|
+ new IdentityResource.Profile(),
|
|
96
|
+ new IdentityResource.Address(),
|
|
97
|
+ new IdentityResource.Phone(),
|
|
98
|
+ new IdentityResource.Email(),
|
|
99
|
+ new IdentityResource("roles","角色",new List<string>{JwtClaimTypes.Role})
|
|
100
|
+ };
|
|
101
|
+}
|
|
102
|
+```
|
|
103
|
+
|
|
104
|
+IdentityServer4管理着所有的`ApiResource`
|
|
105
|
+```
|
|
106
|
+public static IEnumerable<ApiResource> GetApiResources()
|
|
107
|
+{
|
|
108
|
+ return new List<ApiResource>{
|
|
109
|
+ new ApiResource("for_moble","")
|
|
110
|
+ };
|
|
111
|
+}
|
|
112
|
+```
|
|
113
|
+
|
|
114
|
+IdentityServer4管理着所有的`Client`
|
|
115
|
+```
|
|
116
|
+public static IEnumerable<Client> GetClients()
|
|
117
|
+{
|
|
118
|
+ return new List<Client>{
|
|
119
|
+ new Client{
|
|
120
|
+ ClientId = "",
|
|
121
|
+ ClientSecrets = new List<Secret>{},
|
|
122
|
+ AllowedGrantTYpes = GrantTypes.ResourceOwnerPassowrd,
|
|
123
|
+ AllowedScopes = new List<string>{
|
|
124
|
+ "for_mobile",
|
|
125
|
+ IdentityServerConstants.StandardScopes.OpenId,
|
|
126
|
+ IdentityServerConstants.StandardScopes.Profile,
|
|
127
|
+ IdentityServerConstants.StandardScopes.Address,
|
|
128
|
+ IdentityServerConstants.StandardScopes.Email,
|
|
129
|
+ IdentityServerConstants.StandardScopes.Phone,
|
|
130
|
+ "roles" //这里和IdentityResource中的对应
|
|
131
|
+ }
|
|
132
|
+ }
|
|
133
|
+ };
|
|
134
|
+}
|
|
135
|
+```
|
|
136
|
+
|
|
137
|
+种子数据是如何加上的呢?
|
|
138
|
+
|
|
139
|
+```
|
|
140
|
+var host = CreateWebHostBuilder(args).Build();
|
|
141
|
+using(var scope = host.Services.CreateScope())
|
|
142
|
+{
|
|
143
|
+ var services = scope.ServiceProvider;
|
|
144
|
+
|
|
145
|
+ var userManager = services.GetRequestService<UserManager<ApplicationUser>>();
|
|
146
|
+ var roleManager = services.GetRequiredServce<RoleManager<ApplicationRole>>();
|
|
147
|
+ SeedData.EnsureSeedData(servcies, userManager, roleManager).Wait();
|
|
148
|
+}
|
|
149
|
+
|
|
150
|
+host.Run();
|
|
151
|
+```
|
|
152
|
+
|
|
153
|
+接着往下走
|
|
154
|
+```
|
|
155
|
+public static async Task EnsureSeedData(IServiceProvider serviceProvider, UserManager<ApplicationUser> userManager, RoleManager<ApplicationRole> roleManager)
|
|
156
|
+{
|
|
157
|
+ //先保证所有的上下文数据库完成了迁移
|
|
158
|
+ serviceProvider.GetRequestSerivce<ApplicationDbContext>().Database.Migrate();
|
|
159
|
+ serviceProvider.GetRequredService<ConfigurationDbContext>().Database.Migrate();
|
|
160
|
+ serviceProvider.GetRequriedService<PersistedGrantDbContext>().Database.Migrate();
|
|
161
|
+
|
|
162
|
+ //确认和ConfigurationDbContext相关的几张表
|
|
163
|
+ if(!context.Clients.Any())
|
|
164
|
+ {
|
|
165
|
+
|
|
166
|
+ }
|
|
167
|
+
|
|
168
|
+ if(!context.IdentityResources.Any())
|
|
169
|
+ {
|
|
170
|
+
|
|
171
|
+ }
|
|
172
|
+
|
|
173
|
+ if(!context.ApiResources.Any())
|
|
174
|
+ {
|
|
175
|
+
|
|
176
|
+ }
|
|
177
|
+
|
|
178
|
+ //种子化用户数据
|
|
179
|
+ if(!serviceProvider.GetRequredService<ApplicationDbContext>.Users.Any)
|
|
180
|
+ {
|
|
181
|
+
|
|
182
|
+ }
|
|
183
|
+}
|
|
184
|
+```
|
|
185
|
+
|
|
186
|
+最后客户端需要配置认证服务器。
|
|
187
|
+
|
|
188
|
+```
|
|
189
|
+services.AddAuthentication();
|
|
190
|
+app.UseAuthentication();
|
|
191
|
+```
|
|
192
|
+
|
|
193
|
+# 看一个例子
|
|
194
|
+
|
|
195
|
+> 在stackoverflow上提到了这样一个问题:有一台IdentityServer4的验证服务器,有多个Client, 当在某个Client的API中重置密码产生新的token,这个token在其它client就不生效了。
|
|
196
|
+
|
|
197
|
+更新密码产生新的token的写法:
|
|
198
|
+
|
|
199
|
+```
|
|
200
|
+var token = await _userManager.GeneratePasswordResetTokenAsync(appUser);
|
|
201
|
+```
|
|
202
|
+
|
|
203
|
+IdentityServer4的配置:
|
|
204
|
+```
|
|
205
|
+var migrationAssembly = typeof(Startup).GetTypeInfo().Assembly.GetName().Name;
|
|
206
|
+
|
|
207
|
+services
|
|
208
|
+ .AddIdentity<ApplicationUser, IdentityRole>(options => {
|
|
209
|
+ options.Lockout.AllowedForNewUsers = true;
|
|
210
|
+ options.Lockout.DefaultLockoutTimeSpan = new System.TimeSpan(12,0,0);
|
|
211
|
+ options.Lockout.MaxFailedAccessAttempts = int.Parse(Configuration["MaxFailedAttempts"]);
|
|
212
|
+ })
|
|
213
|
+ .AddEntityFrameworkStores<ApplicationDbContext>()
|
|
214
|
+ .AddDefaultTOkenProviders();
|
|
215
|
+
|
|
216
|
+var builder = services.AddIdentityServer(options => {
|
|
217
|
+ options.Events.RaiseErrorEvents = true;
|
|
218
|
+ options.Events.RaiseInformationEvents = true;
|
|
219
|
+ options.Events.RaiseFailureEvents = true;
|
|
220
|
+ options.Events.RaiseSuccessEvents = ture;
|
|
221
|
+ options.Authentication.CookieSlidingExpiration = ture;
|
|
222
|
+})
|
|
223
|
+ .AddAspNetIdentity<ApplicationUser>()
|
|
224
|
+ .AddConfigurationStore(options => {
|
|
225
|
+ options.ConfigureDbContext = b =>
|
|
226
|
+ b.UseSqlServer(connectionString, sql => sql.MirationsAssembly(migrationAssembly));
|
|
227
|
+
|
|
228
|
+ options.DefaultSchemma = Globals.Some;
|
|
229
|
+ })
|
|
230
|
+ .AddOperationalStore(options => {
|
|
231
|
+ options.ConfigureDbContext = b =>
|
|
232
|
+ b.UseSqlServer(connectionString, sql => sql.MigrationAssembly(migrationAssembly));
|
|
233
|
+
|
|
234
|
+ options.DefaultSchema = "";
|
|
235
|
+ opitions.EnalbeTokenCleanup = true;
|
|
236
|
+ options.TokenCleanupInterval = 30;
|
|
237
|
+ })
|
|
238
|
+ .AddProfileServce<CustomProfileService>()
|
|
239
|
+ .AddSigninCredentialFromConfig();
|
|
240
|
+```
|
|
241
|
+
|
|
242
|
+在一个客户端的API的`Startup.cs`中
|
|
243
|
+```
|
|
244
|
+services.AddTransient<IUserStore<ApplicationUser>, UserStore<ApplicaitonUser, IdentityRole, ApplicationDBContext>>();
|
|
245
|
+services.AddTransient<IRoleStore<IdentityRole>, RoleStore<IdentityRole, ApplicatioDbContext>>();
|
|
246
|
+services.AddTransient<IPasswordHasher<ApplicationUser>, PasswordHasher<ApplicationUser>>();
|
|
247
|
+services.AddTransient<ILookupNormalizer, UpperInvariantLookupNomalizer>();
|
|
248
|
+servces.AddTransient<IdentityErrorDescriber>();
|
|
249
|
+
|
|
250
|
+var identityBuilder = new IdentityBuilder(typeof(Applicationuser), typeof(IdentityRole), services);
|
|
251
|
+identityBuilder.AddTokenProvider("Default", typeof(DataProtectorTokenProvider<ApplicationUser>));
|
|
252
|
+services.AddTransient<UserManager<ApplicationUser>>();
|
|
253
|
+```
|
|
254
|
+
|
|
255
|
+在底下的回复中,让所有客户端的API和IdentityServer4 实例使用同一个ASP.NET Core Data Protection。使用Redis缓存作为分布式缓存,让所有的客户端API和IdentityServer4在创建token的时候使用同样的key。在所有的`Startup.cs`中:
|
|
256
|
+
|
|
257
|
+```
|
|
258
|
+services.AddSession();
|
|
259
|
+services.Configure<RedisConfiguration>(Configuration.GetSection("redis"));//配置类全局公用
|
|
260
|
+services.AddDistributedRedisCache(options => {
|
|
261
|
+
|
|
262
|
+ options.Configuration = Configuration.GetValue<string>("redis:host");
|
|
263
|
+});//配置redis
|
|
264
|
+
|
|
265
|
+var redis = Connectionmultiplexer.Connect(Configuration.GetValue<string>("redis:host"));
|
|
266
|
+services.AddDataProtection()
|
|
267
|
+ .PersisteKeysToRedis(redis, "DataProtection-Keys")
|
|
268
|
+ .SetApplicationName();
|
|
269
|
+
|
|
270
|
+services.AddTransient<ICacheService, CacheService>();
|
|
271
|
+```
|
|
272
|
+
|
|
273
|
+在客户端API的请求管道中
|
|
274
|
+```
|
|
275
|
+app.UseAuthentication();
|
|
276
|
+app.UseSession();
|
|
277
|
+```
|
|
278
|
+
|
|
279
|
+在IdentityServer4的请求管道中
|
|
280
|
+```
|
|
281
|
+app.UseIdentityServer();
|
|
282
|
+app.UseSession();
|
|
283
|
+```
|
|
284
|
+
|
|
285
|
+也就是说在生成token的时候和DataProtection有关,让所有的客户端API和IdentityServer4使用同样的key是这里的解决思路。
|
|
286
|
+
|
|
287
|
+# 尝试
|
|
288
|
+
|
|
289
|
+在API的项目首先实现`IdentityUser`接口。
|
|
290
|
+
|
|
291
|
+```
|
|
292
|
+using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
|
293
|
+public class ApplicationUser : IdentityUser
|
|
294
|
+{
|
|
295
|
+ public bool IsAmin{get;set;}
|
|
296
|
+ public string DataEventRecourdsRole{get;set;}
|
|
297
|
+ public string SecuredFilesRole{get;set;}
|
|
298
|
+ public DateTime AccountExpires{get;set;}
|
|
299
|
+}
|
|
300
|
+```
|
|
301
|
+
|
|
302
|
+需要把新创建的`IdentityUser`放到DI容器中,并且配有上下文。
|
|
303
|
+
|
|
304
|
+```
|
|
305
|
+services.AddDbContext<ApplicationDbContext>(options => options.UseSqllite(Configuration.GetConnectionString("")));
|
|
306
|
+
|
|
307
|
+services.AddIdentity<ApplicationUser, IdentityRole>()
|
|
308
|
+ .AddEntityFrameworkStores<ApplicationDbContext>()
|
|
309
|
+ .AddDefaultTokenProviders();
|
|
310
|
+```
|
|
311
|
+
|
|
312
|
+在web中创建用户
|
|
313
|
+```
|
|
314
|
+
|
|
315
|
+private readonly UserManager<ApplicationUser> _userManager;
|
|
316
|
+private readonly SignInManager<ApplicationUser> _signInManager;
|
|
317
|
+
|
|
318
|
+[HttpPost]
|
|
319
|
+[AllowAnonymous]
|
|
320
|
+[ValidateAntiForgeryToken]
|
|
321
|
+public async Task<IActioinResult> Register(RegisterViewModel model, string returnUrl)
|
|
322
|
+{
|
|
323
|
+ ViewData["ReturnUrl"] = returnUrl;
|
|
324
|
+ if(ModelState.IsValid)
|
|
325
|
+ {
|
|
326
|
+ var dataEventsRole = "dataEventRecords.user";
|
|
327
|
+ var secureFilesRole = "securedFiles.user";
|
|
328
|
+ if(model.IsAdmin)
|
|
329
|
+ {
|
|
330
|
+ dataEventsRole = "dataEventRecourds.admin";
|
|
331
|
+ securedFilesRole = "securedFiles.admin";
|
|
332
|
+ }
|
|
333
|
+
|
|
334
|
+ var user = new ApplicationUser{
|
|
335
|
+ UserName = model.Email,
|
|
336
|
+ Email = model.Email,
|
|
337
|
+ IsAdmin = model.IsAdmin,
|
|
338
|
+ DataEventRecordsRole = dataEventsRole,
|
|
339
|
+ SecuredFilesRole = securedFilesRole,
|
|
340
|
+ AccountExpres = DateTime.UtcNow.AddDays(7.0)
|
|
341
|
+ };
|
|
342
|
+
|
|
343
|
+ var result = await _userManager.CreateAsync(user, model.Password);
|
|
344
|
+ if(result.Succeeded)
|
|
345
|
+ {
|
|
346
|
+ await _signInManager.SignInAsync(user, isPersistent:false);
|
|
347
|
+ _logger.LogInformation();
|
|
348
|
+ return RedirectToLocal(returnUrl);
|
|
349
|
+ }
|
|
350
|
+ AddErrors(result);
|
|
351
|
+ }
|
|
352
|
+ return View(model);
|
|
353
|
+}
|
|
354
|
+```
|
|
355
|
+
|
|
356
|
+在`ApplicationUser`中添加的属性如果要开放出去给到其它的客户端,需要通过`IProfileServce`。
|
|
357
|
+
|
|
358
|
+```
|
|
359
|
+public class IdentityWithAdditionalClaimsProfileService : IProfileService
|
|
360
|
+{
|
|
361
|
+ private readonly IUserClaimsPrincipalFactory<ApplicationUser> _claimsFactory;
|
|
362
|
+ private readonly UserManager<ApplicationUser> _userManager;
|
|
363
|
+
|
|
364
|
+ public async Task GetPfoileDataAsync(ProfileDataRequestContext context)
|
|
365
|
+ {
|
|
366
|
+ var sub = context.Subject.GetSubjectId();
|
|
367
|
+ var user = await _userManager.FindByIdAsync(sub);
|
|
368
|
+ var principal = await _claimsFactory.CreateAsync(user);
|
|
369
|
+
|
|
370
|
+ var claims = principal.Claims.ToList();
|
|
371
|
+ claims = claims.Where(claim => context.RequestedClaimTypes.Containes(claim.Type)).ToList();
|
|
372
|
+ claims.Add(new Claim(JwtClaimTypes.GivenName, user.UserName));
|
|
373
|
+
|
|
374
|
+ if(user.IsAdmin)
|
|
375
|
+ {
|
|
376
|
+ claims.Add(new Claim(JwtClimTypes.Role, "admin"));
|
|
377
|
+ }
|
|
378
|
+ else
|
|
379
|
+ {
|
|
380
|
+ claims.Add(new Claim(JwtClaimTypes.Role,"user"));
|
|
381
|
+ }
|
|
382
|
+
|
|
383
|
+ if(user.DataEventRecordsRole == "dataEventRecords.admin")
|
|
384
|
+ {
|
|
385
|
+ claims.Add(new Cliam(JwtClaimTypes.Role, "dataEventRecords.admin"));
|
|
386
|
+ cliams.Add(new Claim(JwtClaimTypes.Role, "dataEventRecords.user"));
|
|
387
|
+ claims.Add(new Claim(JwtClaimTypes.Role, "dataEventRecords"));
|
|
388
|
+ claims.Add(new Claim(JwtClaimTypes.Scoe, "dataEventRecords"));
|
|
389
|
+ }
|
|
390
|
+ else
|
|
391
|
+ {
|
|
392
|
+
|
|
393
|
+ }
|
|
394
|
+ claims.Add(new Claim(IdentityServerConstants.StandardScopes.Email, user.Email));
|
|
395
|
+ context.IssuedClaims = claims;
|
|
396
|
+
|
|
397
|
+ }
|
|
398
|
+}
|
|
399
|
+```
|
|
400
|
+
|
|
401
|
+以上在`ApplicationUser`中的属性值被加到了claims中,在`Startup`中也可以加适当的policy.
|
|
402
|
+
|
|
403
|
+```
|
|
404
|
+services.AddAuthorization(options => {
|
|
405
|
+ options.AddPolicy("dataEventRecordsAdmin", policyAdmin => {
|
|
406
|
+ policyAdmin.RequireClaim("role","dataEventRecords.admin")
|
|
407
|
+ })
|
|
408
|
+})
|
|
409
|
+```
|
|
410
|
+
|
|
411
|
+最后Policy被用到控制器中。
|
|
412
|
+```
|
|
413
|
+[Authorize("policyname")]
|
|
414
|
+public class SomeController : Controller
|
|
415
|
+```
|
|
416
|
+
|
|
417
|
+接下来提供一个接口给外界调用。
|
|
418
|
+[Authorize]
|
|
419
|
+[Produces("application/json")]
|
|
420
|
+[Route(api/UserManagement)]
|
|
421
|
+public class UserManagementController : Controller
|
|
422
|
+{
|
|
423
|
+ private readonly ApplicationDbContext _context;
|
|
424
|
+
|
|
425
|
+ public IActionResult Get()
|
|
426
|
+ {
|
|
427
|
+ var users = _context.Users.ToList();
|
|
428
|
+ var result = new List<UserDto>();
|
|
429
|
+ return Ok(result);
|
|
430
|
+ }
|
|
431
|
+
|
|
432
|
+ public void Put(string id, [FromBody]UserDto userDto)
|
|
433
|
+ {
|
|
434
|
+ var user = _context.Users.First(t=>t.Id == id);
|
|
435
|
+ user.IsAdmin = userDto.IsAdmin;
|
|
436
|
+ if(userDto.IsActive)
|
|
437
|
+ {
|
|
438
|
+ if(user.AccountExpres < DateTime.UtcNow)
|
|
439
|
+ {
|
|
440
|
+ user.AccountExpres = DateTime.UtcNow.AddDays(7.0);
|
|
441
|
+ }
|
|
442
|
+ }
|
|
443
|
+ else
|
|
444
|
+ {
|
|
445
|
+ user.AccountExpires = new DateTime();
|
|
446
|
+ }
|
|
447
|
+
|
|
448
|
+ _context.Users.Update(user);
|
|
449
|
+ _context.SaveChanges();
|
|
450
|
+ }
|
|
451
|
+}
|