Add handler for Console API requests and XSRF token creation and verification (#2211)

This commit is contained in:
Pavlo Tkach 2023-11-09 17:51:53 -05:00 committed by GitHub
parent 779d0c9d37
commit 69ea87be31
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 266 additions and 37 deletions

View file

@ -15,6 +15,7 @@
package google.registry.request; package google.registry.request;
import com.google.common.net.MediaType; import com.google.common.net.MediaType;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
import org.joda.time.DateTime; import org.joda.time.DateTime;
@ -51,4 +52,11 @@ public interface Response {
* @see HttpServletResponse#setDateHeader(String, long) * @see HttpServletResponse#setDateHeader(String, long)
*/ */
void setDateHeader(String header, DateTime timestamp); void setDateHeader(String header, DateTime timestamp);
/**
* Adds a cookie to the response
*
* @see HttpServletResponse#addCookie(Cookie)
*/
void addCookie(Cookie cookie);
} }

View file

@ -17,6 +17,7 @@ package google.registry.request;
import com.google.common.net.MediaType; import com.google.common.net.MediaType;
import java.io.IOException; import java.io.IOException;
import javax.inject.Inject; import javax.inject.Inject;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
import org.joda.time.DateTime; import org.joda.time.DateTime;
@ -58,4 +59,9 @@ public final class ResponseImpl implements Response {
public void setDateHeader(String header, DateTime timestamp) { public void setDateHeader(String header, DateTime timestamp) {
rsp.setDateHeader(header, timestamp.getMillis()); rsp.setDateHeader(header, timestamp.getMillis());
} }
@Override
public void addCookie(Cookie cookie) {
rsp.addCookie(cookie);
}
} }

View file

@ -34,7 +34,7 @@ import org.joda.time.Duration;
/** Helper class for generating and validate XSRF tokens. */ /** Helper class for generating and validate XSRF tokens. */
public final class XsrfTokenManager { 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"; public static final String X_CSRF_TOKEN = "X-CSRF-Token";
/** POST parameter used for transmitting XSRF tokens. */ /** POST parameter used for transmitting XSRF tokens. */

View file

@ -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<Cookie> 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;
}
}

View file

@ -21,12 +21,10 @@ import com.google.common.collect.ImmutableMap;
import google.registry.config.RegistryConfig.Config; import google.registry.config.RegistryConfig.Config;
import google.registry.model.console.User; import google.registry.model.console.User;
import google.registry.request.Action; import google.registry.request.Action;
import google.registry.request.Response;
import google.registry.request.auth.Auth; import google.registry.request.auth.Auth;
import google.registry.request.auth.AuthResult; import google.registry.ui.server.registrar.ConsoleApiParams;
import google.registry.request.auth.UserAuthInfo;
import google.registry.ui.server.registrar.JsonGetAction;
import javax.inject.Inject; import javax.inject.Inject;
import javax.servlet.http.Cookie;
import org.json.JSONObject; import org.json.JSONObject;
@Action( @Action(
@ -34,12 +32,10 @@ import org.json.JSONObject;
path = ConsoleUserDataAction.PATH, path = ConsoleUserDataAction.PATH,
method = {GET}, method = {GET},
auth = Auth.AUTH_PUBLIC_LOGGED_IN) auth = Auth.AUTH_PUBLIC_LOGGED_IN)
public class ConsoleUserDataAction implements JsonGetAction { public class ConsoleUserDataAction extends ConsoleApiAction {
public static final String PATH = "/console-api/userdata"; public static final String PATH = "/console-api/userdata";
private final AuthResult authResult;
private final Response response;
private final String productName; private final String productName;
private final String supportPhoneNumber; private final String supportPhoneNumber;
private final String supportEmail; private final String supportEmail;
@ -47,14 +43,12 @@ public class ConsoleUserDataAction implements JsonGetAction {
@Inject @Inject
public ConsoleUserDataAction( public ConsoleUserDataAction(
AuthResult authResult, ConsoleApiParams consoleApiParams,
Response response,
@Config("productName") String productName, @Config("productName") String productName,
@Config("supportEmail") String supportEmail, @Config("supportEmail") String supportEmail,
@Config("supportPhoneNumber") String supportPhoneNumber, @Config("supportPhoneNumber") String supportPhoneNumber,
@Config("technicalDocsUrl") String technicalDocsUrl) { @Config("technicalDocsUrl") String technicalDocsUrl) {
this.response = response; super(consoleApiParams);
this.authResult = authResult;
this.productName = productName; this.productName = productName;
this.supportEmail = supportEmail; this.supportEmail = supportEmail;
this.supportPhoneNumber = supportPhoneNumber; this.supportPhoneNumber = supportPhoneNumber;
@ -62,13 +56,15 @@ public class ConsoleUserDataAction implements JsonGetAction {
} }
@Override @Override
public void run() { protected void getHandler(User user) {
UserAuthInfo authInfo = authResult.userAuthInfo().get(); // As this is a first GET request we use it as an opportunity to set a XSRF cookie
if (!authInfo.consoleUser().isPresent()) { // for angular to read - https://angular.io/guide/http-security-xsrf-protection
response.setStatus(HttpStatusCodes.STATUS_CODE_UNAUTHORIZED); Cookie xsrfCookie =
return; new Cookie(
} consoleApiParams.xsrfTokenManager().X_CSRF_TOKEN,
User user = authInfo.consoleUser().get(); consoleApiParams.xsrfTokenManager().generateToken(user.getEmailAddress()));
xsrfCookie.setSecure(true);
consoleApiParams.response().addCookie(xsrfCookie);
JSONObject json = JSONObject json =
new JSONObject( new JSONObject(
@ -90,7 +86,7 @@ public class ConsoleUserDataAction implements JsonGetAction {
// Is used by UI to construct a link to registry resources // Is used by UI to construct a link to registry resources
"technicalDocsUrl", technicalDocsUrl)); "technicalDocsUrl", technicalDocsUrl));
response.setPayload(json.toString()); consoleApiParams.response().setPayload(json.toString());
response.setStatus(HttpStatusCodes.STATUS_CODE_OK); consoleApiParams.response().setStatus(HttpStatusCodes.STATUS_CODE_OK);
} }
} }

View file

@ -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();
}

View file

@ -28,6 +28,10 @@ import google.registry.model.registrar.Registrar;
import google.registry.model.registrar.RegistrarPoc; import google.registry.model.registrar.RegistrarPoc;
import google.registry.request.OptionalJsonPayload; import google.registry.request.OptionalJsonPayload;
import google.registry.request.Parameter; 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 java.util.Optional;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import org.joda.time.DateTime; import org.joda.time.DateTime;
@ -35,9 +39,18 @@ import org.joda.time.DateTime;
/** Dagger module for the Registrar Console parameters. */ /** Dagger module for the Registrar Console parameters. */
@Module @Module
public final class RegistrarConsoleModule { public final class RegistrarConsoleModule {
static final String PARAM_CLIENT_ID = "clientId"; 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 @Provides
@Parameter(PARAM_CLIENT_ID) @Parameter(PARAM_CLIENT_ID)
static Optional<String> provideOptionalClientId(HttpServletRequest req) { static Optional<String> provideOptionalClientId(HttpServletRequest req) {

View file

@ -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<AuthResult> 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)));
}
}

View file

@ -22,8 +22,10 @@ import static java.util.Collections.unmodifiableMap;
import com.google.common.base.Throwables; import com.google.common.base.Throwables;
import com.google.common.net.MediaType; import com.google.common.net.MediaType;
import google.registry.request.Response; import google.registry.request.Response;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import javax.servlet.http.Cookie;
import org.joda.time.DateTime; import org.joda.time.DateTime;
/** Fake implementation of {@link Response} for testing. */ /** Fake implementation of {@link Response} for testing. */
@ -36,6 +38,8 @@ public final class FakeResponse implements Response {
private boolean wasMutuallyExclusiveResponseSet; private boolean wasMutuallyExclusiveResponseSet;
private String lastResponseStackTrace; private String lastResponseStackTrace;
private ArrayList<Cookie> cookies = new ArrayList<>();
public int getStatus() { public int getStatus() {
return status; return status;
} }
@ -83,6 +87,15 @@ public final class FakeResponse implements Response {
headers.put(checkNotNull(header), checkNotNull(timestamp)); headers.put(checkNotNull(header), checkNotNull(timestamp));
} }
@Override
public void addCookie(Cookie cookie) {
cookies.add(cookie);
}
public ArrayList<Cookie> getCookies() {
return cookies;
}
private void checkResponsePerformedOnce() { private void checkResponsePerformedOnce() {
checkState( checkState(
!wasMutuallyExclusiveResponseSet, !wasMutuallyExclusiveResponseSet,

View file

@ -19,6 +19,7 @@ import static com.google.common.truth.Truth.assertWithMessage;
import com.google.common.collect.ImmutableSet; import com.google.common.collect.ImmutableSet;
import google.registry.request.Action; import google.registry.request.Action;
import google.registry.request.JsonActionRunner; 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.HtmlAction;
import google.registry.ui.server.registrar.JsonGetAction; import google.registry.ui.server.registrar.JsonGetAction;
import io.github.classgraph.ClassGraph; import io.github.classgraph.ClassGraph;
@ -34,6 +35,7 @@ final class ActionMembershipTest {
// 1. Extending HtmlAction to signal that we are serving an HTML page // 1. Extending HtmlAction to signal that we are serving an HTML page
// 2. Extending JsonAction to show that we are serving JSON POST requests // 2. Extending JsonAction to show that we are serving JSON POST requests
// 3. Extending JsonGetAction to serve JSON GET requests // 3. Extending JsonGetAction to serve JSON GET requests
// 4. Extending ConsoleApiAction to serve JSON requests
ImmutableSet.Builder<String> failingClasses = new ImmutableSet.Builder<>(); ImmutableSet.Builder<String> failingClasses = new ImmutableSet.Builder<>();
try (ScanResult scanResult = try (ScanResult scanResult =
new ClassGraph().enableAnnotationInfo().whitelistPackages("google.registry.ui").scan()) { new ClassGraph().enableAnnotationInfo().whitelistPackages("google.registry.ui").scan()) {
@ -41,7 +43,8 @@ final class ActionMembershipTest {
.getClassesWithAnnotation(Action.class.getName()) .getClassesWithAnnotation(Action.class.getName())
.forEach( .forEach(
classInfo -> { 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(JsonActionRunner.JsonAction.class.getName())
&& !classInfo.implementsInterface(JsonGetAction.class.getName())) { && !classInfo.implementsInterface(JsonGetAction.class.getName())) {
failingClasses.add(classInfo.getName()); failingClasses.add(classInfo.getName());

View file

@ -14,7 +14,9 @@
package google.registry.ui.server.console; package google.registry.ui.server.console;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertThat;
import static org.mockito.Mockito.when;
import com.google.api.client.http.HttpStatusCodes; import com.google.api.client.http.HttpStatusCodes;
import com.google.gson.Gson; import com.google.gson.Gson;
@ -22,12 +24,18 @@ import google.registry.model.console.GlobalRole;
import google.registry.model.console.User; import google.registry.model.console.User;
import google.registry.model.console.UserRoles; import google.registry.model.console.UserRoles;
import google.registry.persistence.transaction.JpaTestExtensions; import google.registry.persistence.transaction.JpaTestExtensions;
import google.registry.request.Action;
import google.registry.request.RequestModule; import google.registry.request.RequestModule;
import google.registry.request.auth.AuthResult; import google.registry.request.auth.AuthResult;
import google.registry.request.auth.UserAuthInfo; import google.registry.request.auth.UserAuthInfo;
import google.registry.testing.FakeConsoleApiParams;
import google.registry.testing.FakeResponse; import google.registry.testing.FakeResponse;
import google.registry.ui.server.registrar.ConsoleApiParams;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import javax.servlet.http.Cookie;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.api.extension.RegisterExtension;
@ -35,12 +43,31 @@ import org.junit.jupiter.api.extension.RegisterExtension;
class ConsoleUserDataActionTest { class ConsoleUserDataActionTest {
private static final Gson GSON = RequestModule.provideGson(); private static final Gson GSON = RequestModule.provideGson();
private FakeResponse response = new FakeResponse();
private ConsoleApiParams consoleApiParams;
@RegisterExtension @RegisterExtension
final JpaTestExtensions.JpaIntegrationTestExtension jpa = final JpaTestExtensions.JpaIntegrationTestExtension jpa =
new JpaTestExtensions.Builder().buildIntegrationTestExtension(); 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<Cookie> cookies = ((FakeResponse) consoleApiParams.response()).getCookies();
assertThat(cookies.stream().map(cookie -> cookie.getName()).collect(toImmutableList()))
.containsExactly("X-CSRF-Token");
}
@Test @Test
void testSuccess_getContactInfo() throws IOException { void testSuccess_getContactInfo() throws IOException {
User user = User user =
@ -49,10 +76,15 @@ class ConsoleUserDataActionTest {
.setUserRoles(new UserRoles.Builder().setGlobalRole(GlobalRole.FTE).build()) .setUserRoles(new UserRoles.Builder().setGlobalRole(GlobalRole.FTE).build())
.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(); action.run();
assertThat(response.getStatus()).isEqualTo(HttpStatusCodes.STATUS_CODE_OK); assertThat(((FakeResponse) consoleApiParams.response()).getStatus())
Map jsonObject = GSON.fromJson(response.getPayload(), Map.class); .isEqualTo(HttpStatusCodes.STATUS_CODE_OK);
Map jsonObject =
GSON.fromJson(((FakeResponse) consoleApiParams.response()).getPayload(), Map.class);
assertThat(jsonObject) assertThat(jsonObject)
.containsExactly( .containsExactly(
"isAdmin", "isAdmin",
@ -71,19 +103,18 @@ class ConsoleUserDataActionTest {
@Test @Test
void testFailure_notAConsoleUser() throws IOException { void testFailure_notAConsoleUser() throws IOException {
ConsoleUserDataAction action = ConsoleUserDataAction action = createAction(Optional.empty(), Action.Method.GET);
createAction(
AuthResult.createUser(
UserAuthInfo.create(
new com.google.appengine.api.users.User(
"JohnDoe@theregistrar.com", "theregistrar.com"),
false)));
action.run(); 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<ConsoleApiParams> maybeConsoleApiParams, Action.Method method) throws IOException {
consoleApiParams =
maybeConsoleApiParams.orElseGet(() -> FakeConsoleApiParams.get(Optional.empty()));
when(consoleApiParams.request().getMethod()).thenReturn(method.toString());
return new ConsoleUserDataAction( return new ConsoleUserDataAction(
authResult, response, "Nomulus", "support@example.com", "+1 (212) 867 5309", "test"); consoleApiParams, "Nomulus", "support@example.com", "+1 (212) 867 5309", "test");
} }
} }