65.9K
CodeProject 正在变化。 阅读更多。
Home

Angular 和 Azure AD 第三部分 - 添加基于角色的安全性

starIconstarIconstarIconstarIconstarIcon

5.00/5 (1投票)

2019 年 4 月 17 日

CPOL

6分钟阅读

viewsIcon

13680

downloadIcon

164

为我们的 Azure AD/Angular 网站添加基于角色的安全

下载 Angular_Azure_AD.zip

引言

到目前为止,这都相当容易,我们有一个 Angular 网站,其 API 由 Azure AD 保护,而且工作量不大。最后这个步骤,支持角色,无疑是最复杂的。

背景

这是本系列的最后一篇文章。如果您想跟着我边解释边编写代码,应该从上一篇文章的源代码开始。完成后,您将能够在 Angular 网站中使用 Azure AD 实现基于角色的安全。虽然我们可以返回用户的角色并使前端只显示对用户有效的选项,但主要的保护是在 API 上,单一页面应用程序会将所有源代码发送到客户端,而我们主要确保的是敏感数据不会被未经授权的人看到。

创建角色

在 Azure AD 中,角色映射到所谓的“组”。因此,第一步是在 Azure 中创建一些组,转到 Azure AD,点击“组”并创建一个新的。我的组名为“WebsiteUser”和“WebsiteAdmin”,如果您正在按照代码进行操作。

我不确定 Office 365 组是做什么的,但您需要创建安全组。

一旦创建了一个组,就需要从详细信息中获取其对象 ID。您可以重命名您的组,但您的代码将继续工作,因为您总是通过对象 ID(而不是名称)来处理组。

从这里开始,如何将用户添加到组中就非常显而易见了。创建至少两个组并将它们添加到您的用户中。

 

访问角色

现在,您还需要完成最后一步。转到 Azure AD 中的应用程序并查看清单。

默认情况下,它显示“"groupMembershipClaims": null”。将其更改为“All”,现在您的令牌将包含您所属组的 ID。我的令牌,解码后(JWT 是三个 Base64 编码的字符串,网络上有解码器),如下所示。

令牌当然是以标头形式传递在我们的受保护请求中的。

后端自定义身份验证

.NET Core 建议不要编写自定义身份验证,而是编写策略。这是一个普遍正确的想法,但对于这种情况,我们还是要编写一个自定义身份验证方法。

在您的项目中添加一个名为 CustomAuth 的文件夹。

创建一个名为 'Constants' 的类。在其中,放入这个值:

    public static class Constants
    {
        public const string WebsiteUser = "WebsiteUser";
        public const string WebsiteAdmin = " WebsiteAdmin";
        public const string Groups = "groups";
    }

其中前两个值是您的组名。这些用于将组名映射到 ID,因此它们需要与名称完全匹配。

最后一个类是为映射权限创建常量。现在我们需要一个类来存储我们角色的 GUID(这将作为配置从我们的 appsettings 中获取)。

    public class Groups
    {
        public Guid WebsiteUser { get; set; }
        public Guid WebsiteAdmin { get; set; }
    }

 

创建一个名为 GroupAuth 的类。将此代码添加到其中

    public class GroupAuthRequirementAttribute : TypeFilterAttribute
    {
        public GroupAuthRequirementAttribute(string claimType, string claimValue) : base(typeof(GroupAuthFilter))
        {
            Arguments = new object[] { new Claim(claimType, claimValue) };
        }
    }

这是我们将要放在要保护的方法上的属性。提供两个参数意味着我们可以过滤任何我们想要的属性,尽管我们可以简化代码并始终过滤“group”,但此代码有可能以更通用的方式使用。

现在将您的组 ID 添加到 appsettings.json 中。这里的名称需要与 Groups 类中的名称匹配,我们使用这些设置来填充该类的实例。

  "Groups": {
    "WebsiteUser": "3d134942-e73f-xxxx-xxxx-xxxxxxxx",
    "WebsiteAdmin": "698c5328-73f4-xxxx-xxxx-xxxxxxxx"
  },

现在开始编码。

   public class GroupAuthFilter : IAuthorizationFilter
    {
        readonly Claim _claim;

        readonly IConfiguration Configuration;

        public GroupAuthFilter(Claim claim, IConfiguration configuration)
        {
            _claim = claim;
            Configuration = configuration;
        }
}

IAuthorizationFilter 接口用于创建身份验证过滤器。配置被注入,这在 .Net Core 中都适用。Claim 是由我们的 Attribute 类创建的。这是类的基础部分,现在让我们让它做些事情。

 

  public void OnAuthorization(AuthorizationFilterContext context)
        {
            Groups groups = Configuration.GetSection("Groups").Get<Groups>();

            var property = groups.GetType().GetProperty(_claim.Value);

            if(property == null)
            {
                // Invalid value
                context.Result = new ForbidResult();
                return;
            }

            Guid value = Guid.Empty;

            value = (Guid)property.GetValue(groups);
            
            var hasClaim = context.HttpContext.User.Claims.Any(c => c.Type == Constants.Groups && c.Value == value.ToString());

            if(!hasClaim)
            {
                context.Result = new ForbidResult();
            }
        }

 

逐行解释

Groups groups = Configuration.GetSection("Groups").Get<Groups>();

使用配置中的 Groups 对象的值填充我们的 Groups 类。

            var property = groups.GetType().GetProperty(_claim.Value);

            if(property == null)
            {
                // Invalid value
                context.Result = new ForbidResult();
                return;
            }

            Guid value = Guid.Empty;

            value = (Guid)property.GetValue(groups);

使用反射根据我们在属性中传入的属性名称来获取 GUID。

            var hasClaim = context.HttpContext.User.Claims.Any(c => c.Type == Constants.Groups && c.Value == value.ToString());

            if(!hasClaim)
            {
                context.Result = new ForbidResult();
            }

 

HttpContext.User 对象是从我们传入的令牌填充的。我们需要搜索其声明中是否存在带有组 ID 的组声明。如果声明不存在,则请求失败。

现在是坏消息……

这不是很棒吗?简洁,简单……可惜,它不起作用。关键在于,如果您是超过 5 个组的成员,您的令牌将不再包含这些 GUID,它只会包含一个属性,通知您用户属于某个组(因此,超过 5 个)。

要解决此问题,您需要添加一个 NuGet 库,名为 Microsoft.Identity.Client。然后您需要添加此代码。

 

 if (!hasClaim)
            {
                // This could mean the user has more than 5 groups and we need to ask Azure AD for them.  
                var options = Configuration.GetSection("AzureAD").Get<AzureAdOptions>();

                var app = ConfidentialClientApplicationBuilder.Create(options.ClientId)
                .WithClientSecret(options.ClientSecret)
                .WithAuthority(new Uri(options.Authority))
                .Build();

                // With client credentials flows the scopes is ALWAYS of the shape "resource/.default", as the 
                // application permissions need to be set statically (in the portal or by PowerShell), and then granted by
                // a tenant administrator
                string[] scopes = new string[] { "https://graph.microsoft.com/.default" };

                AuthenticationResult result = null;
                try
                {
                    result = app.AcquireTokenForClient(scopes)
                        .ExecuteAsync().Result;
                }
                catch (MsalServiceException ex) when (ex.Message.Contains("AADSTS70011"))
                {
                    // Invalid scope. The scope has to be of the form "https://resourceurl/.default"
                    // Mitigation: change the scope to be as expected
                    context.Result = new ForbidResult();
                    return;
                }
                catch(Exception ex)
                {
                    context.Result = new ForbidResult();
                    return;
                }

                var httpClient = new HttpClient();

                var defaultRequestHeaders = httpClient.DefaultRequestHeaders;
                if (defaultRequestHeaders.Accept == null || !defaultRequestHeaders.Accept.Any(m => m.MediaType == "application/json"))
                {
                    httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
                }
                defaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", result.AccessToken);

                var body = "{     \"securityEnabledOnly\" : false } ";

                try
                {
                    var userId = context.HttpContext.User.Claims.Where(e => e.Type == "http://schemas.microsoft.com/identity/claims/objectidentifier").FirstOrDefault()?.Value;

                    HttpResponseMessage response = httpClient.PostAsync($"https://graph.microsoft.com/v1.0/users/{userId}/getMemberGroups", new StringContent(body, Encoding.UTF8, "application/json")).Result;

                    if (response.IsSuccessStatusCode)
                    {
                        string json = response.Content.ReadAsStringAsync().Result;
                        JObject items = JsonConvert.DeserializeObject(json) as JObject;

                        foreach (var groupId in items["value"].Children())
                        {
                            if (value.ToString() == groupId.Value<string>())
                            {
                                // It worked so return;
                                return;
                            }
                        }
                    }
                    else
                    {
                        string json = response.Content.ReadAsStringAsync().Result;
                    }
                }
                catch (Exception ex)
                {
                }

                context.Result = new ForbidResult();
            }

Microsoft Graph API 允许您查询用户属于哪些组。但是,您在登录时获得的令牌不能用于 Graph API。您需要请求一个特殊的令牌才能进行这些请求。此代码的前半部分使用此新库来实现此目的。后半部分使用此新令牌对 Graph API 进行标准 HTTP 请求。我们从新令牌中提取用户 ID,并使用它来形成所需的 URL。

两个注意事项

首先,如果您的 Azure appsettings.json 中没有 ClientSecret,您需要添加一个。之前的代码不需要这个 secret,但调用 Graph API 需要。

其次,您还需要为您的 Azure AD 应用程序添加权限,以允许用户请求用户所属的组。

此 API 的文档在此处

https://docs.microsoft.com/en-us/graph/api/user-getmembergroups?view=graph-rest-1.0

您需要的权限是

Application Group.Read.All, Directory.Read.All, Directory.ReadWrite.All

完成后不要忘记按下“授予管理员同意”按钮。这些权限可以设置在显示的两个位置,我认为您需要的是应用程序权限(我确实必须添加它们才能开始工作)。

有两种方法可以测试这一点。首先,给自己分配超过 5 个组。其次,设置一个断点并强制二次代码每次都运行。

改进空间

此代码效果很好,但它为 Graph API 获取令牌并在每次请求时调用 API。缓存令牌或组列表会更有效。缺点是,某人可能从一个组中被移除,但在缓存过期之前仍能保留其访问权限。鉴于这是一个基于特定业务规则的复杂问题,我没有尝试回答它,但我绝对不建议在生产环境中使用此代码。

关注点

这里主要令人沮丧的一点是,Azure 应用程序不允许您指定您关心的具体组,然后始终只提供那些组 ID,并随您的令牌一起提供。能够请求 Graph API 获取您的组所花费的精力比应有的要多,并且当我在纯 .NET 中执行此操作时,工作量还会进一步增加,因为我使用的库不存在。

历史

V1.0

© . All rights reserved.