diff --git a/aliyun-net-sdk-core.Tests/OIDCToken.txt b/aliyun-net-sdk-core.Tests/OIDCToken.txt new file mode 100644 index 0000000000..fc99aa3ef6 --- /dev/null +++ b/aliyun-net-sdk-core.Tests/OIDCToken.txt @@ -0,0 +1 @@ +OIDCToken \ No newline at end of file diff --git a/aliyun-net-sdk-core.Tests/Units/Auth/OIDCCredentialsProvider.cs b/aliyun-net-sdk-core.Tests/Units/Auth/OIDCCredentialsProvider.cs new file mode 100644 index 0000000000..1e7fcaf3e5 --- /dev/null +++ b/aliyun-net-sdk-core.Tests/Units/Auth/OIDCCredentialsProvider.cs @@ -0,0 +1,98 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +using System; + +using Aliyun.Acs.Core.Auth; +using Aliyun.Acs.Core.Exceptions; +using Xunit; + +namespace Aliyun.Acs.Core.Tests.Units.Auth +{ + public class OIDCCredentialsProviderTest + { + [Fact] + public void TestConstructor() + { + var filePath = TestHelper.GetOIDCTokenFilePath(); + var provider = new OIDCCredentialsProvider("roleArn", "providerArn", filePath, "sessionname", null); + Assert.NotNull(provider); + Assert.Equal("roleArn", provider.RoleArn); + provider.RoleArn = "new-roleArn"; + Assert.Equal("new-roleArn", provider.RoleArn); + + Assert.Equal("providerArn", provider.OIDCProviderArn); + provider.OIDCProviderArn = "new-providerArn"; + Assert.Equal("new-providerArn", provider.OIDCProviderArn); + + Assert.EndsWith("OIDCToken.txt", provider.OIDCTokenFilePath); + provider.OIDCTokenFilePath = "/tmp/oidctoken"; + Assert.Equal("/tmp/oidctoken", provider.OIDCTokenFilePath); + + Assert.Equal("sessionname", provider.RoleSessionName); + provider.RoleSessionName = "new-sessionname"; + Assert.Equal("new-sessionname", provider.RoleSessionName); + + Assert.Null(provider.Policy); + provider.Policy = "{}"; + Assert.Equal("{}", provider.Policy); + } + + [Fact] + public void TestEmptyRoleArn() + { + var ex = Assert.Throws(() => { new OIDCCredentialsProvider(null, null, null, null, null); }); + Assert.StartsWith("roleArn does not exist and env ALIBABA_CLOUD_ROLE_ARN is null.", ex.Message); + + ex = Assert.Throws(() => { new OIDCCredentialsProvider("roleArn", null, null, null, null); }); + Assert.StartsWith("OIDCProviderArn does not exist and env ALIBABA_CLOUD_OIDC_PROVIDER_ARN is null.", ex.Message); + + ex = Assert.Throws(() => { new OIDCCredentialsProvider("roleArn", "providerArn", null, null, null); }); + Assert.StartsWith("OIDCTokenFilePath does not exist and env ALIBABA_CLOUD_OIDC_TOKEN_FILE is null.", ex.Message); + + var provider = new OIDCCredentialsProvider("roleArn", "providerArn", "", "sessionname", null); + var notExistEx = Assert.Throws(() => { provider.GetCredentials(); }); + Assert.Equal("OIDCTokenFilePath does not exist.", notExistEx.Message); + } + + [Fact] + public void TestRealGetCredentials() + { + var filePath = TestHelper.GetOIDCTokenFilePath(); + var provider = new OIDCCredentialsProvider("roleArn", "providerArn", filePath, null, "us-west-1"); + Assert.NotNull(provider); + var ex = Assert.Throws(() => { provider.GetCredentials(); }); + Assert.Contains("Parameter OIDCProviderArn is not valid", ex.Message); + } + + [Fact] + public void TestParseCredentials() + { + var ex = Assert.Throws(() => { OIDCCredentialsProvider.ParseCredentials("{}", 3600L); }); + Assert.Equal("AssumeRoleWithOIDC failed: {}", ex.Message); + ex = Assert.Throws(() => { OIDCCredentialsProvider.ParseCredentials("", 3600L); }); + Assert.Equal("Invalid JSON", ex.Message); + var credentials =OIDCCredentialsProvider.ParseCredentials("{\"Credentials\": {\"AccessKeyId\": \"sts_ak_id\",\"AccessKeySecret\": \"sts_ak_secret\", \"SecurityToken\": \"securitytoken\",\"Expiration\": \"2021-10-20T04:27:09Z\"}}", 3600L); + Assert.Equal("sts_ak_id", credentials.GetAccessKeyId()); + Assert.Equal("sts_ak_secret", credentials.GetAccessKeySecret()); + Assert.Equal("securitytoken", credentials.GetSessionToken()); + Assert.False(credentials.WillSoonExpire()); + } + } +} diff --git a/aliyun-net-sdk-core.Tests/Units/Http/HttpRequest.cs b/aliyun-net-sdk-core.Tests/Units/Http/HttpRequest.cs index 20ef2730b3..4d920c0f32 100644 --- a/aliyun-net-sdk-core.Tests/Units/Http/HttpRequest.cs +++ b/aliyun-net-sdk-core.Tests/Units/Http/HttpRequest.cs @@ -17,9 +17,11 @@ * under the License. */ +using System; using System.Collections.Generic; using System.Net; using System.Text; +using System.Text.RegularExpressions; using Aliyun.Acs.Core.Http; using Aliyun.Acs.Core.Transform; @@ -30,6 +32,16 @@ namespace Aliyun.Acs.Core.Tests.Units.Http { public class HttpRequestTest { + [Fact] + public void CredentialsHeadersTest() + { + var instance = new HttpRequest("https://testurl"); + instance.Headers.Add("User-Agent", UserAgent.Resolve(null, null)); + var pattern = @"^Alibaba Cloud \(.+?\) \S+ Core/\S+$"; + var isMatch = Regex.IsMatch(instance.Headers["User-Agent"], pattern); + Assert.True(isMatch); + } + [Fact] public void ConnectTimeoutTest() { diff --git a/aliyun-net-sdk-core.Tests/Units/TestHelper.cs b/aliyun-net-sdk-core.Tests/Units/TestHelper.cs index 607d82db1b..c1101a368d 100644 --- a/aliyun-net-sdk-core.Tests/Units/TestHelper.cs +++ b/aliyun-net-sdk-core.Tests/Units/TestHelper.cs @@ -30,6 +30,14 @@ public class TestHelper private static readonly string slash = EnvironmentUtil.GetOSSlash(); private static readonly string homePath = EnvironmentUtil.GetHomePath(); + private static readonly string Slash = Environment.OSVersion.Platform == PlatformID.Unix ? "/" : "\\"; + private static readonly string HomePath = Environment.CurrentDirectory; + + public static string GetOIDCTokenFilePath() + { + return HomePath + Slash + "OIDCToken.txt"; + } + public static void RemoveEnvironmentValue() { Environment.SetEnvironmentVariable("ALIBABA_CLOUD_ACCESS_KEY_ID", null); diff --git a/aliyun-net-sdk-core.Tests/Units/Utils/ParameterHelper.cs b/aliyun-net-sdk-core.Tests/Units/Utils/ParameterHelper.cs index f81a1d8b9a..7d5167be76 100644 --- a/aliyun-net-sdk-core.Tests/Units/Utils/ParameterHelper.cs +++ b/aliyun-net-sdk-core.Tests/Units/Utils/ParameterHelper.cs @@ -18,6 +18,7 @@ */ using System; +using System.Collections.Generic; using System.Text; using Aliyun.Acs.Core.Http; @@ -115,5 +116,20 @@ public void StringToMethodType() Assert.True(MethodType.OPTIONS == ParameterHelper.StringToMethodType("options")); Assert.True(null == ParameterHelper.StringToMethodType("test")); } + + [Fact] + public void GetFormDataTest() + { + var parameters = new Dictionary + { + { "key", "value" } + }; + + var output = ParameterHelper.GetFormData(parameters); + Assert.Equal("key=value", Encoding.UTF8.GetString(output)); + parameters.Add("key1", "value1"); + output = ParameterHelper.GetFormData(parameters); + Assert.Equal("key=value&key1=value1", Encoding.UTF8.GetString(output)); + } } } diff --git a/aliyun-net-sdk-core.Tests/aliyun-net-sdk-core-unit-tests.csproj b/aliyun-net-sdk-core.Tests/aliyun-net-sdk-core-unit-tests.csproj index c7318fd454..e5b4c59022 100644 --- a/aliyun-net-sdk-core.Tests/aliyun-net-sdk-core-unit-tests.csproj +++ b/aliyun-net-sdk-core.Tests/aliyun-net-sdk-core-unit-tests.csproj @@ -10,6 +10,9 @@ + + Always + diff --git a/aliyun-net-sdk-core/Auth/Provider/OIDCCredentialsProvider.cs b/aliyun-net-sdk-core/Auth/Provider/OIDCCredentialsProvider.cs new file mode 100644 index 0000000000..ec2d1196ee --- /dev/null +++ b/aliyun-net-sdk-core/Auth/Provider/OIDCCredentialsProvider.cs @@ -0,0 +1,163 @@ +using System; +using System.Text; +using System.Collections.Generic; + +using Aliyun.Acs.Core.Utils; +using Aliyun.Acs.Core.Exceptions; + +using Aliyun.Acs.Core.Http; + +using Newtonsoft.Json; + +namespace Aliyun.Acs.Core.Auth +{ + public class OIDCCredentialsProvider : AlibabaCloudCredentialsProvider + { + public string RoleArn { get; set; } + public string OIDCProviderArn { get; set; } + public string OIDCTokenFilePath { get; set; } + public string RoleSessionName { get; set; } + public string Policy { get; set; } + + private readonly string stsEndpoint; + + private readonly long durationSeconds; + + private BasicSessionCredentials credentials; + + public OIDCCredentialsProvider(string roleArn, string oidcProviderArn, string oidcTokenFilePath, string roleSessionName, string regionId) + { + RoleArn = ParameterHelper.ValidateEnvNotNull(roleArn, "ALIBABA_CLOUD_ROLE_ARN", "roleArn", "roleArn does not exist and env ALIBABA_CLOUD_ROLE_ARN is null."); + OIDCProviderArn = ParameterHelper.ValidateEnvNotNull(oidcProviderArn, "ALIBABA_CLOUD_OIDC_PROVIDER_ARN", "oidcProviderArn", "OIDCProviderArn does not exist and env ALIBABA_CLOUD_OIDC_PROVIDER_ARN is null."); + OIDCTokenFilePath = ParameterHelper.ValidateEnvNotNull(oidcTokenFilePath, "ALIBABA_CLOUD_OIDC_TOKEN_FILE", "oidcTokenFilePath", "OIDCTokenFilePath does not exist and env ALIBABA_CLOUD_OIDC_TOKEN_FILE is null."); + + if (!string.IsNullOrEmpty(roleSessionName)) + { + RoleSessionName = roleSessionName; + } + else + { + RoleSessionName = Environment.GetEnvironmentVariable("ALIBABA_CLOUD_ROLE_SESSION_NAME"); + } + + if (string.IsNullOrEmpty(RoleSessionName)) + { + RoleSessionName = "DEFAULT_ROLE_SESSION_NAME_FOR_C#_SDK_V1"; + } + + if (string.IsNullOrEmpty(regionId)) + { + stsEndpoint = "https://sts.aliyuncs.com/"; + } + else + { + stsEndpoint = string.Format("https://sts.{0}.aliyuncs.com/", regionId); + } + + durationSeconds = 3600; + } + + public string InvokeAssumeRoleWithOIDC() + { + var queries = new Dictionary + { + { "Action", "AssumeRoleWithOIDC" }, + { "Format", "JSON" }, + { "Version", "2015-04-01" }, + { "Timestamp", ParameterHelper.FormatIso8601Date(DateTime.UtcNow) } + }; + + string url; + try + { + url = stsEndpoint + "?" + "Action=AssumeRoleWithOIDC&Format=JSON&Version=2015-04-01&Timestamp=" + ParameterHelper.FormatIso8601Date(DateTime.UtcNow); + } + catch (Exception ex) + { + throw new ClientException("AssumeRoleWithOIDC failed: " + ex.Message); + } + + var httpRequest = new HttpRequest(url) + { + Method = MethodType.POST, + ContentType = FormatType.FORM, + }; + + httpRequest.SetConnectTimeoutInMilliSeconds(1000); + httpRequest.SetReadTimeoutInMilliSeconds(3000); + + var oidcToken = AuthUtils.GetOIDCToken(OIDCTokenFilePath); + if (oidcToken == null) + { + throw new ClientException("Read OIDC token failed"); + } + + var body = new Dictionary + { + { "DurationSeconds", durationSeconds.ToString() }, + { "RoleArn", RoleArn }, + { "OIDCProviderArn", OIDCProviderArn }, + { "OIDCToken", oidcToken }, + { "RoleSessionName", RoleSessionName }, + { "Policy", Policy } + }; + + var content = ParameterHelper.GetFormData(body); + httpRequest.SetContent(content, "UTF-8", FormatType.FORM); + httpRequest.Headers.Add("User-Agent", UserAgent.Resolve(null, null)); + + HttpResponse httpResponse; + try + { + httpResponse = HttpResponse.GetResponse(httpRequest); + } + catch (Exception ex) + { + throw new ClientException("AssumeRoleWithOIDC failed " + ex.Message); + } + + if (!httpResponse.isSuccess()) + { + var responseBody = httpResponse.GetHttpContentString(); + var map = JsonConvert.DeserializeObject>(responseBody); + var requestID = map["RequestId"]; + var message = string.Format("{0}(RequestID: {1}, Code: {2})", map["Message"], requestID, map["Code"]); + throw new ClientException("AssumeRoleWithOIDC failed: " + message); + } + + return httpResponse.GetHttpContentString(); + } + + internal static BasicSessionCredentials ParseCredentials(string body, long durationSeconds) + { + var map = JsonConvert.DeserializeObject>(body); + if (map == null) + { + throw new ClientException("Invalid JSON"); + } + else if (map.ContainsKey("Credentials")) + { + var credentialsJson = JsonConvert.SerializeObject(DictionaryUtil.Get(map, "Credentials")); + var credentials = JsonConvert.DeserializeObject> (credentialsJson); + var accessKeyId = DictionaryUtil.Get(credentials, "AccessKeyId"); + var accessKeySecret = DictionaryUtil.Get(credentials, "AccessKeySecret"); + var securityToken = DictionaryUtil.Get(credentials, "SecurityToken"); + return new BasicSessionCredentials(accessKeyId, accessKeySecret, securityToken, durationSeconds); + } + else + { + throw new ClientException("AssumeRoleWithOIDC failed: " + body); + } + } + + public AlibabaCloudCredentials GetCredentials() + { + if (credentials == null || credentials.WillSoonExpire()) + { + var body = InvokeAssumeRoleWithOIDC(); + credentials = ParseCredentials(body, durationSeconds); + } + return credentials; + } + } +} \ No newline at end of file diff --git a/aliyun-net-sdk-core/Http/HttpRequest.cs b/aliyun-net-sdk-core/Http/HttpRequest.cs index be16be4d34..34c255995a 100644 --- a/aliyun-net-sdk-core/Http/HttpRequest.cs +++ b/aliyun-net-sdk-core/Http/HttpRequest.cs @@ -117,9 +117,9 @@ public void SetContent(byte[] content, string encoding, FormatType? format) DictionaryUtil.Pop(Headers, "Content-Type"); DictionaryUtil.Add(Headers, "Content-MD5", strMd5); - if(this.Method.ToString() == "POST" || this.Method.ToString() == "PUT") - { - DictionaryUtil.Add(Headers, "Content-Length", contentLen); + if(this.Method.ToString() == "POST" || this.Method.ToString() == "PUT") + { + DictionaryUtil.Add(Headers, "Content-Length", contentLen); } DictionaryUtil.Add(Headers, "Content-Type", ParameterHelper.FormatTypeToString(type)); @@ -142,4 +142,4 @@ public void SetHttpsInsecure(bool ignoreCertificate = false) IgnoreCertificate = ignoreCertificate; } } -} +} \ No newline at end of file diff --git a/aliyun-net-sdk-core/Http/HttpResponse.cs b/aliyun-net-sdk-core/Http/HttpResponse.cs index d6fbb10881..d645a57400 100644 --- a/aliyun-net-sdk-core/Http/HttpResponse.cs +++ b/aliyun-net-sdk-core/Http/HttpResponse.cs @@ -55,6 +55,30 @@ public HttpResponse() ContentType = format; } + public string GetHttpContentString() + { + string stringContent = string.Empty; + if (this.Content != null) + { + try + { + if (string.IsNullOrWhiteSpace(this.Encoding)) + { + stringContent = Convert.ToBase64String(this.Content); + } + else + { + stringContent = System.Text.Encoding.GetEncoding(Encoding).GetString(this.Content); + } + } + catch + { + throw new ClientException("Can not parse response due to unsupported encoding."); + } + } + return stringContent; + } + private static void ParseHttpResponse(HttpResponse httpResponse, HttpWebResponse httpWebResponse) { httpResponse.Content = ReadContent(httpResponse, httpWebResponse); diff --git a/aliyun-net-sdk-core/Http/UserAgent.cs b/aliyun-net-sdk-core/Http/UserAgent.cs index 553e2ae1bc..682530593d 100644 --- a/aliyun-net-sdk-core/Http/UserAgent.cs +++ b/aliyun-net-sdk-core/Http/UserAgent.cs @@ -35,30 +35,24 @@ public class UserAgent private readonly List excludedList = new List(); private readonly Dictionary userAgent = new Dictionary(); - private string ClientVersion; - private string CoreVersion; - private string OSVersion; + static UserAgent() + { + DEFAULT_MESSAGE = "Alibaba Cloud (" + GetOsVersion() + ") " + GetClientVersion(RuntimeEnvironment.GetRuntimeDirectory()) + " Core/" + Assembly.GetExecutingAssembly().GetName().Version.ToString(); + } public UserAgent() { SetTheValue(); - - DEFAULT_MESSAGE = "Alibaba Cloud (" + OSVersion + ") "; - DEFAULT_MESSAGE += ClientVersion; - DEFAULT_MESSAGE += " Core/" + CoreVersion; } public void SetTheValue() { - OSVersion = GetOsVersion(); - ClientVersion = GetRuntimeRegexValue(RuntimeEnvironment.GetRuntimeDirectory()); - CoreVersion = Assembly.GetExecutingAssembly().GetName().Version.ToString(); excludedList.Add("core"); excludedList.Add("microsoft.netcore.app"); } - private string GetOsVersion() + private static string GetOsVersion() { #if NETSTANDARD2_0 return RuntimeInformation.OSDescription; @@ -67,11 +61,17 @@ private string GetOsVersion() #endif } + [Obsolete] public string GetRuntimeRegexValue(string value) + { + return GetClientVersion(value); + } + + private static string GetClientVersion(string value) { var rx = new Regex(@"(\.NET).*(\\|\/).*(\d)", RegexOptions.Compiled | RegexOptions.IgnoreCase); var matches = rx.Match(value); - char[] separator = {'\\', '/'}; + char[] separator = { '\\', '/' }; if (matches.Success) { @@ -82,7 +82,7 @@ public string GetRuntimeRegexValue(string value) return "RuntimeNotFound"; } - private string BuildClientVersion(string[] value) + private static string BuildClientVersion(string[] value) { var finalValue = ""; for (var i = 0; i < value.Length - 1; ++i) diff --git a/aliyun-net-sdk-core/Utils/AuthUtils.cs b/aliyun-net-sdk-core/Utils/AuthUtils.cs new file mode 100644 index 0000000000..137ffe9182 --- /dev/null +++ b/aliyun-net-sdk-core/Utils/AuthUtils.cs @@ -0,0 +1,70 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +using System; +using System.Text; +using System.IO; +using System.Security; +using Aliyun.Acs.Core.Exceptions; + +namespace Aliyun.Acs.Core.Utils +{ + public class AuthUtils + { + private static volatile string oidcToken; + + AuthUtils() + { + } + + + public static string GetOIDCToken(string OIDCTokenFilePath) + { + byte[] buffer; + if (!File.Exists(OIDCTokenFilePath)) + { + throw new ClientException("OIDCTokenFilePath " + OIDCTokenFilePath + " does not exist."); + } + try + { + using (var inStream = new FileStream(OIDCTokenFilePath, FileMode.Open, FileAccess.Read)) + { + buffer = new byte[inStream.Length]; + inStream.Read(buffer, 0, buffer.Length); + } + oidcToken = Encoding.UTF8.GetString(buffer); + } + catch (UnauthorizedAccessException) + { + throw new ClientException("OIDCTokenFilePath " + OIDCTokenFilePath + " cannot be read."); + } + catch (SecurityException) + { + throw new ClientException("Security Exception: Do not have the required permission. " + "OIDCTokenFilePath " + OIDCTokenFilePath); + } + catch (IOException e) + { + Console.WriteLine(e.StackTrace); + } + return oidcToken; + } + + + } +} diff --git a/aliyun-net-sdk-core/Utils/ParameterHelper.cs b/aliyun-net-sdk-core/Utils/ParameterHelper.cs index 12f24a3c01..34c99b70ff 100644 --- a/aliyun-net-sdk-core/Utils/ParameterHelper.cs +++ b/aliyun-net-sdk-core/Utils/ParameterHelper.cs @@ -18,8 +18,11 @@ */ using System; +using System.Web; +using System.Text; using System.Globalization; using System.Security.Cryptography; +using System.Collections.Generic; using Aliyun.Acs.Core.Http; @@ -42,12 +45,12 @@ public static string GetRFC2616Date(DateTime datetime) datetime = DateTime.UtcNow; } - return datetime.ToUniversalTime().GetDateTimeFormats('r') [0]; + return datetime.ToUniversalTime().GetDateTimeFormats('r')[0]; } public static string Md5Sum(byte[] buff) { - using(MD5 md5 = new MD5CryptoServiceProvider()) + using (MD5 md5 = new MD5CryptoServiceProvider()) { var output = md5.ComputeHash(buff); return BitConverter.ToString(output).Replace("-", ""); @@ -56,7 +59,7 @@ public static string Md5Sum(byte[] buff) public static string Md5SumAndBase64(byte[] buff) { - using(MD5 md5 = new MD5CryptoServiceProvider()) + using (MD5 md5 = new MD5CryptoServiceProvider()) { var output = md5.ComputeHash(buff); // string md5Str = BitConverter.ToString(output).Replace("-", ""); @@ -142,5 +145,48 @@ public static string FormatTypeToString(FormatType? formatType) } } } + + public static byte[] GetFormData(Dictionary parameters) + { + var result = new StringBuilder(); + var first = true; + + foreach (var entry in parameters) + { + if (string.IsNullOrEmpty(entry.Value)) + { + continue; + } + if (first) + { + first = false; + } + else + { + result.Append("&"); + } + result.Append(HttpUtility.UrlEncode(entry.Key)); + result.Append("="); + result.Append(HttpUtility.UrlEncode(entry.Value)); + } + return Encoding.UTF8.GetBytes(result.ToString()); + } + + + public static string ValidateEnvNotNull(string obj, string envVariableName, string paramName, string message) + { + if (obj == null) + { + if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable(envVariableName))) + { + throw new ArgumentNullException(paramName, message); + } + else + { + return Environment.GetEnvironmentVariable(envVariableName); + } + } + return obj; + } } } diff --git a/aliyun-net-sdk-core/aliyun-net-sdk-core.vs2017.csproj b/aliyun-net-sdk-core/aliyun-net-sdk-core.vs2017.csproj index fe11f49ff3..37169c7cc1 100644 --- a/aliyun-net-sdk-core/aliyun-net-sdk-core.vs2017.csproj +++ b/aliyun-net-sdk-core/aliyun-net-sdk-core.vs2017.csproj @@ -26,6 +26,9 @@ NET45 + + +