DE EN

Spring Security 5: Authentifizierung mit Basic Auth und JWT

von

Alle Artikel dieser Serie

Spring Security 5

Wenn du zum ersten Mal hier bist, solltest du dir folgende frühere Artikel ansehen: Einführung zu Spring Security 5 und Benutzer authentifizieren mit JDBC.

In den vorherigen Artikeln wurden die Grundlagen von Spring Security erläutert und die Verbindung zu JDBC-Datenbanken untersucht. Wenn du damit oder mit dem WebSecurityConfigurerAdapter nicht vertraut bist, dann solltest du diese Artikel zuerst lesen.

Basic Auth? JWT? SPA?

Heutzutage verwenden Anwendungen häufig eine Single Page Application (SPA) als Frontend. Es gab eine Zeit, in der das Klicken auf einen Link bedeutete, dass die gesamte Webseite für eine kurze Zeit verschwand und wieder auftauchte, nachdem sie frisch vom Webserver erstellt wurde. Dieser Ansatz wird heutzutage nicht mehr bevorzugt. Nach dem ersten Laden der Seite wird nur der zu aktualisierte Inhalt ersetzt. All das wird durch Javascript ermöglicht. Das Ergebnis ist eine schnelle und dynamisch reagierende Webseite.

Mit diesem neuen Ansatz wurden Backend-Server auf eine reine Inhaltsbereitstellung (content delivery) umgestellt, anstatt HTML-Seiten zu erstellen. Dies hat auch zu Änderungen in der Art und Weise geführt, wie Webanwendungen die Authentifizierung durchführen. Anstatt HTML-Formulare an den Webserver zu senden, ist es effizienter und sicherer, lediglich die Authentifizierungsinformationen im Header eines HTML-Requests zu senden und der Server erledigt dann den Rest.

In vielen Fällen folgt der Webserver dem REST-Ansatz, definiert also, was gesendet und was akzeptiert werden muss. Diese Art der Implementierung eines Webservers wird als Endpunkt oder als API (Application Programming Interface) bezeichnet.

Basic Auth

Jedes Mal wenn ein Request an den Server gesendet wird, muss dieser authentifiziert werden, damit die Anwendung sicherstellen kann, dass die Anforderung von einem gültigen Benutzer stammt und der Benutzer identifiziert werden kann. Am Einfachsten wäre diese, wenn bei jeder Anfrage der Benutzernamen und das Passwort mitgesendet würden. Theoretisch könnte man eine Art Sitzung erstellen und diese Informationen in einem Cookie speichern. Es ist jedoch schwierig, Sitzungen aufrechtzuerhalten, wenn die Anwendung eine große Anzahl von Benutzern umfasst oder auch in Fällen, in denen mehr als ein Backend-Server vorhanden ist. Es muss also einen besseren Weg geben.

Auf den ersten Blick könnte man sich vorstellen, die Anmeldeinformationen als JSON-Zeichenfolge mit den Anforderungsparametern an den Server zu senden. Das würde aber bedeuten, dass die Authentifizierung in jeder Methode behandelt werden muss.

Die bessere Lösung besteht in der Verwendung der Standardauthentifizierung (Basic Auth), bei der die Anmeldeinformationen zwar bei jeder Anforderung gesendet werden müssen, jedoch als Header.

Ein Beispiel:

Authorization: Basic QWxhZGRpbjpPcGVuU2VzYW1l

Wenn du den letzten Teil der Zeichenfolge als codierte Zeichenfolge erkannt hast, bist du auf dem richtigen Weg. Wir haben Aladdin:OpenSesame (Benutzername:Passwort) als base64 verschlüsselt. Wenn keine Verschlüsselung vorhanden ist, dann wird das Passwort als Klartext übertragen.

Im Vergleich zu unserem Ansatz in früheren Artikeln wurden nur wenige Änderungen am Code vorgenommen.

Der folgende Code aktiviert die Standardauthentifizierung mit httpBasic() anstelle von formLogin(). Durch diese Angabe muss jetzt einen “Realm” definieren. Das ist im Grunde der Name des Bereichs, der geschützt werden soll.

Diese Realms ermöglichen die Partitionierung der geschützten Ressourcen auf einem Server in eine Reihe von Schutzbereichen, die jeweils ein eigenes Authentifizierungsschema und/oder eine eigene Berechtigungsdatenbank aufweisen. – RFC 2617

Die Implementierung sieht folgendermaßen aus:

@EnableWebSecurity
public class BasicSecurityConfiguration extends WebSecurityConfigurerAdapter {
  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http
    .authorizeRequests(authorizeRequests ->
        authorizeRequests
                .antMatchers("/board/*").hasAnyRole("MEMBER", "BOARD")
                .antMatchers("/members/*").hasRole("MEMBER")
                .antMatchers("/").permitAll()
    )
    .httpBasic().realmName("My org ream")
  }
  ...
}

Dieser Code ist dem, den wir im ersten Artikel verwendeten haben sehr ähnlich. Wenn Du dir nicht sicher bist, was dieser Code bedeutet, dann lies bitte zuerst den vorherigen Artikel.

Eine Sache noch. Um zu verhindern, dass Sitzungscookies im Browser gespeichert werden, ist es vorteilhaft, den Sitzungsaufbau für alle Anfragen grundsätzlich zu deaktivieren. Im Allgemeinen sind Sitzungen kompliziert und können Sicherheitsrisiken bergen, aber das ist eine Geschichte die man in einem weiteren Artikel behandeln könnte. Hier ist die vollständige Klasse, einschließlich der Sitzungsdeaktivierung:

@EnableWebSecurity
public class BasicSecurityConfiguration extends WebSecurityConfigurerAdapter {
  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http
        .authorizeRequests(authorizeRequests ->
            authorizeRequests
                    .antMatchers("/board/*").hasAnyRole("MEMBER", "BOARD")
                    .antMatchers("/members/*").hasRole("MEMBER")
                    .antMatchers("/").permitAll()
    )
        .httpBasic().realmName("My org ream")
        .and()
        .sessionManagement()
        .sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    }
    ...
}

Warum JWT?

Die bisherige Implementierung ist als grundlegender Authentifizierungsansatz für eine kleine Anwendung mit nur wenigen Endpunkten in Ordnung, insbesondere wenn der Backend-Server über ein SSL-Zertifikat verfügt. Im Allgemeinen wird es jedoch nicht empfohlen, das Kennwort mit jeder Anforderungen mitzusenden, da dies viele Benutzer beunruhigen würde. Außerdem können Browser auch Dinge tun, die man nicht erwartet (z. B. Caching). Wenn du die API für die Öffentlichkeit bereitstellen möchtest, sollte ein besserer Ansatz gewählt werden.

Wie wäre es beispielsweise, wenn man den Benutzernamen und das Kennwort nur einmal sendet und ein Token mit einer begrenzten Lebensdauer zurück erhält? Das Token könnte dann einfach in verschlüsselter Form gesendet werden und der Server kann anschließend überprüfen, ob dieses Token gültig ist. Selbst wenn jemand das Token stiehlt, kann er nicht viel damit anfangen, da es schnell verfällt.

Genau das macht JWT. Darüber hinaus können auch noch einige Metainformationen über den Benutzer weitergeben werden.

Und hier kommt das Beste: Da es sich bei einem JWT-Token um verschlüsselten Text handelt, sind keine komplexen OAuth- oder Fremdanbieter-Server erforderlich. Wir können alles in unserem eigenen Code realisieren und müssen das Token nicht einmal speichern.

JWT-Abhängigkeiten

Wir werden in diesem Artikel die Bibliothek jsonwebtoken.io verwenden. Mit Spring Security ist dies zwar auch möglich, da aber JWT im Vergleich zu den anderen Lösungen sehr einfach ist, werden wir es hier verwenden.

Beginnen wir mit dem Hinzufügen der erforderlichen Abhängigkeiten:

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.10.7</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.10.7</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.10.7</version>
    <scope>runtime</scope>
</dependency>

Ich habe einige Abhängigkeiten mit dem Gültigkeitsbereich “Runtime” hinzugefügt. Hier erfährst du mehr über die Maven-Gültigkeitsbereiche.

Kurz gesagt: der Code, den wir schreiben, darf den Runtime-Code nicht direkt verwenden. Trotzdem muss der Runtime-Code zur Laufzeit verfügbar sein, damit alles klappt.

JWT-Filter

Eine wichtige Sache, die du über Spring Security wissen solltest, ist die Verwendung von Servlet-Filtern. Dabei kannst du dir einen Filter vorstellen, wie man ihn in einer Kaffeemaschine findet. Man gießt Wasser darüber und der Filter modifiziert das Wasser (mischt es mit Kaffee) und gibt eine modifizierte Flüssigkeit aus: den Kaffee.

Das gleiche passiert mit Spring Filtern. Eine eingehende Webanforderung wird an einen Filter übergeben. Der Request wird geprüft, Sachen wie die Authentifizierung werden durchgeführt und schließlich wird die Anfrage weitergeleitet - oder abgelehnt. Ein Filter ist ein sehr allgemeiner Ansatz für ein bestimmtes Problem. Er ist also sehr flexibel.

Um JWT zu ermöglichen, müssen wir selbst zwei Filter implementieren, da meines Wissens nach keine verfügbaren Implementierungen vorhanden sind.

Der erste ist ein Authentifizierungsfilter und der zweite ist ein Autorisierungsfilter.

JWT Authentifizierungsfilter

In Spring Security findet man eine Klasse mit dem Namen UsernamePasswordAuthenticationFilter. Diese Klasse kann alles, was wir brauchen, deshalb können wir von ihr erben.

Die Minimalversion sollte so aussehen:

public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
        this.setAuthenticationManager(authenticationManager);
    }
}

Dieser Code legt im Grunde den Authentifizierungsmanager fest, der so konzipiert wurde, dass die Methode configure(AuthenticationManagerBuilder auth) überschrieben wird. Dabei handelt es sich um die Methode mit dem JDBC-Code, mit der eine Verbindung zur Datenbank hergestellt wird, um die Benutzerauthentifizierung durchzuführen.

Standardmäßig ist in der Klasse UsernamePasswordAuthenticationFilter das Feld authenticationManager private, daher müssen wir einen Setter hinzufügen.

Mit dem Aufruf

curl -i -X POST http://localhost:8080/login\?username\=emma\&password\=emma

gelangen wird zu der übergeordneten Klasse, die die Methode attemptAuthentication bereitstellt. Diese sieht wie folgt aus:

public Authentication attemptAuthentication(HttpServletRequest request,
        HttpServletResponse response) throws AuthenticationException {
    if (postOnly && !request.getMethod().equals("POST")) {
        throw new AuthenticationServiceException(
                "Authentication method not supported: " + request.getMethod());
    }

    String username = obtainUsername(request);
    String password = obtainPassword(request);

    if (username == null) {
        username = "";
    }

    if (password == null) {
        password = "";
    }

    username = username.trim();

    UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
            username, password);

    // Allow subclasses to set the "details" property
    setDetails(request, authRequest);

    return this.getAuthenticationManager().authenticate(authRequest);
}

Wie du siehst, erledigt diese Methode alle Aufgaben für uns. Wenn wir dieses Verhalten ändern möchten, müssen wir die Implementierung überschreiben. Zum Beispiel könnten wir die Methodensignatur von POST in GET ändern. Aber warum sollte man das tun? POST ist für Aktionen wie das Speichern von Daten und das Erstellen von Objekten vorgesehen. Wir wollen ein Token erstellen, somit ist POST die beste HTTP-Methode für diese Aufgabe.

Eine andere Möglichkeit wäre, die Authentifizierungs-URL zu ändern, die wir verwenden möchten. Dazu können wir unseren Code folgendermaßen ändern:

public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
        this.setAuthenticationManager(authenticationManager);
        setFilterProcessesUrl("/api/login");
    }
}

Die setFilterProcessesUrl-Methode wird verwendet, um eine von uns bevorzugte URL für den Authentifizierungsendpunkt festzulegen.

Das wars mit der Authentifizierung. Aber wir müssen jetzt noch ein Token erstellen und es zurückgeben. Dazu müssen wir noch eine Methode unserer Klasse überschreiben.

Zunächst werden wir einen Signaturschlüssel generieren. Auf StackOverflow habe ich eine einfache Möglichkeit gefunden, um mit HS512 einen solchen Schlüssel zu erstellen.

openssl rand -base64 172 | tr -d '\n'
yiw1z2XJKQ7VHI/ck49j/RUAWm1gmhJ6x0MavXEV2bvHIDNfxXI2s3nCXfD58YYXZW9KYo/OkJmSunGhpJTA4nK53FxVcACt+kf6NhG6VA40gaUGOSnGupPtv8hhLGnKRD9BIjvbhFrMjIkyL4/WGyFObglcnmrxT12z5Cl4Zr6zKKFUfX6W2XXj7VZxGvrXS4vSNWWkBPP117V4+0yiq7/HgjJNAGAL7NrDVg==

Jetzt speichern wir diesen Schlüssel und einige andere Werte als application.properties:

jwt.secret=yiw1z2XJKQ7VHI...
jwt.issuer=Grobmeier Solutions GmbH
jwt.type=JWT
jwt.audience=MyApp

Genial. Weißt du, was? Wir werden das Filterobjekt selbst erstellen und nicht durch Spring, also müssen wir diese Werte zum Konstruktor hinzufügen.

So sollte es aussehen, wenn es fertig ist:


public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    private String jwtAudience;
    private String jwtIssuer;
    private String jwtSecret;
    private String jwtType;

    public JwtAuthenticationFilter(AuthenticationManager authenticationManager,
        String jwtAudience, String jwtIssuer,
        String jwtSecret, String jwtType) {
        this.jwtAudience = jwtAudience;
        this.jwtIssuer = jwtIssuer;
        this.jwtSecret = jwtSecret;
        this.jwtType = jwtType;
        this.setAuthenticationManager(authenticationManager);
        setFilterProcessesUrl("/api/login");
    }

Es ist wahr, das sieht nicht sehr schön aus. Als Nächstes fügen wir den Code für die eigentliche Token-Generierung hinzu:

@Override
protected void successfulAuthentication(
        HttpServletRequest request, HttpServletResponse response,
        FilterChain filterChain, Authentication authentication) {
    User user = (User)authentication.getPrincipal();
    SecretKey secretKey = Keys.hmacShaKeyFor(jwtSecret.getBytes());
    String token = Jwts.builder()
            .signWith(secretKey, SignatureAlgorithm.HS512)
            .setHeaderParam("typ", jwtType)
            .setIssuer(jwtIssuer)
            .setAudience(jwtAudience)
            .setSubject(user.getUsername())
            .setExpiration(new Date(System.currentTimeMillis() + 864000000))
            .compact();

    response.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
}

Ist dir klar, was wir hier genau gemacht haben?

Wir beginnen mit dem Abrufen des Hauptobjekts, das den authentifizierten Benutzer enthält. Dann erhalten wir den geheimen Schlüssel aus unserer geheimen JWT-Schlüsselzeichenfolge und generieren das JWT-Token. Anschließend fügen wir den Signaturschlüssel, einige Header und den Issuer hinzu.

Am wichtigsten ist das Verfallsdatum. Die Empfehlung ist, es sehr kurz zu machen, so dass die Authentifizierung wiederholt in kurzen Intervallen stattfindet. Der Einfachheit halber habe ich diesen Ratschlag hier nicht befolgt, aber du solltest das auf jeden Fall tun.

Schließlich gibt der Aufruf von compact() ein Zeichenfolgentoken zurück. Wir können das Token mit den eigenen HttpHeaders-Konstanten von Spring zur Antwort hinzufügen. Vergiss aber nicht, den Präfix “Bearer” voranzustellen.

Das alles muss nun zur Sicherheitskonfigurationsklasse hinzugefügt werden. Außerdem müssen wir die Konfigurationswerte mit @Value lesen.

Die Klasse ändert sich dementsprechend wie folgt:

@EnableWebSecurity
public class JwtSecurityConfiguration extends WebSecurityConfigurerAdapter {
    @Value("${jwt.secret}")
    private String jwtSecret;
    @Value("${jwt.issuer}")
    private String jwtIssuer;
    @Value("${jwt.type}")
    private String jwtType;
    @Value("${jwt.audience}")
    private String jwtAudience;

    ...

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.cors().and().csrf().disable()
                .addFilter(new JwtAuthenticationFilter(authenticationManager(), jwtAudience, jwtIssuer, jwtSecret, jwtType))
                .authorizeRequests(authorizeRequests ->
                        authorizeRequests
                                .antMatchers("/board/**").hasAnyRole("MEMBER", "BOARD")
                                .antMatchers("/members/**").hasRole("MEMBER")
                                .anyRequest().authenticated()
                )
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    }
    ...

Versuchen wir es jetzt noch einmal:

curl -i -X POST http://localhost:8080/login\?username\=emma\&password\=emma

Wenn die Authentifizierung erfolgreich ist, sollten wir eine Antwort wie diese erhalten:

HTTP/1.1 200
Vary: Origin
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJzZWN1cmUtYXBpIiwiYXVkIjoic2VjdXJlLWFwcCIsInN1YiI6ImEiLCJleHAiOjE1NzI3ODk5MDZ9.L1mvztlVEHh7l5s6AtFHDXHUSIk0-fE4Ded7XZvtgwYdQl3Ej5uLkwUnClk4hcCjh840ajJqrwK1YJFC10RmXy
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
...

Die lange Zeichenfolge nach dem “Bearer”-Schlüsselwort wird für alle weiteren Aufrufe der API benötigt. Du kannst diese Zeichenfolge auf einer Website wie jsonwebtoken.io dekodieren und dir den Inhalt anzeigen lassen.

Folgendes ist noch zu beachten: Einige Personen fügen zusätzliche Informationen hinzu, wie z. B. Rolleninformationen. Dies ist insbesondere dann nicht zu empfehlen, wenn Rollen hinzufügt werden, die erst geändert werden können, nachdem der Benutzer ein neues Token erstellt hat. Es kann dann zu einem nicht autorisierten Zugriff führen, selbst dann, wenn die Rollen auf dem Server bereits entfernt wurden.

Abgesehen davon können wir uns jetzt erfolgreich authentifizieren.

JWT Autorisierungsfilter

Der nächste zu erstellende Filter dient der Autorisierung aller Anfragen. Es gibt einen “OncePerRequestFilter”, den man verwenden könnte, aber ich habe mich dafür entschieden, stattdessen den BasicAuthenticationFilter zu überschreiben. Das ist sehr einfach und leicht zu verstehen. Auf geht’s.

Der folgende Code funktioniert fast genauso wie der vorherige. Ich habe noch eine weitere Sache hinzugefügt, nämlich das JdbcTemplate, mit der ich die Rollen aus der Datenbank ausgewählt habe.

public class JwtAuthorizationFilter extends BasicAuthenticationFilter {

    private String jwtSecret;
    private String jwtIssuer;
    private String jwtType;
    private String jwtAudience;

    public JwtAuthorizationFilter(AuthenticationManager authenticationManager, JdbcTemplate jdbcTemplate,
                                   String jwtAudience, String jwtIssuer, String jwtSecret, String jwtType) {
        super(authenticationManager);
        this.jdbcTemplate = jdbcTemplate;
        this.jwtAudience = jwtAudience;
        this.jwtIssuer = jwtIssuer;
        this.jwtSecret = jwtSecret;
        this.jwtType = jwtType;
    }

Als nächstes müssen wir noch Code hinzufügen, um ein UsernamePasswordAuthenticationToken aus dem JWT-Token zu erstellen.

private UsernamePasswordAuthenticationToken parseToken(HttpServletRequest request) {
    String token = request.getHeader(HttpHeaders.AUTHORIZATION);
    if (token != null && token.startsWith("Bearer ")) {
        String claims = token.replace("Bearer ", "");
        try {
            Jws<Claims> claimsJws = Jwts.parser()
                    .setSigningKey(jwtSecret.getBytes())
                    .parseClaimsJws(claims);

            String username = claimsJws.getBody().getSubject();

            if ("".equals(username) || username == null) {
                return null;
            }

            // TODO roles here!

            return new UsernamePasswordAuthenticationToken(username, null, null);
        } catch (JwtException exception) {
            log.warn("Some exception : {} failed : {}", token, exception.getMessage());
        }
    }

    return null;
}

Die Idee ist, den Benutzernamen mit einem JWTS-Parser zu lesen. Die “Claims” sind Teile der Nutzlast. Diese enthalten ein “Subject”, bei dem es sich um den Benutzernamen handelt. Das UsernamePasswordAuthenticationToken sollte außerdem auch Zugriff auf einige Rollen haben.

Ich wähle diese Rollen mit einer Anweisung wie der folgenden aus, in dem ich das TODO in meinen obigen Code durch folgenden Code ersetze:

List<SimpleGrantedAuthority> authorities = jdbcTemplate.queryForList(
    "SELECT a.authority " +
    "FROM user_authorities a, users u " +
    "WHERE u.username = ? " +
    "AND u.id = a.user_id", String.class, username)
    .stream()
    .map(SimpleGrantedAuthority::new)
    .collect(Collectors.toList());

Diese Abfrage gibt eine Liste von Berechtigungen zurück und ordnet das Ergebnis der SimpleGrantedAuthority zu. Daraufhin erstellen wir eine Liste und geben die Berechtigungen an das neue Token weiter:

return new UsernamePasswordAuthenticationToken(username, null, authorities);

Schlussendlich muss noch die Methode doFilterInternal überschrieben werden.

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                FilterChain filterChain) throws IOException, ServletException {
    UsernamePasswordAuthenticationToken authentication = parseToken(request);

    if (authentication != null) {
        SecurityContextHolder.getContext().setAuthentication(authentication);
    } else {
        SecurityContextHolder.clearContext();
    }

    filterChain.doFilter(request, response);
}

Das ist die grundlegende Implementierung. Die, die sich in BasicAuthenticationFilter befindet, ist jedoch interessanter. Bevor Du diesen Code ausprobierst, solltest Du dir auch den BasicAuthenticationFilter ansehen.

Der obige Code analysiert ein Token. Wenn die Authentifizierung gültig ist, wird der Authentifizierungskontext festgelegt. Das bedeutet, dass der Benutzer somit authentifiziert ist. Ansonsten wird alles gelöscht, genau wie bei der Elternklasse.

Jetzt können wir einfach Spring Security seine Arbeit erledigen lassen.

Fazit

Um zu verstehen, dass Filter die Authentifizierungsaufgabe ausführen, muss man Spring Security als Ganzes verstehen. Wenn du die Filter richtig verstehst, kannst du jeden gewünschten Authentifizierungs- oder Autorisierungsmechanismus implementieren. Spring Security ist im Allgemeinen recht flexibel. Es kann nicht jeden Anwendungsfall lösen, aber die meisten können damit gelöst werden.

Obwohl dieser Blogeintrag ziemlich lang ist, hoffen wir, dass du dieses Trio von Artikeln interessant fandest. Das Verstehen von Spring und Spring Security kann für Anfänger eine ziemliche Herausforderung sein, ein Beitrag, der alle wichtigen Bereiche abdeckt, ist deshalb sehr hilfreich.

Wenn Du Verbesserungsvorschläge für diesen Post hast, die einem anderen Leser weiterhelfen könnten, dann lass es mich bitte wissen. Ich würde das sehr begrüßen!

Image Credits

Tags: #Java #Spring #Sprint Security #Login #JWT #Basic Auth

Newsletter

ABMELDEN