diff --git a/wls-common/security/pom.xml b/wls-common/security/pom.xml index 0acd8a599..15316914f 100644 --- a/wls-common/security/pom.xml +++ b/wls-common/security/pom.xml @@ -29,6 +29,14 @@ org.springframework.security spring-security-oauth2-client + + org.springframework.security + spring-security-oauth2-resource-server + + + org.springframework.security + spring-security-oauth2-jose + org.springframework.boot spring-boot-starter-security diff --git a/wls-common/security/src/main/java/de/muenchen/oss/wahllokalsystem/wls/common/security/Profiles.java b/wls-common/security/src/main/java/de/muenchen/oss/wahllokalsystem/wls/common/security/Profiles.java index 834996c62..0075004e1 100644 --- a/wls-common/security/src/main/java/de/muenchen/oss/wahllokalsystem/wls/common/security/Profiles.java +++ b/wls-common/security/src/main/java/de/muenchen/oss/wahllokalsystem/wls/common/security/Profiles.java @@ -3,4 +3,6 @@ public interface Profiles { // Test Profiles String NO_BEZIRKS_ID_CHECK = "dummy.nobezirkid.check"; + + String NO_SECURITY = "no-security"; } diff --git a/wls-common/security/src/main/java/de/muenchen/oss/wahllokalsystem/wls/common/security/authentication/AnonymousDetailRetriever.java b/wls-common/security/src/main/java/de/muenchen/oss/wahllokalsystem/wls/common/security/authentication/AnonymousDetailRetriever.java new file mode 100644 index 000000000..8b29931fd --- /dev/null +++ b/wls-common/security/src/main/java/de/muenchen/oss/wahllokalsystem/wls/common/security/authentication/AnonymousDetailRetriever.java @@ -0,0 +1,27 @@ +package de.muenchen.oss.wahllokalsystem.wls.common.security.authentication; + +import de.muenchen.oss.wahllokalsystem.wls.common.security.Profiles; +import java.util.Optional; +import org.springframework.context.annotation.Profile; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; + +@Component +@Profile(Profiles.NO_SECURITY) +public class AnonymousDetailRetriever implements AuthDetailRetriever { + + @Override + public boolean canHandle(Authentication authentication) { + Assert.notNull(authentication, "Authentication must not be null"); + return authentication instanceof AnonymousAuthenticationToken; + } + + @Override + public Optional getDetail(String detailKey, Authentication authentication) { + Assert.notNull(authentication, "Authentication must not be null"); + Assert.notNull(detailKey, "detailKey must not be null"); + return Optional.empty(); + } +} diff --git a/wls-common/security/src/main/java/de/muenchen/oss/wahllokalsystem/wls/common/security/authentication/AuthDetailRetriever.java b/wls-common/security/src/main/java/de/muenchen/oss/wahllokalsystem/wls/common/security/authentication/AuthDetailRetriever.java new file mode 100644 index 000000000..e79c134c1 --- /dev/null +++ b/wls-common/security/src/main/java/de/muenchen/oss/wahllokalsystem/wls/common/security/authentication/AuthDetailRetriever.java @@ -0,0 +1,17 @@ +package de.muenchen.oss.wahllokalsystem.wls.common.security.authentication; + +import java.util.Optional; +import org.springframework.security.core.Authentication; + +public interface AuthDetailRetriever { + + /** + * @throws IllegalArgumentException when authentication is null + */ + boolean canHandle(Authentication authentication); + + /** + * @throws IllegalArgumentException when any parameter is null + */ + Optional getDetail(String detailKey, Authentication authentication); +} diff --git a/wls-common/security/src/main/java/de/muenchen/oss/wahllokalsystem/wls/common/security/authentication/JWTDetailRetriever.java b/wls-common/security/src/main/java/de/muenchen/oss/wahllokalsystem/wls/common/security/authentication/JWTDetailRetriever.java new file mode 100644 index 000000000..48b539346 --- /dev/null +++ b/wls-common/security/src/main/java/de/muenchen/oss/wahllokalsystem/wls/common/security/authentication/JWTDetailRetriever.java @@ -0,0 +1,27 @@ +package de.muenchen.oss.wahllokalsystem.wls.common.security.authentication; + +import java.util.Optional; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; + +@Component +public class JWTDetailRetriever implements AuthDetailRetriever { + + @Override + public boolean canHandle(final Authentication authentication) { + Assert.notNull(authentication, "authentication must not be null"); + return authentication instanceof JwtAuthenticationToken; + } + + public Optional getDetail(final String detailKey, final Authentication authentication) { + Assert.notNull(authentication, "authentication must not be null"); + Assert.notNull(detailKey, "detailKey must not be null"); + if (authentication instanceof JwtAuthenticationToken jwtToken) { + return Optional.ofNullable(jwtToken.getToken().getClaimAsString(detailKey)); + } else { + return Optional.empty(); + } + } +} diff --git a/wls-common/security/src/main/java/de/muenchen/oss/wahllokalsystem/wls/common/security/authentication/package-info.java b/wls-common/security/src/main/java/de/muenchen/oss/wahllokalsystem/wls/common/security/authentication/package-info.java new file mode 100644 index 000000000..e8561a3a5 --- /dev/null +++ b/wls-common/security/src/main/java/de/muenchen/oss/wahllokalsystem/wls/common/security/authentication/package-info.java @@ -0,0 +1,4 @@ +@NonNullApi +package de.muenchen.oss.wahllokalsystem.wls.common.security.authentication; + +import org.springframework.lang.NonNullApi; diff --git a/wls-common/security/src/test/java/de/muenchen/oss/wahllokalsystem/wls/common/security/ProfilesTest.java b/wls-common/security/src/test/java/de/muenchen/oss/wahllokalsystem/wls/common/security/ProfilesTest.java index f5032ed9c..4ce1b8de1 100644 --- a/wls-common/security/src/test/java/de/muenchen/oss/wahllokalsystem/wls/common/security/ProfilesTest.java +++ b/wls-common/security/src/test/java/de/muenchen/oss/wahllokalsystem/wls/common/security/ProfilesTest.java @@ -1,5 +1,9 @@ package de.muenchen.oss.wahllokalsystem.wls.common.security; +import de.muenchen.oss.wahllokalsystem.wls.common.security.authentication.AnonymousDetailRetriever; +import de.muenchen.oss.wahllokalsystem.wls.common.security.authentication.AuthDetailRetriever; +import de.muenchen.oss.wahllokalsystem.wls.common.security.authentication.JWTDetailRetriever; +import java.util.Collection; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -38,10 +42,38 @@ class NoSpecialProfile { @Autowired private OAuth2TokenInterceptor oAuth2TokenInterceptor; + @Autowired + private Collection authDetailRetrievers; + @Test void should_haveImplementationWithChecksInContext_when_noAdditionalProfilesAreActive() { Assertions.assertThat(permissionEvaluator).isExactlyInstanceOf(BezirkIDPermissionEvaluatorImpl.class); } + + @Test + void should_findOnlyJwtHandlerAsAuthenticationHandler_when_contextIsInitalized() { + Assertions.assertThat(authDetailRetrievers).hasSize(1); + Assertions.assertThat(authDetailRetrievers).allMatch(handler -> handler instanceof JWTDetailRetriever); + } + } + + @SpringBootTest( + properties = { "app.crypto.key = 770A8A65DA156D24EE2A093277530142", "service.info.oid=My app name" } + ) + @ActiveProfiles(Profiles.NO_SECURITY) + @Nested + class NoSecurityProfile { + + @Autowired + private Collection authDetailRetrievers; + + @Test + void should_findJwtAndAnonymousHandler_when_contextIsInitialized() { + Assertions.assertThat(authDetailRetrievers).hasSize(2); + Assertions.assertThat( + authDetailRetrievers).allMatch(handler -> handler instanceof JWTDetailRetriever || handler instanceof AnonymousDetailRetriever); + } + } @SpringBootApplication( diff --git a/wls-common/security/src/test/java/de/muenchen/oss/wahllokalsystem/wls/common/security/authentication/AnonymousDetailRetrieverTest.java b/wls-common/security/src/test/java/de/muenchen/oss/wahllokalsystem/wls/common/security/authentication/AnonymousDetailRetrieverTest.java new file mode 100644 index 000000000..8fcef46c6 --- /dev/null +++ b/wls-common/security/src/test/java/de/muenchen/oss/wahllokalsystem/wls/common/security/authentication/AnonymousDetailRetrieverTest.java @@ -0,0 +1,77 @@ +package de.muenchen.oss.wahllokalsystem.wls.common.security.authentication; + +import java.util.List; +import lombok.val; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +class AnonymousDetailRetrieverTest { + + private final AnonymousDetailRetriever unitUnderTest = new AnonymousDetailRetriever(); + + @Nested + class CanHandle { + + @Test + void should_throwIllegalArgumentException_when_authenticationIsNull() { + Assertions.assertThatThrownBy(() -> unitUnderTest.canHandle(null)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void should_returnTrue_when_authenticationIsAnonymousAuthenticationToken() { + Assertions.assertThat(unitUnderTest.canHandle(new AnonymousAuthenticationToken("key", "principal", List.of(new SimpleGrantedAuthority("role"))))) + .isTrue(); + } + + @Test + void should_returnTrue_when_authenticationSubclassOfJwtAuthenticationToken() { + Assertions.assertThat(unitUnderTest.canHandle(new AnonymousAuthenticationToken("key", "principal", List.of(new SimpleGrantedAuthority("role"))) { + })).isTrue(); + } + + @Test + void should_returnFalse_when_authenticationIsNotJwtAuthenticationToken() { + Assertions.assertThat(unitUnderTest.canHandle(new AbstractAuthenticationToken(List.of(new SimpleGrantedAuthority("role"))) { + @Override + public Object getCredentials() { + return null; + } + + @Override + public Object getPrincipal() { + return null; + } + })).isFalse(); + } + } + + @Nested + class GetDetail { + + @Test + void should_returnEmptyOptional_when_called() { + val authentication = new AnonymousAuthenticationToken("key", "principal", List.of(new SimpleGrantedAuthority("role"))); + + val result = unitUnderTest.getDetail("key", authentication); + + Assertions.assertThat(result).isEmpty(); + } + + @Test + void should_throwIllegalArgumentException_when_authenticationIsNull() { + Assertions.assertThatThrownBy(() -> unitUnderTest.getDetail("key", null)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void should_throwIllegalArgumentException_when_detailKeyIsNull() { + Assertions.assertThatThrownBy( + () -> unitUnderTest.getDetail(null, new AnonymousAuthenticationToken("key", "principal", List.of(new SimpleGrantedAuthority("role"))))) + .isInstanceOf(IllegalArgumentException.class); + } + } + +} diff --git a/wls-common/security/src/test/java/de/muenchen/oss/wahllokalsystem/wls/common/security/authentication/JWTDetailRetrieverTest.java b/wls-common/security/src/test/java/de/muenchen/oss/wahllokalsystem/wls/common/security/authentication/JWTDetailRetrieverTest.java new file mode 100644 index 000000000..fe708da32 --- /dev/null +++ b/wls-common/security/src/test/java/de/muenchen/oss/wahllokalsystem/wls/common/security/authentication/JWTDetailRetrieverTest.java @@ -0,0 +1,118 @@ +package de.muenchen.oss.wahllokalsystem.wls.common.security.authentication; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import lombok.val; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +class JWTDetailRetrieverTest { + + private final JWTDetailRetriever unitUnderTest = new JWTDetailRetriever(); + + @Nested + class CanHandle { + + @Test + void should_throwIllegalArgumentException_when_authenticationIsNull() { + Assertions.assertThatThrownBy(() -> unitUnderTest.canHandle(null)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void should_returnTrue_when_authenticationIsJwtAuthenticationToken() { + val detailKey = "requestedKey"; + val detailValue = "detailValue"; + val jwt = createJWT(Map.of(detailKey, detailValue)); + + Assertions.assertThat(unitUnderTest.canHandle(new JwtAuthenticationToken(jwt))).isTrue(); + } + + @Test + void should_returnTrue_when_authenticationSubclassOfJwtAuthenticationToken() { + val detailKey = "requestedKey"; + val detailValue = "detailValue"; + val jwt = createJWT(Map.of(detailKey, detailValue)); + + Assertions.assertThat(unitUnderTest.canHandle(new JwtAuthenticationToken(jwt) { + })).isTrue(); + } + + @Test + void should_returnFalse_when_authenticationIsNotJwtAuthenticationToken() { + Assertions.assertThat(unitUnderTest.canHandle(new AbstractAuthenticationToken(Collections.emptyList()) { + @Override + public Object getCredentials() { + return null; + } + + @Override + public Object getPrincipal() { + return null; + } + })).isFalse(); + } + } + + @Nested + class GetDetail { + + @Test + void should_returnEmptyOptional_when_authenticationIsNotInstanceOfJwtAuthenticationToken() { + val result = unitUnderTest.getDetail("key", new UsernamePasswordAuthenticationToken("principal", "credentials")); + + Assertions.assertThat(result).isEmpty(); + } + + @Test + void should_returnValues_when_claimWithKeyExists() { + val detailKey = "requestedKey"; + val detailValue = "detailValue"; + + val jwt = createJWT(Map.of(detailKey, detailValue)); + + val expectedResult = Optional.of(detailValue); + + val result = unitUnderTest.getDetail(detailKey, new JwtAuthenticationToken(jwt)); + + Assertions.assertThat(result).isEqualTo(expectedResult); + } + + @Test + void should_returnValues_when_claimWithKeyDoesNotExists() { + val detailKey = "requestedKey"; + + val jwt = createJWT(Map.of(detailKey + "extra", detailKey)); + + val result = unitUnderTest.getDetail(detailKey, new JwtAuthenticationToken(jwt)); + + Assertions.assertThat(result).isEmpty(); + } + + @Test + void should_throwsIllegalArgumentException_when_keyIsNull() { + val detailValue = "detailValue"; + + val jwt = createJWT(Map.of("detailKey", detailValue)); + + Assertions.assertThatThrownBy(() -> unitUnderTest.getDetail(null, new JwtAuthenticationToken(jwt))).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void should_throwsIllegalArgumentException_when_authenticationIsNull() { + Assertions.assertThatThrownBy(() -> unitUnderTest.getDetail("key", null)).isInstanceOf(IllegalArgumentException.class); + } + } + + private Jwt createJWT(final Map claims) { + return new Jwt("tokenValue", Instant.now().minus(1, ChronoUnit.HOURS), Instant.now().plus(1, ChronoUnit.HOURS), Map.of("key1", "value1"), claims); + } + +} diff --git a/wls-gui-wahllokalsystem/package-lock.json b/wls-gui-wahllokalsystem/package-lock.json index 9ce82904e..5e9242828 100644 --- a/wls-gui-wahllokalsystem/package-lock.json +++ b/wls-gui-wahllokalsystem/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0", "dependencies": { "@vueuse/core": "11.2.0", - "pinia": "2.2.6", + "pinia": "2.3.0", "roboto-fontface": "0.10.0", "vue": "3.5.13", "vue-router": "4.4.5", @@ -1709,31 +1709,6 @@ "url": "https://github.com/sponsors/antfu" } }, - "node_modules/@vueuse/core/node_modules/vue-demi": { - "version": "0.14.10", - "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", - "hasInstallScript": true, - "license": "MIT", - "bin": { - "vue-demi-fix": "bin/vue-demi-fix.js", - "vue-demi-switch": "bin/vue-demi-switch.js" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "@vue/composition-api": "^1.0.0-rc.1", - "vue": "^3.0.0-0 || ^2.6.0" - }, - "peerDependenciesMeta": { - "@vue/composition-api": { - "optional": true - } - } - }, "node_modules/@vueuse/metadata": { "version": "11.2.0", "integrity": "sha512-L0ZmtRmNx+ZW95DmrgD6vn484gSpVeRbgpWevFKXwqqQxW9hnSi2Ppuh2BzMjnbv4aJRiIw8tQatXT9uOB23dQ==", @@ -1753,31 +1728,6 @@ "url": "https://github.com/sponsors/antfu" } }, - "node_modules/@vueuse/shared/node_modules/vue-demi": { - "version": "0.14.10", - "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", - "hasInstallScript": true, - "license": "MIT", - "bin": { - "vue-demi-fix": "bin/vue-demi-fix.js", - "vue-demi-switch": "bin/vue-demi-switch.js" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "@vue/composition-api": "^1.0.0-rc.1", - "vue": "^3.0.0-0 || ^2.6.0" - }, - "peerDependenciesMeta": { - "@vue/composition-api": { - "optional": true - } - } - }, "node_modules/abbrev": { "version": "2.0.0", "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", @@ -3409,8 +3359,8 @@ } }, "node_modules/pinia": { - "version": "2.2.6", - "integrity": "sha512-vIsR8JkDN5Ga2vAxqOE2cJj4VtsHnzpR1Fz30kClxlh0yCHfec6uoMeM3e/ddqmwFUejK3NlrcQa/shnpyT4hA==", + "version": "2.3.0", + "integrity": "sha512-ohZj3jla0LL0OH5PlLTDMzqKiVw2XARmC1XYLdLWIPBMdhDW/123ZWr4zVAhtJm+aoSkFa13pYXskAvAscIkhQ==", "license": "MIT", "dependencies": { "@vue/devtools-api": "^6.6.3", @@ -3420,44 +3370,15 @@ "url": "https://github.com/sponsors/posva" }, "peerDependencies": { - "@vue/composition-api": "^1.4.0", "typescript": ">=4.4.4", - "vue": "^2.6.14 || ^3.5.11" + "vue": "^2.7.0 || ^3.5.11" }, "peerDependenciesMeta": { - "@vue/composition-api": { - "optional": true - }, "typescript": { "optional": true } } }, - "node_modules/pinia/node_modules/vue-demi": { - "version": "0.14.10", - "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", - "hasInstallScript": true, - "license": "MIT", - "bin": { - "vue-demi-fix": "bin/vue-demi-fix.js", - "vue-demi-switch": "bin/vue-demi-switch.js" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "@vue/composition-api": "^1.0.0-rc.1", - "vue": "^3.0.0-0 || ^2.6.0" - }, - "peerDependenciesMeta": { - "@vue/composition-api": { - "optional": true - } - } - }, "node_modules/postcss": { "version": "8.4.49", "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", @@ -4338,6 +4259,31 @@ "dev": true, "license": "MIT" }, + "node_modules/vue-demi": { + "version": "0.14.10", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, "node_modules/vue-eslint-parser": { "version": "9.4.3", "integrity": "sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==", diff --git a/wls-gui-wahllokalsystem/package.json b/wls-gui-wahllokalsystem/package.json index 9f008d393..38129a06f 100644 --- a/wls-gui-wahllokalsystem/package.json +++ b/wls-gui-wahllokalsystem/package.json @@ -16,7 +16,7 @@ }, "dependencies": { "@vueuse/core": "11.2.0", - "pinia": "2.2.6", + "pinia": "2.3.0", "roboto-fontface": "0.10.0", "vue": "3.5.13", "vue-router": "4.4.5",