Динамически добавлять сервер ресурсов OWIN JWT. Приложения (аудитория)

У меня есть API C#, который использует OWIN JWT для аутентификации.

Мой startup.cs (моего сервера ресурсов) настраивает OAuth по коду:

public void ConfigureOAuth(IAppBuilder app)
{
    var issuer = "<the_same_issuer_as_AuthenticationServer.Api>";

    // Api controllers with an [Authorize] attribute will be validated with JWT
    var audiences = DatabaseAccessLayer.GetAllowedAudiences(); // Gets a list of audience Ids, secrets, and names (although names are unused)

    // List the 
    List<string> audienceId = new List<string>();
    List<IIssuerSecurityTokenProvider> providers = new List<IIssuerSecurityTokenProvider>();
    foreach (var aud in audiences) {
        audienceId.Add(aud.ClientId);
        providers.Add(new SymmetricKeyIssuerSecurityTokenProvider(issuer, TextEncodings.Base64Url.Decode(aud.ClientSecret)));
    }

    app.UseJwtBearerAuthentication(
        new JwtBearerAuthenticationOptions
        {
            AuthenticationMode = AuthenticationMode.Active,
            AllowedAudiences = audienceId.ToArray(),
            IssuerSecurityTokenProviders = providers.ToArray(),
            Provider = new OAuthBearerAuthenticationProvider
            {
                OnValidateIdentity = context =>
                {
                    context.Ticket.Identity.AddClaim(new System.Security.Claims.Claim("newCustomClaim", "newValue"));
                    return Task.FromResult<object>(null);
                }
            }
        });
}

который позволяет проверять токены с предъявляемыми сертификатами на нескольких идентификаторах ClientID. Это хорошо работает. Однако мое веб-приложение позволяет пользователю создавать новую аудиторию приложения (т.е. Новую комбинацию ClientID, ClientSecret и ClientName), но после этого я не знаю, как заставить сервер ресурсов JwtBearerAuthenticationOptions распознавать вновь созданную аудиторию.

Я могу перезапустить сервер после новой аудитории, чтобы ConfigureOAuth() повторил, но это не очень хороший подход в конечном итоге.

Кто-нибудь знает, как добавить аудиторию (то есть новую команду ClientID, ClientSecret и ClientName) в OWIN-приложение JwtBearerAuthenticationOptions вне startup.cs и ConfigureOAuth()? **

Я искал: https://docs.auth0.com/aspnetwebapi-owin-tutorial и http://bitoftech.net/2014/10/27/json-web-token-asp-net-web-api-2-jwt-owin-authorization-server/ для справки, но оба примера кода показывают ту же проблему, что описаны выше.

Ответ 1

При использовании X509CertificateSecurityTokenProvider работает следующее. Он был изменен для использования SymmetricKeyIssuerSecurityTokenProvider, но еще не был протестирован.

public void ConfigureOAuth(IAppBuilder app)
{
    var issuer = "<the_same_issuer_as_AuthenticationServer.Api>";

    // Api controllers with an [Authorize] attribute will be validated with JWT
    Func<IEnumerable<Audience>> allowedAudiences = () => DatabaseAccessLayer.GetAllowedAudiences();

    var bearerOptions = new OAuthBearerAuthenticationOptions
    {
        AccessTokenFormat = new JwtFormat(new TokenValidationParameters
        {
            AudienceValidator = (audiences, securityToken, validationParameters) =>
            {
                return allowedAudiences().Select(x => x.ClientId).Intersect(audiences).Count() > 0;
            },
            ValidIssuers = new ValidIssuers { Audiences = allowedAudiences },
            IssuerSigningTokens = new SecurityTokensTokens(issuer) { Audiences = allowedAudiences }
        })
    };
    app.UseOAuthBearerAuthentication(bearerOptions);
}

public abstract class AbstractAudiences<T> : IEnumerable<T>
{
    public Func<IEnumerable<Audience>> Audiences { get; set; }

    public abstract IEnumerator<T> GetEnumerator();

    System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
    {
        throw new NotImplementedException();
    }
}

public class SecurityTokensTokens : AbstractAudiences<SecurityToken>
{
    private string issuer;

    public SecurityTokensTokens(string issuer)
    {
        this.issuer = issuer;
    }

    public override IEnumerator<SecurityToken> GetEnumerator()
    {
        foreach (var aud in Audiences())
        {
            foreach (var securityToken in new SymmetricKeyIssuerSecurityTokenProvider(issuer, TextEncodings.Base64Url.Decode(aud.ClientSecret)).SecurityTokens)
            {
                yield return securityToken;
            };
        }
    }
}

public class ValidIssuers : AbstractAudiences<string>
{
    public override IEnumerator<string> GetEnumerator()
    {
        foreach (var aud in Audiences())
        {
            yield return aud.ClientSecret;
        }
    }
}

}

Ответ 2

Я попытаюсь помочь, однако: D в виду, что я новичок, поэтому он может быть не лучшим: D

Я также хотел бы иметь динамическую аудиторию без перезагрузки моих сервисов, в конце концов, о гибкости и простоте использования.

Поэтому я подтверждаю следующее:

        var bearerOptions = new OAuthBearerAuthenticationOptions
        {
            AccessTokenFormat = new JwtFormat(new TokenValidationParameters
            {
                AudienceValidator = AudienceValidator,
                IssuerSigningToken = x509SecToken,
                ValidIssuer = issuer,
                RequireExpirationTime = true,
                ValidateLifetime = true,                    
            })
        };
        app.UseOAuthBearerAuthentication(bearerOptions);

Как вы можете видеть выше, у меня есть делегат, который проверяет мою аудиторию. Что в основном означает - каждый раз, когда вы запускаете запрос на свой сервер, этот метод вызывается для проверки аудитории.

В этот момент у меня есть только небольшие методы отладки, и я проверяю ЛЮБОЙ аудитории, входящей в нее:

    private bool AudienceValidator(IEnumerable<string> audiences, SecurityToken securityToken, TokenValidationParameters validationParameters)
    {
        Trace.Write("would be validating audience now");
        return true;
    }

Теперь следующий шаг - что здесь делать? Ну наверняка вы не хотите запрашивать DB каждый раз, когда аудитория будет проверена, так как это будет лишено цели использования этих токенов: D Вы можете придумать какую-нибудь приятную идею - пожалуйста, поделитесь!

Первый подход:

Так что я сделал, используя https://github.com/jgeurts/FluentScheduler, и у меня есть запланированное обновление AllowedAudiences из БД каждые 1 час. И это хорошо работает. Я регистрирую новую аудиторию с набором прав, и в лучшем случае они готовы пойти на лету или мне нужно подождать примерно 59 минут:)

Надеюсь, это поможет!

Второй подход:

Теперь с другой стороны я добавил заявку на токен JWT, который определяет авторизованные ресурсы. И затем я проверяю, есть ли в марке безопасности ресурс, соответствующий моему серверу ресурсов. Если это так, мы рассматриваем аудиторию для проверки: D

Ответ 3

Мы имеем ту же проблему и следуем одному и тому же пути. Кроме того, я также попытался создать пользовательский OAuthBearerAuthenticationProvider(), передающий объекты IAppBuilder и JwtBearerAuthenticationOptions, переопределив OnValidateIdentity() и перезагрузив JwtBearerAuthenticationOptions, но новая аудитория все еще не проверена.

Я предполагаю, что сейчас я буду использовать перезапуск приложения, чтобы преодолеть эту проблему.

Надеюсь, что это даст намек для других на правильный путь.

Ответ 4

Нам также нужен динамический обработчик аудитории JWT, особенно для клиентов Azure B2C. Информация об арендаторе хранится в базе данных, которая использовалась для настройки отдельных OAuthBearerAuthenticationProvider() для каждого OAuthBearerAuthenticationProvider() и политики B2C (дополнительный параметр, необходимый для использования арендаторов B2C).

Мы обнаружили, что попытка добавить дополнительные записи, пытаясь использовать IAppBuilder UseOAuthBearerAuthentication() после запуска, просто не работала - провайдеры неправильно управляли, и поэтому маркеры подписи не были получены, что привело к вызову HTTP 401. (Мы сохранили объект IAppBuiler чтобы его можно было использовать позже.)

Глядя на код JwtFormat.cs который проверяет токен, мы получили подсказку (мы находимся на версии 3.1.0 - YMMV) о том, как реализовать решение:

https://github.com/aspnet/AspNetKatana/blob/v3.1.0/src/Microsoft.Owin.Security.Jwt/JwtFormat.cs#L193

Здесь он извлекает эмитентов и подписывает ключи из предоставленного OAuthBearerAuthenticationProvider(). Обратите внимание, что это немного неэффективно для наших целей - он тянет ВСЕХ издателей и подписывающих ключей, даже если только одна аудитория будет соответствовать JWT, выпущенному клиентом Azure B2C.

Вместо этого мы сделали следующее:

  1. Используйте только один UseOAuthBearerAuthentication() без OAuthBearerAuthenticationProvider() - просто передавая TokenValidationParameters;
  2. Используйте подклассный класс JwtSecurityTokenHandler и переопределите ValidateToken для динамического управления аудиториями;
  3. Создайте экземпляр подкласса JwtSecurityTokenHandler и JwtSecurityTokenHandler его в JwtFormat.TokenHandler.

Как вы управляете и инициируете добавление новой аудитории, зависит от вас. Мы используем базу данных и Redis для доставки команды перезагрузки.

Вот фрагмент Startup.Auth.cs:

/// <summary>
/// The B2C token handler for handling dynamically loaded B2C tenants.
/// </summary>
protected B2CTokenHandler TokenHandler = new B2CTokenHandler();

/// <summary>
/// Setup the OAuth authentication. We use the database to retrieve the available B2C tenants.
/// </summary>
/// <param name="app">The application builder object</param>
public AuthOAuth2(IAppBuilder app) {
    // get Active Directory endpoint
    AadInstance = ConfigurationManager.AppSettings["b2c:AadInstance"];

    // get the B2C policy list used by API1
    PolicyIdList = ConfigurationManager.AppSettings["b2c:PolicyIdList"].Split(',').Select(p => p.Trim()).ToList();

    TokenValidationParameters tvps = new TokenValidationParameters {
        NameClaimType = "http://schemas.microsoft.com/identity/claims/objectidentifier"
    };

    // create a access token format 
    JwtFormat jwtFormat = new JwtFormat(tvps);

    // add our custom token handler which will provide token validation parameters per tenant
    jwtFormat.TokenHandler = TokenHandler;

    // wire OAuth authentication for tenants
    app.UseOAuthBearerAuthentication(new OAuthBearerAuthenticationOptions {
        // the security token provider handles Azure AD B2C metadata & signing keys from the OpenIDConnect metadata endpoint
        AccessTokenFormat = jwtFormat,
        Provider = new OAuthBearerAuthenticationProvider() {
            OnValidateIdentity = async (context) => await OAuthValidateIdentity(context)
        }
    });

    // load initial OAuth authentication tenants
    LoadAuthentication();
}

/// <summary>
/// Load the OAuth authentication tenants. We maintain a local hash map of those tenants during
/// processing so we can track those tenants no longer in use.
/// </summary>
protected override void LoadAuthentication() {
    AuthProcessing authProcessing = new AuthProcessing();

    List<B2CAuthTenant> authTenantList = new List<B2CAuthTenant>();

    // add all tenants for authentication
    foreach (AuthTenantApp authTenantApp in authProcessing.GetAuthTenantsByAppId("API1")) {
        // create a B2C authentication tenant per policy. Note that the policy may not exist, and
        // this will be handled by the B2C token handler at configuration load time below
        foreach (string policyId in PolicyIdList) {
            authTenantList.Add(new B2CAuthTenant {
                Audience = authTenantApp.ClientId,
                PolicyId = policyId,
                TenantName = authTenantApp.Tenant
            });
        }
    }

    // and load the token handler with the B2C authentication tenants
    TokenHandler.LoadConfiguration(AadInstance, authTenantList);

    // we must update the CORS origins
    string origins = string.Join(",", authProcessing.GetAuthTenantAuthoritiesByAppId("API1").Select(a => a.AuthorityUri));

    // note some browsers do not support wildcard for exposed headers - there specific needed. See
    //
    // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Headers#Browser_compatibility
    EnableCorsAttribute enableCors = new EnableCorsAttribute(origins, "*", "*", "Content-Disposition");
    enableCors.SupportsCredentials = true;
    enableCors.PreflightMaxAge = 30 * 60;

    GlobalConfiguration.Configuration.EnableCors(enableCors);
}

Вот фрагмент для переопределенного класса JwtSecurityTokenHandler:

/// <summary>
/// Dictionary of currently configured OAuth audience+policy to the B2C endpoint signing key cache.
/// </summary>
protected ConcurrentDictionary<string, OpenIdConnectCachingSecurityTokenProvider> AudiencePolicyMap = new ConcurrentDictionary<string, OpenIdConnectCachingSecurityTokenProvider>();

/// <summary>
/// Load the B2C authentication tenant list, creating a B2C endpoint security token provider
/// which will bethe source of the token signing keys.
/// </summary>
/// <param name="aadInstance">The Active Directory instance endpoint URI</param>
/// <param name="b2cAuthTenantList">The B2C authentication tenant list</param>
public void LoadConfiguration(string aadInstance, List<B2CAuthTenant> b2cAuthTenantList) {
    // maintain a list of keys that are loaded
    HashSet<string> b2cAuthTenantSet = new HashSet<string>();

    // attempt to create a security token provider for each authentication tenant
    foreach(B2CAuthTenant b2cAuthTenant in b2cAuthTenantList) {
        // form the dictionary key
        string tenantKey = $"{b2cAuthTenant.Audience}:{b2cAuthTenant.PolicyId}";

        if (!AudiencePolicyMap.ContainsKey(tenantKey)) {
            try {
                // attempt to create a B2C endpoint security token provider. We may fail if there is no policy 
                // defined for that tenant
                OpenIdConnectCachingSecurityTokenProvider tokenProvider = new OpenIdConnectCachingSecurityTokenProvider(String.Format(aadInstance, b2cAuthTenant.TenantName, b2cAuthTenant.PolicyId));

                // add to audience:policy map
                AudiencePolicyMap[tenantKey] = tokenProvider;

                // this guy is new
                b2cAuthTenantSet.Add(tenantKey);
            } catch (Exception ex) {
                // exception has already been reported appropriately
            }
        } else {
            // this guys is already present
            b2cAuthTenantSet.Add(tenantKey);
        }
    }

    // at this point we have a set of B2C authentication tenants that still exist. Remove any that are not
    foreach (KeyValuePair<string, OpenIdConnectCachingSecurityTokenProvider> kvpAudiencePolicy in AudiencePolicyMap.Where(t => !b2cAuthTenantSet.Contains(t.Key))) {
        AudiencePolicyMap.TryRemove(kvpAudiencePolicy.Key, out _);
    }
}

/// <summary>
/// Validate a security token. We are responsible for priming the token validation parameters
/// with the specific parameters for the audience:policy, if found.
/// </summary>
/// <param name="securityToken">A 'JSON Web Token' (JWT) that has been encoded as a JSON object. May be signed using 'JSON Web Signature' (JWS)</param>
/// <param name="tvps">Contains validation parameters for the security token</param>
/// <param name="validatedToken">The security token that was validated</param>
/// <returns>A claims principal from the jwt. Does not include the header claims</returns>
public override ClaimsPrincipal ValidateToken(string securityToken, TokenValidationParameters tvps, out SecurityToken validatedToken) {
    if (string.IsNullOrWhiteSpace(securityToken)) {
        throw new ArgumentNullException("Security token is null");
    }

    // decode the token as we need the 'aud' and 'tfp' claims
    JwtSecurityToken token = ReadToken(securityToken) as JwtSecurityToken;

    if (token == null) {
        throw new ArgumentOutOfRangeException("Security token is invalid");
    }

    // get the audience and policy
    Claim audience = token.Claims.FirstOrDefault(c => c.Type == JwtRegisteredClaimNames.Aud);
    Claim policy = token.Claims.FirstOrDefault(c => c.Type == ClaimTypesB2C.Tfp);

    if ((audience == null) || (policy == null)) {
        throw new SecurityTokenInvalidAudienceException("Security token has no audience/policy id");
    }

    // generate the key
    string tenantKey = $"{audience.Value}:{policy.Value}";

    // check if this audience:policy is known
    if (!AudiencePolicyMap.ContainsKey(tenantKey)) {
        throw new SecurityTokenInvalidAudienceException("Security token has unknown audience/policy id");
    }

    // get the security token provider
    OpenIdConnectCachingSecurityTokenProvider tokenProvider = AudiencePolicyMap[tenantKey];

    // clone the token validation parameters so we can update
    tvps = tvps.Clone();

    // we now need to prime the validation parameters for this audience
    tvps.ValidIssuer = tokenProvider.Issuer;
    tvps.ValidAudience = audience.Value;
    tvps.AuthenticationType = policy.Value;
    tvps.IssuerSigningTokens = tokenProvider.SecurityTokens;

    // and call real validator with updated parameters
    return base.ValidateToken(securityToken, tvps, out validatedToken);
}

Для наших арендаторов B2C случается, что не все доступные политики определены для арендатора. Нам нужно обработать это в OpenIdConnectCachingSecurityTokenProvider:

/// <summary>
/// Retrieve the metadata from the endpoint.
/// </summary>
private void RetrieveMetadata() {
    metadataLock.EnterWriteLock();

    try {
        // retrieve the metadata
        OpenIdConnectConfiguration config = Task.Run(configManager.GetConfigurationAsync).Result;

        // and update
        issuer = config.Issuer;
        securityTokens = config.SigningTokens;
    } catch (Exception ex) when (CheckHttp404(ex)) {
        // ignore 404 errors as they indicate that the policy does not exist for a tenant
        logger.Warn($"Policy endpoint not found for {metadataEndpoint} - ignored");
        throw ex;
    } catch (Exception ex) {
        logger.Fatal(ex, $"System error in retrieving token metadatafor {metadataEndpoint}");
        throw ex;
    } finally {
        metadataLock.ExitWriteLock();
    }
}

/// <summary>
/// Check if the inner most exception is a HTTP response with status code of Not Found.
/// </summary>
/// <param name="ex">The exception being examined for a 404 status code</param>
/// <returns></returns>
private bool CheckHttp404(Exception ex) {
    // get the inner most exception
    while(ex.InnerException != null) {
        ex = ex.InnerException;
    }

    // check if a HttpWebResponse with a 404
    return (ex is WebException webex) && (webex.Response is HttpWebResponse response) && (response.StatusCode == HttpStatusCode.NotFound);
}