diff --git a/src/main/java/gov/cabinetoffice/gap/applybackend/annotations/LambdasHeaderValidator.java b/src/main/java/gov/cabinetoffice/gap/applybackend/annotations/LambdasHeaderValidator.java new file mode 100644 index 0000000..6f1e66b --- /dev/null +++ b/src/main/java/gov/cabinetoffice/gap/applybackend/annotations/LambdasHeaderValidator.java @@ -0,0 +1,12 @@ +package gov.cabinetoffice.gap.applybackend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface LambdasHeaderValidator { + +} diff --git a/src/main/java/gov/cabinetoffice/gap/applybackend/config/LambdaSecretConfigProperties.java b/src/main/java/gov/cabinetoffice/gap/applybackend/config/LambdaSecretConfigProperties.java new file mode 100644 index 0000000..ebbf8a7 --- /dev/null +++ b/src/main/java/gov/cabinetoffice/gap/applybackend/config/LambdaSecretConfigProperties.java @@ -0,0 +1,22 @@ +package gov.cabinetoffice.gap.applybackend.config; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Configuration("lambdaProperties") +@ConfigurationProperties(prefix = "lambda") +public class LambdaSecretConfigProperties { + + private String secret; + + private String privateKey; + +} diff --git a/src/main/java/gov/cabinetoffice/gap/applybackend/config/LambdasInterceptor.java b/src/main/java/gov/cabinetoffice/gap/applybackend/config/LambdasInterceptor.java new file mode 100644 index 0000000..3a55138 --- /dev/null +++ b/src/main/java/gov/cabinetoffice/gap/applybackend/config/LambdasInterceptor.java @@ -0,0 +1,32 @@ +package gov.cabinetoffice.gap.applybackend.config; + +import gov.cabinetoffice.gap.applybackend.security.interceptors.AuthorizationHeaderInterceptor; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@RequiredArgsConstructor +@Configuration +public class LambdasInterceptor implements WebMvcConfigurer { + + private static final String UUID_REGEX_STRING = "[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"; + private final LambdaSecretConfigProperties lambdaSecretConfigProperties; + + @Bean() + AuthorizationHeaderInterceptor lambdasInterceptor() { + return new AuthorizationHeaderInterceptor(lambdaSecretConfigProperties.getSecret(), + lambdaSecretConfigProperties.getPrivateKey()); + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(lambdasInterceptor()) + .addPathPatterns( "/submissions/{submissionId:" + UUID_REGEX_STRING + + "}/question/{questionId:" + UUID_REGEX_STRING + "}/attachment/scanresult") + .order(Ordered.HIGHEST_PRECEDENCE); + } + +} diff --git a/src/main/java/gov/cabinetoffice/gap/applybackend/security/interceptors/AuthorizationHeaderInterceptor.java b/src/main/java/gov/cabinetoffice/gap/applybackend/security/interceptors/AuthorizationHeaderInterceptor.java new file mode 100644 index 0000000..f5e221c --- /dev/null +++ b/src/main/java/gov/cabinetoffice/gap/applybackend/security/interceptors/AuthorizationHeaderInterceptor.java @@ -0,0 +1,105 @@ +package gov.cabinetoffice.gap.applybackend.security.interceptors; + + +import gov.cabinetoffice.gap.applybackend.annotations.LambdasHeaderValidator; +import lombok.extern.log4j.Log4j2; +import org.springframework.http.HttpHeaders; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Base64; + +//this is needed to "authenticate" all the call from the Lambdas +@Log4j2 +public class AuthorizationHeaderInterceptor implements HandlerInterceptor { + + private final String expectedAuthorizationValue; + + private final String privateKey; + + public AuthorizationHeaderInterceptor(String expectedAuthorizationValue, String privateKey) { + this.expectedAuthorizationValue = expectedAuthorizationValue; + this.privateKey = privateKey; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + log.info("Intercepting request: " + request.getRequestURI()); + if (handler instanceof HandlerMethod handlerMethod) { + final Method method = handlerMethod.getMethod(); + + final LambdasHeaderValidator annotation = method.getAnnotation(LambdasHeaderValidator.class); + + if (annotation != null) { + log.info("Request is coming from lambda, validating authorization header"); + + final String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + final boolean isAuthorizationHeaderCorrect = compareAuthorizationSecret(authorizationHeader, + expectedAuthorizationValue, privateKey); + if (authorizationHeader == null || !isAuthorizationHeaderCorrect) { + + log.info("Authorization Header Value: " + authorizationHeader + + " does not match the expected value"); + + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + return false; + } + + log.info("Authorization Header Value matches the expected value"); + } + } + + return true; + } + + private boolean compareAuthorizationSecret(String authorizationHeader, String expectedAuthorizationValue, + String privateKey) { + + if (authorizationHeader == null || privateKey == null) { + return false; + } + + return decrypt(authorizationHeader, privateKey).equals(expectedAuthorizationValue); + + } + + private String decrypt(String encryptedText, String privateKeyString) { + + try { + + final byte[] privateKeyBytes = Base64.getDecoder().decode(privateKeyString); + final byte[] encryptedMessageBytes = Base64.getDecoder().decode(encryptedText); + + final PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateKeyBytes); + final KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + final PrivateKey rsaPrivateKey = keyFactory.generatePrivate(keySpec); + + final Cipher decryptCipher = Cipher.getInstance("RSA"); + decryptCipher.init(Cipher.DECRYPT_MODE, rsaPrivateKey); + + final byte[] decryptedMessageBytes = decryptCipher.doFinal(encryptedMessageBytes); + + return new String(decryptedMessageBytes, StandardCharsets.UTF_8); + } + catch (IllegalArgumentException | InvalidKeyException | NoSuchAlgorithmException | InvalidKeySpecException + | BadPaddingException | IllegalBlockSizeException | NoSuchPaddingException e) { + log.info("Error decrypting authorization header from lambdas: " + e.getMessage()); + return ""; + } + } + +} diff --git a/src/main/java/gov/cabinetoffice/gap/applybackend/web/SubmissionController.java b/src/main/java/gov/cabinetoffice/gap/applybackend/web/SubmissionController.java index d4770b4..b445af3 100644 --- a/src/main/java/gov/cabinetoffice/gap/applybackend/web/SubmissionController.java +++ b/src/main/java/gov/cabinetoffice/gap/applybackend/web/SubmissionController.java @@ -1,6 +1,7 @@ package gov.cabinetoffice.gap.applybackend.web; import com.fasterxml.jackson.core.JsonProcessingException; +import gov.cabinetoffice.gap.applybackend.annotations.LambdasHeaderValidator; import gov.cabinetoffice.gap.applybackend.constants.APIConstants; import gov.cabinetoffice.gap.applybackend.dto.api.CreateQuestionResponseDto; import gov.cabinetoffice.gap.applybackend.dto.api.CreateSubmissionResponseDto; @@ -56,7 +57,6 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -273,14 +273,13 @@ public ResponseEntity createApplication(@PathVariab //this endpoint is consumed by the lambda-upload @PutMapping("/{submissionId}/question/{questionId}/attachment/scanresult") + @LambdasHeaderValidator public ResponseEntity updateAttachment( @PathVariable final UUID submissionId, @PathVariable final String questionId, - @RequestBody final UpdateAttachmentDto updateDetails, - @RequestHeader(HttpHeaders.AUTHORIZATION) final String authHeader) { - log.info("Lambda-upload is updating attachment for submissionId: {}, questionId: {} based on the scan result", submissionId, questionId); + @RequestBody final UpdateAttachmentDto updateDetails) { - secretAuthService.authenticateSecret(authHeader); + log.info("Lambda-upload is updating attachment for submissionId: {}, questionId: {} based on the scan result", submissionId, questionId); final Submission submission = submissionService.getSubmissionById(submissionId); log.info("Submission found for submissionId: {}", submissionId); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index c836f25..08f5334 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -37,6 +37,7 @@ frontEndUri=http://localhost:3001/apply/applicant environmentName=an-environment-name lambda.secret=lambdaSecretKey +lambda.privateKey=lambdaPrivateKey feature.onelogin.enabled=false @@ -49,4 +50,5 @@ contentful.environmentId=an-environment spotlight-queue.queueUrl=a-sqs-queue-url cloud.aws.sqs.event-service-queue=gap-apply-events-service-queue -cloud.aws.sqs.event-service-queue-enabled=true \ No newline at end of file +cloud.aws.sqs.event-service-queue-enabled=true +spring.profiles.active=LOCAL \ No newline at end of file diff --git a/src/test/java/gov/cabinetoffice/gap/applybackend/security/interceptors/AuthorizationHeaderInterceptorTest.java b/src/test/java/gov/cabinetoffice/gap/applybackend/security/interceptors/AuthorizationHeaderInterceptorTest.java new file mode 100644 index 0000000..ccccc2f --- /dev/null +++ b/src/test/java/gov/cabinetoffice/gap/applybackend/security/interceptors/AuthorizationHeaderInterceptorTest.java @@ -0,0 +1,100 @@ +package gov.cabinetoffice.gap.applybackend.security.interceptors; + +import gov.cabinetoffice.gap.applybackend.annotations.LambdasHeaderValidator; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.http.HttpHeaders; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.web.method.HandlerMethod; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig + +class AuthorizationHeaderInterceptorTest { + + private static final String EXPECTED_AUTHORIZATION_VALUE = "expectedToken"; + + private static final String ENCRYPTED_EXPECTED_AUTHORIZATION_VALUE = "gPiAtfeQbMcv9e0boGa2tlNIthMM8uXq1JnzQy/KVPtpcmqVpOkA28dKR7UN/hJTO8ACW3TVVMTzHxvmJT+YPUmC7ggRHO9VSpItdPBdwgaHzDDu571KYrTLKEeUYhzt00PfB+O7kwA8cw1sr2rog8dI0wXVU0tBqkoFjBUl+NdSCe/AxkjL1ziq6MSpI3yflJC3crkYoZf0zrWQTKRZWIr2MXBgCRHfXGy0LKdwrkvKUUvcWObSybA6yPFzGW4IDNbivOUngGr9goRtnkSY3/ezvsQzl/nPGS2VF2FrTtDD/U7kKOE83s5ojKR8VEu4hCieYu0T8eT3ur51seHTOA=="; + + private static final String PRIVATE_KEY = "MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDUKDTCzH6/coqqf2y95NWJ/FfrSbT273rCrTT8in+CDeDaHmm+kHSwOigL5o5Vci4kY71/pqLASp9PwmSbbWuzTnK0hr30aZsYELVr1Nv4lE6IrQUby2pDaiXdd/PrKBeXGbzd5JNmamYqLn8B9jl3Sftde66pGhimaSr2MTLGfE32ZoK8VA3mAHGURt9zWZ3B97d+ivkT/UTu64VzczsyrIf9Rtll0xrIki00NToXt5gX32YPzGHX1ZYtT2aGVpfZSpgnwOuJZORIepE8aB5jsqvy24gGBi60nYFSXW1mxVRJ7VHZoOLPkiX28BLexsl4CIYTvAUwJhEGy3QxOTWRAgMBAAECggEAD6unmALkUs5KVUUjulQCfNZ1hQTU989hDSufiA7P08KkKuBHtcJ0SAcHqbdeKIwOQfRXDSx5BYSNh2Xnqf5XT+95tAFm2vyI7upfCCe5Fr+XnGtRPwgToYaf5N+lFefIdEn7pBUr2Qr/YFq+WV3/SSMfDLzjvgxWKUaH3JbuNfBafyuLDObXFYKTxS+JJIE/DHFKK9qG+D1KSaJsm7XxD3u9njziXiNP62DnnhpW7NE7z8Bj8Pn5qJEnW8DSjGlWfeNJ0dvXvepBgilk0dZKEuDXVHpw3yxl/aP+SWA3IxMsYRi6vGC6dCASO1oiQoh69FhWAxYeyZAFJXM+5luH1QKBgQD+eq26WN7HkD9slrvA9lP7NaE47BsFsxJbWGjJOkimA+arVVvKlflPZ8RR4nQIFex8ygm2LoTZVxLb6y21NVowNthPltitWPEuvGxmwM2dP9wHU+HGo+TSFVTKspxj9vdBItO2Jv9xatevUjbmmkJwUero3pAn7yss6OkzBx1rnQKBgQDVbMelE/Bj6yFtFFIGuljpap6R3gMEq7+cqIvYT5z1EOdb23DPYD30U7YZPx19hIB+Q8NF6CHiuLIgNgm4Vm0DlCBaxuS/SvXclII/hMKOTKFI8TkQ4C3cOukMpbrZRSnslfQAx9Nw2I6aega8MmQx+Ib3oX97K4utbDKzGTBxhQKBgDS0KajPYQxJtqkBqs7y9T+wwrwsQghzJtkePU13sbYYVryjSAhz+RdV8VXYJZnLGJhbq5k8ly0AGJrNYUEHVK9pnHEXV7bHFeRNB9JcGfl4UVR/LeJa7TgJTO9SAIu/iUPHN7ug5gSSUSsxRseJqTrj7FfgSFDM+s8pSarUzWYNAoGAZv0UJhPGF+FaAvIgVwDLcO+zTz5sBHAAic9HlH1uh0+95TSybk/J9cIDiJFNYMl02/lFHNHUsDxiMGsDK8IA3w42wrdhoPHCTMwZQh+FZveRiMpmuD8FwlVnKmQ7EyduAK2nzkyfOsA1qFOkNmK2uOpt7scu5jfwMiKZIJXSK6UCgYB446Uz1BR/0Gb7zQ6hbV/tEO1pOtgV/uXMOFJ4UOulyQzQXyIhMuzT/66oxKc7Ik1ZQ468bVv4CCL4yoaKOj04j1z23v3aR9tg2gv45ryRiqo2vJbwMrZpP/BYeIZehxMrXauqMKwl/t64JR8XTwArbWVDSIqwYX76kGG46YHW/w=="; + + @Mock + private HttpServletRequest request; + + @Mock + private HttpServletResponse response; + + @Mock + private HandlerMethod handlerMethod; + + @InjectMocks + private AuthorizationHeaderInterceptor authorizationHeaderInterceptor; + + @Test + void preHandleValidAuthorization() throws Exception { + final byte[] expectedAuthorizationValueBytes = EXPECTED_AUTHORIZATION_VALUE.getBytes(StandardCharsets.UTF_8); + + authorizationHeaderInterceptor = new AuthorizationHeaderInterceptor(EXPECTED_AUTHORIZATION_VALUE, PRIVATE_KEY); + when(handlerMethod.getMethod()).thenReturn(getClass().getMethod("annotatedTestMethod")); + when(request.getHeader(HttpHeaders.AUTHORIZATION)).thenReturn(ENCRYPTED_EXPECTED_AUTHORIZATION_VALUE); + + boolean result = authorizationHeaderInterceptor.preHandle(request, response, handlerMethod); + + assertTrue(result); + } + + @Test + void preHandleNullAuthorizationHeader() throws Exception { + when(handlerMethod.getMethod()).thenReturn(getClass().getMethod("annotatedTestMethod")); + when(request.getHeader(HttpHeaders.AUTHORIZATION)).thenReturn(null); + + boolean result = authorizationHeaderInterceptor.preHandle(request, response, handlerMethod); + + verify(response).setStatus(HttpServletResponse.SC_FORBIDDEN); + assertFalse(result); + } + + @Test + void preHandleInvalidAuthorizationHeader() throws Exception { + String invalidAuthorizationValue = "invalidToken"; + when(handlerMethod.getMethod()).thenReturn(getClass().getMethod("annotatedTestMethod")); + when(request.getHeader(HttpHeaders.AUTHORIZATION)).thenReturn(invalidAuthorizationValue); + + boolean result = authorizationHeaderInterceptor.preHandle(request, response, handlerMethod); + + verify(response).setStatus(HttpServletResponse.SC_FORBIDDEN); + assertFalse(result); + } + + @Test + void preHandleValidAuthorizationForNonAnnotatedMethods() throws Exception { + authorizationHeaderInterceptor = new AuthorizationHeaderInterceptor(EXPECTED_AUTHORIZATION_VALUE, PRIVATE_KEY); + when(handlerMethod.getMethod()).thenReturn(getClass().getMethod("nonAnnotatedTestMethod")); + when(request.getHeader(HttpHeaders.AUTHORIZATION)).thenReturn(EXPECTED_AUTHORIZATION_VALUE); + + boolean result = authorizationHeaderInterceptor.preHandle(request, response, handlerMethod); + + assertTrue(result); + } + + // Test method to provide a valid HandlerMethod for testing + @LambdasHeaderValidator + public void annotatedTestMethod() { + // This method is just a placeholder for testing HandlerMethod + } + + // Test method to provide a NON valid HandlerMethod for testing + public void nonAnnotatedTestMethod() { + // This method is just a placeholder for testing HandlerMethod + } + +} \ No newline at end of file diff --git a/src/test/java/gov/cabinetoffice/gap/applybackend/web/SubmissionControllerTest.java b/src/test/java/gov/cabinetoffice/gap/applybackend/web/SubmissionControllerTest.java index cfe3ac4..c666317 100644 --- a/src/test/java/gov/cabinetoffice/gap/applybackend/web/SubmissionControllerTest.java +++ b/src/test/java/gov/cabinetoffice/gap/applybackend/web/SubmissionControllerTest.java @@ -1173,7 +1173,7 @@ void updateAttachment_UpdatesExpectedAttachment(UpdateAttachmentDto update, Gran final ArgumentCaptor attachmentCaptor = ArgumentCaptor.forClass(GrantAttachment.class); - final ResponseEntity methodResponse = controllerUnderTest.updateAttachment(SUBMISSION_ID, QUESTION_ID_1, update, "topSecretKey"); + final ResponseEntity methodResponse = controllerUnderTest.updateAttachment(SUBMISSION_ID, QUESTION_ID_1, update); assertThat(methodResponse.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(methodResponse.getBody()).isEqualTo("Attachment Updated"); @@ -1184,13 +1184,7 @@ void updateAttachment_UpdatesExpectedAttachment(UpdateAttachmentDto update, Gran assertThat(attachmentCaptor.getValue().getLocation()).isEqualTo(update.getUri()); } - @ParameterizedTest - @MethodSource("provideGrantAttachmentUpdates") - void updateAttachment_unauthenticatedError(UpdateAttachmentDto update) { - doThrow(new UnauthorizedException("Unauthorized oh nooo")).when(secretAuthService).authenticateSecret(anyString()); - assertThrows(UnauthorizedException.class, () -> controllerUnderTest.updateAttachment(SUBMISSION_ID, QUESTION_ID_1, update, "topSecretKey")); - } } } } \ No newline at end of file