在Azure Active Directory B2C中按组进行授权

44

我正在尝试弄清楚如何在Azure Active Directory B2C中使用组进行授权。例如,我可以通过用户进行授权:

[Authorize(Users="Bill")]

然而,这并不是非常有效的解决方案,我很少看到这种用例。一种替代方案是通过角色进行授权。但是由于某些原因,它似乎无法正常工作。例如,如果我将用户分配为“全局管理员”角色,则无法正常工作:

[Authorize(Roles="Global Admin")]

是否有一种通过组或角色进行授权的方法?

8个回答

50
从 Azure AD 中获取用户的组成员身份比“几行代码”要复杂得多,因此我想分享一下最终对我有用的内容,以节省其他人数天的抓狂和头痛。
让我们先在 project.json 中添加以下依赖项:
"dependencies": {
    ...
    "Microsoft.IdentityModel.Clients.ActiveDirectory": "3.13.8",
    "Microsoft.Azure.ActiveDirectory.GraphClient": "2.0.2"
}

第一个是必须的,因为我们需要对应用程序进行身份验证,以便它能够访问AAD Graph API。 第二个是我们将使用它来查询用户成员资格的Graph API客户端库。 不用说版本仅在本文撰写时有效,并且可能在未来发生更改。

接下来,在Startup类的Configure()方法中,也许就在我们配置OpenID Connect身份验证之前,我们创建Graph API客户端,如下所示:

var authContext = new AuthenticationContext("https://login.microsoftonline.com/<your_directory_name>.onmicrosoft.com");
var clientCredential = new ClientCredential("<your_b2c_app_id>", "<your_b2c_secret_app_key>");
const string AAD_GRAPH_URI = "https://graph.windows.net";
var graphUri = new Uri(AAD_GRAPH_URI);
var serviceRoot = new Uri(graphUri, "<your_directory_name>.onmicrosoft.com");
this.aadClient = new ActiveDirectoryClient(serviceRoot, async () => await AcquireGraphAPIAccessToken(AAD_GRAPH_URI, authContext, clientCredential));

警告:不要硬编码秘密应用程序密钥,而是将其保存在安全位置。好了,你已经知道了,对吧? :)

我们提供给AD客户端构造函数的异步AcquireGraphAPIAccessToken()方法将在客户端需要获取身份验证令牌时根据需要调用。这是该方法的样子:

private async Task<string> AcquireGraphAPIAccessToken(string graphAPIUrl, AuthenticationContext authContext, ClientCredential clientCredential)
{
    AuthenticationResult result = null;
    var retryCount = 0;
    var retry = false;

    do
    {
        retry = false;
        try
        {
            // ADAL includes an in-memory cache, so this will only send a request if the cached token has expired
            result = await authContext.AcquireTokenAsync(graphAPIUrl, clientCredential);
        }
        catch (AdalException ex)
        {
            if (ex.ErrorCode == "temporarily_unavailable")
            {
                retry = true;
                retryCount++;
                await Task.Delay(3000);
            }
        }
    } while (retry && (retryCount < 3));

    if (result != null)
    {
        return result.AccessToken;
    }

    return null;
}

请注意,它具有内置的重试机制,用于处理暂时性条件,您可能希望根据应用程序的需要进行定制。

现在我们已经处理了应用程序身份验证和AD客户端设置,我们可以继续利用OpenIdConnect事件来最终使用它。回到Configure()方法,在那里我们通常会调用app.UseOpenIdConnectAuthentication()并创建一个OpenIdConnectOptions实例,我们添加一个OnTokenValidated事件处理程序:

new OpenIdConnectOptions()
{
    ...         
    Events = new OpenIdConnectEvents()
    {
        ...
        OnTokenValidated = SecurityTokenValidated
    },
};

当签到用户的访问令牌被获取、验证并建立用户身份时,将触发此事件。(不要与调用AAD Graph API所需的应用程序自己的访问令牌混淆!) 这似乎是查询用户组成员资格的Graph API的好地方,并将这些组添加到标识中,以形成附加声明的形式:

private Task SecurityTokenValidated(TokenValidatedContext context)
{
    return Task.Run(async () =>
    {
        var oidClaim = context.SecurityToken.Claims.FirstOrDefault(c => c.Type == "oid");
        if (!string.IsNullOrWhiteSpace(oidClaim?.Value))
        {
            var pagedCollection = await this.aadClient.Users.GetByObjectId(oidClaim.Value).MemberOf.ExecuteAsync();

            do
            {
                var directoryObjects = pagedCollection.CurrentPage.ToList();
                foreach (var directoryObject in directoryObjects)
                {
                    var group = directoryObject as Group;
                    if (group != null)
                    {
                        ((ClaimsIdentity)context.Ticket.Principal.Identity).AddClaim(new Claim(ClaimTypes.Role, group.DisplayName, ClaimValueTypes.String));
                    }
                }
                pagedCollection = pagedCollection.MorePagesAvailable ? await pagedCollection.GetNextPageAsync() : null;
            }
            while (pagedCollection != null);
        }
    });
}

这里使用的是“Role”声明类型,但您也可以使用自定义的声明类型。

完成上述步骤后,如果您正在使用“ClaimType.Role”,则只需像这样装饰您的控制器类或方法:

[Authorize(Role = "Administrators")]

当然,前提是您已经在B2C中配置了一个名为“管理员”的显示名称的指定组。

如果您选择使用自定义声明类型,则需要根据声明类型定义授权策略,例如在ConfigureServices()方法中添加以下内容:

services.AddAuthorization(options => options.AddPolicy("ADMIN_ONLY", policy => policy.RequireClaim("<your_custom_claim_type>", "Administrators")));

然后,按以下方式修饰特权控制器类或方法:

[Authorize(Policy = "ADMIN_ONLY")]

好的,我们完成了吗?- 嗯,不完全是。

如果您运行应用程序并尝试登录,则会从Graph API获得异常,声称“权限不足以完成操作”。 可能不太明显,但是虽然您的应用程序使用其app_id和app_key成功通过AD进行身份验证,但它没有所需的特权来读取您的AD中用户的详细信息。 为了授予应用程序这样的访问权限,我选择使用Azure Active Directory Module for PowerShell

对我而言,以下脚本解决了问题:

$tenantGuid = "<your_tenant_GUID>"
$appID = "<your_app_id>"

$userVal = "<admin_user>@<your_AD>.onmicrosoft.com"
$pass = "<admin password in clear text>"
$Creds = New-Object System.Management.Automation.PsCredential($userVal, (ConvertTo-SecureString $pass -AsPlainText -Force))

Connect-MSOLSERVICE -Credential $Creds
$msSP = Get-MsolServicePrincipal -AppPrincipalId $appID -TenantID $tenantGuid

$objectId = $msSP.ObjectId

Add-MsolRoleMember -RoleName "Company Administrator" -RoleMemberType ServicePrincipal -RoleMemberObjectId $objectId

现在我们终于完成了!这"几行代码"怎么样? :)


2
这是一篇非常出色的文章。谢谢! - Dan
这样的美丽,如此的清晰,非常时尚! - Molibar
@ChristerBrannstrom 谢谢!- 我很高兴它帮助了一些人。 - Alex Lobakov
我通过使用Azure Active Directory 2模块重新编写您的PowerShell来解决了我的问题:https://www.powershellgallery.com/packages/AzureAD/2.0.0.131 - TonE
很棒的答案,非常清晰。感谢您抽出时间分享这个。 - pcdev
显示剩余9条评论

26

这个方法可行,但是您需要在身份验证逻辑中编写几行代码才能实现您想要的目标。

首先,您必须区分Azure AD(B2C)中的角色

用户角色非常具体,仅在Azure AD(B2C)本身中有效。该角色定义了用户在Azure AD内部拥有哪些权限。

(或安全组)定义了用户组成员身份,并可以向外部应用程序公开。外部应用程序可以在安全组之上建立基于角色的访问控制。是的,我知道这可能听起来有点混淆,但事实就是这样。

因此,您的第一步是在Azure AD B2C中对您的进行建模-您必须创建这些组并手动将用户指派到这些组中。您可以在Azure门户(https://portal.azure.com/)中完成此操作:

azure门户的说明

接下来,返回你的应用程序,你需要编写一些代码,并在用户成功验证后请求Azure AD B2C Graph API以获得用户成员身份。您可以使用此示例来获取有关如何获取用户组成员身份的灵感。最好在其中一个OpenID通知(即SecurityTokenValidated)中执行此代码,并将用户角色添加到ClaimsPrincipal中。

一旦更改了ClaimsPrincipal以拥有Azure AD安全组和“Role Claim”值,您就可以使用带有Roles功能的Authorize属性。这实际上只需要5-6行代码。

最后,您可以在此处投票支持此功能,以便无需查询Graph API即可获得组成员身份声明。


2
你能展示一下那5-6行代码吗?我已经试了几天来回答这个问题,但是我已经写了超过100行代码(而且还没有成功!)。如果只需要5或6行代码就可以连接通知、查询用户组数据并将组添加到ClaimsPrincipal角色中,那么我显然走错了方向。我真的很感激您的指导! - reidLinden
2
你如何访问“Azure B2C设置”?我找不到任何地方可以将组添加到Azure B2C租户中,尽管奇怪的是,我可以将用户添加到组中(即使不存在任何组)。 - Quark Soup
@Donald Airey,它已经被移动到Azure门户中的单独条目“群组”中。 - Thom Hubers

5
我按照所写的实现了这个,但截至2017年5月,这行代码

出现了问题。
((ClaimsIdentity)context.Ticket.Principal.Identity).AddClaim(new Claim(ClaimTypes.Role, group.DisplayName, ClaimValueTypes.String));

需要更改为:

((ClaimsIdentity)context.Ticket.Principal.Identity).AddClaim(new Claim(ClaimTypes.Role, group.DisplayName));

让它与最新的库一起工作

向作者致以崇高的敬意

如果您在使用Connect-MsolService时遇到了用户名和密码错误,请更新到最新的库


现在Ticket属性已经消失,因此必须更改为((ClaimsIdentity) context.Principal.Identity - g.pickardou

4

这里有一个官方的示例:Azure AD B2C:基于角色的访问控制,可在Azure AD团队此处获取。

但是,似乎唯一的解决方案是通过使用MS Graph读取用户组来进行自定义实现。


4

亚历克斯的回答对于找出一个工作解决方案是至关重要的,感谢他指出了正确的方向。

然而,它使用了app.UseOpenIdConnectAuthentication(),这个方法在Core 2中已经被废弃很长时间,并且在Core 3中完全被移除了(将身份验证和身份迁移到ASP.NET Core 2.0)。

我们必须实现的基本任务是使用OpenIdConnectOptions将事件处理程序附加到OnTokenValidated,该选项由ADB2C身份验证在内部使用。我们必须在不干扰ADB2C的其他配置的情况下完成这个任务。

以下是我的想法:

// My (and probably everyone's) existing code in Startup:
services.AddAuthentication(AzureADB2CDefaults.AuthenticationScheme)
        .AddAzureADB2C(options => Configuration.Bind("AzureAdB2C", options));

// This adds the custom event handler, without interfering any existing functionality:
services.Configure<OpenIdConnectOptions>(AzureADB2CDefaults.OpenIdScheme,
options =>
{
    options.Events.OnTokenValidated =
        new AzureADB2CHelper(options.Events.OnTokenValidated).OnTokenValidated;
});

所有的实现都封装在一个辅助类中,以保持Startup类的清晰。原始的事件处理程序被保存并在其不为空的情况下调用(顺便说一句,它确实不为空)。

public class AzureADB2CHelper
{
    private readonly ActiveDirectoryClient _activeDirectoryClient;
    private readonly Func<TokenValidatedContext, Task> _onTokenValidated;
    private const string AadGraphUri = "https://graph.windows.net";


    public AzureADB2CHelper(Func<TokenValidatedContext, Task> onTokenValidated)
    {
        _onTokenValidated = onTokenValidated;
        _activeDirectoryClient = CreateActiveDirectoryClient();
    }

    private ActiveDirectoryClient CreateActiveDirectoryClient()
    {
        // TODO: Refactor secrets to settings
        var authContext = new AuthenticationContext("https://login.microsoftonline.com/<yourdomain, like xxx.onmicrosoft.com>");
        var clientCredential = new ClientCredential("<yourclientcredential>", @"<yourappsecret>");


        var graphUri = new Uri(AadGraphUri);
        var serviceRoot = new Uri(graphUri, "<yourdomain, like xxx.onmicrosoft.com>");
        return new ActiveDirectoryClient(serviceRoot,
            async () => await AcquireGraphAPIAccessToken(AadGraphUri, authContext, clientCredential));
    }

    private async Task<string> AcquireGraphAPIAccessToken(string graphAPIUrl,
        AuthenticationContext authContext,
        ClientCredential clientCredential)
    {
        AuthenticationResult result = null;
        var retryCount = 0;
        var retry = false;

        do
        {
            retry = false;
            try
            {
                // ADAL includes an in-memory cache, so this will only send a request if the cached token has expired
                result = await authContext.AcquireTokenAsync(graphAPIUrl, clientCredential);
            }
            catch (AdalException ex)
            {
                if (ex.ErrorCode != "temporarily_unavailable")
                {
                    continue;
                }

                retry = true;
                retryCount++;
                await Task.Delay(3000);
            }
        } while (retry && retryCount < 3);

        return result?.AccessToken;
    }

    public Task OnTokenValidated(TokenValidatedContext context)
    {
        _onTokenValidated?.Invoke(context);
        return Task.Run(async () =>
        {
            try
            {
                var oidClaim = context.SecurityToken.Claims.FirstOrDefault(c => c.Type == "oid");
                if (!string.IsNullOrWhiteSpace(oidClaim?.Value))
                {
                    var pagedCollection = await _activeDirectoryClient.Users.GetByObjectId(oidClaim.Value).MemberOf
                        .ExecuteAsync();

                    do
                    {
                        var directoryObjects = pagedCollection.CurrentPage.ToList();
                        foreach (var directoryObject in directoryObjects)
                        {
                            if (directoryObject is Group group)
                            {
                                ((ClaimsIdentity) context.Principal.Identity).AddClaim(new Claim(ClaimTypes.Role,
                                    group.DisplayName, ClaimValueTypes.String));
                            }
                        }

                        pagedCollection = pagedCollection.MorePagesAvailable
                            ? await pagedCollection.GetNextPageAsync()
                            : null;
                    } while (pagedCollection != null);
                }
            }
            catch (Exception e)
            {
                Debug.WriteLine(e);
            }
        });
    }
}

您需要适当的软件包,我使用以下软件包:

<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="3.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="3.0.0" />
<PackageReference Include="Microsoft.Azure.ActiveDirectory.GraphClient" Version="2.1.1" />
<PackageReference Include="Microsoft.IdentityModel.Clients.ActiveDirectory" Version="5.2.3" />

注意:您必须授权您的应用程序读取AD。截至2019年10月,此应用程序必须是“传统”的应用程序,而不是最新的B2C应用程序。以下是一个非常好的指南:Azure AD B2C: 使用Azure AD Graph API


3

根据这里所有出色的答案,使用新的Microsoft Graph API获取用户组


IConfidentialClientApplication confidentialClientApplication = ConfidentialClientApplicationBuilder
          .Create("application-id")
          .WithTenantId("tenant-id")
          .WithClientSecret("xxxxxxxxx")
          .Build();

ClientCredentialProvider authProvider = new ClientCredentialProvider(confidentialClientApplication);

GraphServiceClient graphClient = new GraphServiceClient(authProvider);


var groups = await graphClient.Users[oid].MemberOf.Request().GetAsync();

在 .NET Core 5 中似乎没有 ClientCredentialProvider。 - Martin Meeser
你需要安装以下的程序包: Install-Package Microsoft.Graph Install-Package Microsoft.Graph.Auth -IncludePrerelease - Martin Meeser

2
我真的很喜欢@AlexLobakov的答案,但我想要一个更新的答案来适用于.NET 6,并且仍然实现了缓存功能但是可以进行测试。我还希望将角色发送到我的前端,与任何SPA(如React)兼容,并在应用程序中使用标准的Azure AD B2C用户流进行基于角色的访问控制(RBAC)。
我也错过了一个从头到尾的指南,因为有太多可能出错的变量,最终你会得到一个不工作的应用程序。
首先,在Visual Studio 2022中创建一个新的ASP.NET Core Web API,设置如下:

您应该在创建后获得这样的对话框:

如果您没有看到这个选项,请在 Visual Studio 中右键单击项目,然后单击“概述”,接着点击“连接的服务”。

在您的Azure AD B2C中创建一个新的“应用程序注册”,或使用现有的注册。我为了演示目的注册了一个新的。

创建了应用程序注册后,Visual Studio 在“依赖项配置进度”上卡住了,因此其余部分将手动配置:

登录https://portal.azure.com/,切换目录到您的AD B2C,选择您的新应用程序注册,然后单击“身份验证”。然后单击添加平台并选择Web
为本地主机添加重定向URI前端通道注销URL
示例:
https://localhost:7166/signin-oidc
https://localhost:7166/logout

如果您选择单页面应用程序,它的外观几乎相同。但是,您需要按照下面所述添加一个 code_challenge。这里不会展示完整的示例。
Active Directory是否支持使用PKCE的授权代码流?(链接)

认证应该类似于这样:

点击“证书和密码”,创建一个新的客户端密码。
点击“公开 API”,然后编辑“应用程序 ID URI”。
默认值应该类似于这样:api://11111111-1111-1111-1111-111111111111。将其编辑为https://youradb2c.onmicrosoft.com/11111111-1111-1111-1111-111111111111。应该有一个名为“access_as_user”的范围。如果没有,请创建它。
现在点击“API 权限”:
需要四个 Microsoft Graph 权限。
两个应用程序:
GroupMember.Read.All
User.Read.All

"两个代表:"
offline_access
openid

您还需要从“我的 API”中获取“access_as_user”权限。完成后,单击“授予管理员对...的同意”。应该是这样的:

如果您还没有用户流程,请创建一个“注册和登录”或“登录”并选择“推荐”。我的用户流程默认为“B2C_1_signin”。

验证您的 AD B2C 用户是否属于您想要进行身份验证的组:

enter image description here

现在您可以返回应用程序并验证是否可以获取登录代码。使用此示例,它应该会重定向并显示一个代码:
https://<tenant-name>.b2clogin.com/tfp/<tenant-name>.onmicrosoft.com/<user-flow-name>/oauth2/v2.0/authorize?
client_id=<application-ID>
&nonce=anyRandomValue
&redirect_uri=https://localhost:7166/signin-oidc
&scope=https://<tenant-name>.onmicrosoft.com/11111111-1111-1111-1111-111111111111/access_as_user
&response_type=code

如果成功了,登录后您应该会被重定向到类似于这样的页面:
https://localhost:7166/signin-oidc?code=

如果您收到以下错误消息:  

AADB2C99059:提供的请求必须呈现code_challenge

那么您可能选择了平台单页应用程序,需要向请求添加一个code_challenge,例如:&code_challenge=123。但这还不够,因为您还需要稍后验证挑战,否则在运行我的代码时会收到以下错误:

AADB2C90183:提供的code_verifier无效

现在打开您的应用程序和appsettings.json。默认情况下应该是这样的:
  "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "Domain": "qualified.domain.name",
    "TenantId": "22222222-2222-2222-2222-222222222222",
    "ClientId": "11111111-1111-1111-11111111111111111",

    "Scopes": "access_as_user",
    "CallbackPath": "/signin-oidc"
  },

我们需要更多的数值,最终看起来应该是这样的:
  "AzureAd": {
    "Instance": "https://<tenant-name>.b2clogin.com/",
    "Domain": "<tenant-name>.onmicrosoft.com",
    "TenantId": "22222222-2222-2222-2222-222222222222",
    "ClientId": "11111111-1111-1111-11111111111111111",
    "SignUpSignInPolicyId": "B2C_1_signin",
    "ClientSecret": "--SECRET--",
    "ApiScope": "https://<tenant-name>.onmicrosoft.com/11111111-1111-1111-11111111111111111/access_as_user",
    "TokenUrl": "https://<tenant-name>.b2clogin.com/<tenant-name>.onmicrosoft.com/B2C_1_signin/oauth2/v2.0/token",
    "Scopes": "access_as_user",
    "CallbackPath": "/signin-oidc"
  },

我将 ClientSecret 存储在 Secret Manager 中。

https://learn.microsoft.com/en-us/aspnet/core/security/app-secrets?view=aspnetcore-6.0&tabs=windows#manage-user-secrets-with-visual-studio

现在创建这些新类:
AppSettings:
namespace AzureADB2CWebAPIGroupTest
{
    public class AppSettings
    {
        public AzureAdSettings AzureAd { get; set; } = new AzureAdSettings();

    }

    public class AzureAdSettings
    {
        public string Instance { get; set; }

        public string Domain { get; set; }

        public string TenantId { get; set; }

        public string ClientId { get; set; }

        public string IssuerSigningKey { get; set; }

        public string ValidIssuer { get; set; }

        public string ClientSecret { get; set; }

        public string ApiScope { get; set; }

        public string TokenUrl { get; set; }

    }
}

Adb2cTokenResponse:
namespace AzureADB2CWebAPIGroupTest
{
    public class Adb2cTokenResponse
    {
        public string access_token { get; set; }
        public string id_token { get; set; }
        public string token_type { get; set; }
        public int not_before { get; set; }
        public int expires_in { get; set; }
        public int ext_expires_in { get; set; }
        public int expires_on { get; set; }
        public string resource { get; set; }
        public int id_token_expires_in { get; set; }
        public string profile_info { get; set; }
        public string scope { get; set; }
        public string refresh_token { get; set; }
        public int refresh_token_expires_in { get; set; }
    }
}

缓存键:
namespace AzureADB2CWebAPIGroupTest
{
    public static class CacheKeys
    {
        public const string GraphApiAccessToken = "_GraphApiAccessToken";
    }
}

GraphApiService:
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Graph;
using System.Text.Json;

namespace AzureADB2CWebAPIGroupTest
{
    public class GraphApiService
    {
        private readonly IHttpClientFactory _clientFactory;
        private readonly IMemoryCache _memoryCache;
        private readonly AppSettings _settings;
        private readonly string _accessToken;

        public GraphApiService(IHttpClientFactory clientFactory, IMemoryCache memoryCache, AppSettings settings)
        {
            _clientFactory = clientFactory;
            _memoryCache = memoryCache;
            _settings = settings;

            string graphApiAccessTokenCacheEntry;

            // Look for cache key.
            if (!_memoryCache.TryGetValue(CacheKeys.GraphApiAccessToken, out graphApiAccessTokenCacheEntry))
            {
                // Key not in cache, so get data.
                var adb2cTokenResponse = GetAccessTokenAsync().GetAwaiter().GetResult();

                graphApiAccessTokenCacheEntry = adb2cTokenResponse.access_token;

                // Set cache options.
                var cacheEntryOptions = new MemoryCacheEntryOptions()
                    .SetAbsoluteExpiration(TimeSpan.FromSeconds(adb2cTokenResponse.expires_in));

                // Save data in cache.
                _memoryCache.Set(CacheKeys.GraphApiAccessToken, graphApiAccessTokenCacheEntry, cacheEntryOptions);
            }

            _accessToken = graphApiAccessTokenCacheEntry;
        }

        public async Task<List<string>> GetUserGroupsAsync(string oid)
        {
            var authProvider = new AuthenticationProvider(_accessToken);
            GraphServiceClient graphClient = new GraphServiceClient(authProvider, new HttpClientHttpProvider(_clientFactory.CreateClient()));

            //Requires GroupMember.Read.All and User.Read.All to get everything we want
            var groups = await graphClient.Users[oid].MemberOf.Request().GetAsync();

            if (groups == null)
            {
                return null;
            }

            var graphGroup = groups.Cast<Microsoft.Graph.Group>().ToList();

            return graphGroup.Select(x => x.DisplayName).ToList();
        }

        private async Task<Adb2cTokenResponse> GetAccessTokenAsync()
        {
            var client = _clientFactory.CreateClient();

            var kvpList = new List<KeyValuePair<string, string>>();
            kvpList.Add(new KeyValuePair<string, string>("grant_type", "client_credentials"));
            kvpList.Add(new KeyValuePair<string, string>("client_id", _settings.AzureAd.ClientId));
            kvpList.Add(new KeyValuePair<string, string>("scope", "https://graph.microsoft.com/.default"));
            kvpList.Add(new KeyValuePair<string, string>("client_secret", _settings.AzureAd.ClientSecret));

#pragma warning disable SecurityIntelliSenseCS // MS Security rules violation
            var req = new HttpRequestMessage(HttpMethod.Post, $"https://login.microsoftonline.com/{_settings.AzureAd.Domain}/oauth2/v2.0/token")
            { Content = new FormUrlEncodedContent(kvpList) };
#pragma warning restore SecurityIntelliSenseCS // MS Security rules violation

            using var httpResponse = await client.SendAsync(req);

            var response = await httpResponse.Content.ReadAsStringAsync();

            httpResponse.EnsureSuccessStatusCode();

            var adb2cTokenResponse = JsonSerializer.Deserialize<Adb2cTokenResponse>(response);

            return adb2cTokenResponse;
        }
    }

    public class AuthenticationProvider : IAuthenticationProvider
    {
        private readonly string _accessToken;

        public AuthenticationProvider(string accessToken)
        {
            _accessToken = accessToken;
        }

        public Task AuthenticateRequestAsync(HttpRequestMessage request)
        {
            request.Headers.Add("Authorization", $"Bearer {_accessToken}");

            return Task.CompletedTask;
        }
    }

    public class HttpClientHttpProvider : IHttpProvider
    {
        private readonly HttpClient http;

        public HttpClientHttpProvider(HttpClient http)
        {
            this.http = http;
        }

        public ISerializer Serializer { get; } = new Serializer();

        public TimeSpan OverallTimeout { get; set; } = TimeSpan.FromSeconds(300);

        public void Dispose()
        {
        }

        public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request)
        {
            return http.SendAsync(request);
        }

        public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
            HttpCompletionOption completionOption,
            CancellationToken cancellationToken)
        {
            return http.SendAsync(request, completionOption, cancellationToken);
        }
    }
}

目前只有 GraphServiceClientaccessToken 存储在内存缓存中,但如果应用程序需要更好的性能,则用户组也可以被缓存。
添加一个新类:
Adb2cUser:
namespace AzureADB2CWebAPIGroupTest
{
    public class Adb2cUser
    {
        public Guid Id { get; set; }

        public string GivenName { get; set; }

        public string FamilyName { get; set; }

        public string Email { get; set; }

        public List<string> Roles { get; set; }

        public Adb2cTokenResponse Adb2cTokenResponse { get; set; }
    }
}

and struct:

namespace AzureADB2CWebAPIGroupTest
{
    public struct ADB2CJwtRegisteredClaimNames
    {
        public const string Emails = "emails";

        public const string Name = "name";
    }
}

现在添加一个新的API控制器。
登录控制器:
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.IdentityModel.Tokens.Jwt;
using System.Text.Json;

namespace AzureADB2CWebAPIGroupTest.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    [Authorize]
    public class LoginController : ControllerBase
    {

        private readonly ILogger<LoginController> _logger;
        private readonly IHttpClientFactory _clientFactory;
        private readonly AppSettings _settings;
        private readonly GraphApiService _graphApiService;

        public LoginController(ILogger<LoginController> logger, IHttpClientFactory clientFactory, AppSettings settings, GraphApiService graphApiService)
        {
            _logger = logger;
            _clientFactory = clientFactory;
            _settings = settings;
            _graphApiService=graphApiService;
        }

        [HttpPost]
        [AllowAnonymous]
        public async Task<ActionResult<Adb2cUser>> Post([FromBody] string code)
        {
            var redirectUri = "";

            if (HttpContext != null)
            {
                redirectUri = HttpContext.Request.Scheme + "://" + HttpContext.Request.Host + "/signin-oidc";
            }

            var kvpList = new List<KeyValuePair<string, string>>();
            kvpList.Add(new KeyValuePair<string, string>("grant_type", "authorization_code"));
            kvpList.Add(new KeyValuePair<string, string>("client_id", _settings.AzureAd.ClientId));
            kvpList.Add(new KeyValuePair<string, string>("scope", "openid offline_access " + _settings.AzureAd.ApiScope));
            kvpList.Add(new KeyValuePair<string, string>("code", code));
            kvpList.Add(new KeyValuePair<string, string>("redirect_uri", redirectUri));
            kvpList.Add(new KeyValuePair<string, string>("client_secret", _settings.AzureAd.ClientSecret));

            return await UserLoginAndRefresh(kvpList);

        }

        [HttpPost("refresh")]
        [AllowAnonymous]
        public async Task<ActionResult<Adb2cUser>> Refresh([FromBody] string token)
        {
            var redirectUri = "";

            if (HttpContext != null)
            {
                redirectUri = HttpContext.Request.Scheme + "://" + HttpContext.Request.Host;
            }

            var kvpList = new List<KeyValuePair<string, string>>();
            kvpList.Add(new KeyValuePair<string, string>("grant_type", "refresh_token"));
            kvpList.Add(new KeyValuePair<string, string>("client_id", _settings.AzureAd.ClientId));
            kvpList.Add(new KeyValuePair<string, string>("scope", "openid offline_access " + _settings.AzureAd.ApiScope));
            kvpList.Add(new KeyValuePair<string, string>("refresh_token", token));
            kvpList.Add(new KeyValuePair<string, string>("redirect_uri", redirectUri));
            kvpList.Add(new KeyValuePair<string, string>("client_secret", _settings.AzureAd.ClientSecret));

            return await UserLoginAndRefresh(kvpList);
        }

        private async Task<ActionResult<Adb2cUser>> UserLoginAndRefresh(List<KeyValuePair<string, string>> kvpList)
        {
            var user = await TokenRequest(kvpList);
            if (user == null)
            {
                return Unauthorized();
            }

            //Return access token and user information
            return Ok(user);
        }

        private async Task<Adb2cUser> TokenRequest(List<KeyValuePair<string, string>> keyValuePairs)
        {
            var client = _clientFactory.CreateClient();

#pragma warning disable SecurityIntelliSenseCS // MS Security rules violation
            var req = new HttpRequestMessage(HttpMethod.Post, _settings.AzureAd.TokenUrl)
            { Content = new FormUrlEncodedContent(keyValuePairs) };
#pragma warning restore SecurityIntelliSenseCS // MS Security rules violation

            using var httpResponse = await client.SendAsync(req);

            var response = await httpResponse.Content.ReadAsStringAsync();

            httpResponse.EnsureSuccessStatusCode();

            var adb2cTokenResponse = JsonSerializer.Deserialize<Adb2cTokenResponse>(response);

            var handler = new JwtSecurityTokenHandler();
            var jwtSecurityToken = handler.ReadJwtToken(adb2cTokenResponse.access_token);

            var id = jwtSecurityToken.Claims.First(claim => claim.Type == JwtRegisteredClaimNames.Sub).Value;

            var groups = await _graphApiService.GetUserGroupsAsync(id);

            var givenName = jwtSecurityToken.Claims.First(claim => claim.Type == JwtRegisteredClaimNames.GivenName).Value;
            var familyName = jwtSecurityToken.Claims.First(claim => claim.Type == JwtRegisteredClaimNames.FamilyName).Value;
            //Unless Alternate email have been added in Azure AD there will only be one email here. 
            //TODO Handle multiple emails
            var emails = jwtSecurityToken.Claims.First(claim => claim.Type == ADB2CJwtRegisteredClaimNames.Emails).Value;

            var user = new Adb2cUser()
            {
                Id = Guid.Parse(id),
                GivenName = givenName,
                FamilyName = familyName,
                Email = emails,
                Roles = groups,
                Adb2cTokenResponse = adb2cTokenResponse
            };

            return user;
        }
    }
}

现在是时候编辑 Program.cs 了。对于 ASP.NET Core 6.0 中的新最小托管模型,它应该看起来像这样:
var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"));

请注意,ASP.NET Core 6.0 使用的是 JwtBearerDefaults.AuthenticationScheme 而不是 AzureADB2CDefaults.AuthenticationSchemeAzureADB2CDefaults.OpenIdScheme
请编辑 Program.cs,使其如下所示:
using AzureADB2CWebAPIGroupTest;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Identity.Web;
using System.Security.Claims;

var builder = WebApplication.CreateBuilder(args);

//Used for debugging
//IdentityModelEventSource.ShowPII = true;

var settings = new AppSettings();
builder.Configuration.Bind(settings);
builder.Services.AddSingleton(settings);

var services = new ServiceCollection();
services.AddMemoryCache();
services.AddHttpClient();
var serviceProvider = services.BuildServiceProvider();

var memoryCache = serviceProvider.GetService<IMemoryCache>();
var httpClientFactory = serviceProvider.GetService<IHttpClientFactory>();

var graphApiService = new GraphApiService(httpClientFactory, memoryCache, settings);

// Add services to the container.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddMicrosoftIdentityWebApi(options => {
        builder.Configuration.Bind("AzureAd", options);
        options.TokenValidationParameters.NameClaimType = "name";
        options.TokenValidationParameters.ValidateIssuerSigningKey = true;
        options.TokenValidationParameters.ValidateLifetime = true;
        options.TokenValidationParameters.ValidateIssuer = true;
        options.TokenValidationParameters.ValidateLifetime = true;
        options.TokenValidationParameters.ValidateTokenReplay = true;
        options.Audience = settings.AzureAd.ClientId;
        options.Events = new JwtBearerEvents()
        {
            OnTokenValidated = async ctx =>
            {
                //Runs on every request, cache a users groups if needed
                var oidClaim = ((System.IdentityModel.Tokens.Jwt.JwtSecurityToken)ctx.SecurityToken).Claims.FirstOrDefault(c => c.Type == "oid");
                if (!string.IsNullOrWhiteSpace(oidClaim?.Value))
                {
                    var groups = await graphApiService.GetUserGroupsAsync(oidClaim.Value);

                    foreach (var group in groups)
                    {
                        ((ClaimsIdentity)ctx.Principal.Identity).AddClaim(new Claim(ClaimTypes.Role.ToString(), group));
                    }
                }
            }
        };
    },
    options => {
        builder.Configuration.Bind("AzureAd", options);
    });

builder.Services.AddTransient<GraphApiService>();

builder.Services.AddHttpClient();
builder.Services.AddMemoryCache();

builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

app.UseAuthentication();
app.UseAuthorization();

app.MapControllers();

app.Run();

现在,您可以运行您的应用程序,并在请求中使用之前的代码,如下所示:
POST /api/login/ HTTP/1.1
Host: localhost:7166
Content-Type: application/json

"code"

您将会收到像这样的回复,其中包含一个access_token
{
    "id": "31111111-1111-1111-1111-111111111111",
    "givenName": "Oscar",
    "familyName": "Andersson",
    "email": "oscar.andersson@example.com",
    "roles": [
        "Administrator",
    ],
    "adb2cTokenResponse": {
        
    }
}

[Authorize(Roles = "Administrator")] 添加到 WeatherForecastController.cs 中,我们现在可以使用之前获取的 access_token 验证只有具有正确角色的用户才能访问此资源:

如果我们改为[Authorize(Roles = "Administrator2")],使用相同的用户会得到HTTP 403错误:

LoginController 可以处理刷新令牌。
使用 NuGets 的 Microsoft.NET.Test.Sdk、xunit、xunit.runner.visualstudio 和 Moq,我们也可以测试 LoginController,并进而测试用于 Program.cs 中 ClaimsIdentity 的 GraphApiService。不幸的是,由于正文限制为 30000 个字符,无法显示整个测试。
它基本上看起来像这样:
LoginControllerTest:
using AzureADB2CWebAPIGroupTest.Controllers;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using Moq;
using Moq.Protected;
using System.Net;
using Xunit;

namespace AzureADB2CWebAPIGroupTest
{
    public class LoginControllerTest
    {

        [Theory]
        [MemberData(nameof(PostData))]
        public async Task Post(string code, string response, string expectedEmail, string expectedFamilyName, string expectedGivenName)
        {
            var controller = GetLoginController(response);

            var result = await controller.Post(code);

            var actionResult = Assert.IsType<ActionResult<Adb2cUser>>(result);
            var okResult = Assert.IsType<OkObjectResult>(result.Result);
            var returnValue = Assert.IsType<Adb2cUser>(okResult.Value);
            Assert.Equal(returnValue.Email, expectedEmail);
            Assert.Equal(returnValue.Roles[1], GraphApiServiceMock.DummyGroup2Name);
        }

        [Theory]
        [MemberData(nameof(RefreshData))]
        public async Task Refresh(string code, string response, string expectedEmail, string expectedFamilyName, string expectedGivenName)
        {
            var controller = GetLoginController(response);

            var result = await controller.Refresh(code);

            var actionResult = Assert.IsType<ActionResult<Adb2cUser>>(result);
            var okResult = Assert.IsType<OkObjectResult>(result.Result);
            var returnValue = Assert.IsType<Adb2cUser>(okResult.Value);
            Assert.Equal(returnValue.Email, expectedEmail);
            Assert.Equal(returnValue.Roles[1], GraphApiServiceMock.DummyGroup2Name);
        }
        
        //PostData and RefreshData removed for space

        private LoginController GetLoginController(string expectedResponse)
        {
            var mockFactory = new Mock<IHttpClientFactory>();

            var settings = new AppSettings();

            settings.AzureAd.TokenUrl = "https://example.com";

            var mockMessageHandler = new Mock<HttpMessageHandler>();

            GraphApiServiceMock.MockHttpRequests(mockMessageHandler);

            mockMessageHandler.Protected()
                .Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.Is<HttpRequestMessage>(x => x.RequestUri.AbsoluteUri.Contains(settings.AzureAd.TokenUrl)), ItExpr.IsAny<CancellationToken>())
                .ReturnsAsync(new HttpResponseMessage
                {
                    StatusCode = HttpStatusCode.OK,
                    Content = new StringContent(expectedResponse)
                });

            var httpClient = new HttpClient(mockMessageHandler.Object);

            mockFactory.Setup(_ => _.CreateClient(It.IsAny<string>())).Returns(httpClient);

            var logger = Mock.Of<ILogger<LoginController>>();

            var services = new ServiceCollection();
            services.AddMemoryCache();
            var serviceProvider = services.BuildServiceProvider();

            var memoryCache = serviceProvider.GetService<IMemoryCache>();

            var graphService = new GraphApiService(mockFactory.Object, memoryCache, settings);

            var controller = new LoginController(logger, mockFactory.Object, settings, graphService);

            return controller;
        }
    }
}

还需要一个名为 GraphApiServiceMock.cs 的文件,它会像使用 mockMessageHandler.Protected() 和静态值 public static string DummyUserExternalId = "11111111-1111-1111-1111-111111111111"; 的示例一样添加更多的值。

这可以用其他方法实现,但通常需要依赖于“自定义策略”:

https://learn.microsoft.com/en-us/answers/questions/469509/can-we-get-and-edit-azure-ad-b2c-roles-using-ad-b2.html

https://devblogs.microsoft.com/premier-developer/using-groups-in-azure-ad-b2c/

https://learn.microsoft.com/en-us/azure/active-directory-b2c/user-flow-overview


这种方法在使用Azure Functions API而不是ASP.NET Core Web API时是否可行?需要做些什么不同的事情吗?我一直在努力使用Azure Functions API后端完成所有这些工作。 - user1227445
@user1227445 我还没有在Azure Functions API中使用过它。我知道普通的Azure AD可以直接使用,但不确定AD B2C是否可以。https://learn.microsoft.com/en-us/azure/azure-functions/security-concepts#user-management-permissions - Ogglas
我更多的想法是,像你示例中的GraphApiService一样获取用户组的东西是否可以放入一个匿名的Azure函数中。只是试图避免部署服务器应用程序来获取用户组。 - user1227445

0
首先,感谢大家之前的回复。我花了整整一天的时间来让它工作。我正在使用ASPNET Core 3.1,并且在使用之前的解决方案时遇到了以下错误:
secure binary serialization is not supported on this platform

我已经更改为使用REST API查询,并且能够获取组:

    public Task OnTokenValidated(TokenValidatedContext context)
    {
        _onTokenValidated?.Invoke(context);
        return Task.Run(async () =>
        {
            try
            {
                var oidClaim = context.SecurityToken.Claims.FirstOrDefault(c => c.Type == "oid");
                if (!string.IsNullOrWhiteSpace(oidClaim?.Value))
                {
                    HttpClient http = new HttpClient();

                    var domainName = _azureADSettings.Domain;
                    var authContext = new AuthenticationContext($"https://login.microsoftonline.com/{domainName}");
                    var clientCredential = new ClientCredential(_azureADSettings.ApplicationClientId, _azureADSettings.ApplicationSecret);
                    var accessToken = AcquireGraphAPIAccessToken(AadGraphUri, authContext, clientCredential).Result;

                    var url = $"https://graph.windows.net/{domainName}/users/" + oidClaim?.Value + "/$links/memberOf?api-version=1.6";

                    HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, url);
                    request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
                    HttpResponseMessage response = await http.SendAsync(request);

                    dynamic json = JsonConvert.DeserializeObject<dynamic>(await response.Content.ReadAsStringAsync());

                    foreach(var group in json.value)
                    {
                        dynamic x = group.url.ToString();

                        request = new HttpRequestMessage(HttpMethod.Get, x + "?api-version=1.6");
                        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
                        response = await http.SendAsync(request);

                        dynamic json2 = JsonConvert.DeserializeObject<dynamic>(await response.Content.ReadAsStringAsync());

                        ((ClaimsIdentity)((ClaimsIdentity)context.Principal.Identity)).AddClaim(new Claim(ClaimTypes.Role.ToString(), json2.displayName.ToString()));
                    }
                }
            }
            catch (Exception e)
            {
                Debug.WriteLine(e);
            }
        });
    }

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接