REST-Sicherheit mit JWT unter Verwendung von Java und Spring Security
Veröffentlicht: 2022-03-11Sicherheit
Sicherheit ist der Feind der Bequemlichkeit und umgekehrt. Diese Aussage gilt für jedes System, ob virtuell oder real, vom physischen Hauseingang bis hin zu Web-Banking-Plattformen. Ingenieure versuchen ständig, die richtige Balance für den jeweiligen Anwendungsfall zu finden, wobei sie sich zur einen oder anderen Seite neigen. Wenn eine neue Bedrohung auftaucht, bewegen wir uns normalerweise in Richtung Sicherheit und weg von Bequemlichkeit. Dann sehen wir, ob wir etwas verlorenen Komfort wiederherstellen können, ohne die Sicherheit zu stark zu reduzieren. Außerdem geht dieser Teufelskreis ewig weiter.
Lassen Sie uns versuchen, den Stand der REST-Sicherheit heute zu untersuchen, indem wir ein einfaches Spring-Sicherheits-Tutorial verwenden, um es in Aktion zu demonstrieren.
REST-Dienste (was für Representational State Transfer steht) begannen als extrem vereinfachter Ansatz für Webdienste, die riesige Spezifikationen und umständliche Formate hatten, wie z. B. WSDL zur Beschreibung des Dienstes oder SOAP zur Angabe des Nachrichtenformats. In REST haben wir nichts davon. Wir können den REST-Dienst in einer Klartextdatei beschreiben und jedes beliebige Nachrichtenformat verwenden, wie z. B. JSON, XML oder sogar wieder Klartext. Der vereinfachte Ansatz wurde auch auf die Sicherheit von REST-Diensten angewendet; Kein definierter Standard schreibt eine bestimmte Methode zur Authentifizierung von Benutzern vor.
Obwohl REST-Dienste nicht viel spezifiziert haben, ist der Mangel an Status ein wichtiger. Dies bedeutet, dass der Server keinen Client-Status behält, wobei Sitzungen ein gutes Beispiel sind. Somit antwortet der Server auf jede Anfrage, als wäre es die erste, die der Client gestellt hat. Allerdings verwenden auch heute noch viele Implementierungen eine Cookie-basierte Authentifizierung, die von der standardmäßigen Website-Architektur geerbt wird. Der zustandslose Ansatz von REST macht Session-Cookies aus Sicherheitsgründen ungeeignet, aber sie sind dennoch weit verbreitet. Neben dem Ignorieren der erforderlichen Zustandslosigkeit war der vereinfachte Ansatz ein erwarteter Sicherheitskompromiss. Im Vergleich zum WS-Security-Standard, der für Webdienste verwendet wird, ist es viel einfacher, REST-Dienste zu erstellen und zu nutzen, daher ging die Bequemlichkeit durch die Decke. Der Kompromiss ist ziemlich geringe Sicherheit; Session Hijacking und Cross-Site Request Forgery (XSRF) sind die häufigsten Sicherheitsprobleme.
Bei dem Versuch, Clientsitzungen vom Server zu entfernen, wurden gelegentlich einige andere Methoden verwendet, z. B. Basic- oder Digest-HTTP-Authentifizierung. Beide verwenden einen Authorization
, um Benutzeranmeldeinformationen zu übertragen, wobei etwas Codierung (HTTP Basic) oder Verschlüsselung (HTTP Digest) hinzugefügt wird. Natürlich hatten sie die gleichen Mängel wie Websites: HTTP Basic musste über HTTPS verwendet werden, da Benutzername und Passwort in leicht umkehrbarer Base64-Codierung gesendet werden, und HTTP Digest erzwang die Verwendung von veraltetem MD5-Hashing, das sich als unsicher erwiesen hat.
Schließlich verwendeten einige Implementierungen willkürliche Token, um Clients zu authentifizieren. Diese Option scheint im Moment die beste zu sein, die wir haben. Bei richtiger Implementierung behebt es alle Sicherheitsprobleme von HTTP Basic, HTTP Digest oder Sitzungscookies, ist einfach zu verwenden und folgt dem zustandslosen Muster.
Bei solchen willkürlichen Token ist jedoch wenig Standard erforderlich. Jeder Dienstanbieter hatte seine eigene Vorstellung davon, was er in den Token einfügen und wie er kodieren oder verschlüsseln sollte. Die Nutzung von Diensten verschiedener Anbieter erforderte zusätzliche Einrichtungszeit, nur um sich an das verwendete spezifische Token-Format anzupassen. Die anderen Methoden hingegen (Session Cookie, HTTP Basic und HTTP Digest) sind den Entwicklern bestens bekannt und fast alle Browser auf allen Geräten arbeiten damit out of the box. Frameworks und Sprachen sind für diese Methoden bereit und verfügen über integrierte Funktionen, um sich nahtlos mit jeder zu befassen.
JWT-Authentifizierung
JWT (abgekürzt von JSON Web Token) ist die fehlende Standardisierung für die Verwendung von Token zur Authentifizierung im Web im Allgemeinen, nicht nur für REST-Dienste. Derzeit befindet es sich als RFC 7519 im Entwurfsstatus. Es ist robust und kann viele Informationen enthalten, ist aber dennoch einfach zu verwenden, obwohl es relativ klein ist. Wie jedes andere Token kann JWT verwendet werden, um die Identität authentifizierter Benutzer zwischen einem Identitätsanbieter und einem Dienstanbieter (bei denen es sich nicht notwendigerweise um dieselben Systeme handelt) weiterzugeben. Es kann auch alle Ansprüche des Benutzers enthalten, wie z. B. Autorisierungsdaten, sodass der Dienstanbieter nicht in die Datenbank oder externe Systeme einsteigen muss, um Benutzerrollen und Berechtigungen für jede Anfrage zu überprüfen. dass Daten aus dem Token extrahiert werden.
So funktioniert die JWT-Sicherheit:
- Clients melden sich an, indem sie ihre Anmeldeinformationen an den Identitätsanbieter senden.
- Der Identitätsanbieter überprüft die Anmeldeinformationen; Wenn alles in Ordnung ist, ruft es die Benutzerdaten ab, generiert ein JWT mit Benutzerdetails und Berechtigungen, die für den Zugriff auf die Dienste verwendet werden, und legt auch das Ablaufdatum für das JWT fest (das unbegrenzt sein kann).
- Der Identitätsanbieter signiert und verschlüsselt das JWT bei Bedarf und sendet es als Antwort auf die ursprüngliche Anfrage mit Anmeldeinformationen an den Client.
- Der Client speichert das JWT für einen begrenzten oder unbegrenzten Zeitraum, je nach dem vom Identitätsanbieter festgelegten Ablaufdatum.
- Der Client sendet das gespeicherte JWT in einem Autorisierungsheader für jede Anfrage an den Dienstanbieter.
- Für jede Anfrage entnimmt der Dienstanbieter das JWT aus dem
Authorization
-Header und entschlüsselt es bei Bedarf, validiert die Signatur und extrahiert, wenn alles in Ordnung ist, die Benutzerdaten und Berechtigungen. Allein aufgrund dieser Daten und wiederum ohne weitere Details in der Datenbank nachzuschlagen oder den Identitätsanbieter zu kontaktieren, kann er die Client-Anfrage annehmen oder ablehnen. Die einzige Voraussetzung ist, dass die Identitäts- und Dienstanbieter eine Vereinbarung über die Verschlüsselung getroffen haben, damit der Dienst die Signatur überprüfen oder sogar entschlüsseln kann, welche Identität verschlüsselt wurde.
Dieser Ablauf ermöglicht eine große Flexibilität und sorgt gleichzeitig dafür, dass die Dinge sicher und einfach zu entwickeln sind. Durch die Verwendung dieses Ansatzes ist es einfach, neue Serverknoten zum Dienstanbieter-Cluster hinzuzufügen und sie mit nur der Fähigkeit zu initialisieren, die Signatur zu überprüfen und die Token zu entschlüsseln, indem ihnen ein gemeinsamer geheimer Schlüssel bereitgestellt wird. Es ist keine Sitzungsreplikation, Datenbanksynchronisierung oder Kommunikation zwischen Knoten erforderlich. RUHE in seiner vollen Pracht.
Der Hauptunterschied zwischen JWT und anderen beliebigen Token ist die Standardisierung des Inhalts des Tokens. Ein weiterer empfohlener Ansatz besteht darin, das JWT-Token im Authorization
Header mithilfe des Bearer-Schemas zu senden. Der Inhalt des Headers sollte wie folgt aussehen:
Authorization: Bearer <token>
REST-Sicherheitsimplementierung
Damit REST-Dienste wie erwartet funktionieren, benötigen wir einen etwas anderen Autorisierungsansatz im Vergleich zu klassischen, mehrseitigen Websites.
Anstatt den Authentifizierungsprozess durch Umleitung auf eine Anmeldeseite auszulösen, wenn ein Client eine gesicherte Ressource anfordert, authentifiziert der REST-Server alle Anfragen mit den in der Anfrage selbst verfügbaren Daten, in diesem Fall dem JWT-Token. Wenn eine solche Authentifizierung fehlschlägt, macht eine Umleitung keinen Sinn. Die REST-API sendet einfach eine HTTP-Code-401-Antwort (nicht autorisiert) und Clients sollten wissen, was zu tun ist; Beispielsweise zeigt ein Browser ein dynamisches div an, damit der Benutzer den Benutzernamen und das Kennwort eingeben kann.
Bei klassischen, mehrseitigen Websites hingegen wird der Nutzer nach erfolgreicher Authentifizierung per HTTP-Code 301 (Moved permanent) in der Regel auf eine Startseite oder noch besser auf die vom Nutzer angefragte und getriggerte Seite umgeleitet der Authentifizierungsprozess. Bei REST macht das wiederum keinen Sinn. Stattdessen würden wir einfach mit der Ausführung der Anfrage fortfahren, als ob die Ressource überhaupt nicht gesichert wäre, den HTTP-Code 200 (OK) und den erwarteten Antworttext zurückgeben.
Beispiel für Federsicherheit
Sehen wir uns nun an, wie wir die auf JWT-Token basierende REST-API mit Java und Spring implementieren können, während wir versuchen, das Standardverhalten von Spring Security nach Möglichkeit wiederzuverwenden.
Wie erwartet enthält das Spring Security-Framework viele fertige Plug-in-Klassen, die sich mit „alten“ Autorisierungsmechanismen befassen: Sitzungscookies, HTTP Basic und HTTP Digest. Es fehlt jedoch die native Unterstützung für JWT, und wir müssen uns die Hände schmutzig machen, damit es funktioniert. Für einen detaillierteren Überblick sollten Sie die offizielle Spring Security-Dokumentation konsultieren.
Beginnen wir nun mit der üblichen Spring Security-Filterdefinition in web.xml
:
<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>
Beachten Sie, dass der Name des Spring Security-Filters genau springSecurityFilterChain
sein muss, damit der Rest der Spring-Konfiguration sofort funktioniert.
Als nächstes kommt die XML-Deklaration der Spring-Beans in Bezug auf die Sicherheit. Um das XML zu vereinfachen, setzen wir den Standardnamensraum auf security
, indem wir xmlns="http://www.springframework.org/schema/security"
zum XML-Stammelement hinzufügen. Der Rest des XML sieht so aus:
<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) In dieser Zeile aktivieren wir die
@PreFilter
,@PreAuthorize
,@PostFilter
,@PostAuthorize
für alle Spring Beans im Kontext. - (2) Wir definieren die Anmelde- und Anmeldeendpunkte, um die Sicherheit zu überspringen; sogar „anonym“ sollte in der Lage sein, diese beiden Operationen durchzuführen.
- (3) Als nächstes definieren wir die Filterkette, die auf alle Anfragen angewendet wird, während wir zwei wichtige Konfigurationen hinzufügen: Einstiegspunktreferenz und Festlegen der Sitzungserstellung auf
stateless
(wir möchten nicht, dass die Sitzung aus Sicherheitsgründen erstellt wird, da wir Tokens für jede Anfrage verwenden). . - (4) Wir brauchen keinen
csrf
-Schutz, weil unsere Token dagegen immun sind. - (5) Als nächstes fügen wir unseren speziellen Authentifizierungsfilter in die vordefinierte Filterkette von Spring ein, direkt vor dem Formular-Login-Filter.
- (6) Diese Bean ist die Deklaration unseres Authentifizierungsfilters; Da es Springs
AbstractAuthenticationProcessingFilter
erweitert, müssen wir es in XML deklarieren, um seine Eigenschaften zu verbinden (automatische Verbindung funktioniert hier nicht). Wir werden später erklären, was der Filter tut. - (7) Der Standard-Erfolgshandler von
AbstractAuthenticationProcessingFilter
ist für REST-Zwecke nicht gut genug, da er den Benutzer auf eine Erfolgsseite umleitet; deshalb setzen wir uns hier ein. - (8) Die vom
authenticationManager
erstellte Angabe des Anbieters wird von unserem Filter zur Authentifizierung von Benutzern verwendet.
Sehen wir uns nun an, wie wir die im obigen XML deklarierten spezifischen Klassen implementieren. Beachten Sie, dass Spring sie für uns verkabeln wird. Wir beginnen mit den einfachsten.

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"); } }
Wie oben erläutert, gibt diese Klasse nur den HTTP-Code 401 (Nicht autorisiert) zurück, wenn die Authentifizierung fehlschlägt, und überschreibt die Standardumleitung von Spring.
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 } }
Diese einfache Überschreibung entfernt das Standardverhalten einer erfolgreichen Authentifizierung (Umleitung zur Startseite oder einer anderen vom Benutzer angeforderten Seite). Wenn Sie sich fragen, warum wir den AuthenticationFailureHandler
nicht überschreiben müssen, liegt das daran, dass die Standardimplementierung nirgendwo umleitet, wenn die Umleitungs-URL nicht festgelegt ist. Wir vermeiden also einfach die Festlegung der URL, was gut genug ist.
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); } }
Diese Klasse ist der Einstiegspunkt unseres JWT-Authentifizierungsprozesses; Der Filter extrahiert das JWT-Token aus den Anforderungsheadern und delegiert die Authentifizierung an den eingefügten AuthenticationManager
. Wenn das Token nicht gefunden wird, wird eine Ausnahme ausgelöst, die die Verarbeitung der Anforderung stoppt. Wir benötigen auch eine Überschreibung für eine erfolgreiche Authentifizierung, da der standardmäßige Spring-Flow die Filterkette stoppen und mit einer Umleitung fortfahren würde. Denken Sie daran, dass die Kette vollständig ausgeführt werden muss, einschließlich der Generierung der Antwort, wie oben erläutert.
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); } }
In dieser Klasse verwenden wir den standardmäßigen AuthenticationManager
von Spring, aber wir injizieren ihn mit unserem eigenen AuthenticationProvider
, der den eigentlichen Authentifizierungsprozess durchführt. Um dies zu implementieren, erweitern wir den AbstractUserDetailsAuthenticationProvider
, wodurch wir nur UserDetails
basierend auf der Authentifizierungsanforderung zurückgeben müssen, in unserem Fall das JWT-Token, das in die Klasse JwtAuthenticationToken
ist. Wenn das Token nicht gültig ist, lösen wir eine Ausnahme aus. Wenn es jedoch gültig ist und die Entschlüsselung durch JwtUtil
erfolgreich ist, extrahieren wir die Benutzerdetails (wir werden genau sehen, wie in der JwtUtil
-Klasse), ohne überhaupt auf die Datenbank zuzugreifen. Alle Informationen über den Benutzer, einschließlich seiner Rollen, sind im Token selbst enthalten.
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(); } }
Schließlich ist die JwtUtil
-Klasse für das Parsen des Tokens in das User
und das Generieren des Tokens aus dem User
zuständig. Es ist unkompliziert, da es die jjwt
Bibliothek verwendet, um die gesamte JWT-Arbeit zu erledigen. In unserem Beispiel speichern wir einfach den Benutzernamen, die Benutzer-ID und die Benutzerrollen im Token. Wir könnten auch mehr willkürliches Zeug speichern und mehr Sicherheitsfunktionen hinzufügen, wie zum Beispiel den Ablauf des Tokens. Die Analyse des Tokens wird im AuthenticationProvider
wie oben gezeigt verwendet. Die Methode generateToken()
wird von Anmelde- und Registrierungs-REST-Diensten aufgerufen, die ungesichert sind und keine Sicherheitsüberprüfungen auslösen oder erfordern, dass ein Token in der Anforderung vorhanden ist. Am Ende generiert es das Token, das basierend auf dem Benutzer an die Clients zurückgegeben wird.
Fazit
Obwohl die alten, standardisierten Sicherheitsansätze (Session-Cookie, HTTP Basic und HTTP Digest) auch mit REST-Diensten funktionieren, haben sie alle Probleme, die durch die Verwendung eines besseren Standards vermieden werden sollten. JWT kommt gerade rechtzeitig, um den Tag zu retten, und vor allem steht es kurz davor, ein IETF-Standard zu werden.
Die Hauptstärke von JWT besteht darin, die Benutzerauthentifizierung auf eine zustandslose und daher skalierbare Weise zu handhaben und gleichzeitig alles mit aktuellen Kryptografiestandards sicher zu halten. Das Speichern von Ansprüchen (Benutzerrollen und Berechtigungen) im Token selbst schafft enorme Vorteile in verteilten Systemarchitekturen, in denen der Server, der die Anfrage ausgibt, keinen Zugriff auf die Authentifizierungsdatenquelle hat.