Skip to content

Commit 4110116

Browse files
committed
Persist identity grant store to db
Persist machine Keys to Redis
1 parent cb546c9 commit 4110116

17 files changed

Lines changed: 2121 additions & 21 deletions

k8s/deployments.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ kind: Deployment
6969
metadata:
7070
name: identity
7171
spec:
72+
replicas: 3
7273
paused: true
7374
template:
7475
metadata:

src/Services/Identity/Identity.API/Configuration/Config.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ public static IEnumerable<Client> GetClients(Dictionary<string,string> clientsUr
8080
},
8181
ClientUri = $"{clientsUrl["Mvc"]}", // public uri of the client
8282
AllowedGrantTypes = GrantTypes.Hybrid,
83+
AllowAccessTokensViaBrowser = false,
8384
RequireConsent = false,
8485
AllowOfflineAccess = true,
8586
RedirectUris = new List<string>
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
namespace DataProtectionExtensions
2+
{
3+
using System;
4+
using System.Linq;
5+
using System.Security.Cryptography.X509Certificates;
6+
using Microsoft.AspNetCore.DataProtection;
7+
using Microsoft.AspNetCore.DataProtection.Repositories;
8+
using Microsoft.AspNetCore.DataProtection.XmlEncryption;
9+
using Microsoft.Extensions.DependencyInjection;
10+
using Microsoft.Extensions.Logging;
11+
using System.Net;
12+
13+
/// <summary>
14+
/// Extension methods for <see cref="IDataProtectionBuilder"/> for configuring
15+
/// data protection options.
16+
/// </summary>
17+
public static class DataProtectionBuilderExtensions
18+
{
19+
/// <summary>
20+
/// Sets up data protection to persist session keys in Redis.
21+
/// </summary>
22+
/// <param name="builder">The <see cref="IDataProtectionBuilder"/> used to set up data protection options.</param>
23+
/// <param name="redisConnectionString">The connection string specifying the Redis instance and database for key storage.</param>
24+
/// <returns>
25+
/// The <paramref name="builder" /> for continued configuration.
26+
/// </returns>
27+
/// <exception cref="System.ArgumentNullException">
28+
/// Thrown if <paramref name="builder" /> or <paramref name="redisConnectionString" /> is <see langword="null" />.
29+
/// </exception>
30+
/// <exception cref="System.ArgumentException">
31+
/// Thrown if <paramref name="redisConnectionString" /> is empty.
32+
/// </exception>
33+
public static IDataProtectionBuilder PersistKeysToRedis(this IDataProtectionBuilder builder, string redisConnectionString)
34+
{
35+
if (builder == null)
36+
{
37+
throw new ArgumentNullException(nameof(builder));
38+
}
39+
40+
if (redisConnectionString == null)
41+
{
42+
throw new ArgumentNullException(nameof(redisConnectionString));
43+
}
44+
45+
if (redisConnectionString.Length == 0)
46+
{
47+
throw new ArgumentException("Redis connection string may not be empty.", nameof(redisConnectionString));
48+
}
49+
50+
var ips = Dns.GetHostAddressesAsync(redisConnectionString).Result;
51+
52+
return builder.Use(ServiceDescriptor.Singleton<IXmlRepository>(services => new RedisXmlRepository(ips.First().ToString(), services.GetRequiredService<ILogger<RedisXmlRepository>>())));
53+
}
54+
55+
/// <summary>
56+
/// Updates an <see cref="IDataProtectionBuilder"/> to use the service of
57+
/// a specific type, removing all other services of that type.
58+
/// </summary>
59+
/// <param name="builder">The <see cref="IDataProtectionBuilder"/> that should use the specified service.</param>
60+
/// <param name="descriptor">The <see cref="ServiceDescriptor"/> with the service the <paramref name="builder" /> should use.</param>
61+
/// <returns>
62+
/// The <paramref name="builder" /> for continued configuration.
63+
/// </returns>
64+
/// <exception cref="System.ArgumentNullException">
65+
/// Thrown if <paramref name="builder" /> or <paramref name="descriptor" /> is <see langword="null" />.
66+
/// </exception>
67+
public static IDataProtectionBuilder Use(this IDataProtectionBuilder builder, ServiceDescriptor descriptor)
68+
{
69+
// This algorithm of removing all other services of a specific type
70+
// before adding the new/replacement service is how the base ASP.NET
71+
// DataProtection bits work. Due to some of the differences in how
72+
// that base set of bits handles DI, it's better to follow suit
73+
// and work in the same way than to try and debug weird issues.
74+
if (builder == null)
75+
{
76+
throw new ArgumentNullException(nameof(builder));
77+
}
78+
79+
if (descriptor == null)
80+
{
81+
throw new ArgumentNullException(nameof(descriptor));
82+
}
83+
84+
for (int i = builder.Services.Count - 1; i >= 0; i--)
85+
{
86+
if (builder.Services[i]?.ServiceType == descriptor.ServiceType)
87+
{
88+
builder.Services.RemoveAt(i);
89+
}
90+
}
91+
92+
builder.Services.Add(descriptor);
93+
return builder;
94+
}
95+
}
96+
}
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Diagnostics.CodeAnalysis;
4+
using System.IO;
5+
using System.Linq;
6+
using System.Text;
7+
using System.Text.RegularExpressions;
8+
using System.Xml.Linq;
9+
using Microsoft.AspNetCore.DataProtection.Repositories;
10+
using Microsoft.Extensions.Logging;
11+
using StackExchange.Redis;
12+
13+
namespace DataProtectionExtensions
14+
{
15+
/// <summary>
16+
/// Key repository that stores XML encrypted keys in a Redis distributed cache.
17+
/// </summary>
18+
/// <remarks>
19+
/// <para>
20+
/// The values stored in Redis are XML documents that contain encrypted session
21+
/// keys used for the protection of things like session state. The document contents
22+
/// are double-encrypted - first with a changing session key; then by a master key.
23+
/// As such, there's no risk in storing the keys in Redis - even if someone can crack
24+
/// the master key, they still need to also crack the session key. (Other solutions
25+
/// for sharing keys across a farm environment include writing them to files
26+
/// on a file share.)
27+
/// </para>
28+
/// <para>
29+
/// While the repository uses a hash to keep the set of encrypted keys separate, you
30+
/// can further separate these items from other items in Redis by specifying a unique
31+
/// database in the connection string.
32+
/// </para>
33+
/// <para>
34+
/// Consumers of the repository are responsible for caching the XML items as needed.
35+
/// Typically repositories are consumed by things like <see cref="Microsoft.AspNetCore.DataProtection.KeyManagement.KeyRingProvider"/>
36+
/// which generates <see cref="Microsoft.AspNetCore.DataProtection.KeyManagement.Internal.CacheableKeyRing"/>
37+
/// values that get cached. The mechanism is already optimized for caching so there's
38+
/// no need to create a redundant cache.
39+
/// </para>
40+
/// </remarks>
41+
/// <seealso cref="Microsoft.AspNetCore.DataProtection.Repositories.IXmlRepository" />
42+
/// <seealso cref="System.IDisposable" />
43+
public class RedisXmlRepository : IXmlRepository, IDisposable
44+
{
45+
/// <summary>
46+
/// The root cache key for XML items stored in Redis
47+
/// </summary>
48+
public static readonly string RedisHashKey = "DataProtectionXmlRepository";
49+
50+
/// <summary>
51+
/// The connection to the Redis backing store.
52+
/// </summary>
53+
private IConnectionMultiplexer _connection;
54+
55+
/// <summary>
56+
/// Flag indicating whether the object has been disposed.
57+
/// </summary>
58+
private bool _disposed = false;
59+
60+
/// <summary>
61+
/// Initializes a new instance of the <see cref="RedisXmlRepository"/> class.
62+
/// </summary>
63+
/// <param name="connectionString">
64+
/// The Redis connection string.
65+
/// </param>
66+
/// <param name="logger">
67+
/// The <see cref="ILogger{T}"/> used to log diagnostic messages.
68+
/// </param>
69+
/// <exception cref="System.ArgumentNullException">
70+
/// Thrown if <paramref name="connectionString" /> or <paramref name="logger" /> is <see langword="null" />.
71+
/// </exception>
72+
public RedisXmlRepository(string connectionString, ILogger<RedisXmlRepository> logger)
73+
: this(ConnectionMultiplexer.Connect(connectionString), logger)
74+
{
75+
}
76+
77+
/// <summary>
78+
/// Initializes a new instance of the <see cref="RedisXmlRepository"/> class.
79+
/// </summary>
80+
/// <param name="connection">
81+
/// The Redis database connection.
82+
/// </param>
83+
/// <param name="logger">
84+
/// The <see cref="ILogger{T}"/> used to log diagnostic messages.
85+
/// </param>
86+
/// <exception cref="System.ArgumentNullException">
87+
/// Thrown if <paramref name="connection" /> or <paramref name="logger" /> is <see langword="null" />.
88+
/// </exception>
89+
public RedisXmlRepository(IConnectionMultiplexer connection, ILogger<RedisXmlRepository> logger)
90+
{
91+
if (connection == null)
92+
{
93+
throw new ArgumentNullException(nameof(connection));
94+
}
95+
96+
if (logger == null)
97+
{
98+
throw new ArgumentNullException(nameof(logger));
99+
}
100+
101+
this._connection = connection;
102+
this.Logger = logger;
103+
104+
// Mask the password so it doesn't get logged.
105+
var configuration = Regex.Replace(this._connection.Configuration, @"password\s*=\s*[^,]*", "password=****", RegexOptions.IgnoreCase);
106+
this.Logger.LogDebug("Storing data protection keys in Redis: {RedisConfiguration}", configuration);
107+
}
108+
109+
/// <summary>
110+
/// Gets the logger.
111+
/// </summary>
112+
/// <value>
113+
/// The <see cref="ILogger{T}"/> used to log diagnostic messages.
114+
/// </value>
115+
public ILogger<RedisXmlRepository> Logger { get; private set; }
116+
117+
/// <summary>
118+
/// Performs application-defined tasks associated with freeing, releasing,
119+
/// or resetting unmanaged resources.
120+
/// </summary>
121+
public void Dispose()
122+
{
123+
this.Dispose(true);
124+
}
125+
126+
/// <summary>
127+
/// Gets all top-level XML elements in the repository.
128+
/// </summary>
129+
/// <returns>
130+
/// An <see cref="IReadOnlyCollection{T}"/> with the set of elements
131+
/// stored in the repository.
132+
/// </returns>
133+
public IReadOnlyCollection<XElement> GetAllElements()
134+
{
135+
var database = this._connection.GetDatabase();
136+
var hash = database.HashGetAll(RedisHashKey);
137+
var elements = new List<XElement>();
138+
139+
if (hash == null || hash.Length == 0)
140+
{
141+
return elements.AsReadOnly();
142+
}
143+
144+
foreach (var item in hash.ToStringDictionary())
145+
{
146+
elements.Add(XElement.Parse(item.Value));
147+
}
148+
149+
this.Logger.LogDebug("Read {XmlElementCount} XML elements from Redis.", elements.Count);
150+
return elements.AsReadOnly();
151+
}
152+
153+
/// <summary>
154+
/// Adds a top-level XML element to the repository.
155+
/// </summary>
156+
/// <param name="element">The element to add.</param>
157+
/// <param name="friendlyName">
158+
/// An optional name to be associated with the XML element.
159+
/// For instance, if this repository stores XML files on disk, the friendly name may
160+
/// be used as part of the file name. Repository implementations are not required to
161+
/// observe this parameter even if it has been provided by the caller.
162+
/// </param>
163+
/// <remarks>
164+
/// The <paramref name="friendlyName" /> parameter must be unique if specified.
165+
/// For instance, it could be the ID of the key being stored.
166+
/// </remarks>
167+
/// <exception cref="System.ArgumentNullException">
168+
/// Thrown if <paramref name="element" /> is <see langword="null" />.
169+
/// </exception>
170+
public void StoreElement(XElement element, string friendlyName)
171+
{
172+
if (element == null)
173+
{
174+
throw new ArgumentNullException(nameof(element));
175+
}
176+
177+
if (string.IsNullOrEmpty(friendlyName))
178+
{
179+
// The framework always passes in a name, but
180+
// the contract indicates this may be null or empty.
181+
friendlyName = Guid.NewGuid().ToString();
182+
}
183+
184+
this.Logger.LogDebug("Storing XML element with friendly name {XmlElementFriendlyName}.", friendlyName);
185+
186+
this._connection.GetDatabase().HashSet(RedisHashKey, friendlyName, element.ToString());
187+
}
188+
189+
/// <summary>
190+
/// Releases unmanaged and - optionally - managed resources.
191+
/// </summary>
192+
/// <param name="disposing">
193+
/// <see langword="true" /> to release both managed and unmanaged resources;
194+
/// <see langword="false" /> to release only unmanaged resources.
195+
/// </param>
196+
protected virtual void Dispose(bool disposing)
197+
{
198+
if (!this._disposed)
199+
{
200+
if (disposing)
201+
{
202+
if (this._connection != null)
203+
{
204+
this._connection.Close();
205+
this._connection.Dispose();
206+
}
207+
}
208+
209+
this._connection = null;
210+
this._disposed = true;
211+
}
212+
}
213+
}
214+
}

src/Services/Identity/Identity.API/Identity.API.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
</PackageReference>
3939
<PackageReference Include="IdentityServer4.AspNetIdentity" Version="1.0.1" />
4040
<PackageReference Include="IdentityServer4.EntityFramework" Version="1.0.1" />
41+
<PackageReference Include="StackExchange.Redis" Version="1.2.3" />
4142
</ItemGroup>
4243

4344
<Target Name="PrepublishScript" BeforeTargets="PrepareForPublish">

src/Services/Identity/Identity.API/Migrations/20170604151240_Init-persisted-grant.Designer.cs

Lines changed: 52 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)