-
Notifications
You must be signed in to change notification settings - Fork 968
/
GraphHelper.cs
274 lines (239 loc) · 12.9 KB
/
GraphHelper.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Graph;
using Microsoft.Graph.Me.GetMemberGroups;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using WebApp_OpenIDConnect_DotNet.Infrastructure;
namespace WebApp_OpenIDConnect_DotNet.Services
{
/// <summary>
/// Contains a set of methods and a pattern on how to handle group claims overage
/// </summary>
public class GraphHelper
{
private const string Cached_Graph_Token_Key = "JwtSecurityTokenUsedToCallWebAPI";
private const string Groups_Cache_Key = "groupClaims_";
private static IMemoryCache _memoryCache;
/// <summary>
/// This method inspects the claims collection created from the ID or Access token issued to a user and returns the groups that are present in the token.
/// If it detects groups overage, the method then makes calls to ProcessUserGroupsForOverage method to get the entire set of security groups and populates
/// the Claim Principal's "groups" claim with the complete list of groups.
/// </summary>
/// <param name="context">TokenValidatedContext</param>
/// <param name="requiredGroups">List</param>
public static async Task ProcessAnyGroupsOverage(TokenValidatedContext context, List<string> requiredGroupIds, CacheSettings cacheSettings)
{
ClaimsPrincipal principal = context.Principal;
if (principal == null || principal.Identity == null)
{
await Task.CompletedTask;
}
// ensure MemoryCache is available
_memoryCache = context.HttpContext.RequestServices.GetService<IMemoryCache>();
if (_memoryCache == null)
{
throw new ArgumentNullException("_memoryCache", "Memory cache is not available.");
}
// Checks if the incoming token contains a 'Group Overage' claim.
if (HasOverageOccurred(principal))
{
// Gets group values from cache if available.
var usergroups = GetUserGroupsFromCache(principal);
if (usergroups == null || usergroups.Count == 0) // Cache eviction
{
usergroups = await ProcessUserGroupsForOverage(context, requiredGroupIds);
}
// Populate the current ClaimsPrincipal 'groups' claim with all the groups to ensure that policy check works as expected
if (usergroups?.Count > 0)
{
var identity = (ClaimsIdentity)principal.Identity;
// Remove any existing 'groups' claim
RemoveExistingGroupsClaims(identity);
// And re-populate
RepopulateGroupsClaim(usergroups, identity);
// Here we add the groups in a cache variable so that calls to Graph can be minimized to fetch all the groups for a user.
// IMPORTANT: Group list is cached for 1 hr by default, and thus cached groups will miss any changes to a users group membership for this duration.
// For capturing real-time changes to a user's group membership, consider implementing MS Graph change notifications (https://learn.microsoft.com/graph/api/resources/webhooks)
SaveUsersGroupsToCache(usergroups, principal, cacheSettings);
}
}
}
/// <summary>
/// Checks if 'Group Overage' claim exists for signed-in user.
/// </summary>
/// <param name="identity"></param>
/// <returns></returns>
private static bool HasOverageOccurred(ClaimsPrincipal identity)
{
return identity.Claims.Any(x => x.Type == "hasgroups" || (x.Type == "_claim_names" && x.Value == "{\"groups\":\"src1\"}"));
}
/// <summary>
/// ID Token does not contain 'scp' claim.
/// This claims only exists in the Access Token.
/// </summary>
/// <param name="identity"></param>
/// <returns></returns>
private static bool IsAccessToken(ClaimsIdentity identity)
{
return identity.Claims.Any(x => x.Type == "scp" || x.Type == "http://schemas.microsoft.com/identity/claims/scope");
}
/// <summary>
/// This method inspects the claims collection created from the ID or Access token issued to a user and returns the groups that are present in the token . If it detects groups overage,
/// the method then makes calls to Microsoft Graph to fetch the group membership of the authenticated user.
/// </summary>
/// <param name="context">TokenValidatedContext</param>
private static async Task<List<string>> ProcessUserGroupsForOverage(TokenValidatedContext context, List<string> requiredGroupIds)
{
var allgroups = new List<string>();
try
{
// Before instantiating GraphServiceClient, the app should have granted admin consent for 'GroupMember.Read.All' permission.
var graphClient = context.HttpContext.RequestServices.GetService<GraphServiceClient>();
if (graphClient == null)
{
throw new ArgumentNullException("GraphServiceClient", "No service for type 'Microsoft.Graph.GraphServiceClient' has been registered in the Startup.");
}
// Checks if the SecurityToken is not null.
// For the Web Api, SecurityToken contains claims from the Access Token.
if (context.SecurityToken != null)
{
// Checks if 'JwtSecurityTokenUsedToCallWebAPI' key already exists.
// This key is required to acquire Access Token for Graph Service Client.
if (!context.HttpContext.Items.ContainsKey(Cached_Graph_Token_Key))
{
// For Web App, access token is retrieved using account identifier. But at this point account identifier is null.
// So, SecurityToken is saved in 'JwtSecurityTokenUsedToCallWebAPI' key.
// The key is then used to get the Access Token on-behalf of user.
context.HttpContext.Items.Add(Cached_Graph_Token_Key, context.SecurityToken as JwtSecurityToken);
}
try
{
// Request to get groups and directory roles that the user is a direct member of.
var memberPage = await graphClient.Me.GetMemberGroups.PostAsync(new GetMemberGroupsPostRequestBody() { SecurityEnabledOnly = false});
allgroups = memberPage.Value.ToList<string>();
if (allgroups?.Count > 0)
{
var principal = context.Principal;
if (principal != null)
{
var identity = principal.Identity as ClaimsIdentity;
// Remove existing groups claims
RemoveExistingGroupsClaims(identity);
// And re-populate
RepopulateGroupsClaim(allgroups, identity);
}
// return the full list of security groups
return allgroups;
}
}
catch (Exception graphEx)
{
var exMsg = graphEx.InnerException != null ? graphEx.InnerException.Message : graphEx.Message;
Console.WriteLine("Call to Microsoft Graph failed: " + exMsg);
}
}
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
finally
{
// Checks if the key 'JwtSecurityTokenUsedToCallWebAPI' exists.
if (context.HttpContext.Items.ContainsKey(Cached_Graph_Token_Key))
{
// Removes 'JwtSecurityTokenUsedToCallWebAPI' from Items collection.
// If not removed then it can cause failure to the application.
// Because this key is also added by StoreTokenUsedToCallWebAPI method of Microsoft.Identity.Web.
context.HttpContext.Items.Remove(Cached_Graph_Token_Key);
}
}
return null;
}
/// <summary>
/// Re-populate the `groups` claim with the complete list of groups fetched from MS Graph
/// </summary>
/// <param name="allgroups">The user's entire security group membership.</param>
/// <param name="identity">The identity.</param>
/// <autogeneratedoc />
private static void RepopulateGroupsClaim(List<string> allgroups, ClaimsIdentity identity)
{
foreach (string group in allgroups)
{
// The following code adds group ids to the 'groups' claim. But depending upon your requirement and the format of the 'groups' claim selected in
// the app registration, you might want to add other attributes than id to the `groups` claim, examples being;
// For instance if the required format is 'NetBIOSDomain\sAMAccountName' then the code is as commented below:
// identity.AddClaim(new Claim("groups", group.OnPremisesNetBiosName+"\\"+group.OnPremisesSamAccountName));
identity.AddClaim(new Claim("groups", group));
}
}
/// <summary>
/// Remove groups claims if already exists.
/// </summary>
/// <param name="identity"></param>
private static void RemoveExistingGroupsClaims(ClaimsIdentity identity)
{
//clear existing claim
List<Claim> existingGroupsClaims = identity.Claims.Where(x => x.Type == "groups").ToList();
if (existingGroupsClaims?.Count > 0)
{
foreach (Claim groupsClaim in existingGroupsClaims)
{
identity.RemoveClaim(groupsClaim);
}
}
}
/// <summary>
/// Gets the signed-in user's object identifier.
/// </summary>
/// <param name="principal">The principal.</param>
/// <returns></returns>
/// <autogeneratedoc />
private static string GetUserObjectId(ClaimsPrincipal principal)
{
return principal.Claims.FirstOrDefault(x => x.Type == "oid").Value;
}
/// <summary>
/// Retrieves all the groups saved in Cache.
/// </summary>>
/// <returns></returns>
private static List<string> GetUserGroupsFromCache(ClaimsPrincipal principal)
{
// Checks if Session contains data for groupClaims.
// The data will exist for 'Group Overage' claim if already populated.
string cacheKey = $"{Groups_Cache_Key}{GetUserObjectId(principal)}";
if (_memoryCache.TryGetValue(cacheKey, out List<string> groups))
{
Debug.WriteLine($"Cache hit successful for '{cacheKey}'");
return groups;
}
return null;
}
/// <summary>
/// Saves the users groups to the memory cache.
/// </summary>
/// <param name="usersGroups">The users groups to cache.</param>
/// <param name="principal">The Claims principal.</param>
/// <autogeneratedoc />
private static void SaveUsersGroupsToCache(List<string> usersGroups, ClaimsPrincipal principal, CacheSettings cacheSettings)
{
string cacheKey = $"{Groups_Cache_Key}{GetUserObjectId(principal)}";
Console.WriteLine($"Adding users groups for '{cacheKey}'.");
// IMPORTANT: Group list is cached for 1 hr by default, and thus cached groups will miss any changes to a users group membership for this duration.
// For capturing real-time changes to a user's group membership, consider implementing MS Graph change notifications (https://learn.microsoft.com/en-us/graph/api/resources/webhooks)
var cacheEntryOptions = new MemoryCacheEntryOptions()
.SetSlidingExpiration(TimeSpan.FromSeconds(Convert.ToDouble(cacheSettings.SlidingExpirationInSeconds)))
.SetAbsoluteExpiration(TimeSpan.FromSeconds(Convert.ToDouble(cacheSettings.AbsoluteExpirationInSeconds)))
.SetPriority(CacheItemPriority.Normal)
.SetSize(10240);
_memoryCache.Set(cacheKey, usersGroups, cacheEntryOptions);
}
}
}