From 2948dcc1bebfacb71b0941894341dad16fb92f97 Mon Sep 17 00:00:00 2001 From: gbrodman Date: Thu, 17 Jul 2025 17:33:29 -0400 Subject: [PATCH] Add password reset request and verify console actions (#2775) This works fairly similarly to the registry lock request and verification mechanism. The request action generates a UUI which is emailed (in link form) to the user in question. The frontend will send a request to the verify action with the UUID and hopefully the action should be finalized. EPP password requests can be sent by anyone with edit-registrar permissions and must be approved by an admin POC email. Registry lock password resets can only be sent by primary contacts, and are verified/performed by the user in question. --- .../registry/module/RequestComponent.java | 12 +- .../frontend/FrontendRequestComponent.java | 10 +- .../ui/server/console/ConsoleModule.java | 24 ++ .../console/PasswordResetRequestAction.java | 150 ++++++++++++ .../console/PasswordResetVerifyAction.java | 131 +++++++++++ .../registry/testing/DatabaseHelper.java | 2 + .../ConsoleUpdateRegistrarActionTest.java | 2 +- .../PasswordResetRequestActionTest.java | 218 ++++++++++++++++++ .../PasswordResetVerifyActionTest.java | 199 ++++++++++++++++ .../module/frontend/frontend_routing.txt | 40 ++-- .../google/registry/module/routing.txt | 4 +- 11 files changed, 766 insertions(+), 26 deletions(-) create mode 100644 core/src/main/java/google/registry/ui/server/console/PasswordResetRequestAction.java create mode 100644 core/src/main/java/google/registry/ui/server/console/PasswordResetVerifyAction.java create mode 100644 core/src/test/java/google/registry/ui/server/console/PasswordResetRequestActionTest.java create mode 100644 core/src/test/java/google/registry/ui/server/console/PasswordResetVerifyActionTest.java diff --git a/core/src/main/java/google/registry/module/RequestComponent.java b/core/src/main/java/google/registry/module/RequestComponent.java index 446a988b7..a945d3efb 100644 --- a/core/src/main/java/google/registry/module/RequestComponent.java +++ b/core/src/main/java/google/registry/module/RequestComponent.java @@ -122,6 +122,8 @@ import google.registry.ui.server.console.ConsoleRegistryLockVerifyAction; import google.registry.ui.server.console.ConsoleUpdateRegistrarAction; import google.registry.ui.server.console.ConsoleUserDataAction; import google.registry.ui.server.console.ConsoleUsersAction; +import google.registry.ui.server.console.PasswordResetRequestAction; +import google.registry.ui.server.console.PasswordResetVerifyAction; import google.registry.ui.server.console.RegistrarsAction; import google.registry.ui.server.console.domains.ConsoleBulkDomainAction; import google.registry.ui.server.console.settings.ContactAction; @@ -249,6 +251,10 @@ interface RequestComponent { NordnVerifyAction nordnVerifyAction(); + PasswordResetRequestAction passwordResetRequestAction(); + + PasswordResetVerifyAction passwordResetVerifyAction(); + PublishDnsUpdatesAction publishDnsUpdatesAction(); PublishInvoicesAction uploadInvoicesAction(); @@ -281,6 +287,8 @@ interface RequestComponent { RdapNameserverSearchAction rdapNameserverSearchAction(); + RdapRegistrarFieldsAction rdapRegistrarFieldsAction(); + RdeReportAction rdeReportAction(); RdeReporter rdeReporter(); @@ -332,9 +340,7 @@ interface RequestComponent { WhoisAction whoisAction(); WhoisHttpAction whoisHttpAction(); - - RdapRegistrarFieldsAction rdapRegistrarFieldsAction(); - + WipeOutContactHistoryPiiAction wipeOutContactHistoryPiiAction(); @Subcomponent.Builder 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 4ca3b4d96..50a7886d1 100644 --- a/core/src/main/java/google/registry/module/frontend/FrontendRequestComponent.java +++ b/core/src/main/java/google/registry/module/frontend/FrontendRequestComponent.java @@ -38,6 +38,8 @@ import google.registry.ui.server.console.ConsoleRegistryLockVerifyAction; import google.registry.ui.server.console.ConsoleUpdateRegistrarAction; import google.registry.ui.server.console.ConsoleUserDataAction; import google.registry.ui.server.console.ConsoleUsersAction; +import google.registry.ui.server.console.PasswordResetRequestAction; +import google.registry.ui.server.console.PasswordResetVerifyAction; import google.registry.ui.server.console.RegistrarsAction; import google.registry.ui.server.console.domains.ConsoleBulkDomainAction; import google.registry.ui.server.console.settings.ContactAction; @@ -84,6 +86,12 @@ public interface FrontendRequestComponent { FlowComponent.Builder flowComponentBuilder(); + PasswordResetRequestAction passwordResetRequestAction(); + + PasswordResetVerifyAction passwordResetVerifyAction(); + + RdapRegistrarFieldsAction rdapRegistrarFieldsAction(); + ReadinessProbeActionFrontend readinessProbeActionFrontend(); ReadinessProbeConsoleAction readinessProbeConsoleAction(); @@ -92,8 +100,6 @@ public interface FrontendRequestComponent { SecurityAction securityAction(); - RdapRegistrarFieldsAction rdapRegistrarFieldsAction(); - @Subcomponent.Builder abstract class Builder implements RequestComponentBuilder { @Override public abstract Builder requestModule(RequestModule requestModule); diff --git a/core/src/main/java/google/registry/ui/server/console/ConsoleModule.java b/core/src/main/java/google/registry/ui/server/console/ConsoleModule.java index 7b49fec55..807629891 100644 --- a/core/src/main/java/google/registry/ui/server/console/ConsoleModule.java +++ b/core/src/main/java/google/registry/ui/server/console/ConsoleModule.java @@ -36,6 +36,7 @@ import google.registry.ui.server.console.ConsoleEppPasswordAction.EppPasswordDat import google.registry.ui.server.console.ConsoleOteAction.OteCreateData; import google.registry.ui.server.console.ConsoleRegistryLockAction.ConsoleRegistryLockPostInput; import google.registry.ui.server.console.ConsoleUsersAction.UserData; +import google.registry.ui.server.console.PasswordResetRequestAction.PasswordResetRequestData; import jakarta.servlet.http.HttpServletRequest; import java.util.Optional; import org.joda.time.DateTime; @@ -246,6 +247,12 @@ public final class ConsoleModule { return extractRequiredParameter(req, "bulkDomainAction"); } + @Provides + @Parameter("resetRequestVerificationCode") + public static String provideResetRequestVerificationCode(HttpServletRequest req) { + return extractRequiredParameter(req, "resetRequestVerificationCode"); + } + @Provides @Parameter("eppPasswordChangeRequest") public static Optional provideEppPasswordChangeRequest( @@ -273,4 +280,21 @@ public final class ConsoleModule { Gson gson, @OptionalJsonPayload Optional payload) { return payload.map(e -> gson.fromJson(e, ConsoleRegistryLockPostInput.class)); } + + @Provides + @Parameter("passwordResetRequestData") + public static PasswordResetRequestData providePasswordResetRequestData( + Gson gson, @OptionalJsonPayload Optional payload) { + return payload + .map(e -> gson.fromJson(e, PasswordResetRequestData.class)) + .orElseThrow( + () -> new IllegalArgumentException("Must provide password request reset data")); + } + + @Provides + @Parameter("newPassword") + public static Optional provideNewPassword( + Gson gson, @OptionalJsonPayload Optional payload) { + return payload.map(e -> gson.fromJson(e, String.class)); + } } diff --git a/core/src/main/java/google/registry/ui/server/console/PasswordResetRequestAction.java b/core/src/main/java/google/registry/ui/server/console/PasswordResetRequestAction.java new file mode 100644 index 000000000..7ee27e4e4 --- /dev/null +++ b/core/src/main/java/google/registry/ui/server/console/PasswordResetRequestAction.java @@ -0,0 +1,150 @@ +// Copyright 2025 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.base.Preconditions.checkArgument; +import static google.registry.persistence.transaction.TransactionManagerFactory.tm; + +import com.google.gson.annotations.Expose; +import google.registry.model.console.ConsolePermission; +import google.registry.model.console.PasswordResetRequest; +import google.registry.model.console.User; +import google.registry.model.registrar.RegistrarPoc; +import google.registry.persistence.transaction.QueryComposer; +import google.registry.request.Action; +import google.registry.request.Parameter; +import google.registry.request.auth.Auth; +import google.registry.util.EmailMessage; +import jakarta.inject.Inject; +import jakarta.mail.internet.AddressException; +import jakarta.mail.internet.InternetAddress; +import jakarta.servlet.http.HttpServletResponse; +import javax.annotation.Nullable; + +@Action( + service = Action.GaeService.DEFAULT, + gkeService = Action.GkeService.CONSOLE, + path = PasswordResetRequestAction.PATH, + method = Action.Method.POST, + auth = Auth.AUTH_PUBLIC_LOGGED_IN) +public class PasswordResetRequestAction extends ConsoleApiAction { + + static final String PATH = "/console-api/password-reset-request"; + static final String VERIFICATION_EMAIL_TEMPLATE = + """ + Please click the link below to perform the requested password reset. Note: this\ + code will expire in one hour. + + %s\ + """; + + private final PasswordResetRequestData passwordResetRequestData; + + @Inject + public PasswordResetRequestAction( + ConsoleApiParams consoleApiParams, + @Parameter("passwordResetRequestData") PasswordResetRequestData passwordResetRequestData) { + super(consoleApiParams); + this.passwordResetRequestData = passwordResetRequestData; + } + + @Override + protected void postHandler(User user) { + // Temporary flag when testing email sending etc + if (!user.getUserRoles().isAdmin()) { + setFailedResponse("", HttpServletResponse.SC_FORBIDDEN); + } + tm().transact(() -> performRequest(user)); + consoleApiParams.response().setStatus(HttpServletResponse.SC_OK); + } + + private void performRequest(User user) { + checkArgument(passwordResetRequestData.type != null, "Type cannot be null"); + checkArgument(passwordResetRequestData.registrarId != null, "Registrar ID cannot be null"); + PasswordResetRequest.Type type = passwordResetRequestData.type; + String registrarId = passwordResetRequestData.registrarId; + + ConsolePermission requiredPermission; + String destinationEmail; + String emailSubject; + switch (type) { + case EPP: + requiredPermission = ConsolePermission.EDIT_REGISTRAR_DETAILS; + destinationEmail = getAdminPocEmail(registrarId); + emailSubject = "EPP password reset request"; + break; + case REGISTRY_LOCK: + checkArgument( + passwordResetRequestData.registryLockEmail != null, + "Must provide registry lock email to reset"); + requiredPermission = ConsolePermission.MANAGE_USERS; + destinationEmail = passwordResetRequestData.registryLockEmail; + checkUserExistsWithRegistryLockEmail(destinationEmail); + emailSubject = "Registry lock password reset request"; + break; + default: + throw new IllegalArgumentException("Unknown type " + type); + } + + checkPermission(user, registrarId, requiredPermission); + + InternetAddress destinationAddress; + try { + destinationAddress = new InternetAddress(destinationEmail); + } catch (AddressException e) { + // Shouldn't happen + throw new RuntimeException(e); + } + + PasswordResetRequest resetRequest = + new PasswordResetRequest.Builder() + .setRequester(user.getEmailAddress()) + .setRegistrarId(registrarId) + .setType(type) + .setDestinationEmail(destinationEmail) + .build(); + tm().put(resetRequest); + String verificationUrl = + String.format( + "https://%s/console/#/password-reset-verify?resetRequestVerificationCode=%s", + consoleApiParams.request().getServerName(), resetRequest.getVerificationCode()); + String body = String.format(VERIFICATION_EMAIL_TEMPLATE, verificationUrl); + consoleApiParams + .sendEmailUtils() + .gmailClient + .sendEmail(EmailMessage.create(emailSubject, body, destinationAddress)); + } + + static User checkUserExistsWithRegistryLockEmail(String destinationEmail) { + return tm().createQueryComposer(User.class) + .where("registryLockEmailAddress", QueryComposer.Comparator.EQ, destinationEmail) + .first() + .orElseThrow( + () -> new IllegalArgumentException("Unknown user with lock email " + destinationEmail)); + } + + private String getAdminPocEmail(String registrarId) { + return RegistrarPoc.loadForRegistrar(registrarId).stream() + .filter(poc -> poc.getTypes().contains(RegistrarPoc.Type.ADMIN)) + .map(RegistrarPoc::getEmailAddress) + .findAny() + .orElseThrow(() -> new IllegalStateException("No admin contacts found for " + registrarId)); + } + + public record PasswordResetRequestData( + @Expose PasswordResetRequest.Type type, + @Expose String registrarId, + @Expose @Nullable String registryLockEmail) {} +} diff --git a/core/src/main/java/google/registry/ui/server/console/PasswordResetVerifyAction.java b/core/src/main/java/google/registry/ui/server/console/PasswordResetVerifyAction.java new file mode 100644 index 000000000..1c5da6284 --- /dev/null +++ b/core/src/main/java/google/registry/ui/server/console/PasswordResetVerifyAction.java @@ -0,0 +1,131 @@ +// Copyright 2025 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.base.Preconditions.checkArgument; +import static google.registry.persistence.transaction.TransactionManagerFactory.tm; +import static google.registry.request.Action.Method.GET; +import static google.registry.request.Action.Method.POST; +import static google.registry.ui.server.console.PasswordResetRequestAction.checkUserExistsWithRegistryLockEmail; + +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableMap; +import google.registry.model.console.ConsolePermission; +import google.registry.model.console.PasswordResetRequest; +import google.registry.model.console.User; +import google.registry.model.registrar.Registrar; +import google.registry.persistence.VKey; +import google.registry.request.Action; +import google.registry.request.Parameter; +import google.registry.request.auth.Auth; +import jakarta.inject.Inject; +import jakarta.servlet.http.HttpServletResponse; +import java.util.Optional; +import org.joda.time.Duration; + +@Action( + service = Action.GaeService.DEFAULT, + gkeService = Action.GkeService.CONSOLE, + path = PasswordResetVerifyAction.PATH, + method = {GET, POST}, + auth = Auth.AUTH_PUBLIC_LOGGED_IN) +public class PasswordResetVerifyAction extends ConsoleApiAction { + + static final String PATH = "/console-api/password-reset-verify"; + + private final String verificationCode; + private final Optional newPassword; + + @Inject + public PasswordResetVerifyAction( + ConsoleApiParams consoleApiParams, + @Parameter("resetRequestVerificationCode") String verificationCode, + @Parameter("newPassword") Optional newPassword) { + super(consoleApiParams); + this.verificationCode = verificationCode; + this.newPassword = newPassword; + } + + @Override + protected void getHandler(User user) { + // Temporary flag when testing email sending etc + if (!user.getUserRoles().isAdmin()) { + setFailedResponse("", HttpServletResponse.SC_FORBIDDEN); + } + PasswordResetRequest request = tm().transact(() -> loadAndValidateResetRequest(user)); + ImmutableMap result = + ImmutableMap.of("type", request.getType(), "registrarId", request.getRegistrarId()); + consoleApiParams.response().setPayload(consoleApiParams.gson().toJson(result)); + consoleApiParams.response().setStatus(HttpServletResponse.SC_OK); + } + + @Override + protected void postHandler(User user) { + // Temporary flag when testing email sending etc + if (!user.getUserRoles().isAdmin()) { + setFailedResponse("", HttpServletResponse.SC_FORBIDDEN); + } + checkArgument(!Strings.isNullOrEmpty(newPassword.orElse(null)), "Password must be provided"); + tm().transact( + () -> { + PasswordResetRequest request = loadAndValidateResetRequest(user); + switch (request.getType()) { + case EPP -> handleEppPasswordReset(request); + case REGISTRY_LOCK -> handleRegistryLockPasswordReset(request); + } + tm().put(request.asBuilder().setFulfillmentTime(tm().getTransactionTime()).build()); + }); + consoleApiParams.response().setStatus(HttpServletResponse.SC_OK); + } + + private void handleEppPasswordReset(PasswordResetRequest request) { + Registrar registrar = Registrar.loadByRegistrarId(request.getRegistrarId()).get(); + tm().put(registrar.asBuilder().setPassword(newPassword.get()).build()); + } + + private void handleRegistryLockPasswordReset(PasswordResetRequest request) { + User affectedUser = checkUserExistsWithRegistryLockEmail(request.getDestinationEmail()); + tm().put( + affectedUser + .asBuilder() + .removeRegistryLockPassword() + .setRegistryLockPassword(newPassword.get()) + .build()); + } + + private PasswordResetRequest loadAndValidateResetRequest(User user) { + PasswordResetRequest request = + tm().loadByKeyIfPresent(VKey.create(PasswordResetRequest.class, verificationCode)) + .orElseThrow(this::createVerificationCodeException); + ConsolePermission requiredVerifyPermission = + switch (request.getType()) { + case EPP -> ConsolePermission.MANAGE_USERS; + case REGISTRY_LOCK -> ConsolePermission.REGISTRY_LOCK; + }; + checkPermission(user, request.getRegistrarId(), requiredVerifyPermission); + if (request + .getRequestTime() + .plus(Duration.standardHours(1)) + .isBefore(tm().getTransactionTime())) { + throw createVerificationCodeException(); + } + return request; + } + + private IllegalArgumentException createVerificationCodeException() { + return new IllegalArgumentException( + "Unknown, invalid, or expired verification code " + verificationCode); + } +} diff --git a/core/src/test/java/google/registry/testing/DatabaseHelper.java b/core/src/test/java/google/registry/testing/DatabaseHelper.java index 07d880d7e..14e676bd6 100644 --- a/core/src/test/java/google/registry/testing/DatabaseHelper.java +++ b/core/src/test/java/google/registry/testing/DatabaseHelper.java @@ -1042,6 +1042,8 @@ public final class DatabaseHelper { .setGlobalRole(GlobalRole.FTE) .setIsAdmin(true) .build()) + .setRegistryLockEmailAddress("registrylock" + emailAddress) + .setRegistryLockPassword("password") .build(); tm().put(user); return user; diff --git a/core/src/test/java/google/registry/ui/server/console/ConsoleUpdateRegistrarActionTest.java b/core/src/test/java/google/registry/ui/server/console/ConsoleUpdateRegistrarActionTest.java index 4f4872c0d..dd3eff5b7 100644 --- a/core/src/test/java/google/registry/ui/server/console/ConsoleUpdateRegistrarActionTest.java +++ b/core/src/test/java/google/registry/ui/server/console/ConsoleUpdateRegistrarActionTest.java @@ -215,7 +215,7 @@ class ConsoleUpdateRegistrarActionTest extends ConsoleActionBaseTestCase { return ConsoleApiParamsUtils.createFake(authResult); } - ConsoleUpdateRegistrarAction createAction(String requestData) throws IOException { + private ConsoleUpdateRegistrarAction createAction(String requestData) throws IOException { when(consoleApiParams.request().getMethod()).thenReturn(Action.Method.POST.toString()); doReturn(new BufferedReader(new StringReader(requestData))) .when(consoleApiParams.request()) diff --git a/core/src/test/java/google/registry/ui/server/console/PasswordResetRequestActionTest.java b/core/src/test/java/google/registry/ui/server/console/PasswordResetRequestActionTest.java new file mode 100644 index 000000000..a72f0ad6d --- /dev/null +++ b/core/src/test/java/google/registry/ui/server/console/PasswordResetRequestActionTest.java @@ -0,0 +1,218 @@ +// Copyright 2025 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.model.ImmutableObjectSubject.assertAboutImmutableObjects; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.common.collect.ImmutableMap; +import google.registry.model.console.PasswordResetRequest; +import google.registry.model.console.RegistrarRole; +import google.registry.model.console.User; +import google.registry.model.console.UserRoles; +import google.registry.request.Action; +import google.registry.request.auth.AuthResult; +import google.registry.testing.ConsoleApiParamsUtils; +import google.registry.testing.DatabaseHelper; +import google.registry.testing.FakeResponse; +import google.registry.ui.server.console.PasswordResetRequestAction.PasswordResetRequestData; +import google.registry.util.EmailMessage; +import jakarta.mail.internet.InternetAddress; +import jakarta.servlet.http.HttpServletResponse; +import javax.annotation.Nullable; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +/** Tests for {@link PasswordResetRequestAction}. */ +public class PasswordResetRequestActionTest extends ConsoleActionBaseTestCase { + + @Test + void testSuccess_epp() throws Exception { + PasswordResetRequestAction action = + createAction(PasswordResetRequest.Type.EPP, "TheRegistrar", null); + action.run(); + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK); + PasswordResetRequest actualRequest = + DatabaseHelper.loadSingleton(PasswordResetRequest.class).get(); + assertAboutImmutableObjects() + .that(actualRequest) + .isEqualExceptFields( + new PasswordResetRequest.Builder() + .setDestinationEmail("johndoe@theregistrar.com") + .setRequester("fte@email.tld") + .setType(PasswordResetRequest.Type.EPP) + .setRegistrarId("TheRegistrar") + .build(), + "requestTime", + "verificationCode"); + EmailMessage expectedMessage = + EmailMessage.create( + "EPP password reset request", + """ + Please click the link below to perform the requested password reset. Note: this\ + code will expire in one hour. + + https://registrarconsole.tld/console/#/password-reset-verify?resetRequestVerificationCode=\ + """ + + actualRequest.getVerificationCode(), + new InternetAddress("johndoe@theregistrar.com")); + verify(consoleApiParams.sendEmailUtils().gmailClient).sendEmail(expectedMessage); + } + + @Test + void testSuccess_registryLock() throws Exception { + DatabaseHelper.persistResource( + new User.Builder() + .setEmailAddress("email@registry.tld") + .setUserRoles( + new UserRoles.Builder() + .setRegistrarRoles( + ImmutableMap.of( + "TheRegistrar", RegistrarRole.ACCOUNT_MANAGER_WITH_REGISTRY_LOCK)) + .build()) + .setRegistryLockEmailAddress("registrylock@theregistrar.com") + .setRegistryLockPassword("password") + .build()); + PasswordResetRequestAction action = + createAction( + PasswordResetRequest.Type.REGISTRY_LOCK, + "TheRegistrar", + "registrylock@theregistrar.com"); + action.run(); + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK); + PasswordResetRequest actualRequest = + DatabaseHelper.loadSingleton(PasswordResetRequest.class).get(); + assertAboutImmutableObjects() + .that(actualRequest) + .isEqualExceptFields( + new PasswordResetRequest.Builder() + .setDestinationEmail("registrylock@theregistrar.com") + .setRequester("fte@email.tld") + .setType(PasswordResetRequest.Type.REGISTRY_LOCK) + .setRegistrarId("TheRegistrar") + .build(), + "requestTime", + "verificationCode"); + EmailMessage expectedMessage = + EmailMessage.create( + "Registry lock password reset request", + """ + Please click the link below to perform the requested password reset. Note: this\ + code will expire in one hour. + + https://registrarconsole.tld/console/#/password-reset-verify?resetRequestVerificationCode=\ + """ + + actualRequest.getVerificationCode(), + new InternetAddress("registrylock@theregistrar.com")); + verify(consoleApiParams.sendEmailUtils().gmailClient).sendEmail(expectedMessage); + } + + @Test + void testFailure_nullType() throws Exception { + PasswordResetRequestAction action = createAction(null, "TheRegistrar", "email@email.test"); + action.run(); + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_BAD_REQUEST); + assertThat(response.getPayload()).isEqualTo("Type cannot be null"); + } + + @Test + void testFailure_nullRegistrarId() throws Exception { + PasswordResetRequestAction action = + createAction(PasswordResetRequest.Type.EPP, null, "email@email.test"); + action.run(); + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_BAD_REQUEST); + assertThat(response.getPayload()).isEqualTo("Registrar ID cannot be null"); + } + + @Test + void testFailure_registryLock_nullEmail() throws Exception { + PasswordResetRequestAction action = + createAction(PasswordResetRequest.Type.REGISTRY_LOCK, "TheRegistrar", null); + action.run(); + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_BAD_REQUEST); + assertThat(response.getPayload()).isEqualTo("Must provide registry lock email to reset"); + } + + @Test + void testFailure_registryLock_invalidEmail() throws Exception { + PasswordResetRequestAction action = + createAction( + PasswordResetRequest.Type.REGISTRY_LOCK, "TheRegistrar", "nonexistent@email.com"); + action.run(); + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_BAD_REQUEST); + assertThat(response.getPayload()) + .isEqualTo("Unknown user with lock email nonexistent@email.com"); + } + + @Test + @Disabled("Enable when testing is done in sandbox and isAdmin check is removed") + void testFailure_epp_noPermission() throws Exception { + User user = + new User.Builder() + .setEmailAddress("email@email.test") + .setUserRoles( + new UserRoles.Builder() + .setRegistrarRoles( + ImmutableMap.of("TheRegistrar", RegistrarRole.ACCOUNT_MANAGER)) + .build()) + .build(); + PasswordResetRequestAction action = + createAction(user, PasswordResetRequest.Type.EPP, "TheRegistrar", null); + action.run(); + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_FORBIDDEN); + } + + @Test + @Disabled("Enable when testing is done in sandbox and isAdmin check is removed") + void testFailure_lock_noPermission() throws Exception { + User user = + new User.Builder() + .setEmailAddress("email@email.test") + .setUserRoles( + new UserRoles.Builder() + .setRegistrarRoles(ImmutableMap.of("TheRegistrar", RegistrarRole.TECH_CONTACT)) + .build()) + .build(); + PasswordResetRequestAction action = + createAction( + user, + PasswordResetRequest.Type.REGISTRY_LOCK, + "TheRegistrar", + "registrylockfte@email.tld"); + action.run(); + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_FORBIDDEN); + } + + private PasswordResetRequestAction createAction( + User user, + PasswordResetRequest.Type type, + String registrarId, + @Nullable String registryLockEmail) { + consoleApiParams = ConsoleApiParamsUtils.createFake(AuthResult.createUser(user)); + return createAction(type, registrarId, registryLockEmail); + } + + private PasswordResetRequestAction createAction( + PasswordResetRequest.Type type, String registrarId, @Nullable String registryLockEmail) { + when(consoleApiParams.request().getMethod()).thenReturn(Action.Method.POST.toString()); + when(consoleApiParams.request().getServerName()).thenReturn("registrarconsole.tld"); + response = (FakeResponse) consoleApiParams.response(); + PasswordResetRequestData data = + new PasswordResetRequestData(type, registrarId, registryLockEmail); + return new PasswordResetRequestAction(consoleApiParams, data); + } +} diff --git a/core/src/test/java/google/registry/ui/server/console/PasswordResetVerifyActionTest.java b/core/src/test/java/google/registry/ui/server/console/PasswordResetVerifyActionTest.java new file mode 100644 index 000000000..852d7545f --- /dev/null +++ b/core/src/test/java/google/registry/ui/server/console/PasswordResetVerifyActionTest.java @@ -0,0 +1,199 @@ +// Copyright 2025 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.testing.DatabaseHelper.loadByEntity; +import static google.registry.testing.DatabaseHelper.persistResource; +import static org.mockito.Mockito.when; + +import com.google.common.collect.ImmutableMap; +import google.registry.model.console.PasswordResetRequest; +import google.registry.model.console.RegistrarRole; +import google.registry.model.console.User; +import google.registry.model.console.UserRoles; +import google.registry.model.registrar.Registrar; +import google.registry.request.auth.AuthResult; +import google.registry.testing.ConsoleApiParamsUtils; +import google.registry.testing.FakeResponse; +import jakarta.servlet.http.HttpServletResponse; +import java.util.Map; +import java.util.Optional; +import javax.annotation.Nullable; +import org.joda.time.Duration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +/** Tests for {@link PasswordResetVerifyAction}. */ +public class PasswordResetVerifyActionTest extends ConsoleActionBaseTestCase { + + private String verificationCode; + + @BeforeEach + void beforeEach() { + verificationCode = saveRequest(PasswordResetRequest.Type.EPP).getVerificationCode(); + } + + @Test + void testSuccess_get_epp() throws Exception { + createAction("GET", verificationCode, null).run(); + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK); + assertThat(GSON.fromJson(response.getPayload(), Map.class)) + .isEqualTo(ImmutableMap.of("registrarId", "TheRegistrar", "type", "EPP")); + } + + @Test + void testSuccess_get_lock() throws Exception { + verificationCode = saveRequest(PasswordResetRequest.Type.REGISTRY_LOCK).getVerificationCode(); + createAction("GET", verificationCode, null).run(); + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK); + assertThat(GSON.fromJson(response.getPayload(), Map.class)) + .isEqualTo(ImmutableMap.of("registrarId", "TheRegistrar", "type", "REGISTRY_LOCK")); + } + + @Test + void testSuccess_post_epp() throws Exception { + assertThat(Registrar.loadByRegistrarId("TheRegistrar").get().verifyPassword("password2")) + .isTrue(); + createAction("POST", verificationCode, "newEppPassword").run(); + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK); + assertThat(Registrar.loadByRegistrarId("TheRegistrar").get().verifyPassword("password2")) + .isFalse(); + assertThat(Registrar.loadByRegistrarId("TheRegistrar").get().verifyPassword("newEppPassword")) + .isTrue(); + } + + @Test + void testSuccess_post_lock() throws Exception { + assertThat(loadByEntity(fteUser).verifyRegistryLockPassword("password")).isTrue(); + verificationCode = saveRequest(PasswordResetRequest.Type.REGISTRY_LOCK).getVerificationCode(); + createAction("POST", verificationCode, "newRegistryLockPassword").run(); + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK); + assertThat(loadByEntity(fteUser).verifyRegistryLockPassword("newRegistryLockPassword")) + .isTrue(); + } + + @Test + void testFailure_get_invalidVerificationCode() throws Exception { + createAction("GET", "invalid", null).run(); + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_BAD_REQUEST); + } + + @Test + void testFailure_post_invalidVerificationCode() throws Exception { + createAction("POST", "invalid", "newPassword").run(); + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_BAD_REQUEST); + } + + @Test + void testFailure_nullPassword() throws Exception { + createAction("POST", verificationCode, null).run(); + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_BAD_REQUEST); + } + + @Test + void testFailure_emptyPassword() throws Exception { + createAction("POST", verificationCode, "").run(); + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_BAD_REQUEST); + } + + @Test + @Disabled("Enable when testing is done in sandbox and isAdmin check is removed") + void testFailure_get_epp_badPermission() throws Exception { + createAction(createTechUser(), "GET", verificationCode, null).run(); + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_FORBIDDEN); + } + + @Test + @Disabled("Enable when testing is done in sandbox and isAdmin check is removed") + void testFailure_get_lock_badPermission() throws Exception { + createAction(createAccountManager(), "GET", verificationCode, null).run(); + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_FORBIDDEN); + } + + @Test + @Disabled("Enable when testing is done in sandbox and isAdmin check is removed") + void testFailure_post_epp_badPermission() throws Exception { + createAction(createTechUser(), "POST", verificationCode, "newPassword").run(); + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_FORBIDDEN); + } + + @Test + @Disabled("Enable when testing is done in sandbox and isAdmin check is removed") + void testFailure_post_lock_badPermission() throws Exception { + createAction(createAccountManager(), "POST", verificationCode, "newPassword").run(); + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_FORBIDDEN); + } + + @Test + void testFailure_get_expired() throws Exception { + clock.advanceBy(Duration.standardDays(1)); + createAction("GET", verificationCode, null).run(); + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_BAD_REQUEST); + } + + @Test + void testFailure_post_expired() throws Exception { + clock.advanceBy(Duration.standardDays(1)); + createAction("POST", verificationCode, "newPassword").run(); + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_BAD_REQUEST); + } + + private User createTechUser() { + return new User.Builder() + .setEmailAddress("tech@example.tld") + .setUserRoles( + new UserRoles.Builder() + .setRegistrarRoles(ImmutableMap.of("TheRegistrar", RegistrarRole.TECH_CONTACT)) + .build()) + .build(); + } + + private User createAccountManager() { + return new User.Builder() + .setEmailAddress("accountmanager@example.tld") + .setUserRoles( + new UserRoles.Builder() + .setRegistrarRoles(ImmutableMap.of("TheRegistrar", RegistrarRole.ACCOUNT_MANAGER)) + .build()) + .build(); + } + + private PasswordResetRequest saveRequest(PasswordResetRequest.Type type) { + return persistResource( + new PasswordResetRequest.Builder() + // use the built-in user registry lock email + .setDestinationEmail("registrylockfte@email.tld") + .setRequester("requester@email.tld") + .setRegistrarId("TheRegistrar") + .setType(type) + .build()); + } + + private PasswordResetVerifyAction createAction( + User user, String method, String verificationCode, @Nullable String newPassword) { + consoleApiParams = ConsoleApiParamsUtils.createFake(AuthResult.createUser(user)); + return createAction(method, verificationCode, newPassword); + } + + private PasswordResetVerifyAction createAction( + String method, String verificationCode, @Nullable String newPassword) { + when(consoleApiParams.request().getMethod()).thenReturn(method); + response = (FakeResponse) consoleApiParams.response(); + return new PasswordResetVerifyAction( + consoleApiParams, verificationCode, Optional.ofNullable(newPassword)); + } +} 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 0088f6899..d57477330 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 @@ -1,19 +1,21 @@ -SERVICE PATH CLASS METHODS OK MIN USER_POLICY -FRONTEND /_dr/epp EppTlsAction POST n APP ADMIN -FRONTEND /ready/frontend ReadinessProbeActionFrontend GET n NONE PUBLIC -CONSOLE /console-api/bulk-domain ConsoleBulkDomainAction POST n USER PUBLIC -CONSOLE /console-api/domain ConsoleDomainGetAction GET n USER PUBLIC -CONSOLE /console-api/domain-list ConsoleDomainListAction GET n USER PUBLIC -CONSOLE /console-api/dum-download ConsoleDumDownloadAction GET n USER PUBLIC -CONSOLE /console-api/eppPassword ConsoleEppPasswordAction POST n USER PUBLIC -CONSOLE /console-api/ote ConsoleOteAction GET,POST n USER PUBLIC -CONSOLE /console-api/registrar ConsoleUpdateRegistrarAction POST n USER PUBLIC -CONSOLE /console-api/registrars RegistrarsAction GET,POST n USER PUBLIC -CONSOLE /console-api/registry-lock ConsoleRegistryLockAction GET,POST n USER PUBLIC -CONSOLE /console-api/registry-lock-verify ConsoleRegistryLockVerifyAction GET n USER PUBLIC -CONSOLE /console-api/settings/contacts ContactAction GET,POST,DELETE,PUT n USER PUBLIC -CONSOLE /console-api/settings/rdap-fields RdapRegistrarFieldsAction POST n USER PUBLIC -CONSOLE /console-api/settings/security SecurityAction POST n USER PUBLIC -CONSOLE /console-api/userdata ConsoleUserDataAction GET n USER PUBLIC -CONSOLE /console-api/users ConsoleUsersAction GET,POST,DELETE,PUT n USER PUBLIC -CONSOLE /ready/console ReadinessProbeConsoleAction GET n NONE PUBLIC \ No newline at end of file +SERVICE PATH CLASS METHODS OK MIN USER_POLICY +FRONTEND /_dr/epp EppTlsAction POST n APP ADMIN +FRONTEND /ready/frontend ReadinessProbeActionFrontend GET n NONE PUBLIC +CONSOLE /console-api/bulk-domain ConsoleBulkDomainAction POST n USER PUBLIC +CONSOLE /console-api/domain ConsoleDomainGetAction GET n USER PUBLIC +CONSOLE /console-api/domain-list ConsoleDomainListAction GET n USER PUBLIC +CONSOLE /console-api/dum-download ConsoleDumDownloadAction GET n USER PUBLIC +CONSOLE /console-api/eppPassword ConsoleEppPasswordAction POST n USER PUBLIC +CONSOLE /console-api/ote ConsoleOteAction GET,POST n USER PUBLIC +CONSOLE /console-api/password-reset-request PasswordResetRequestAction POST n USER PUBLIC +CONSOLE /console-api/password-reset-verify PasswordResetVerifyAction GET,POST n USER PUBLIC +CONSOLE /console-api/registrar ConsoleUpdateRegistrarAction POST n USER PUBLIC +CONSOLE /console-api/registrars RegistrarsAction GET,POST n USER PUBLIC +CONSOLE /console-api/registry-lock ConsoleRegistryLockAction GET,POST n USER PUBLIC +CONSOLE /console-api/registry-lock-verify ConsoleRegistryLockVerifyAction GET n USER PUBLIC +CONSOLE /console-api/settings/contacts ContactAction GET,POST,DELETE,PUT n USER PUBLIC +CONSOLE /console-api/settings/rdap-fields RdapRegistrarFieldsAction POST n USER PUBLIC +CONSOLE /console-api/settings/security SecurityAction POST n USER PUBLIC +CONSOLE /console-api/userdata ConsoleUserDataAction GET n USER PUBLIC +CONSOLE /console-api/users ConsoleUsersAction GET,POST,DELETE,PUT n USER PUBLIC +CONSOLE /ready/console ReadinessProbeConsoleAction GET n NONE PUBLIC diff --git a/core/src/test/resources/google/registry/module/routing.txt b/core/src/test/resources/google/registry/module/routing.txt index 8ec087d6a..7b2412394 100644 --- a/core/src/test/resources/google/registry/module/routing.txt +++ b/core/src/test/resources/google/registry/module/routing.txt @@ -75,6 +75,8 @@ CONSOLE /console-api/domain-list ConsoleDomainListAct CONSOLE /console-api/dum-download ConsoleDumDownloadAction GET n USER PUBLIC CONSOLE /console-api/eppPassword ConsoleEppPasswordAction POST n USER PUBLIC CONSOLE /console-api/ote ConsoleOteAction GET,POST n USER PUBLIC +CONSOLE /console-api/password-reset-request PasswordResetRequestAction POST n USER PUBLIC +CONSOLE /console-api/password-reset-verify PasswordResetVerifyAction GET,POST n USER PUBLIC CONSOLE /console-api/registrar ConsoleUpdateRegistrarAction POST n USER PUBLIC CONSOLE /console-api/registrars RegistrarsAction GET,POST n USER PUBLIC CONSOLE /console-api/registry-lock ConsoleRegistryLockAction GET,POST n USER PUBLIC @@ -84,4 +86,4 @@ CONSOLE /console-api/settings/rdap-fields RdapRegistrarFieldsA CONSOLE /console-api/settings/security SecurityAction POST n USER PUBLIC CONSOLE /console-api/userdata ConsoleUserDataAction GET n USER PUBLIC CONSOLE /console-api/users ConsoleUsersAction GET,POST,DELETE,PUT n USER PUBLIC -CONSOLE /ready/console ReadinessProbeConsoleAction GET n NONE PUBLIC \ No newline at end of file +CONSOLE /ready/console ReadinessProbeConsoleAction GET n NONE PUBLIC