diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 7fa71f7..8cedd4b 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -12,10 +12,10 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Set up JDK 17 + - name: Set up JDK 25 uses: actions/setup-java@v5 with: - java-version: '17' + java-version: '25' distribution: 'temurin' cache: maven - name: Build with Maven diff --git a/pom.xml b/pom.xml index 5541aec..9a57443 100644 --- a/pom.xml +++ b/pom.xml @@ -11,15 +11,15 @@ org.springframework.boot spring-boot-starter-parent - 3.5.7 + 4.0.3 - 23 - 23 + 25 + 25 UTF-8 - 24.9.4 + 25.0.5 test @@ -38,6 +38,11 @@ org.springframework.boot spring-boot-starter-data-jpa + + org.springframework.boot + spring-boot-starter-data-jpa-test + test + org.springframework.boot spring-boot-starter-web @@ -46,6 +51,11 @@ com.vaadin vaadin-spring-boot-starter + + com.vaadin + vaadin-dev + true + org.springframework.boot spring-boot-starter-oauth2-client diff --git a/src/main/frontend/themes/my-theme/styles.css b/src/main/frontend/themes/my-theme/styles.css index 7402663..f1ec79f 100644 --- a/src/main/frontend/themes/my-theme/styles.css +++ b/src/main/frontend/themes/my-theme/styles.css @@ -4,6 +4,8 @@ Visit https://vaadin.com/docs/styling/application-theme/ for more information. */ +@import url("@vaadin/lumo-styles/lumo.css"); + /* Example: CSS class name to center align the content . */ .centered-content { margin: 0 auto; diff --git a/src/main/frontend/themes/my-theme/theme.json b/src/main/frontend/themes/my-theme/theme.json deleted file mode 100644 index b007ffd..0000000 --- a/src/main/frontend/themes/my-theme/theme.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "lumoImports" : [ "typography", "color", "spacing", "badge", "utility" ] -} diff --git a/src/main/java/org/kickerelo/kickerelo/KickerEloApplication.java b/src/main/java/org/kickerelo/kickerelo/KickerEloApplication.java index d9ce578..55a0ad1 100644 --- a/src/main/java/org/kickerelo/kickerelo/KickerEloApplication.java +++ b/src/main/java/org/kickerelo/kickerelo/KickerEloApplication.java @@ -1,19 +1,20 @@ package org.kickerelo.kickerelo; +import com.vaadin.flow.component.dependency.StyleSheet; +import com.vaadin.flow.theme.lumo.Lumo; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.boot.persistence.autoconfigure.EntityScan; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import com.vaadin.flow.component.page.AppShellConfigurator; import com.vaadin.flow.server.PWA; -import com.vaadin.flow.theme.Theme; @SpringBootApplication @EntityScan(basePackages = "org.kickerelo.kickerelo.data") @EnableJpaRepositories -@Theme("my-theme") +@StyleSheet(Lumo.UTILITY_STYLESHEET) @PWA(name = "Tischkicker-Elo", shortName = "KickerElo", description = "Erlaubt Hinzufügen von Spielständen und Tracking der Spieler Elos für 1 vs 1 und 2 vs 2") public class KickerEloApplication implements AppShellConfigurator { diff --git a/src/main/java/org/kickerelo/kickerelo/config/SecurityConfiguration.java b/src/main/java/org/kickerelo/kickerelo/config/SecurityConfiguration.java index 226f435..6c38351 100644 --- a/src/main/java/org/kickerelo/kickerelo/config/SecurityConfiguration.java +++ b/src/main/java/org/kickerelo/kickerelo/config/SecurityConfiguration.java @@ -1,18 +1,28 @@ package org.kickerelo.kickerelo.config; +import com.vaadin.flow.spring.security.VaadinSecurityConfigurer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; import org.springframework.security.oauth2.client.*; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizedClientManager; import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; +import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority; import org.springframework.security.web.SecurityFilterChain; +import java.util.HashSet; +import java.util.Set; + @Profile("prod") +@EnableWebSecurity @Configuration class SecurityConfiguration { @Bean @@ -36,13 +46,10 @@ class SecurityConfiguration { } @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - http.authorizeHttpRequests(auth -> auth - .requestMatchers("/app/admin/**", "/app/admin", "/app/app/admin/**", "/app/app/admin").hasAuthority("Kicker Admin") - .anyRequest().permitAll()) - .oauth2Login(org.springframework.security.config.Customizer.withDefaults()) - .logout(logout -> logout.logoutSuccessUrl("/")) - .csrf(csrf -> csrf.disable()); + public SecurityFilterChain filterChain(HttpSecurity http) { + http.with(VaadinSecurityConfigurer.vaadin(), + configurer -> configurer.oauth2LoginPage("/oauth2/authorization/oidc")); + http.logout(logout -> logout.logoutSuccessUrl("/app/")); return http.build(); } @@ -53,4 +60,27 @@ class SecurityConfiguration { ClientRegistrationRepository clientRegistrationRepository) { return new JdbcOAuth2AuthorizedClientService(jdbcTemplate, clientRegistrationRepository); } + + @Bean + public GrantedAuthoritiesMapper userAuthoritiesMapper() { + return authorities -> { + Set mappedAuthorities = new HashSet<>(); + + authorities.forEach(authority -> { + if (authority instanceof OidcUserAuthority oidcAuth) { + var roles = oidcAuth.getIdToken().getClaimAsStringList("groups"); + + if (roles != null) { + roles.forEach(role -> { + // Add the ROLE_ prefix so @RolesAllowed works + mappedAuthorities.add(new SimpleGrantedAuthority("ROLE_" + role)); + }); + } + } + mappedAuthorities.add(authority); + }); + + return mappedAuthorities; + }; + } } diff --git a/src/main/java/org/kickerelo/kickerelo/config/TestSecurityConfiguration.java b/src/main/java/org/kickerelo/kickerelo/config/TestSecurityConfiguration.java new file mode 100644 index 0000000..27524d5 --- /dev/null +++ b/src/main/java/org/kickerelo/kickerelo/config/TestSecurityConfiguration.java @@ -0,0 +1,32 @@ +package org.kickerelo.kickerelo.config; + +import com.vaadin.flow.spring.security.NavigationAccessControlConfigurer; +import com.vaadin.flow.spring.security.VaadinSecurityConfigurer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; +import org.springframework.security.web.SecurityFilterChain; + +@Profile("test") +@EnableWebSecurity +@Configuration +public class TestSecurityConfiguration { + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) { + http.with(VaadinSecurityConfigurer.vaadin(), + configurer -> configurer.anyRequest(AuthorizeHttpRequestsConfigurer.AuthorizedUrl::permitAll)); + + http.csrf(AbstractHttpConfigurer::disable); + + return http.build(); + } + + @Bean + public static NavigationAccessControlConfigurer navigationAccessControlConfigurer() { + return new NavigationAccessControlConfigurer().disabled(); + } +} diff --git a/src/main/java/org/kickerelo/kickerelo/layout/KickerAppLayout.java b/src/main/java/org/kickerelo/kickerelo/layout/KickerAppLayout.java index 0d0f56e..f0daa92 100644 --- a/src/main/java/org/kickerelo/kickerelo/layout/KickerAppLayout.java +++ b/src/main/java/org/kickerelo/kickerelo/layout/KickerAppLayout.java @@ -2,6 +2,7 @@ package org.kickerelo.kickerelo.layout; import com.vaadin.flow.component.applayout.AppLayout; import com.vaadin.flow.component.applayout.DrawerToggle; +import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.dependency.JsModule; import com.vaadin.flow.component.html.Anchor; import com.vaadin.flow.component.html.Div; @@ -12,21 +13,21 @@ import com.vaadin.flow.component.sidenav.SideNav; import com.vaadin.flow.component.sidenav.SideNavItem; import com.vaadin.flow.dom.Style; import com.vaadin.flow.router.Layout; +import com.vaadin.flow.server.auth.AnonymousAllowed; +import com.vaadin.flow.spring.security.AuthenticationContext; import org.kickerelo.kickerelo.util.AccessControlService; import org.kickerelo.kickerelo.views.*; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.authentication.AnonymousAuthenticationToken; - - @Layout @JsModule("./prefers-color-scheme.js") +@AnonymousAllowed public class KickerAppLayout extends AppLayout { AccessControlService accessControlService; + AuthenticationContext authenticationContext; - public KickerAppLayout(AccessControlService accessControlService) { + public KickerAppLayout(AccessControlService accessControlService, AuthenticationContext authenticationContext) { this.accessControlService = accessControlService; + this.authenticationContext = authenticationContext; DrawerToggle drawerToggle = new DrawerToggle(); H1 title = new H1("Kicker-ELO"); @@ -36,8 +37,7 @@ public class KickerAppLayout extends AppLayout { // Add login/logout button if (accessControlService.userAllowedForRole("")) { - Anchor logoutLink = new Anchor("/logout", "Logout"); - + Button logoutLink = new Button("Logout", e -> this.authenticationContext.logout()); logoutLink.getElement().getStyle() .set("margin-left", "auto") .set("margin-right", "10px") diff --git a/src/main/java/org/kickerelo/kickerelo/views/AdminView.java b/src/main/java/org/kickerelo/kickerelo/views/AdminView.java index dfcc632..97193dd 100644 --- a/src/main/java/org/kickerelo/kickerelo/views/AdminView.java +++ b/src/main/java/org/kickerelo/kickerelo/views/AdminView.java @@ -1,31 +1,23 @@ package org.kickerelo.kickerelo.views; +import jakarta.annotation.security.RolesAllowed; import org.kickerelo.kickerelo.exception.DuplicatePlayerException; import org.kickerelo.kickerelo.exception.InvalidDataException; import org.kickerelo.kickerelo.exception.PlayerNameNotSetException; import org.kickerelo.kickerelo.service.KickerEloService; -import org.kickerelo.kickerelo.util.AccessControlService; import com.vaadin.flow.component.button.Button; -import com.vaadin.flow.component.html.Paragraph; import com.vaadin.flow.component.notification.Notification; import com.vaadin.flow.component.notification.NotificationVariant; import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.component.textfield.TextField; -import com.vaadin.flow.router.BeforeEnterEvent; import com.vaadin.flow.router.Route; @Route("admin") +@RolesAllowed("Kicker Admin") public class AdminView extends VerticalLayout { - public AdminView(KickerEloService service, AccessControlService accessControlService) { - // Deny access if user isn't part of the Kicker Admin group - if (!accessControlService.userAllowedForRole("Kicker Admin")) { - add(new Paragraph("Du bist nicht berechtigt, diese Seite zu sehen.")); - getUI().ifPresent(ui -> ui.navigate("")); - return; - } - + public AdminView(KickerEloService service) { TextField spielername = new TextField("Spielername"); spielername.addClassName("bordered"); diff --git a/src/main/java/org/kickerelo/kickerelo/views/Enter1vs1View.java b/src/main/java/org/kickerelo/kickerelo/views/Enter1vs1View.java index 9d58e53..00a42e8 100644 --- a/src/main/java/org/kickerelo/kickerelo/views/Enter1vs1View.java +++ b/src/main/java/org/kickerelo/kickerelo/views/Enter1vs1View.java @@ -1,17 +1,16 @@ package org.kickerelo.kickerelo.views; +import jakarta.annotation.security.PermitAll; import org.kickerelo.kickerelo.data.Spieler; import org.kickerelo.kickerelo.exception.DuplicatePlayerException; import org.kickerelo.kickerelo.exception.InvalidDataException; import org.kickerelo.kickerelo.exception.NoSuchPlayerException; import org.kickerelo.kickerelo.exception.PlayerNameNotSetException; import org.kickerelo.kickerelo.service.KickerEloService; -import org.kickerelo.kickerelo.util.AccessControlService; import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.combobox.ComboBox; import com.vaadin.flow.component.html.H2; -import com.vaadin.flow.component.html.Paragraph; import com.vaadin.flow.component.notification.Notification; import com.vaadin.flow.component.notification.NotificationVariant; import com.vaadin.flow.component.orderedlayout.VerticalLayout; @@ -19,16 +18,10 @@ import com.vaadin.flow.component.textfield.IntegerField; import com.vaadin.flow.router.Route; @Route("enter1vs1") +@PermitAll public class Enter1vs1View extends VerticalLayout { - public Enter1vs1View(KickerEloService eloService, AccessControlService accessControlService) { - // Deny access if user isn't part of the Kicker User group - if (!accessControlService.userAllowedForRole("Kicker User") && !accessControlService.userAllowedForRole("Kicker Admin")) { - add(new Paragraph("Du bist nicht berechtigt, diese Seite zu sehen.")); - getUI().ifPresent(ui -> ui.navigate("")); - return; - } - + public Enter1vs1View(KickerEloService eloService) { H2 subheading = new H2("1 vs 1 Ergebnis"); ComboBox winnerSelect = new ComboBox<>("Gewinner"); diff --git a/src/main/java/org/kickerelo/kickerelo/views/Enter2vs2View.java b/src/main/java/org/kickerelo/kickerelo/views/Enter2vs2View.java index 87e42d4..154034b 100644 --- a/src/main/java/org/kickerelo/kickerelo/views/Enter2vs2View.java +++ b/src/main/java/org/kickerelo/kickerelo/views/Enter2vs2View.java @@ -1,17 +1,16 @@ package org.kickerelo.kickerelo.views; +import jakarta.annotation.security.PermitAll; import org.kickerelo.kickerelo.data.Spieler; import org.kickerelo.kickerelo.exception.DuplicatePlayerException; import org.kickerelo.kickerelo.exception.InvalidDataException; import org.kickerelo.kickerelo.exception.NoSuchPlayerException; import org.kickerelo.kickerelo.exception.PlayerNameNotSetException; import org.kickerelo.kickerelo.service.KickerEloService; -import org.kickerelo.kickerelo.util.AccessControlService; import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.combobox.ComboBox; import com.vaadin.flow.component.html.H2; -import com.vaadin.flow.component.html.Paragraph; import com.vaadin.flow.component.notification.Notification; import com.vaadin.flow.component.notification.NotificationVariant; import com.vaadin.flow.component.orderedlayout.VerticalLayout; @@ -19,15 +18,9 @@ import com.vaadin.flow.component.textfield.IntegerField; import com.vaadin.flow.router.Route; @Route("enter2vs2") +@PermitAll public class Enter2vs2View extends VerticalLayout { - public Enter2vs2View(KickerEloService eloService, AccessControlService accessControlService) { - // Deny access if user isn't part of the Kicker User group - if (!accessControlService.userAllowedForRole("Kicker User") && !accessControlService.userAllowedForRole("Kicker Admin")) { - add(new Paragraph("Du bist nicht berechtigt, diese Seite zu sehen.")); - getUI().ifPresent(ui -> ui.navigate("")); - return; - } - + public Enter2vs2View(KickerEloService eloService) { H2 subheading = new H2("2 vs 2 Ergebnis"); ComboBox winnerFrontSelect = new ComboBox<>("Gewinner vorne"); diff --git a/src/main/java/org/kickerelo/kickerelo/views/Graph1vs1View.java b/src/main/java/org/kickerelo/kickerelo/views/Graph1vs1View.java index 6d47b31..73272f2 100644 --- a/src/main/java/org/kickerelo/kickerelo/views/Graph1vs1View.java +++ b/src/main/java/org/kickerelo/kickerelo/views/Graph1vs1View.java @@ -4,6 +4,7 @@ import com.vaadin.flow.component.checkbox.Checkbox; import com.vaadin.flow.component.html.H2; import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.router.Route; +import com.vaadin.flow.server.auth.AnonymousAllowed; import org.kickerelo.kickerelo.data.Spieler; import org.kickerelo.kickerelo.repository.SpielerRepository; @@ -12,6 +13,7 @@ import java.util.ArrayList; import java.util.List; @Route("graph1vs1") +@AnonymousAllowed public class Graph1vs1View extends VerticalLayout { private final SpielerRepository spielerRepository; private Chart chart; diff --git a/src/main/java/org/kickerelo/kickerelo/views/Graph2vs2View.java b/src/main/java/org/kickerelo/kickerelo/views/Graph2vs2View.java index 2a45b8c..b32949d 100644 --- a/src/main/java/org/kickerelo/kickerelo/views/Graph2vs2View.java +++ b/src/main/java/org/kickerelo/kickerelo/views/Graph2vs2View.java @@ -4,6 +4,7 @@ import com.vaadin.flow.component.checkbox.Checkbox; import com.vaadin.flow.component.html.H2; import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.router.Route; +import com.vaadin.flow.server.auth.AnonymousAllowed; import org.kickerelo.kickerelo.data.Spieler; import org.kickerelo.kickerelo.repository.SpielerRepository; @@ -12,6 +13,7 @@ import java.util.ArrayList; import java.util.List; @Route("graph2vs2") +@AnonymousAllowed public class Graph2vs2View extends VerticalLayout { private final SpielerRepository spielerRepository; private Chart chart; diff --git a/src/main/java/org/kickerelo/kickerelo/views/History1vs1View.java b/src/main/java/org/kickerelo/kickerelo/views/History1vs1View.java index 5680b1f..9590bca 100644 --- a/src/main/java/org/kickerelo/kickerelo/views/History1vs1View.java +++ b/src/main/java/org/kickerelo/kickerelo/views/History1vs1View.java @@ -2,6 +2,7 @@ package org.kickerelo.kickerelo.views; import java.util.List; +import com.vaadin.flow.server.auth.AnonymousAllowed; import org.kickerelo.kickerelo.data.Ergebnis1vs1; import org.kickerelo.kickerelo.repository.Ergebnis1vs1Repository; @@ -20,6 +21,7 @@ import com.vaadin.flow.data.value.ValueChangeMode; import com.vaadin.flow.router.Route; @Route("history1vs1") +@AnonymousAllowed public class History1vs1View extends HistoryView { private final Ergebnis1vs1Repository repo; diff --git a/src/main/java/org/kickerelo/kickerelo/views/History2vs2View.java b/src/main/java/org/kickerelo/kickerelo/views/History2vs2View.java index bf0f2b3..89a2b04 100644 --- a/src/main/java/org/kickerelo/kickerelo/views/History2vs2View.java +++ b/src/main/java/org/kickerelo/kickerelo/views/History2vs2View.java @@ -2,6 +2,7 @@ package org.kickerelo.kickerelo.views; import java.util.List; +import com.vaadin.flow.server.auth.AnonymousAllowed; import org.kickerelo.kickerelo.data.Ergebnis2vs2; import org.kickerelo.kickerelo.repository.Ergebnis2vs2Repository; @@ -22,6 +23,7 @@ import com.vaadin.flow.router.Route; @Route("history2vs2") +@AnonymousAllowed public class History2vs2View extends HistoryView { private final Ergebnis2vs2Repository repo; diff --git a/src/main/java/org/kickerelo/kickerelo/views/PlayerListView.java b/src/main/java/org/kickerelo/kickerelo/views/PlayerListView.java index 7f61a0f..0250f62 100644 --- a/src/main/java/org/kickerelo/kickerelo/views/PlayerListView.java +++ b/src/main/java/org/kickerelo/kickerelo/views/PlayerListView.java @@ -2,6 +2,7 @@ package org.kickerelo.kickerelo.views; import java.util.List; +import com.vaadin.flow.server.auth.AnonymousAllowed; import org.kickerelo.kickerelo.data.Spieler; import org.kickerelo.kickerelo.service.KickerEloService; @@ -13,6 +14,7 @@ import com.vaadin.flow.data.provider.SortDirection; import com.vaadin.flow.router.Route; @Route("/") +@AnonymousAllowed public class PlayerListView extends VerticalLayout { public PlayerListView(KickerEloService eloService) { setSizeFull(); diff --git a/src/main/java/org/kickerelo/kickerelo/views/Stat2vs2View.java b/src/main/java/org/kickerelo/kickerelo/views/Stat2vs2View.java index d9a2fee..a9bef32 100644 --- a/src/main/java/org/kickerelo/kickerelo/views/Stat2vs2View.java +++ b/src/main/java/org/kickerelo/kickerelo/views/Stat2vs2View.java @@ -1,5 +1,6 @@ package org.kickerelo.kickerelo.views; +import com.vaadin.flow.server.auth.AnonymousAllowed; import org.kickerelo.kickerelo.data.Spieler; import org.kickerelo.kickerelo.repository.Ergebnis2vs2Repository; import org.kickerelo.kickerelo.service.KickerEloService; @@ -16,6 +17,7 @@ import com.vaadin.flow.component.progressbar.ProgressBarVariant; import com.vaadin.flow.router.Route; @Route("stat2vs2") +@AnonymousAllowed public class Stat2vs2View extends VerticalLayout { Stat2vs2Service stat2vs2Service; KickerEloService kickerEloService; diff --git a/src/main/resources/application-test.properties b/src/main/resources/application-test.properties index 0db2142..61ad05b 100644 --- a/src/main/resources/application-test.properties +++ b/src/main/resources/application-test.properties @@ -9,7 +9,4 @@ spring.datasource.password= spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=true -# == OIDC Configuration == -spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration - vaadin.urlMapping=/app/* diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 6dbe2e1..85405ed 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -6,23 +6,23 @@ CREATE SEQUENCE spieler_seq INCREMENT BY 50 START WITH 1; CREATE TABLE ergebnis1vs1 ( - id BIGINT NOT NULL, - gewinner INT NOT NULL, - verlierer INT NOT NULL, - tore_verlierer SMALLINT NOT NULL, - zeitpunkt datetime NULL, + id BIGINT NOT NULL, + gewinner INT NOT NULL, + verlierer INT NOT NULL, + tore_verlierer SMALLINT NOT NULL, + zeitpunkt TIMESTAMP NULL, CONSTRAINT pk_ergebnis1vs1 PRIMARY KEY (id) ); CREATE TABLE ergebnis2vs2 ( - id BIGINT NOT NULL, - gewinner_vorn INT NOT NULL, - gewinner_hinten INT NOT NULL, - verlierer_vorn INT NOT NULL, - verlierer_hinten INT NOT NULL, - tore_verlierer SMALLINT NOT NULL, - zeitpunkt datetime NOT NULL, + id BIGINT NOT NULL, + gewinner_vorn INT NOT NULL, + gewinner_hinten INT NOT NULL, + verlierer_vorn INT NOT NULL, + verlierer_hinten INT NOT NULL, + tore_verlierer SMALLINT NOT NULL, + zeitpunkt TIMESTAMP NOT NULL, CONSTRAINT pk_ergebnis2vs2 PRIMARY KEY (id) );