Browse Source

Merge pull request #1027 from colinin/open-api-validation

refactor(open-api): get api key from the request header
pull/1050/head
yx lin 1 year ago
committed by GitHub
parent
commit
db22a50825
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 68
      aspnet-core/framework/open-api/LINGYUN.Abp.OpenApi.Authorization/LINGYUN/Abp/OpenApi/Authorization/OpenApiAuthorizationService.cs
  2. 1
      aspnet-core/framework/open-api/LINGYUN.Abp.OpenApi.IdentityServer/LINGYUN.Abp.OpenApi.IdentityServer.csproj
  3. 2
      aspnet-core/framework/open-api/LINGYUN.Abp.OpenApi.IdentityServer/LINGYUN/Abp/OpenApi/IdentityServer/IdentityServerAppKeyStore.cs
  4. 1
      aspnet-core/framework/open-api/LINGYUN.Abp.OpenApi.OpenIddict/LINGYUN.Abp.OpenApi.OpenIddict.csproj
  5. 2
      aspnet-core/framework/open-api/LINGYUN.Abp.OpenApi.OpenIddict/LINGYUN/Abp/OpenApi/OpenIddict/OpenIddictAppKeyStore.cs
  6. 2
      aspnet-core/framework/open-api/LINGYUN.Abp.OpenApi/LINGYUN.Abp.OpenApi.csproj
  7. 41
      aspnet-core/framework/open-api/LINGYUN.Abp.OpenApi/LINGYUN/Abp/OpenApi/AbpOpenApiConsts.cs
  8. 2
      aspnet-core/framework/open-api/LINGYUN.Abp.OpenApi/LINGYUN/Abp/OpenApi/AbpOpenApiModule.cs
  9. 20
      aspnet-core/framework/open-api/LINGYUN.Abp.OpenApi/LINGYUN/Abp/OpenApi/AbpOpenApiOptions.cs
  10. 4
      aspnet-core/framework/open-api/LINGYUN.Abp.OpenApi/LINGYUN/Abp/OpenApi/ConfigurationStore/DefaultAppKeyStore.cs
  11. 45
      aspnet-core/framework/open-api/LINGYUN.Abp.OpenApi/LINGYUN/Abp/OpenApi/DefaultNonceStore.cs
  12. 2
      aspnet-core/framework/open-api/LINGYUN.Abp.OpenApi/LINGYUN/Abp/OpenApi/IAppKeyStore.cs
  13. 8
      aspnet-core/framework/open-api/LINGYUN.Abp.OpenApi/LINGYUN/Abp/OpenApi/INonceStore.cs
  14. 2
      aspnet-core/framework/open-api/LINGYUN.Abp.OpenApi/LINGYUN/Abp/OpenApi/Localization/Resources/en.json
  15. 2
      aspnet-core/framework/open-api/LINGYUN.Abp.OpenApi/LINGYUN/Abp/OpenApi/Localization/Resources/zh-Hans.json
  16. 26
      aspnet-core/framework/open-api/LINGYUN.Abp.OpenApi/LINGYUN/Abp/OpenApi/NonceStateCacheItem.cs
  17. 2
      aspnet-core/framework/open-api/OpenApi.Sdk/Microsoft/Extensions/DependencyInjection/ClientProxyServiceCollectionExtensions.cs
  18. 33
      aspnet-core/framework/open-api/OpenApi.Sdk/OpenApi/ClientProxy.cs

68
aspnet-core/framework/open-api/LINGYUN.Abp.OpenApi.Authorization/LINGYUN/Abp/OpenApi/Authorization/OpenApiAuthorizationService.cs

@ -13,7 +13,6 @@ using System.Web;
using Volo.Abp;
using Volo.Abp.AspNetCore.ExceptionHandling;
using Volo.Abp.AspNetCore.WebClientInfo;
using Volo.Abp.Clients;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Http;
using Volo.Abp.Json;
@ -24,23 +23,23 @@ namespace LINGYUN.Abp.OpenApi.Authorization
{
private readonly IAppKeyStore _appKeyStore;
private readonly AbpOpenApiOptions _openApiOptions;
private readonly ICurrentClient _currentClient;
private readonly IWebClientInfoProvider _clientInfoProvider;
private readonly INonceStore _nonceStore;
private readonly IClientChecker _clientChecker;
private readonly IIpAddressChecker _ipAddressChecker;
private readonly AbpExceptionHandlingOptions _exceptionHandlingOptions;
public OpenApiAuthorizationService(
INonceStore nonceStore,
IAppKeyStore appKeyStore,
ICurrentClient currentClient,
IClientChecker clientChecker,
IIpAddressChecker ipAddressChecker,
IWebClientInfoProvider clientInfoProvider,
IOptionsMonitor<AbpOpenApiOptions> options,
IOptions<AbpExceptionHandlingOptions> exceptionHandlingOptions)
{
_nonceStore = nonceStore;
_appKeyStore = appKeyStore;
_currentClient = currentClient;
_clientChecker = clientChecker;
_ipAddressChecker = ipAddressChecker;
_clientInfoProvider = clientInfoProvider;
@ -55,7 +54,7 @@ namespace LINGYUN.Abp.OpenApi.Authorization
return true;
}
if (!await ValidateClient(httpContext))
if (!await ValidateClientIpAddress(httpContext))
{
return false;
}
@ -73,18 +72,8 @@ namespace LINGYUN.Abp.OpenApi.Authorization
return true;
}
protected async virtual Task<bool> ValidateClient(HttpContext httpContext)
protected async virtual Task<bool> ValidateClientIpAddress(HttpContext httpContext)
{
if (_currentClient.IsAuthenticated && !await _clientChecker.IsGrantAsync(_currentClient.Id, httpContext.RequestAborted))
{
var exception = new BusinessException(
AbpOpenApiConsts.InvalidAccessWithClientId,
$"Client Id {_currentClient.Id} Not Allowed",
$"Client Id {_currentClient.Id} Not Allowed");
await Unauthorized(httpContext, exception);
return false;
}
if (!string.IsNullOrWhiteSpace(_clientInfoProvider.ClientIpAddress) &&
!await _ipAddressChecker.IsGrantAsync(_clientInfoProvider.ClientIpAddress, httpContext.RequestAborted))
{
@ -114,9 +103,11 @@ namespace LINGYUN.Abp.OpenApi.Authorization
protected async virtual Task<bool> ValidatAppDescriptor(HttpContext httpContext)
{
httpContext.Request.Query.TryGetValue(AbpOpenApiConsts.AppKeyFieldName, out var appKey);
httpContext.Request.Query.TryGetValue(AbpOpenApiConsts.SignatureFieldName, out var sign);
httpContext.Request.Query.TryGetValue(AbpOpenApiConsts.TimeStampFieldName, out var timeStampString);
httpContext.Request.Headers.TryGetValue(AbpOpenApiConsts.AppKeyFieldName, out var appKey);
httpContext.Request.Headers.TryGetValue(AbpOpenApiConsts.SignatureFieldName, out var sign);
httpContext.Request.Headers.TryGetValue(AbpOpenApiConsts.NonceFieldName, out var nonce);
httpContext.Request.Headers.TryGetValue(AbpOpenApiConsts.TimeStampFieldName, out var timeStampString);
if (StringValues.IsNullOrEmpty(appKey))
{
@ -128,6 +119,17 @@ namespace LINGYUN.Abp.OpenApi.Authorization
return false;
}
if (StringValues.IsNullOrEmpty(nonce))
{
var exception = new BusinessException(
AbpOpenApiConsts.InvalidAccessWithNonceNotFound,
$"{AbpOpenApiConsts.NonceFieldName} Not Found",
$"{AbpOpenApiConsts.NonceFieldName} Not Found");
await Unauthorized(httpContext, exception);
return false;
}
if (StringValues.IsNullOrEmpty(sign))
{
var exception = new BusinessException(
@ -161,6 +163,26 @@ namespace LINGYUN.Abp.OpenApi.Authorization
return false;
}
if (!await _nonceStore.TrySetAsync(nonce.ToString(), httpContext.RequestAborted))
{
var exception = new BusinessException(
AbpOpenApiConsts.InvalidAccessWithNonceRepeated,
$"Request {nonce} has repeated",
$"Request {nonce} has repeated");
await Unauthorized(httpContext, exception);
return false;
}
if (!await _clientChecker.IsGrantAsync(appKey.ToString(), httpContext.RequestAborted))
{
var exception = new BusinessException(
AbpOpenApiConsts.InvalidAccessWithClientId,
$"Client Id {appKey} Not Allowed",
$"Client Id {appKey} Not Allowed");
await Unauthorized(httpContext, exception);
return false;
}
var appDescriptor = await _appKeyStore.FindAsync(appKey.ToString(), httpContext.RequestAborted);
if (appDescriptor == null)
{
@ -178,13 +200,13 @@ namespace LINGYUN.Abp.OpenApi.Authorization
var queryStringCollection = httpContext.Request.Query;
foreach (var queryString in queryStringCollection)
{
if (queryString.Key.Equals(AbpOpenApiConsts.SignatureFieldName))
{
continue;
}
queryDictionary.Add(queryString.Key, queryString.Value.ToString());
}
queryDictionary.TryAdd("appKey", appDescriptor.AppKey);
queryDictionary.TryAdd("appSecret", appDescriptor.AppSecret);
queryDictionary.TryAdd("nonce", nonce.ToString());
queryDictionary.TryAdd("t", timeStampString.ToString());
var requiredSign = CalculationSignature(httpContext.Request.Path.Value, queryDictionary);
if (!string.Equals(requiredSign, sign.ToString()))
{

1
aspnet-core/framework/open-api/LINGYUN.Abp.OpenApi.IdentityServer/LINGYUN.Abp.OpenApi.IdentityServer.csproj

@ -10,6 +10,7 @@
<GenerateAssemblyConfigurationAttribute>false</GenerateAssemblyConfigurationAttribute>
<GenerateAssemblyCompanyAttribute>false</GenerateAssemblyCompanyAttribute>
<GenerateAssemblyProductAttribute>false</GenerateAssemblyProductAttribute>
<Nullable>enable</Nullable>
<RootNamespace />
</PropertyGroup>

2
aspnet-core/framework/open-api/LINGYUN.Abp.OpenApi.IdentityServer/LINGYUN/Abp/OpenApi/IdentityServer/IdentityServerAppKeyStore.cs

@ -27,7 +27,7 @@ public class IdentityServerAppKeyStore : IAppKeyStore, ITransientDependency
Logger = NullLogger<IdentityServerAppKeyStore>.Instance;
}
public async virtual Task<AppDescriptor> FindAsync(string appKey, CancellationToken cancellationToken = default)
public async virtual Task<AppDescriptor?> FindAsync(string appKey, CancellationToken cancellationToken = default)
{
var client = await _clientRepository.FindByClientIdAsync(appKey, cancellationToken: cancellationToken);
if (client != null)

1
aspnet-core/framework/open-api/LINGYUN.Abp.OpenApi.OpenIddict/LINGYUN.Abp.OpenApi.OpenIddict.csproj

@ -10,6 +10,7 @@
<GenerateAssemblyConfigurationAttribute>false</GenerateAssemblyConfigurationAttribute>
<GenerateAssemblyCompanyAttribute>false</GenerateAssemblyCompanyAttribute>
<GenerateAssemblyProductAttribute>false</GenerateAssemblyProductAttribute>
<Nullable>enable</Nullable>
<RootNamespace />
</PropertyGroup>

2
aspnet-core/framework/open-api/LINGYUN.Abp.OpenApi.OpenIddict/LINGYUN/Abp/OpenApi/OpenIddict/OpenIddictAppKeyStore.cs

@ -22,7 +22,7 @@ public class OpenIddictAppKeyStore : IAppKeyStore, ITransientDependency
_guidGenerator = guidGenerator;
}
public async virtual Task<AppDescriptor> FindAsync(string appKey, CancellationToken cancellationToken = default)
public async virtual Task<AppDescriptor?> FindAsync(string appKey, CancellationToken cancellationToken = default)
{
var application = await _appStore.FindByClientIdAsync(appKey, cancellationToken);
if (application != null)

2
aspnet-core/framework/open-api/LINGYUN.Abp.OpenApi/LINGYUN.Abp.OpenApi.csproj

@ -10,6 +10,7 @@
<GenerateAssemblyConfigurationAttribute>false</GenerateAssemblyConfigurationAttribute>
<GenerateAssemblyCompanyAttribute>false</GenerateAssemblyCompanyAttribute>
<GenerateAssemblyProductAttribute>false</GenerateAssemblyProductAttribute>
<Nullable>enable</Nullable>
<RootNamespace />
</PropertyGroup>
@ -22,6 +23,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Volo.Abp.Caching" />
<PackageReference Include="Volo.Abp.Security" />
<PackageReference Include="Volo.Abp.Localization" />
</ItemGroup>

41
aspnet-core/framework/open-api/LINGYUN.Abp.OpenApi/LINGYUN/Abp/OpenApi/AbpOpenApiConsts.cs

@ -4,21 +4,54 @@ public static class AbpOpenApiConsts
{
public const string SecurityChecking = "_AbpOpenApiSecurityChecking";
public const string AppKeyFieldName = "appKey";
public const string SignatureFieldName = "sign";
public const string TimeStampFieldName = "t";
public const string AppKeyFieldName = "X-API-APPKEY";
public const string SignatureFieldName = "X-API-SIGN";
public const string NonceFieldName = "X-API-NONCE";
public const string TimeStampFieldName = "X-API-TIMESTAMP";
public const string KeyPrefix = "AbpOpenApi";
/// <summary>
/// 无效的应用标识 {AppKey}.
/// </summary>
public const string InvalidAccessWithAppKey = KeyPrefix + ":9100";
/// <summary>
/// 未携带应用标识(appKey).
/// </summary>
public const string InvalidAccessWithAppKeyNotFound = KeyPrefix + ":9101";
/// <summary>
/// 无效的签名 sign.
/// </summary>
public const string InvalidAccessWithSign = KeyPrefix + ":9110";
/// <summary>
/// 未携带签名(sign).
/// </summary>
public const string InvalidAccessWithSignNotFound = KeyPrefix + ":9111";
/// <summary>
/// 请求超时或会话已过期.
/// </summary>
public const string InvalidAccessWithTimestamp = KeyPrefix + ":9210";
/// <summary>
/// 未携带时间戳标识.
/// </summary>
public const string InvalidAccessWithTimestampNotFound = KeyPrefix + ":9211";
/// <summary>
/// 重复发起的请求.
/// </summary>
public const string InvalidAccessWithNonceRepeated = KeyPrefix + ":9220";
/// <summary>
/// 未携带随机数.
/// </summary>
public const string InvalidAccessWithNonceNotFound = KeyPrefix + ":9221";
/// <summary>
/// 客户端不在允许的范围内.
/// </summary>
public const string InvalidAccessWithClientId = KeyPrefix + ":9300";
/// <summary>
/// 客户端IP不在允许的范围内.
/// </summary>
public const string InvalidAccessWithIpAddress = KeyPrefix + ":9400";
}

2
aspnet-core/framework/open-api/LINGYUN.Abp.OpenApi/LINGYUN/Abp/OpenApi/AbpOpenApiModule.cs

@ -1,6 +1,7 @@
using LINGYUN.Abp.OpenApi.ConfigurationStore;
using LINGYUN.Abp.OpenApi.Localization;
using Microsoft.Extensions.DependencyInjection;
using Volo.Abp.Caching;
using Volo.Abp.Localization;
using Volo.Abp.Localization.ExceptionHandling;
using Volo.Abp.Modularity;
@ -10,6 +11,7 @@ using Volo.Abp.VirtualFileSystem;
namespace LINGYUN.Abp.OpenApi;
[DependsOn(
typeof(AbpCachingModule),
typeof(AbpSecurityModule),
typeof(AbpLocalizationModule))]
public class AbpOpenApiModule : AbpModule

20
aspnet-core/framework/open-api/LINGYUN.Abp.OpenApi/LINGYUN/Abp/OpenApi/AbpOpenApiOptions.cs

@ -1,10 +1,28 @@
namespace LINGYUN.Abp.OpenApi;
using System;
namespace LINGYUN.Abp.OpenApi;
public class AbpOpenApiOptions
{
/// <summary>
/// 启用Api签名检查
/// </summary>
/// <remarks>
/// 默认: true
/// </remarks>
public bool IsEnabled { get; set; }
/// <summary>
/// 请求随机数过期时间
/// </summary>
/// <remarks>
/// 默认: 10分钟
/// </remarks>
public TimeSpan RequestNonceExpireIn { get; set; }
public AbpOpenApiOptions()
{
IsEnabled = true;
RequestNonceExpireIn = TimeSpan.FromMinutes(10);
}
}

4
aspnet-core/framework/open-api/LINGYUN.Abp.OpenApi/LINGYUN/Abp/OpenApi/ConfigurationStore/DefaultAppKeyStore.cs

@ -17,12 +17,12 @@ public class DefaultAppKeyStore : IAppKeyStore, ITransientDependency
_options = options.CurrentValue;
}
public Task<AppDescriptor> FindAsync(string appKey, CancellationToken cancellationToken = default)
public Task<AppDescriptor?> FindAsync(string appKey, CancellationToken cancellationToken = default)
{
return Task.FromResult(Find(appKey));
}
public AppDescriptor Find(string appKey)
public AppDescriptor? Find(string appKey)
{
return _options.AppDescriptors?.FirstOrDefault(t => t.AppKey == appKey);
}

45
aspnet-core/framework/open-api/LINGYUN.Abp.OpenApi/LINGYUN/Abp/OpenApi/DefaultNonceStore.cs

@ -0,0 +1,45 @@
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Options;
using System.Threading;
using System.Threading.Tasks;
using Volo.Abp.Caching;
using Volo.Abp.DependencyInjection;
namespace LINGYUN.Abp.OpenApi;
public class DefaultNonceStore : INonceStore, ITransientDependency
{
private const string CacheKeyFormat = "open-api,n:{0}";
private readonly IDistributedCache<NonceStateCacheItem> _nonceCache;
private readonly AbpOpenApiOptions _options;
public DefaultNonceStore(
IDistributedCache<NonceStateCacheItem> nonceCache,
IOptions<AbpOpenApiOptions> options)
{
_nonceCache = nonceCache;
_options = options.Value;
}
public async virtual Task<bool> TrySetAsync(string nonce, CancellationToken cancellationToken = default)
{
var cacheKey = string.Format(CacheKeyFormat, nonce);
var cacheItem = await _nonceCache.GetAsync(cacheKey, token: cancellationToken);
if (cacheItem == null)
{
await _nonceCache.SetAsync(
cacheKey,
new NonceStateCacheItem(nonce),
options: new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = _options.RequestNonceExpireIn,
},
token: cancellationToken);
return true;
}
return false;
}
}

2
aspnet-core/framework/open-api/LINGYUN.Abp.OpenApi/LINGYUN/Abp/OpenApi/IAppKeyStore.cs

@ -5,7 +5,7 @@ namespace LINGYUN.Abp.OpenApi;
public interface IAppKeyStore
{
Task<AppDescriptor> FindAsync(string appKey, CancellationToken cancellationToken = default);
Task<AppDescriptor?> FindAsync(string appKey, CancellationToken cancellationToken = default);
Task StoreAsync(AppDescriptor descriptor, CancellationToken cancellationToken = default);
}

8
aspnet-core/framework/open-api/LINGYUN.Abp.OpenApi/LINGYUN/Abp/OpenApi/INonceStore.cs

@ -0,0 +1,8 @@
using System.Threading;
using System.Threading.Tasks;
namespace LINGYUN.Abp.OpenApi;
public interface INonceStore
{
Task<bool> TrySetAsync(string nonce, CancellationToken cancellationToken = default);
}

2
aspnet-core/framework/open-api/LINGYUN.Abp.OpenApi/LINGYUN/Abp/OpenApi/Localization/Resources/en.json

@ -7,6 +7,8 @@
"AbpOpenApi:9111": "sign not found.",
"AbpOpenApi:9210": "Request timed out or the session expired.",
"AbpOpenApi:9211": "timestamp not found.",
"AbpOpenApi:9220": "Repeatedly initiated requests.",
"AbpOpenApi:9221": "nonce not found.",
"AbpOpenApi:9300": "The client is not within the allowed range.",
"AbpOpenApi:9400": "The client IP is not within the allowed range."
}

2
aspnet-core/framework/open-api/LINGYUN.Abp.OpenApi/LINGYUN/Abp/OpenApi/Localization/Resources/zh-Hans.json

@ -7,6 +7,8 @@
"AbpOpenApi:9111": "未携带签名(sign).",
"AbpOpenApi:9210": "请求超时或会话已过期.",
"AbpOpenApi:9211": "未携带时间戳标识.",
"AbpOpenApi:9220": "重复发起的请求.",
"AbpOpenApi:9221": "未携带随机数.",
"AbpOpenApi:9300": "客户端不在允许的范围内.",
"AbpOpenApi:9400": "客户端IP不在允许的范围内."
}

26
aspnet-core/framework/open-api/LINGYUN.Abp.OpenApi/LINGYUN/Abp/OpenApi/NonceStateCacheItem.cs

@ -0,0 +1,26 @@
using System;
namespace LINGYUN.Abp.OpenApi;
[Serializable]
public class NonceStateCacheItem
{
private const string CacheKeyFormat = "open-api,nonce:{0}";
public string Nonce { get; set; }
public NonceStateCacheItem()
{
}
public NonceStateCacheItem(string nonce)
{
Nonce = nonce;
}
public static string CalculateCacheKey(string nonce)
{
return string.Format(CacheKeyFormat, nonce);
}
}

2
aspnet-core/framework/open-api/OpenApi.Sdk/Microsoft/Extensions/DependencyInjection/ClientProxyServiceCollectionExtensions.cs

@ -7,7 +7,7 @@ namespace Microsoft.Extensions.DependencyInjection
{
public static IServiceCollection AddClientProxy(this IServiceCollection services, string serverUrl)
{
services.AddHttpClient("opensdk", options =>
services.AddHttpClient("openapi-sdk", options =>
{
options.BaseAddress = new Uri(serverUrl);
});

33
aspnet-core/framework/open-api/OpenApi.Sdk/OpenApi/ClientProxy.cs

@ -57,6 +57,8 @@ namespace OpenApi
{
// UTC时间戳
var timeStamp = GetUtcTimeStampString();
// 随机数
var nonce = Guid.NewGuid().ToString();
// 取出api地址
var baseUrl = url.Split('?')[0];
// 组装请求参数
@ -65,34 +67,39 @@ namespace OpenApi
url.Contains('?') ? "&" : "?",
"appKey=",
appKey,
"appSecret=",
appSecret,
"nonce=",
nonce,
"&t=",
timeStamp);
var quertString = ReverseQueryString(requestUrl);
// 密钥参与计算
quertString.Add("appSecret", appSecret);
var queryString = ReverseQueryString(requestUrl);
// 对请求参数签名
var sign = CalculationSignature(baseUrl, quertString);
// 移除密钥
quertString.Remove("appSecret");
// 签名随请求传递
quertString.Add("sign", sign);
// 重新拼接请求参数
requestUrl = string.Concat(baseUrl, "?", BuildQuery(quertString));
var sign = CalculationSignature(baseUrl, queryString);
// 构建请求体
var requestMessage = new HttpRequestMessage(httpMethod, requestUrl);
var requestMessage = new HttpRequestMessage(httpMethod, url);
if (request != null)
{
// Request Payload
requestMessage.Content = new StringContent(JsonConvert.SerializeObject(request));
}
// 返回中文错误提示
// appKey添加到headers
requestMessage.Headers.TryAddWithoutValidation("X-API-APPKEY", appKey);
// 随机数添加到headers
requestMessage.Headers.TryAddWithoutValidation("X-API-NONCE", nonce);
// 时间戳添加到headers
requestMessage.Headers.TryAddWithoutValidation("X-API-TIMESTAMP", timeStamp);
// 签名添加到headers
requestMessage.Headers.TryAddWithoutValidation("X-API-SIGN", sign);
// 返回本地化错误提示
requestMessage.Headers.AcceptLanguage.Add(new StringWithQualityHeaderValue(CultureInfo.CurrentUICulture.Name));
// 返回错误消息可序列化
requestMessage.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
// 序列化响应
var response = await client.SendAsync(requestMessage);
var stringContent = await response.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<ApiResponse<TResult>>(stringContent);

Loading…
Cancel
Save