Java ve Spring Security kullanarak JWT ile REST Security

Yayınlanan: 2022-03-11

Güvenlik

Güvenlik, rahatlığın düşmanıdır ve bunun tersi de geçerlidir. Bu ifade, fiziksel ev girişinden web bankacılığı platformlarına kadar sanal veya gerçek herhangi bir sistem için geçerlidir. Mühendisler, bir tarafa ya da diğerine eğilerek sürekli olarak belirli kullanım durumu için doğru dengeyi bulmaya çalışıyorlar. Genellikle, yeni bir tehdit ortaya çıktığında, güvenliğe doğru hareket eder ve kolaylıktan uzaklaşırız. Ardından, güvenliği çok fazla düşürmeden bazı kayıp kolaylıkları kurtarıp kurtaramayacağımıza bakarız. Üstelik bu kısır döngü sonsuza kadar devam eder.

bahar güvenlik eğitimi: Güvenlik ve kolaylık illüstrasyonu

Güvenlik, rahatlığın düşmanıdır ve bunun tersi de geçerlidir.
Cıvıldamak

Eylemde göstermek için basit bir Spring güvenlik öğreticisi kullanarak bugün REST güvenliğinin durumunu incelemeye çalışalım.

REST (Temsili Durum Aktarımı anlamına gelir) hizmetleri, hizmeti açıklamak için WSDL veya mesaj biçimini belirlemek için SOAP gibi çok büyük özelliklere ve hantal biçimlere sahip Web Hizmetlerine son derece basitleştirilmiş bir yaklaşım olarak başladı. REST'te bunların hiçbiri yok. REST hizmetini bir düz metin dosyasında tanımlayabilir ve yine JSON, XML veya hatta düz metin gibi istediğimiz herhangi bir mesaj biçimini kullanabiliriz. Basitleştirilmiş yaklaşım, REST hizmetlerinin güvenliğine de uygulandı; tanımlanmış hiçbir standart, kullanıcıların kimliğini doğrulamak için belirli bir yol dayatmaz.

REST servislerinin çok fazla belirtilmemiş olmasına rağmen, önemli bir tanesi durum eksikliğidir. Bu, sunucunun iyi bir örnek olarak oturumlarla herhangi bir istemci durumunu tutmadığı anlamına gelir. Böylece sunucu, her isteğe, istemcinin yaptığı ilk istekmiş gibi yanıt verir. Ancak, şimdi bile birçok uygulama, standart web sitesi mimari tasarımından devralınan tanımlama bilgisi tabanlı kimlik doğrulamasını kullanmaya devam ediyor. REST'in durum bilgisi olmayan yaklaşımı, oturum tanımlama bilgilerini güvenlik açısından uygunsuz kılar, ancak yine de bunlar hala yaygın olarak kullanılmaktadır. Gerekli vatansızlığı göz ardı etmenin yanı sıra, basitleştirilmiş yaklaşım, beklenen bir güvenlik değiş tokuşu olarak geldi. Web Servisleri için kullanılan WS-Security standardına kıyasla, REST servislerini oluşturmak ve tüketmek çok daha kolaydır, bu nedenle kolaylık tavan yaptı. Takas oldukça zayıf bir güvenliktir; oturum ele geçirme ve siteler arası istek sahteciliği (XSRF) en yaygın güvenlik sorunlarıdır.

Sunucudan istemci oturumlarından kurtulmaya çalışırken, zaman zaman Temel veya Özet HTTP kimlik doğrulaması gibi başka yöntemler de kullanılmıştır. Her ikisi de, bazı kodlama (HTTP Temel) veya şifreleme (HTTP Özeti) eklenmiş olarak, kullanıcı kimlik bilgilerini iletmek için bir Authorization başlığı kullanır. Elbette, web sitelerinde bulunan kusurların aynısını taşıyorlardı: Kullanıcı adı ve parola kolayca geri çevrilebilir base64 kodlamasında gönderildiğinden ve HTTP Özeti, güvenli olmadığı kanıtlanmış eski MD5 karma kullanımını zorunlu kıldığı için HTTPS üzerinden kullanılması gerekiyordu.

Son olarak, bazı uygulamalar istemcilerin kimliğini doğrulamak için rastgele belirteçler kullandı. Bu seçenek şimdilik elimizdekilerin en iyisi gibi görünüyor. Düzgün uygulanırsa, HTTP Basic, HTTP Digest veya oturum tanımlama bilgilerinin tüm güvenlik sorunlarını giderir, kullanımı kolaydır ve durum bilgisi olmayan modeli izler.

Ancak, bu tür keyfi belirteçlerle ilgili çok az standart vardır. Her hizmet sağlayıcı, tokene ne koyacağına ve onu nasıl kodlayacağına veya şifreleyeceğine dair kendi fikrine sahipti. Farklı sağlayıcılardan hizmet tüketmek, yalnızca kullanılan belirli belirteç biçimine uyum sağlamak için ek kurulum süresi gerektiriyordu. Öte yandan diğer yöntemler (oturum tanımlama bilgisi, HTTP Temel ve HTTP Özeti) geliştiriciler tarafından iyi bilinir ve tüm cihazlarda neredeyse tüm tarayıcılar onlarla birlikte çalışır. Çerçeveler ve diller, her biri ile sorunsuz bir şekilde başa çıkmak için yerleşik işlevlere sahip olan bu yöntemler için hazırdır.

JWT Kimlik Doğrulaması

JWT (JSON Web Token'dan kısaltılmıştır), yalnızca REST hizmetleri için değil, genel olarak web'de kimlik doğrulaması yapmak için belirteçleri kullanmaya yönelik eksik standartlaştırmadır. Şu anda RFC 7519 olarak taslak durumundadır. Sağlamdır ve çok fazla bilgi taşıyabilir, ancak boyutu nispeten küçük olmasına rağmen kullanımı yine de basittir. Diğer herhangi bir belirteç gibi, JWT, kimliği doğrulanmış kullanıcıların kimliğini bir kimlik sağlayıcı ile bir hizmet sağlayıcı arasında (bunlar aynı sistemler olması gerekmez) iletmek için kullanılabilir. Ayrıca yetkilendirme verileri gibi kullanıcının tüm taleplerini de taşıyabilir, böylece hizmet sağlayıcının her istek için kullanıcı rollerini ve izinlerini doğrulamak için veritabanına veya harici sistemlere girmesi gerekmez; bu veriler belirteçten çıkarılır.

JWT güvenliğinin çalışması şu şekilde tasarlanmıştır:

JWT java akış illüstrasyonu

  • İstemciler, kimlik bilgilerini kimlik sağlayıcıya göndererek oturum açar.
  • Kimlik sağlayıcı, kimlik bilgilerini doğrular; her şey yolundaysa, kullanıcı verilerini alır, hizmetlere erişmek için kullanılacak kullanıcı ayrıntılarını ve izinleri içeren bir JWT oluşturur ve ayrıca JWT'de (sınırsız olabilir) sona erme tarihini ayarlar.
  • Kimlik sağlayıcı imzalar ve gerekirse JWT'yi şifreler ve kimlik bilgileriyle ilk isteğe yanıt olarak istemciye gönderir.
  • Müşteri, JWT'yi, kimlik sağlayıcı tarafından belirlenen sona erme süresine bağlı olarak sınırlı veya sınırsız bir süre boyunca saklar.
  • Müşteri, depolanan JWT'yi hizmet sağlayıcıya her istek için bir Yetkilendirme başlığında gönderir.
  • Her istek için, servis sağlayıcı JWT'yi Authorization başlığından alır ve gerekirse şifresini çözer, imzayı doğrular ve her şey yolundaysa, kullanıcı verilerini ve izinlerini çıkarır. Yalnızca bu verilere dayanarak ve yine veritabanında daha fazla ayrıntıya bakmadan veya kimlik sağlayıcıyla iletişim kurmadan, müşteri talebini kabul edebilir veya reddedebilir. Tek gereklilik, hizmetin imzayı doğrulayabilmesi ve hatta hangi kimliğin şifrelendiğinin şifresini çözebilmesi için kimlik ve hizmet sağlayıcıların şifreleme konusunda bir anlaşmaya sahip olmalarıdır.

Bu akış, işleri güvenli ve geliştirmesi kolay tutarken büyük esneklik sağlar. Bu yaklaşımı kullanarak, hizmet sağlayıcı kümesine yeni sunucu düğümleri eklemek, bunları yalnızca imzayı doğrulama ve belirteçlerin şifresini onlara paylaşılan bir gizli anahtar sağlayarak çözme yeteneği ile başlatmak kolaydır. Oturum çoğaltma, veritabanı senkronizasyonu veya düğümler arası iletişim gerekmez. Tüm ihtişamıyla REST.

JWT ve diğer keyfi belirteçler arasındaki temel fark, belirteç içeriğinin standartlaştırılmasıdır. Önerilen diğer bir yaklaşım, JWT belirtecini, Taşıyıcı şemasını kullanarak Authorization başlığında göndermektir. Başlığın içeriği şöyle görünmelidir:

 Authorization: Bearer <token>

REST Güvenlik Uygulaması

REST hizmetlerinin beklendiği gibi çalışması için klasik, çok sayfalı web sitelerine kıyasla biraz farklı bir yetkilendirme yaklaşımına ihtiyacımız var.

Bir istemci güvenli bir kaynak istediğinde bir oturum açma sayfasına yeniden yönlendirerek kimlik doğrulama sürecini tetiklemek yerine, REST sunucusu, bu durumda JWT belirteci olan isteğin kendisinde bulunan verileri kullanarak tüm istekleri doğrular. Böyle bir kimlik doğrulama başarısız olursa, yeniden yönlendirme bir anlam ifade etmez. REST API, yalnızca bir HTTP kodu 401 (Yetkisiz) yanıtı gönderir ve istemciler ne yapacaklarını bilmelidir; örneğin, bir tarayıcı, kullanıcının kullanıcı adını ve şifreyi sağlamasına izin vermek için dinamik bir div gösterecektir.

Öte yandan, klasik, çok sayfalı web sitelerinde başarılı bir kimlik doğrulamasından sonra, kullanıcı HTTP kodu 301 (Kalıcı olarak taşındı) kullanılarak, genellikle bir ana sayfaya veya daha da iyisi, kullanıcının başlangıçta talep ettiği ve tetiklenen sayfaya yönlendirilir. kimlik doğrulama işlemi. REST ile bu yine bir anlam ifade etmiyor. Bunun yerine, kaynak hiç güvenli değilmiş gibi isteğin yürütülmesine devam eder, HTTP kodu 200 (OK) ve beklenen yanıt gövdesini döndürürüz.

Yay Güvenliği Örneği

JWT ve Java ile Spring REST Güvenliği

Şimdi, Spring Security varsayılan davranışını elimizden geldiğince yeniden kullanmaya çalışırken, JWT belirteci tabanlı REST API'sini Java ve Spring kullanarak nasıl uygulayabileceğimizi görelim.

Beklendiği gibi, Spring Security çerçevesi, "eski" yetkilendirme mekanizmalarıyla ilgilenen birçok eklentiye hazır sınıfla birlikte gelir: oturum çerezleri, HTTP Temel ve HTTP Özeti. Ancak, JWT için yerel desteğe sahip değil ve çalışması için ellerimizi kirletmemiz gerekiyor. Daha ayrıntılı bir genel bakış için resmi Spring Security belgelerine başvurmalısınız.

Şimdi, web.xml olağan Spring Security filtre tanımıyla başlayalım:

 <filter> <filter-name>springSecurityFilterChain</filter-name> <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class> </filter> <filter-mapping> <filter-name>springSecurityFilterChain</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>

Spring yapılandırmasının geri kalanının kutudan çıkması için Spring Security filtresinin adının tam olarak springSecurityFilterChain olması gerektiğini unutmayın.

Ardından, bahar fasulyelerinin güvenlikle ilgili XML bildirimi gelir. XML'i basitleştirmek için, kök XML öğesine xmlns="http://www.springframework.org/schema/security" ekleyerek varsayılan ad alanını security ayarlayacağız. XML'in geri kalanı şöyle görünür:

 <global-method-security pre-post-annotations="enabled" /> (1) <http pattern="/api/login" security="none"/> (2) <http pattern="/api/signup" security="none"/> <http pattern="/api/**" entry-point-ref="restAuthenticationEntryPoint" create-session="stateless"> (3) <csrf disabled="true"/> (4) <custom-filter before="FORM_LOGIN_FILTER" ref="jwtAuthenticationFilter"/> (5) </http> <beans:bean class="com.toptal.travelplanner.security.JwtAuthenticationFilter"> (6) <beans:property name="authenticationManager" ref="authenticationManager" /> <beans:property name="authenticationSuccessHandler" ref="jwtAuthenticationSuccessHandler" /> (7) </beans:bean> <authentication-manager alias="authenticationManager"> <authentication-provider ref="jwtAuthenticationProvider" /> (8) </authentication-manager>
  • (1) Bu satırda, bağlamdaki herhangi bir bahar fasulyesi üzerinde @PreFilter , @PreAuthorize , @PostFilter , @PostAuthorize ek açıklamalarını etkinleştiririz.
  • (2) Güvenliği atlamak için oturum açma ve kayıt uç noktalarını tanımlarız; bu iki işlemi “anonim” bile yapabilmeli.
  • (3) Ardından, iki önemli yapılandırma eklerken tüm isteklere uygulanan filtre zincirini tanımlarız: Giriş noktası referansı ve oturum oluşturmayı stateless olarak ayarlama (her istek için belirteçler kullandığımız için oturumun güvenlik amacıyla oluşturulmasını istemiyoruz) .
  • (4) Belirteçlerimiz buna bağışık olduğu için csrf korumasına ihtiyacımız yok.
  • (5) Ardından, form oturum açma filtresinden hemen önce Spring'in önceden tanımlanmış filtre zincirine özel kimlik doğrulama filtremizi takarız.
  • (6) Bu çekirdek, doğrulama filtremizin bildirimidir; Spring'in AbstractAuthenticationProcessingFilter öğesini genişlettiğinden, özelliklerini kablolamak için XML'de bildirmemiz gerekiyor (otomatik kablo burada çalışmıyor). Filtrenin ne yaptığını daha sonra açıklayacağız.
  • (7) AbstractAuthenticationProcessingFilter varsayılan başarı işleyicisi, kullanıcıyı bir başarı sayfasına yönlendirdiği için REST amaçları için yeterince iyi değildir; bu yüzden burada kendimizinkini belirledik.
  • (8) AuthenticationManager tarafından oluşturulan sağlayıcının beyanı, kullanıcıların kimliğini authenticationManager için filtremiz tarafından kullanılır.

Şimdi, yukarıdaki XML'de belirtilen belirli sınıfları nasıl uyguladığımızı görelim. Spring'in onları bizim için kablolayacağını unutmayın. En basit olanlarla başlıyoruz.

RestAuthenticationEntryPoint.java

 public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { // This is invoked when user tries to access a secured REST resource without supplying any credentials // We should just send a 401 Unauthorized response because there is no 'login page' to redirect to response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"); } }

Yukarıda açıklandığı gibi, bu sınıf, kimlik doğrulama başarısız olduğunda, varsayılan Spring'in yeniden yönlendirmesini geçersiz kılarak yalnızca HTTP kodu 401'i (Yetkisiz) döndürür.

JwtAuthenticationSuccessHandler.java

 public class JwtAuthenticationSuccessHandler implements AuthenticationSuccessHandler { @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { // We do not need to do anything extra on REST authentication success, because there is no page to redirect to } }

Bu basit geçersiz kılma, başarılı bir kimlik doğrulamanın varsayılan davranışını kaldırır (ana sayfaya veya kullanıcının istediği başka bir sayfaya yönlendirme). AuthenticationFailureHandler öğesini neden geçersiz kılmamız gerekmediğini merak ediyorsanız, bunun nedeni, yönlendirme URL'si ayarlanmadığında varsayılan uygulamanın hiçbir yere yönlendirme yapmamasıdır, bu nedenle URL'yi ayarlamaktan kaçınıyoruz, bu da yeterince iyi.

JwtAuthenticationFilter.java

 public class JwtAuthenticationFilter extends AbstractAuthenticationProcessingFilter { public JwtAuthenticationFilter() { super("/**"); } @Override protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) { return true; } @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { String header = request.getHeader("Authorization"); if (header == null || !header.startsWith("Bearer ")) { throw new JwtTokenMissingException("No JWT token found in request headers"); } String authToken = header.substring(7); JwtAuthenticationToken authRequest = new JwtAuthenticationToken(authToken); return getAuthenticationManager().authenticate(authRequest); } @Override protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { super.successfulAuthentication(request, response, chain, authResult); // As this authentication is in HTTP header, after success we need to continue the request normally // and return the response as if the resource was not secured at all chain.doFilter(request, response); } }

Bu sınıf, JWT kimlik doğrulama sürecimizin giriş noktasıdır; filtre, istek başlıklarından JWT belirtecini çıkarır ve kimlik doğrulamasını enjekte edilen AuthenticationManager devreder. Belirteç bulunamazsa, isteğin işlenmesini durduran bir özel durum oluşturulur. Ayrıca başarılı kimlik doğrulama için bir geçersiz kılmaya ihtiyacımız var çünkü varsayılan Spring akışı filtre zincirini durduracak ve bir yönlendirme ile devam edecek. Yukarıda açıklandığı gibi yanıtı oluşturmak da dahil olmak üzere zincirin tam olarak yürütülmesine ihtiyacımız olduğunu unutmayın.

JwtAuthenticationProvider.java

 public class JwtAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider { @Autowired private JwtUtil jwtUtil; @Override public boolean supports(Class<?> authentication) { return (JwtAuthenticationToken.class.isAssignableFrom(authentication)); } @Override protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { } @Override protected UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { JwtAuthenticationToken jwtAuthenticationToken = (JwtAuthenticationToken) authentication; String token = jwtAuthenticationToken.getToken(); User parsedUser = jwtUtil.parseToken(token); if (parsedUser == null) { throw new JwtTokenMalformedException("JWT token is not valid"); } List<GrantedAuthority> authorityList = AuthorityUtils.commaSeparatedStringToAuthorityList(parsedUser.getRole()); return new AuthenticatedUser(parsedUser.getId(), parsedUser.getUsername(), token, authorityList); } }

Bu sınıfta, Spring'in varsayılan AuthenticationManager kullanıyoruz, ancak onu gerçek kimlik doğrulama işlemini yapan kendi AuthenticationProvider ile enjekte ediyoruz. Bunu uygulamak için, yalnızca kimlik doğrulama isteğine dayalı olarak UserDetails döndürmemizi gerektiren AbstractUserDetailsAuthenticationProvider öğesini genişletiyoruz, bizim durumumuzda JWT belirteci JwtAuthenticationToken sınıfına sarılmış. Belirteç geçerli değilse, bir istisna atarız. Ancak, geçerliyse ve JwtUtil tarafından şifre çözme başarılıysa, veritabanına hiç erişmeden kullanıcı ayrıntılarını çıkarırız (tam olarak JwtUtil sınıfında nasıl olduğunu göreceğiz). Rolleri de dahil olmak üzere kullanıcı hakkındaki tüm bilgiler belirtecin kendisinde bulunur.

JwtUtil.java

 public class JwtUtil { @Value("${jwt.secret}") private String secret; /** * Tries to parse specified String as a JWT token. If successful, returns User object with username, id and role prefilled (extracted from token). * If unsuccessful (token is invalid or not containing all required user properties), simply returns null. * * @param token the JWT token to parse * @return the User object extracted from specified token or null if a token is invalid. */ public User parseToken(String token) { try { Claims body = Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); User u = new User(); u.setUsername(body.getSubject()); u.setId(Long.parseLong((String) body.get("userId"))); u.setRole((String) body.get("role")); return u; } catch (JwtException | ClassCastException e) { return null; } } /** * Generates a JWT token containing username as subject, and userId and role as additional claims. These properties are taken from the specified * User object. Tokens validity is infinite. * * @param u the user for which the token will be generated * @return the JWT token */ public String generateToken(User u) { Claims claims = Jwts.claims().setSubject(u.getUsername()); claims.put("userId", u.getId() + ""); claims.put("role", u.getRole()); return Jwts.builder() .setClaims(claims) .signWith(SignatureAlgorithm.HS512, secret) .compact(); } }

Son olarak, JwtUtil sınıfı, belirteci User nesnesine ayrıştırmaktan ve belirteci User nesnesinden oluşturmaktan sorumludur. Tüm JWT işlerini yapmak için jjwt kitaplığını kullandığından basittir. Örneğimizde, kullanıcı adını, kullanıcı kimliğini ve kullanıcı rollerini belirteçte saklıyoruz. Ayrıca daha rastgele şeyler depolayabilir ve belirtecin süresinin dolması gibi daha fazla güvenlik özelliği ekleyebiliriz. Belirtecin ayrıştırılması, yukarıda gösterildiği gibi AuthenticationProvider kullanılır. generateToken() yöntemi, güvenli olmayan ve herhangi bir güvenlik kontrolünü tetiklemeyen veya istekte bir belirteç bulunmasını gerektirmeyen oturum açma ve kayıt REST hizmetlerinden çağrılır. Sonunda, kullanıcıya bağlı olarak istemcilere döndürülecek belirteci üretir.

Çözüm

Eski, standartlaştırılmış güvenlik yaklaşımları (oturum tanımlama bilgisi, HTTP Temel ve HTTP Özeti) REST hizmetleriyle de çalışacak olsa da, hepsinin daha iyi bir standart kullanarak kaçınılması güzel olan sorunları vardır. JWT günü kurtarmak için tam zamanında geliyor ve en önemlisi IETF standardı olmaya çok yakın.

JWT'nin ana gücü, her şeyi güncel şifreleme standartlarıyla güvende tutarken, kullanıcı kimlik doğrulamasını durumsuz ve dolayısıyla ölçeklenebilir bir şekilde ele almasıdır. Talepleri (kullanıcı rolleri ve izinleri) belirtecin kendisinde depolamak, isteği yayınlayan sunucunun kimlik doğrulama veri kaynağına erişiminin olmadığı dağıtılmış sistem mimarilerinde büyük faydalar sağlar.