diff --git a/core/src/main/java/google/registry/model/registrar/Registrar.java b/core/src/main/java/google/registry/model/registrar/Registrar.java index e7ec521c1..6e837ca97 100644 --- a/core/src/main/java/google/registry/model/registrar/Registrar.java +++ b/core/src/main/java/google/registry/model/registrar/Registrar.java @@ -50,6 +50,7 @@ import com.google.common.collect.Maps; import com.google.common.collect.Range; import com.google.common.collect.Sets; import com.google.common.collect.Streams; +import com.google.gson.annotations.Expose; import com.google.re2j.Pattern; import google.registry.model.Buildable; import google.registry.model.CreateAutoTimestamp; @@ -253,7 +254,7 @@ public class Registrar extends UpdateAutoTimestampEntity implements Buildable, J // Authentication. /** X.509 PEM client certificate(s) used to authenticate registrar to EPP service. */ - String clientCertificate; + @Expose String clientCertificate; /** Base64 encoded SHA256 hash of {@link #clientCertificate}. */ String clientCertificateHash; @@ -263,13 +264,13 @@ public class Registrar extends UpdateAutoTimestampEntity implements Buildable, J * *

This allows registrars to migrate certificates without downtime. */ - String failoverClientCertificate; + @Expose String failoverClientCertificate; /** Base64 encoded SHA256 hash of {@link #failoverClientCertificate}. */ String failoverClientCertificateHash; /** An allow list of netmasks (in CIDR notation) which the client is allowed to connect from. */ - List ipAddressAllowList; + @Expose List ipAddressAllowList; /** A hashed password for EPP access. The hash is a base64 encoded SHA256 string. */ String passwordHash; 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 e2d7ac0b5..53841e6ac 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.request.RequestScope; import google.registry.ui.server.console.ConsoleDomainGetAction; import google.registry.ui.server.console.RegistrarsAction; import google.registry.ui.server.console.settings.ContactAction; +import google.registry.ui.server.console.settings.SecurityAction; import google.registry.ui.server.registrar.ConsoleOteSetupAction; import google.registry.ui.server.registrar.ConsoleRegistrarCreatorAction; import google.registry.ui.server.registrar.ConsoleUiAction; @@ -70,6 +71,8 @@ interface FrontendRequestComponent { RegistrarsAction registrarsAction(); + SecurityAction securityAction(); + @Subcomponent.Builder abstract class Builder implements RequestComponentBuilder { @Override public abstract Builder requestModule(RequestModule requestModule); diff --git a/core/src/main/java/google/registry/ui/server/console/settings/SecurityAction.java b/core/src/main/java/google/registry/ui/server/console/settings/SecurityAction.java new file mode 100644 index 000000000..0e69da19b --- /dev/null +++ b/core/src/main/java/google/registry/ui/server/console/settings/SecurityAction.java @@ -0,0 +1,171 @@ +// Copyright 2023 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.settings; + +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 avro.shaded.com.google.common.collect.ImmutableList; +import com.google.api.client.http.HttpStatusCodes; +import com.google.gson.Gson; +import google.registry.flows.certs.CertificateChecker; +import google.registry.flows.certs.CertificateChecker.InsecureCertificateException; +import google.registry.model.console.ConsolePermission; +import google.registry.model.console.User; +import google.registry.model.registrar.Registrar; +import google.registry.request.Action; +import google.registry.request.Parameter; +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.ui.server.registrar.JsonGetAction; +import java.util.Optional; +import javax.inject.Inject; +import javax.servlet.http.HttpServletRequest; + +@Action( + service = Action.Service.DEFAULT, + path = SecurityAction.PATH, + method = {GET, POST}, + auth = Auth.AUTH_PUBLIC_LOGGED_IN) +public class SecurityAction implements JsonGetAction { + + static final String PATH = "/console-api/settings/security"; + private final HttpServletRequest req; + private final AuthResult authResult; + private final Response response; + private final Gson gson; + private final String registrarId; + private AuthenticatedRegistrarAccessor registrarAccessor; + private Optional registrar; + private CertificateChecker certificateChecker; + + @Inject + public SecurityAction( + HttpServletRequest req, + AuthResult authResult, + Response response, + Gson gson, + CertificateChecker certificateChecker, + AuthenticatedRegistrarAccessor registrarAccessor, + @Parameter("registrarId") String registrarId, + @Parameter("registrar") Optional registrar) { + this.req = req; + this.authResult = authResult; + this.response = response; + this.gson = gson; + this.registrarId = registrarId; + this.registrarAccessor = registrarAccessor; + this.registrar = registrar; + this.certificateChecker = certificateChecker; + } + + @Override + public void run() { + if (req.getMethod().equals(GET.toString())) { + getHandler(); + } else { + postHandler(); + } + } + + private void getHandler() { + try { + Registrar registrar = registrarAccessor.getRegistrar(registrarId); + response.setStatus(HttpStatusCodes.STATUS_CODE_OK); + response.setPayload(gson.toJson(registrar)); + } catch (RegistrarAccessDeniedException e) { + response.setStatus(HttpStatusCodes.STATUS_CODE_FORBIDDEN); + response.setPayload(e.getMessage()); + } + } + + private void postHandler() { + User user = authResult.userAuthInfo().get().consoleUser().get(); + if (!user.getUserRoles().hasPermission(registrarId, ConsolePermission.EDIT_REGISTRAR_DETAILS)) { + response.setStatus(HttpStatusCodes.STATUS_CODE_FORBIDDEN); + return; + } + + if (!registrar.isPresent()) { + response.setStatus(HttpStatusCodes.STATUS_CODE_BAD_REQUEST); + response.setPayload(gson.toJson("'registrar' parameter is not present")); + return; + } + + Registrar savedRegistrar; + try { + savedRegistrar = registrarAccessor.getRegistrar(registrarId); + } catch (RegistrarAccessDeniedException e) { + response.setStatus(HttpStatusCodes.STATUS_CODE_FORBIDDEN); + response.setPayload(e.getMessage()); + return; + } + + tm().transact(() -> setResponse(savedRegistrar)); + } + + private void setResponse(Registrar savedRegistrar) { + Registrar registrarParameter = registrar.get(); + Registrar.Builder updatedRegistrar = + savedRegistrar + .asBuilder() + .setIpAddressAllowList(registrarParameter.getIpAddressAllowList()); + + boolean hasInvalidCerts = + ImmutableList.of( + registrarParameter.getClientCertificate(), + registrarParameter.getFailoverClientCertificate()) + .stream() + .filter(Optional::isPresent) + .map(Optional::get) + .anyMatch( + cert -> { + try { + certificateChecker.validateCertificate(cert); + return false; + } catch (InsecureCertificateException e) { + return true; + } + }); + + if (hasInvalidCerts) { + response.setStatus(HttpStatusCodes.STATUS_CODE_BAD_REQUEST); + response.setPayload("Insecure Certificate in parameter"); + return; + } + + registrarParameter + .getClientCertificate() + .ifPresent( + newClientCert -> { + updatedRegistrar.setClientCertificate(newClientCert, tm().getTransactionTime()); + }); + + registrarParameter + .getFailoverClientCertificate() + .ifPresent( + failoverCert -> { + updatedRegistrar.setFailoverClientCertificate( + failoverCert, tm().getTransactionTime()); + }); + + tm().put(updatedRegistrar.build()); + response.setStatus(HttpStatusCodes.STATUS_CODE_OK); + } +} diff --git a/core/src/main/java/google/registry/ui/server/registrar/RegistrarConsoleModule.java b/core/src/main/java/google/registry/ui/server/registrar/RegistrarConsoleModule.java index 9061cb9ab..429421bc4 100644 --- a/core/src/main/java/google/registry/ui/server/registrar/RegistrarConsoleModule.java +++ b/core/src/main/java/google/registry/ui/server/registrar/RegistrarConsoleModule.java @@ -24,6 +24,7 @@ import com.google.gson.Gson; import com.google.gson.JsonObject; import dagger.Module; import dagger.Provides; +import google.registry.model.registrar.Registrar; import google.registry.model.registrar.RegistrarPoc; import google.registry.request.OptionalJsonPayload; import google.registry.request.Parameter; @@ -187,4 +188,15 @@ public final class RegistrarConsoleModule { static String provideRegistrarId(HttpServletRequest req) { return extractRequiredParameter(req, "registrarId"); } + + @Provides + @Parameter("registrar") + public static Optional provideRegistrar( + Gson gson, @OptionalJsonPayload Optional payload) { + if (payload.isPresent() && payload.get().has("registrar")) { + return Optional.of(gson.fromJson(payload.get().get("registrar"), Registrar.class)); + } + + return Optional.empty(); + } } diff --git a/core/src/test/java/google/registry/ui/server/console/settings/SecurityActionTest.java b/core/src/test/java/google/registry/ui/server/console/settings/SecurityActionTest.java new file mode 100644 index 000000000..b422a191e --- /dev/null +++ b/core/src/test/java/google/registry/ui/server/console/settings/SecurityActionTest.java @@ -0,0 +1,183 @@ +// Copyright 2023 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.settings; + +import static com.google.common.truth.Truth.assertThat; +import static google.registry.testing.CertificateSamples.SAMPLE_CERT; +import static google.registry.testing.CertificateSamples.SAMPLE_CERT2; +import static google.registry.testing.DatabaseHelper.loadRegistrar; +import static google.registry.testing.DatabaseHelper.persistResource; +import static google.registry.testing.SqlHelper.saveRegistrar; +import static google.registry.util.DateTimeUtils.START_OF_TIME; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.google.api.client.http.HttpStatusCodes; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.ImmutableSetMultimap; +import com.google.common.collect.ImmutableSortedMap; +import com.google.common.net.InetAddresses; +import com.google.gson.Gson; +import google.registry.flows.certs.CertificateChecker; +import google.registry.model.console.GlobalRole; +import google.registry.model.console.User; +import google.registry.model.console.UserRoles; +import google.registry.model.registrar.Registrar; +import google.registry.persistence.transaction.JpaTestExtensions; +import google.registry.request.Action; +import google.registry.request.RequestModule; +import google.registry.request.auth.AuthResult; +import google.registry.request.auth.AuthSettings.AuthLevel; +import google.registry.request.auth.AuthenticatedRegistrarAccessor; +import google.registry.request.auth.UserAuthInfo; +import google.registry.testing.FakeClock; +import google.registry.testing.FakeResponse; +import google.registry.ui.server.registrar.RegistrarConsoleModule; +import google.registry.util.CidrAddressBlock; +import google.registry.util.UtilsModule; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.StringReader; +import java.util.Optional; +import javax.servlet.http.HttpServletRequest; +import org.joda.time.DateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** Tests for {@link google.registry.ui.server.console.settings.SecurityAction}. */ +class SecurityActionTest { + + private static String jsonRegistrar1 = + String.format( + "{\"registrarId\": \"registrarId\", \"clientCertificate\": \"%s\"," + + " \"ipAddressAllowList\": [\"192.168.1.1/32\"]}", + SAMPLE_CERT2); + private static final Gson GSON = UtilsModule.provideGson(); + private final HttpServletRequest request = mock(HttpServletRequest.class); + private final FakeClock clock = new FakeClock(); + private Registrar testRegistrar; + private FakeResponse response = new FakeResponse(); + + private AuthenticatedRegistrarAccessor registrarAccessor = + AuthenticatedRegistrarAccessor.createForTesting( + ImmutableSetMultimap.of("registrarId", AuthenticatedRegistrarAccessor.Role.ADMIN)); + + private CertificateChecker certificateChecker = + new CertificateChecker( + ImmutableSortedMap.of(START_OF_TIME, 20825, DateTime.parse("2020-09-01T00:00:00Z"), 398), + 30, + 15, + 2048, + ImmutableSet.of("secp256r1", "secp384r1"), + clock); + + @RegisterExtension + final JpaTestExtensions.JpaIntegrationTestExtension jpa = + new JpaTestExtensions.Builder().withClock(clock).buildIntegrationTestExtension(); + + @BeforeEach + void beforeEach() { + testRegistrar = saveRegistrar("registrarId"); + } + + @Test + void testSuccess_getRegistrarInfo() throws IOException { + persistResource( + testRegistrar + .asBuilder() + .setClientCertificate(SAMPLE_CERT, clock.nowUtc()) + .setIpAddressAllowList( + ImmutableSet.of( + CidrAddressBlock.create(InetAddresses.forString("192.168.1.1"), 32), + CidrAddressBlock.create(InetAddresses.forString("2001:db8::1"), 128))) + .build()); + SecurityAction action = + createAction( + Action.Method.GET, + AuthResult.create( + AuthLevel.USER, + UserAuthInfo.create( + createUser(new UserRoles.Builder().setGlobalRole(GlobalRole.FTE).build()))), + testRegistrar.getRegistrarId()); + action.run(); + assertThat(response.getStatus()).isEqualTo(HttpStatusCodes.STATUS_CODE_OK); + String payload = response.getPayload().replace("\\n", "").replace("\\u003d", "="); + assertThat(payload).contains(SAMPLE_CERT.replace("\n", "")); + assertThat(payload).contains("192.168.1.1/32"); + assertThat(payload).contains("2001:db8:0:0:0:0:0:1/128"); + } + + @Test + void testSuccess_postRegistrarInfo() throws IOException { + clock.setTo(DateTime.parse("2020-11-01T00:00:00Z")); + SecurityAction action = + createAction( + Action.Method.POST, + AuthResult.create( + AuthLevel.USER, + UserAuthInfo.create( + createUser(new UserRoles.Builder().setGlobalRole(GlobalRole.FTE).build()))), + testRegistrar.getRegistrarId()); + action.run(); + assertThat(response.getStatus()).isEqualTo(HttpStatusCodes.STATUS_CODE_OK); + Registrar r = loadRegistrar(testRegistrar.getRegistrarId()); + assertThat(r.getClientCertificateHash().get()) + .isEqualTo("GNd6ZP8/n91t9UTnpxR8aH7aAW4+CpvufYx9ViGbcMY"); + assertThat(r.getIpAddressAllowList().get(0).getIp()).isEqualTo("192.168.1.1"); + assertThat(r.getIpAddressAllowList().get(0).getNetmask()).isEqualTo(32); + } + + private User createUser(UserRoles userRoles) { + return new User.Builder() + .setEmailAddress("email@email.com") + .setGaiaId("TestUserId") + .setUserRoles(userRoles) + .build(); + } + + private SecurityAction createAction( + Action.Method method, AuthResult authResult, String registrarId) throws IOException { + when(request.getMethod()).thenReturn(method.toString()); + if (method.equals(Action.Method.GET)) { + return new SecurityAction( + request, + authResult, + response, + GSON, + certificateChecker, + registrarAccessor, + registrarId, + Optional.empty()); + } else { + doReturn(new BufferedReader(new StringReader("{\"registrar\":" + jsonRegistrar1 + "}"))) + .when(request) + .getReader(); + Optional maybeRegistrar = + RegistrarConsoleModule.provideRegistrar( + GSON, RequestModule.provideJsonBody(request, GSON)); + return new SecurityAction( + request, + authResult, + response, + GSON, + certificateChecker, + registrarAccessor, + registrarId, + maybeRegistrar); + } + } +} 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 863b2e757..731e191eb 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 @@ -3,6 +3,7 @@ PATH CLASS METHODS OK AUTH_ME /console-api/domain ConsoleDomainGetAction GET n API,LEGACY USER PUBLIC /console-api/registrars RegistrarsAction GET n API,LEGACY USER PUBLIC /console-api/settings/contacts ContactAction GET,POST n API,LEGACY USER PUBLIC +/console-api/settings/security SecurityAction GET,POST n API,LEGACY USER PUBLIC /registrar ConsoleUiAction GET n API,LEGACY NONE PUBLIC /registrar-create ConsoleRegistrarCreatorAction POST,GET n API,LEGACY NONE PUBLIC /registrar-ote-setup ConsoleOteSetupAction POST,GET n API,LEGACY NONE PUBLIC diff --git a/util/src/main/java/google/registry/util/CidrAddressBlock.java b/util/src/main/java/google/registry/util/CidrAddressBlock.java index de20650fd..2c569acf2 100644 --- a/util/src/main/java/google/registry/util/CidrAddressBlock.java +++ b/util/src/main/java/google/registry/util/CidrAddressBlock.java @@ -19,6 +19,10 @@ import static com.google.common.base.Preconditions.checkArgument; import com.google.common.collect.AbstractSequentialIterator; import com.google.common.flogger.FluentLogger; import com.google.common.net.InetAddresses; +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import java.io.IOException; import java.io.Serializable; import java.net.InetAddress; import java.net.UnknownHostException; @@ -476,4 +480,20 @@ public class CidrAddressBlock implements Iterable, Serializable { public String toString() { return getCidrString(ip, netmask); } + + public static class CidrAddressBlockAdapter extends TypeAdapter { + @Override + public CidrAddressBlock read(JsonReader reader) throws IOException { + String stringValue = reader.nextString(); + if (stringValue.equals("null")) { + return null; + } + return new CidrAddressBlock(stringValue); + } + + @Override + public void write(JsonWriter writer, CidrAddressBlock cidrAddressBlock) throws IOException { + writer.value(cidrAddressBlock.toString()); + } + } } diff --git a/util/src/main/java/google/registry/util/UtilsModule.java b/util/src/main/java/google/registry/util/UtilsModule.java index d3ab99bbb..4ce5e5679 100644 --- a/util/src/main/java/google/registry/util/UtilsModule.java +++ b/util/src/main/java/google/registry/util/UtilsModule.java @@ -19,6 +19,7 @@ import com.google.gson.GsonBuilder; import dagger.Binds; import dagger.Module; import dagger.Provides; +import google.registry.util.CidrAddressBlock.CidrAddressBlockAdapter; import java.security.NoSuchAlgorithmException; import java.security.ProviderException; import java.security.SecureRandom; @@ -79,6 +80,7 @@ public abstract class UtilsModule { public static Gson provideGson() { return new GsonBuilder() .registerTypeAdapter(DateTime.class, new DateTimeTypeAdapter()) + .registerTypeAdapter(CidrAddressBlock.class, new CidrAddressBlockAdapter()) .excludeFieldsWithoutExposeAnnotation() .create(); }