diff --git a/oidc-api/src/main/java/uk/gov/di/authentication/oidc/lambda/LogoutHandler.java b/oidc-api/src/main/java/uk/gov/di/authentication/oidc/lambda/LogoutHandler.java index 084b8e6629..4fa985b2e8 100644 --- a/oidc-api/src/main/java/uk/gov/di/authentication/oidc/lambda/LogoutHandler.java +++ b/oidc-api/src/main/java/uk/gov/di/authentication/oidc/lambda/LogoutHandler.java @@ -10,6 +10,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.ThreadContext; +import uk.gov.di.orchestration.audit.TxmaAuditUser; import uk.gov.di.orchestration.shared.entity.ClientRegistry; import uk.gov.di.orchestration.shared.entity.Session; import uk.gov.di.orchestration.shared.helpers.CookieHelper; @@ -29,11 +30,13 @@ import static uk.gov.di.orchestration.shared.helpers.AuditHelper.attachTxmaAuditFieldFromHeaders; import static uk.gov.di.orchestration.shared.helpers.InstrumentationHelper.segmentedFunctionCall; +import static uk.gov.di.orchestration.shared.helpers.IpAddressHelper.extractIpAddress; import static uk.gov.di.orchestration.shared.helpers.LogLineHelper.LogFieldName.CLIENT_ID; import static uk.gov.di.orchestration.shared.helpers.LogLineHelper.LogFieldName.CLIENT_SESSION_ID; import static uk.gov.di.orchestration.shared.helpers.LogLineHelper.LogFieldName.GOVUK_SIGNIN_JOURNEY_ID; import static uk.gov.di.orchestration.shared.helpers.LogLineHelper.attachLogFieldToLogs; import static uk.gov.di.orchestration.shared.helpers.LogLineHelper.attachSessionIdToLogs; +import static uk.gov.di.orchestration.shared.helpers.PersistentIdHelper.extractPersistentIdFromCookieHeader; public class LogoutHandler implements RequestHandler { @@ -97,25 +100,31 @@ public APIGatewayProxyResponseEvent logoutRequestHandler(APIGatewayProxyRequestE () -> sessionService.getSessionFromSessionCookie(input.getHeaders())); attachSessionToLogsIfExists(sessionFromSessionCookie, input.getHeaders()); + var subjectId = sessionFromSessionCookie.map(Session::getInternalCommonSubjectIdentifier); + var sessionId = sessionFromSessionCookie.map(Session::getSessionId); + + var auditUser = + TxmaAuditUser.user() + .withIpAddress(extractIpAddress(input)) + .withPersistentSessionId( + extractPersistentIdFromCookieHeader(input.getHeaders())) + .withSessionId(sessionId.orElse(null)) + .withUserId(subjectId.orElse(null)); + Map queryStringParameters = input.getQueryStringParameters(); if (queryStringParameters == null || queryStringParameters.isEmpty()) { LOG.info("Returning default logout as no input parameters"); + getSessionAndDestroyIfExists(sessionFromSessionCookie); return logoutService.generateDefaultLogoutResponse( - Optional.empty(), - input, - Optional.empty(), - getSessionAndDestroyIfExists(sessionFromSessionCookie)); + Optional.empty(), auditUser, Optional.empty()); } Optional state = Optional.ofNullable(queryStringParameters.get("state")); Optional idTokenHint = Optional.ofNullable(queryStringParameters.get("id_token_hint")); if (idTokenHint.isEmpty()) { - return logoutService.generateDefaultLogoutResponse( - state, - input, - Optional.empty(), - getSessionAndDestroyIfExists(sessionFromSessionCookie)); + getSessionAndDestroyIfExists(sessionFromSessionCookie); + return logoutService.generateDefaultLogoutResponse(state, auditUser, Optional.empty()); } LOG.info("ID token hint is present"); @@ -129,8 +138,7 @@ public APIGatewayProxyResponseEvent logoutRequestHandler(APIGatewayProxyRequestE Optional.empty(), new ErrorObject( OAuth2Error.INVALID_REQUEST_CODE, "unable to validate id_token_hint"), - input, - Optional.empty(), + auditUser, Optional.empty()); } @@ -143,17 +151,14 @@ public APIGatewayProxyResponseEvent logoutRequestHandler(APIGatewayProxyRequestE return logoutService.generateErrorLogoutResponse( Optional.empty(), new ErrorObject(OAuth2Error.INVALID_REQUEST_CODE, "invalid id_token_hint"), - input, - Optional.empty(), + auditUser, Optional.empty()); } if (audience.isEmpty()) { + getSessionAndDestroyIfExists(sessionFromSessionCookie); return logoutService.generateDefaultLogoutResponse( - Optional.empty(), - input, - Optional.empty(), - getSessionAndDestroyIfExists(sessionFromSessionCookie)); + Optional.empty(), auditUser, Optional.empty()); } final String clientID = audience.get(); @@ -162,12 +167,12 @@ public APIGatewayProxyResponseEvent logoutRequestHandler(APIGatewayProxyRequestE Optional clientRegistry = dynamoClientService.getClient(clientID); if (clientRegistry.isEmpty()) { LOG.warn("Client not found in ClientRegistry"); + getSessionAndDestroyIfExists(sessionFromSessionCookie); return logoutService.generateErrorLogoutResponse( state, new ErrorObject(OAuth2Error.UNAUTHORIZED_CLIENT_CODE, "client not found"), - input, - Optional.of(clientID), - getSessionAndDestroyIfExists(sessionFromSessionCookie)); + auditUser, + Optional.of(clientID)); } Optional postLogoutRedirectUri = @@ -175,22 +180,20 @@ public APIGatewayProxyResponseEvent logoutRequestHandler(APIGatewayProxyRequestE if (postLogoutRedirectUri.isEmpty()) { LOG.info( "post_logout_redirect_uri is NOT present in logout request. Generating default logout response"); + getSessionAndDestroyIfExists(sessionFromSessionCookie); return logoutService.generateDefaultLogoutResponse( - state, - input, - Optional.of(clientID), - getSessionAndDestroyIfExists(sessionFromSessionCookie)); + state, auditUser, Optional.of(clientID)); } if (!postLogoutRedirectUriInClientReg(postLogoutRedirectUri, clientRegistry)) { + getSessionAndDestroyIfExists(sessionFromSessionCookie); return logoutService.generateErrorLogoutResponse( state, new ErrorObject( OAuth2Error.INVALID_REQUEST_CODE, "client registry does not contain post_logout_redirect_uri"), - input, - Optional.of(clientID), - getSessionAndDestroyIfExists(sessionFromSessionCookie)); + auditUser, + Optional.of(clientID)); } if (sessionFromSessionCookie.isPresent()) { @@ -202,7 +205,7 @@ public APIGatewayProxyResponseEvent logoutRequestHandler(APIGatewayProxyRequestE clientID, postLogoutRedirectUri.get(), state, - input)); + auditUser)); } else { return segmentedFunctionCall( @@ -212,9 +215,8 @@ public APIGatewayProxyResponseEvent logoutRequestHandler(APIGatewayProxyRequestE URI.create(postLogoutRedirectUri.get()), state, Optional.empty(), - input, - Optional.of(clientID), - Optional.empty())); + auditUser, + Optional.of(clientID))); } } @@ -267,16 +269,11 @@ private APIGatewayProxyResponseEvent logout( String clientID, String uri, Optional state, - APIGatewayProxyRequestEvent input) { + TxmaAuditUser auditUser) { segmentedFunctionCall("destroySessions", () -> logoutService.destroySessions(session)); cloudwatchMetricsService.incrementLogout(Optional.of(clientID)); return logoutService.generateLogoutResponse( - URI.create(uri), - state, - Optional.empty(), - input, - Optional.of(clientID), - Optional.of(session.getSessionId())); + URI.create(uri), state, Optional.empty(), auditUser, Optional.of(clientID)); } } diff --git a/oidc-api/src/test/java/uk/gov/di/authentication/oidc/lambda/LogoutHandlerTest.java b/oidc-api/src/test/java/uk/gov/di/authentication/oidc/lambda/LogoutHandlerTest.java index f613861c62..7a45d37e86 100644 --- a/oidc-api/src/test/java/uk/gov/di/authentication/oidc/lambda/LogoutHandlerTest.java +++ b/oidc-api/src/test/java/uk/gov/di/authentication/oidc/lambda/LogoutHandlerTest.java @@ -18,6 +18,7 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; +import uk.gov.di.orchestration.audit.TxmaAuditUser; import uk.gov.di.orchestration.shared.entity.ClientRegistry; import uk.gov.di.orchestration.shared.helpers.CookieHelper; import uk.gov.di.orchestration.shared.helpers.IdGenerator; @@ -47,6 +48,8 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; +import static uk.gov.di.orchestration.shared.helpers.IpAddressHelper.extractIpAddress; +import static uk.gov.di.orchestration.shared.helpers.PersistentIdHelper.extractPersistentIdFromCookieHeader; import static uk.gov.di.orchestration.sharedtest.helper.RequestEventHelper.contextWithSourceIp; import static uk.gov.di.orchestration.sharedtest.logging.LogEventMatcher.withMessageContaining; @@ -107,9 +110,9 @@ void setUp() throws JOSEException, ParseException { logoutService); when(configurationService.getDefaultLogoutURI()).thenReturn(DEFAULT_LOGOUT_URI); when(configurationService.getInternalSectorUri()).thenReturn(INTERNAL_SECTOR_URI); - when(logoutService.generateLogoutResponse(any(), any(), any(), any(), any(), any())) + when(logoutService.generateLogoutResponse(any(), any(), any(), any(), any())) .thenReturn(new APIGatewayProxyResponseEvent()); - when(logoutService.generateErrorLogoutResponse(any(), any(), any(), any(), any())) + when(logoutService.generateErrorLogoutResponse(any(), any(), any(), any())) .thenReturn(new APIGatewayProxyResponseEvent()); when(context.getAwsRequestId()).thenReturn("aws-session-id"); @@ -128,7 +131,10 @@ class Session { @BeforeEach void sessionExistsSetup() { - session = generateSession().setEmailAddress(EMAIL); + session = + generateSession() + .setEmailAddress(EMAIL) + .setInternalCommonSubjectIdentifier(SUBJECT.getValue()); idTokenHint = signedIDToken.serialize(); when(dynamoClientService.getClient("client-id")) .thenReturn(Optional.of(createClientRegistry())); @@ -159,9 +165,8 @@ void shouldDeleteSessionAndRedirectToClientLogoutUriForValidLogoutRequest() { CLIENT_LOGOUT_URI, Optional.of(STATE.toString()), Optional.empty(), - event, - audience, - Optional.of(SESSION_ID)); + getAuditUser(event), + audience); verify(cloudwatchMetricsService).incrementLogout(Optional.of("client-id")); } @@ -188,9 +193,8 @@ void shouldNotThrowWhenTryingToDeleteClientSessionWhichHasExpired() { CLIENT_LOGOUT_URI, Optional.of(STATE.toString()), Optional.empty(), - event, - audience, - Optional.of(SESSION_ID)); + getAuditUser(event), + audience); verify(cloudwatchMetricsService).incrementLogout(Optional.of("client-id")); } @@ -205,7 +209,7 @@ void shouldNotThrowWhenTryingToDeleteClientSessionWhichHasExpired() { verify(logoutService, times(1)).destroySessions(session); verify(logoutService) .generateDefaultLogoutResponse( - Optional.empty(), event, Optional.empty(), Optional.of(SESSION_ID)); + Optional.empty(), getAuditUser(event), Optional.empty()); } @Test @@ -228,9 +232,8 @@ void shouldRedirectToClientLogoutUriWhenNoCookieExists() { CLIENT_LOGOUT_URI, Optional.of(STATE.getValue()), Optional.empty(), - event, - audience, - Optional.empty()); + getAuditUserWhenNoCookie(event), + audience); verifyNoInteractions(cloudwatchMetricsService); } } @@ -239,7 +242,10 @@ void shouldRedirectToClientLogoutUriWhenNoCookieExists() { class IdToken { @Test void shouldRedirectToDefaultLogoutUriForValidLogoutRequestWithNoTokenHint() { - session = generateSession().setEmailAddress(EMAIL); + session = + generateSession() + .setEmailAddress(EMAIL) + .setInternalCommonSubjectIdentifier(SUBJECT.getValue()); APIGatewayProxyRequestEvent event = generateRequestEvent( Map.of( @@ -253,10 +259,7 @@ void shouldRedirectToDefaultLogoutUriForValidLogoutRequestWithNoTokenHint() { verify(logoutService, times(1)).destroySessions(session); verify(logoutService) .generateDefaultLogoutResponse( - Optional.of(STATE.toString()), - event, - Optional.empty(), - Optional.of(session.getSessionId())); + Optional.of(STATE.toString()), getAuditUser(event), Optional.empty()); } @Test @@ -274,15 +277,17 @@ void shouldRedirectToDefaultLogoutUriForValidLogoutRequestWithNoTokenHint() { verify(logoutService) .generateDefaultLogoutResponse( Optional.of(STATE.toString()), - event, - Optional.empty(), + getAuditUserWhenNoCookie(event), Optional.empty()); } @Test void shouldRedirectToDefaultLogoutUriWithErrorMessageWhenSignatureIdTokenIsInvalid() throws JOSEException { - session = generateSession().setEmailAddress(EMAIL); + session = + generateSession() + .setEmailAddress(EMAIL) + .setInternalCommonSubjectIdentifier(SUBJECT.getValue()); ECKey ecSigningKey = new ECKeyGenerator(Curve.P_256).algorithm(JWSAlgorithm.ES256).generate(); SignedJWT signedJWT = @@ -308,8 +313,7 @@ void shouldRedirectToDefaultLogoutUriWithErrorMessageWhenSignatureIdTokenIsInval new ErrorObject( OAuth2Error.INVALID_REQUEST_CODE, "unable to validate id_token_hint"), - event, - Optional.empty(), + getAuditUser(event), Optional.empty()); verifyNoInteractions(cloudwatchMetricsService); } @@ -321,7 +325,10 @@ class ClientIdAndPostLogoutRedirectUri { @Test void shouldRedirectToDefaultLogoutUriWithErrorMessageWhenClientIsNotFoundInClientRegistry() throws JOSEException, ParseException { - session = generateSession().setEmailAddress(EMAIL); + session = + generateSession() + .setEmailAddress(EMAIL) + .setInternalCommonSubjectIdentifier(SUBJECT.getValue()); ECKey ecSigningKey = new ECKeyGenerator(Curve.P_256).algorithm(JWSAlgorithm.ES256).generate(); SignedJWT signedJWT = @@ -346,9 +353,8 @@ void shouldRedirectToDefaultLogoutUriWithErrorMessageWhenClientIsNotFoundInClien Optional.of(STATE.getValue()), new ErrorObject( OAuth2Error.UNAUTHORIZED_CLIENT_CODE, "client not found"), - event, - signedJWT.getJWTClaimsSet().getAudience().stream().findFirst(), - Optional.of(session.getSessionId())); + getAuditUser(event), + signedJWT.getJWTClaimsSet().getAudience().stream().findFirst()); verifyNoInteractions(cloudwatchMetricsService); } @@ -378,15 +384,17 @@ void shouldRedirectToDefaultLogoutUriWithErrorMessageWhenClientIsNotFoundInClien Optional.of(STATE.getValue()), new ErrorObject( OAuth2Error.UNAUTHORIZED_CLIENT_CODE, "client not found"), - event, - signedJWT.getJWTClaimsSet().getAudience().stream().findFirst(), - Optional.empty()); + getAuditUserWhenNoCookie(event), + signedJWT.getJWTClaimsSet().getAudience().stream().findFirst()); verifyNoInteractions(cloudwatchMetricsService); } @Test void shouldRedirectToDefaultUriWhenLogoutRedirectUriIsMissing() { - session = generateSession().setEmailAddress(EMAIL); + session = + generateSession() + .setEmailAddress(EMAIL) + .setInternalCommonSubjectIdentifier(SUBJECT.getValue()); var idTokenHint = signedIDToken.serialize(); session.getClientSessions().add(CLIENT_SESSION_ID); @@ -404,10 +412,7 @@ void shouldRedirectToDefaultUriWhenLogoutRedirectUriIsMissing() { verify(logoutService, times(1)).destroySessions(session); verify(logoutService) .generateDefaultLogoutResponse( - Optional.of(STATE.toString()), - event, - audience, - Optional.of(session.getSessionId())); + Optional.of(STATE.toString()), getAuditUser(event), audience); } @Test @@ -427,14 +432,19 @@ void shouldNotTryToDeleteSessionWhenSessionDoesNotExistWhileLogoutRedirectUriIsM verify(logoutService, times(0)).destroySessions(session); verify(logoutService) .generateDefaultLogoutResponse( - Optional.of(STATE.toString()), event, audience, Optional.empty()); + Optional.of(STATE.toString()), + getAuditUserWhenNoCookie(event), + audience); } @Test void shouldRedirectToDefaultLogoutUriWithErrorMessageWhenLogoutUriInRequestDoesNotMatchClientRegistry() throws JOSEException, ParseException { - session = generateSession().setEmailAddress(EMAIL); + session = + generateSession() + .setEmailAddress(EMAIL) + .setInternalCommonSubjectIdentifier(SUBJECT.getValue()); ECKey ecSigningKey = new ECKeyGenerator(Curve.P_256).algorithm(JWSAlgorithm.ES256).generate(); SignedJWT signedJWT = @@ -461,9 +471,8 @@ void shouldNotTryToDeleteSessionWhenSessionDoesNotExistWhileLogoutRedirectUriIsM new ErrorObject( OAuth2Error.INVALID_REQUEST_CODE, "client registry does not contain post_logout_redirect_uri"), - event, - signedJWT.getJWTClaimsSet().getAudience().stream().findFirst(), - Optional.of(session.getSessionId())); + getAuditUser(event), + signedJWT.getJWTClaimsSet().getAudience().stream().findFirst()); verifyNoInteractions(cloudwatchMetricsService); } @@ -495,9 +504,8 @@ void shouldNotTryToDeleteSessionWhenSessionDoesNotExistWhileLogoutRedirectUriIsM new ErrorObject( OAuth2Error.INVALID_REQUEST_CODE, "client registry does not contain post_logout_redirect_uri"), - event, - signedJWT.getJWTClaimsSet().getAudience().stream().findFirst(), - Optional.empty()); + getAuditUserWhenNoCookie(event), + signedJWT.getJWTClaimsSet().getAudience().stream().findFirst()); verifyNoInteractions(cloudwatchMetricsService); } } @@ -555,4 +563,20 @@ private void setUpClientSession(String clientSessionId, String clientId) { when(dynamoClientService.getClient(clientId)) .thenReturn(Optional.of(new ClientRegistry().withClientID(clientId))); } + + private TxmaAuditUser getAuditUser(APIGatewayProxyRequestEvent event) { + return TxmaAuditUser.user() + .withIpAddress(extractIpAddress(event)) + .withPersistentSessionId(extractPersistentIdFromCookieHeader(event.getHeaders())) + .withSessionId(session.getSessionId()) + .withUserId(session.getInternalCommonSubjectIdentifier()); + } + + private TxmaAuditUser getAuditUserWhenNoCookie(APIGatewayProxyRequestEvent event) { + return TxmaAuditUser.user() + .withIpAddress(extractIpAddress(event)) + .withPersistentSessionId(extractPersistentIdFromCookieHeader(event.getHeaders())) + .withSessionId(null) + .withUserId(null); + } } diff --git a/orchestration-shared/src/main/java/uk/gov/di/orchestration/shared/services/LogoutService.java b/orchestration-shared/src/main/java/uk/gov/di/orchestration/shared/services/LogoutService.java index 1b6122a530..6c617aa1ee 100644 --- a/orchestration-shared/src/main/java/uk/gov/di/orchestration/shared/services/LogoutService.java +++ b/orchestration-shared/src/main/java/uk/gov/di/orchestration/shared/services/LogoutService.java @@ -8,8 +8,11 @@ import org.apache.logging.log4j.Logger; import uk.gov.di.orchestration.audit.TxmaAuditUser; import uk.gov.di.orchestration.shared.entity.AccountIntervention; +import uk.gov.di.orchestration.shared.entity.ClientRegistry; import uk.gov.di.orchestration.shared.entity.ResponseHeaders; import uk.gov.di.orchestration.shared.entity.Session; +import uk.gov.di.orchestration.shared.entity.UserProfile; +import uk.gov.di.orchestration.shared.helpers.ClientSubjectHelper; import java.net.URI; import java.net.URISyntaxException; @@ -20,6 +23,7 @@ import static uk.gov.di.orchestration.shared.helpers.ApiGatewayResponseHelper.generateApiGatewayProxyResponse; import static uk.gov.di.orchestration.shared.helpers.IpAddressHelper.extractIpAddress; import static uk.gov.di.orchestration.shared.helpers.PersistentIdHelper.extractPersistentIdFromCookieHeader; +import static uk.gov.di.orchestration.shared.services.AuditService.MetadataPair.pair; public class LogoutService { @@ -32,6 +36,7 @@ public class LogoutService { private final AuditService auditService; private final CloudwatchMetricsService cloudwatchMetricsService; private final BackChannelLogoutService backChannelLogoutService; + private final DynamoService dynamoService; public LogoutService(ConfigurationService configurationService) { this.configurationService = configurationService; @@ -41,6 +46,7 @@ public LogoutService(ConfigurationService configurationService) { this.auditService = new AuditService(configurationService); this.cloudwatchMetricsService = new CloudwatchMetricsService(); this.backChannelLogoutService = new BackChannelLogoutService(configurationService); + this.dynamoService = new DynamoService(configurationService); } public LogoutService( @@ -50,7 +56,8 @@ public LogoutService( ClientSessionService clientSessionService, AuditService auditService, CloudwatchMetricsService cloudwatchMetricsService, - BackChannelLogoutService backChannelLogoutService) { + BackChannelLogoutService backChannelLogoutService, + DynamoService dynamoService) { this.configurationService = configurationService; this.sessionService = sessionService; this.dynamoClientService = dynamoClientService; @@ -58,6 +65,7 @@ public LogoutService( this.auditService = auditService; this.cloudwatchMetricsService = cloudwatchMetricsService; this.backChannelLogoutService = backChannelLogoutService; + this.dynamoService = dynamoService; } public APIGatewayProxyResponseEvent handleAccountInterventionLogout( @@ -65,9 +73,17 @@ public APIGatewayProxyResponseEvent handleAccountInterventionLogout( APIGatewayProxyRequestEvent input, String clientId, AccountIntervention intervention) { + + var auditUser = + TxmaAuditUser.user() + .withIpAddress(extractIpAddress(input)) + .withPersistentSessionId( + extractPersistentIdFromCookieHeader(input.getHeaders())) + .withSessionId(session.getSessionId()) + .withUserId(session.getInternalCommonSubjectIdentifier()); + destroySessions(session); - return generateAccountInterventionLogoutResponse( - input, clientId, session.getSessionId(), intervention); + return generateAccountInterventionLogoutResponse(auditUser, clientId, intervention); } public void destroySessions(Session session) { @@ -95,9 +111,8 @@ public void destroySessions(Session session) { public APIGatewayProxyResponseEvent generateErrorLogoutResponse( Optional state, ErrorObject errorObject, - APIGatewayProxyRequestEvent input, - Optional clientId, - Optional sessionId) { + TxmaAuditUser auditUser, + Optional clientId) { LOG.info( "Generating Logout Error Response with code: {} and description: {}", errorObject.getCode(), @@ -106,34 +121,30 @@ public APIGatewayProxyResponseEvent generateErrorLogoutResponse( configurationService.getDefaultLogoutURI(), state, Optional.of(errorObject), - input, - clientId, - sessionId); + auditUser, + clientId); } public APIGatewayProxyResponseEvent generateDefaultLogoutResponse( - Optional state, - APIGatewayProxyRequestEvent input, - Optional clientId, - Optional sessionId) { + Optional state, TxmaAuditUser auditUser, Optional clientId) { LOG.info("Generating default Logout Response"); - sessionId.ifPresent(t -> cloudwatchMetricsService.incrementLogout(clientId)); + if (auditUser.sessionId() != null) { + cloudwatchMetricsService.incrementLogout(clientId); + } return generateLogoutResponse( configurationService.getDefaultLogoutURI(), state, Optional.empty(), - input, - clientId, - sessionId); + auditUser, + clientId); } public APIGatewayProxyResponseEvent generateLogoutResponse( URI logoutUri, Optional state, Optional errorObject, - APIGatewayProxyRequestEvent input, - Optional clientId, - Optional sessionId) { + TxmaAuditUser auditUser, + Optional clientId) { LOG.info("Generating Logout Response using URI: {}", logoutUri); URIBuilder uriBuilder = new URIBuilder(logoutUri); state.ifPresent(s -> uriBuilder.addParameter("state", s)); @@ -148,24 +159,13 @@ public APIGatewayProxyResponseEvent generateLogoutResponse( throw new RuntimeException("Unable to build URI"); } - var user = - TxmaAuditUser.user() - .withIpAddress(extractIpAddress(input)) - .withPersistentSessionId( - extractPersistentIdFromCookieHeader(input.getHeaders())) - .withSessionId(sessionId.orElse(null)); - - auditService.submitAuditEvent(LOG_OUT_SUCCESS, clientId.orElse(AuditService.UNKNOWN), user); - + sendAuditEvent(clientId, auditUser); return generateApiGatewayProxyResponse( 302, "", Map.of(ResponseHeaders.LOCATION, uri.toString()), null); } private APIGatewayProxyResponseEvent generateAccountInterventionLogoutResponse( - APIGatewayProxyRequestEvent input, - String clientId, - String sessionId, - AccountIntervention intervention) { + TxmaAuditUser auditUser, String clientId, AccountIntervention intervention) { URI redirectURI; if (intervention.getBlocked()) { redirectURI = configurationService.getAccountStatusBlockedURI(); @@ -179,11 +179,50 @@ private APIGatewayProxyResponseEvent generateAccountInterventionLogoutResponse( cloudwatchMetricsService.incrementLogout(Optional.of(clientId), Optional.of(intervention)); return generateLogoutResponse( - redirectURI, - Optional.empty(), - Optional.empty(), - input, - Optional.of(clientId), - Optional.of(sessionId)); + redirectURI, Optional.empty(), Optional.empty(), auditUser, Optional.of(clientId)); + } + + public Optional getRpPairwiseId(String subject, String clientId) { + try { + if (subject == null || clientId == null) { + LOG.warn("User or client ID is null while getting RP pairwise ID for audit event"); + return Optional.empty(); + } + UserProfile userProfile = dynamoService.getUserProfileFromSubject(subject); + Optional client = dynamoClientService.getClient(clientId); + if (client.isEmpty()) { + LOG.warn("Client not found while getting RP pairwise ID for audit event"); + return Optional.empty(); + } + return Optional.of( + ClientSubjectHelper.getSubject( + userProfile, + client.get(), + dynamoService, + configurationService.getInternalSectorUri()) + .getValue()); + } catch (Exception e) { + LOG.warn("Exception caught while getting RP pairwise ID for audit event"); + return Optional.empty(); + } + } + + private void sendAuditEvent(Optional clientId, TxmaAuditUser auditUser) { + if (clientId.isPresent()) { + Optional rpPairwiseId = getRpPairwiseId(auditUser.userId(), clientId.get()); + if (rpPairwiseId.isPresent()) { + auditService.submitAuditEvent( + LOG_OUT_SUCCESS, + clientId.orElse(AuditService.UNKNOWN), + auditUser, + pair("rpPairwiseId", rpPairwiseId.get())); + } else { + auditService.submitAuditEvent( + LOG_OUT_SUCCESS, clientId.orElse(AuditService.UNKNOWN), auditUser); + } + } else { + auditService.submitAuditEvent( + LOG_OUT_SUCCESS, clientId.orElse(AuditService.UNKNOWN), auditUser); + } } } diff --git a/orchestration-shared/src/test/java/uk/gov/di/orchestration/shared/services/LogoutServiceTest.java b/orchestration-shared/src/test/java/uk/gov/di/orchestration/shared/services/LogoutServiceTest.java index fe6a3b7e5a..b5524a846a 100644 --- a/orchestration-shared/src/test/java/uk/gov/di/orchestration/shared/services/LogoutServiceTest.java +++ b/orchestration-shared/src/test/java/uk/gov/di/orchestration/shared/services/LogoutServiceTest.java @@ -26,7 +26,9 @@ import uk.gov.di.orchestration.shared.entity.ClientSession; import uk.gov.di.orchestration.shared.entity.ResponseHeaders; import uk.gov.di.orchestration.shared.entity.Session; +import uk.gov.di.orchestration.shared.entity.UserProfile; import uk.gov.di.orchestration.shared.entity.VectorOfTrust; +import uk.gov.di.orchestration.shared.helpers.ClientSubjectHelper; import uk.gov.di.orchestration.shared.helpers.IdGenerator; import uk.gov.di.orchestration.shared.helpers.IpAddressHelper; import uk.gov.di.orchestration.shared.helpers.PersistentIdHelper; @@ -34,6 +36,7 @@ import java.net.URI; import java.net.URISyntaxException; +import java.nio.ByteBuffer; import java.text.ParseException; import java.time.LocalDateTime; import java.util.List; @@ -65,6 +68,7 @@ public class LogoutServiceTest { private final DynamoClientService dynamoClientService = mock(DynamoClientService.class); private final ClientSessionService clientSessionService = mock(ClientSessionService.class); private final AuditService auditService = mock(AuditService.class); + private final DynamoService dynamoService = mock(DynamoService.class); private final APIGatewayProxyRequestEvent event = mock(APIGatewayProxyRequestEvent.class); @@ -75,6 +79,7 @@ public class LogoutServiceTest { private static MockedStatic ipAddressHelper; private static MockedStatic persistentIdHelper; + private static MockedStatic clientSubjectHelper; private static final State STATE = new State(); private static final String INTERNAL_SECTOR_URI = "https://test.account.gov.uk"; @@ -98,20 +103,31 @@ public class LogoutServiceTest { private static final String ENVIRONMENT = "test"; + private static final UserProfile USER_PROFILE = + new UserProfile().withSubjectID("any").withSalt(ByteBuffer.allocateDirect(12345)); + private SignedJWT signedIDToken; private Optional audience; private Session session; private LogoutService logoutService; - private final TxmaAuditUser user = + private final TxmaAuditUser auditUser = + TxmaAuditUser.user() + .withIpAddress(IP_ADDRESS) + .withSessionId(SESSION_ID) + .withPersistentSessionId(PERSISTENT_SESSION_ID) + .withUserId(SUBJECT.getValue()); + private final TxmaAuditUser auditUserWhenNoCookie = TxmaAuditUser.user() .withIpAddress(IP_ADDRESS) .withSessionId(SESSION_ID) - .withPersistentSessionId(PERSISTENT_SESSION_ID); + .withPersistentSessionId(null) + .withUserId(null); @BeforeEach void setup() throws JOSEException, ParseException { ipAddressHelper = mockStatic(IpAddressHelper.class); persistentIdHelper = mockStatic(PersistentIdHelper.class); + clientSubjectHelper = mockStatic(ClientSubjectHelper.class); when(IpAddressHelper.extractIpAddress(any())).thenReturn(IP_ADDRESS); when(PersistentIdHelper.extractPersistentIdFromCookieHeader(event.getHeaders())) .thenReturn(PERSISTENT_SESSION_ID); @@ -131,7 +147,8 @@ void setup() throws JOSEException, ParseException { clientSessionService, auditService, cloudwatchMetricsService, - backChannelLogoutService); + backChannelLogoutService, + dynamoService); ECKey ecSigningKey = new ECKeyGenerator(Curve.P_256).algorithm(JWSAlgorithm.ES256).generate(); @@ -141,13 +158,17 @@ void setup() throws JOSEException, ParseException { SignedJWT idToken = SignedJWT.parse(signedIDToken.serialize()); audience = idToken.getJWTClaimsSet().getAudience().stream().findFirst(); - session = generateSession().setEmailAddress(EMAIL); + session = + generateSession() + .setEmailAddress(EMAIL) + .setInternalCommonSubjectIdentifier(SUBJECT.getValue()); } @AfterEach void teardown() { ipAddressHelper.close(); persistentIdHelper.close(); + clientSubjectHelper.close(); } @Test @@ -157,11 +178,10 @@ void successfullyReturnsClientLogoutResponse() { CLIENT_LOGOUT_URI, Optional.of(STATE.getValue()), Optional.empty(), - event, - Optional.of(audience.get()), - Optional.of(SESSION_ID)); + auditUser, + Optional.of(audience.get())); - verify(auditService).submitAuditEvent(LOG_OUT_SUCCESS, CLIENT_ID, user); + verify(auditService).submitAuditEvent(LOG_OUT_SUCCESS, CLIENT_ID, auditUser); assertThat(response, hasStatus(302)); assertThat( @@ -173,12 +193,9 @@ void successfullyReturnsClientLogoutResponse() { void successfullyReturnsDefaultLogoutResponseWithoutStateWhenStateIsAbsent() { APIGatewayProxyResponseEvent response = logoutService.generateDefaultLogoutResponse( - Optional.empty(), - event, - Optional.of(audience.get()), - Optional.of(SESSION_ID)); + Optional.empty(), auditUser, Optional.of(audience.get())); - verify(auditService).submitAuditEvent(LOG_OUT_SUCCESS, CLIENT_ID, user); + verify(auditService).submitAuditEvent(LOG_OUT_SUCCESS, CLIENT_ID, auditUser); verify(cloudwatchMetricsService).incrementLogout(Optional.of(CLIENT_ID)); assertThat(response, hasStatus(302)); @@ -191,12 +208,9 @@ void successfullyReturnsDefaultLogoutResponseWithoutStateWhenStateIsAbsent() { void successfullyReturnsDefaultLogoutResponseWithStateWhenStateIsPresent() { APIGatewayProxyResponseEvent response = logoutService.generateDefaultLogoutResponse( - Optional.of(STATE.getValue()), - event, - Optional.of(audience.get()), - Optional.of(SESSION_ID)); + Optional.of(STATE.getValue()), auditUser, Optional.of(audience.get())); - verify(auditService).submitAuditEvent(LOG_OUT_SUCCESS, CLIENT_ID, user); + verify(auditService).submitAuditEvent(LOG_OUT_SUCCESS, CLIENT_ID, auditUser); verify(cloudwatchMetricsService).incrementLogout(Optional.of(CLIENT_ID)); assertThat(response, hasStatus(302)); @@ -211,11 +225,10 @@ void successfullyReturnsErrorLogoutResponse() throws URISyntaxException { logoutService.generateErrorLogoutResponse( Optional.empty(), new ErrorObject(OAuth2Error.INVALID_REQUEST_CODE, "invalid session"), - event, - Optional.empty(), - Optional.of(SESSION_ID)); + auditUser, + Optional.empty()); - verify(auditService).submitAuditEvent(LOG_OUT_SUCCESS, AuditService.UNKNOWN, user); + verify(auditService).submitAuditEvent(LOG_OUT_SUCCESS, AuditService.UNKNOWN, auditUser); verifyNoInteractions(cloudwatchMetricsService); assertThat(response, hasStatus(302)); @@ -240,7 +253,7 @@ void destroysSessionsAndReturnsAccountInterventionLogoutResponseWhenAccountIsBlo verify(clientSessionService).deleteStoredClientSession(session.getClientSessions().get(0)); verify(sessionService).deleteSessionFromRedis(session.getSessionId()); - verify(auditService).submitAuditEvent(LOG_OUT_SUCCESS, CLIENT_ID, user); + verify(auditService).submitAuditEvent(LOG_OUT_SUCCESS, CLIENT_ID, auditUser); verify(cloudwatchMetricsService) .incrementLogout(Optional.of(CLIENT_ID), Optional.of(intervention)); @@ -261,7 +274,7 @@ void destroysSessionsAndReturnsAccountInterventionLogoutResponseWhenAccountIsSus verify(clientSessionService).deleteStoredClientSession(session.getClientSessions().get(0)); verify(sessionService).deleteSessionFromRedis(session.getSessionId()); - verify(auditService).submitAuditEvent(LOG_OUT_SUCCESS, CLIENT_ID, user); + verify(auditService).submitAuditEvent(LOG_OUT_SUCCESS, CLIENT_ID, auditUser); verify(cloudwatchMetricsService) .incrementLogout(Optional.of(CLIENT_ID), Optional.of(intervention)); @@ -286,12 +299,13 @@ void shouldDeleteSessionFromRedisWhenNoCookieExists() { CLIENT_LOGOUT_URI, Optional.of(STATE.getValue()), Optional.empty(), - input, - Optional.empty(), - Optional.of(SESSION_ID)); + auditUserWhenNoCookie, + Optional.empty()); verify(sessionService, times(0)).deleteSessionFromRedis(SESSION_ID); verifyNoInteractions(cloudwatchMetricsService); + verify(auditService) + .submitAuditEvent(LOG_OUT_SUCCESS, AuditService.UNKNOWN, auditUserWhenNoCookie); } @Test @@ -333,6 +347,29 @@ void throwsWhenGenerateAccountInterventionLogoutResponseCalledInappropriately() assertEquals("Account status must be blocked or suspended", exception.getMessage()); } + @Test + void includesRpPairwiseIdInLogOutSuccessAuditEventWhenItIsAvailable() { + Subject rpSubject = new Subject(); + when(dynamoClientService.getClient(CLIENT_ID)) + .thenReturn(Optional.of(new ClientRegistry().withClientID(CLIENT_ID))); + when(dynamoService.getUserProfileFromSubject(any())).thenReturn(USER_PROFILE); + when(ClientSubjectHelper.getSubject(any(), any(), any(), any())).thenReturn(rpSubject); + + logoutService.generateLogoutResponse( + CLIENT_LOGOUT_URI, + Optional.of(STATE.getValue()), + Optional.empty(), + auditUser, + Optional.of(audience.get())); + + verify(auditService) + .submitAuditEvent( + LOG_OUT_SUCCESS, + CLIENT_ID, + auditUser, + AuditService.MetadataPair.pair("rpPairwiseId", rpSubject.getValue())); + } + private Session generateSession() { return new Session(SESSION_ID).addClientSession(CLIENT_SESSION_ID); }