如何使用 Active Directory 存储在 AcquireTokenAsync 中收到的令牌

Posted

技术标签:

【中文标题】如何使用 Active Directory 存储在 AcquireTokenAsync 中收到的令牌【英文标题】:How to store the token received in AcquireTokenAsync with Active Directory 【发布时间】:2017-05-22 00:13:01 【问题描述】:

问题陈述

我正在使用 .NET Core,并且正在尝试使 Web 应用程序与 Web API 对话。两者都需要在其所有类上使用[Authorize] 属性进行身份验证。为了能够在服务器到服务器之间进行通信,我需要检索验证令牌。感谢a Microsoft tutorial,我能够做到这一点。

问题

在教程中,他们使用了对AcquireTokenByAuthorizationCodeAsync 的调用,以便将令牌保存在缓存中,这样在其他地方,代码只需执行AcquireTokenSilentAsync,这不需要去权威机构验证用户。

此方法不查找令牌缓存,而是将结果存储在其中,因此可以使用AcquireTokenSilentAsync等其他方法进行查找

当用户已经登录时,问题就出现了。存储在OpenIdConnectEvents.OnAuthorizationCodeReceived 的方法永远不会被调用,因为没有收到授权。只有在有新登录时才会调用该方法。

当仅通过 cookie 验证用户时,还有另一个事件称为:CookieAuthenticationEvents.OnValidatePrincipal。这行得通,我可以获得令牌,但我必须使用AcquireTokenAsync,因为那时我没有授权码。根据文档,它

从权威机构获取安全令牌。

这使得调用AcquireTokenSilentAsync 失败,因为令牌没有被缓存。而且我宁愿不要总是使用AcquireTokenAsync,因为那总是交给权威机构。

问题

如何告诉AcquireTokenAsync 获得的令牌被缓存,以便我可以在其他任何地方使用AcquireTokenSilentAsync

相关代码

这一切都来自主 Web 应用程序项目中的 Startup.cs 文件。


这是事件处理的完成方式:

app.UseCookieAuthentication(new CookieAuthenticationOptions()

    Events = new CookieAuthenticationEvents()
    
        OnValidatePrincipal = OnValidatePrincipal,
    
);

app.UseOpenIdConnectAuthentication(new OpenIdConnectOptions

    ClientId = ClientId,
    Authority = Authority,
    PostLogoutRedirectUri = Configuration["AzureAd:PostLogoutRedirectUri"],
    ResponseType = OpenIdConnectResponseType.CodeIdToken,
    CallbackPath = Configuration["Authentication:AzureAd:CallbackPath"],
    GetClaimsFromUserInfoEndpoint = false,

    Events = new OpenIdConnectEvents()
    
        OnRemoteFailure = OnAuthenticationFailed,
        OnAuthorizationCodeReceived = OnAuthorizationCodeReceived,
    
);

这些是背后的事件:

private async Task OnValidatePrincipal(CookieValidatePrincipalContext context)

    string userObjectId = (context.Principal.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier"))?.Value;
    ClientCredential clientCred = new ClientCredential(ClientId, ClientSecret);
    AuthenticationContext authContext = new AuthenticationContext(Authority, new NaiveSessionCache(userObjectId, context.HttpContext.Session));
    AuthenticationResult authResult = await authContext.AcquireTokenAsync(ClientResourceId, clientCred);

    // How to store token in authResult?


private async Task OnAuthorizationCodeReceived(AuthorizationCodeReceivedContext context)

    // Acquire a Token for the Graph API and cache it using ADAL.  In the TodoListController, we'll use the cache to acquire a token to the Todo List API
    string userObjectId = (context.Ticket.Principal.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier"))?.Value;
    ClientCredential clientCred = new ClientCredential(ClientId, ClientSecret);
    AuthenticationContext authContext = new AuthenticationContext(Authority, new NaiveSessionCache(userObjectId, context.HttpContext.Session));
    AuthenticationResult authResult = await authContext.AcquireTokenByAuthorizationCodeAsync(
        context.ProtocolMessage.Code, new Uri(context.Properties.Items[OpenIdConnectDefaults.RedirectUriForCodePropertiesKey]), clientCred, GraphResourceId);

    // Notify the OIDC middleware that we already took care of code redemption.
    context.HandleCodeRedemption();


// Handle sign-in errors differently than generic errors.
private Task OnAuthenticationFailed(FailureContext context)

    context.HandleResponse();
    context.Response.Redirect("/Home/Error?message=" + context.Failure.Message);
    return Task.FromResult(0);

任何其他代码都可以在链接的教程中找到,或者问我会添加到问题中。

【问题讨论】:

您找到解决方案了吗? @MichaelFreidgeim,没有。现在,我只是将令牌存储在 Session 数据中。 @MichaelFreidgeim 我相信我在下面的回答中有一个可行的解决方案 @k25,我使用另一个页面将令牌发送给客户端。它只是一个可以轮询的 JSON 响应,并且具有一定的安全性来确保用户是有效的。另请注意,这些令牌的超时速度比一般前端的要快(据我记得大约一个小时),因此请考虑到您必须弄清楚何时重新查询令牌。我个人的方法是跟踪我得到它的时间,如果是一定时间后再试一次。服务器具有相同的信息,如果超过了分配的时间(双方相同),则重新登录。 @David 感谢您抽出宝贵时间!是的,最后我不得不求助于做同样的事情。我非常希望将其作为 AuthenticationProperties 的一部分发送,但就是不知道如何在客户端获取它。 【参考方案1】:

(注意:我已经为这个确切的问题苦苦挣扎了好几天。我遵循了与问题中链接的相同的 Microsoft 教程,并跟踪了各种问题,例如大雁追逐;结果是示例使用最新版本的Microsoft.AspNetCore.Authentication.OpenIdConnect 包时包含一大堆看似不必要的步骤。)

当我阅读此页面时,我终于有了一个突破性的时刻: http://docs.identityserver.io/en/release/quickstarts/5_hybrid_and_api_access.html

解决方案主要涉及让 OpenID Connect 身份验证将各种令牌(access_tokenrefresh_token)放入 cookie。

首先,我使用的是在https://apps.dev.microsoft.com 创建的融合应用程序和 Azure AD 端点的 v2.0。该应用程序有一个应用程序密钥(密码/公钥)并使用Allow Implicit Flow 用于 Web 平台。

(由于某种原因,端点的 v2.0 似乎不适用于仅限 Azure AD 的应用程序。我不知道为什么,我也不确定它是否真的很重要。)

Startup.Configure 方法的相关行:

    // Configure the OWIN pipeline to use cookie auth.
    app.UseCookieAuthentication(new CookieAuthenticationOptions());

    // Configure the OWIN pipeline to use OpenID Connect auth.
    var openIdConnectOptions = new OpenIdConnectOptions
    
         ClientId = "Your-ClientId",
         ClientSecret = "Your-ClientSecret",
         Authority = "http://login.microsoftonline.com/Your-TenantId/v2.0",
         ResponseType = OpenIdConnectResponseType.CodeIdToken,
         TokenValidationParameters = new TokenValidationParameters
         
             NameClaimType = "name",
         ,
         GetClaimsFromUserInfoEndpoint = true,
         SaveTokens = true,
    ;

    openIdConnectOptions.Scope.Add("offline_access");

    app.UseOpenIdConnectAuthentication(openIdConnectOptions);

就是这样!没有OpenIdConnectOptions.Event 回调。没有致电 AcquireTokenAsyncAcquireTokenSilentAsync。没有TokenCache。这些东西似乎都不是必需的。

魔法似乎是OpenIdConnectOptions.SaveTokens = true的一部分

这是一个示例,我使用访问令牌代表使用 Office365 帐户的用户发送电子邮件。

我有一个 WebAPI 控制器操作,它使用 HttpContext.Authentication.GetTokenAsync("access_token") 获取他们的访问令牌:

    [HttpGet]
    public async Task<IActionResult> Get()
    
        var graphClient = new GraphServiceClient(new DelegateAuthenticationProvider(async requestMessage =>
        
            var accessToken = await HttpContext.Authentication.GetTokenAsync("access_token");
            requestMessage.Headers.Authorization = new AuthenticationHeaderValue("bearer", accessToken);
        ));

        var message = new Message
        
            Subject = "Hello",
            Body = new ItemBody
            
                Content = "World",
                ContentType = BodyType.Text,
            ,
            ToRecipients = new[]
            
                new Recipient
                
                    EmailAddress = new EmailAddress
                    
                        Address = "email@address.com",
                        Name = "Somebody",
                    
                
            ,
        ;

        var request = graphClient.Me.SendMail(message, true);
        await request.Request().PostAsync();

        return Ok();
    

旁注#1

在某些时候,您可能还需要获取refresh_token,以防 access_token 过期:

HttpContext.Authentication.GetTokenAsync("refresh_token")

旁注#2

我的OpenIdConnectOptions 实际上还包括一些我在这里省略的内容,例如:

    openIdConnectOptions.Scope.Add("email");
    openIdConnectOptions.Scope.Add("Mail.Send");

我已将这些用于使用Microsoft.Graph API 来代表当前登录的用户发送电子邮件。

(Microsoft Graph 的那些委派权限也在应用程序上设置)。


更新 - 如何“静默”刷新 Azure AD 访问令牌

到目前为止,这个答案解释了如何使用缓存的访问令牌,但没有说明令牌过期时(通常在 1 小时后)要做什么。

选项似乎是:

    强制用户重新登录。 (不沉默) 使用 refresh_token 向 Azure AD 服务发布请求,以获取新的 access_token(静默)。

如何使用 Endpoint v2.0 刷新访问令牌

经过更多挖掘,我在这个 SO Question 中找到了部分答案:

How to handle expired access token in asp.net core using refresh token with OpenId Connect

Microsoft OpenIdConnect 库似乎不会为您刷新访问令牌。不幸的是,上述问题的答案缺少关于准确如何刷新令牌的关键细节;大概是因为它依赖于 OpenIdConnect 不关心的有关 Azure AD 的特定细节。

上述问题的公认答案建议直接向 Azure AD 令牌 REST API 发送请求,而不是使用 Azure AD 库之一。

这是相关文档(注意:这涵盖了 v1.0 和 v2.0 的混合)

https://developer.microsoft.com/en-us/graph/docs/concepts/rest https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-protocols-oauth-code#refreshing-the-access-tokens

这是一个基于 API 文档的代理:

public class AzureAdRefreshTokenProxy

    private const string HostUrl = "https://login.microsoftonline.com/";
    private const string TokenUrl = $"Your-Tenant-Id/oauth2/v2.0/token";
    private const string ContentType = "application/x-www-form-urlencoded";

    // "HttpClient is intended to be instantiated once and re-used throughout the life of an application."
    // - MSDN Docs:
    // https://msdn.microsoft.com/en-us/library/system.net.http.httpclient(v=vs.110).aspx
    private static readonly HttpClient Http = new HttpClient BaseAddress = new Uri(HostUrl);

    public async Task<AzureAdTokenResponse> RefreshAccessTokenAsync(string refreshToken)
    
        var body = $"client_id=Your-Client-Id" +
                   $"&refresh_token=refreshToken" +
                   "&grant_type=refresh_token" +
                   $"&client_secret=Your-Client-Secret";
        var content = new StringContent(body, Encoding.UTF8, ContentType);

        using (var response = await Http.PostAsync(TokenUrl, content))
        
            var responseContent = await response.Content.ReadAsStringAsync();
            return response.IsSuccessStatusCode
                ? JsonConvert.DeserializeObject<AzureAdTokenResponse>(responseContent)
                : throw new AzureAdTokenApiException(
                    JsonConvert.DeserializeObject<AzureAdErrorResponse>(responseContent));
        
    

JsonConvert 使用的 AzureAdTokenResponseAzureAdErrorResponse 类:

[JsonObject(MemberSerialization = MemberSerialization.OptIn)]
public class AzureAdTokenResponse

    [JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "token_type", Required = Required.Default)]
    public string TokenType  get; set; 
    [JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "expires_in", Required = Required.Default)]
    public int ExpiresIn  get; set; 
    [JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "expires_on", Required = Required.Default)]
    public string ExpiresOn  get; set;  
    [JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "resource", Required = Required.Default)]
    public string Resource  get; set; 
    [JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "access_token", Required = Required.Default)]
    public string AccessToken  get; set; 
    [JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "refresh_token", Required = Required.Default)]
    public string RefreshToken  get; set; 


[JsonObject(MemberSerialization = MemberSerialization.OptIn)]
public class AzureAdErrorResponse

    [JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "error", Required = Required.Default)]
    public string Error  get; set; 
    [JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "error_description", Required = Required.Default)]
    public string ErrorDescription  get; set; 
    [JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "error_codes", Required = Required.Default)]
    public int[] ErrorCodes  get; set; 
    [JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "timestamp", Required = Required.Default)]
    public string Timestamp  get; set; 
    [JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "trace_id", Required = Required.Default)]
    public string TraceId  get; set; 
    [JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "correlation_id", Required = Required.Default)]
    public string CorrelationId  get; set; 


public class AzureAdTokenApiException : Exception

    public AzureAdErrorResponse Error  get; 

    public AzureAdTokenApiException(AzureAdErrorResponse error) :
        base($"error.Error error.ErrorDescription")
    
        Error = error;
    

最后,我修改了 Startup.cs 以刷新access_token (根据我上面链接的答案)

        // Configure the OWIN pipeline to use cookie auth.
        app.UseCookieAuthentication(new CookieAuthenticationOptions
        
            Events = new CookieAuthenticationEvents
            
                OnValidatePrincipal = OnValidatePrincipal
            ,
        );

Startup.cs 中的 OnValidatePrincipal 处理程序(同样,来自上面的链接答案):

    private async Task OnValidatePrincipal(CookieValidatePrincipalContext context)
    
        if (context.Properties.Items.ContainsKey(".Token.expires_at"))
        
            if (!DateTime.TryParse(context.Properties.Items[".Token.expires_at"], out var expiresAt))
            
                expiresAt = DateTime.Now;
            

            if (expiresAt < DateTime.Now.AddMinutes(-5))
            
                var refreshToken = context.Properties.Items[".Token.refresh_token"];
                var refreshTokenService = new AzureAdRefreshTokenService();
                var response = await refreshTokenService.RefreshAccessTokenAsync(refreshToken);

                context.Properties.Items[".Token.access_token"] = response.AccessToken;
                context.Properties.Items[".Token.refresh_token"] = response.RefreshToken;
                context.Properties.Items[".Token.expires_at"] = DateTime.Now.AddSeconds(response.ExpiresIn).ToString(CultureInfo.InvariantCulture);
                context.ShouldRenew = true;
            
        
    

最后,一个使用 Azure AD API v2.0 的 OpenIdConnect 解决方案。

有趣的是,v2.0 似乎没有要求将resource 包含在 API 请求中;文档表明这是必要的,但 API 本身只是回复 resource 不受支持。这可能是件好事——大概这意味着访问令牌适用于所有资源(它当然适用于 Microsoft Graph API)

【讨论】:

TokenCache 在某些情况下是必要的。如果有刷新令牌,则用于获取新的访问令牌。 这里的文章不错。 dzimchuk.net/adal-distributed-token-cache-in-asp-net-core 此外,asp.net 核心还允许隐式流(id_token 令牌),因此前端通道是一个选项,但除非您每次都可以启动登录流,否则将需要再次刷新令牌。 是的,直接比较新世界使用范围docs.microsoft.com/en-gb/azure/active-directory/develop/… 天哪,我并不孤单!所有关于这些东西的微软示例都被不必要地混淆了,这使得学习如何做一些简单的事情变得非常痛苦。

以上是关于如何使用 Active Directory 存储在 AcquireTokenAsync 中收到的令牌的主要内容,如果未能解决你的问题,请参考以下文章

我们可以使用 Azure Active Directory 提供对 blob/容器/存储帐户的访问吗?

我们是不是必须将密码存储在与 Active Directory 链接的系统中?

你能计算出 Active Directory 使用的密码哈希吗?

学习总结-Active Directory 域服务管理03-导入资源

如何使用C ++在Active Directory中获取maxpwdAge属性值?

如何使用 java 在 OpenLdap 和 Active Directory 之间进行同步?