Tüm yazılar
api-testing

TestFly Bölüm 15: Kurumsal API Kimlik Doğrulama — RFC 7616 Digest, HMAC İmzalama ve OAuth2 Refresh Token

TestFly 1.1.0, bozuk Digest auth'u gerçek bir RFC 7616 challenge/response implementasyonuyla değiştiriyor, imzalı istekler için ApiAuth.hmacSigner()'ı ve otomatik refresh-token grant'leri için ApiAuth.oauth2Refresh(...)'i ekliyor.

12 Ekim 20268 dk okuma
TestFly Bölüm 15: Kurumsal API Kimlik Doğrulama — RFC 7616 Digest, HMAC İmzalama ve OAuth2 Refresh Token

Özet: TestFly 1.1.0, eskiden işe yaramayan "Digest" auth'u gerçek bir RFC 7616 challenge/response implementasyonuyla değiştiriyor (MD5, SHA-256, SHA-512-256, -sess, qop=auth/auth-int); her isteğin method, path+query, timestamp ve body hash'ini imzalayan ApiAuth.hmacSigner()'ı ekliyor; ve otomatik refresh-token grant'leri için ApiAuth.oauth2Refresh(...) getiriyor. Yeni ApiAuth.Intercepting arayüzü, bu stratejilerin gerçek isteği ve yanıtı görecek şekilde en içteki interceptor olarak çalışmasını sağlıyor, @UseAuth ise artık digest, hmac-signed ve oauth2-refresh'i doğrudan testfly.yml'den kabul ediyor.


TestFly'ın Digest Auth'u Neden Yeniden Yazılmaya İhtiyaç Duydu?

1.1.0 öncesinde, ApiAuth.digest(username, password) makul görünen ama tamamen yanlış bir şey yapıyordu: her istekte Authorization: Digest base64(user:password) gönderiyordu. RFC 7616 uyumlu hiçbir sunucu bunu kabul etmez — Digest auth, farklı bir şema adına sahip Basic auth değildir; sunucunun bir 401 üzerinde yayınladığı bir nonce'tan inşa edilen kriptografik bir challenge/response'tur. Eski kodla gerçek bir Digest korumalı API'yi test eden herhangi bir test seti, API katmanında değil auth katmanında başarısız olurdu.

Düzeltme, gerçek protokolün inşa edilmesini gerektirdi: WWW-Authenticate başlığını ayrıştırmak, RFC 7616'ya göre HA1/HA2 hesaplamak, nonce sayaçlarını takip etmek ve bir nonce'un ne zaman eskidiğini (stale) bilmek.

TestFly, RFC 7616 Digest Kimlik Doğrulamasını Nasıl Uyguluyor?

ApiAuth.digest(username, password), en içteki interceptor olarak kurulan bir ApiAuth.Intercepting implementasyonu (DigestAuth) döndürür — giden isteği ve gelen yanıtı gerçekten gören, bu yüzden bir challenge'dan sonra isteği yeniden gönderebilen interceptor:

java
ApiClient.setGlobalAuth(ApiAuth.digest("qa-user", System.getenv("DIGEST_PASSWORD")));

apiClient().get("/api/secure/orders")
        .send()
        .assertStatus(200);

Tel üzerindeki akış:

  1. Bir origin'e ilk istek, kimlik bilgisi olmadan gönderilir.
  2. Bir 401 WWW-Authenticate: Digest ... yanıtı bir Challenge'a (realm, nonce, opaque, algorithm, qop kümesi, stale bayrağı) ayrıştırılır. Bir sunucu birden fazla şema duyurduğunda, TestFly desteklenen en güçlü algoritmayı seçer (SHA-512-256 > SHA-256 > MD5).
  3. İstek, hesaplanmış bir Authorization: Digest ... başlığıyla bir kez yeniden gönderilir.
  4. Challenge, geçerli test için origin başına önbelleğe alınır (ApiClient.challengeCache()), sendAsync çağrıları arasında paylaşılır, böylece sonraki istekler her seferinde bir 401'i gidip gelmek yerine artan bir nonce sayısıyla (nc) önceden (preemptive) kimlik doğrular.
  5. Eskimiş bir nonce (sunucu stale=true döndürür, ya da sonraki bir 401'de yeni bir nonce gelir) tam olarak bir yeniden kimlik doğrulamayı tetikler. Aynı nonce eskimemiş olarak geri gelirse, TestFly bunu reddedilmiş kimlik bilgileri olarak değerlendirir ve sonsuz döngü yerine 401'i çağırana döndürür.

Desteklenen algoritmalar MD5, MD5-sess, SHA-256, SHA-256-sess, SHA-512-256 ve SHA-512-256-sess'tir; qop=auth ve istek body'sinin hash'ini HA2'ye katan qop=auth-int ile birlikte. Response hash'inin çekirdeği, doğrudan implementasyondan:

java
String ha1 = hash(digestName, username + ":" + challenge.realm + ":" + password);
if (session) ha1 = hash(digestName, ha1 + ":" + challenge.nonce + ":" + cnonce);

String a2 = request.method() + ":" + digestUri;
if ("auth-int".equals(qop)) a2 += ":" + hash(digestName, request.body());
String ha2 = hash(digestName, a2);

String response = qop == null
        ? hash(digestName, ha1 + ":" + challenge.nonce + ":" + ha2)
        : hash(digestName, ha1 + ":" + challenge.nonce + ":" + nc + ":" + cnonce + ":" + qop + ":" + ha2);

-sess varyantları, RFC 7616'nın belirttiği gibi, client nonce'u her istekte yeniden hesaplamak yerine challenge başına bir kez HA1'e katar.

İsteklerinizi HMAC ile Nasıl İmzalarsınız?

Pek çok iç ve partner API, Basic/Bearer istemez — yakalanan bir başlığın farklı bir payload'a karşı tekrar oynatılamaması için her isteğin imzalanmasını ister. TestFly 1.1.0, ApiHmacSigner için bir builder olan ApiAuth.hmacSigner()'ı ekliyor; bu da başka bir ApiAuth.Intercepting stratejisi:

java
ApiAuth signer = ApiAuth.hmacSigner()
        .apiKey(System.getenv("API_KEY"))
        .secret(System.getenv("API_SECRET"))
        .signatureHeader("X-Signature")
        .encoding(ApiHmacSigner.Encoding.HEX)
        .build();

ApiClient.setGlobalAuth(signer);

Varsayılan olarak, method, path+query, bir timestamp ve body'nin SHA-256 hash'inden oluşan — satır sonlarıyla birleştirilmiş — kanonik bir string imzalar:

java
public static String defaultCanonical(SigningContext ctx) {
    return ctx.method() + "\n" + ctx.pathAndQuery() + "\n" + ctx.timestamp() + "\n" + ctx.bodySha256();
}

İmzayla ilgili her şey builder üzerinden yapılandırılabilir: algorithm(...) (herhangi bir JCA Mac algoritması, varsayılan HmacSHA256), keyHeader(...) / signatureHeader(...) / timestampHeader(...) (herhangi birini atlamak için null ayarlayın), encoding(...) (BASE64 ya da HEX), timestampFormat(...) (varsayılan epoch saniye), ve farklı bir satır sırası ya da ek bir nonce bekleyen bir sunucu için tüm imzalama string'ini bir Function<SigningContext, String> ile değiştirmek üzere canonical(...). İmzalayıcı en içteki interceptor olarak çalıştığından, tel üzerine çıkmak üzere olan tam istek baytlarını imzalar — zincirde daha önce gelen diğer ApiAuth stratejilerinin eklediği başlıklar dahil — ama bunun dışında çalışan eski RequestInterceptor hook'larının sonradan eklediği başlıklar hariç.

Eski ApiAuth.hmac(apiKey, secret, algorithm) artık deprecated: yalnızca API key string'ini imzalıyordu, isteğin kendisini asla imzalamıyordu, bu yüzden istek imzalarını doğrulayan bir sunucuyla eşleşemez. Tel uyumluluğu için korunuyor, ama yeni kod hmacSigner()'ı kullanmalı.

OAuth2 Token Yenileme Nasıl Çalışır?

ApiAuth.oauth2(...) zaten client-credentials token'larını önbellekleme ile işliyordu; 1.1.0, uzun ömürlü bir refresh token'ın kısa ömürlü access token'larla değiştirildiği akışlar için ApiAuth.oauth2Refresh(tokenUrl, clientId, clientSecret, refreshToken)'ı ekliyor:

java
ApiClient.setGlobalAuth(ApiAuth.oauth2Refresh(
        "https://auth.example.com/token",
        System.getenv("CLIENT_ID"),
        System.getenv("CLIENT_SECRET"),
        System.getenv("REFRESH_TOKEN")));

Proje genelindeki OAuth2TokenCache, üç OAuth2 fabrika metodu (oauth2, oauth2Password, oauth2Refresh) tarafından paylaşılır ve aynı tokenUrl/clientId için bir client-credentials token'ının, bir password token'ının ve bir refresh token'ının asla çakışmaması için anahtarlanır. Süresi dolduğunda önce önbellekte sahip olduğu refresh token ile bir refresh_token grant'i dener; bu grant tamamen başarısız olursa, testi başarısız kılmak yerine orijinal grant'i yeniden çalıştırmaya geri döner:

java
if (cached != null && cached.refreshToken != null) {
    try {
        fresh = refresh(tokenUrl, clientId, clientSecret, cached.refreshToken);
    } catch (RuntimeException refreshFailed) {
        fresh = null; // orijinal grant'e geri dön
    }
}
if (fresh == null) fresh = grant.get();

Her kullanımda refresh token'ı döndüren (rotate eden) sunucular da ele alınır: token yanıtı yeni bir refresh_token içeriyorsa, sonraki yenileme için önbelleğe alınan odur; bunu atlayan (rotasyon yapmayan) sunucular TestFly'ın zaten sahip olduğunu korur. ApiAuth.clearTokenCache(), talep üzerine önbelleğe alınan her token'ı temizler, ve framework bunu suite sonunda otomatik olarak çağırır, böylece token'lar aynı JVM'deki suite'ler arasında sızmaz.

@UseAuth ile Bunu Deklaratif Olarak Nasıl Bağlarsınız?

Üç strateji de artık hem TestNG listener hem de JUnit 5 extension tarafından kullanılan paylaşımlı AuthStrategies sınıfı tarafından testfly.yml'den çözülen birinci sınıf @UseAuth tipleridir:

yaml
# testfly.yml
api:
  auth:
    legacyPartner:
      type: digest
      username: ${DIGEST_USER}
      password: ${DIGEST_PASSWORD}
    signedService:
      type: hmac-signed
      apiKey: ${API_KEY}
      secret: ${API_SECRET}
      algorithm: HmacSHA256
      keyHeader: X-Api-Key
      signatureHeader: X-Signature
      timestampHeader: X-Timestamp
      encoding: base64
    refreshableAccount:
      type: oauth2-refresh
      tokenUrl: https://auth.example.com/token
      clientId: ${CLIENT_ID}
      clientSecret: ${CLIENT_SECRET}
      refreshToken: ${REFRESH_TOKEN}
java
@Test
@UseAuth("signedService")
public void createOrderIsSigned() {
    apiClient().post("/api/orders").body(order).send().assertStatus(201);
}

keyHeader: none ya da timestampHeader: none ayarlamak (ya da bir başlığı boş bırakmak), o başlığı tamamen atlar — bir sunucu yalnızca imza başlığını istiyorsa ve key'i upstream bir gateway'den türetiyorsa kullanışlıdır. TestNG listener ve JUnit 5 extension, @UseAuth'u tamamen aynı kod yolundan çözer, bu yüzden iki runner arasında davranış farkı yoktur.

1.1.0'da API Auth Etrafında Başka Neler Değişti?

ApiAuth.Intercepting arayüzünün kendisi yeni bir altyapı: istek oluşturulmadan önce ekleyecek bir başlıktan daha fazlasına — nihai istek baytlarına ya da gerçek yanıta — ihtiyaç duyan herhangi bir strateji bunu uygular ve ApiClient onu, transport'a en yakın, en içteki ApiInterceptor olarak kurar. DigestAuth'un bir 401 okuduktan sonra isteği yeniden göndermesini, ve ApiHmacSigner'ın isteği süreçten gerçekten çıkacağı haliyle imzalamasını sağlayan budur. İkisi de package-private ya da final implementasyonlar olarak gelir; onlara yalnızca ApiAuth.digest(...) ve ApiAuth.hmacSigner() üzerinden ulaşırsınız.

Aynı sürümde iki ilgili düzeltme de geldi: ApiAuth.basicAuth artık kimlik bilgilerini her zaman UTF-8 olarak kodluyor (önceden platform varsayılanıydı), ve OAuth2 token önbelleği JVM'in ömrü boyunca kalıcı olmak yerine suite sonunda temizleniyor — böylece aynı çalıştırmadaki ikinci bir suite, başka bir suite'in token'ını sessizce yeniden kullanamaz.

SSS

ApiAuth.digest(...) istek başına iki HTTP çağrısı mı yapıyor?

Yalnızca bir origin'e ilk istek bu bedeli öder. İlk 401/challenge gidiş-dönüşünden sonra, challenge geçerli test için origin başına önbelleğe alınır, ve sonraki istekler artan bir nonce sayısıyla geçerli bir Authorization: Digest başlığını önceden gönderir — nonce eskimedikçe ya da rotasyona uğramadıkça ikinci bir istek yoktur.

Digest kimlik bilgileri gerçekten yanlışsa ne olur?

TestFly her yeni challenge başına bir kez yeniden gönderir. Sunucu aynı eskimemiş nonce'u tekrar döndürürse, bu kurtarılabilir bir challenge değil kimlik bilgilerinin reddi olarak değerlendirilir — sonsuza dek yeniden denemek yerine 401 çağırana döndürülür.

Varsayılan yerine özel bir HMAC kanonik string'i kullanabilir miyim?

Evet — ApiAuth.hmacSigner().canonical(ctx -> ...), method, URI, timestamp, ham body bayt'ları ve başlıklara erişimi olan bir Function<SigningContext, String> alır, böylece yalnızca TestFly'ın varsayılanı METHOD\npath?query\ntimestamp\nbodyHash değil, herhangi bir sunucu taraflı imzalama şemasıyla eşleşebilirsiniz.

oauth2Refresh, sunucu hiçbir zaman refresh token rotasyonu yapmazsa bozulur mu?

Hayır. Bir refresh yanıtı refresh_token'ı atlıyorsa, TestFly sizin sağladığınız refresh token'ı yeniden kullanmaya devam eder; yalnızca sunucu gerçekten yeni bir tane döndürdüğünde yenisine geçer.


TestFly, Java 21 Selenium/API test otomasyon SDK'sıdır. Bu serinin önceki yazıları: Bölüm 12 — Playwright tarzı assertion'lar, Bölüm 13 — actionability, Bölüm 14 — step trace'ler.

#TestFly#API Testing#Security#Java#SDET
Paylaş X LinkedIn

Yorumlar

  • Henüz yorum yok. İlk yorumu sen yaz.