Itt jársz most: Kezdőlap > Alkalmazásfejlesztés > Amikor az erős jelszó hátránnyá válik

Szűrő megjelenítése

Amikor az erős jelszó hátránnyá válik

Nemrégiben átalakításokat végeztem a teljes Leaflet stacken, illetve egyúttal a Domino is kapott új képességeket, többek között az integrált OAuth2 kliens regisztrációt. Ennek kapcsán (mivel a Leaflet stack alkalmazásait úgyis újra kellett regisztrálnom), minden kliens alkalmazás jelszavát (OAuth2 terminológiával a Client Secret-et), újrageneráltattam. Majd jött a meglepetés: teljesen kizártam magam a saját admin alkalmazásomból, annak minden kliens-bejelentkezési kísérlete HTTP 401 Unauthorized eredménnyel zárult. Emlékeztetőül nézzük meg pontosan mi is történik ilyenkor:

  1. Az admin alkalmazás, mivel egy felhasználói interakciót igénylő alkalmazásról beszélünk, az OAuth2 terminológiájában az úgynevezett Authorization Code Grant Flow-t használja a bejelentkezéshez. Ennek megfelelően az admin alkalmazás kezdeményezi a felhasználó beléptetését oly módon, hogy a felhasználót az OAuth2 Authorization Server egy bizonyos felületére irányítja.
  2. Ezen a felületen engedélyezheti a felhasználó a forrás alkalmazás számára a felhasználó bizonyos jogosultságainak, illetve vele adatainak felhasználását, ez tipikusan például a profil alapadatok olvasása (úgy, mint a felhasználó neve, profilképe és email címe). Így működik például a Facebook-kal, Google-lel, GitHub-bal stb. történő bejelentkezés is számtalan weboldalon. Természetesen a felhasználónak ezen az Authorization Serveren rendelkeznie kell fiókkal és be is kell legyen jelentkezve.
  3. Ha a felhasználó azonosítása sikeres és a felhasználó engedélyezi az "összekapcsolást", a böngésző egy speciális endpointra lesz visszairányítva a "forrás" oldalon, ahol a forrás alkalmazás (esetemben a blog admin alkalmazása) kap a visszaírányításban egy speciális kódot, amit azonnal be kell váltania egy hozzáférési (access) tokenre.
  4. Ekkor a forrás alkalmazás egy újabb hívást indít az Authorization Server felé, a kóddal és a saját kliens azonosítójával, ez a Client ID és a Client Secret pár. Tehát ez nem a felhasználót azonosítja, hanem magát a kliens alkalmazást. Ha az azonosítás ekkor sikertelen, a teljes beléptetési folyamat megakad. És pontosan ez is történt.

(A fenti folyamat erősen zanzásítva van, bővebb részletekért ajánlom figyelmedbe az OAuth-tal foglalkozó korábbi cikkeimet.)

Na de miért?

Nem fogok hazudni, Kedves Olvasó, ezek az olyan jellegű problémák, ahol megkérdőjelezem a saját mentális egészségem is, legalábbis az első 1-2 órában. Eleve nehezen kereshető a probléma és sajnos a legkézenfekvőbb oka egy egyszerű elírás. De amikor látványosan minden stimmel, mégis stabilan reprodukálható a probléma, akkor nehéz logikus magyarázatot találni. Az egész ott kezdett igazán agyrobbanhatónak hatni, mikor ugyanazzal a kliens azonosítóval, "kézzel" (gyakorlatilag manuálisan összerakva egy token exchange kérést) el tudtam érni, hogy az Authorization Server kigenerálja az access tokent, tehát akkor elfogadta ugyanazt a Client ID és Client Secret párt. Egészen hihetetlen volt, de le kellett túrnom végül a kód legmélyére, mind az admin alkalmazás, mind az Authorization Server oldalán - a felfedezés pedig valahol szürreálisan vicces, de közben valahol mégis felháborító volt.

És akkor a Root Cause Analysis eredménye...

A hiba valóban egy "elírás" volt a Client Secretben, de nem az átlagos formájában. Mint azt a szakmában jól ismerjük, a különböző technológiák, keretrendszerek, nyelvek, tervezési minták, protokollok működését az úgynevezett RFC-k szabályozzák, így természetesen az OAuth2 protokoll is rendelkezik a maga RFC gyűjteményével. Ebben természetesen a fentebb leírt kliens azonosítási folyamat is definiálva van. Ennek az esetünkben fontos kitétele az Authorization: Basic ... header használata, melyben a kliens alkalmazásnak küldenie kell a saját credential párját, a már fentebb említett Client ID és Client Secret értékeket. "Standard" esetben ugyanez a header használható közvetlenül a felhasználó azonosítására is, bár erre a gyakorlatban már nem nagyon szokás használni, biztonsági gyengeségei miatt: egyrészt ilyenkor minden kérésben szerepelnie kell, másrészt pedig gyakorlatilag plain textben olvasható benne a jelszó. Ennek oka az, hogy a Basic authentikációnál csupán fogjuk a felhasználónevet (Client ID) és a jelszót (Client Secret), összefűzzük egy kettősponttal és az egészet alávetjük egy Base64 enkódolásnak (aztán ennek eredménye kerül a headerbe, ami végül nagyjából így fog kinézni: Authorization: Basic bmV2ZXJnb25uYTpnaXZleW91dXA=).

Azonban ha csak ennyit teszünk, azzal még nem az idevonatkozó RFC-nek megfelelő a header tartalma. Ugyanis az összefűzés előtt, külön a felhasználónevet és a jelszót is URL enkódolásnak kell alávetni. Ennek során a speciális karaktereket az azoknak megfelelő URL enkódolt értékre cseréljük (ezek a %xx szimbólumok, amiket speciális karaktereket tartalmazó URL-ekben láthatunk). És itt jön képbe az, hogy az ipar sajnos nem egységesen követi ezeket az előírásokat (amik sokkal inkább csak ajánlások egyébként is). Mint kiderült, a Next.js OAuth modulja (pontosabban az általa használt OpenID Client implementáció) követi az RFC előírást, tehát a kérés kiküldése előtt elvégzi a Client ID és Client Secret értékeken az URL enkódolást is, a Spring Security OAuth modulja azonban nem URL dekódolja azokat. Ha a Client Secret például ABcd+1234, annak az URL enkódolt megfelelője ABcd%2B1234. Dekódolás nélkül a Spring Security az azonosítást az utóbbi értékkel próbálta meg, holott az adatbázisban nyilvánvalóan az előbbi érték szerepelt. Alább látható a vonatkozó kódrészlet a Node.js projektekben használható OpenID Client implementációból:

case 'client_secret_basic': {
  // This is correct behaviour, see https://tools.ietf.org/html/rfc6749#section-2.3.1 and the
  // related appendix. (also https://github.com/panva/node-openid-client/pull/91)
  // > The client identifier is encoded using the
  // > "application/x-www-form-urlencoded" encoding algorithm per
  // > Appendix B, and the encoded value is used as the username; the client
  // > password is encoded using the same algorithm and used as the
  // > password.
  if (typeof this.client_secret !== 'string') {
    throw new TypeError(
      'client_secret_basic client authentication method requires a client_secret',
    );
  }
  const encoded = `${formUrlEncode(this.client_id)}:${formUrlEncode(this.client_secret)}`;
  const value = Buffer.from(encoded).toString('base64');
  return { headers: { Authorization: `Basic ${value}` } };
}

Külön mókás, hogy ki van emelve kommentben, hogy ez az implementáció bizony követi a 6749-es sorszámú RFC-t, ami bizony elő is írja az URL enkódolást külön-külön a Client ID és Client Secret értékeknek. A Spring Security moduljában levő parser viszont már láthatóan nem foglalkozik az URL dekódolással (még a legutóbbi, 7-es főverziója sem!):

byte[] base64Token = header.substring(6).getBytes(StandardCharsets.UTF_8);
byte[] decoded = decode(base64Token);
String token = new String(decoded, getCredentialsCharset(request));
int delim = token.indexOf(":");
if (delim == -1) {
    throw new BadCredentialsException("Invalid basic authentication token");
}
UsernamePasswordAuthenticationToken result = UsernamePasswordAuthenticationToken
    .unauthenticated(token.substring(0, delim), token.substring(delim + 1));

A helyzetet különösen prózaivá viszont az teszi, hogy Postmanből kényelmesen tudtam authentikálni a "hibás" jelszóval, ami azt jelenti, hogy a Postman sem foglalkozik az RFC követésével (mert az sem URL enkódolta a Client Secretet).

Mit tehetünk a hiba ellen?

Szerencsére viszonylag lokalizált és megkerülhető problémáról van szó. Akkor lenne nagy a baj, ha a felhasználók is Authorization: Basic ... headerrel lennének beléptetve, de mint azt említettem korábban, ez már nem igazán jellemző. A probléma teljesen megkerülhető, ha az OAuth klienst úgy állítjuk be, hogy a kliens bejelentkeztetését formmal végezze (a többi paramétert egyébként is így kapja meg a szerver), vagy - a kevésbé elegáns megoldás, ha egyáltalán van rá lehetőségünk -, a Client Secretekből az olyan speciális karakterek eltávolítása, melyeket az URL enkódolás egyébként érintene. Vagy persze, ha nem használunk Next.js-t és/vagy Spring Securityt, az is egy megoldás. :)

Kommentek

Komment írásához jelentkezz be
Bejelentkezés

Még senki nem szólt hozzá ehhez a bejegyzéshez.