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 0351e6ddb14..ecb43665a9c 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 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 7645e835f3a..19ba44f6773 100644 --- a/core/src/main/java/google/registry/module/RequestComponent.java +++ b/core/src/main/java/google/registry/module/RequestComponent.java @@ -111,6 +111,7 @@ 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.ConsoleRegistryLockAction; import google.registry.ui.server.console.ConsoleUserDataAction; import google.registry.ui.server.console.RegistrarsAction; import google.registry.ui.server.console.settings.ContactAction; @@ -185,6 +186,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 bf40ef47402..ff88e2759cd 100644 --- a/core/src/main/java/google/registry/module/frontend/FrontendRequestComponent.java +++ b/core/src/main/java/google/registry/module/frontend/FrontendRequestComponent.java @@ -28,6 +28,7 @@ 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.ConsoleRegistryLockAction; import google.registry.ui.server.console.ConsoleUserDataAction; import google.registry.ui.server.console.RegistrarsAction; import google.registry.ui.server.console.settings.ContactAction; @@ -63,6 +64,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 08deba8e066..dc47df204b9 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 static int extractIntParameter(HttpServletRequest req, String name) { } } + /** + * 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 00000000000..430f091b71e --- /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/console/ConsoleUserDataAction.java b/core/src/main/java/google/registry/ui/server/console/ConsoleUserDataAction.java index 314013cf3a6..8258314e70c 100644 --- a/core/src/main/java/google/registry/ui/server/console/ConsoleUserDataAction.java +++ b/core/src/main/java/google/registry/ui/server/console/ConsoleUserDataAction.java @@ -22,6 +22,7 @@ import google.registry.model.console.User; import google.registry.request.Action; import google.registry.request.auth.Auth; +import google.registry.security.XsrfTokenManager; import google.registry.ui.server.registrar.ConsoleApiParams; import jakarta.servlet.http.Cookie; import javax.inject.Inject; @@ -61,7 +62,7 @@ protected void getHandler(User user) { // for angular to read - https://angular.io/guide/http-security-xsrf-protection Cookie xsrfCookie = new Cookie( - consoleApiParams.xsrfTokenManager().X_CSRF_TOKEN, + XsrfTokenManager.X_CSRF_TOKEN, consoleApiParams.xsrfTokenManager().generateToken(user.getEmailAddress())); xsrfCookie.setSecure(true); consoleApiParams.response().addCookie(xsrfCookie); 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 e268d9fcaf0..45d28b505f0 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 @@ -74,7 +74,7 @@ public class RegistryLockPostAction implements Runnable, JsonActionRunner.JsonAc 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.\n\n%s"; diff --git a/core/src/test/java/google/registry/testing/FakeConsoleApiParams.java b/core/src/test/java/google/registry/testing/FakeConsoleApiParams.java index 8d9f85bcdeb..d8cf867c80d 100644 --- a/core/src/test/java/google/registry/testing/FakeConsoleApiParams.java +++ b/core/src/test/java/google/registry/testing/FakeConsoleApiParams.java @@ -15,12 +15,14 @@ package google.registry.testing; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import com.google.appengine.api.users.UserService; import google.registry.request.auth.AuthResult; import google.registry.request.auth.UserAuthInfo; import google.registry.security.XsrfTokenManager; import google.registry.ui.server.registrar.ConsoleApiParams; +import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import java.util.Optional; import org.joda.time.DateTime; @@ -36,11 +38,15 @@ public static ConsoleApiParams get(Optional maybeAuthResult) { new com.google.appengine.api.users.User( "JohnDoe@theregistrar.com", "theregistrar.com"), false))); - return ConsoleApiParams.create( - mock(HttpServletRequest.class), - new FakeResponse(), - authResult, + HttpServletRequest request = mock(HttpServletRequest.class); + XsrfTokenManager xsrfTokenManager = new XsrfTokenManager( - new FakeClock(DateTime.parse("2020-02-02T01:23:45Z")), mock(UserService.class))); + new FakeClock(DateTime.parse("2020-02-02T01:23:45Z")), mock(UserService.class)); + when(request.getCookies()) + .thenReturn( + new Cookie[] { + new Cookie(XsrfTokenManager.X_CSRF_TOKEN, xsrfTokenManager.generateToken("")) + }); + return ConsoleApiParams.create(request, new FakeResponse(), authResult, xsrfTokenManager); } } 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 00000000000..7f104ce1fda --- /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 801ae92f2c9..3ed3d3d0533 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 @@ -24,7 +24,6 @@ import google.registry.model.console.User; import google.registry.model.console.UserRoles; 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; @@ -60,8 +59,7 @@ void testSuccess_hasXSRFCookie() throws IOException { 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())) @@ -78,8 +76,7 @@ void testSuccess_getContactInfo() throws IOException { 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); @@ -103,17 +100,17 @@ void testSuccess_getContactInfo() throws IOException { @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 c708534cf8c..6f9f2ae982c 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 @@ -4,6 +4,7 @@ PATH CLASS METHODS OK AUT /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/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 748e1fe3a3b..133cbb46c4d 100644 --- a/core/src/test/resources/google/registry/module/routing.txt +++ b/core/src/test/resources/google/registry/module/routing.txt @@ -59,6 +59,7 @@ PATH CLASS /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/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 @@ -80,4 +81,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 \ No newline at end of file +/whois/(*) WhoisHttpAction GET n API NONE PUBLIC