From 49d2e34e12eed5ff52671a8dd747fb9c64522405 Mon Sep 17 00:00:00 2001 From: gbrodman Date: Fri, 3 May 2024 18:37:22 -0400 Subject: [PATCH] Add a separate RegistryLock action for the console (#2411) This handles both GET and POST requests. For POST requests it doesn't actually change anything about the domains because we will need to add a verification action (this will be done in a future PR). --- .../registry/model/domain/RegistryLock.java | 12 +- .../registry/module/RequestComponent.java | 3 + .../frontend/FrontendRequestComponent.java | 4 + .../registry/request/RequestParameters.java | 14 + .../console/ConsoleRegistryLockAction.java | 215 +++++++ .../registrar/RegistryLockPostAction.java | 9 +- .../ConsoleRegistryLockActionTest.java | 539 ++++++++++++++++++ .../console/ConsoleUserDataActionTest.java | 15 +- .../module/frontend/frontend_routing.txt | 1 + .../google/registry/module/routing.txt | 1 + 10 files changed, 795 insertions(+), 18 deletions(-) create mode 100644 core/src/main/java/google/registry/ui/server/console/ConsoleRegistryLockAction.java create mode 100644 core/src/test/java/google/registry/ui/server/console/ConsoleRegistryLockActionTest.java diff --git a/core/src/main/java/google/registry/model/domain/RegistryLock.java b/core/src/main/java/google/registry/model/domain/RegistryLock.java index 0351e6ddb..ecb43665a 100644 --- a/core/src/main/java/google/registry/model/domain/RegistryLock.java +++ b/core/src/main/java/google/registry/model/domain/RegistryLock.java @@ -18,6 +18,7 @@ import static com.google.common.base.Preconditions.checkArgument; import static google.registry.util.DateTimeUtils.isBeforeOrAt; import static google.registry.util.PreconditionsUtils.checkArgumentNotNull; +import com.google.gson.annotations.Expose; import google.registry.model.Buildable; import google.registry.model.CreateAutoTimestamp; import google.registry.model.UpdateAutoTimestampEntity; @@ -90,6 +91,7 @@ public final class RegistryLock extends UpdateAutoTimestampEntity implements Bui // TODO (b/140568328): remove this when everything is in Cloud SQL and we can join on "domain" @Column(nullable = false) + @Expose private String domainName; /** @@ -100,7 +102,7 @@ public final class RegistryLock extends UpdateAutoTimestampEntity implements Bui private String registrarId; /** The POC that performed the action, or null if it was a superuser. */ - private String registrarPocId; + @Expose private String registrarPocId; /** When the lock is first requested. */ @AttributeOverrides({ @@ -108,22 +110,23 @@ public final class RegistryLock extends UpdateAutoTimestampEntity implements Bui name = "creationTime", column = @Column(name = "lockRequestTime", nullable = false)) }) + @Expose private final CreateAutoTimestamp lockRequestTime = CreateAutoTimestamp.create(null); /** When the unlock is first requested. */ - private DateTime unlockRequestTime; + @Expose private DateTime unlockRequestTime; /** * When the user has verified the lock. If this field is null, it means the lock has not been * verified yet (and thus not been put into effect). */ - private DateTime lockCompletionTime; + @Expose private DateTime lockCompletionTime; /** * When the user has verified the unlock of this lock. If this field is null, it means the unlock * action has not been verified yet (and has not been put into effect). */ - private DateTime unlockCompletionTime; + @Expose private DateTime unlockCompletionTime; /** The user must provide the random verification code in order to complete the action. */ @Column(nullable = false) @@ -134,6 +137,7 @@ public final class RegistryLock extends UpdateAutoTimestampEntity implements Bui * this case, the action was performed by a registry admin rather than a registrar. */ @Column(nullable = false) + @Expose private boolean isSuperuser; /** The lock that undoes this lock, if this lock has been unlocked and the domain locked again. */ diff --git a/core/src/main/java/google/registry/module/RequestComponent.java b/core/src/main/java/google/registry/module/RequestComponent.java index a7a3135b3..926d4f087 100644 --- a/core/src/main/java/google/registry/module/RequestComponent.java +++ b/core/src/main/java/google/registry/module/RequestComponent.java @@ -112,6 +112,7 @@ import google.registry.ui.server.console.ConsoleDomainGetAction; import google.registry.ui.server.console.ConsoleDomainListAction; import google.registry.ui.server.console.ConsoleDumDownloadAction; import google.registry.ui.server.console.ConsoleEppPasswordAction; +import google.registry.ui.server.console.ConsoleRegistryLockAction; import google.registry.ui.server.console.ConsoleUserDataAction; import google.registry.ui.server.console.RegistrarsAction; import google.registry.ui.server.console.settings.ContactAction; @@ -186,6 +187,8 @@ interface RequestComponent { ConsoleRegistrarCreatorAction consoleRegistrarCreatorAction(); + ConsoleRegistryLockAction consoleRegistryLockAction(); + ConsoleUiAction consoleUiAction(); ConsoleUserDataAction consoleUserDataAction(); 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 62f460f5f..5212b40aa 100644 --- a/core/src/main/java/google/registry/module/frontend/FrontendRequestComponent.java +++ b/core/src/main/java/google/registry/module/frontend/FrontendRequestComponent.java @@ -29,6 +29,7 @@ import google.registry.ui.server.console.ConsoleDomainGetAction; import google.registry.ui.server.console.ConsoleDomainListAction; import google.registry.ui.server.console.ConsoleDumDownloadAction; import google.registry.ui.server.console.ConsoleEppPasswordAction; +import google.registry.ui.server.console.ConsoleRegistryLockAction; import google.registry.ui.server.console.ConsoleUserDataAction; import google.registry.ui.server.console.RegistrarsAction; import google.registry.ui.server.console.settings.ContactAction; @@ -64,6 +65,9 @@ public interface FrontendRequestComponent { ConsoleOteSetupAction consoleOteSetupAction(); ConsoleRegistrarCreatorAction consoleRegistrarCreatorAction(); + + ConsoleRegistryLockAction consoleRegistryLockAction(); + ConsoleUiAction consoleUiAction(); ConsoleUserDataAction consoleUserDataAction(); diff --git a/core/src/main/java/google/registry/request/RequestParameters.java b/core/src/main/java/google/registry/request/RequestParameters.java index 08deba8e0..dc47df204 100644 --- a/core/src/main/java/google/registry/request/RequestParameters.java +++ b/core/src/main/java/google/registry/request/RequestParameters.java @@ -106,6 +106,20 @@ public final class RequestParameters { } } + /** + * Returns first GET or POST parameter associated with {@code name} as a long. + * + * @throws BadRequestException if request parameter is present but not a valid long + */ + public static Optional extractOptionalLongParameter(HttpServletRequest req, String name) { + String stringParam = req.getParameter(name); + try { + return isNullOrEmpty(stringParam) ? Optional.empty() : Optional.of(Long.valueOf(stringParam)); + } catch (NumberFormatException e) { + throw new BadRequestException("Expected long: " + name); + } + } + /** * Returns first GET or POST parameter associated with {@code name} as a long. * diff --git a/core/src/main/java/google/registry/ui/server/console/ConsoleRegistryLockAction.java b/core/src/main/java/google/registry/ui/server/console/ConsoleRegistryLockAction.java new file mode 100644 index 000000000..430f091b7 --- /dev/null +++ b/core/src/main/java/google/registry/ui/server/console/ConsoleRegistryLockAction.java @@ -0,0 +1,215 @@ +// 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.collect.ImmutableList.toImmutableList; +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.request.RequestParameters.extractBooleanParameter; +import static google.registry.request.RequestParameters.extractOptionalLongParameter; +import static google.registry.request.RequestParameters.extractOptionalParameter; +import static google.registry.request.RequestParameters.extractRequiredParameter; +import static google.registry.ui.server.registrar.RegistryLockPostAction.VERIFICATION_EMAIL_TEMPLATE; + +import com.google.api.client.http.HttpStatusCodes; +import com.google.common.collect.ImmutableList; +import com.google.common.flogger.FluentLogger; +import com.google.gson.Gson; +import google.registry.flows.EppException; +import google.registry.flows.domain.DomainFlowUtils; +import google.registry.groups.GmailClient; +import google.registry.model.console.ConsolePermission; +import google.registry.model.console.User; +import google.registry.model.domain.RegistryLock; +import google.registry.model.registrar.Registrar; +import google.registry.model.tld.RegistryLockDao; +import google.registry.request.Action; +import google.registry.request.HttpException; +import google.registry.request.Parameter; +import google.registry.request.Response; +import google.registry.request.auth.Auth; +import google.registry.tools.DomainLockUtils; +import google.registry.ui.server.registrar.ConsoleApiParams; +import google.registry.util.EmailMessage; +import jakarta.servlet.http.HttpServletRequest; +import java.net.URISyntaxException; +import java.util.Optional; +import javax.inject.Inject; +import javax.mail.internet.AddressException; +import javax.mail.internet.InternetAddress; +import org.apache.http.client.utils.URIBuilder; +import org.joda.time.Duration; + +/** + * Handler for retrieving / creating registry lock requests in the console. + * + *

Note: two-factor verification of the locks occurs separately (TODO: link the verification + * action). + */ +@Action( + service = Action.Service.DEFAULT, + path = ConsoleRegistryLockAction.PATH, + method = {GET, POST}, + auth = Auth.AUTH_PUBLIC_LOGGED_IN) +public class ConsoleRegistryLockAction extends ConsoleApiAction { + + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + static final String PATH = "/console-api/registry-lock"; + + private final DomainLockUtils domainLockUtils; + private final GmailClient gmailClient; + private final Gson gson; + private final String registrarId; + + @Inject + public ConsoleRegistryLockAction( + ConsoleApiParams consoleApiParams, + DomainLockUtils domainLockUtils, + GmailClient gmailClient, + Gson gson, + @Parameter("registrarId") String registrarId) { + super(consoleApiParams); + this.domainLockUtils = domainLockUtils; + this.gmailClient = gmailClient; + this.gson = gson; + this.registrarId = registrarId; + } + + @Override + protected void getHandler(User user) { + if (!user.getUserRoles().hasPermission(registrarId, ConsolePermission.REGISTRY_LOCK)) { + consoleApiParams.response().setStatus(HttpStatusCodes.STATUS_CODE_FORBIDDEN); + return; + } + consoleApiParams.response().setPayload(gson.toJson(getLockedDomains())); + consoleApiParams.response().setStatus(HttpStatusCodes.STATUS_CODE_OK); + } + + @Override + protected void postHandler(User user) { + HttpServletRequest req = consoleApiParams.request(); + Response response = consoleApiParams.response(); + // User must have the proper permission on the registrar + if (!user.getUserRoles().hasPermission(registrarId, ConsolePermission.REGISTRY_LOCK)) { + setFailedResponse("", HttpStatusCodes.STATUS_CODE_FORBIDDEN); + return; + } + + // Shouldn't happen, but double-check the registrar has registry lock enabled + Registrar registrar = Registrar.loadByRegistrarIdCached(registrarId).get(); + if (!registrar.isRegistryLockAllowed()) { + setFailedResponse( + String.format("Registry lock not allowed for registrar %s", registrarId), + HttpStatusCodes.STATUS_CODE_BAD_REQUEST); + return; + } + + // Retrieve and validate the necessary params + String domainName; + boolean isLock; + Optional maybePassword; + Optional relockDurationMillis; + + try { + domainName = extractRequiredParameter(req, "domainName"); + isLock = extractBooleanParameter(req, "isLock"); + maybePassword = extractOptionalParameter(req, "password"); + relockDurationMillis = extractOptionalLongParameter(req, "relockDurationMillis"); + DomainFlowUtils.validateDomainName(domainName); + } catch (HttpException.BadRequestException | EppException e) { + logger.atWarning().withCause(e).log("Bad request when attempting registry lock/unlock"); + setFailedResponse(e.getMessage(), HttpStatusCodes.STATUS_CODE_BAD_REQUEST); + return; + } + + // Passwords aren't required for admin users, otherwise we need to validate it + boolean isAdmin = user.getUserRoles().isAdmin(); + if (!isAdmin) { + if (maybePassword.isEmpty()) { + setFailedResponse("No password provided", HttpStatusCodes.STATUS_CODE_BAD_REQUEST); + return; + } + if (!user.verifyRegistryLockPassword(maybePassword.get())) { + setFailedResponse( + "Incorrect registry lock password", HttpStatusCodes.STATUS_CODE_UNAUTHORIZED); + return; + } + } + + String userEmail = user.getEmailAddress(); + try { + tm().transact( + () -> { + RegistryLock registryLock = + isLock + ? domainLockUtils.saveNewRegistryLockRequest( + domainName, registrarId, userEmail, isAdmin) + : domainLockUtils.saveNewRegistryUnlockRequest( + domainName, + registrarId, + isAdmin, + relockDurationMillis.map(Duration::new)); + sendVerificationEmail(registryLock, userEmail, isLock); + }); + } catch (IllegalArgumentException e) { + // Catch IllegalArgumentExceptions separately to give a nicer error message and code + logger.atWarning().withCause(e).log("Failed to lock/unlock domain"); + setFailedResponse(e.getMessage(), HttpStatusCodes.STATUS_CODE_BAD_REQUEST); + return; + } catch (Throwable t) { + logger.atWarning().withCause(t).log("Failed to lock/unlock domain"); + setFailedResponse("Internal server error", HttpStatusCodes.STATUS_CODE_SERVER_ERROR); + return; + } + response.setStatus(HttpStatusCodes.STATUS_CODE_OK); + } + + private void sendVerificationEmail(RegistryLock lock, String userEmail, boolean isLock) { + try { + String url = + new URIBuilder() + .setScheme("https") + .setHost(consoleApiParams.request().getServerName()) + // TODO: replace this with the PATH in ConsoleRegistryLockVerifyAction once it exists + .setPath("/console-api/registry-lock-verify") + .setParameter("lockVerificationCode", lock.getVerificationCode()) + .setParameter("isLock", String.valueOf(isLock)) + .build() + .toString(); + String body = String.format(VERIFICATION_EMAIL_TEMPLATE, lock.getDomainName(), url); + ImmutableList recipients = + ImmutableList.of(new InternetAddress(userEmail, true)); + String action = isLock ? "lock" : "unlock"; + gmailClient.sendEmail( + EmailMessage.newBuilder() + .setBody(body) + .setSubject(String.format("Registry %s verification", action)) + .setRecipients(recipients) + .build()); + } catch (AddressException | URISyntaxException e) { + throw new RuntimeException(e); // caught above -- this is so we can run in a transaction + } + } + + private ImmutableList getLockedDomains() { + return tm().transact( + () -> + RegistryLockDao.getLocksByRegistrarId(registrarId).stream() + .filter(lock -> !lock.isLockRequestExpired(tm().getTransactionTime())) + .collect(toImmutableList())); + } +} diff --git a/core/src/main/java/google/registry/ui/server/registrar/RegistryLockPostAction.java b/core/src/main/java/google/registry/ui/server/registrar/RegistryLockPostAction.java index 41274c092..bfb0bbad9 100644 --- a/core/src/main/java/google/registry/ui/server/registrar/RegistryLockPostAction.java +++ b/core/src/main/java/google/registry/ui/server/registrar/RegistryLockPostAction.java @@ -70,17 +70,16 @@ import org.joda.time.Duration; auth = Auth.AUTH_PUBLIC_LOGGED_IN) public class RegistryLockPostAction implements Runnable, JsonActionRunner.JsonAction { public static final String PATH = "/registry-lock-post"; - - private static final FluentLogger logger = FluentLogger.forEnclosingClass(); - private static final Gson GSON = new Gson(); - - private static final String VERIFICATION_EMAIL_TEMPLATE = + public static final String VERIFICATION_EMAIL_TEMPLATE = """ Please click the link below to perform the lock / unlock action on domain %s. Note: this\ code will expire in one hour. %s"""; + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + private static final Gson GSON = new Gson(); + private final HttpServletRequest req; private final JsonActionRunner jsonActionRunner; private final AuthResult authResult; diff --git a/core/src/test/java/google/registry/ui/server/console/ConsoleRegistryLockActionTest.java b/core/src/test/java/google/registry/ui/server/console/ConsoleRegistryLockActionTest.java new file mode 100644 index 000000000..7f104ce1f --- /dev/null +++ b/core/src/test/java/google/registry/ui/server/console/ConsoleRegistryLockActionTest.java @@ -0,0 +1,539 @@ +// 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.testing.DatabaseHelper.createTld; +import static google.registry.testing.DatabaseHelper.loadByEntity; +import static google.registry.testing.DatabaseHelper.loadRegistrar; +import static google.registry.testing.DatabaseHelper.newDomain; +import static google.registry.testing.DatabaseHelper.persistActiveDomain; +import static google.registry.testing.DatabaseHelper.persistResource; +import static google.registry.testing.SqlHelper.getMostRecentRegistryLockByRepoId; +import static google.registry.testing.SqlHelper.saveRegistryLock; +import static google.registry.tools.LockOrUnlockDomainCommand.REGISTRY_LOCK_STATUSES; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import com.google.api.client.http.HttpStatusCodes; +import com.google.common.collect.ImmutableMap; +import com.google.gson.Gson; +import google.registry.groups.GmailClient; +import google.registry.model.console.GlobalRole; +import google.registry.model.console.RegistrarRole; +import google.registry.model.console.User; +import google.registry.model.console.UserRoles; +import google.registry.model.domain.Domain; +import google.registry.model.domain.RegistryLock; +import google.registry.model.eppcommon.StatusValue; +import google.registry.persistence.transaction.JpaTestExtensions; +import google.registry.request.RequestModule; +import google.registry.request.auth.AuthResult; +import google.registry.request.auth.UserAuthInfo; +import google.registry.testing.CloudTasksHelper; +import google.registry.testing.DeterministicStringGenerator; +import google.registry.testing.FakeClock; +import google.registry.testing.FakeConsoleApiParams; +import google.registry.testing.FakeResponse; +import google.registry.tools.DomainLockUtils; +import google.registry.ui.server.registrar.ConsoleApiParams; +import google.registry.util.EmailMessage; +import google.registry.util.StringGenerator; +import java.io.IOException; +import java.util.Optional; +import javax.mail.internet.InternetAddress; +import org.joda.time.DateTime; +import org.joda.time.Duration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +/** Tests for {@link ConsoleRegistryLockAction}. */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class ConsoleRegistryLockActionTest { + + private static final String EMAIL_MESSAGE_TEMPLATE = + "Please click the link below to perform the lock \\/ unlock action on domain example.test." + + " Note: this code will expire in one hour.\n\n" + + "https:\\/\\/registrarconsole.tld\\/console-api\\/registry-lock-verify\\?lockVerificationCode=" + + "[0-9a-zA-Z_\\-]+&isLock=(true|false)"; + + private static final Gson GSON = RequestModule.provideGson(); + + private final FakeClock fakeClock = new FakeClock(DateTime.parse("2024-04-18T12:00:00.000Z")); + + @RegisterExtension + final JpaTestExtensions.JpaIntegrationTestExtension jpa = + new JpaTestExtensions.Builder().withClock(fakeClock).buildIntegrationTestExtension(); + + @Mock GmailClient gmailClient; + private ConsoleRegistryLockAction action; + private Domain defaultDomain; + private FakeResponse response; + private User user; + + @BeforeEach + void beforeEach() throws Exception { + createTld("test"); + defaultDomain = persistActiveDomain("example.test"); + user = + new User.Builder() + .setEmailAddress("user@theregistrar.com") + .setUserRoles( + new UserRoles.Builder() + .setRegistrarRoles( + ImmutableMap.of("TheRegistrar", RegistrarRole.PRIMARY_CONTACT)) + .build()) + .setRegistryLockPassword("registryLockPassword") + .build(); + action = createGetAction(); + } + + @AfterEach + void afterEach() { + verifyNoMoreInteractions(gmailClient); + } + + @Test + void testGet_simpleLock() { + saveRegistryLock( + new RegistryLock.Builder() + .setRepoId("repoId") + .setDomainName("example.test") + .setRegistrarId("TheRegistrar") + .setVerificationCode("123456789ABCDEFGHJKLMNPQRSTUVWXY") + .setRegistrarPocId("johndoe@theregistrar.com") + .setLockCompletionTime(fakeClock.nowUtc()) + .build()); + action.run(); + assertThat(response.getStatus()).isEqualTo(HttpStatusCodes.STATUS_CODE_OK); + assertThat(response.getPayload()) + .isEqualTo( + """ + [{"domainName":"example.test","registrarPocId":"johndoe@theregistrar.com","lockRequestTime":\ + {"creationTime":"2024-04-18T12:00:00.000Z"},"unlockRequestTime":"null","lockCompletionTime":\ + "2024-04-18T12:00:00.000Z","unlockCompletionTime":"null","isSuperuser":false}]\ + """); + } + + @Test + void testGet_allCurrentlyValidLocks() { + RegistryLock expiredLock = + new RegistryLock.Builder() + .setRepoId("repoId") + .setDomainName("expired.test") + .setRegistrarId("TheRegistrar") + .setVerificationCode("123456789ABCDEFGHJKLMNPQRSTUVWXY") + .setRegistrarPocId("johndoe@theregistrar.com") + .build(); + saveRegistryLock(expiredLock); + RegistryLock expiredUnlock = + new RegistryLock.Builder() + .setRepoId("repoId") + .setDomainName("expiredunlock.test") + .setRegistrarId("TheRegistrar") + .setVerificationCode("123456789ABCDEFGHJKLMNPQRSTUVWXY") + .setRegistrarPocId("johndoe@theregistrar.com") + .setLockCompletionTime(fakeClock.nowUtc()) + .setUnlockRequestTime(fakeClock.nowUtc()) + .build(); + saveRegistryLock(expiredUnlock); + fakeClock.advanceBy(Duration.standardDays(1)); + + RegistryLock regularLock = + new RegistryLock.Builder() + .setRepoId("repoId") + .setDomainName("example.test") + .setRegistrarId("TheRegistrar") + .setVerificationCode("123456789ABCDEFGHJKLMNPQRSTUVWXY") + .setRegistrarPocId("johndoe@theregistrar.com") + .setLockCompletionTime(fakeClock.nowUtc()) + .build(); + fakeClock.advanceOneMilli(); + RegistryLock adminLock = + new RegistryLock.Builder() + .setRepoId("repoId") + .setDomainName("adminexample.test") + .setRegistrarId("TheRegistrar") + .setVerificationCode("122222222ABCDEFGHJKLMNPQRSTUVWXY") + .isSuperuser(true) + .setLockCompletionTime(fakeClock.nowUtc()) + .build(); + RegistryLock incompleteLock = + new RegistryLock.Builder() + .setRepoId("repoId") + .setDomainName("pending.test") + .setRegistrarId("TheRegistrar") + .setVerificationCode("111111111ABCDEFGHJKLMNPQRSTUVWXY") + .setRegistrarPocId("johndoe@theregistrar.com") + .build(); + + RegistryLock incompleteUnlock = + new RegistryLock.Builder() + .setRepoId("repoId") + .setDomainName("incompleteunlock.test") + .setRegistrarId("TheRegistrar") + .setVerificationCode("123456789ABCDEFGHJKLMNPQRSTUVWXY") + .setRegistrarPocId("johndoe@theregistrar.com") + .setLockCompletionTime(fakeClock.nowUtc()) + .setUnlockRequestTime(fakeClock.nowUtc()) + .build(); + + RegistryLock unlockedLock = + new RegistryLock.Builder() + .setRepoId("repoId") + .setDomainName("unlocked.test") + .setRegistrarId("TheRegistrar") + .setRegistrarPocId("johndoe@theregistrar.com") + .setVerificationCode("123456789ABCDEFGHJKLMNPQRSTUUUUU") + .setLockCompletionTime(fakeClock.nowUtc()) + .setUnlockRequestTime(fakeClock.nowUtc()) + .setUnlockCompletionTime(fakeClock.nowUtc()) + .build(); + + saveRegistryLock(regularLock); + saveRegistryLock(adminLock); + saveRegistryLock(incompleteLock); + saveRegistryLock(incompleteUnlock); + saveRegistryLock(unlockedLock); + + action.run(); + assertThat(response.getStatus()).isEqualTo(HttpStatusCodes.STATUS_CODE_OK); + // We should include all the locks that are currently locked, which does not include pending + // locks or completed unlocks + assertThat(response.getPayload()) + .isEqualTo( + """ + [{"domainName":"adminexample.test","lockRequestTime":{"creationTime":"2024-04-19T12:00:00.001Z"},\ + "unlockRequestTime":"null","lockCompletionTime":"2024-04-19T12:00:00.001Z","unlockCompletionTime":\ + "null","isSuperuser":true},\ + \ + {"domainName":"example.test","registrarPocId":"johndoe@theregistrar.com","lockRequestTime":\ + {"creationTime":"2024-04-19T12:00:00.001Z"},"unlockRequestTime":"null","lockCompletionTime":\ + "2024-04-19T12:00:00.000Z","unlockCompletionTime":"null","isSuperuser":false},\ + \ + {"domainName":"expiredunlock.test","registrarPocId":"johndoe@theregistrar.com","lockRequestTime":\ + {"creationTime":"2024-04-18T12:00:00.000Z"},"unlockRequestTime":"2024-04-18T12:00:00.000Z",\ + "lockCompletionTime":"2024-04-18T12:00:00.000Z","unlockCompletionTime":"null","isSuperuser":false},\ + \ + {"domainName":"incompleteunlock.test","registrarPocId":"johndoe@theregistrar.com","lockRequestTime":\ + {"creationTime":"2024-04-19T12:00:00.001Z"},"unlockRequestTime":"2024-04-19T12:00:00.001Z",\ + "lockCompletionTime":"2024-04-19T12:00:00.001Z","unlockCompletionTime":"null","isSuperuser":false},\ + \ + {"domainName":"pending.test","registrarPocId":"johndoe@theregistrar.com","lockRequestTime":\ + {"creationTime":"2024-04-19T12:00:00.001Z"},"unlockRequestTime":"null","lockCompletionTime":"null",\ + "unlockCompletionTime":"null","isSuperuser":false}]"""); + } + + @Test + void testGet_noLocks() { + action.run(); + assertThat(response.getStatus()).isEqualTo(HttpStatusCodes.STATUS_CODE_OK); + assertThat(response.getPayload()).isEqualTo("[]"); + } + + @Test + void testGet_failure_noRegistrarAccess() throws Exception { + user = + user.asBuilder() + .setUserRoles( + user.getUserRoles().asBuilder().setRegistrarRoles(ImmutableMap.of()).build()) + .build(); + action = createGetAction(); + action.run(); + assertThat(response.getStatus()).isEqualTo(HttpStatusCodes.STATUS_CODE_FORBIDDEN); + } + + @Test + void testGet_failure_noRegistryLockAccess() throws Exception { + // User has access to the registrar, but not to do locks + user = + user.asBuilder() + .setUserRoles( + user.getUserRoles() + .asBuilder() + .setRegistrarRoles( + ImmutableMap.of("TheRegistrar", RegistrarRole.ACCOUNT_MANAGER)) + .build()) + .build(); + action = createGetAction(); + action.run(); + assertThat(response.getStatus()).isEqualTo(HttpStatusCodes.STATUS_CODE_FORBIDDEN); + } + + @Test + void testPost_lock() throws Exception { + action = createDefaultPostAction(true); + action.run(); + assertThat(response.getStatus()).isEqualTo(HttpStatusCodes.STATUS_CODE_OK); + assertThat(getMostRecentRegistryLockByRepoId(defaultDomain.getRepoId())).isPresent(); + verifyEmail(); + // Doesn't actually change the status values (hasn't been verified) + assertThat(loadByEntity(defaultDomain).getStatusValues()).containsExactly(StatusValue.INACTIVE); + } + + @Test + void testPost_unlock() throws Exception { + saveRegistryLock(createDefaultLockBuilder().setLockCompletionTime(fakeClock.nowUtc()).build()); + persistResource(defaultDomain.asBuilder().setStatusValues(REGISTRY_LOCK_STATUSES).build()); + action = createDefaultPostAction(false); + action.run(); + assertThat(response.getStatus()).isEqualTo(HttpStatusCodes.STATUS_CODE_OK); + verifyEmail(); + // Doesn't actually change the status values (hasn't been verified) + assertThat(loadByEntity(defaultDomain).getStatusValues()) + .containsAtLeastElementsIn(REGISTRY_LOCK_STATUSES); + } + + @Test + void testPost_unlock_relockDuration() throws Exception { + saveRegistryLock(createDefaultLockBuilder().setLockCompletionTime(fakeClock.nowUtc()).build()); + persistResource(defaultDomain.asBuilder().setStatusValues(REGISTRY_LOCK_STATUSES).build()); + action = + createPostAction( + "example.test", + false, + "registryLockPassword", + Optional.of(Duration.standardDays(1).getMillis())); + action.run(); + assertThat(response.getStatus()).isEqualTo(HttpStatusCodes.STATUS_CODE_OK); + verifyEmail(); + RegistryLock savedUnlockRequest = + getMostRecentRegistryLockByRepoId(defaultDomain.getRepoId()).get(); + assertThat(savedUnlockRequest.getRelockDuration()) + .isEqualTo(Optional.of(Duration.standardDays(1))); + } + + @Test + void testPost_adminUnlockingAdmin() throws Exception { + saveRegistryLock( + createDefaultLockBuilder() + .setLockCompletionTime(fakeClock.nowUtc()) + .isSuperuser(true) + .build()); + persistResource(defaultDomain.asBuilder().setStatusValues(REGISTRY_LOCK_STATUSES).build()); + user = + user.asBuilder() + .setUserRoles( + new UserRoles.Builder().setGlobalRole(GlobalRole.FTE).setIsAdmin(true).build()) + .build(); + action = createDefaultPostAction(false); + action.run(); + assertThat(response.getStatus()).isEqualTo(HttpStatusCodes.STATUS_CODE_OK); + verifyEmail(); + } + + @Test + void testPost_success_noPasswordForAdmin() throws Exception { + user = + user.asBuilder() + .setUserRoles( + new UserRoles.Builder().setGlobalRole(GlobalRole.FTE).setIsAdmin(true).build()) + .build(); + action = createPostAction("example.test", true, "", Optional.empty()); + action.run(); + assertThat(response.getStatus()).isEqualTo(HttpStatusCodes.STATUS_CODE_OK); + verifyEmail(); + } + + @Test + void testPost_failure_noRegistrarAccess() throws Exception { + user = + user.asBuilder() + .setUserRoles( + user.getUserRoles().asBuilder().setRegistrarRoles(ImmutableMap.of()).build()) + .build(); + action = createDefaultPostAction(true); + action.run(); + assertThat(response.getStatus()).isEqualTo(HttpStatusCodes.STATUS_CODE_FORBIDDEN); + } + + @Test + void testPost_failure_noRegistryLockAccess() throws Exception { + // User has access to the registrar, but not to do locks + user = + user.asBuilder() + .setUserRoles( + user.getUserRoles() + .asBuilder() + .setRegistrarRoles( + ImmutableMap.of("TheRegistrar", RegistrarRole.ACCOUNT_MANAGER)) + .build()) + .build(); + action = createDefaultPostAction(true); + action.run(); + assertThat(response.getStatus()).isEqualTo(HttpStatusCodes.STATUS_CODE_FORBIDDEN); + } + + @Test + void testPost_failure_unlock_noLock() throws Exception { + action = createDefaultPostAction(false); + action.run(); + assertThat(response.getStatus()).isEqualTo(HttpStatusCodes.STATUS_CODE_BAD_REQUEST); + assertThat(response.getPayload()).isEqualTo("Domain example.test is already unlocked"); + } + + @Test + void testPost_failure_nonAdminUnlockingAdmin() throws Exception { + saveRegistryLock( + createDefaultLockBuilder() + .setLockCompletionTime(fakeClock.nowUtc()) + .isSuperuser(true) + .build()); + persistResource(defaultDomain.asBuilder().setStatusValues(REGISTRY_LOCK_STATUSES).build()); + action = createDefaultPostAction(false); + action.run(); + assertThat(response.getStatus()).isEqualTo(HttpStatusCodes.STATUS_CODE_BAD_REQUEST); + assertThat(response.getPayload()) + .isEqualTo("Non-admin user cannot unlock admin-locked domain example.test"); + } + + @Test + void testPost_failure_wrongRegistrarForDomain() throws Exception { + persistResource( + newDomain("otherregistrar.test") + .asBuilder() + .setCreationRegistrarId("NewRegistrar") + .setPersistedCurrentSponsorRegistrarId("NewRegistrar") + .build()); + action = + createPostAction("otherregistrar.test", true, "registryLockPassword", Optional.empty()); + action.run(); + assertThat(response.getStatus()).isEqualTo(HttpStatusCodes.STATUS_CODE_BAD_REQUEST); + assertThat(response.getPayload()) + .isEqualTo("Domain otherregistrar.test is not owned by registrar TheRegistrar"); + } + + @Test + void testPost_failure_notAllowedForRegistrar() throws Exception { + persistResource( + loadRegistrar("TheRegistrar").asBuilder().setRegistryLockAllowed(false).build()); + action = createDefaultPostAction(true); + action.run(); + assertThat(response.getStatus()).isEqualTo(HttpStatusCodes.STATUS_CODE_BAD_REQUEST); + assertThat(response.getPayload()) + .isEqualTo("Registry lock not allowed for registrar TheRegistrar"); + } + + @Test + void testPost_failure_badPassword() throws Exception { + action = createPostAction("example.test", true, "badPassword", Optional.empty()); + action.run(); + assertThat(response.getStatus()).isEqualTo(HttpStatusCodes.STATUS_CODE_UNAUTHORIZED); + } + + @Test + void testPost_failure_lock_alreadyPendingLock() throws Exception { + saveRegistryLock(createDefaultLockBuilder().build()); + action = createDefaultPostAction(true); + action.run(); + assertThat(response.getStatus()).isEqualTo(HttpStatusCodes.STATUS_CODE_BAD_REQUEST); + assertThat(response.getPayload()) + .isEqualTo("A pending or completed lock action already exists for example.test"); + } + + @Test + void testPost_failure_alreadyLocked() throws Exception { + persistResource(defaultDomain.asBuilder().setStatusValues(REGISTRY_LOCK_STATUSES).build()); + action = createDefaultPostAction(true); + action.run(); + assertThat(response.getStatus()).isEqualTo(HttpStatusCodes.STATUS_CODE_BAD_REQUEST); + assertThat(response.getPayload()).isEqualTo("Domain example.test is already locked"); + } + + @Test + void testPost_failure_alreadyUnlocked() throws Exception { + saveRegistryLock( + createDefaultLockBuilder() + .setLockCompletionTime(fakeClock.nowUtc()) + .setUnlockRequestTime(fakeClock.nowUtc()) + .setUnlockCompletionTime(fakeClock.nowUtc()) + .build()); + action = createDefaultPostAction(false); + action.run(); + assertThat(response.getStatus()).isEqualTo(HttpStatusCodes.STATUS_CODE_BAD_REQUEST); + assertThat(response.getPayload()).isEqualTo("Domain example.test is already unlocked"); + } + + private ConsoleRegistryLockAction createDefaultPostAction(boolean isLock) { + return createPostAction("example.test", isLock, "registryLockPassword", Optional.empty()); + } + + private ConsoleRegistryLockAction createPostAction( + String domainName, boolean isLock, String password, Optional relockDurationMillis) { + ConsoleApiParams params = createParams(); + when(params.request().getParameter("domainName")).thenReturn(domainName); + when(params.request().getParameterMap()) + .thenReturn(ImmutableMap.of("isLock", new String[] {String.valueOf(isLock)})); + when(params.request().getParameter("isLock")).thenReturn(String.valueOf(isLock)); + when(params.request().getParameter("password")).thenReturn(password); + relockDurationMillis.ifPresent( + duration -> + when(params.request().getParameter("relockDurationMillis")) + .thenReturn(String.valueOf(duration))); + return createGenericAction(params, "POST"); + } + + private ConsoleRegistryLockAction createGetAction() throws IOException { + return createGenericAction(createParams(), "GET"); + } + + private ConsoleRegistryLockAction createGenericAction(ConsoleApiParams params, String method) { + when(params.request().getMethod()).thenReturn(method); + when(params.request().getServerName()).thenReturn("registrarconsole.tld"); + when(params.request().getParameter("registrarId")).thenReturn("TheRegistrar"); + DomainLockUtils domainLockUtils = + new DomainLockUtils( + new DeterministicStringGenerator(StringGenerator.Alphabets.BASE_58), + "adminreg", + new CloudTasksHelper(fakeClock).getTestCloudTasksUtils()); + response = (FakeResponse) params.response(); + return new ConsoleRegistryLockAction( + params, domainLockUtils, gmailClient, GSON, "TheRegistrar"); + } + + private ConsoleApiParams createParams() { + AuthResult authResult = AuthResult.createUser(UserAuthInfo.create(user)); + return FakeConsoleApiParams.get(Optional.of(authResult)); + } + + private RegistryLock.Builder createDefaultLockBuilder() { + return new RegistryLock.Builder() + .setRepoId(defaultDomain.getRepoId()) + .setDomainName(defaultDomain.getDomainName()) + .setRegistrarId(defaultDomain.getCurrentSponsorRegistrarId()) + .setRegistrarPocId("johndoe@theregistrar.com") + .setVerificationCode("123456789ABCDEFGHJKLMNPQRSTUUUUU"); + } + + private void verifyEmail() throws Exception { + ArgumentCaptor emailCaptor = ArgumentCaptor.forClass(EmailMessage.class); + verify(gmailClient).sendEmail(emailCaptor.capture()); + EmailMessage sentMessage = emailCaptor.getValue(); + assertThat(sentMessage.subject()).matches("Registry (un)?lock verification"); + assertThat(sentMessage.body()).matches(EMAIL_MESSAGE_TEMPLATE); + assertThat(sentMessage.recipients()) + .containsExactly(new InternetAddress("user@theregistrar.com")); + } +} diff --git a/core/src/test/java/google/registry/ui/server/console/ConsoleUserDataActionTest.java b/core/src/test/java/google/registry/ui/server/console/ConsoleUserDataActionTest.java index 6392b83c7..50921d91a 100644 --- a/core/src/test/java/google/registry/ui/server/console/ConsoleUserDataActionTest.java +++ b/core/src/test/java/google/registry/ui/server/console/ConsoleUserDataActionTest.java @@ -22,7 +22,6 @@ import com.google.api.client.http.HttpStatusCodes; import com.google.gson.Gson; import google.registry.model.console.User; import google.registry.persistence.transaction.JpaTestExtensions; -import google.registry.request.Action; import google.registry.request.RequestModule; import google.registry.request.auth.AuthResult; import google.registry.request.auth.UserAuthInfo; @@ -54,8 +53,7 @@ class ConsoleUserDataActionTest { User user = DatabaseHelper.createAdminUser("email@email.com"); AuthResult authResult = AuthResult.createUser(UserAuthInfo.create(user)); ConsoleUserDataAction action = - createAction( - Optional.of(FakeConsoleApiParams.get(Optional.of(authResult))), Action.Method.GET); + createAction(Optional.of(FakeConsoleApiParams.get(Optional.of(authResult)))); action.run(); List cookies = ((FakeResponse) consoleApiParams.response()).getCookies(); assertThat(cookies.stream().map(cookie -> cookie.getName()).collect(toImmutableList())) @@ -67,8 +65,7 @@ class ConsoleUserDataActionTest { User user = DatabaseHelper.createAdminUser("email@email.com"); AuthResult authResult = AuthResult.createUser(UserAuthInfo.create(user)); ConsoleUserDataAction action = - createAction( - Optional.of(FakeConsoleApiParams.get(Optional.of(authResult))), Action.Method.GET); + createAction(Optional.of(FakeConsoleApiParams.get(Optional.of(authResult)))); action.run(); assertThat(((FakeResponse) consoleApiParams.response()).getStatus()) .isEqualTo(HttpStatusCodes.STATUS_CODE_OK); @@ -92,17 +89,17 @@ class ConsoleUserDataActionTest { @Test void testFailure_notAConsoleUser() throws IOException { - ConsoleUserDataAction action = createAction(Optional.empty(), Action.Method.GET); + ConsoleUserDataAction action = createAction(Optional.empty()); action.run(); assertThat(((FakeResponse) consoleApiParams.response()).getStatus()) .isEqualTo(HttpStatusCodes.STATUS_CODE_UNAUTHORIZED); } - private ConsoleUserDataAction createAction( - Optional maybeConsoleApiParams, Action.Method method) throws IOException { + private ConsoleUserDataAction createAction(Optional maybeConsoleApiParams) + throws IOException { consoleApiParams = maybeConsoleApiParams.orElseGet(() -> FakeConsoleApiParams.get(Optional.empty())); - when(consoleApiParams.request().getMethod()).thenReturn(method.toString()); + when(consoleApiParams.request().getMethod()).thenReturn("GET"); return new ConsoleUserDataAction( consoleApiParams, "Nomulus", "support@example.com", "+1 (212) 867 5309", "test"); } 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 8951d7077..911d56fcf 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 @@ -5,6 +5,7 @@ PATH CLASS METHODS OK AUT /console-api/dum-download ConsoleDumDownloadAction 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/registry-lock ConsoleRegistryLockAction 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 /console-api/settings/whois-fields WhoisRegistrarFieldsAction POST n API,LEGACY USER PUBLIC diff --git a/core/src/test/resources/google/registry/module/routing.txt b/core/src/test/resources/google/registry/module/routing.txt index 376ebb319..fc138951b 100644 --- a/core/src/test/resources/google/registry/module/routing.txt +++ b/core/src/test/resources/google/registry/module/routing.txt @@ -60,6 +60,7 @@ PATH CLASS /console-api/dum-download ConsoleDumDownloadAction 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/registry-lock ConsoleRegistryLockAction 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 /console-api/settings/whois-fields WhoisRegistrarFieldsAction POST n API,LEGACY USER PUBLIC