簡體   English   中英

在 Azure Active Directory B2C 中按組授權

[英]Authorize By Group in Azure Active Directory B2C

我想弄清楚如何授權使用 Azure Active Directory B2C 中的組。 我可以通過用戶授權,例如:

[Authorize(Users="Bill")]

但是,這不是很有效,而且我看到的用例很少。 另一種解決方案是通過角色授權。 但是由於某種原因,這似乎不起作用。 例如,如果我給用戶角色“全局管理員”並嘗試:

[Authorize(Roles="Global Admin")]

有沒有辦法通過組或角色授權?

從 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 事件來最終使用它。 回到我們通常調用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);
        }
    });
}

此處使用的是 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

現在我們終於完成了! “幾行代碼”怎么樣? :)

這將起作用,但是您必須在身份驗證邏輯中編寫幾行代碼才能實現您要查找的內容。

首先,您必須區分 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詢問用戶成員資格。 您可以使用此示例來獲得有關如何獲取用戶組成員資格的靈感。 最好在 OpenID 通知之一(即SecurityTokenValidated )中執行此代碼並將用戶角色添加到 ClaimsPrincipal。

將 ClaimsPrincipal 更改為具有 Azure AD 安全組和“角色聲明”值后,您將能夠使用具有角色功能的授權屬性。 這實際上是 5-6 行代碼。

最后,您可以在此處對該功能進行投票,以便獲得群組成員資格聲明,而無需為此查詢 Graph API。

我按照書面方式實施了這一點,但截至 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 時遇到問題,將錯誤的用戶名和密碼更新到最新的 lib

亞歷克斯的回答對於找出可行的解決方案至關重要,感謝您指出正確的方向。

然而,它使用app.UseOpenIdConnectAuthentication()已經在 Core 2 中長期貶值並在 Core 3 中完全刪除( 將身份驗證和身份遷移到 ASP.NET Core 2.0

我們必須實現的基本任務是使用OpenIdConnectOptions將事件處理程序附加到OnTokenValidated ,ADB2C 身份驗證在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 類干凈。 如果原始事件處理程序不為空(它不是順便說一句),則保存並調用原始事件處理程序

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" />

Catch:您必須授予您的應用程序讀取 AD 的權限。 截至 2019 年 10 月,此應用程序必須是“舊版”應用程序,而不是最新的 B2C 應用程序。 這是一個非常好的指南: Azure AD B2C:使用 Azure AD Graph API

基於這里所有驚人的答案,使用新的 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: Azure AD 團隊在此處提供的基於角色的訪問控制

但是,是的,唯一的解決方案似乎是通過在 MS Graph 的幫助下讀取用戶組的自定義實現。

我真的很喜歡@AlexLobakov 的回答,但我想要.NET 6的更新答案,還有一些可測試但仍實現緩存功能的東西。 我還希望將角色發送到我的前端,與 React 等任何 SPA 兼容,並在我的應用程序中使用標准 Azure AD B2C 用戶流進行基於角色的訪問控制 (RBAC)。

我還錯過了一個從頭到尾的指南,如此多的變量可能 go 錯誤,最終導致應用程序無法運行。

首先在Visual Studio 2022中使用以下設置創建一個新的ASP.NET Core Web API

創建后你應該得到這樣的對話:

如果您沒有看到它,請右鍵單擊 Visual Studio 中的項目,然后依次單擊“概述”和“連接的服務”。

在您的 Azure AD B2C 中創建一個新的App registration或使用現有的。 我為此演示目的注冊了一個新的。

創建App registration后,Visual Studio 卡在Dependency configuration progress上,因此將手動配置 rest:

登錄https://portal.azure.com/ ,切換目錄到你的AD B2C,select你的新App registration然后點擊Authentication。 然后點擊Add a platform和 select Web

為本地主機添加Redirect URIFront-channel logout URL

例子:

https://localhost:7166/signin-oidc

https://localhost:7166/注銷

如果您選擇單頁應用程序,它看起來幾乎一樣。 但是,您隨后需要添加一個 code_challenge,如下所述。 將不會顯示一個完整的例子。

Active Directory 是否不支持 PKCE 的授權代碼流?

身份驗證應如下所示:

單擊Certificates & secrets並創建一個新的 Client secret。

單擊Expose an API ,然后編輯Application ID URI

默認值應如下所示api://11111111-1111-1111-1111-111111111111 將其編輯為https://youradb2c.onmicrosoft.com/11111111-1111-1111-1111-111111111111 應該有一個名為 access_as_user 的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 用戶是否是您要對其進行身份驗證的組的成員:

在此處輸入圖像描述

現在您可以 go 返回您的應用程序並驗證您是否可以獲得登錄代碼。 使用此示例,它應該使用代碼重定向:

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存儲在 Secret Manager 中。

https://learn.microsoft.com/en-us/as.net/core/security/app-secrets?view=as.netcore-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; }

    }
}

Adb2cToken 響應:

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

目前只有GraphServiceClientaccessToken存儲在 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 Controller

登錄控制器:

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 中的新最小托管 model 應該看起來像這樣:

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中用於GraphApiServiceClaimsIdentity 不幸的是,由於正文限制為 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()的示例和 static 值,例如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

首先感謝大家之前的回復。 我花了一整天的時間來解決這個問題。 我正在使用 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);
            }
        });
    }

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM