// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using AWS.Deploy.CLI.ServerMode.Services;
namespace AWS.Deploy.CLI.ServerMode
{
public class AwsCredentialsAuthenticationSchemeOptions
: AuthenticationSchemeOptions
{ }
///
/// The ASP.NET Core Authentication handler. Verify the Authorization header has been correctly set with AWS Credentials.
///
public class AwsCredentialsAuthenticationHandler : AuthenticationHandler
{
public const string SchemaName = "aws-deploy-tool-server-mode";
public const string ClaimAwsAccessKeyId = "awsAccessKeyId";
public const string ClaimAwsSecretKey = "awsSecretKey";
public const string ClaimAwsSessionToken = "awsSessionToken";
public const string ClaimAwsIssueDate = "issueDate";
public const string ClaimAwsRequestId = "requestId";
///
/// The max duration auth request values are valid based on the issue date.
///
public static readonly TimeSpan MaxIssueDateDuration = TimeSpan.FromSeconds(10);
// Cache of auth request ids that have already been used. Request ids are not allowed to be reused.
// The timestamp of when they were added is stored. Values can be cleared out cache after the MaxIssueDateDuration.
private static readonly IDictionary _processedRequestIds = new Dictionary();
///
/// Readonly view of the already processed auth request ids. The main use case of this property is for testing.
///
public static IReadOnlyDictionary ProcessRequestIds
{
get
{
return new ReadOnlyDictionary(_processedRequestIds);
}
}
private readonly IEncryptionProvider _encryptionProvider;
public AwsCredentialsAuthenticationHandler(
IOptionsMonitor options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock,
IEncryptionProvider encryptionProvider)
: base(options, logger, encoder, clock)
{
_encryptionProvider = encryptionProvider;
}
protected override Task HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue("Authorization", out var value))
{
return Task.FromResult(AuthenticateResult.Fail("Missing Authorization header"));
}
return Task.FromResult(ProcessAuthorizationHeader(value, _encryptionProvider));
}
public static AuthenticateResult ProcessAuthorizationHeader(string authorizationHeaderValue, IEncryptionProvider encryptionProvider)
{
var tokens = authorizationHeaderValue.Split(' ');
if (tokens.Length != 2 && tokens.Length != 3)
{
var ivPlaceholder = "";
if (encryptionProvider is AesEncryptionProvider)
{
ivPlaceholder = " ";
}
return AuthenticateResult.Fail($"Incorrect format Authorization header. Format should be \"{SchemaName} {ivPlaceholder}\"");
}
if (tokens.Length == 2 && encryptionProvider is AesEncryptionProvider)
{
return AuthenticateResult.Fail($"Incorrect format Authorization header. Format should be \"{SchemaName} \"");
}
if (!string.Equals(SchemaName, tokens[0]))
{
return AuthenticateResult.Fail($"Unsupported authorization schema. Supported schema: {SchemaName}");
}
try
{
byte[]? base64IV;
byte[] base64Bytes;
if (tokens.Length == 2)
{
base64IV = null;
base64Bytes = Convert.FromBase64String(tokens[1]);
}
else
{
base64IV = Convert.FromBase64String(tokens[1]);
base64Bytes = Convert.FromBase64String(tokens[2]);
}
var decryptedBytes = encryptionProvider.Decrypt(base64Bytes, base64IV);
var json = Encoding.UTF8.GetString(decryptedBytes);
var authParameters = JsonConvert.DeserializeObject>(json) ?? new Dictionary();
// Validate the issue date and request id are valid.
var validateResult = ValidateAuthParameters(authParameters);
if(validateResult != null)
{
return validateResult;
}
var claimIdentity = new ClaimsIdentity(nameof(AwsCredentialsAuthenticationHandler));
foreach (var kvp in authParameters)
{
claimIdentity.AddClaim(new Claim(kvp.Key, kvp.Value));
}
var ticket = new AuthenticationTicket(
new ClaimsPrincipal(claimIdentity), SchemaName);
return AuthenticateResult.Success(ticket);
}
catch (Exception)
{
return AuthenticateResult.Fail("Error decoding authorization value");
}
}
public static AuthenticateResult? ValidateAuthParameters(IDictionary authParameters)
{
lock (_processedRequestIds)
{
if (!authParameters.TryGetValue(ClaimAwsIssueDate, out var issueDateStr))
{
return AuthenticateResult.Fail($"Authorization header missing {ClaimAwsIssueDate} property");
}
if (!DateTime.TryParse(issueDateStr, out var issueDate))
{
return AuthenticateResult.Fail("Failed to parse issue date");
}
issueDate = issueDate.ToUniversalTime();
// The encrypted authorization header, which includes a date stamp, is only valid for a small amount of time.
// The request can potentially go longer then a minute but the issue date check
// is verified at the start of the request. This is to reduce the window the authorization
// header could be replayed.
if (issueDate < DateTime.UtcNow.Subtract(MaxIssueDateDuration))
{
return AuthenticateResult.Fail("Issue date has expired");
}
// Check to see if the issue date was incorrectly set in the future. A one second buffer is used in
// case the caller was using a less precise clock.
if(DateTime.UtcNow.AddSeconds(1) < issueDate)
{
return AuthenticateResult.Fail("Issue date invalid set in the future");
}
if (!authParameters.TryGetValue(ClaimAwsRequestId, out var requestId))
{
return AuthenticateResult.Fail($"Authorization header missing {ClaimAwsRequestId} property");
}
// If the authorization header value is attempted to be reused then fail auth check.
if (_processedRequestIds.ContainsKey(requestId))
{
return AuthenticateResult.Fail($"Value for authorization header has already been used");
}
// Store the request id so it can not be reused.
_processedRequestIds.Add(requestId, DateTime.UtcNow);
// Remove request ids that are older then MaxIssueDateDuration
var expirationDate = DateTime.UtcNow.Subtract(MaxIssueDateDuration.Add(TimeSpan.FromSeconds(2)));
var expiredRequests = _processedRequestIds.Where(x => x.Value < expirationDate);
foreach (var expiredRequest in expiredRequests)
{
_processedRequestIds.Remove(expiredRequest.Key);
}
}
return null;
}
}
}