在 Azure Active Directory B2C 中按组授权

Authorize By Group in Azure Active Directory B2C

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

[Authorize(Users="Bill")]

但是,这不是很有效,我看到很少有用例。另一种解决方案是通过角色授权。但是由于某种原因,这似乎不起作用。例如,如果我给用户角色 "Global Admin" 并尝试:

,它就不起作用
[Authorize(Roles="Global Admin")]

有没有办法通过组或角色进行授权?

这会起作用,但是您必须在您的身份验证逻辑中编写几行代码才能实现您正在寻找的内容。

首先,你必须区分Azure AD (B2C)中的RolesGroups

User Role 非常具体,仅在 Azure AD (B2C) 本身内有效。角色定义了用户在 inside Azure AD 中拥有的权限。

Group(或Security Group)定义用户组成员资格,可以向外部应用程序公开。外部应用程序可以在 安全组 之上建模 基于角色的访问控制 。是的,我知道这听起来有点混乱,但事实就是如此。

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

然后,回到您的应用程序,您将需要编写一些代码并询问 Azure AD B2C Graph API for users memberships once the user is successfully authenticated. You can use this sample to get inspired on how to get users group memberships. It is best to execute this code in one of the OpenID Notifications (i.e. SecurityTokenValidated) 并将用户角色添加到 ClaimsPrincipal。

将 ClaimsPrincipal 更改为具有 Azure AD 安全组和 "Role Claim" 值后,您将能够使用具有角色功能的 Authrize 属性。这真的是5-6行代码。

最后,您可以为功能 here 投票以获得群组成员资格声明,而无需为此查询 Graph API。

从 Azure AD 为用户获取组成员资格需要的不仅仅是 "a couple lines of code",所以我想我应该分享一下最终对我有用的东西,以节省其他人几天的麻烦和撞头。

让我们首先将以下依赖项添加到 project.json:

"dependencies": {
    ...
    "Microsoft.IdentityModel.Clients.ActiveDirectory": "3.13.8",
    "Microsoft.Azure.ActiveDirectory.GraphClient": "2.0.2"
}

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

接下来,在 Startup class 的 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()方法,当客户端需要获取认证token时,会在必要时调用。该方法如下所示:

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;
}

请注意,它有一个内置的重试机制来处理 t运行sient 条件,您可能希望根据应用程序的需要进行调整。

现在我们已经处理了应用程序身份验证和 AD 客户端设置,我们可以继续并利用 OpenIdConnect 事件来最终使用它。 回到我们通常调用 app.UseOpenIdConnectAuthentication() 并创建 OpenIdConnectOptions 实例的 Configure() 方法,我们为 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);
        }
    });
}

此处使用的是角色声明类型,但您可以使用自定义类型。

完成上述操作后,如果您正在使用 ClaimType.Role,您需要做的就是装饰您的控制器 class 或像这样的方法:

[Authorize(Role = "Administrators")]

当然,前提是您在 B2C 中配置了一个显示名称为 "Administrators" 的指定组。

但是,如果您选择使用自定义声明类型,则需要通过在 ConfigureServices() 方法中添加类似这样的内容来定义基于声明类型的授权策略,例如:

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

然后装饰一个特权控制器class或方法如下:

[Authorize(Policy = "ADMIN_ONLY")]

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

如果您 运行 您的应用程序并尝试登录,您会从 Graph API 中得到一个异常,声称 "Insufficient privileges to complete the operation"。 这可能并不明显,但是当您的应用程序使用其 app_id 和 app_key 成功通过 AD 身份验证时,它没有从您的 AD 读取用户详细信息所需的权限。 为了g运行t这样的应用访问,我选择了使用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

现在我们终于完成了! "a couple lines of code" 怎么样? :)

我按照所写的方式实现了这一点,但截至 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 问题,将错误的用户名和密码更新到最新的库

Alex 的回答对于找到可行的解决方案至关重要,感谢您指出了正确的方向。

但是它使用了 app.UseOpenIdConnectAuthentication(),它在 Core 2 中已经贬值了很长时间,在 Core 3 中被完全移除 (Migrate authentication and Identity to 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;
});

所有实现都封装在帮助程序 class 中以保持 Startup class 干净。保存原始事件处理程序并在它不为空时调用(顺便说一句)

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 月,此应用程序必须是 'legacy' 应用程序,而不是最新的 B2C 应用程序。这是一个很好的指南:Azure AD B2C: Use the Azure AD Graph API

首先感谢大家之前的回复。我花了一整天的时间来解决这个问题。我正在使用 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);
            }
        });
    }

基于此处所有令人惊叹的答案,使用新的 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();

有官方示例:Azure AD B2C:基于角色的访问控制 available here 来自 Azure AD 团队。

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

我真的很喜欢@AlexLobakov 的回答,但我想要 .NET 6 的更新答案以及可测试但仍实现缓存功能的内容。我还希望将角色发送到我的前端,与 React 等任何 SPA 兼容,并在我的应用程序中使用标准 Azure AD B2C 用户流进行 Role-based 访问控制 (RBAC)。

我还错过了一个从头到尾的指南,太多的变量可能会出错,最终导致应用程序无法运行。

开始使用以下设置在 Visual Studio 2022 中创建一个新的 ASP.NET Core Web API

创建后应该会出现这样的对话:

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

在您的 Azure AD B2C 中创建一个新的 App registration 或使用现有的。我为这个演示目的注册了一个新的。

创建 App registration 之后 Visual Studio 卡在了 Dependency configuration progress 上,所以剩下的将手动配置:

登录 https://portal.azure.com/,将目录切换到您的 AD B2C,select 您的新 App registration,然后单击身份验证。然后点击 Add a platform 和 select Web.

为本地主机添加 Redirect URIFront-channel logout URL

示例:

https://localhost:7166/signin-oidc

https://localhost:7166/logout

如果您选择 Single-page 应用程序,它看起来几乎一样。但是,您随后需要添加一个 code_challenge,如下所述。不会显示完整的示例。

身份验证应如下所示:

单击 Certificates & secrets 并创建一个新的客户端密码。

单击 Expose an API,然后编辑 Application ID URI

默认值应如下所示api://11111111-1111-1111-1111-111111111111。将其编辑为 https://strtestadb2c.onmicrosoft.com/11111111-1111-1111-1111-111111111111。应该有一个名为 access_as_user 的范围。如果不存在则创建。

现在点击API permissions:

需要四个 Microsoft Graph 权限。

两个申请:

GroupMember.Read.All
User.Read.All

两个委派:

offline_access
openid

您还需要获得我的 API 的 access_as_user 许可。完成后点击 Grant admin consent for ...。应如下所示:

如果您还没有用户流,则创建 Sign up and sign inSign in 和 select Recommended。我的用户流是默认的B2C_1_signin.

确认您的 AD B2C 用户是您要验证的组的成员:

现在您可以返回您的应用程序并验证您是否可以获得登录代码。使用此示例,它应该使用代码重定向:

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: The supplied request must present a code_challenge

那么您可能已经 select 编辑了平台 Single-page application,并且需要向请求中添加一个 code_challenge,例如:&code_challenge=123。这还不够,因为您稍后还需要验证挑战,否则在 运行 执行我的代码时会出现以下错误。

AADB2C90183: The supplied code_verifier is invalid

现在打开您的应用程序并 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://docs.microsoft.com/en-us/aspnet/core/security/app-secrets?view=aspnetcore-6.0&tabs=windows#manage-user-secrets-with-visual-studio

现在创建这些新的 classes:

应用程序设置:

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";
    }
}

GraphApi服务:

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);
        }
    }
}

目前只有 accessToken for GraphServiceClient 存储在 memorycache 中,但如果应用程序需要更好的性能,也可以缓存用户组。

添加一个新的class:

Adb2c用户:

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; }
    }
}

和结构:

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.Sdkxunitxunit.runner.visualstudioMoq 我们还可以测试 LoginController,进而 GraphApiService 用于ClaimsIdentityProgram.cs。不幸的是,正文被限制为 30000 个字符无法显示整个测试。

基本上是这样的:

登录控制器测试:

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;
        }
    }
}

A GraphApiServiceMock.cs 也是需要的,但它只是添加了更多的值,例如带有 mockMessageHandler.Protected() 的示例和静态值,例如 public static string DummyUserExternalId = "11111111-1111-1111-1111-111111111111";.

还有其他方法可以做到这一点,但它们通常取决于 Custom Policies:

https://docs.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://docs.microsoft.com/en-us/azure/active-directory-b2c/user-flow-overview