diff --git a/.github/workflows/release-maven.yml b/.github/workflows/release-maven.yml index 8583da5cd..aa7f90cc4 100644 --- a/.github/workflows/release-maven.yml +++ b/.github/workflows/release-maven.yml @@ -4,14 +4,18 @@ name: release-maven on: workflow_dispatch: inputs: + release-method: + type: choice + description: "Release-Methode = patch, minor oder major" + required: true + options: + - patch + - minor + - major release-version: description: "Version to use when preparing a release (e.g., 1.2.3)" - required: true - default: "X.Y.Z" - development-version: - description: "Version to use for new local working copy (e.g., 1.2.4-SNAPSHOT)" - required: true - default: X.Y.Z-SNAPSHOT + required: false + default: "" app-path: type: choice description: Service-Name @@ -31,11 +35,11 @@ jobs: ARTIFACT_NAME: ${{ steps.release-maven-artifact.outputs.artifact-name }} steps: - id: release-maven-artifact - uses: it-at-m/lhm_actions/action-templates/actions/action-maven-release@4ed8f906bab8111fbd9a7af8e3f4129f9b721757 # v1.2.3 + uses: it-at-m/lhm_actions/action-templates/actions/action-maven-release@330-action-release-mvn-should-offer-input-variable-to-use-patchminormajor with: app-path: ${{ inputs.app-path }} releaseVersion: ${{ inputs.release-version }} - developmentVersion: ${{ inputs.development-version }} + releaseMethod: ${{ inputs.release-method }} use-pr: "true" # the following variables are necessary to publish the packages to maven central # docs: https://it-at-m.github.io/lhm_actions/workflows.html#maven-central diff --git a/backend/pom.xml b/backend/pom.xml index 2ec3d5fd1..73d459bc3 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -6,12 +6,12 @@ org.springframework.boot spring-boot-starter-parent 4.1.0 - + de.muenchen.oss.refarch refarch-backend - 0.0.1-SNAPSHOT + 0.0.2-SNAPSHOT refarch-backend This is the default starting project for a backend project based on the reference architecture of it@M diff --git a/backend/src/main/java/de/muenchen/oss/refarch/backend/configuration/filter/RequestResponseLoggingFilter.java b/backend/src/main/java/de/muenchen/oss/refarch/backend/configuration/filter/RequestResponseLoggingFilter.java index b97aeb498..bbef48f78 100644 --- a/backend/src/main/java/de/muenchen/oss/refarch/backend/configuration/filter/RequestResponseLoggingFilter.java +++ b/backend/src/main/java/de/muenchen/oss/refarch/backend/configuration/filter/RequestResponseLoggingFilter.java @@ -10,6 +10,7 @@ import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.jspecify.annotations.NonNull; import org.springframework.boot.web.servlet.FilterRegistration; import org.springframework.http.HttpMethod; import org.springframework.security.core.context.SecurityContext; @@ -17,7 +18,7 @@ import org.springframework.web.filter.OncePerRequestFilter; /** - * This filter logs the username for requests. + * This filter logs the username from requests using the {@link AuthUtils} bean. */ @Component @FilterRegistration(urlPatterns = "/*", order = 1) @@ -28,6 +29,7 @@ public class RequestResponseLoggingFilter extends OncePerRequestFilter { private static final List CHANGING_METHODS = List.of(HttpMethod.POST.name(), HttpMethod.PUT.name(), HttpMethod.PATCH.name(), HttpMethod.DELETE.name()); + private final AuthUtils authUtils; private final SecurityProperties securityProperties; /** @@ -54,12 +56,12 @@ public enum LoggingMode { * {@inheritDoc} */ @Override - protected void doFilterInternal(final HttpServletRequest request, final HttpServletResponse response, final FilterChain filterChain) + protected void doFilterInternal(final @NonNull HttpServletRequest request, final @NonNull HttpServletResponse response, final FilterChain filterChain) throws ServletException, IOException { filterChain.doFilter(request, response); if (checkForLogging(request)) { log.info("User {} executed {} on URI {} with http status {}", - AuthUtils.getUsername(), + authUtils.getUsername(), request.getMethod(), request.getRequestURI(), response.getStatus()); diff --git a/backend/src/main/java/de/muenchen/oss/refarch/backend/configuration/security/SecurityProperties.java b/backend/src/main/java/de/muenchen/oss/refarch/backend/configuration/security/SecurityProperties.java index 67df25e5e..4e38cb7f7 100644 --- a/backend/src/main/java/de/muenchen/oss/refarch/backend/configuration/security/SecurityProperties.java +++ b/backend/src/main/java/de/muenchen/oss/refarch/backend/configuration/security/SecurityProperties.java @@ -26,6 +26,12 @@ public class SecurityProperties { */ @NotBlank private String clientId; + /** + * Name of the JWT claim that contains the username. + * Defaults to "preferred_username" if not configured. + */ + @NotBlank private String usernameClaim = "preferred_username"; + /** * URI of the endpoint used for fetching permissions, * see also {@link KeycloakPermissionsAuthoritiesConverter}. diff --git a/backend/src/main/java/de/muenchen/oss/refarch/backend/security/AuthUtils.java b/backend/src/main/java/de/muenchen/oss/refarch/backend/security/AuthUtils.java index e73ce7554..65ad9ab70 100644 --- a/backend/src/main/java/de/muenchen/oss/refarch/backend/security/AuthUtils.java +++ b/backend/src/main/java/de/muenchen/oss/refarch/backend/security/AuthUtils.java @@ -1,21 +1,23 @@ package de.muenchen.oss.refarch.backend.security; +import de.muenchen.oss.refarch.backend.configuration.security.SecurityProperties; +import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.stereotype.Component; /** - * Utilities for authentication data. + * Utility bean for authentication related data. */ -public final class AuthUtils { +@Component +@RequiredArgsConstructor +public class AuthUtils { public static final String NAME_UNAUTHENTICATED_USER = "unauthenticated"; - private static final String TOKEN_USER_NAME = "preferred_username"; - - private AuthUtils() { - } + private final SecurityProperties securityProperties; /** * Extracts the user name from the existing Spring Security Context via @@ -23,13 +25,11 @@ private AuthUtils() { * * @return the username or an "unauthenticated" if no {@link Authentication} exists */ - public static String getUsername() { + public String getUsername() { final Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication instanceof JwtAuthenticationToken) { - final JwtAuthenticationToken jwtAuth = (JwtAuthenticationToken) authentication; - return (String) jwtAuth.getTokenAttributes().getOrDefault(TOKEN_USER_NAME, null); - } else if (authentication instanceof UsernamePasswordAuthenticationToken) { - final UsernamePasswordAuthenticationToken usernameAuth = (UsernamePasswordAuthenticationToken) authentication; + if (authentication instanceof JwtAuthenticationToken jwtAuth) { + return (String) jwtAuth.getTokenAttributes().get(securityProperties.getUsernameClaim()); + } else if (authentication instanceof UsernamePasswordAuthenticationToken usernameAuth) { return usernameAuth.getName(); } else { return NAME_UNAUTHENTICATED_USER; diff --git a/backend/src/test/java/de/muenchen/oss/refarch/backend/security/AuthUtilsTest.java b/backend/src/test/java/de/muenchen/oss/refarch/backend/security/AuthUtilsTest.java new file mode 100644 index 000000000..53b5c1371 --- /dev/null +++ b/backend/src/test/java/de/muenchen/oss/refarch/backend/security/AuthUtilsTest.java @@ -0,0 +1,95 @@ +package de.muenchen.oss.refarch.backend.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import de.muenchen.oss.refarch.backend.configuration.security.SecurityProperties; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +@ExtendWith(MockitoExtension.class) +class AuthUtilsTest { + + private static final String USERNAME_CLAIM = "preferred_username"; + + @Mock + private SecurityProperties securityProperties; + + @InjectMocks + private AuthUtils authUtils; + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void givenJwtWithUsernameClaim_thenReturnUsername() { + // given + when(securityProperties.getUsernameClaim()).thenReturn(USERNAME_CLAIM); + final Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "none") + .claim(USERNAME_CLAIM, "john.doe") + .build(); + final JwtAuthenticationToken authentication = new JwtAuthenticationToken(jwt); + SecurityContextHolder.getContext().setAuthentication(authentication); + + // when + final String username = authUtils.getUsername(); + + // then + assertThat(username).isEqualTo("john.doe"); + } + + @Test + void givenJwtWithoutUsernameClaim_thenReturnUnauthenticatedUser() { + // given + when(securityProperties.getUsernameClaim()).thenReturn(USERNAME_CLAIM); + final Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "none") + .claims(claims -> claims.put("other", "value")) + .build(); + final JwtAuthenticationToken authentication = new JwtAuthenticationToken(jwt); + SecurityContextHolder.getContext().setAuthentication(authentication); + + // when + final String username = authUtils.getUsername(); + + // then + assertThat(username).isNull(); + } + + @Test + void givenUsernamePasswordAuthentication_thenReturnUsername() { + // given + final UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken("jane.doe", "password"); + SecurityContextHolder.getContext().setAuthentication(authentication); + + // when + final String username = authUtils.getUsername(); + + // then + assertThat(username).isEqualTo("jane.doe"); + } + + @Test + void givenNoAuthentication_thenReturnUnauthenticatedUser() { + // given + SecurityContextHolder.clearContext(); + + // when + final String username = authUtils.getUsername(); + + // then + assertThat(username).isEqualTo(AuthUtils.NAME_UNAUTHENTICATED_USER); + } + +}