diff --git a/src/test/java/de/ozgcloud/admin/security/SecurityConfigurationITCase.java b/src/test/java/de/ozgcloud/admin/security/SecurityConfigurationITCase.java index 6e84b05448a56c7e6f80d399b74a9fdec349b98a..bbd28b4b2d495ca4b2846236068a1345bf2f24ce 100644 --- a/src/test/java/de/ozgcloud/admin/security/SecurityConfigurationITCase.java +++ b/src/test/java/de/ozgcloud/admin/security/SecurityConfigurationITCase.java @@ -137,10 +137,15 @@ class SecurityConfigurationITCase { @DisplayName("with authentication") @Nested class TestWithAuthentication { + static final String CLAIMS = """ + { + "preferredUsername": "testUser", + "scope": "openid testscope" + }"""; @Test @SneakyThrows - @WithMockUser + @WithJwt(CLAIMS) void shouldAllowApiEndpoint() { var result = doPerformAuthenticated("/api"); @@ -149,7 +154,7 @@ class SecurityConfigurationITCase { @Test @SneakyThrows - @WithMockUser + @WithJwt(CLAIMS) void shouldForbidSettingsEndpoint() { var result = doPerformAuthenticated("/api/configuration/settings"); @@ -158,7 +163,7 @@ class SecurityConfigurationITCase { @Test @SneakyThrows - @WithMockUser + @WithJwt(CLAIMS) void shouldForbidConfigurationsEndpoint() { var result = doPerformAuthenticated("/api/configuration"); diff --git a/src/test/java/de/ozgcloud/admin/security/WithJwt.java b/src/test/java/de/ozgcloud/admin/security/WithJwt.java new file mode 100644 index 0000000000000000000000000000000000000000..ed3e4d36b99a451a98c1b882b0c8a047da4f4157 --- /dev/null +++ b/src/test/java/de/ozgcloud/admin/security/WithJwt.java @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2024. Das Land Schleswig-Holstein vertreten durch das Ministerium für Energiewende, Klimaschutz, Umwelt und Natur + * Zentrales IT-Management + * + * Lizenziert unter der EUPL, Version 1.2 oder - sobald + * diese von der Europäischen Kommission genehmigt wurden - + * Folgeversionen der EUPL ("Lizenz"); + * Sie dürfen dieses Werk ausschließlich gemäß + * dieser Lizenz nutzen. + * Eine Kopie der Lizenz finden Sie hier: + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Sofern nicht durch anwendbare Rechtsvorschriften + * gefordert oder in schriftlicher Form vereinbart, wird + * die unter der Lizenz verbreitete Software "so wie sie + * ist", OHNE JEGLICHE GEWÄHRLEISTUNG ODER BEDINGUNGEN - + * ausdrücklich oder stillschweigend - verbreitet. + * Die sprachspezifischen Genehmigungen und Beschränkungen + * unter der Lizenz sind dem Lizenztext zu entnehmen. + */ +package de.ozgcloud.admin.security; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.test.context.support.WithSecurityContext; +import org.springframework.security.test.context.support.WithSecurityContextFactory; +import org.springframework.util.StringUtils; + +import com.nimbusds.jwt.JWTClaimNames; + +import lombok.RequiredArgsConstructor; +import net.minidev.json.JSONObject; +import net.minidev.json.parser.JSONParser; +import net.minidev.json.parser.ParseException; + +/** + * Annotation to setup test {@link SecurityContext} with an {@link Authentication}. Adjusted from source: + * com.c4_soft.springaddons.security.oauth2.test.annotations.WithJwt Author: Jérôme Wacongne <ch4mp@c4-soft.com> + */ +@Target({ ElementType.METHOD, ElementType.TYPE }) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Documented +@WithSecurityContext(factory = WithJwt.AuthenticationFactory.class) +public @interface WithJwt { + + String value() default ""; + + String bearerString() default AuthenticationFactory.DEFAULT_BEARER; + + String headers() default AuthenticationFactory.DEFAULT_HEADERS; + + @RequiredArgsConstructor + final class AuthenticationFactory implements WithSecurityContextFactory<WithJwt> { + static final String DEFAULT_BEARER = "test.jwt.bearer"; + static final String DEFAULT_HEADERS = "{\"alg\": \"none\"}"; + + private final Converter<Jwt, ? extends AbstractAuthenticationToken> jwtAuthenticationConverter; + + @Override + public SecurityContext createSecurityContext(WithJwt annotation) { + var auth = authentication(annotation); + + var securityContext = SecurityContextHolder.createEmptyContext(); + securityContext.setAuthentication(auth); + + return securityContext; + } + + private AbstractAuthenticationToken authentication(WithJwt annotation) { + var claims = parseJson(annotation.value()); + var headers = parseJson(annotation.headers()); + var bearerString = annotation.bearerString(); + + var now = Instant.now(); + var iat = Optional.ofNullable((Integer) claims.get(JWTClaimNames.ISSUED_AT)).map(Instant::ofEpochSecond).orElse(now); + var exp = Optional.ofNullable((Integer) claims.get(JWTClaimNames.EXPIRATION_TIME)).map(Instant::ofEpochSecond) + .orElse(now.plusSeconds(42)); + + var jwt = new Jwt(bearerString, iat, exp, headers, claims); + + return jwtAuthenticationConverter.convert(jwt); + } + + private static Map<String, Object> parseJson(String json) { + if (!StringUtils.hasText(json)) { + return Map.of(); + } + try { + return new JSONParser(JSONParser.MODE_PERMISSIVE).parse(json, JSONObject.class); + } catch (final ParseException e) { + throw new RuntimeException("Invalid JSON payload in @WithJwt"); + } + } + } +}