Arayn
Arayn

Reputation: 1106

ASP.NET CORE 2.0 Distributed Session Management with AWS DynamoDB

I'm looking out to implement distributed session management in ASP.NET CORE 2.0 with Amazon Dynamodb. But couldn't find any documentation or sample source code.

How can I implement distributed session management with ASP.NET CORE 2.0 and DynamoDb?

Upvotes: 2

Views: 1563

Answers (1)

Kirkaiya
Kirkaiya

Reputation: 1274

UPDATE 2023: On the off chance anyone is still looking for a solution, AWS has announced an official package for this, which is no doubt nicer than my homebrew from 2018 (below). Read the launch blog here: https://aws.amazon.com/blogs/developer/introducing-the-aws-net-distributed-cache-provider-for-dynamodb-preview/

Original answer:

I've implemented AWS DynamoDB for ASP.NET Core distributed session state, including storing the session-cookie encryption keys in DynamoDB also (you have to store the keys somewhere so that different instances of your app can decode each others cookies).

Note that this is a "bare bones" implementation, I haven't make it yet testable, or using DI, etc. The two classes are DynamoDbCache and DdbXmlRepository.

DynamoDBCache.cs:

using System;
using System.Threading;
using System.Threading.Tasks;
using Amazon.DynamoDBv2;
using Amazon.DynamoDBv2.DocumentModel;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Options;

namespace Microsoft.Extensions.Caching.DynamoDb
{
    public class DynamoDbCache : IDistributedCache
    {
        private static IAmazonDynamoDB _client;
        private static Table _table;

        private string _tableName = "ASP.NET_SessionState";
        private string _ttlfield = "TTL";
        private int _sessionMinutes = 20;
        private enum ExpiryType
        {
            Sliding,
            Absolute
        }

        public DynamoDbCache(IOptions<DynamoDbCacheOptions> optionsAccessor, IAmazonDynamoDB dynamoDb)
        {
            _client = dynamoDb;

            if (optionsAccessor != null)
            {
                _tableName = optionsAccessor.Value.TableName;
                _ttlfield = optionsAccessor.Value.TtlAttribute;
                _sessionMinutes = (int)optionsAccessor.Value.IdleTimeout.TotalMinutes;
            }

            if (_client == null)
            {
                _client = new AmazonDynamoDBClient();
            }

            if (_table == null)
            {
                _table = Table.LoadTable(_client, _tableName);
            }
        }

        public byte[] Get(string key)
        {
            return GetAsync(key).Result;
        }

        public async Task<byte[]> GetAsync(string key, CancellationToken token = default(CancellationToken))
        {
            var value = await _table.GetItemAsync(key);
            if (value == null || value["Session"] == null)
            {
                return null;
            }

            return value["Session"].AsByteArray();
        }

        public void Refresh(string key)
        {
            var value = _table.GetItemAsync(key).Result;
            if (value == null || value["ExpiryType"] == null || value["ExpiryType"] != "Sliding")
            {
                return;
            }
            value[_ttlfield] = DateTimeOffset.Now.ToUniversalTime().ToUnixTimeSeconds() + (_sessionMinutes * 60);

            Task.Run(() => Set(key, value["Session"].AsByteArray(), new DistributedCacheEntryOptions { SlidingExpiration = new TimeSpan(0, _sessionMinutes, 0) }));
        }

        public async Task RefreshAsync(string key, CancellationToken token = default(CancellationToken))
        {
            var value = _table.GetItemAsync(key).Result;
            if (value == null || value["ExpiryType"] == null || value["ExpiryType"] != "Sliding")
            {
                return;
            }
            value[_ttlfield] = DateTimeOffset.Now.ToUniversalTime().ToUnixTimeSeconds() + (_sessionMinutes * 60);

            await SetAsync(key, value["Session"].AsByteArray(), new DistributedCacheEntryOptions { SlidingExpiration = new TimeSpan(0, _sessionMinutes, 0) });
        }

        public void Remove(string key)
        {
            _table.DeleteItemAsync(key).Wait();
        }

        public async Task RemoveAsync(string key, CancellationToken token = default(CancellationToken))
        {
            await _table.DeleteItemAsync(key);
        }

        public void Set(string key, byte[] value, DistributedCacheEntryOptions options)
        {
            SetAsync(key, value, options).Wait();
        }

        public async Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default(CancellationToken))
        {
            ExpiryType expiryType;
            var epoctime = GetEpochExpiry(options, out expiryType);
            var _ssdoc = new Document();

            _ssdoc.Add("SessionId", key);
            _ssdoc.Add("Session", value);
            _ssdoc.Add("CreateDate", DateTime.Now.ToUniversalTime().ToString("o"));
            _ssdoc.Add("ExpiryType", expiryType.ToString());
            _ssdoc.Add(_ttlfield, epoctime);

            await _table.PutItemAsync(_ssdoc);
        }

        private long GetEpochExpiry(DistributedCacheEntryOptions options, out ExpiryType expiryType)
        {
            if (options.SlidingExpiration.HasValue)
            {
                expiryType = ExpiryType.Sliding;
                return DateTimeOffset.Now.ToUniversalTime().ToUnixTimeSeconds() + (long)options.SlidingExpiration.Value.TotalSeconds;

            }
            else if (options.AbsoluteExpiration.HasValue)
            {
                expiryType = ExpiryType.Absolute;
                return options.AbsoluteExpiration.Value.ToUnixTimeSeconds();

            }
            else if (options.AbsoluteExpirationRelativeToNow.HasValue)
            {
                expiryType = ExpiryType.Absolute;
                return DateTimeOffset.Now.Add(options.AbsoluteExpirationRelativeToNow.Value).ToUniversalTime().ToUnixTimeSeconds();
            }
            else
            {
                throw new Exception("Cache expiry option must be set to Sliding, Absolute or Absolute relative to now");
            }
        }
    }
}

and DdbXmlRepository:

using Amazon.DynamoDBv2;
using Amazon.DynamoDBv2.DataModel;
using Microsoft.AspNetCore.DataProtection.Repositories;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Xml.Linq;

namespace Website.Session
{
    public class DdbXmlRepository : IXmlRepository
    {
        private static IAmazonDynamoDB _dynamoDb;

        public DdbXmlRepository(IAmazonDynamoDB dynamoDb)
        {
            _dynamoDb = dynamoDb;
        }

        public IReadOnlyCollection<XElement> GetAllElements()
        {

            var context = new DynamoDBContext(_dynamoDb);
            var search = context.ScanAsync<XmlKey>(new List<ScanCondition>());
            var results = search.GetRemainingAsync().Result;

            return results.Select(x => XElement.Parse(x.Xml)).ToList();
        }

        public void StoreElement(XElement element, string friendlyName)
        {
            var key = new XmlKey
            {
                Xml = element.ToString(SaveOptions.DisableFormatting),
                FriendlyName = friendlyName
            };

            var context = new DynamoDBContext(_dynamoDb);
            context.SaveAsync(key).Wait();
        }
    }

    [DynamoDBTable("AspXmlKeys")]
    public class XmlKey
    {
        [DynamoDBHashKey]
        public string KeyId { get; set; } = Guid.NewGuid().ToString();
        public string Xml { get; set; }
        public string FriendlyName { get; set; }
    }
}

You also need a service-collection extension:

using System;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.DynamoDb;

namespace Microsoft.Extensions.DependencyInjection
{
    public static class DynamoDbCacheServiceCollectionExtensions
    {
        /// <summary>
        /// Adds Amazon DynamoDB caching services to the specified <see cref="IServiceCollection" />.
        /// </summary>
        /// <param name="services">The <see cref="IServiceCollection" /> to add services to.</param>
        /// <param name="setupAction">An <see cref="Action{DynamoDbCacheOptions}"/> to configure the provided
        /// <see cref="DynamoDbCacheOptions"/>.</param>
        /// <returns>The <see cref="IServiceCollection"/> so that additional calls can be chained.</returns>
        public static IServiceCollection AddDistributedDynamoDbCache(this IServiceCollection services, Action<DynamoDbCacheOptions> setupAction)
        {
            if (services == null)
            {
                throw new ArgumentNullException(nameof(services));
            }

            if (setupAction == null)
            {
                throw new ArgumentNullException(nameof(setupAction));
            }

            services.AddOptions();
            services.Configure(setupAction);
            services.Add(ServiceDescriptor.Singleton<IDistributedCache, DynamoDbCache>());

            return services;
        }
    }
}

And finally I used an options class for setting things like table name, default expiry, etc:

using Microsoft.Extensions.Options;
using System;

namespace Microsoft.Extensions.Caching.DynamoDb
{
    public class DynamoDbCacheOptions : IOptions<DynamoDbCacheOptions>
    {
        public string TableName { get; set; } = "ASP.NET_SessionState";
        public TimeSpan IdleTimeout { get; set; } = new TimeSpan(0, 20, 0);
        public string TtlAttribute { get; set; } = "TTL";

        DynamoDbCacheOptions IOptions<DynamoDbCacheOptions>.Value
        {
            get { return this; }
        }
    }
}

In your startup, you'll need to wire it all up in ConfigureServices with code like this:

services.AddDefaultAWSOptions(Configuration.GetAWSOptions());
            services.AddAWSService<IAmazonDynamoDB>();
services.AddSingleton<IXmlRepository, DdbXmlRepository>();
            
            services.AddDistributedDynamoDbCache(o => {
                o.TableName = "TechSummitSessionState";
                o.IdleTimeout = TimeSpan.FromMinutes(30);
            });

            services.AddSession(o => {
                o.IdleTimeout = TimeSpan.FromMinutes(30);
                o.Cookie.HttpOnly = false;
            });
services.AddDataProtection()
                .AddKeyManagementOptions(o => o.XmlRepository = sp.GetService<IXmlRepository>());

Hope this helps! It's working for my app :-)

Upvotes: 3

Related Questions