diff --git a/core/src/main/java/google/registry/request/Response.java b/core/src/main/java/google/registry/request/Response.java index b0fcc147a93..27c62706dd9 100644 --- a/core/src/main/java/google/registry/request/Response.java +++ b/core/src/main/java/google/registry/request/Response.java @@ -15,6 +15,7 @@ package google.registry.request; import com.google.common.net.MediaType; +import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletResponse; import org.joda.time.DateTime; @@ -51,4 +52,11 @@ public interface Response { * @see HttpServletResponse#setDateHeader(String, long) */ void setDateHeader(String header, DateTime timestamp); + + /** + * Adds a cookie to the response + * + * @see HttpServletResponse#addCookie(Cookie) + */ + void addCookie(Cookie cookie); } diff --git a/core/src/main/java/google/registry/request/ResponseImpl.java b/core/src/main/java/google/registry/request/ResponseImpl.java index 0e14218a59c..4bb317e6aef 100644 --- a/core/src/main/java/google/registry/request/ResponseImpl.java +++ b/core/src/main/java/google/registry/request/ResponseImpl.java @@ -17,6 +17,7 @@ import com.google.common.net.MediaType; import java.io.IOException; import javax.inject.Inject; +import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletResponse; import org.joda.time.DateTime; @@ -58,4 +59,9 @@ public void setHeader(String header, String value) { public void setDateHeader(String header, DateTime timestamp) { rsp.setDateHeader(header, timestamp.getMillis()); } + + @Override + public void addCookie(Cookie cookie) { + rsp.addCookie(cookie); + } } diff --git a/core/src/main/java/google/registry/security/XsrfTokenManager.java b/core/src/main/java/google/registry/security/XsrfTokenManager.java index 21318fcabed..40ddab314b0 100644 --- a/core/src/main/java/google/registry/security/XsrfTokenManager.java +++ b/core/src/main/java/google/registry/security/XsrfTokenManager.java @@ -34,7 +34,7 @@ /** Helper class for generating and validate XSRF tokens. */ public final class XsrfTokenManager { - /** HTTP header used for transmitting XSRF tokens. */ + /** HTTP header or cookie name used for transmitting XSRF tokens. */ public static final String X_CSRF_TOKEN = "X-CSRF-Token"; /** POST parameter used for transmitting XSRF tokens. */ diff --git a/core/src/main/java/google/registry/ui/server/console/ConsoleApiAction.java b/core/src/main/java/google/registry/ui/server/console/ConsoleApiAction.java new file mode 100644 index 00000000000..d3071d0d162 --- /dev/null +++ b/core/src/main/java/google/registry/ui/server/console/ConsoleApiAction.java @@ -0,0 +1,72 @@ +// 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; + +import static google.registry.request.Action.Method.GET; + +import com.google.api.client.http.HttpStatusCodes; +import google.registry.model.console.User; +import google.registry.security.XsrfTokenManager; +import google.registry.ui.server.registrar.ConsoleApiParams; +import java.util.Arrays; +import java.util.Optional; +import javax.servlet.http.Cookie; + +/** Base class for handling Console API requests */ +public abstract class ConsoleApiAction implements Runnable { + protected ConsoleApiParams consoleApiParams; + + public ConsoleApiAction(ConsoleApiParams consoleApiParams) { + this.consoleApiParams = consoleApiParams; + } + + @Override + public final void run() { + // Shouldn't be even possible because of Auth annotations on the various implementing classes + if (!consoleApiParams.authResult().userAuthInfo().get().consoleUser().isPresent()) { + consoleApiParams.response().setStatus(HttpStatusCodes.STATUS_CODE_UNAUTHORIZED); + return; + } + User user = consoleApiParams.authResult().userAuthInfo().get().consoleUser().get(); + if (consoleApiParams.request().getMethod().equals(GET.toString())) { + getHandler(user); + } else { + if (verifyXSRF()) { + postHandler(user); + } + } + } + + protected void postHandler(User user) { + throw new UnsupportedOperationException("Console API POST handler not implemented"); + } + + protected void getHandler(User user) { + throw new UnsupportedOperationException("Console API GET handler not implemented"); + } + + private boolean verifyXSRF() { + Optional maybeCookie = + Arrays.stream(consoleApiParams.request().getCookies()) + .filter(c -> XsrfTokenManager.X_CSRF_TOKEN.equals(c.getName())) + .findFirst(); + if (!maybeCookie.isPresent() + || !consoleApiParams.xsrfTokenManager().validateToken(maybeCookie.get().getValue())) { + consoleApiParams.response().setStatus(HttpStatusCodes.STATUS_CODE_UNAUTHORIZED); + return false; + } + return true; + } +} 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 da76c870bc1..15d86971a4a 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 @@ -21,12 +21,10 @@ import google.registry.config.RegistryConfig.Config; import google.registry.model.console.User; import google.registry.request.Action; -import google.registry.request.Response; import google.registry.request.auth.Auth; -import google.registry.request.auth.AuthResult; -import google.registry.request.auth.UserAuthInfo; -import google.registry.ui.server.registrar.JsonGetAction; +import google.registry.ui.server.registrar.ConsoleApiParams; import javax.inject.Inject; +import javax.servlet.http.Cookie; import org.json.JSONObject; @Action( @@ -34,12 +32,10 @@ path = ConsoleUserDataAction.PATH, method = {GET}, auth = Auth.AUTH_PUBLIC_LOGGED_IN) -public class ConsoleUserDataAction implements JsonGetAction { +public class ConsoleUserDataAction extends ConsoleApiAction { public static final String PATH = "/console-api/userdata"; - private final AuthResult authResult; - private final Response response; private final String productName; private final String supportPhoneNumber; private final String supportEmail; @@ -47,14 +43,12 @@ public class ConsoleUserDataAction implements JsonGetAction { @Inject public ConsoleUserDataAction( - AuthResult authResult, - Response response, + ConsoleApiParams consoleApiParams, @Config("productName") String productName, @Config("supportEmail") String supportEmail, @Config("supportPhoneNumber") String supportPhoneNumber, @Config("technicalDocsUrl") String technicalDocsUrl) { - this.response = response; - this.authResult = authResult; + super(consoleApiParams); this.productName = productName; this.supportEmail = supportEmail; this.supportPhoneNumber = supportPhoneNumber; @@ -62,13 +56,15 @@ public ConsoleUserDataAction( } @Override - public void run() { - UserAuthInfo authInfo = authResult.userAuthInfo().get(); - if (!authInfo.consoleUser().isPresent()) { - response.setStatus(HttpStatusCodes.STATUS_CODE_UNAUTHORIZED); - return; - } - User user = authInfo.consoleUser().get(); + protected void getHandler(User user) { + // As this is a first GET request we use it as an opportunity to set a XSRF cookie + // for angular to read - https://angular.io/guide/http-security-xsrf-protection + Cookie xsrfCookie = + new Cookie( + consoleApiParams.xsrfTokenManager().X_CSRF_TOKEN, + consoleApiParams.xsrfTokenManager().generateToken(user.getEmailAddress())); + xsrfCookie.setSecure(true); + consoleApiParams.response().addCookie(xsrfCookie); JSONObject json = new JSONObject( @@ -90,7 +86,7 @@ public void run() { // Is used by UI to construct a link to registry resources "technicalDocsUrl", technicalDocsUrl)); - response.setPayload(json.toString()); - response.setStatus(HttpStatusCodes.STATUS_CODE_OK); + consoleApiParams.response().setPayload(json.toString()); + consoleApiParams.response().setStatus(HttpStatusCodes.STATUS_CODE_OK); } } diff --git a/core/src/main/java/google/registry/ui/server/registrar/ConsoleApiParams.java b/core/src/main/java/google/registry/ui/server/registrar/ConsoleApiParams.java new file mode 100644 index 00000000000..93f0a990102 --- /dev/null +++ b/core/src/main/java/google/registry/ui/server/registrar/ConsoleApiParams.java @@ -0,0 +1,41 @@ +// 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.registrar; + +import com.google.auto.value.AutoValue; +import google.registry.request.Response; +import google.registry.request.auth.AuthResult; +import google.registry.security.XsrfTokenManager; +import javax.servlet.http.HttpServletRequest; + +/** Groups necessary dependencies for Console API actions * */ +@AutoValue +public abstract class ConsoleApiParams { + public static ConsoleApiParams create( + HttpServletRequest request, + Response response, + AuthResult authResult, + XsrfTokenManager xsrfTokenManager) { + return new AutoValue_ConsoleApiParams(request, response, authResult, xsrfTokenManager); + } + + public abstract HttpServletRequest request(); + + public abstract Response response(); + + public abstract AuthResult authResult(); + + public abstract XsrfTokenManager xsrfTokenManager(); +} 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 4009018612b..05852f1d203 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 @@ -28,6 +28,10 @@ import google.registry.model.registrar.RegistrarPoc; import google.registry.request.OptionalJsonPayload; import google.registry.request.Parameter; +import google.registry.request.RequestScope; +import google.registry.request.Response; +import google.registry.request.auth.AuthResult; +import google.registry.security.XsrfTokenManager; import java.util.Optional; import javax.servlet.http.HttpServletRequest; import org.joda.time.DateTime; @@ -35,9 +39,18 @@ /** Dagger module for the Registrar Console parameters. */ @Module public final class RegistrarConsoleModule { - static final String PARAM_CLIENT_ID = "clientId"; + @Provides + @RequestScope + ConsoleApiParams provideConsoleApiParams( + HttpServletRequest request, + Response response, + AuthResult authResult, + XsrfTokenManager xsrfTokenManager) { + return ConsoleApiParams.create(request, response, authResult, xsrfTokenManager); + } + @Provides @Parameter(PARAM_CLIENT_ID) static Optional provideOptionalClientId(HttpServletRequest req) { diff --git a/core/src/test/java/google/registry/testing/FakeConsoleApiParams.java b/core/src/test/java/google/registry/testing/FakeConsoleApiParams.java new file mode 100644 index 00000000000..18290ca4ba3 --- /dev/null +++ b/core/src/test/java/google/registry/testing/FakeConsoleApiParams.java @@ -0,0 +1,46 @@ +// 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.testing; + +import static org.mockito.Mockito.mock; + +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 java.util.Optional; +import javax.servlet.http.HttpServletRequest; +import org.joda.time.DateTime; + +public final class FakeConsoleApiParams { + + public static ConsoleApiParams get(Optional maybeAuthResult) { + AuthResult authResult = + maybeAuthResult.orElseGet( + () -> + AuthResult.createUser( + UserAuthInfo.create( + new com.google.appengine.api.users.User( + "JohnDoe@theregistrar.com", "theregistrar.com"), + false))); + return ConsoleApiParams.create( + mock(HttpServletRequest.class), + new FakeResponse(), + authResult, + new XsrfTokenManager( + new FakeClock(DateTime.parse("2020-02-02T01:23:45Z")), mock(UserService.class))); + } +} diff --git a/core/src/test/java/google/registry/testing/FakeResponse.java b/core/src/test/java/google/registry/testing/FakeResponse.java index 1dff1364793..3f72c2f468f 100644 --- a/core/src/test/java/google/registry/testing/FakeResponse.java +++ b/core/src/test/java/google/registry/testing/FakeResponse.java @@ -22,8 +22,10 @@ import com.google.common.base.Throwables; import com.google.common.net.MediaType; import google.registry.request.Response; +import java.util.ArrayList; import java.util.HashMap; import java.util.Map; +import javax.servlet.http.Cookie; import org.joda.time.DateTime; /** Fake implementation of {@link Response} for testing. */ @@ -36,6 +38,8 @@ public final class FakeResponse implements Response { private boolean wasMutuallyExclusiveResponseSet; private String lastResponseStackTrace; + private ArrayList cookies = new ArrayList<>(); + public int getStatus() { return status; } @@ -83,6 +87,15 @@ public void setDateHeader(String header, DateTime timestamp) { headers.put(checkNotNull(header), checkNotNull(timestamp)); } + @Override + public void addCookie(Cookie cookie) { + cookies.add(cookie); + } + + public ArrayList getCookies() { + return cookies; + } + private void checkResponsePerformedOnce() { checkState( !wasMutuallyExclusiveResponseSet, diff --git a/core/src/test/java/google/registry/ui/server/ActionMembershipTest.java b/core/src/test/java/google/registry/ui/server/ActionMembershipTest.java index 0f71b0b5d05..a4f6af0e21e 100644 --- a/core/src/test/java/google/registry/ui/server/ActionMembershipTest.java +++ b/core/src/test/java/google/registry/ui/server/ActionMembershipTest.java @@ -19,6 +19,7 @@ import com.google.common.collect.ImmutableSet; import google.registry.request.Action; import google.registry.request.JsonActionRunner; +import google.registry.ui.server.console.ConsoleApiAction; import google.registry.ui.server.registrar.HtmlAction; import google.registry.ui.server.registrar.JsonGetAction; import io.github.classgraph.ClassGraph; @@ -34,6 +35,7 @@ void testAllActionsEitherHtmlOrJson() { // 1. Extending HtmlAction to signal that we are serving an HTML page // 2. Extending JsonAction to show that we are serving JSON POST requests // 3. Extending JsonGetAction to serve JSON GET requests + // 4. Extending ConsoleApiAction to serve JSON requests ImmutableSet.Builder failingClasses = new ImmutableSet.Builder<>(); try (ScanResult scanResult = new ClassGraph().enableAnnotationInfo().whitelistPackages("google.registry.ui").scan()) { @@ -41,7 +43,8 @@ void testAllActionsEitherHtmlOrJson() { .getClassesWithAnnotation(Action.class.getName()) .forEach( classInfo -> { - if (!classInfo.extendsSuperclass(HtmlAction.class.getName()) + if (!classInfo.extendsSuperclass(ConsoleApiAction.class.getName()) + && !classInfo.extendsSuperclass(HtmlAction.class.getName()) && !classInfo.implementsInterface(JsonActionRunner.JsonAction.class.getName()) && !classInfo.implementsInterface(JsonGetAction.class.getName())) { failingClasses.add(classInfo.getName()); 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 f7bbdda8224..885f5cae0a4 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 @@ -14,7 +14,9 @@ package google.registry.ui.server.console; +import static com.google.common.collect.ImmutableList.toImmutableList; import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.when; import com.google.api.client.http.HttpStatusCodes; import com.google.gson.Gson; @@ -22,12 +24,18 @@ 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; +import google.registry.testing.FakeConsoleApiParams; import google.registry.testing.FakeResponse; +import google.registry.ui.server.registrar.ConsoleApiParams; import java.io.IOException; +import java.util.ArrayList; import java.util.Map; +import java.util.Optional; +import javax.servlet.http.Cookie; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -35,12 +43,31 @@ class ConsoleUserDataActionTest { private static final Gson GSON = RequestModule.provideGson(); - private FakeResponse response = new FakeResponse(); + + private ConsoleApiParams consoleApiParams; @RegisterExtension final JpaTestExtensions.JpaIntegrationTestExtension jpa = new JpaTestExtensions.Builder().buildIntegrationTestExtension(); + @Test + void testSuccess_hasXSRFCookie() throws IOException { + User user = + new User.Builder() + .setEmailAddress("email@email.com") + .setUserRoles(new UserRoles.Builder().setGlobalRole(GlobalRole.FTE).build()) + .build(); + + AuthResult authResult = AuthResult.createUser(UserAuthInfo.create(user)); + ConsoleUserDataAction action = + createAction( + Optional.of(FakeConsoleApiParams.get(Optional.of(authResult))), Action.Method.GET); + action.run(); + ArrayList cookies = ((FakeResponse) consoleApiParams.response()).getCookies(); + assertThat(cookies.stream().map(cookie -> cookie.getName()).collect(toImmutableList())) + .containsExactly("X-CSRF-Token"); + } + @Test void testSuccess_getContactInfo() throws IOException { User user = @@ -49,10 +76,15 @@ void testSuccess_getContactInfo() throws IOException { .setUserRoles(new UserRoles.Builder().setGlobalRole(GlobalRole.FTE).build()) .build(); - ConsoleUserDataAction action = createAction(AuthResult.createUser(UserAuthInfo.create(user))); + AuthResult authResult = AuthResult.createUser(UserAuthInfo.create(user)); + ConsoleUserDataAction action = + createAction( + Optional.of(FakeConsoleApiParams.get(Optional.of(authResult))), Action.Method.GET); action.run(); - assertThat(response.getStatus()).isEqualTo(HttpStatusCodes.STATUS_CODE_OK); - Map jsonObject = GSON.fromJson(response.getPayload(), Map.class); + assertThat(((FakeResponse) consoleApiParams.response()).getStatus()) + .isEqualTo(HttpStatusCodes.STATUS_CODE_OK); + Map jsonObject = + GSON.fromJson(((FakeResponse) consoleApiParams.response()).getPayload(), Map.class); assertThat(jsonObject) .containsExactly( "isAdmin", @@ -71,19 +103,18 @@ void testSuccess_getContactInfo() throws IOException { @Test void testFailure_notAConsoleUser() throws IOException { - ConsoleUserDataAction action = - createAction( - AuthResult.createUser( - UserAuthInfo.create( - new com.google.appengine.api.users.User( - "JohnDoe@theregistrar.com", "theregistrar.com"), - false))); + ConsoleUserDataAction action = createAction(Optional.empty(), Action.Method.GET); action.run(); - assertThat(response.getStatus()).isEqualTo(HttpStatusCodes.STATUS_CODE_UNAUTHORIZED); + assertThat(((FakeResponse) consoleApiParams.response()).getStatus()) + .isEqualTo(HttpStatusCodes.STATUS_CODE_UNAUTHORIZED); } - private ConsoleUserDataAction createAction(AuthResult authResult) throws IOException { + private ConsoleUserDataAction createAction( + Optional maybeConsoleApiParams, Action.Method method) throws IOException { + consoleApiParams = + maybeConsoleApiParams.orElseGet(() -> FakeConsoleApiParams.get(Optional.empty())); + when(consoleApiParams.request().getMethod()).thenReturn(method.toString()); return new ConsoleUserDataAction( - authResult, response, "Nomulus", "support@example.com", "+1 (212) 867 5309", "test"); + consoleApiParams, "Nomulus", "support@example.com", "+1 (212) 867 5309", "test"); } }