Add SecurityStampValidator
This commit is contained in:
parent
e74496c079
commit
fc53503d1d
|
|
@ -2,12 +2,14 @@ using Microsoft.AspNet.Builder;
|
|||
using Microsoft.AspNet.Diagnostics;
|
||||
using Microsoft.AspNet.Http;
|
||||
using Microsoft.AspNet.Identity;
|
||||
using Microsoft.AspNet.Identity.Authentication;
|
||||
using Microsoft.AspNet.Routing;
|
||||
using Microsoft.AspNet.Security.Cookies;
|
||||
using Microsoft.Data.Entity;
|
||||
using Microsoft.Framework.ConfigurationModel;
|
||||
using Microsoft.Framework.DependencyInjection;
|
||||
using IdentitySample.Models;
|
||||
using System;
|
||||
|
||||
namespace IdentitySamples
|
||||
{
|
||||
|
|
@ -65,6 +67,11 @@ namespace IdentitySamples
|
|||
{
|
||||
AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie,
|
||||
LoginPath = new PathString("/Account/Login"),
|
||||
Notifications = new CookieAuthenticationNotifications
|
||||
{
|
||||
OnValidateIdentity = SecurityStampValidator.OnValidateIdentity<ApplicationUser>(
|
||||
validateInterval: TimeSpan.FromMinutes(0))
|
||||
}
|
||||
});
|
||||
|
||||
app.UseTwoFactorSignInCookies();
|
||||
|
|
|
|||
|
|
@ -20,10 +20,10 @@
|
|||
<Content Include="project.json" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="SecurityStampValidator.cs" />
|
||||
<Compile Include="HttpAuthenticationManager.cs" />
|
||||
<Compile Include="IdentityBuilderExtensions.cs" />
|
||||
<Compile Include="BuilderExtensions.cs" />
|
||||
<Compile Include="IdentityExtensions.cs" />
|
||||
</ItemGroup>
|
||||
<Import Project="$(VSToolsPath)\AspNet\Microsoft.Web.AspNet.targets" Condition="'$(VSToolsPath)' != ''" />
|
||||
</Project>
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Principal;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNet.Security.Cookies;
|
||||
using Microsoft.Framework.DependencyInjection;
|
||||
|
||||
namespace Microsoft.AspNet.Identity.Authentication
|
||||
{
|
||||
/// <summary>
|
||||
/// Static helper class used to configure a CookieAuthenticationProvider to validate a cookie against a user's security
|
||||
/// stamp
|
||||
/// </summary>
|
||||
public static class SecurityStampValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Can be used as the ValidateIdentity method for a CookieAuthenticationProvider which will check a user's security
|
||||
/// stamp after validateInterval
|
||||
/// Rejects the identity if the stamp changes, and otherwise will call regenerateIdentity to sign in a new
|
||||
/// ClaimsIdentity
|
||||
/// </summary>
|
||||
/// <typeparam name="TUser"></typeparam>
|
||||
/// <param name="validateInterval"></param>
|
||||
/// <param name="regenerateIdentity"></param>
|
||||
/// <returns></returns>
|
||||
public static Func<CookieValidateIdentityContext, Task> OnValidateIdentity<TUser>(
|
||||
TimeSpan validateInterval)
|
||||
where TUser : class
|
||||
{
|
||||
return OnValidateIdentity<TUser>(validateInterval, id => id.GetUserId());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Can be used as the ValidateIdentity method for a CookieAuthenticationProvider which will check a user's security
|
||||
/// stamp after validateInterval
|
||||
/// Rejects the identity if the stamp changes, and otherwise will call regenerateIdentity to sign in a new
|
||||
/// ClaimsIdentity
|
||||
/// </summary>
|
||||
/// <typeparam name="TUser"></typeparam>
|
||||
/// <typeparam name="TKey"></typeparam>
|
||||
/// <param name="validateInterval"></param>
|
||||
/// <param name="regenerateIdentityCallback"></param>
|
||||
/// <param name="getUserIdCallback"></param>
|
||||
/// <returns></returns>
|
||||
public static Func<CookieValidateIdentityContext, Task> OnValidateIdentity<TUser>(
|
||||
TimeSpan validateInterval,
|
||||
Func<ClaimsIdentity, string> getUserIdCallback)
|
||||
where TUser : class
|
||||
{
|
||||
return async context =>
|
||||
{
|
||||
var currentUtc = DateTimeOffset.UtcNow;
|
||||
if (context.Options != null && context.Options.SystemClock != null)
|
||||
{
|
||||
currentUtc = context.Options.SystemClock.UtcNow;
|
||||
}
|
||||
var issuedUtc = context.Properties.IssuedUtc;
|
||||
|
||||
// Only validate if enough time has elapsed
|
||||
var validate = (issuedUtc == null);
|
||||
if (issuedUtc != null)
|
||||
{
|
||||
var timeElapsed = currentUtc.Subtract(issuedUtc.Value);
|
||||
validate = timeElapsed > validateInterval;
|
||||
}
|
||||
if (validate)
|
||||
{
|
||||
var manager = context.HttpContext.ApplicationServices.GetService<SignInManager<TUser>>();
|
||||
var userId = getUserIdCallback(context.Identity);
|
||||
var user = await manager.ValidateSecurityStamp(context.Identity, userId);
|
||||
if (user != null)
|
||||
{
|
||||
bool isPersistent = false;
|
||||
if (context.Properties != null)
|
||||
{
|
||||
isPersistent = context.Properties.IsPersistent;
|
||||
}
|
||||
await manager.SignInAsync(user, isPersistent);
|
||||
}
|
||||
else
|
||||
{
|
||||
context.RejectIdentity();
|
||||
manager.SignOut();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace System.Security.Principal
|
||||
{
|
||||
/// <summary>
|
||||
/// Extensions making it easier to get the user name/user id claims off of an identity
|
||||
/// </summary>
|
||||
public static class ClaimsIdentityExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Return the user name using the UserNameClaimType
|
||||
/// </summary>
|
||||
/// <param name="identity"></param>
|
||||
/// <returns></returns>
|
||||
public static string GetUserName(this IIdentity identity)
|
||||
{
|
||||
if (identity == null)
|
||||
{
|
||||
throw new ArgumentNullException("identity");
|
||||
}
|
||||
var ci = identity as ClaimsIdentity;
|
||||
return ci != null ? ci.FindFirstValue(ClaimsIdentity.DefaultNameClaimType) : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return the user id using the UserIdClaimType
|
||||
/// </summary>
|
||||
/// <param name="identity"></param>
|
||||
/// <returns></returns>
|
||||
public static string GetUserId(this IIdentity identity)
|
||||
{
|
||||
if (identity == null)
|
||||
{
|
||||
throw new ArgumentNullException("identity");
|
||||
}
|
||||
var ci = identity as ClaimsIdentity;
|
||||
return ci != null ? ci.FindFirstValue(ClaimTypes.NameIdentifier) : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return the claim value for the first claim with the specified type if it exists, null otherwise
|
||||
/// </summary>
|
||||
/// <param name="identity"></param>
|
||||
/// <param name="claimType"></param>
|
||||
/// <returns></returns>
|
||||
public static string FindFirstValue(this ClaimsIdentity identity, string claimType)
|
||||
{
|
||||
if (identity == null)
|
||||
{
|
||||
throw new ArgumentNullException("identity");
|
||||
}
|
||||
var claim = identity.FindFirst(claimType);
|
||||
return claim != null ? claim.Value : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@
|
|||
<Content Include="Resources.resx" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="ClaimsIdentityExtensions.cs" />
|
||||
<Compile Include="ClaimsIdentityFactory.cs" />
|
||||
<Compile Include="ClaimsIdentityOptions.cs" />
|
||||
<Compile Include="Crypto.cs" />
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
using System;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Principal;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.AspNet.Identity
|
||||
|
|
@ -51,12 +52,34 @@ namespace Microsoft.AspNet.Identity
|
|||
}
|
||||
|
||||
// TODO: Should this be async?
|
||||
public void SignOut()
|
||||
public virtual void SignOut()
|
||||
{
|
||||
// REVIEW: need a new home for this option config?
|
||||
AuthenticationManager.SignOut(UserManager.Options.ClaimsIdentity.AuthenticationType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the claims identity has a security stamp matching the users
|
||||
/// Returns the user if it matches, null otherwise
|
||||
/// </summary>
|
||||
/// <param name="identity"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <returns></returns>
|
||||
public virtual async Task<TUser> ValidateSecurityStamp(ClaimsIdentity identity, string userId)
|
||||
{
|
||||
var user = await UserManager.FindByIdAsync(userId);
|
||||
if (user != null && UserManager.SupportsUserSecurityStamp)
|
||||
{
|
||||
var securityStamp =
|
||||
identity.FindFirstValue(UserManager.Options.ClaimsIdentity.SecurityStampClaimType);
|
||||
if (securityStamp == await UserManager.GetSecurityStampAsync(user))
|
||||
{
|
||||
return user;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public virtual async Task<SignInStatus> PasswordSignInAsync(string userName, string password,
|
||||
bool isPersistent, bool shouldLockout)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="HttpSignInTest.cs" />
|
||||
<Compile Include="IdentityExtensionsTest.cs" />
|
||||
<Compile Include="SecurityStampValidatorTest.cs" />
|
||||
</ItemGroup>
|
||||
<Import Project="$(VSToolsPath)\AspNet\Microsoft.Web.AspNet.targets" Condition="'$(VSToolsPath)' != ''" />
|
||||
</Project>
|
||||
</Project>
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNet.Http;
|
||||
using Microsoft.AspNet.Http.Security;
|
||||
using Microsoft.AspNet.Identity.Test;
|
||||
using Microsoft.AspNet.Security;
|
||||
using Microsoft.AspNet.Security.Cookies;
|
||||
using Microsoft.Framework.DependencyInjection;
|
||||
using Microsoft.Framework.DependencyInjection.Fallback;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Microsoft.AspNet.Identity.Authentication.Test
|
||||
{
|
||||
public class SecurityStampTest
|
||||
{
|
||||
[Fact]
|
||||
public async Task OnValidateIdentityThrowsWithEmptyServiceCollection()
|
||||
{
|
||||
var httpContext = new Mock<HttpContext>();
|
||||
httpContext.Setup(c => c.ApplicationServices).Returns(new ServiceCollection().BuildServiceProvider());
|
||||
var id = new ClaimsIdentity(DefaultAuthenticationTypes.ApplicationCookie);
|
||||
var ticket = new AuthenticationTicket(id, new AuthenticationProperties { IssuedUtc = DateTimeOffset.UtcNow });
|
||||
var context = new CookieValidateIdentityContext(httpContext.Object, ticket, new CookieAuthenticationOptions());
|
||||
await Assert.ThrowsAsync<Exception>(() => SecurityStampValidator.OnValidateIdentity<IdentityUser>(TimeSpan.Zero).Invoke(context));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task OnValidateIdentityTestSuccess(bool isPersistent)
|
||||
{
|
||||
var user = new IdentityUser("test");
|
||||
var httpContext = new Mock<HttpContext>();
|
||||
var userManager = MockHelpers.MockUserManager<IdentityUser>();
|
||||
var authManager = new Mock<IAuthenticationManager>();
|
||||
var claimsManager = new Mock<IClaimsIdentityFactory<IdentityUser>>();
|
||||
var signInManager = new Mock<SignInManager<IdentityUser>>(userManager.Object,
|
||||
authManager.Object, claimsManager.Object);
|
||||
signInManager.Setup(s => s.ValidateSecurityStamp(It.IsAny<ClaimsIdentity>(), user.Id)).ReturnsAsync(user).Verifiable();
|
||||
signInManager.Setup(s => s.SignInAsync(user, isPersistent)).Returns(Task.FromResult(0)).Verifiable();
|
||||
var services = new ServiceCollection();
|
||||
services.AddInstance(signInManager.Object);
|
||||
httpContext.Setup(c => c.ApplicationServices).Returns(services.BuildServiceProvider());
|
||||
var id = new ClaimsIdentity(DefaultAuthenticationTypes.ApplicationCookie);
|
||||
id.AddClaim(new Claim(ClaimTypes.NameIdentifier, user.Id));
|
||||
|
||||
var ticket = new AuthenticationTicket(id, new AuthenticationProperties { IssuedUtc = DateTimeOffset.UtcNow, IsPersistent = isPersistent });
|
||||
var context = new CookieValidateIdentityContext(httpContext.Object, ticket, new CookieAuthenticationOptions());
|
||||
Assert.NotNull(context.Properties);
|
||||
Assert.NotNull(context.Options);
|
||||
Assert.NotNull(context.Identity);
|
||||
await
|
||||
SecurityStampValidator.OnValidateIdentity<IdentityUser>(TimeSpan.Zero).Invoke(context);
|
||||
Assert.NotNull(context.Identity);
|
||||
signInManager.VerifyAll();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnValidateIdentityRejectsWhenValidateSecurityStampFails()
|
||||
{
|
||||
var user = new IdentityUser("test");
|
||||
var httpContext = new Mock<HttpContext>();
|
||||
var userManager = MockHelpers.MockUserManager<IdentityUser>();
|
||||
var authManager = new Mock<IAuthenticationManager>();
|
||||
var claimsManager = new Mock<IClaimsIdentityFactory<IdentityUser>>();
|
||||
var signInManager = new Mock<SignInManager<IdentityUser>>(userManager.Object,
|
||||
authManager.Object, claimsManager.Object);
|
||||
signInManager.Setup(s => s.ValidateSecurityStamp(It.IsAny<ClaimsIdentity>(), user.Id)).ReturnsAsync(null).Verifiable();
|
||||
var services = new ServiceCollection();
|
||||
services.AddInstance(signInManager.Object);
|
||||
httpContext.Setup(c => c.ApplicationServices).Returns(services.BuildServiceProvider());
|
||||
var id = new ClaimsIdentity(DefaultAuthenticationTypes.ApplicationCookie);
|
||||
id.AddClaim(new Claim(ClaimTypes.NameIdentifier, user.Id));
|
||||
|
||||
var ticket = new AuthenticationTicket(id, new AuthenticationProperties { IssuedUtc = DateTimeOffset.UtcNow });
|
||||
var context = new CookieValidateIdentityContext(httpContext.Object, ticket, new CookieAuthenticationOptions());
|
||||
Assert.NotNull(context.Properties);
|
||||
Assert.NotNull(context.Options);
|
||||
Assert.NotNull(context.Identity);
|
||||
await
|
||||
SecurityStampValidator.OnValidateIdentity<IdentityUser>(TimeSpan.Zero).Invoke(context);
|
||||
Assert.Null(context.Identity);
|
||||
signInManager.VerifyAll();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnValidateIdentityRejectsWhenNoIssuedUtc()
|
||||
{
|
||||
var user = new IdentityUser("test");
|
||||
var httpContext = new Mock<HttpContext>();
|
||||
var userManager = MockHelpers.MockUserManager<IdentityUser>();
|
||||
var authManager = new Mock<IAuthenticationManager>();
|
||||
var claimsManager = new Mock<IClaimsIdentityFactory<IdentityUser>>();
|
||||
var signInManager = new Mock<SignInManager<IdentityUser>>(userManager.Object,
|
||||
authManager.Object, claimsManager.Object);
|
||||
signInManager.Setup(s => s.ValidateSecurityStamp(It.IsAny<ClaimsIdentity>(), user.Id)).ReturnsAsync(null).Verifiable();
|
||||
var services = new ServiceCollection();
|
||||
services.AddInstance(signInManager.Object);
|
||||
httpContext.Setup(c => c.ApplicationServices).Returns(services.BuildServiceProvider());
|
||||
var id = new ClaimsIdentity(DefaultAuthenticationTypes.ApplicationCookie);
|
||||
id.AddClaim(new Claim(ClaimTypes.NameIdentifier, user.Id));
|
||||
|
||||
var ticket = new AuthenticationTicket(id, new AuthenticationProperties());
|
||||
var context = new CookieValidateIdentityContext(httpContext.Object, ticket, new CookieAuthenticationOptions());
|
||||
Assert.NotNull(context.Properties);
|
||||
Assert.NotNull(context.Options);
|
||||
Assert.NotNull(context.Identity);
|
||||
await
|
||||
SecurityStampValidator.OnValidateIdentity<IdentityUser>(TimeSpan.Zero).Invoke(context);
|
||||
Assert.Null(context.Identity);
|
||||
signInManager.VerifyAll();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnValidateIdentityDoesNotRejectsWhenNotExpired()
|
||||
{
|
||||
var user = new IdentityUser("test");
|
||||
var httpContext = new Mock<HttpContext>();
|
||||
var userManager = MockHelpers.MockUserManager<IdentityUser>();
|
||||
var authManager = new Mock<IAuthenticationManager>();
|
||||
var claimsManager = new Mock<IClaimsIdentityFactory<IdentityUser>>();
|
||||
var signInManager = new Mock<SignInManager<IdentityUser>>(userManager.Object,
|
||||
authManager.Object, claimsManager.Object);
|
||||
signInManager.Setup(s => s.ValidateSecurityStamp(It.IsAny<ClaimsIdentity>(), user.Id)).Throws(new Exception("Shouldn't be called"));
|
||||
signInManager.Setup(s => s.SignInAsync(user, false)).Throws(new Exception("Shouldn't be called"));
|
||||
var services = new ServiceCollection();
|
||||
services.AddInstance(signInManager.Object);
|
||||
httpContext.Setup(c => c.ApplicationServices).Returns(services.BuildServiceProvider());
|
||||
var id = new ClaimsIdentity(DefaultAuthenticationTypes.ApplicationCookie);
|
||||
id.AddClaim(new Claim(ClaimTypes.NameIdentifier, user.Id));
|
||||
|
||||
var ticket = new AuthenticationTicket(id, new AuthenticationProperties { IssuedUtc = DateTimeOffset.UtcNow });
|
||||
var context = new CookieValidateIdentityContext(httpContext.Object, ticket, new CookieAuthenticationOptions());
|
||||
Assert.NotNull(context.Properties);
|
||||
Assert.NotNull(context.Options);
|
||||
Assert.NotNull(context.Identity);
|
||||
await
|
||||
SecurityStampValidator.OnValidateIdentity<IdentityUser>(TimeSpan.FromDays(1)).Invoke(context);
|
||||
Assert.NotNull(context.Identity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Principal;
|
||||
using Xunit;
|
||||
|
||||
namespace Microsoft.AspNet.Identity.Test
|
||||
{
|
||||
public class ClaimsIdentityExtensionsTest
|
||||
{
|
||||
public const string ExternalAuthenticationType = "TestExternalAuth";
|
||||
|
||||
[Fact]
|
||||
public void IdentityNullCheckTest()
|
||||
{
|
||||
IIdentity identity = null;
|
||||
Assert.Throws<ArgumentNullException>("identity", () => identity.GetUserId());
|
||||
Assert.Throws<ArgumentNullException>("identity", () => identity.GetUserName());
|
||||
ClaimsIdentity claimsIdentity = null;
|
||||
Assert.Throws<ArgumentNullException>("identity", () => claimsIdentity.FindFirstValue(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IdentityNullIfNotClaimsIdentityTest()
|
||||
{
|
||||
IIdentity identity = new TestIdentity();
|
||||
Assert.Null(identity.GetUserId());
|
||||
Assert.Null(identity.GetUserName());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserNameAndIdTest()
|
||||
{
|
||||
var id = CreateTestExternalIdentity();
|
||||
Assert.Equal("NameIdentifier", id.GetUserId());
|
||||
Assert.Equal("Name", id.GetUserName());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IdentityExtensionsFindFirstValueNullIfUnknownTest()
|
||||
{
|
||||
var id = CreateTestExternalIdentity();
|
||||
Assert.Null(id.FindFirstValue("bogus"));
|
||||
}
|
||||
|
||||
private static ClaimsIdentity CreateTestExternalIdentity()
|
||||
{
|
||||
return new ClaimsIdentity(
|
||||
new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, "NameIdentifier", null, ExternalAuthenticationType),
|
||||
new Claim(ClaimTypes.Name, "Name")
|
||||
},
|
||||
ExternalAuthenticationType);
|
||||
}
|
||||
|
||||
private class TestIdentity : IIdentity
|
||||
{
|
||||
public string AuthenticationType
|
||||
{
|
||||
get { throw new NotImplementedException(); }
|
||||
}
|
||||
|
||||
public bool IsAuthenticated
|
||||
{
|
||||
get { throw new NotImplementedException(); }
|
||||
}
|
||||
|
||||
public string Name
|
||||
{
|
||||
get { throw new NotImplementedException(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@
|
|||
<ItemGroup>
|
||||
<Compile Include="ClaimsIdentityFactoryTest.cs" />
|
||||
<Compile Include="IdentityBuilderTest.cs" />
|
||||
<Compile Include="ClaimsIdentityExtensionsTest.cs" />
|
||||
<Compile Include="IdentityOptionsTest.cs" />
|
||||
<Compile Include="IdentityResultTest.cs" />
|
||||
<Compile Include="NoopRoleStore.cs" />
|
||||
|
|
|
|||
Loading…
Reference in New Issue