diff --git a/core/src/main/java/google/registry/env/common/default/WEB-INF/web.xml b/core/src/main/java/google/registry/env/common/default/WEB-INF/web.xml index 639a1204d..80d6601e7 100644 --- a/core/src/main/java/google/registry/env/common/default/WEB-INF/web.xml +++ b/core/src/main/java/google/registry/env/common/default/WEB-INF/web.xml @@ -55,6 +55,12 @@ /registrar-settings + + + frontend-servlet + /registry-lock-get + + 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 c8d2b1352..a9b529051 100644 --- a/core/src/main/java/google/registry/module/frontend/FrontendRequestComponent.java +++ b/core/src/main/java/google/registry/module/frontend/FrontendRequestComponent.java @@ -30,6 +30,7 @@ import google.registry.ui.server.registrar.ConsoleUiAction; import google.registry.ui.server.registrar.OteStatusAction; import google.registry.ui.server.registrar.RegistrarConsoleModule; import google.registry.ui.server.registrar.RegistrarSettingsAction; +import google.registry.ui.server.registrar.RegistryLockGetAction; /** Dagger component with per-request lifetime for "default" App Engine module. */ @RequestScope @@ -50,6 +51,8 @@ interface FrontendRequestComponent { OteStatusAction oteStatusAction(); RegistrarSettingsAction registrarSettingsAction(); + RegistryLockGetAction registryLockGetAction(); + @Subcomponent.Builder abstract class Builder implements RequestComponentBuilder { @Override public abstract Builder requestModule(RequestModule requestModule); diff --git a/core/src/main/java/google/registry/request/auth/AuthenticatedRegistrarAccessor.java b/core/src/main/java/google/registry/request/auth/AuthenticatedRegistrarAccessor.java index 581fc3f3e..92ef3c964 100644 --- a/core/src/main/java/google/registry/request/auth/AuthenticatedRegistrarAccessor.java +++ b/core/src/main/java/google/registry/request/auth/AuthenticatedRegistrarAccessor.java @@ -347,7 +347,7 @@ public class AuthenticatedRegistrarAccessor { /** Exception thrown when the current user doesn't have access to the requested Registrar. */ public static class RegistrarAccessDeniedException extends Exception { - RegistrarAccessDeniedException(String message) { + public RegistrarAccessDeniedException(String message) { super(message); } } diff --git a/core/src/main/java/google/registry/schema/domain/RegistryLock.java b/core/src/main/java/google/registry/schema/domain/RegistryLock.java index 49b69f64d..84e2090f4 100644 --- a/core/src/main/java/google/registry/schema/domain/RegistryLock.java +++ b/core/src/main/java/google/registry/schema/domain/RegistryLock.java @@ -176,6 +176,10 @@ public final class RegistryLock extends ImmutableObject implements Buildable { this.completionTimestamp = toZonedDateTime(dateTime); } + public boolean isVerified() { + return completionTimestamp != null; + } + @Override public Builder asBuilder() { return new Builder(clone(this)); diff --git a/core/src/main/java/google/registry/ui/server/registrar/RegistryLockGetAction.java b/core/src/main/java/google/registry/ui/server/registrar/RegistryLockGetAction.java new file mode 100644 index 000000000..d9cfcde26 --- /dev/null +++ b/core/src/main/java/google/registry/ui/server/registrar/RegistryLockGetAction.java @@ -0,0 +1,171 @@ +// Copyright 2019 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.registrar; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.collect.ImmutableList.toImmutableList; +import static com.google.common.net.HttpHeaders.X_FRAME_OPTIONS; +import static google.registry.security.JsonResponseHelper.Status.SUCCESS; +import static google.registry.ui.server.registrar.RegistrarConsoleModule.PARAM_CLIENT_ID; +import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN; +import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR; + +import com.google.appengine.api.users.User; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.flogger.FluentLogger; +import com.google.common.net.MediaType; +import com.google.gson.Gson; +import google.registry.model.registrar.Registrar; +import google.registry.model.registrar.RegistrarContact; +import google.registry.model.registry.RegistryLockDao; +import google.registry.request.Action; +import google.registry.request.Action.Method; +import google.registry.request.Parameter; +import google.registry.request.RequestMethod; +import google.registry.request.Response; +import google.registry.request.auth.Auth; +import google.registry.request.auth.AuthResult; +import google.registry.request.auth.AuthenticatedRegistrarAccessor; +import google.registry.request.auth.AuthenticatedRegistrarAccessor.RegistrarAccessDeniedException; +import google.registry.request.auth.UserAuthInfo; +import google.registry.schema.domain.RegistryLock; +import google.registry.security.JsonResponseHelper; +import java.util.Optional; +import javax.inject.Inject; +import org.joda.time.DateTime; + +/** + * Servlet that allows for getting locks for a particular registrar. + * + *

Note: at the moment we have no mechanism for JSON GET/POSTs in the same class or at the same + * URL, which is why this is distinct from the {@link RegistryLockPostAction}. + */ +@Action( + service = Action.Service.DEFAULT, + path = RegistryLockGetAction.PATH, + auth = Auth.AUTH_PUBLIC_LOGGED_IN) +public final class RegistryLockGetAction implements Runnable { + + public static final String PATH = "/registry-lock-get"; + + private static final String LOCK_ENABLED_FOR_CONTACT_PARAM = "lockEnabledForContact"; + private static final String EMAIL_PARAM = "email"; + private static final String LOCKS_PARAM = "locks"; + private static final String FULLY_QUALIFIED_DOMAIN_NAME_PARAM = "fullyQualifiedDomainName"; + private static final String LOCKED_TIME_PARAM = "lockedTime"; + private static final String LOCKED_BY_PARAM = "lockedBy"; + + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + private static final Gson GSON = new Gson(); + + @VisibleForTesting Method method; + private final Response response; + private final AuthenticatedRegistrarAccessor registrarAccessor; + @VisibleForTesting AuthResult authResult; + @VisibleForTesting Optional paramClientId; + + @Inject + RegistryLockGetAction( + @RequestMethod Method method, + Response response, + AuthenticatedRegistrarAccessor registrarAccessor, + AuthResult authResult, + @Parameter(PARAM_CLIENT_ID) Optional paramClientId) { + this.method = method; + this.response = response; + this.registrarAccessor = registrarAccessor; + this.authResult = authResult; + this.paramClientId = paramClientId; + } + + @Override + public void run() { + checkArgument(Method.GET.equals(method), "Only GET requests allowed"); + checkArgument(authResult.userAuthInfo().isPresent(), "User auth info must be present"); + checkArgument(paramClientId.isPresent(), "clientId must be present"); + response.setContentType(MediaType.JSON_UTF_8); + response.setHeader(X_FRAME_OPTIONS, "SAMEORIGIN"); // Disallow iframing. + response.setHeader("X-Ui-Compatible", "IE=edge"); // Ask IE not to be silly. + + try { + ImmutableMap resultMap = getLockedDomainsMap(paramClientId.get()); + ImmutableMap payload = + JsonResponseHelper.create(SUCCESS, "Successful locks retrieval", resultMap); + response.setPayload(GSON.toJson(payload)); + } catch (RegistrarAccessDeniedException e) { + logger.atWarning().withCause(e).log( + "User %s doesn't have access to this registrar", authResult.userIdForLogging()); + response.setStatus(SC_FORBIDDEN); + } catch (Exception e) { + logger.atWarning().withCause(e).log("Unexpected error when retrieving locks for a registrar"); + response.setStatus(SC_INTERNAL_SERVER_ERROR); + } + } + + private ImmutableMap getLockedDomainsMap(String clientId) + throws RegistrarAccessDeniedException { + // Note: admins always have access to the locks page + checkArgument(authResult.userAuthInfo().isPresent(), "User auth info must be present"); + UserAuthInfo userAuthInfo = authResult.userAuthInfo().get(); + boolean isAdmin = userAuthInfo.isUserAdmin(); + Registrar registrar = getRegistrarAndVerifyLockAccess(clientId, isAdmin); + User user = userAuthInfo.user(); + boolean isRegistryLockAllowed = + isAdmin + || registrar.getContacts().stream() + .filter(contact -> contact.getEmailAddress().equals(user.getEmail())) + .findFirst() + .map(RegistrarContact::isRegistryLockAllowed) + .orElse(false); + return ImmutableMap.of( + LOCK_ENABLED_FOR_CONTACT_PARAM, + isRegistryLockAllowed, + EMAIL_PARAM, + user.getEmail(), + PARAM_CLIENT_ID, + registrar.getClientId(), + LOCKS_PARAM, + getLockedDomains(clientId)); + } + + private Registrar getRegistrarAndVerifyLockAccess(String clientId, boolean isAdmin) + throws RegistrarAccessDeniedException { + Registrar registrar = registrarAccessor.getRegistrar(clientId); + checkArgument( + isAdmin || registrar.isRegistryLockAllowed(), + "Registry lock not allowed for this registrar"); + return registrar; + } + + private ImmutableList> getLockedDomains(String clientId) { + ImmutableList locks = + RegistryLockDao.getByRegistrarId(clientId).stream() + .filter(RegistryLock::isVerified) + .collect(toImmutableList()); + return locks.stream().map(this::lockToMap).collect(toImmutableList()); + } + + private ImmutableMap lockToMap(RegistryLock lock) { + return ImmutableMap.of( + FULLY_QUALIFIED_DOMAIN_NAME_PARAM, + lock.getDomainName(), + LOCKED_TIME_PARAM, + lock.getCompletionTimestamp().map(DateTime::toString).orElse(""), + LOCKED_BY_PARAM, + lock.isSuperuser() ? "admin" : lock.getRegistrarPocId()); + } +} diff --git a/core/src/test/java/google/registry/server/RegistryTestServer.java b/core/src/test/java/google/registry/server/RegistryTestServer.java index 1ae9715f9..0d36cfbbe 100644 --- a/core/src/test/java/google/registry/server/RegistryTestServer.java +++ b/core/src/test/java/google/registry/server/RegistryTestServer.java @@ -82,7 +82,8 @@ public final class RegistryTestServer { route("/registrar-create", FrontendServlet.class), route("/registrar-ote-setup", FrontendServlet.class), route("/registrar-ote-status", FrontendServlet.class), - route("/registrar-settings", FrontendServlet.class)); + route("/registrar-settings", FrontendServlet.class), + route("/registry-lock-get", FrontendServlet.class)); private static final ImmutableList> FILTERS = ImmutableList.of( ObjectifyFilter.class, diff --git a/core/src/test/java/google/registry/ui/server/registrar/RegistryLockGetActionTest.java b/core/src/test/java/google/registry/ui/server/registrar/RegistryLockGetActionTest.java new file mode 100644 index 000000000..796e075d4 --- /dev/null +++ b/core/src/test/java/google/registry/ui/server/registrar/RegistryLockGetActionTest.java @@ -0,0 +1,256 @@ +// Copyright 2019 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.registrar; + +import static com.google.common.truth.Truth.assertThat; +import static google.registry.request.auth.AuthenticatedRegistrarAccessor.Role.OWNER; +import static google.registry.testing.AppEngineRule.makeRegistrar2; +import static google.registry.testing.AppEngineRule.makeRegistrarContact3; +import static google.registry.testing.DatastoreHelper.persistResource; +import static google.registry.testing.JUnitBackports.assertThrows; +import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN; +import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR; + +import com.google.api.client.http.HttpStatusCodes; +import com.google.appengine.api.users.User; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSetMultimap; +import com.google.gson.Gson; +import google.registry.model.registry.RegistryLockDao; +import google.registry.model.transaction.JpaTransactionManagerRule; +import google.registry.request.Action.Method; +import google.registry.request.auth.AuthLevel; +import google.registry.request.auth.AuthResult; +import google.registry.request.auth.AuthenticatedRegistrarAccessor; +import google.registry.request.auth.UserAuthInfo; +import google.registry.schema.domain.RegistryLock; +import google.registry.schema.domain.RegistryLock.Action; +import google.registry.testing.AppEngineRule; +import google.registry.testing.FakeResponse; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.joda.time.DateTime; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +/** Unit tests for {@link RegistryLockGetAction}. */ +@RunWith(JUnit4.class) +public final class RegistryLockGetActionTest { + + private static final Gson GSON = new Gson(); + + @Rule public final AppEngineRule appEngineRule = AppEngineRule.builder().withDatastore().build(); + + @Rule + public final JpaTransactionManagerRule jpaTmRule = + new JpaTransactionManagerRule.Builder().build(); + + @Rule public final MockitoRule mocks = MockitoJUnit.rule(); + + private final FakeResponse response = new FakeResponse(); + private final User user = new User("Marla.Singer@crr.com", "gmail.com", "12345"); + + private AuthResult authResult; + private AuthenticatedRegistrarAccessor accessor; + private RegistryLockGetAction action; + + @Before + public void setup() { + jpaTmRule.getTxnClock().setTo(DateTime.parse("2000-06-08T22:00:00.0Z")); + authResult = AuthResult.create(AuthLevel.USER, UserAuthInfo.create(user, false)); + accessor = + AuthenticatedRegistrarAccessor.createForTesting( + ImmutableSetMultimap.of( + "TheRegistrar", OWNER, + "NewRegistrar", OWNER)); + action = + new RegistryLockGetAction( + Method.GET, response, accessor, authResult, Optional.of("TheRegistrar")); + } + + @Test + public void testSuccess_retrievesLocks() { + RegistryLock regularLock = + new RegistryLock.Builder() + .setRepoId("repoId") + .setDomainName("example.test") + .setRegistrarId("TheRegistrar") + .setAction(Action.LOCK) + .setVerificationCode(UUID.randomUUID().toString()) + .setRegistrarPocId("johndoe@theregistrar.com") + .setCompletionTimestamp(jpaTmRule.getTxnClock().nowUtc()) + .build(); + jpaTmRule.getTxnClock().advanceOneMilli(); + RegistryLock adminLock = + new RegistryLock.Builder() + .setRepoId("repoId") + .setDomainName("adminexample.test") + .setRegistrarId("TheRegistrar") + .setAction(Action.LOCK) + .setVerificationCode(UUID.randomUUID().toString()) + .isSuperuser(true) + .setCompletionTimestamp(jpaTmRule.getTxnClock().nowUtc()) + .build(); + RegistryLock incompleteLock = + new RegistryLock.Builder() + .setRepoId("repoId") + .setDomainName("incomplete.test") + .setRegistrarId("TheRegistrar") + .setAction(Action.LOCK) + .setVerificationCode(UUID.randomUUID().toString()) + .setRegistrarPocId("johndoe@theregistrar.com") + .build(); + + RegistryLockDao.save(regularLock); + RegistryLockDao.save(adminLock); + RegistryLockDao.save(incompleteLock); + + action.run(); + assertThat(response.getStatus()).isEqualTo(HttpStatusCodes.STATUS_CODE_OK); + assertThat(GSON.fromJson(response.getPayload(), Map.class)) + .containsExactly( + "status", "SUCCESS", + "message", "Successful locks retrieval", + "results", + ImmutableList.of( + ImmutableMap.of( + "lockEnabledForContact", true, + "email", "Marla.Singer@crr.com", + "clientId", "TheRegistrar", + "locks", + ImmutableList.of( + ImmutableMap.of( + "fullyQualifiedDomainName", "example.test", + "lockedTime", "2000-06-08T22:00:00.000Z", + "lockedBy", "johndoe@theregistrar.com"), + ImmutableMap.of( + "fullyQualifiedDomainName", "adminexample.test", + "lockedTime", "2000-06-08T22:00:00.001Z", + "lockedBy", "admin"))))); + } + + @Test + public void testFailure_invalidMethod() { + action.method = Method.POST; + assertThat(assertThrows(IllegalArgumentException.class, action::run)) + .hasMessageThat() + .isEqualTo("Only GET requests allowed"); + } + + @Test + public void testFailure_noAuthInfo() { + action.authResult = AuthResult.NOT_AUTHENTICATED; + assertThat(assertThrows(IllegalArgumentException.class, action::run)) + .hasMessageThat() + .isEqualTo("User auth info must be present"); + } + + @Test + public void testFailure_noClientId() { + action.paramClientId = Optional.empty(); + assertThat(assertThrows(IllegalArgumentException.class, action::run)) + .hasMessageThat() + .isEqualTo("clientId must be present"); + } + + @Test + public void testFailure_noRegistrarAccess() { + accessor = AuthenticatedRegistrarAccessor.createForTesting(ImmutableSetMultimap.of()); + action = + new RegistryLockGetAction( + Method.GET, response, accessor, authResult, Optional.of("TheRegistrar")); + action.run(); + assertThat(response.getStatus()).isEqualTo(SC_FORBIDDEN); + } + + @Test + public void testSuccess_readOnlyAccessForOtherUsers() { + // If lock is not enabled for a user, this should be read-only + persistResource( + makeRegistrarContact3().asBuilder().setAllowedToSetRegistryLockPassword(true).build()); + action.run(); + assertThat(GSON.fromJson(response.getPayload(), Map.class).get("results")) + .isEqualTo( + ImmutableList.of( + ImmutableMap.of( + "lockEnabledForContact", + false, + "email", + "Marla.Singer@crr.com", + "clientId", + "TheRegistrar", + "locks", + ImmutableList.of()))); + } + + @Test + public void testSuccess_lockAllowedForAdmin() throws Exception { + // Locks are allowed for admins even when they're not enabled for the registrar + persistResource(makeRegistrar2().asBuilder().setRegistryLockAllowed(false).build()); + authResult = AuthResult.create(AuthLevel.USER, UserAuthInfo.create(user, true)); + action = + new RegistryLockGetAction( + Method.GET, response, accessor, authResult, Optional.of("TheRegistrar")); + action.run(); + assertThat(GSON.fromJson(response.getPayload(), Map.class).get("results")) + .isEqualTo( + ImmutableList.of( + ImmutableMap.of( + "lockEnabledForContact", + true, + "email", + "Marla.Singer@crr.com", + "clientId", + "TheRegistrar", + "locks", + ImmutableList.of()))); + } + + @Test + public void testFailure_lockNotAllowedForRegistrar() { + // The UI shouldn't be making requests where lock isn't enabled for this registrar + action = + new RegistryLockGetAction( + Method.GET, response, accessor, authResult, Optional.of("NewRegistrar")); + action.run(); + assertThat(response.getStatus()).isEqualTo(SC_INTERNAL_SERVER_ERROR); + } + + @Test + public void testFailure_accessDenied() { + accessor = AuthenticatedRegistrarAccessor.createForTesting(ImmutableSetMultimap.of()); + action = + new RegistryLockGetAction( + Method.GET, response, accessor, authResult, Optional.of("TheRegistrar")); + action.run(); + assertThat(response.getStatus()).isEqualTo(SC_FORBIDDEN); + } + + @Test + public void testFailure_badRegistrar() { + action = + new RegistryLockGetAction( + Method.GET, response, accessor, authResult, Optional.of("SomeBadRegistrar")); + action.run(); + assertThat(response.getStatus()).isEqualTo(SC_FORBIDDEN); + } +} 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 8c9fa277a..3f94daeeb 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,3 +5,4 @@ PATH CLASS METHODS OK AUTH_METHODS /registrar-ote-setup ConsoleOteSetupAction POST,GET n INTERNAL,API,LEGACY NONE PUBLIC /registrar-ote-status OteStatusAction POST n API,LEGACY USER PUBLIC /registrar-settings RegistrarSettingsAction POST n API,LEGACY USER PUBLIC +/registry-lock-get RegistryLockGetAction GET n API,LEGACY USER PUBLIC