[JAVA] Implementieren Sie eine einfache Rest-API mit Spring Security & JWT mit Spring Boot 2.0

Überblick

Dieser Artikel ist die Demoanwendung, die im vorherigen Beitrag "Implementieren einer einfachen Rest-API mit Spring Security mit Spring Boot 2.0.1" verwendet wurde. Ist ein Artikel, der die Änderungen erklärt, wenn JWT (Json Web Token) kompatibel gemacht wird. Es gibt viele detaillierte Artikel zu den JWT-Spezifikationen, daher werde ich sie hier nicht behandeln.

Den Quellcode finden Sie unter rubytomato / demo-security-jwt-spring2.

Umgebung

Referenz

Anforderungen an die Demo-Anwendung

So authentifizieren Sie sich

Diese Demo-Anwendung authentifiziert sich mit Ihrer E-Mail-Adresse und Ihrem Passwort. Insbesondere wird eine POST-Anforderung für eine E-Mail-Adresse und ein Kennwort an die Anmelde-API gesendet. Wenn eine Authentifizierung möglich ist, werden ein HTTP-Status 200 und ein JWT-Token zurückgegeben. Die anschließende Authentifizierung und Autorisierung erfolgt mit dem im Anforderungsheader festgelegten JWT-Token.

Sicherheitsimplementierung (wurde aufgrund der JWT-Unterstützung geändert)

Abhängigkeit hinzufügen

Ich habe Java-JWT aus den Bibliotheken ausgewählt, die mehrere JWTs verarbeiten.

<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>3.3.0</version>
</dependency>

Einfach zu bedienendes Java-JWT

Über Token-Nutzdaten

Einige Token-Payload- (Anspruchs-) Kennungen sind registrierte Anspruchsnamen. Beide werden als optional behandelt. Ob sie festgelegt werden sollen oder nicht, hängt also von der zu verwendenden Anwendung ab.

id name description
jti JWT ID JWT eindeutige Kennung
aud Audience JWT-Benutzer
iss Issuer Herausgeber von JWT
sub Subject Hauptteil von JWT.Eindeutiger oder global eindeutiger Wert im Kontext des JWT-Emittenten
iat Issued At JWT-Ausstellungszeit
nbf Not Before Startzeit des JWT-Gültigkeitszeitraums.Vor dieser Zeit nicht verfügbar
exp Expiration Time Endzeit der JWT-Gültigkeitsdauer.Nach dieser Zeit nicht mehr verfügbar

Token-Generierung

private static final Long EXPIRATION_TIME = 1000L * 60L * 10L;

public void build() {
     String secretKey = "secret";
     Date issuedAt = new Date(); 
     Date notBefore = new Date(issuedAt.getTime());
     Date expiresAt = new Date(issuedAt.getTime() + EXPIRATION_TIME);

     try {
         Algorithm algorithm = Algorithm.HMAC256(secretKey);
         String token = JWT.create()
             // registered claims
             //.withJWTId("jwtId")        //"jti" : JWT ID
             //.withAudience("audience")  //"aud" : Audience
             //.withIssuer("issuer")      //"iss" : Issuer
             .withSubject("test")         //"sub" : Subject
             .withIssuedAt(issuedAt)      //"iat" : Issued At
             .withNotBefore(notBefore)    //"nbf" : Not Before
             .withExpiresAt(expiresAt)    //"exp" : Expiration Time
             //private claims
             .withClaim("X-AUTHORITIES", "aaa")
             .withClaim("X-USERNAME", "bbb")
             .sign(algorithm);
         System.out.println("generate token : " + token);
     } catch (UnsupportedEncodingException e) {
         e.printStackTrace();
     }
}

Die durch diese Einstellung generierten Token lauten wie folgt.

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0IiwibmJmIjoxNTIzNTA2NzQwLCJYLUFVVEhPUklUSUVTIjoiYWFhIiwiZXhwIjoxNTIzNTA3MzQwLCJpYXQiOjE1MjM1MDY3NDAsIlgtVVNFUk5BTUUiOiJiYmIifQ.KLwUQcuNEt7m1HAC6ZzzGtRjZ3a2kvY11732aP9dyDY

Sie können Token auf der Site JSON Web Tokens --jwt.io dekodieren.

d.png

Token-Überprüfung

public void verify() {
    String secretKey = "secret";
    String token = "";

    try {
        Algorithm algorithm = Algorithm.HMAC256(secretKey);
        JWTVerifier verifier = JWT.require(algorithm).build();

        DecodedJWT jwt = verifier.verify(token);

        // registered claims
        String subject = jwt.getSubject();
        Date issuedAt = jwt.getIssuedAt();
        Date notBefore = jwt.getNotBefore();
        Date expiresAt = jwt.getExpiresAt();
        System.out.println("subject : [" + subject + "] issuedAt : [" + issuedAt.toString() + "] notBefore : [" + notBefore.toString() + "] expiresAt : [" + expiresAt.toString() + "]");
        // subject : [test] issuedAt : [Thu Apr 12 13:19:00 JST 2018] notBefore : [Thu Apr 12 13:19:00 JST 2018] expiresAt : [Thu Apr 12 13:29:00 JST 2018]

        // private claims
        String authorities = jwt.getClaim("X-AUTHORITIES").asString();
        String username = jwt.getClaim("X-USERNAME").asString();
        System.out.println("private claim  X-AUTHORITIES : [" + authorities + "] X-USERNAME : [" + username + "]");
        // private claim  X-AUTHORITIES : [aaa] X-USERNAME : [bbb]

    } catch (UnsupportedEncodingException | JWTVerificationException e) {
        e.printStackTrace();
    }
}

Ausnahmen, die während der Überprüfung auftreten können

Die Ausnahmen, die nicht erschöpfend sind, aber wahrscheinlich auftreten (anfällig für das Auftreten), sind: Beide Ausnahmen sind Unterklassen von JWTVerificationException.

exception description
SignatureVerificationException Wenn der geheime Schlüssel anders ist usw.
AlgorithmMismatchException Wenn der Signaturalgorithmus anders ist usw.
JWTDecodeException Zum Beispiel, wenn das Token manipuliert wurde
TokenExpiredException Wenn das Token abgelaufen ist
InvalidClaimException Bevor Sie mit der Verwendung von Token usw. beginnen.

Spring Security-Konfiguration

Zeigt die geänderten Teile für JWT an.

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserRepository userRepository;

    // ★1
    @Value("${security.secret-key:secret}")
    private String secretKey = "secret";

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // @formatter:off
        http
            // AUTHORIZE
            .authorizeRequests()
                .mvcMatchers("/hello/**")
                    .permitAll()
                .mvcMatchers("/user/**")
                    .hasRole("USER")
                .mvcMatchers("/admin/**")
                    .hasRole("ADMIN")
                .anyRequest()
                    .authenticated()
            .and()
            // EXCEPTION
            .exceptionHandling()
                .authenticationEntryPoint(authenticationEntryPoint())
                .accessDeniedHandler(accessDeniedHandler())
            .and()
            // LOGIN
            .formLogin()
                .loginProcessingUrl("/login").permitAll()
                    .usernameParameter("email")
                    .passwordParameter("pass")
                .successHandler(authenticationSuccessHandler())
                .failureHandler(authenticationFailureHandler())
            .and()
            // ★2 LOGOUT
            .logout()
                .logoutUrl("/logout")
                .logoutSuccessHandler(logoutSuccessHandler())
            .and()
            // ★3 CSRF
            .csrf()
                .disable()
            // ★4 AUTHORIZE
            .addFilterBefore(tokenFilter(), UsernamePasswordAuthenticationFilter.class)
            // ★5 SESSION
            .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            ;
        // @formatter:on
    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth.eraseCredentials(true)
                .userDetailsService(simpleUserDetailsService())
                .passwordEncoder(passwordEncoder());
    }

    @Bean("simpleUserDetailsService")
    UserDetailsService simpleUserDetailsService() {
        return new SimpleUserDetailsService(userRepository);
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    GenericFilterBean tokenFilter() {
        return new SimpleTokenFilter(userRepository, secretKey);
    }

    AuthenticationEntryPoint authenticationEntryPoint() {
        return new SimpleAuthenticationEntryPoint();
    }

    AccessDeniedHandler accessDeniedHandler() {
        return new SimpleAccessDeniedHandler();
    }

    AuthenticationSuccessHandler authenticationSuccessHandler() {
        return new SimpleAuthenticationSuccessHandler(secretKey);
    }

    AuthenticationFailureHandler authenticationFailureHandler() {
        return new SimpleAuthenticationFailureHandler();
    }

    LogoutSuccessHandler logoutSuccessHandler() {
        return new HttpStatusReturningLogoutSuccessHandler();
    }

}

Authentifizierung und Verarbeitung bei Erfolg / Misserfolg

Die Einstellungen für die Authentifizierung wurden nicht geändert.

configuration


.formLogin()
    .loginProcessingUrl("/login").permitAll()
        .usernameParameter("email")
        .passwordParameter("pass")
    .successHandler(authenticationSuccessHandler())
    .failureHandler(authenticationFailureHandler())

successHandler()

Vor der Änderung war es ein Handler, der nur den HTTP-Status 200 zurückgegeben hat. Er wurde jedoch geändert, um aus den Anmeldeinformationen ein Token zu generieren und es im Antwortheader festzulegen.

AuthenticationSuccessHandler authenticationSuccessHandler() {
    return new SimpleAuthenticationSuccessHandler(secretKey);
}

Da die in der Token-Nutzlast (Anspruch) festgelegten Informationen von jedem dekodiert werden können, wählen Sie die Informationen aus, die nach außen offengelegt werden können. In diesem Beispiel wird die Benutzer-ID im Betreff festgelegt, und zum Zeitpunkt der Autorisierung wird die Benutzer-ID vom Token erfasst und der Benutzer durchsucht.

String token = JWT.create()
        .withIssuedAt(issuedAt)        //JWT-Ausstellungszeit
        .withNotBefore(notBefore)      //Startzeit für JWT-Ablauf
        .withExpiresAt(expiresAt)      //Endzeit des JWT-Ablaufdatums
        .withSubject(loginUser.getUser().getId().toString()) //Eindeutiger oder global eindeutiger Wert im Kontext der JWT-Entität, des JWT-Emittenten
        .sign(this.algorithm);

Die generateToken-Methode generiert ein Token aus den Anmeldeinformationen, und die setToken-Methode legt das Token im Authorization-Header fest.

SimpleAuthenticationSuccessHandler


@Slf4j
public class SimpleAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    final private Algorithm algorithm;

    public SimpleAuthenticationSuccessHandler(String secretKey) {
        Objects.requireNonNull(secretKey, "secret key must be not null");
        try {
            this.algorithm = Algorithm.HMAC256(secretKey);
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
                                        HttpServletResponse response,
                                        Authentication auth) throws IOException, ServletException {
        if (response.isCommitted()) {
            log.info("Response has already been committed.");
            return;
        }
        setToken(response, generateToken(auth));
        response.setStatus(HttpStatus.OK.value());
        clearAuthenticationAttributes(request);
    }

    private static final Long EXPIRATION_TIME = 1000L * 60L * 10L;

    private String generateToken(Authentication auth) {
        SimpleLoginUser loginUser = (SimpleLoginUser) auth.getPrincipal();
        Date issuedAt = new Date();
        Date notBefore = new Date(issuedAt.getTime());
        Date expiresAt = new Date(issuedAt.getTime() + EXPIRATION_TIME);
        String token = JWT.create()
                .withIssuedAt(issuedAt)
                .withNotBefore(notBefore)
                .withExpiresAt(expiresAt)
                .withSubject(loginUser.getUser().getId().toString())
                .sign(this.algorithm);
        log.debug("generate token : {}", token);
        return token;
    }

    private void setToken(HttpServletResponse response, String token) {
        response.setHeader("Authorization", String.format("Bearer %s", token));
    }

    /**
     * Removes temporary authentication-related data which may have been stored in the
     * session during the authentication process.
     */
    private void clearAuthenticationAttributes(HttpServletRequest request) {
        HttpSession session = request.getSession(false);
        if (session == null) {
            return;
        }
        session.removeAttribute(WebAttributes.AUTHENTICATION_EXCEPTION);
    }

}

Token-Überprüfung und Autorisierung

Wir haben einen neuen Filter implementiert, der Token validiert und autorisiert. Dieser Filter soll vor dem Filter ausgeführt werden, der sich durch Benutzername / Passwort (UsernamePasswordAuthenticationFilter) authentifiziert.

configuration


.addFilterBefore(tokenFilter(), UsernamePasswordAuthenticationFilter.class)

SimpleTokenFilter

GenericFilterBean tokenFilter() {
    return new SimpleTokenFilter(userRepository, secretKey);
}

Beziehen und validieren Sie das Token aus dem Anforderungsheader "Authorization". Bei der Überprüfung wird nicht nur geprüft, ob das Token manipuliert wurde, sondern auch das festgelegte Ablaufdatum. Wenn das Token normal ist, wird die Benutzer-ID zum Durchsuchen der Benutzerinformationen von der Nutzlast abgerufen und der Benutzer wird durchsucht.

SimpleTokenFilter


@Slf4j
public class SimpleTokenFilter extends GenericFilterBean {

    final private UserRepository userRepository;
    final private Algorithm algorithm;

    public SimpleTokenFilter(UserRepository userRepository, String secretKey) {
        Objects.requireNonNull(secretKey, "secret key must be not null");
        this.userRepository = userRepository;
        try {
            this.algorithm = Algorithm.HMAC256(secretKey);
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
        String token = resolveToken(request);
        if (token == null) {
            filterChain.doFilter(request, response);
            return;
        }

        try {
            authentication(verifyToken(token));
        } catch (JWTVerificationException e) {
            log.error("verify token error", e);
            SecurityContextHolder.clearContext();
            ((HttpServletResponse) response).sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
        }
        filterChain.doFilter(request, response);
    }

    private String resolveToken(ServletRequest request) {
        String token = ((HttpServletRequest) request).getHeader("Authorization");
        if (token == null || !token.startsWith("Bearer ")) {
            return null;
        }
        return token.substring(7);
    }

    private DecodedJWT verifyToken(String token) {
        JWTVerifier verifier = JWT.require(algorithm).build();
        return verifier.verify(token);
    }

    private void authentication(DecodedJWT jwt) {
        Long userId = Long.valueOf(jwt.getSubject());
        userRepository.findById(userId).ifPresent(user -> {
            SimpleLoginUser simpleLoginUser = new SimpleLoginUser(user);
            SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(simpleLoginUser, null, simpleLoginUser.getAuthorities()));
        });
    }

}

Verarbeitung beim Abmelden

Beim Abmelden werden nur die URL und der Handler festgelegt. Ich wollte das zum Zeitpunkt der Abmeldung ausgestellte Token ungültig machen, aber soweit ich es überprüft habe, scheint es, dass das Token nicht ungültig gemacht werden kann, sodass der Handler nur den HTTP-Status zurückgibt.

configuration


.logout()
    .logoutUrl("/logout")
    //.invalidateHttpSession(true)
    //.deleteCookies("JSESSIONID")
    .logoutSuccessHandler(logoutSuccessHandler())

Token-Ungültigmachung

Wenn es um die Ungültigmachung auf der Serverseite geht, scheint es eine Methode zu geben, das Token des abgemeldeten Benutzers für einen bestimmten Zeitraum (bis zum Ablaufdatum des Tokens) zu halten und nicht zu autorisieren, wenn sie während des Autorisierungsprozesses übereinstimmen. ..

CSRF

Ich benutze kein CSRF, also habe ich es deaktiviert.

configuration


.csrf()
    .disable()
    //.ignoringAntMatchers("/login")
    //.csrfTokenRepository(new CookieCsrfTokenRepository())

SessionManagement

Ich habe es auf zustandslos gesetzt, weil es keine Sitzungen verwaltet. Mit dieser Einstellung verwendet Spring Security kein HttpSession.

configuration


.sessionManagement()
    .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

API-Funktionsprüfung

Durch den Wechsel zu JWT sind weder Sitzungscookies noch CSRF-Token erforderlich. Legen Sie stattdessen das nach der Authentifizierung empfangene JWT-Token im Anforderungsheader fest.

Login-API

** Für ein gültiges Konto **

Wenn die Authentifizierung erfolgreich ist, wird das Token im Autorisierungsheader festgelegt, wie im folgenden Beispiel gezeigt.

> curl -i -X POST "http://localhost:9000/app/login" -d "[email protected]" -d "pass=iWKw06pvj"

HTTP/1.1 200
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIiwibmJmIjoxNTIzNTExNjY5LCJleHAiOjE1MjM1MTIyNjksImlhdCI6MTUyMzUxMTY2OX0.E6HZShowNPUvNj84dYRHMyZROxIvYjsEP7e29
_QLXic
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Length: 0
Date: Thu, 12 Apr 2018 05:41:09 GMT

** Für ungültiges Konto **

Falsche E-Mail-Adresse, falsches Passwort usw.

> curl -i -X POST "http://localhost:9000/app/login" -d "[email protected]" -d "pass=hogehoge"

HTTP/1.1 401

API, auf die authentifizierte Benutzer zugreifen können

** Wenn Sie ein gültiges Token angeben **

> curl -i -H "Authorization: Bearer {valid_token}" "http://localhost:9000/app/memo/1"

HTTP/1.1 200

** Wenn kein Token angegeben ist **

> curl -i "http://localhost:9000/app/memo/1"

HTTP/1.1 401

** Wenn der Token manipuliert wird **

> curl -i -H "Authorization: Bearer {invalid_token}" "http://localhost:9000/app/memo/1"

HTTP/1.1 401

** Wenn der Token abgelaufen ist **

> curl -i -H "Authorization: Bearer {invalid_token}" "http://localhost:9000/app/memo/1"

HTTP/1.1 401

APIs, die Authentifizierung und USER-Rollen erfordern

** Für Benutzer mit der Rolle USER **

> curl -i -H "Authorization: Bearer {valid_token}" "http://localhost:9000/app/user"

HTTP/1.1 200

APIs, die eine Authentifizierung und eine ADMIN-Rolle erfordern

** Für Benutzer mit der ADMIN-Rolle **

> curl -i -H "Authorization: Bearer {valid_token}" "http://localhost:9000/app/admin"

HTTP/1.1 200

** Für Benutzer, die nicht die ADMIN-Rolle haben **

Wenn das Token erfolgreich ist, der Benutzer jedoch nicht über die erforderliche Rolle verfügt, tritt ein Fehler auf.

> curl -i -H "Authorization: Bearer {valid_token}" "http://localhost:9000/app/admin"

HTTP/1.1 403

Recommended Posts

Implementieren Sie eine einfache Rest-API mit Spring Security & JWT mit Spring Boot 2.0
Implementieren Sie eine einfache Rest-API mit Spring Security mit Spring Boot 2.0
Implementieren Sie einen einfachen Web-REST-API-Server mit Spring Boot + MySQL
Erstellen Sie mit Spring Security 2.1 eine einfache Demo-Site mit Spring Security
Implementieren Sie die REST-API mit Spring Boot
Implementieren Sie die REST-API mit Spring Boot und JPA (Infrastructure Layer).
Erstellen wir eine einfache API mit EC2 + RDS + Spring Boot ①
Implementieren Sie die REST-API mit Spring Boot und JPA (Domain Layer Edition).
Ich habe ein einfaches Suchformular mit Spring Boot + GitHub Search API erstellt.
Erstellen Sie mit Spring Boot eine einfache Such-App
Erstellen Sie einen Web-API-Server mit Spring Boot
Hallo Welt (REST API) mit Apache Camel + Spring Boot 2
[Spring Boot] Benutzerinformationen mit Rest API abrufen (Anfänger)
Passen Sie die Antwort auf REST-API-Fehler mit Spring Boot an (Teil 2).
Ein Memorandum beim Erstellen eines REST-Service mit Spring Boot
Passen Sie die Antwort auf REST-API-Fehler mit Spring Boot an (Teil 1).
Feder mit Kotorin --4 REST API Design
[Anfänger] Versuchen Sie, die REST-API für die Todo-App mit Spring Boot zu schreiben
Erstellen Sie mit Spring Batch eine einfache On-Demand-Charge
Implementieren Sie CRUD mit Spring Boot + Thymeleaf + MySQL
Implementieren Sie die Paging-Funktion mit Spring Boot + Thymeleaf
Erreichen Sie die BASIC-Authentifizierung mit Spring Boot + Spring Security
Erstellen Sie eine Website mit Spring Boot + Gradle (jdk1.8.x)
Hash-Passwörter mit Spring Boot + Spring Security (mit Salt, mit Stretching)
Versuchen Sie die LDAP-Authentifizierung mit Spring Security (Spring Boot) + OpenLDAP
Versuchen Sie, die Anmeldefunktion mit Spring Boot zu implementieren
[Einführung in Spring Boot] Authentifizierungsfunktion mit Spring Security
Erstellen Sie mit Docker eine Spring Boot-Entwicklungsumgebung
Erstellen Sie mit Spring Boot 2.0 einen Spring Cloud Config Server mit Sicherheit
Mit Spring Boot herunterladen
Ordnen Sie DTO automatisch Entitäten mit der Spring Boot-API zu
Verwendungshinweis zu Spring Security: Zusammenarbeit mit Spring MVC und Boot
[JUnit 5-kompatibel] Schreiben Sie einen Test mit JUnit 5 mit Spring Boot 2.2, 2.3
Führen Sie swagger-ui in die in Spring Boot implementierte REST-API ein
Generieren Sie mit Spring Boot einen Barcode
Hallo Welt mit Spring Boot
Behandeln Sie die Java 8-Datums- und Uhrzeit-API mit Thymeleaf mit Spring Boot
Beginnen Sie mit Spring Boot
Hallo Welt mit Spring Boot!
Eine Geschichte voller Grundlagen von Spring Boot (gelöst)
Führen Sie LIFF mit Spring Boot aus
SNS-Login mit Spring Boot
Anmeldefunktion mit Spring Security
Spring Boot beginnend mit Docker
Hallo Welt mit Spring Boot
Setzen Sie Cookies mit Spring Boot
REST-API-Test mit REST Assured
Verwenden Sie Spring JDBC mit Spring Boot
Hash beim Spring-Boot das Passwort und verwenden Sie die Mitgliederregistrierung und die Spring-Sicherheit, um die Anmeldefunktion zu implementieren.
Modul mit Spring Boot hinzufügen
Erste Schritte mit Spring Boot
API mit Spring + Vue.js verknüpfen
Versuchen Sie es mit Spring Boot Security
Erstellen Sie mit Spring Boot einen Mikrodienst
Mail mit Spring Boot verschicken
Lassen Sie uns herausfinden, wie Sie mit Request Body mit der REST-API von Spring Boot empfangen können
Ich habe mit Spring Boot ein einfaches MVC-Beispielsystem erstellt
Erstellen wir eine Buchverwaltungs-Webanwendung mit Spring Boot part1
Beim Testen der Formularauthentifizierung mit Spring Security tritt ein 404-Fehler auf
Ich habe versucht, einen OAuth-Client mit Spring Boot / Security (LINE-Anmeldung) zu implementieren.
Lassen Sie uns mit Spring Boot part3 eine Webanwendung für die Buchverwaltung erstellen