Building a "Most Secure" API Interface with ASP.NET Core

Building a "Most Secure" API Interface with ASP.NET Core

The company gives you a task to write an API interface, so how should we design it?

Last updated 10/21/2021 10:34 AM
薛家明
16 min read
Category
ASP.NET Core
Tags
.NET C# ASP.NET Core Security API

If the company assigns you a task to write an API interface, how should we design this API interface to ensure it looks "high-end," "impressive," and "enviable" from the outside, while being as easy to use as a regular API interface, and seamlessly integrating with ASP.NET Core's authentication and authorization system, rather than using custom signatures and custom filters (albeit possible but not the most perfect)? How to make a novice realize at a glance that you are a seasoned expert?

Next, I will share with you the unknown world of custom authentication and authorization systems.

I believe this is a hurdle you must cross when working with ASP.NET Core, and also an unfamiliar territory for many experienced developers (many say "as long as it works," then you can directly click the top right or top left corner).

How to Build the Most Secure API Interface

Technology Selection

Without considering performance impact, we choose asymmetric encryption, which can be SM or RSA encryption. Here, we select RSA 2048-bit PKCS8 keys. HTTP transmission can be divided into two modes: request and response.

A secure interaction method, without using HTTPS, is to encrypt and sign the plaintext information before sending it to you. After receiving it, you decrypt it, then encrypt and sign the plaintext information you respond to, and send it back to me. This ensures the security of data interaction.

Asymmetric encryption generally has two keys: one is called a public key, and the other is called a private key. The public key can be disclosed, even if placed on the internet; it doesn't matter. The private key is kept by yourself. Generally speaking, you will never use your own private key.

The result signed with a private key can only be verified by the corresponding public key. Data encrypted with a public key can only be decrypted by the corresponding private key.

Implementation Principle

Assume we have two systems interacting: System A and System B. System A has a pair of RSA key pairs, which we call public key APubKey and private key APriKey. System B has a pair of RSA key pairs, which we call public key BPubKey and private key BPriKey.

The private key is generated by each system and stored internally. The role of the private key is to tell the sender that the recipient is definitely me. The role of the public key is to tell the recipient whether it was sent by me. Based on these two theorems, we design the program.

First, System A calls System B's Api1 interface. Suppose we pass "hello," and System B will reply with "world." How should we design to ensure security? First, how does System A send a message so that System B knows it was sent by System A and not by a man-in-the-middle attack? Here, we need to use a signature. That is, System A uses APriKey to encrypt "hello." If the sent data has signature X and content "hello," System B will verify the signature of "hello" upon receiving it. If the verification result is that it was encrypted with a private key, then which public key you use for signature verification will ensure which system sent it. Data signed with APriKey can only be verified with APubKey, so System B can be sure it was sent by System A and not by another system. However, we are still transmitting plaintext information, so we also need to encrypt the data. Generally, we choose the receiver's public key for encryption because only data encrypted with the receiver's public key can be decrypted with the receiver's private key.

Project Creation

First, we create a simple ASP.NET Core Web API project.

Create a configuration option to store the private and public keys.

public class RsaOptions
{
    public string PrivateKey { get; set; }
}

Create a Scheme option class.

public class AuthSecurityRsaOptions: AuthenticationSchemeOptions
{
}

Define a constant.

public class AuthSecurityRsaDefaults
{
    public const string AuthenticationScheme = "SecurityRsaAuth";
}

Create our authentication handler AuthSecurityRsaAuthenticationHandler.

public class AuthSecurityRsaAuthenticationHandler: AuthenticationHandler<AuthSecurityRsaOptions>
{
    // Replace with Redis in production
    private readonly ConcurrentDictionary<string, object> _repeatRequestMap =
        new ConcurrentDictionary<string, object>();

    public AuthSecurityRsaAuthenticationHandler(IOptionsMonitor<AuthSecurityRsaOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock) : base(options, logger, encoder, clock)
    {
    }

    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        try
        {
            string authorization = Request.Headers["AuthSecurity-Authorization"];
            // If no authorization header found, nothing to process further
            if (string.IsNullOrWhiteSpace(authorization))
                return AuthenticateResult.NoResult();

            var authorizationSplit = authorization.Split('.');
            if (authorizationSplit.Length != 4)
                return await AuthenticateResultFailAsync("Invalid signature parameters");
            var reg = new Regex(@"[0-9a-zA-Z]{1,40}");

            var requestId = authorizationSplit[0];
            if (string.IsNullOrWhiteSpace(requestId) || !reg.IsMatch(requestId))
                return await AuthenticateResultFailAsync("Invalid request ID");

            var appid = authorizationSplit[1];
            if (string.IsNullOrWhiteSpace(appid) || !reg.IsMatch(appid))
                return await AuthenticateResultFailAsync("Invalid application ID");

            var timeStamp = authorizationSplit[2];
            if (string.IsNullOrWhiteSpace(timeStamp) || !long.TryParse(timeStamp, out var timestamp))
                return await AuthenticateResultFailAsync("Invalid request timestamp");
            // Discard requests older than 30 minutes
            if (Math.Abs(UtcTime.CurrentTimeMillis() - timestamp) > 30 * 60 * 1000)
                return await AuthenticateResultFailAsync("Request has expired");

            var sign = authorizationSplit[3];
            if (string.IsNullOrWhiteSpace(sign))
                return await AuthenticateResultFailAsync("Invalid signature parameters");
            // Get from database
            // Request.HttpContext.RequestServices.GetService<DbContext>()
            var app = AppCallerStorage.ApiCallers.FirstOrDefault(o=>o.Id==appid);
            if (app == null)
                return AuthenticateResult.Fail("Application information not found");
            // Get request body
            var body = await Request.RequestBodyAsync();

            // Verify signature
            if (!RsaFunc.ValidateSignature(app.AppPublickKey, $"{requestId}{appid}{timeStamp}{body}", sign))
                return await AuthenticateResultFailAsync("Signature verification failed");
            var repeatKey = $"AuthSecurityRequestDistinct:{appid}:{requestId}";
            // Replace with cache or Redis. This project does not include key deletion with expiration; it should be set to expire in 1 hour to account for a 30-minute server time difference.
            if (_repeatRequestMap.ContainsKey(repeatKey) || !_repeatRequestMap.TryAdd(repeatKey,null))
            {
                return await AuthenticateResultFailAsync("Duplicate submission");
            }

            // Assign identity
            var identity = new ClaimsIdentity(AuthSecurityRsaDefaults.AuthenticationScheme);
            identity.AddClaim(new Claim("appid", appid));
            identity.AddClaim(new Claim("appname", app.Name));
            identity.AddClaim(new Claim("role", "app"));
            // ...

            var principal = new ClaimsPrincipal(identity);
            return HandleRequestResult.Success(new AuthenticationTicket(principal, new AuthenticationProperties(), Scheme.Name));
        }
        catch (Exception ex)
        {
            Logger.LogError(ex, "RSA signature failure");
            return await AuthenticateResultFailAsync("Authentication failed");
        }
    }

    private async Task<AuthenticateResult> AuthenticateResultFailAsync(string message)
    {
        Response.StatusCode = 401;
        await Response.WriteAsync(message);
        return AuthenticateResult.Fail(message);
    }
}

Third, we add extension methods.

public static class AuthSecurityRsaExtension
{
    public static AuthenticationBuilder AddAuthSecurityRsa(this AuthenticationBuilder builder)
        => builder.AddAuthSecurityRsa(AuthSecurityRsaDefaults.AuthenticationScheme, _ => { });

    public static AuthenticationBuilder AddAuthSecurityRsa(this AuthenticationBuilder builder, Action<AuthSecurityRsaOptions> configureOptions)
        => builder.AddAuthSecurityRsa(AuthSecurityRsaDefaults.AuthenticationScheme, configureOptions);

    public static AuthenticationBuilder AddAuthSecurityRsa(this AuthenticationBuilder builder, string authenticationScheme, Action<AuthSecurityRsaOptions> configureOptions)
        => builder.AddAuthSecurityRsa(authenticationScheme, displayName: null, configureOptions: configureOptions);

    public static AuthenticationBuilder AddAuthSecurityRsa(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action<AuthSecurityRsaOptions> configureOptions)
    {
        return builder.AddScheme<AuthSecurityRsaOptions, AuthSecurityRsaAuthenticationHandler>(authenticationScheme, displayName, configureOptions);
    }
}

Add response encryption/decryption middleware SafeResponseMiddleware.

public class SafeResponseMiddleware
{
    private readonly RequestDelegate _next;

    public SafeResponseMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task Invoke(HttpContext context)
    {
        // AuthSecurity-Authorization
        if (context.Request.Headers.TryGetValue("AuthSecurity-Authorization", out var authorization) && !string.IsNullOrWhiteSpace(authorization))
        {
            // Capture Response.Body content
            var originalBodyStream = context.Response.Body;
            await using (var newResponse = new MemoryStream())
            {
                // Replace response stream
                context.Response.Body = newResponse;
                await _next(context);
                string responseString = null;
                var identityIsAuthenticated = context.User?.Identity?.IsAuthenticated;
                if (identityIsAuthenticated.HasValue && identityIsAuthenticated.Value)
                {
                    var authorizationSplit = authorization.ToString().Split('.');
                    var requestId = authorizationSplit[0];
                    var appid = authorizationSplit[1];

                    using (var reader = new StreamReader(newResponse))
                    {
                        newResponse.Position = 0;
                        responseString = (await reader.ReadToEndAsync()) ?? string.Empty;
                        var responseStr = JsonConvert.SerializeObject(responseString);
                        var app = AppCallerStorage.ApiCallers.FirstOrDefault(o => o.Id == appid);
                        var encryptBody = RsaFunc.Encrypt(app.AppPublickKey, responseStr);
                        var signature = RsaFunc.CreateSignature(app.MyPrivateKey, $"{requestId}{appid}{encryptBody}");
                        context.Response.Headers.Add("AuthSecurity-Signature", signature);
                        responseString = encryptBody;
                    }

                    await using (var writer = new StreamWriter(originalBodyStream))
                    {
                        await writer.WriteAsync(responseString);
                        await writer.FlushAsync();
                    }
                }
            }
        }
        else
        {
            await _next(context);
        }
    }
}

Add a base controller to enable authentication.

[Authorize(AuthenticationSchemes = AuthSecurityRsaDefaults.AuthenticationScheme)]
public class RsaBaseController : ControllerBase
{
}

At this point, our API interface is mostly complete, compatible with the Microsoft framework, but we still cannot do happy coding. Next, we need to implement model parsing and validation.

Model Parsing

First, we need to understand how Microsoft binds the request body string to the model. Through source code analysis, we can see that ASP.NET Core uses IModelBinder.

First, implement model binding.

public class EncryptBodyModelBinder : IModelBinder
{
    public async Task BindModelAsync(ModelBindingContext bindingContext)
    {
        var httpContext = bindingContext.HttpContext;
        //if (bindingContext.ModelType != typeof(string))
        //    return;
        string authorization = httpContext.Request.Headers["AuthSecurity-Authorization"];
        if (!string.IsNullOrWhiteSpace(authorization))
        {
            // If there are parameters to receive, deserialize and verify
            if (bindingContext.ModelType != null)
            {
                // Get request body
                var encryptBody = await httpContext.Request.RequestBodyAsync();
                if (string.IsNullOrWhiteSpace(encryptBody))
                    return;
                // Decrypt
                var rsaOptions = httpContext.RequestServices.GetService<RsaOptions>();
                var body = RsaFunc.Decrypt(rsaOptions.PrivateKey, encryptBody);
                var request = JsonConvert.DeserializeObject(body, bindingContext.ModelType);
                if (request == null)
                {
                    return;
                }
                bindingContext.Result = ModelBindingResult.Success(request);
            }
        }
    }
}

Add an attribute for parsing.

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Enum | AttributeTargets.Property | AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)]
public class RsaModelParseAttribute : Attribute, IBinderTypeProviderMetadata, IBindingSourceMetadata, IModelNameProvider
{
    private readonly ModelBinderAttribute modelBinderAttribute = new ModelBinderAttribute() { BinderType = typeof(EncryptBodyModelBinder) };

    public BindingSource BindingSource => modelBinderAttribute.BindingSource;

    public string Name => modelBinderAttribute.Name;

    public Type BinderType => modelBinderAttribute.BinderType;
}

Add a test DTO.

[RsaModelParse]
public class TestModel
{
    [Display(Name = "id"), Required(ErrorMessage = "{0} cannot be empty")]
    public string Id { get; set; }
}

Create a model controller.

[Route("api/[controller]/[action]")]
[ApiController]
public class TestController : RsaBaseController
{
    [AllowAnonymous]
    public IActionResult Test()
    {
        return Ok();
    }

    // Normal test
    public IActionResult Test1()
    {
        var appid = Request.HttpContext.User.Claims.FirstOrDefault(o=>o.Type== "appid").Value;
        var appname = Request.HttpContext.User.Claims.FirstOrDefault(o=>o.Type== "appname").Value;

        return Ok($"appid:{appid},appname:{appname}");
    }
    /// Model validation
    public IActionResult Test2(TestModel request)
    {
        return Ok(JsonConvert.SerializeObject(request));
    }
    // Error handling test
    public IActionResult Test3(TestModel request)
    {
        var x = 0;
        var a = 1 / x;
        return Ok("ok");
    }
}

Add global exception handling.

public class HttpGlobalExceptionFilter : IExceptionFilter
{
    private readonly ILogger<HttpGlobalExceptionFilter> _logger;

    public HttpGlobalExceptionFilter(ILogger<HttpGlobalExceptionFilter> logger)
    {
        _logger = logger;
    }

    public void OnException(ExceptionContext context)
    {
        _logger.LogError(new EventId(context.Exception.HResult),
            context.Exception,
            context.Exception.Message);
        context.Result = new OkObjectResult("Unknown exception");
        context.HttpContext.Response.StatusCode = (int)HttpStatusCode.OK;
        context.ExceptionHandled = true;
    }
}

Add model validation.

public class ValidateModelStateFilter : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext context)
    {
        if (context.ModelState.IsValid)
        {
            return;
        }

        var validationErrors = context.ModelState
            .Keys
            .SelectMany(k => context.ModelState[k].Errors)
            .Select(e => e.ErrorMessage)
            .ToArray();

        context.Result = new OkObjectResult(string.Join(",", validationErrors));
        context.HttpContext.Response.StatusCode = (int)HttpStatusCode.OK;
    }
}

Startup configuration.

public void ConfigureServices(IServiceCollection services)
{
    services.Configure<ApiBehaviorOptions>(options =>
    {
        // Ignore the built-in validation from [ApiController]
        options.SuppressModelStateInvalidFilter = true;
    });
    services.AddControllers(options =>
    {
        options.Filters.Add<HttpGlobalExceptionFilter>();
        options.Filters.Add<ValidateModelStateFilter>();
    });
    services.AddControllers();

    services.AddAuthentication().AddAuthSecurityRsa();
    services.AddSingleton(sp =>
    {
        return new RsaOptions()
        {
            PrivateKey = Configuration.GetSection("RsaConfig")["PrivateKey"],
        };
    });
}

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }

    app.UseMiddleware<SafeResponseMiddleware>();
    app.UseRouting();
    app.UseAuthentication();
    app.UseAuthorization();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });
}

At this point, all server-side API interfaces and configurations are complete. Next, we can write the client interface and generate RSA key pairs to start using the API.

How to Generate RSA Keys

First, download OpenSSL.

Download link OpenSSL

Double-click to install.

Run the following commands.

Open bin/openssl.exe
Generate RSA private key
openssl>genrsa -out rsa_private_key.pem 2048

Generate RSA public key
openssl>rsa -in rsa_private_key.pem -pubout -out rsa_public_key.pem

Convert RSA private key to PKCS8 format
openssl>pkcs8 -topk8 -inform PEM -in rsa_private_key.pem -outform PEM -nocrypt -out rsa_pkcs8_private_key.pem

The public and private keys are not in XML format. C# requires XML format keys for RSA. So, we need to convert the keys.

First, download a key conversion tool from NuGet.

Install-Package BouncyCastle.NET Core -Version 1.8.8
public class RsaKeyConvert
{
    private RsaKeyConvert()
    {
    }

    public static string RsaPrivateKeyJava2DotNet(string privateKey)
    {
        RsaPrivateCrtKeyParameters privateKeyParam = (RsaPrivateCrtKeyParameters)PrivateKeyFactory.CreateKey(Convert.FromBase64String(TrimPrivatePrefixSuffix(privateKey)));

        return string.Format("<RSAKeyValue><Modulus>{0}</Modulus><Exponent>{1}</Exponent><P>{2}</P><Q>{3}</Q><DP>{4}</DP><DQ>{5}</DQ><InverseQ>{6}</InverseQ><D>{7}</D></RSAKeyValue>",
            Convert.ToBase64String(privateKeyParam.Modulus.ToByteArrayUnsigned()),
            Convert.ToBase64String(privateKeyParam.PublicExponent.ToByteArrayUnsigned()),
            Convert.ToBase64String(privateKeyParam.P.ToByteArrayUnsigned()),
            Convert.ToBase64String(privateKeyParam.Q.ToByteArrayUnsigned()),
            Convert.ToBase64String(privateKeyParam.DP.ToByteArrayUnsigned()),
            Convert.ToBase64String(privateKeyParam.DQ.ToByteArrayUnsigned()),
            Convert.ToBase64String(privateKeyParam.QInv.ToByteArrayUnsigned()),
            Convert.ToBase64String(privateKeyParam.Exponent.ToByteArrayUnsigned()));
    }

    public static string RsaPrivateKeyDotNet2Java(string privateKey)
    {
        XmlDocument doc = new XmlDocument();
        doc.LoadXml(TrimPrivatePrefixSuffix(privateKey));
        BigInteger m = new BigInteger(1, Convert.FromBase64String(doc.DocumentElement.GetElementsByTagName("Modulus")[0].InnerText));
        BigInteger exp = new BigInteger(1, Convert.FromBase64String(doc.DocumentElement.GetElementsByTagName("Exponent")[0].InnerText));
        BigInteger d = new BigInteger(1, Convert.FromBase64String(doc.DocumentElement.GetElementsByTagName("D")[0].InnerText));
        BigInteger p = new BigInteger(1, Convert.FromBase64String(doc.DocumentElement.GetElementsByTagName("P")[0].InnerText));
        BigInteger q = new BigInteger(1, Convert.FromBase64String(doc.DocumentElement.GetElementsByTagName("Q")[0].InnerText));
        BigInteger dp = new BigInteger(1, Convert.FromBase64String(doc.DocumentElement.GetElementsByTagName("DP")[0].InnerText));
        BigInteger dq = new BigInteger(1, Convert.FromBase64String(doc.DocumentElement.GetElementsByTagName("DQ")[0].InnerText));
        BigInteger qinv = new BigInteger(1, Convert.FromBase64String(doc.DocumentElement.GetElementsByTagName("InverseQ")[0].InnerText));

        RsaPrivateCrtKeyParameters privateKeyParam = new RsaPrivateCrtKeyParameters(m, exp, d, p, q, dp, dq, qinv);

        PrivateKeyInfo privateKeyInfo = PrivateKeyInfoFactory.CreatePrivateKeyInfo(privateKeyParam);
        byte[] serializedPrivateBytes = privateKeyInfo.ToAsn1Object().GetEncoded();
        return Convert.ToBase64String(serializedPrivateBytes);
    }

    public static string RsaPublicKeyJava2DotNet(string publicKey)
    {
        RsaKeyParameters publicKeyParam = (RsaKeyParameters)PublicKeyFactory.CreateKey(Convert.FromBase64String(TrimPublicPrefixSuffix(publicKey)));
        return string.Format("<RSAKeyValue><Modulus>{0}</Modulus><Exponent>{1}</Exponent></RSAKeyValue>",
            Convert.ToBase64String(publicKeyParam.Modulus.ToByteArrayUnsigned()),
            Convert.ToBase64String(publicKeyParam.Exponent.ToByteArrayUnsigned()));
    }

    public static string RsaPublicKeyDotNet2Java(string publicKey)
    {
        XmlDocument doc = new XmlDocument();
        doc.LoadXml(TrimPublicPrefixSuffix(publicKey));
        BigInteger m = new BigInteger(1, Convert.FromBase64String(doc.DocumentElement.GetElementsByTagName("Modulus")[0].InnerText));
        BigInteger p = new BigInteger(1, Convert.FromBase64String(doc.DocumentElement.GetElementsByTagName("Exponent")[0].InnerText));
        RsaKeyParameters pub = new RsaKeyParameters(false, m, p);

        SubjectPublicKeyInfo publicKeyInfo = SubjectPublicKeyInfoFactory.CreateSubjectPublicKeyInfo(pub);
        byte[] serializedPublicBytes = publicKeyInfo.ToAsn1Object().GetDerEncoded();
        return Convert.ToBase64String(serializedPublicBytes);
    }

    public static string TrimPublicPrefixSuffix(string publicKey)
    {
        return publicKey
            .Replace("-----BEGIN PUBLIC KEY-----", string.Empty)
            .Replace("-----END PUBLIC KEY-----", string.Empty)
            .Replace("\r\n", "");
    }

    public static string TrimPrivatePrefixSuffix(string privateKey)
    {
        return privateKey
            .Replace("-----BEGIN PRIVATE KEY-----", string.Empty)
            .Replace("-----END PRIVATE KEY-----", string.Empty)
            .Replace("\r\n", "");
    }
}

Write the Client and Start Calling

Start both projects sequentially, and you can see that the call succeeded.

This project uses RSA bidirectional signing and encryption to integrate with ASP.NET Core's authorization system and can obtain the calling system's user.

Seamlessly integrated with ASP.NET Core authentication and authorization system (a future article will cover how to design authorization).

System interaction uses bidirectional encryption and signature authentication.

Seamlessly integrated with model validation.

Perfectly handles response results.

Note: This project is just a learning demo. Based on practical conclusions, RSA encryption only meets the "most secure API" condition, but performance degrades sharply as the body size increases. Therefore, it is not an optimal choice. It can be used when both parties set up keys to provide an API. In practice, you can use symmetric encryption, such as AES or DES, for body encryption/decryption. However, for signing, RSA is perfectly fine. This time, we used RSA2 (RSA 2048-bit key). The larger the key size, the higher the encryption level, but the lower the decryption performance.

Of course, you can directly use HTTPS. This article is not necessarily about bidirectional processing; it is more about sharing how to integrate with ASP.NET Core's authentication system and model validation, without needing to pile on many attributes.

Demo: AspNetCoreSafeApi

Finally

Share the EF Core sharding, table splitting, read-write separation component I developed. I hope to contribute to the .NET ecosystem. If you like it or find it useful, please click Star or like to let more people see it.

Gitee Star Help the dotnet ecosystem GitHub Star

Keep Exploring

Related Reading

More Articles