Itt jársz most: Kezdőlap > Alkalmazásfejlesztés > JWT authentikáció Spring Security alatt

Szűrő megjelenítése

JWT authentikáció Spring Security alatt

Az authentikációs folyamat

Az authentikációs folyamat több, szekvenciálisan egymás után futó lépésből áll, melyeket egy authentikációs manager koordinál. A Spring Security filterei a HTTP kérés feldolgozása során betöltődnek és a kérésre adott válasz generálása előtt ellenőrzik, hogy a kérés már authentikálva lett-e. Ha már authentikált a kérés, léteznie kell nyomának a biztonsági kontextusban, mely az alkalmazásban bárhol elérhető. Ellenkező esetben a biztonsági filterek dolga lesz a kérés authentikálása az éppen aktuális HTTP fejléc alapján. A folyamat a legtöbb authentikációs módszernél azonos, de mint látni fogjuk, némiképp el lehet és esetleg el is kell térni a megszokott módszerektől.

Szóval, mint azt már említettem, a fejléc tartalma elérhető lesz a láncba kötött összes biztonsági filter részére. A Spring Security egyenként, egymás után fogja minden egyes tagnak átadni a fejléc tartalmát, és azok a maguk módján fogják elvégezni a szükséges lépéseket. Ha valamely filter nem tudja a kapott fejléc alapján a kérést authentikálni, egyszerűen továbbadja a fejlecet a következő filternek. Ha bármely filter sikeresen authentikálja a kérést, azt a biztonsági kontextusban rögzíteni fogja – innentől authentikált a kérés, és a filter viselkedésétől függően lefutnak a hátralevő filterek (ez az általános) vagy megáll a folyamat. A filter kinyeri a fejlécből a számára hasznos információkat, majd azokból egy authentikációs tokent készít – ekkor a kérés természetesen még nem authentikált, a tokent még nyilván ellenőrizni kell. Fontos: ez a token nem összekeverendő a klasszikus token fogalommal - kicsit szerencsétlen az elnevezés, mivel a tokenről általában a fejlécben (vagy máshol) átadott speciális, fontos információkat tartalmazó karaktersorozatokat nevezzük. Ez esetben azonban a kezdeti authentikációs objektumot nevezzük tokennek.

Ekkor lépnek a képbe az Authentication Provider-ek (szabadfordításban authentikációs ellátók – maradjunk az angol megnevezésnél ez esetben). A tokent a manager fogja átadni a providereknek, melyek elvégzik annak validálását. Ahogy több filter, úgy több provider is létezhet (és létezik is), és minden provider csak meghatározott authentikációs tokeneken képes operálni. Hogy tisztább legyen a kép: egy adott filter képes a kapott fejléc adatok alapján elkészíteni egy bizonyos tokent, és ezt a tokent képes kezelni egy (vagy több) provider. A provider dolga tehát „mindössze” annyi lesz, hogy eldöntse, az adott token tartalma egy érvényes felhasználóhoz tartozik-e. Itt tetszőleges alkalmazáslogika foglalhat helyet, nyilván a legáltalánosabb megoldás a token tartalmának (felhasználónév és jelszó) összevetése a felhasználói adatbázissal (amire egy UserDetailsService interfész implementációt szokás használni, erről picit később bővebben).

Amennyiben a provider zöld utat ad a kapott tokennek, lehetősége van egy új authentikációs objektum generálására és annak visszaadására, vagy a kapott token authentikáltként megjelölésére. Természetesen egyrészt egy jelszót jobb nem nyersen hagyni a tokenben, az Authentication interfész pedig alapesetben nem engedi az implementációk számára a tartalom módosítását (kivétel az authentikáltság állapota). Másrészt a felhasználói szerepkör sem szerepel még ebben a tokenben, így érdemes egy új Authentication objektumot visszaadni, benne a megfelelő adatokkal (felhasználónév, hash-elt jelszó, felhasználói szerepkörök, stb.). Fontos, hogy a visszaadott token „authenticated” mezőjének értéke igaz legyen, ezzel jelezzük ugyanis a managernek, hogy ez a token egy sikeres authentikálás végterméke.

A filter sikeres authentikáció esetén utolsó lépésként bejegyzi az authentikációs objektumot a biztonsági kontextusban, ezzel befejezve az authentikációs folyamatot.

A felhasználó nyomában

A sikeres authentikációs folyamathoz természetesen jogosult felhasználókra is szükségünk lesz. Alapesetben a felhasználókat egy adatbázisban fogjuk tárolni, a HTTP kérés fejlecében levő adatok alapján pedig meg kell keresnünk a megfelelőt. Ahogy azt már említettem, a feladat a provider-t fogja megilletni, azonban önmagában ő sem képes a jogosult felhasználók kitalálására. A Spring Security által biztosított standard megoldás a UserDetailsService interfész implementálása, mely mindössze egy metódus implementálását igényli. Az említett metódus a loadUserByUsername nevet viseli, és célja a megadott felhasználónév alapján egy érvényes felhasználó visszaadása. Természetesen a visszaadott felhasználó objektumnak is teljesítenie kell bizonyos kritériumokat, ezért az alkalmazásunk saját felhasználó entitását konvertálni kell a Spring Security User entitásává. Ha az átadott felhasználónévhez található felhasználó, a provider dolga lesz a jelszó ellenőrzése. Azonban a provider ez esetben további paramétereket is ellenőrizni fog, többek között azt, hogy a felhasználó jelszava nincs-e lejárva, illetve hogy a felhasználó fiókja engedélyezett-e. Ha mindent rendben talál a provider, a korábban már részletezett módon adja majd vissza a felhasználóhoz tartozó érvényes authentikációs objektumot. Azonban ha az adott felhasználónévvel nem létezik felhasználó, az interfész specifikációja UsernameNotFound kivételt ír elő.

JWT alapú authentikáció

A JWT és úgy általánosságban a token alapú authentikálás olyan esetekben hasznos, mikor a felhasználó jelszavát túl sokszor kellene átküldeni a hálózaton, illetve ha egyszerűsíteni szeretnénk az authentikálás folyamatát a felhasználók számára. Tipikus felhasználási területe a REST API fejlesztés, mivel a REST standard elvei szerint állapotmentes, a felhasználó bejelentkezése nem hoz létre munkafolyamatot, így a jelszót minden kérés alkalmával továbbítani kéne a szerver felé. Ez természetesen se nem biztonságos, se nem hatékony.

Egy kis JWT gyorstalpaló; bővebb, részletesebb leírás megtalálható a jwt.io oldalon. A JWT tokenek három fő részre oszthatóak: a fejléc, a payload, és az aláírás. A fejlécben a hashelő algoritmus típusát szükséges tárolni - bár erre csak akkor van szükség, ha a tokent alá akarjuk írni, de ez általában így van, többnyire fontos a biztonság. A payload tartalmazza az összes hasznos információt a felhasználóról. Felhasználónév, felhasználói szerepkör, token létrehozás és lejárati dátum, stb., tetszőleges és szabvány mezők egyaránt szerepelnek itt. Az aláírás a harmadik rész, melyet a fejlécben megadott hashelő algoritmus a payload és a privát kulcs felhasználásával generál. A payload módosítása így érvényteleníti az aláírást, illetőleg az eredeti privát kulcs hiányában a token úgyszintén érvénytelen lesz. A token három része külön-külön base64 kódolásra, majd a három rész pontokkal elválasztva összefűzésre kerül.

Célunk tehát a következő lesz: biztosítsunk a felhasználó számára egyszeri bejelentkezést, mely után a további kérések során a felhasználó azonosítását az authentikáló rendszer által generált, érvényes token végzi. Ehhez a standard authentikációs folyamat lépéseit (bejelentkezés – munkafolyamat létrehozás – munkafolyamat azonosítás kérésenként) az alábbiakra cseréljük:

  1. A standard folyamathoz hasonlóan a felhasználó megadja felhasználónevét és jelszavát. A kérésre válaszul a szerver a felhasználó adataiból egy speciális tokent generál, melyet aláír egy privát kulccsal (ez utóbbi nagyon fontos lépés) – természetesen ez csak akkor történik meg, ha a felhasználó jogosult a rendszer használatára (már regisztrált).

  2. A tokent megadja a kliens és a memóriában tárolja. Nem véletlenül emeltem ki, hogy a tárolás a memóriában történik: a tokent lehetőleg ne tároljuk perzisztensen, egyrészt azért sem, mert egy jól konfigurált token általában néhány órán belül érvénytelenné válik. Másrészt, ha ez mégsem történne meg, óriási biztonsági kockázatnak tennénk ki a felhasználókat és a rendszert magát. Ha mégis szükséges a perzisztens tárolás, az legfeljebb egy szintén gyorsan lejáró sütiben történjen.

  3. A kliens a kapott token segítségével megkezdi a kommunikációt a szerverrel. Minden kérésben mellékeli a tokent, hogy a szerver azonosítani tudja.

  4. A szerver minden kérés során azonosítja a felhasználót a kapott token alapján. Természetesen az aláírást is mindig ellenőrzi. Biztonságkritikusság szintjétől függően a felhasználó minden kérés során visszakereshető az adatbázisból, viszont a tokenben nem ajánlott még a hashelt jelszó tárolása sem, ezért a kérésenkénti újra-bejelentkeztetés nem kivitelezhető. Viszont a JWT működése miatt, amennyiben az aláírás érvényes, a token maga is az, tehát a felhasználó valóban az, akinek állítja magát.

Tehát hogyan is fog kinézni alkalmazásunk? Fontos megjegyeznem, a most bemutatásra kerülő példaalkalmazás csak a JWT token használatához szükséges elengedhetetlen kódokat fogja bemutatni, teljes REST alkalmazás készítéséhez további finomhangolásokra és természetesen controller osztályokra lenne szükség. Cikkemben most erre nem szeretnék kitérni, azonban egy későbbi cikkben szó lesz standard MVC és REST interfész implementálásáról is. Vágjunk bele.

Visszautalva cikkem elejére az authentikációs folyamat három fő komponensre támaszkodik, illetve megjelenhet egy negyedik, valamint egy kitüntetett komponens koordinálja az egészet. Emlékeztetőül a három komponens a filter, az authentikációs token, és a provider, továbbá a provider számára biztosítja az adatbázissal való kommunikációt a UserDetailsService implementáció. A komponensek munkáját pedig az authentikációs manager koordinálja. Nézzük a komponensek implementációját egyenként:

A filter dolga ez esetben a token kinyerése lesz a fejlécben tárolt információk alapján.

public class JWTAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
 
	private static final String URL_ROOT = "/api/user/**";
 
	public JWTAuthenticationFilter() {
		super(new NegatedRequestMatcher(new AntPathRequestMatcher(URL_ROOT)));
	}
 
	@Override
	public Authentication attemptAuthentication(HttpServletRequest request, 
				HttpServletResponse response)
			throws AuthenticationException, IOException, ServletException {
 
		String token = JWTUtility.extractToken(request);
		Authentication authentication = new JWTAuthenticationToken(token);
 
		return getAuthenticationManager().authenticate(authentication);
	}
 
	@Override
	protected void successfulAuthentication(HttpServletRequest request, 
				HttpServletResponse response, FilterChain chain,
			Authentication authResult) throws IOException, ServletException {
 
		SecurityContextHolder.getContext().setAuthentication(authResult);
 
		chain.doFilter(request, response);
	}
}

Az filter implementációját érdemes az AbstractAuthenticationProcessingFilter osztály kiterjesztésével kezdeni, mivel ez már tartalmazza a legfontosabb, közösen használható kódrészleteket. Az alapértelmezett konstruktor felülbírálása lehetőséget ad egy alap URL mintázat megadására, mellyel szabályozható, mely URL alatt lépjen működésbe a filter. A példán az látható, hogy az „/api/user/” kezdetű útvonalakon nem történik meg a token felolvasása – ez nem a legprecízebb mintázat, de a példához most megfelel. Az attemptAuthentication() metódus kinyeri a fejlécből a tokent, becsomagolja authentikációs token objektummá és továbbadja a managernek. A successfulAuthentication() metódus csak akkor kerül meghívásra, ha a filter sikeresen authentikálja a felhasználót, viszont ez egyúttal be is jegyzi az authentikációs objektumot a biztonsági kontextusba. A példában használt JWT parser library (io.jsonwebtoken.Jwts) már a parse-olás során elvégzi a szükséges vizsgálatokat, és megfelelő kivételeket dob a követelmények megsértése esetén.

A manager a provider számára továbbítja a tokent, mely most nem végez további ellenőrzéseket, csupán authentikáltra állítja a tokent. Ezt azért tehetem meg, mert – mint azt említettem – a parser korábban már megszakította volna a feldolgozást, ha a token érvénytelen. Ha a fejlesztés során használt library ezt nem teszi meg, a validálást elvégezhetjük a providerben és akár új authentikációs objektumot is generálhatunk.

public class JWTAuthenticationProvider implements AuthenticationProvider {
	
	@Override
	public Authentication authenticate(Authentication authentication) 
			throws AuthenticationException {
 
		authentication.setAuthenticated(true);
 
		return authentication;
	}
 
	@Override
	public boolean supports(Class authentication) {
 
		return JWTAuthenticationToken.class.isAssignableFrom(authentication);
	}
}

A providernek az AuthenticationProvider interfészt kell implementálnia, mely a példában látható két metódust követeli meg. Az első végzi a feladat oroszlánrészét, a második pedig meg tudja állapítani, hogy a kapott authentikációs token objektum validálható-e ezzel a providerrel. És ha már szóbakerült, az authentikációs token az Authentication interfész implementációja kell, legyen. Mivel ez egy elég standard implementáció, nem mellékelném itt a teljes forrását, a cikk végén található git repository linken természetesen megtekinthető lesz. Egy fontos részlet azonban a következő:

private JWTPayload payload;
 
public JWTAuthenticationToken(String token) {
 
	this.payload = JWTUtility.decode(token);
}

A fentebb látható módon a JWT payloadja már az authentikációs objektum létrehozásakor felolvasásra kerül. A JWTUtility osztály tartalmazza a token kódolásához és parse-olásához szükséges metódusokat, melyek forrása dokumentálva megtekinthető a fentebb említett repositoryban.

Zárásképp vessünk még egy pillantást a Spring Security alapkonfigurációjára.

public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
	
	// ...
	
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		
		http
			
			// set URL permissions
			.authorizeRequests()
				.antMatchers(URL_LOGIN).permitAll()
				.antMatchers(URL_REGISTER).permitAll()
				.anyRequest().authenticated()
			.and()
			
			// set processing filter
			.addFilterBefore(jwtAuthenticationFilter(), 
				UsernamePasswordAuthenticationFilter.class)
				
			// disable CSRF
			.csrf()
				.disable()
				
			// change session management to stateless
			.sessionManagement()
				.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
			.and()
			
			// set entry point
			.exceptionHandling()
				.authenticationEntryPoint(restAuthenticationEntryPoint())	
			.and()
			
			// enable https connection
			.requiresChannel()
				.anyRequest().requiresSecure();
	}
	
	// ...
}

A konfiguráláshoz a WebSecurityConfigurerAdapter osztály kiterjesztésére lesz szükség. Ennek három különböző „configure()” metódusa van, most a HttpSecurity paraméterest kell használnunk. A különböző csoportok sorrendben a következőre szolgálnak:

  1. Az első szakasz beállítja az alapvető URL szintű jogosultságokat: a bejelentkezés és regisztráció oldalakon nem kérjük, hogy a felhasználó már authentikált legyen (nyilván), minden egyéb esetben elvárjuk.
  2. A JWT filtert a standard bejelentkezési folyamatot végrehajtó filter elé beszúrjuk a filterláncba. Ezzel lehetővé tesszük, hogy a felhasználó „be tudjon jelentkezni”, azaz tokent igényeljen, majd a további kérésekre az authentikálást a JWT filterre bízzuk.
  3. CSRF tokeneket kikapcsoljuk: muszáj, a JWT tokenben nem lesz.
  4. Az alkalmazás munkafolyamat kezelési módszerét állapotmentesre állítjuk, tehát nem fog munkafolyamat létrejönni a bejelentkezés után, a token így tehát minden kérésnél validálásra kerül – tiszteletben tartva a REST elveket.
  5. Hozzáadunk egy egyedi „belépési pontot”. Ez egy egyedileg implementált belépési pont, mely kikapcsolja a sikertelen authentikáció esetén automatikusan történő átirányítást. Nyilván erre most nem lesz szükség. (Az implementáció mindössze egy sor, szintén megtekinthető a repository-ban).
  6. Mint az bármilyen authentikációs módszer esetén előfordulhat, a token is ellopható, és a felhasználó nevében használható. Ezt elkerülendő, az alkalmazást érdemes HTTPS kommunikációra konfigurálni, hogy a kommunikációs csatorna ne legyen lehallgatható.

Példaalkalmazás forrása

Kommentek

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

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