diff --git a/core/src/main/java/google/registry/module/RequestComponent.java b/core/src/main/java/google/registry/module/RequestComponent.java index 954aecb5f..7645e835f 100644 --- a/core/src/main/java/google/registry/module/RequestComponent.java +++ b/core/src/main/java/google/registry/module/RequestComponent.java @@ -110,6 +110,7 @@ import google.registry.tools.server.ToolsServerModule; import google.registry.tools.server.VerifyOteAction; import google.registry.ui.server.console.ConsoleDomainGetAction; import google.registry.ui.server.console.ConsoleDomainListAction; +import google.registry.ui.server.console.ConsoleEppPasswordAction; import google.registry.ui.server.console.ConsoleUserDataAction; import google.registry.ui.server.console.RegistrarsAction; import google.registry.ui.server.console.settings.ContactAction; @@ -178,6 +179,8 @@ interface RequestComponent { ConsoleDomainListAction consoleDomainListAction(); + ConsoleEppPasswordAction consoleEppPasswordAction(); + ConsoleOteSetupAction consoleOteSetupAction(); ConsoleRegistrarCreatorAction consoleRegistrarCreatorAction(); diff --git a/core/src/main/java/google/registry/module/frontend/FrontendRequestComponent.java b/core/src/main/java/google/registry/module/frontend/FrontendRequestComponent.java index 33cbeeeec..bf40ef474 100644 --- a/core/src/main/java/google/registry/module/frontend/FrontendRequestComponent.java +++ b/core/src/main/java/google/registry/module/frontend/FrontendRequestComponent.java @@ -27,6 +27,7 @@ import google.registry.request.RequestModule; import google.registry.request.RequestScope; import google.registry.ui.server.console.ConsoleDomainGetAction; import google.registry.ui.server.console.ConsoleDomainListAction; +import google.registry.ui.server.console.ConsoleEppPasswordAction; import google.registry.ui.server.console.ConsoleUserDataAction; import google.registry.ui.server.console.RegistrarsAction; import google.registry.ui.server.console.settings.ContactAction; @@ -58,6 +59,8 @@ public interface FrontendRequestComponent { ConsoleDomainListAction consoleDomainListAction(); + ConsoleEppPasswordAction consoleEppPasswordAction(); + ConsoleOteSetupAction consoleOteSetupAction(); ConsoleRegistrarCreatorAction consoleRegistrarCreatorAction(); ConsoleUiAction consoleUiAction(); diff --git a/core/src/main/java/google/registry/ui/server/console/ConsoleApiAction.java b/core/src/main/java/google/registry/ui/server/console/ConsoleApiAction.java index 35f6e530f..a70cf2ca0 100644 --- a/core/src/main/java/google/registry/ui/server/console/ConsoleApiAction.java +++ b/core/src/main/java/google/registry/ui/server/console/ConsoleApiAction.java @@ -49,6 +49,7 @@ public abstract class ConsoleApiAction implements Runnable { } } + protected void postHandler(User user) { throw new UnsupportedOperationException("Console API POST handler not implemented"); } @@ -57,6 +58,11 @@ public abstract class ConsoleApiAction implements Runnable { throw new UnsupportedOperationException("Console API GET handler not implemented"); } + protected void setFailedResponse(String message, int code) { + consoleApiParams.response().setStatus(code); + consoleApiParams.response().setPayload(message); + } + private boolean verifyXSRF() { Optional maybeCookie = Arrays.stream(consoleApiParams.request().getCookies()) diff --git a/core/src/main/java/google/registry/ui/server/console/ConsoleEppPasswordAction.java b/core/src/main/java/google/registry/ui/server/console/ConsoleEppPasswordAction.java new file mode 100644 index 000000000..d69d6144e --- /dev/null +++ b/core/src/main/java/google/registry/ui/server/console/ConsoleEppPasswordAction.java @@ -0,0 +1,126 @@ +// Copyright 2024 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.ui.server.console; + +import static google.registry.persistence.transaction.TransactionManagerFactory.tm; +import static google.registry.request.Action.Method.POST; +import static google.registry.request.RequestParameters.extractRequiredParameter; + +import com.google.api.client.http.HttpStatusCodes; +import com.google.common.base.Throwables; +import com.google.common.flogger.FluentLogger; +import google.registry.flows.EppException.AuthenticationErrorException; +import google.registry.flows.PasswordOnlyTransportCredentials; +import google.registry.groups.GmailClient; +import google.registry.model.console.User; +import google.registry.model.registrar.Registrar; +import google.registry.request.Action; +import google.registry.request.HttpException.BadRequestException; +import google.registry.request.auth.Auth; +import google.registry.request.auth.AuthenticatedRegistrarAccessor; +import google.registry.request.auth.AuthenticatedRegistrarAccessor.RegistrarAccessDeniedException; +import google.registry.ui.server.registrar.ConsoleApiParams; +import google.registry.util.EmailMessage; +import java.util.Optional; +import javax.inject.Inject; +import javax.mail.internet.InternetAddress; + +@Action( + service = Action.Service.DEFAULT, + path = ConsoleEppPasswordAction.PATH, + method = {POST}, + auth = Auth.AUTH_PUBLIC_LOGGED_IN) +public class ConsoleEppPasswordAction extends ConsoleApiAction { + + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + protected static final String EMAIL_SUBJ = "EPP password update confirmation"; + protected static final String EMAIL_BODY = + "Dear %s,\n" + "This is to confirm that your account password has been changed."; + + public static final String PATH = "/console-api/eppPassword"; + + private final PasswordOnlyTransportCredentials credentials = + new PasswordOnlyTransportCredentials(); + private final AuthenticatedRegistrarAccessor registrarAccessor; + private final GmailClient gmailClient; + + @Inject + public ConsoleEppPasswordAction( + ConsoleApiParams consoleApiParams, + AuthenticatedRegistrarAccessor registrarAccessor, + GmailClient gmailClient) { + super(consoleApiParams); + this.registrarAccessor = registrarAccessor; + this.gmailClient = gmailClient; + } + + @Override + protected void postHandler(User user) { + String registrarId; + String oldPassword; + String newPassword; + String newPasswordRepeat; + + try { + registrarId = extractRequiredParameter(consoleApiParams.request(), "registrarId"); + oldPassword = extractRequiredParameter(consoleApiParams.request(), "oldPassword"); + newPassword = extractRequiredParameter(consoleApiParams.request(), "newPassword"); + newPasswordRepeat = extractRequiredParameter(consoleApiParams.request(), "newPasswordRepeat"); + } catch (BadRequestException e) { + setFailedResponse(e.getMessage(), HttpStatusCodes.STATUS_CODE_BAD_REQUEST); + return; + } + + if (!newPassword.equals(newPasswordRepeat)) { + setFailedResponse("New password fields don't match", HttpStatusCodes.STATUS_CODE_BAD_REQUEST); + return; + } + + Registrar registrar; + try { + registrar = registrarAccessor.getRegistrar(registrarId); + } catch (RegistrarAccessDeniedException e) { + setFailedResponse(e.getMessage(), HttpStatusCodes.STATUS_CODE_NOT_FOUND); + return; + } + + try { + credentials.validate(registrar, oldPassword); + } catch (AuthenticationErrorException e) { + setFailedResponse(e.getMessage(), HttpStatusCodes.STATUS_CODE_FORBIDDEN); + return; + } + + try { + tm().transact( + () -> { + tm().put(registrar.asBuilder().setPassword(newPassword).build()); + this.gmailClient.sendEmail( + EmailMessage.create( + EMAIL_SUBJ, + String.format(EMAIL_BODY, registrar.getRegistrarName()), + new InternetAddress(registrar.getEmailAddress(), true))); + }); + } catch (Throwable e) { + logger.atWarning().withCause(e).log("Failed to update password."); + String message = + Optional.ofNullable(Throwables.getRootCause(e).getMessage()).orElse("Unspecified error"); + setFailedResponse(message, HttpStatusCodes.STATUS_CODE_SERVER_ERROR); + } + + consoleApiParams.response().setStatus(HttpStatusCodes.STATUS_CODE_OK); + } +} diff --git a/core/src/test/java/google/registry/ui/server/console/ConsoleEppPasswordActionTest.java b/core/src/test/java/google/registry/ui/server/console/ConsoleEppPasswordActionTest.java new file mode 100644 index 000000000..f8ea41bbd --- /dev/null +++ b/core/src/test/java/google/registry/ui/server/console/ConsoleEppPasswordActionTest.java @@ -0,0 +1,210 @@ +// Copyright 2024 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.ui.server.console; + +import static com.google.common.truth.Truth.assertThat; +import static google.registry.request.auth.AuthenticatedRegistrarAccessor.Role.OWNER; +import static google.registry.testing.DatabaseHelper.loadRegistrar; +import static google.registry.testing.DatabaseHelper.persistNewRegistrar; +import static google.registry.testing.DatabaseHelper.persistResource; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.api.client.http.HttpStatusCodes; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSetMultimap; +import com.google.gson.Gson; +import google.registry.flows.PasswordOnlyTransportCredentials; +import google.registry.groups.GmailClient; +import google.registry.model.console.GlobalRole; +import google.registry.model.console.User; +import google.registry.model.console.UserRoles; +import google.registry.model.registrar.Registrar; +import google.registry.persistence.transaction.JpaTestExtensions; +import google.registry.request.Action; +import google.registry.request.auth.AuthResult; +import google.registry.request.auth.AuthenticatedRegistrarAccessor; +import google.registry.request.auth.UserAuthInfo; +import google.registry.testing.FakeConsoleApiParams; +import google.registry.testing.FakeResponse; +import google.registry.tools.GsonUtils; +import google.registry.ui.server.registrar.ConsoleApiParams; +import google.registry.util.EmailMessage; +import jakarta.servlet.http.Cookie; +import java.util.Optional; +import javax.mail.internet.AddressException; +import javax.mail.internet.InternetAddress; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +class ConsoleEppPasswordActionTest { + private static final Gson GSON = GsonUtils.provideGson(); + private ConsoleApiParams consoleApiParams; + protected PasswordOnlyTransportCredentials credentials = new PasswordOnlyTransportCredentials(); + private FakeResponse response; + private GmailClient gmailClient = mock(GmailClient.class); + + @RegisterExtension + final JpaTestExtensions.JpaIntegrationTestExtension jpa = + new JpaTestExtensions.Builder().buildIntegrationTestExtension(); + + @BeforeEach + void beforeEach() { + Registrar registrar = persistNewRegistrar("registrarId"); + registrar = + registrar + .asBuilder() + .setType(Registrar.Type.TEST) + .setIanaIdentifier(null) + .setPassword("foobar") + .setEmailAddress("testEmail@google.com") + .build(); + persistResource(registrar); + } + + @Test + void testFailure_emptyParams() { + ConsoleEppPasswordAction action = createAction(); + action.run(); + assertThat(((FakeResponse) consoleApiParams.response()).getStatus()) + .isEqualTo(HttpStatusCodes.STATUS_CODE_BAD_REQUEST); + assertThat(((FakeResponse) consoleApiParams.response()).getPayload()) + .isEqualTo("Missing parameter: registrarId"); + } + + @Test + void testFailure_passwordsDontMatch() { + ConsoleEppPasswordAction action = createAction(); + setParams( + ImmutableMap.of( + "registrarId", + "registrarId", + "oldPassword", + "oldPassword", + "newPassword", + "newPassword", + "newPasswordRepeat", + "newPasswordRepeat")); + action.run(); + assertThat(((FakeResponse) consoleApiParams.response()).getStatus()) + .isEqualTo(HttpStatusCodes.STATUS_CODE_BAD_REQUEST); + assertThat(((FakeResponse) consoleApiParams.response()).getPayload()) + .contains("New password fields don't match"); + } + + @Test + void testFailure_existingPasswordIncorrect() { + ConsoleEppPasswordAction action = createAction(); + setParams( + ImmutableMap.of( + "registrarId", + "registrarId", + "oldPassword", + "oldPassword", + "newPassword", + "randomPasword", + "newPasswordRepeat", + "randomPasword")); + action.run(); + assertThat(((FakeResponse) consoleApiParams.response()).getStatus()) + .isEqualTo(HttpStatusCodes.STATUS_CODE_FORBIDDEN); + assertThat(((FakeResponse) consoleApiParams.response()).getPayload()) + .contains("Registrar password is incorrect"); + } + + @Test + void testSuccess_sendsConfirmationEmail() throws AddressException { + ConsoleEppPasswordAction action = createAction(); + setParams( + ImmutableMap.of( + "registrarId", + "registrarId", + "oldPassword", + "foobar", + "newPassword", + "randomPassword", + "newPasswordRepeat", + "randomPassword")); + action.run(); + verify(gmailClient, times(1)) + .sendEmail( + EmailMessage.create( + "EPP password update confirmation", + "Dear registrarId name,\n" + + "This is to confirm that your account password has been changed.", + new InternetAddress("testEmail@google.com"))); + assertThat(((FakeResponse) consoleApiParams.response()).getStatus()) + .isEqualTo(HttpStatusCodes.STATUS_CODE_OK); + } + + @Test + void testSuccess_passwordUpdated() throws AddressException { + ConsoleEppPasswordAction action = createAction(); + setParams( + ImmutableMap.of( + "registrarId", + "registrarId", + "oldPassword", + "foobar", + "newPassword", + "randomPassword", + "newPasswordRepeat", + "randomPassword")); + action.run(); + assertThat(((FakeResponse) consoleApiParams.response()).getStatus()) + .isEqualTo(HttpStatusCodes.STATUS_CODE_OK); + assertDoesNotThrow( + () -> { + credentials.validate(loadRegistrar("registrarId"), "randomPassword"); + }); + } + + private void setParams(ImmutableMap params) { + params.entrySet().stream() + .forEach( + entry -> { + when(consoleApiParams.request().getParameter(entry.getKey())) + .thenReturn(entry.getValue()); + }); + } + + private ConsoleEppPasswordAction createAction() { + response = new FakeResponse(); + User user = + new User.Builder() + .setEmailAddress("email@email.com") + .setUserRoles(new UserRoles.Builder().setGlobalRole(GlobalRole.FTE).build()) + .build(); + + AuthResult authResult = AuthResult.createUser(UserAuthInfo.create(user)); + consoleApiParams = FakeConsoleApiParams.get(Optional.of(authResult)); + AuthenticatedRegistrarAccessor authenticatedRegistrarAccessor = + AuthenticatedRegistrarAccessor.createForTesting( + ImmutableSetMultimap.of("registrarId", OWNER)); + Cookie cookie = + new Cookie( + consoleApiParams.xsrfTokenManager().X_CSRF_TOKEN, + consoleApiParams.xsrfTokenManager().generateToken("")); + when(consoleApiParams.request().getMethod()).thenReturn(Action.Method.POST.toString()); + when(consoleApiParams.request().getCookies()).thenReturn(new Cookie[] {cookie}); + + return new ConsoleEppPasswordAction( + consoleApiParams, authenticatedRegistrarAccessor, gmailClient); + } +} diff --git a/core/src/test/resources/google/registry/module/frontend/frontend_routing.txt b/core/src/test/resources/google/registry/module/frontend/frontend_routing.txt index 2615a5198..c708534cf 100644 --- a/core/src/test/resources/google/registry/module/frontend/frontend_routing.txt +++ b/core/src/test/resources/google/registry/module/frontend/frontend_routing.txt @@ -2,6 +2,7 @@ PATH CLASS METHODS OK AUT /_dr/epp EppTlsAction POST n API APP ADMIN /console-api/domain ConsoleDomainGetAction GET n API,LEGACY USER PUBLIC /console-api/domain-list ConsoleDomainListAction GET n API,LEGACY USER PUBLIC +/console-api/eppPassword ConsoleEppPasswordAction POST n API,LEGACY USER PUBLIC /console-api/registrars RegistrarsAction GET,POST n API,LEGACY USER PUBLIC /console-api/settings/contacts ContactAction GET,POST n API,LEGACY USER PUBLIC /console-api/settings/security SecurityAction POST n API,LEGACY USER PUBLIC @@ -14,4 +15,4 @@ PATH CLASS METHODS OK AUT /registrar-settings RegistrarSettingsAction POST n API,LEGACY USER PUBLIC /registry-lock-get RegistryLockGetAction GET n API,LEGACY USER PUBLIC /registry-lock-post RegistryLockPostAction POST n API,LEGACY USER PUBLIC -/registry-lock-verify RegistryLockVerifyAction GET n API,LEGACY NONE PUBLIC +/registry-lock-verify RegistryLockVerifyAction GET n API,LEGACY NONE PUBLIC \ No newline at end of file diff --git a/core/src/test/resources/google/registry/module/routing.txt b/core/src/test/resources/google/registry/module/routing.txt index cd0ad6e8e..748e1fe3a 100644 --- a/core/src/test/resources/google/registry/module/routing.txt +++ b/core/src/test/resources/google/registry/module/routing.txt @@ -57,6 +57,7 @@ PATH CLASS /check CheckApiAction GET n API NONE PUBLIC /console-api/domain ConsoleDomainGetAction GET n API,LEGACY USER PUBLIC /console-api/domain-list ConsoleDomainListAction GET n API,LEGACY USER PUBLIC +/console-api/eppPassword ConsoleEppPasswordAction POST n API,LEGACY USER PUBLIC /console-api/registrars RegistrarsAction GET,POST n API,LEGACY USER PUBLIC /console-api/settings/contacts ContactAction GET,POST n API,LEGACY USER PUBLIC /console-api/settings/security SecurityAction POST n API,LEGACY USER PUBLIC @@ -79,4 +80,4 @@ PATH CLASS /registry-lock-get RegistryLockGetAction GET n API,LEGACY USER PUBLIC /registry-lock-post RegistryLockPostAction POST n API,LEGACY USER PUBLIC /registry-lock-verify RegistryLockVerifyAction GET n API,LEGACY NONE PUBLIC -/whois/(*) WhoisHttpAction GET n API NONE PUBLIC +/whois/(*) WhoisHttpAction GET n API NONE PUBLIC \ No newline at end of file