diff --git a/account-management-api/api-contract/README.md b/account-management-api/api-contract/README.md index 2ffc489219e..c0fe5034da0 100644 --- a/account-management-api/api-contract/README.md +++ b/account-management-api/api-contract/README.md @@ -137,6 +137,45 @@ http DELETE :8080/v1/mfa-methods/delete-when-mfa-method-not-found/id http DELETE :8080/v1/mfa-methods/delete-when-cannot-delete-default-priority-mfa-method/id +######################################################################################################### +# AUTHENTICATE +######################################################################################################### + +# POST 204 success response (any valid email) +http POST :8080/authenticate \ + Content-Type:application/json \ + email="user@example.gov.uk" \ + password="test" + +# POST 400 error response (missing email) +http POST :8080/authenticate \ + Content-Type:application/json \ + password="test" + +# POST 400 error response (account does not exist) +http POST :8080/authenticate \ + Content-Type:application/json \ + email="nonexistent@example.gov.uk" \ + password="test" + +# POST 401 error response (invalid credentials) +http POST :8080/authenticate \ + Content-Type:application/json \ + email="invalid@example.gov.uk" \ + password="test" + +# POST 403 error response (blocked account) +http POST :8080/authenticate \ + Content-Type:application/json \ + email="blocked@example.gov.uk" \ + password="test" + +# POST 403 error response (suspended account) +http POST :8080/authenticate \ + Content-Type:application/json \ + email="suspended@example.gov.uk" \ + password="test" + ######################################################################################################### # UPDATE EMAIL ######################################################################################################### diff --git a/account-management-api/api-contract/mock/respond-with-examples-from-spec.groovy b/account-management-api/api-contract/mock/respond-with-examples-from-spec.groovy index 3b3697ead7e..d98f1e6c425 100644 --- a/account-management-api/api-contract/mock/respond-with-examples-from-spec.groovy +++ b/account-management-api/api-contract/mock/respond-with-examples-from-spec.groovy @@ -6,11 +6,25 @@ // // context.operation.responses["200"].content["application/json"].examples["user-with-single-mfa-type-app"]) +import groovy.json.JsonSlurper + def request = context.request def method = request.method.toLowerCase() +def path = request.path if (method == "get" || method == "post" || method == "put" || method == "delete") { - def publicSubjectId = context.request.pathParams.publicSubjectId + // Handle /authenticate endpoint + if (path == "/authenticate" && method == "post") { + handleAuthenticate(request) + return + } + + def publicSubjectId = context.request.pathParams?.publicSubjectId + + // Skip processing if no publicSubjectId + if (publicSubjectId == null) { + return + } def responseStatusCode def responseBody @@ -30,3 +44,73 @@ if (method == "get" || method == "post" || method == "put" || method == "delete" } } } + +def handleAuthenticate(request) { + def requestBody = [:] + try { + if (request.body) { + requestBody = new JsonSlurper().parseText(request.body) + } + } catch (Exception e) { + // Invalid JSON, treat as empty + } + + def email = requestBody?.email + def missingEmail = !email + + if (missingEmail) { + def response400 = context.operation.responses["400"] + def example = response400.content["application/json"].examples["post-when-request-is-missing-parameters"] + respond() + .withContent(example.value.toString()) + .withHeader("Content-Type", "application/json") + .withStatusCode(400) + return + } + + // 400 - Account does not exist + if (email == "nonexistent@example.gov.uk") { + def response400 = context.operation.responses["400"] + def example = response400.content["application/json"].examples["post-when-account-does-not-exist"] + respond() + .withContent(example.value.toString()) + .withHeader("Content-Type", "application/json") + .withStatusCode(400) + return + } + + // 401 - Invalid credentials + if (email == "invalid@example.gov.uk") { + def response401 = context.operation.responses["401"] + def example = response401.content["application/json"].examples["post-when-invalid-credentials"] + respond() + .withContent(example.value.toString()) + .withHeader("Content-Type", "application/json") + .withStatusCode(401) + return + } + + // 403 - Blocked account + if (email == "blocked@example.gov.uk") { + def response403 = context.operation.responses["403"] + def example = response403.content["application/json"].examples["post-when-user-has-blocked-intervention"] + respond() + .withContent(example.value.toString()) + .withHeader("Content-Type", "application/json") + .withStatusCode(403) + return + } + + // 403 - Suspended account + if (email == "suspended@example.gov.uk") { + def response403 = context.operation.responses["403"] + def example = response403.content["application/json"].examples["post-when-user-has-suspended-intervention"] + respond() + .withContent(example.value.toString()) + .withHeader("Content-Type", "application/json") + .withStatusCode(403) + return + } + + // Default to 204 success for any other valid email +} diff --git a/account-management-api/src/main/java/uk/gov/di/accountmanagement/entity/ActionSource.java b/account-management-api/src/main/java/uk/gov/di/accountmanagement/entity/ActionSource.java new file mode 100644 index 00000000000..3b5bf0680d7 --- /dev/null +++ b/account-management-api/src/main/java/uk/gov/di/accountmanagement/entity/ActionSource.java @@ -0,0 +1,10 @@ +package uk.gov.di.accountmanagement.entity; + +public enum ActionSource { + ACCOUNT_MANAGEMENT, + ACCOUNT_COMPONENTS; + + public String getValue() { + return name(); + } +} diff --git a/account-management-api/src/main/java/uk/gov/di/accountmanagement/entity/AuthenticateRequest.java b/account-management-api/src/main/java/uk/gov/di/accountmanagement/entity/AuthenticateRequest.java index 5f2f6ba0724..71d38b8e467 100644 --- a/account-management-api/src/main/java/uk/gov/di/accountmanagement/entity/AuthenticateRequest.java +++ b/account-management-api/src/main/java/uk/gov/di/accountmanagement/entity/AuthenticateRequest.java @@ -9,6 +9,10 @@ public class AuthenticateRequest { @Expose @Required private String password; + @Expose private PostAuthAction postAuthAction; + + @Expose private ActionSource actionSource; + public AuthenticateRequest() {} public AuthenticateRequest(String email, String password) { @@ -23,4 +27,12 @@ public String getEmail() { public String getPassword() { return password; } + + public PostAuthAction getPostAuthAction() { + return postAuthAction; + } + + public ActionSource getActionSource() { + return actionSource; + } } diff --git a/account-management-api/src/main/java/uk/gov/di/accountmanagement/entity/PostAuthAction.java b/account-management-api/src/main/java/uk/gov/di/accountmanagement/entity/PostAuthAction.java new file mode 100644 index 00000000000..0509e57f7c0 --- /dev/null +++ b/account-management-api/src/main/java/uk/gov/di/accountmanagement/entity/PostAuthAction.java @@ -0,0 +1,11 @@ +package uk.gov.di.accountmanagement.entity; + +public enum PostAuthAction { + UPDATE_EMAIL, + UPDATE_PASSWORD, + DELETE_ACCOUNT; + + public String getValue() { + return name(); + } +} diff --git a/account-management-api/src/main/java/uk/gov/di/accountmanagement/lambda/AuthenticateHandler.java b/account-management-api/src/main/java/uk/gov/di/accountmanagement/lambda/AuthenticateHandler.java index b9611a6a2d0..ed7285fca3c 100644 --- a/account-management-api/src/main/java/uk/gov/di/accountmanagement/lambda/AuthenticateHandler.java +++ b/account-management-api/src/main/java/uk/gov/di/accountmanagement/lambda/AuthenticateHandler.java @@ -7,7 +7,9 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.ThreadContext; +import uk.gov.di.accountmanagement.entity.ActionSource; import uk.gov.di.accountmanagement.entity.AuthenticateRequest; +import uk.gov.di.accountmanagement.entity.PostAuthAction; import uk.gov.di.accountmanagement.helpers.AuditHelper; import uk.gov.di.audit.AuditContext; import uk.gov.di.authentication.shared.entity.AccountInterventionsInboundResponse; @@ -30,11 +32,14 @@ import static uk.gov.di.authentication.shared.helpers.InstrumentationHelper.segmentedFunctionCall; import static uk.gov.di.authentication.shared.helpers.LogLineHelper.attachSessionIdToLogs; import static uk.gov.di.authentication.shared.helpers.LogLineHelper.attachTraceId; +import static uk.gov.di.authentication.shared.services.AuditService.MetadataPair.pair; public class AuthenticateHandler implements RequestHandler { private static final Logger LOG = LogManager.getLogger(AuthenticateHandler.class); + public static final String POST_AUTH_ACTION = "post_auth_action"; + public static final String ACTION_SOURCE = "action_source"; private final AuthenticationService authenticationService; private final Json objectMapper = SerializationService.getInstance(); @@ -166,7 +171,17 @@ public APIGatewayProxyResponseEvent authenticateRequestHandler( auditService.submitAuditEvent( AUTH_ACCOUNT_MANAGEMENT_AUTHENTICATE, auditContext, - AUDIT_EVENT_COMPONENT_ID_AUTH); + AUDIT_EVENT_COMPONENT_ID_AUTH, + pair( + POST_AUTH_ACTION, + Optional.ofNullable(loginRequest.getPostAuthAction()) + .map(PostAuthAction::getValue) + .orElse(AuditService.UNKNOWN)), + pair( + ACTION_SOURCE, + Optional.ofNullable(loginRequest.getActionSource()) + .map(ActionSource::getValue) + .orElse(AuditService.UNKNOWN))); return generateEmptySuccessApiGatewayResponse(); } catch (JsonException e) { diff --git a/account-management-api/src/test/java/uk/gov/di/accountmanagement/lambda/AuthenticateHandlerTest.java b/account-management-api/src/test/java/uk/gov/di/accountmanagement/lambda/AuthenticateHandlerTest.java index bfc545b31d3..e87c8b4a507 100644 --- a/account-management-api/src/test/java/uk/gov/di/accountmanagement/lambda/AuthenticateHandlerTest.java +++ b/account-management-api/src/test/java/uk/gov/di/accountmanagement/lambda/AuthenticateHandlerTest.java @@ -9,6 +9,8 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import uk.gov.di.accountmanagement.entity.ActionSource; +import uk.gov.di.accountmanagement.entity.PostAuthAction; import uk.gov.di.accountmanagement.helpers.AuditHelper; import uk.gov.di.audit.AuditContext; import uk.gov.di.authentication.shared.entity.*; @@ -34,6 +36,7 @@ import static org.mockito.Mockito.when; import static uk.gov.di.accountmanagement.constants.AccountManagementConstants.AUDIT_EVENT_COMPONENT_ID_AUTH; import static uk.gov.di.accountmanagement.domain.AccountManagementAuditableEvent.*; +import static uk.gov.di.authentication.shared.services.AuditService.MetadataPair.pair; import static uk.gov.di.authentication.sharedtest.helper.RequestEventHelper.contextWithSourceIp; import static uk.gov.di.authentication.sharedtest.matchers.APIGatewayProxyResponseEventMatcher.hasJsonBody; import static uk.gov.di.authentication.sharedtest.matchers.APIGatewayProxyResponseEventMatcher.hasStatus; @@ -111,21 +114,55 @@ void setUp() throws UnsuccessfulAccountInterventionsResponseException { new Intervention(1L), new State(false, false, false, false))); } - @Test - void shouldReturn204IfLoginIsSuccessful() { + private static Stream postAuthActionAndSourceValues() { + var postAuthActions = + java.util.List.of( + "not-a-valid-target-action", + PostAuthAction.DELETE_ACCOUNT.getValue(), + PostAuthAction.UPDATE_EMAIL.getValue(), + PostAuthAction.UPDATE_PASSWORD.getValue()); + var actionSources = + java.util.List.of( + "not-a-valid-action-source", + ActionSource.ACCOUNT_MANAGEMENT.getValue(), + ActionSource.ACCOUNT_COMPONENTS.getValue()); + return postAuthActions.stream() + .flatMap( + target -> + actionSources.stream().map(source -> Arguments.of(target, source))); + } + + @ParameterizedTest + @MethodSource("postAuthActionAndSourceValues") + void shouldReturn204IfLoginIsSuccessful(String postAuthAction, String actionSource) { when(authenticationService.getUserProfileByEmailMaybe(EMAIL)) .thenReturn(Optional.of(USER_PROFILE)); when(authenticationService.login(EMAIL, PASSWORD)).thenReturn(true); when(authenticationService.getPhoneNumber(EMAIL)).thenReturn(Optional.of(PHONE_NUMBER)); + event.setBody( + format( + "{ \"password\": \"%s\", \"email\": \"%s\", \"post_auth_action\": \"%s\", \"action_source\": \"%s\" }", + PASSWORD, EMAIL, postAuthAction, actionSource)); + APIGatewayProxyResponseEvent result = handler.handleRequest(event, context); assertThat(result, hasStatus(204)); + String expectedPostAuthAction = + "not-a-valid-target-action".equals(postAuthAction) + ? AuditService.UNKNOWN + : postAuthAction; + String expectedActionSource = + "not-a-valid-action-source".equals(actionSource) + ? AuditService.UNKNOWN + : actionSource; verify(auditService) .submitAuditEvent( AUTH_ACCOUNT_MANAGEMENT_AUTHENTICATE, auditContext.withSubjectId(clientSubjectId), - AUDIT_EVENT_COMPONENT_ID_AUTH); + AUDIT_EVENT_COMPONENT_ID_AUTH, + pair("post_auth_action", expectedPostAuthAction), + pair("action_source", expectedActionSource)); } @Test @@ -147,7 +184,9 @@ void shouldNotSendEncodedAuditDataIfHeaderNotPresent() { .withClientSessionId("unknown") .withSubjectId(clientSubjectId) .withTxmaAuditEncoded(Optional.empty()), - AUDIT_EVENT_COMPONENT_ID_AUTH); + AUDIT_EVENT_COMPONENT_ID_AUTH, + pair("post_auth_action", AuditService.UNKNOWN), + pair("action_source", AuditService.UNKNOWN)); } @Test @@ -289,7 +328,9 @@ void shouldReturn204IfAisCallEnabledAndUserIsSuspendedWithInterventions( .submitAuditEvent( AUTH_ACCOUNT_MANAGEMENT_AUTHENTICATE, auditContext.withSubjectId(clientSubjectId), - AUDIT_EVENT_COMPONENT_ID_AUTH); + AUDIT_EVENT_COMPONENT_ID_AUTH, + pair("post_auth_action", AuditService.UNKNOWN), + pair("action_source", AuditService.UNKNOWN)); } @Test diff --git a/ci/terraform/account-management/openapi_v2.yaml b/ci/terraform/account-management/openapi_v2.yaml index 108b64c1f31..13a0da80697 100644 --- a/ci/terraform/account-management/openapi_v2.yaml +++ b/ci/terraform/account-management/openapi_v2.yaml @@ -1,7 +1,7 @@ openapi: "3.0.1" info: title: "auth-account-management-method-management-api" - version: 1.0.2 + version: 1.1.0 description: Auth Account Management API servers: - url: "https://localhost:8080/" @@ -16,13 +16,59 @@ paths: uri: "${endpoint_modules.authenticate.integration_uri}" passthroughBehavior: "when_no_match" timeoutInMillis: 29000 + description: "Validates user credentials (email and password) for authentication. Optionally accepts post-authentication action context to indicate the user's intended next action after successful authentication." + summary: Authenticate user credentials + operationId: "authenticate" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/AuthenticateRequest" + examples: + authenticate-request: + summary: Authenticate user with optional target action and action source + value: + email: "user@example.gov.uk" + password: "" + postAuthAction: "UPDATE_EMAIL" + actionSource: "ACCOUNT_MANAGEMENT" + authenticate-request-no-target-action: + summary: Authenticate user request with no target action for backwards compatability + value: + email: "user@example.gov.uk" + password: "" + required: true responses: "204": description: Successful Operation. The password is correct for this user. "400": - description: One of the required fields is missing. + description: Bad Request - Missing parameters or account does not exist. + content: + application/json: + schema: + oneOf: + - $ref: "#/components/schemas/RequestIsMissingParameters" + - $ref: "#/components/schemas/AccountDoesNotExist" + examples: + post-when-request-is-missing-parameters: + value: + code: 1001 + message: "Request is missing parameters" + post-when-account-does-not-exist: + value: + code: 1010 + message: "An account with this email address does not exist" "401": description: This user does not have an account or the password is wrong. + content: + application/json: + schema: + $ref: "#/components/schemas/SimpleError" + examples: + post-when-invalid-credentials: + value: + code: 1002 + message: "Invalid email or password" "403": description: This user cannot authenticate because of a suspension. content: @@ -208,7 +254,7 @@ paths: priorityIdentifier: DEFAULT method: mfaMethodType: AUTH_APP - credential: "AAAABBBBCCCCCDDDDD55551111EEEE2222FFFF3333GGGG4444" + credential: "" methodVerified: true get-when-user-with-multiple-mfa-types-default-sms: summary: a user with a default mfa method of SMS, and a backup auth app method @@ -223,7 +269,7 @@ paths: priorityIdentifier: BACKUP method: mfaMethodType: AUTH_APP - credential: "AAAABBBBCCCCCDDDDD55551111EEEE2222FFFF3333GGGG4444" + credential: "" methodVerified: true "400": description: Bad Request @@ -331,7 +377,7 @@ paths: "method": { "mfaMethodType": "AUTH_APP", - "credential": "AAAABBBBCCCCCDDDDD55551111EEEE2222FFFF3333GGGG4444", + "credential": "", }, }, } @@ -370,7 +416,7 @@ paths: priorityIdentifier: BACKUP method: mfaMethodType: AUTH_APP - credential: "postAAAABBBBCCCCCDDDDD55551111EEEE2222FFFF3333GGGG4444" + credential: "post" methodVerified: true "400": description: Bad Request @@ -535,7 +581,7 @@ paths: priorityIdentifier: BACKUP method: mfaMethodType: AUTH_APP - credential: "AAAABBBBCCCCCDDDDD55551111EEEE2222FFFF3333GGGG4444" + credential: "" methodVerified: true put-when-user-with-multiple-mfa-types-default-sms: summary: a user with a default mfa method of SMS, and a backup auth app method after update @@ -550,7 +596,7 @@ paths: priorityIdentifier: BACKUP method: mfaMethodType: AUTH_APP - credential: "AAAABBBBCCCCCDDDDD55551111EEEE2222FFFF3333GGGG4444" + credential: "" methodVerified: true "400": description: Bad Request @@ -812,7 +858,7 @@ components: priorityIdentifier: DEFAULT method: mfaMethodType: AUTH_APP - credential: "AAAABBBBCCCCCDDDDD55551111EEEE2222FFFF3333GGGG4444" + credential: "" MfaMethodUpdate: required: @@ -843,7 +889,7 @@ components: priorityIdentifier: DEFAULT method: mfaMethodType: AUTH_APP - credential: "AAAABBBBCCCCCDDDDD55551111EEEE2222FFFF3333GGGG4444" + credential: "" MfaMethodUpdateRequest: required: @@ -857,7 +903,7 @@ components: priorityIdentifier: DEFAULT method: mfaMethodType: AUTH_APP - credential: "AAAABBBBCCCCCDDDDD55551111EEEE2222FFFF3333GGGG4444" + credential: "" SimpleError: type: object @@ -1092,6 +1138,14 @@ components: example: code: 1080 message: "Default method already exists, new one cannot be created." + UserAccountSuspended: + allOf: + - $ref: "#/components/schemas/SimpleError" + - type: object + properties: + code: + type: integer + enum: [1083] UserAccountBlocked: allOf: - $ref: "#/components/schemas/SimpleError" @@ -1182,6 +1236,41 @@ components: example: code: 1089 message: "Email address is denied" + AuthenticateRequest: + required: + - email + - password + type: object + properties: + email: + type: string + format: email + description: The user's email address + example: "user@example.gov.uk" + password: + type: string + description: The user's password + example: "" + postAuthAction: + type: string + enum: + - UPDATE_EMAIL + - UPDATE_PASSWORD + - DELETE_ACCOUNT + description: Optional field indicating the user's intended action + example: "UPDATE_EMAIL" + actionSource: + type: string + enum: + - ACCOUNT_MANAGEMENT + - ACCOUNT_COMPONENTS + description: Optional field indicating the source of the action + example: "ACCOUNT_MANAGEMENT" + example: + email: "user@example.gov.uk" + password: "" + postAuthAction: "UPDATE_EMAIL" + actionSource: "ACCOUNT_MANAGEMENT" securitySchemes: authorise-access-token: