在Azure Active Directory B2C中按组授权

1qczuiv0  于 2023-01-18  发布在  其他
关注(0)|答案(8)|浏览(164)

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

[Authorize(Users="Bill")]

但是,这不是很有效,而且我很少看到这样的用例。另一种解决方案是通过角色授权。但是由于某些原因,这似乎不起作用。例如,如果我为用户给予角色“全局管理员”,并尝试:

[Authorize(Roles="Global Admin")]

是否可以通过组或角色进行授权?

dsekswqp

dsekswqp1#

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

polkgigr2#

从Azure AD获取用户的组成员资格需要的不仅仅是“几行代码”,所以我想我应该分享一下最终对我有效的方法,这样可以保存其他人几天的时间。
让我们开始向project.json添加以下依赖项:

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

第一个是必需的,因为我们需要验证我们的应用程序,以便它能够访问AAD图形API。第二个是图形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;
}

请注意,它有一个内置的重试机制来处理 transient 条件,您可能希望根据应用程序的需要来定制该机制。
现在我们已经处理了应用程序身份验证和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中配置了一个显示名称为“Administrators”的指定组。
但是,如果您选择使用自定义声明类型,则需要通过在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

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

vs91vp4v

vs91vp4v3#

这是可行的,但是您必须在身份验证逻辑中编写几行代码才能实现您所期望的功能。
首先,您必须区分Azure AD(B2C)中的RolesGroups
User Role非常具体,仅在Azure AD(B2C)本身内有效。该角色定义用户在Azure AD内具有哪些权限。
Group(或Security Group)定义了用户组成员资格,可以向外部应用程序公开。外部应用程序可以在 * 安全组 * 之上建模 * 基于角色的访问控制 *。是的,我知道这听起来可能有点混乱,但事实就是如此。
因此,您的第一步是在Azure AD B2C中建模您的Groups-您必须创建组并手动将用户分配到这些组。您可以在Azure门户(https://portal.azure.com/)中执行此操作:

然后,回到应用程序,您需要编写一些代码,并在用户成功通过身份验证后向Azure AD B2C Graph API请求用户成员资格。您可以使用this sample获得灵感,了解如何获取用户组成员资格。最好在OpenID通知(即SecurityTokenValidated)中执行此代码,并将用户角色添加到ClaimsPrincipal。
将ClaimsPrincipal更改为具有Azure AD安全组和“角色声明”值后,你将能够使用Authrize属性和角色功能。这实际上需要5-6行代码。
最后,您可以为特性**here**投票,以便获得组成员资格声明,而不必为此查询Graph API。

olhwl3o2

olhwl3o24#

我按照书面要求实施了这一点,但截至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问题,将错误的用户名和密码更新到最新库

y1aodyip

y1aodyip5#

亚历克斯的回答对于找出一个可行的解决方案至关重要,谢谢你指出了正确的方向。
但是,它使用的app.UseOpenIdConnectAuthentication()在Core 2中已经过时很长时间,在Core 3中完全删除(将身份验证和标识迁移到ASP.NET Core 2.0)
我们必须执行的基本任务是使用ADB2C身份验证所使用的OpenIdConnectOptions将事件处理程序连接到OnTokenValidated,但不能干扰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类干净。如果原始事件处理程序不为空(不为btw),则保存并调用该处理程序

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: Use the Azure AD Graph API

mcdcgff0

mcdcgff06#

有官方样本:Azure AD B2C:Azure AD团队提供的基于角色的访问控制available here
但是是的,唯一的解决方案似乎是通过在MS Graph的帮助下阅读用户组来自定义实现。

t3irkdon

t3irkdon7#

基于这里所有令人惊讶的答案,使用新的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();
rqdpfwrv

rqdpfwrv8#

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

在创建之后,您应该会看到这样的对话框:

如果您没有看到这一点,请右键单击Visual Studio中的项目,然后单击Overview,然后单击Connected services。

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

创建App registration后,Visual Studio在Dependency configuration progress上卡住,因此将手动配置其余部分:

登录到https://portal.azure.com/,切换目录到AD B2C,选择新的App registration,然后单击Authentication。然后单击Add a platform并选择Web
为本地主机添加Redirect URIFront-channel logout URL
示例:
https://本地主机:7166/登录-oidc
https://本地主机:7166/注销

如果你选择单页应用程序,它看起来几乎一样。但是你需要添加一个code_challenge,如下所述。完整的例子将不会显示。
Is Active Directory not supporting Authorization Code Flow with PKCE?

身份验证应如下所示:

单击Certificates & secrets并创建新的客户端密码。
单击Expose an API,然后编辑Application ID URI
默认值应该类似于api://11111111-1111-1111-1111-111111111111。将其编辑为https://youradb2c.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,然后选择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:提供的请求必须提供code_challenge
那么您可能选择了平台Single-page application,需要向请求中添加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
https://learn.microsoft.com/en-us/aspnet/core/security/app-secrets?view=aspnetcore-6.0&tabs=windows#manage-user-secrets-with-visual-studio
现在创建这些新类:
应用程序设置:

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

    }
}

Adb2c令牌响应:

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

图形应用程序服务:

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存储在内存缓存中,但如果应用程序需要更好的性能,也可以缓存用户组。
添加新类:
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,反过来也可以在Program.cs中测试用于ClaimsIdentityGraphApiService。不幸的是,由于正文被限制为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;
        }
    }
}

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

还有其他方法可以做到这一点,但它们通常依赖于Custom Policies
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

相关问题