Refactor UI actions to be more reliable (#348)

* Refactor UI actions to be more reliable

- Created HtmlAction to handle the nitty-gritty of login and setting up
HTML pages
- Created a test to verify that all UI actions implement JSON GET, JSON
POST, or HTML classes
- Move CSS renaming into a utility class

* Move logging of request into HtmlAction

* Comment and wording in exception

* mention JsonGetAction in the comment

* JsonGetAction extends Runnable
This commit is contained in:
gbrodman 2019-11-11 16:18:48 -05:00 committed by GitHub
parent 3a47fa2fe9
commit dea7dfcf28
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 248 additions and 235 deletions

View file

@ -19,6 +19,7 @@ import static com.google.common.io.Resources.asCharSource;
import static com.google.common.io.Resources.getResource; import static com.google.common.io.Resources.getResource;
import static java.nio.charset.StandardCharsets.UTF_8; import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Joiner; import com.google.common.base.Joiner;
import com.google.common.base.Splitter; import com.google.common.base.Splitter;
import com.google.common.base.Supplier; import com.google.common.base.Supplier;
@ -42,6 +43,12 @@ import java.util.Map;
/** Helper methods for rendering Soy templates from Java code. */ /** Helper methods for rendering Soy templates from Java code. */
public final class SoyTemplateUtils { public final class SoyTemplateUtils {
@VisibleForTesting
public static final Supplier<SoyCssRenamingMap> CSS_RENAMING_MAP_SUPPLIER =
SoyTemplateUtils.createCssRenamingMapSupplier(
Resources.getResource("google/registry/ui/css/registrar_bin.css.js"),
Resources.getResource("google/registry/ui/css/registrar_dbg.css.js"));
/** Returns a memoized supplier containing compiled tofu. */ /** Returns a memoized supplier containing compiled tofu. */
public static Supplier<SoyTofu> createTofuSupplier(final SoyFileInfo... soyInfos) { public static Supplier<SoyTofu> createTofuSupplier(final SoyFileInfo... soyInfos) {
return memoize( return memoize(

View file

@ -15,45 +15,30 @@
package google.registry.ui.server.registrar; package google.registry.ui.server.registrar;
import static com.google.common.base.Preconditions.checkState; import static com.google.common.base.Preconditions.checkState;
import static com.google.common.net.HttpHeaders.LOCATION;
import static com.google.common.net.HttpHeaders.X_FRAME_OPTIONS;
import static google.registry.config.RegistryEnvironment.PRODUCTION; import static google.registry.config.RegistryEnvironment.PRODUCTION;
import static google.registry.ui.server.SoyTemplateUtils.CSS_RENAMING_MAP_SUPPLIER;
import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN; import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
import static javax.servlet.http.HttpServletResponse.SC_MOVED_TEMPORARILY;
import com.google.appengine.api.users.User;
import com.google.appengine.api.users.UserService;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Ascii; import com.google.common.base.Ascii;
import com.google.common.base.Supplier; import com.google.common.base.Supplier;
import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableMap;
import com.google.common.flogger.FluentLogger; import com.google.common.flogger.FluentLogger;
import com.google.common.io.Resources;
import com.google.common.net.MediaType;
import com.google.template.soy.shared.SoyCssRenamingMap;
import com.google.template.soy.tofu.SoyTofu; import com.google.template.soy.tofu.SoyTofu;
import google.registry.config.RegistryConfig.Config;
import google.registry.config.RegistryEnvironment; import google.registry.config.RegistryEnvironment;
import google.registry.model.OteAccountBuilder; import google.registry.model.OteAccountBuilder;
import google.registry.request.Action; import google.registry.request.Action;
import google.registry.request.Action.Method; import google.registry.request.Action.Method;
import google.registry.request.Parameter; 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.Auth;
import google.registry.request.auth.AuthResult;
import google.registry.request.auth.AuthenticatedRegistrarAccessor; import google.registry.request.auth.AuthenticatedRegistrarAccessor;
import google.registry.security.XsrfTokenManager;
import google.registry.ui.server.SendEmailUtils; import google.registry.ui.server.SendEmailUtils;
import google.registry.ui.server.SoyTemplateUtils; import google.registry.ui.server.SoyTemplateUtils;
import google.registry.ui.soy.registrar.OteSetupConsoleSoyInfo; import google.registry.ui.soy.registrar.OteSetupConsoleSoyInfo;
import google.registry.util.StringGenerator; import google.registry.util.StringGenerator;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import javax.inject.Inject; import javax.inject.Inject;
import javax.inject.Named; import javax.inject.Named;
import javax.servlet.http.HttpServletRequest;
/** /**
* Action that serves OT&amp;E setup web page. * Action that serves OT&amp;E setup web page.
@ -69,14 +54,9 @@ import javax.servlet.http.HttpServletRequest;
path = ConsoleOteSetupAction.PATH, path = ConsoleOteSetupAction.PATH,
method = {Method.POST, Method.GET}, method = {Method.POST, Method.GET},
auth = Auth.AUTH_PUBLIC) auth = Auth.AUTH_PUBLIC)
public final class ConsoleOteSetupAction implements Runnable { public final class ConsoleOteSetupAction extends HtmlAction {
public static final String PATH = "/registrar-ote-setup"; public static final String PATH = "/registrar-ote-setup";
@VisibleForTesting // webdriver and screenshot tests need this
public static final Supplier<SoyCssRenamingMap> CSS_RENAMING_MAP_SUPPLIER =
SoyTemplateUtils.createCssRenamingMapSupplier(
Resources.getResource("google/registry/ui/css/registrar_bin.css.js"),
Resources.getResource("google/registry/ui/css/registrar_dbg.css.js"));
private static final int PASSWORD_LENGTH = 16; private static final int PASSWORD_LENGTH = 16;
private static final FluentLogger logger = FluentLogger.forEnclosingClass(); private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private static final Supplier<SoyTofu> TOFU_SUPPLIER = private static final Supplier<SoyTofu> TOFU_SUPPLIER =
@ -85,27 +65,10 @@ public final class ConsoleOteSetupAction implements Runnable {
google.registry.ui.soy.FormsSoyInfo.getInstance(), google.registry.ui.soy.FormsSoyInfo.getInstance(),
google.registry.ui.soy.AnalyticsSoyInfo.getInstance(), google.registry.ui.soy.AnalyticsSoyInfo.getInstance(),
google.registry.ui.soy.registrar.OteSetupConsoleSoyInfo.getInstance()); google.registry.ui.soy.registrar.OteSetupConsoleSoyInfo.getInstance());
@Inject HttpServletRequest req;
@Inject @RequestMethod Method method;
@Inject Response response;
@Inject AuthenticatedRegistrarAccessor registrarAccessor; @Inject AuthenticatedRegistrarAccessor registrarAccessor;
@Inject UserService userService;
@Inject XsrfTokenManager xsrfTokenManager;
@Inject AuthResult authResult;
@Inject SendEmailUtils sendEmailUtils; @Inject SendEmailUtils sendEmailUtils;
@Inject
@Config("logoFilename")
String logoFilename;
@Inject
@Config("productName")
String productName;
@Inject
@Config("analyticsConfig")
Map<String, Object> analyticsConfig;
@Inject @Inject
@Named("base58StringGenerator") @Named("base58StringGenerator")
StringGenerator passwordGenerator; StringGenerator passwordGenerator;
@ -126,43 +89,9 @@ public final class ConsoleOteSetupAction implements Runnable {
ConsoleOteSetupAction() {} ConsoleOteSetupAction() {}
@Override @Override
public void run() { public void runAfterLogin(HashMap<String, Object> data) {
response.setHeader(X_FRAME_OPTIONS, "SAMEORIGIN"); // Disallow iframing.
response.setHeader("X-Ui-Compatible", "IE=edge"); // Ask IE not to be silly.
logger.atInfo().log(
"User %s is accessing the OT&E setup page. Method= %s",
registrarAccessor.userIdForLogging(), method);
checkState( checkState(
!RegistryEnvironment.get().equals(PRODUCTION), "Can't create OT&E in prod"); !RegistryEnvironment.get().equals(PRODUCTION), "Can't create OT&E in prod");
if (!authResult.userAuthInfo().isPresent()) {
response.setStatus(SC_MOVED_TEMPORARILY);
String location;
try {
location = userService.createLoginURL(req.getRequestURI());
} catch (IllegalArgumentException e) {
// UserServiceImpl.createLoginURL() throws IllegalArgumentException if underlying API call
// returns an error code of NOT_ALLOWED. createLoginURL() assumes that the error is caused
// by an invalid URL. But in fact, the error can also occur if UserService doesn't have any
// user information, which happens when the request has been authenticated as internal. In
// this case, we want to avoid dying before we can send the redirect, so just redirect to
// the root path.
location = "/";
}
response.setHeader(LOCATION, location);
return;
}
User user = authResult.userAuthInfo().get().user();
// Using HashMap to allow null values
HashMap<String, Object> data = new HashMap<>();
data.put("logoFilename", logoFilename);
data.put("productName", productName);
data.put("username", user.getNickname());
data.put("logoutUrl", userService.createLogoutURL(PATH));
data.put("xsrfToken", xsrfTokenManager.generateToken(user.getEmail()));
data.put("analyticsConfig", analyticsConfig);
response.setContentType(MediaType.HTML_UTF_8);
if (!registrarAccessor.isAdmin()) { if (!registrarAccessor.isAdmin()) {
response.setStatus(SC_FORBIDDEN); response.setStatus(SC_FORBIDDEN);
@ -187,6 +116,11 @@ public final class ConsoleOteSetupAction implements Runnable {
} }
} }
@Override
public String getPath() {
return PATH;
}
private void runPost(HashMap<String, Object> data) { private void runPost(HashMap<String, Object> data) {
try { try {
checkState(clientId.isPresent() && email.isPresent(), "Must supply clientId and email"); checkState(clientId.isPresent() && email.isPresent(), "Must supply clientId and email");

View file

@ -18,27 +18,18 @@ import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState; import static com.google.common.base.Preconditions.checkState;
import static com.google.common.collect.ImmutableList.toImmutableList; import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.collect.ImmutableMap.toImmutableMap; import static com.google.common.collect.ImmutableMap.toImmutableMap;
import static com.google.common.net.HttpHeaders.LOCATION;
import static com.google.common.net.HttpHeaders.X_FRAME_OPTIONS;
import static google.registry.model.common.GaeUserIdConverter.convertEmailAddressToGaeUserId; import static google.registry.model.common.GaeUserIdConverter.convertEmailAddressToGaeUserId;
import static google.registry.model.ofy.ObjectifyService.ofy; import static google.registry.model.ofy.ObjectifyService.ofy;
import static google.registry.model.transaction.TransactionManagerFactory.tm; import static google.registry.model.transaction.TransactionManagerFactory.tm;
import static google.registry.ui.server.SoyTemplateUtils.CSS_RENAMING_MAP_SUPPLIER;
import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN; import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
import static javax.servlet.http.HttpServletResponse.SC_MOVED_TEMPORARILY;
import com.google.appengine.api.users.User;
import com.google.appengine.api.users.UserService;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Ascii; import com.google.common.base.Ascii;
import com.google.common.base.Splitter; import com.google.common.base.Splitter;
import com.google.common.base.Supplier; import com.google.common.base.Supplier;
import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableMap;
import com.google.common.flogger.FluentLogger; import com.google.common.flogger.FluentLogger;
import com.google.common.io.Resources;
import com.google.common.net.MediaType;
import com.google.template.soy.shared.SoyCssRenamingMap;
import com.google.template.soy.tofu.SoyTofu; import com.google.template.soy.tofu.SoyTofu;
import google.registry.config.RegistryConfig.Config;
import google.registry.config.RegistryEnvironment; import google.registry.config.RegistryEnvironment;
import google.registry.model.registrar.Registrar; import google.registry.model.registrar.Registrar;
import google.registry.model.registrar.RegistrarAddress; import google.registry.model.registrar.RegistrarAddress;
@ -46,23 +37,17 @@ import google.registry.model.registrar.RegistrarContact;
import google.registry.request.Action; import google.registry.request.Action;
import google.registry.request.Action.Method; import google.registry.request.Action.Method;
import google.registry.request.Parameter; 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.Auth;
import google.registry.request.auth.AuthResult;
import google.registry.request.auth.AuthenticatedRegistrarAccessor; import google.registry.request.auth.AuthenticatedRegistrarAccessor;
import google.registry.security.XsrfTokenManager;
import google.registry.ui.server.SendEmailUtils; import google.registry.ui.server.SendEmailUtils;
import google.registry.ui.server.SoyTemplateUtils; import google.registry.ui.server.SoyTemplateUtils;
import google.registry.ui.soy.registrar.RegistrarCreateConsoleSoyInfo; import google.registry.ui.soy.registrar.RegistrarCreateConsoleSoyInfo;
import google.registry.util.StringGenerator; import google.registry.util.StringGenerator;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Stream; import java.util.stream.Stream;
import javax.inject.Inject; import javax.inject.Inject;
import javax.inject.Named; import javax.inject.Named;
import javax.servlet.http.HttpServletRequest;
import org.joda.money.CurrencyUnit; import org.joda.money.CurrencyUnit;
/** /**
@ -79,7 +64,7 @@ import org.joda.money.CurrencyUnit;
path = ConsoleRegistrarCreatorAction.PATH, path = ConsoleRegistrarCreatorAction.PATH,
method = {Method.POST, Method.GET}, method = {Method.POST, Method.GET},
auth = Auth.AUTH_PUBLIC) auth = Auth.AUTH_PUBLIC)
public final class ConsoleRegistrarCreatorAction implements Runnable { public final class ConsoleRegistrarCreatorAction extends HtmlAction {
private static final int PASSWORD_LENGTH = 16; private static final int PASSWORD_LENGTH = 16;
private static final int PASSCODE_LENGTH = 5; private static final int PASSCODE_LENGTH = 5;
@ -95,23 +80,8 @@ public final class ConsoleRegistrarCreatorAction implements Runnable {
google.registry.ui.soy.AnalyticsSoyInfo.getInstance(), google.registry.ui.soy.AnalyticsSoyInfo.getInstance(),
google.registry.ui.soy.registrar.RegistrarCreateConsoleSoyInfo.getInstance()); google.registry.ui.soy.registrar.RegistrarCreateConsoleSoyInfo.getInstance());
@VisibleForTesting // webdriver and screenshot tests need this
public static final Supplier<SoyCssRenamingMap> CSS_RENAMING_MAP_SUPPLIER =
SoyTemplateUtils.createCssRenamingMapSupplier(
Resources.getResource("google/registry/ui/css/registrar_bin.css.js"),
Resources.getResource("google/registry/ui/css/registrar_dbg.css.js"));
@Inject HttpServletRequest req;
@Inject @RequestMethod Method method;
@Inject Response response;
@Inject AuthenticatedRegistrarAccessor registrarAccessor; @Inject AuthenticatedRegistrarAccessor registrarAccessor;
@Inject UserService userService;
@Inject XsrfTokenManager xsrfTokenManager;
@Inject AuthResult authResult;
@Inject SendEmailUtils sendEmailUtils; @Inject SendEmailUtils sendEmailUtils;
@Inject @Config("logoFilename") String logoFilename;
@Inject @Config("productName") String productName;
@Inject @Config("analyticsConfig") Map<String, Object> analyticsConfig;
@Inject @Named("base58StringGenerator") StringGenerator passwordGenerator; @Inject @Named("base58StringGenerator") StringGenerator passwordGenerator;
@Inject @Named("digitOnlyStringGenerator") StringGenerator passcodeGenerator; @Inject @Named("digitOnlyStringGenerator") StringGenerator passcodeGenerator;
@Inject @Parameter("clientId") Optional<String> clientId; @Inject @Parameter("clientId") Optional<String> clientId;
@ -137,42 +107,7 @@ public final class ConsoleRegistrarCreatorAction implements Runnable {
@Inject ConsoleRegistrarCreatorAction() {} @Inject ConsoleRegistrarCreatorAction() {}
@Override @Override
public void run() { public void runAfterLogin(HashMap<String, Object> data) {
response.setHeader(X_FRAME_OPTIONS, "SAMEORIGIN"); // Disallow iframing.
response.setHeader("X-Ui-Compatible", "IE=edge"); // Ask IE not to be silly.
logger.atInfo().log(
"User %s is accessing the Registrar creation page. Method= %s",
registrarAccessor.userIdForLogging(), method);
if (!authResult.userAuthInfo().isPresent()) {
response.setStatus(SC_MOVED_TEMPORARILY);
String location;
try {
location = userService.createLoginURL(req.getRequestURI());
} catch (IllegalArgumentException e) {
// UserServiceImpl.createLoginURL() throws IllegalArgumentException if underlying API call
// returns an error code of NOT_ALLOWED. createLoginURL() assumes that the error is caused
// by an invalid URL. But in fact, the error can also occur if UserService doesn't have any
// user information, which happens when the request has been authenticated as internal. In
// this case, we want to avoid dying before we can send the redirect, so just redirect to
// the root path.
location = "/";
}
response.setHeader(LOCATION, location);
return;
}
User user = authResult.userAuthInfo().get().user();
// Using HashMap to allow null values
HashMap<String, Object> data = new HashMap<>();
data.put("logoFilename", logoFilename);
data.put("productName", productName);
data.put("username", user.getNickname());
data.put("logoutUrl", userService.createLogoutURL(PATH));
data.put("xsrfToken", xsrfTokenManager.generateToken(user.getEmail()));
data.put("analyticsConfig", analyticsConfig);
response.setContentType(MediaType.HTML_UTF_8);
if (!registrarAccessor.isAdmin()) { if (!registrarAccessor.isAdmin()) {
response.setStatus(SC_FORBIDDEN); response.setStatus(SC_FORBIDDEN);
response.setPayload( response.setPayload(
@ -196,6 +131,11 @@ public final class ConsoleRegistrarCreatorAction implements Runnable {
} }
} }
@Override
public String getPath() {
return PATH;
}
private void checkPresent(Optional<?> value, String name) { private void checkPresent(Optional<?> value, String name) {
checkState(value.isPresent(), "Missing value for %s", name); checkState(value.isPresent(), "Missing value for %s", name);
} }
@ -226,7 +166,6 @@ public final class ConsoleRegistrarCreatorAction implements Runnable {
private void runPost(HashMap<String, Object> data) { private void runPost(HashMap<String, Object> data) {
try { try {
checkPresent(clientId, "clientId"); checkPresent(clientId, "clientId");
checkPresent(name, "name"); checkPresent(name, "name");
checkPresent(billingAccount, "billingAccount"); checkPresent(billingAccount, "billingAccount");
@ -321,11 +260,11 @@ public final class ConsoleRegistrarCreatorAction implements Runnable {
data.put("errorMessage", e.getMessage()); data.put("errorMessage", e.getMessage());
response.setPayload( response.setPayload(
TOFU_SUPPLIER TOFU_SUPPLIER
.get() .get()
.newRenderer(RegistrarCreateConsoleSoyInfo.FORM_PAGE) .newRenderer(RegistrarCreateConsoleSoyInfo.FORM_PAGE)
.setCssRenamingMap(CSS_RENAMING_MAP_SUPPLIER.get()) .setCssRenamingMap(CSS_RENAMING_MAP_SUPPLIER.get())
.setData(data) .setData(data)
.render()); .render());
} }
} }
@ -357,8 +296,8 @@ public final class ConsoleRegistrarCreatorAction implements Runnable {
String environment = Ascii.toLowerCase(String.valueOf(RegistryEnvironment.get())); String environment = Ascii.toLowerCase(String.valueOf(RegistryEnvironment.get()));
String body = String body =
String.format( String.format(
"The following registrar was created in %s by %s:\n", "The following registrar was created in %s by %s:\n",
environment, registrarAccessor.userIdForLogging()) environment, registrarAccessor.userIdForLogging())
+ toEmailLine(clientId, "clientId") + toEmailLine(clientId, "clientId")
+ toEmailLine(name, "name") + toEmailLine(name, "name")
+ toEmailLine(billingAccount, "billingAccount") + toEmailLine(billingAccount, "billingAccount")

View file

@ -14,47 +14,35 @@
package google.registry.ui.server.registrar; package google.registry.ui.server.registrar;
import static com.google.common.net.HttpHeaders.LOCATION;
import static com.google.common.net.HttpHeaders.X_FRAME_OPTIONS;
import static google.registry.request.auth.AuthenticatedRegistrarAccessor.Role.ADMIN; import static google.registry.request.auth.AuthenticatedRegistrarAccessor.Role.ADMIN;
import static google.registry.request.auth.AuthenticatedRegistrarAccessor.Role.OWNER; import static google.registry.request.auth.AuthenticatedRegistrarAccessor.Role.OWNER;
import static google.registry.ui.server.SoyTemplateUtils.CSS_RENAMING_MAP_SUPPLIER;
import static google.registry.ui.server.registrar.RegistrarConsoleModule.PARAM_CLIENT_ID; 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_FORBIDDEN;
import static javax.servlet.http.HttpServletResponse.SC_MOVED_TEMPORARILY;
import static javax.servlet.http.HttpServletResponse.SC_SERVICE_UNAVAILABLE; import static javax.servlet.http.HttpServletResponse.SC_SERVICE_UNAVAILABLE;
import com.google.appengine.api.users.User;
import com.google.appengine.api.users.UserService;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Supplier; import com.google.common.base.Supplier;
import com.google.common.collect.ImmutableSetMultimap; import com.google.common.collect.ImmutableSetMultimap;
import com.google.common.flogger.FluentLogger; import com.google.common.flogger.FluentLogger;
import com.google.common.io.Resources;
import com.google.common.net.MediaType;
import com.google.template.soy.data.SoyMapData; import com.google.template.soy.data.SoyMapData;
import com.google.template.soy.shared.SoyCssRenamingMap;
import com.google.template.soy.tofu.SoyTofu; import com.google.template.soy.tofu.SoyTofu;
import google.registry.config.RegistryConfig.Config; import google.registry.config.RegistryConfig.Config;
import google.registry.config.RegistryEnvironment; import google.registry.config.RegistryEnvironment;
import google.registry.request.Action; import google.registry.request.Action;
import google.registry.request.Parameter; import google.registry.request.Parameter;
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.request.auth.AuthenticatedRegistrarAccessor; import google.registry.request.auth.AuthenticatedRegistrarAccessor;
import google.registry.request.auth.AuthenticatedRegistrarAccessor.RegistrarAccessDeniedException; import google.registry.request.auth.AuthenticatedRegistrarAccessor.RegistrarAccessDeniedException;
import google.registry.request.auth.AuthenticatedRegistrarAccessor.Role; import google.registry.request.auth.AuthenticatedRegistrarAccessor.Role;
import google.registry.security.XsrfTokenManager;
import google.registry.ui.server.SoyTemplateUtils; import google.registry.ui.server.SoyTemplateUtils;
import google.registry.ui.soy.registrar.ConsoleSoyInfo; import google.registry.ui.soy.registrar.ConsoleSoyInfo;
import java.util.Map; import java.util.HashMap;
import java.util.Optional; import java.util.Optional;
import javax.inject.Inject; import javax.inject.Inject;
import javax.servlet.http.HttpServletRequest;
/** Action that serves Registrar Console single HTML page (SPA). */ /** Action that serves Registrar Console single HTML page (SPA). */
@Action(service = Action.Service.DEFAULT, path = ConsoleUiAction.PATH, auth = Auth.AUTH_PUBLIC) @Action(service = Action.Service.DEFAULT, path = ConsoleUiAction.PATH, auth = Auth.AUTH_PUBLIC)
public final class ConsoleUiAction implements Runnable { public final class ConsoleUiAction extends HtmlAction {
private static final FluentLogger logger = FluentLogger.forEnclosingClass(); private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@ -66,27 +54,8 @@ public final class ConsoleUiAction implements Runnable {
google.registry.ui.soy.registrar.ConsoleSoyInfo.getInstance(), google.registry.ui.soy.registrar.ConsoleSoyInfo.getInstance(),
google.registry.ui.soy.AnalyticsSoyInfo.getInstance()); google.registry.ui.soy.AnalyticsSoyInfo.getInstance());
@VisibleForTesting // webdriver and screenshot tests need this
public static final Supplier<SoyCssRenamingMap> CSS_RENAMING_MAP_SUPPLIER =
SoyTemplateUtils.createCssRenamingMapSupplier(
Resources.getResource("google/registry/ui/css/registrar_bin.css.js"),
Resources.getResource("google/registry/ui/css/registrar_dbg.css.js"));
@Inject HttpServletRequest req;
@Inject Response response;
@Inject RegistrarConsoleMetrics registrarConsoleMetrics; @Inject RegistrarConsoleMetrics registrarConsoleMetrics;
@Inject AuthenticatedRegistrarAccessor registrarAccessor; @Inject AuthenticatedRegistrarAccessor registrarAccessor;
@Inject UserService userService;
@Inject XsrfTokenManager xsrfTokenManager;
@Inject AuthResult authResult;
@Inject
@Config("logoFilename")
String logoFilename;
@Inject
@Config("productName")
String productName;
@Inject @Inject
@Config("integrationEmail") @Config("integrationEmail")
@ -112,10 +81,6 @@ public final class ConsoleUiAction implements Runnable {
@Config("registrarConsoleEnabled") @Config("registrarConsoleEnabled")
boolean enabled; boolean enabled;
@Inject
@Config("analyticsConfig")
Map<String, Object> analyticsConfig;
@Inject @Inject
@Parameter(PARAM_CLIENT_ID) @Parameter(PARAM_CLIENT_ID)
Optional<String> paramClientId; Optional<String> paramClientId;
@ -124,37 +89,15 @@ public final class ConsoleUiAction implements Runnable {
ConsoleUiAction() {} ConsoleUiAction() {}
@Override @Override
public void run() { public void runAfterLogin(HashMap<String, Object> data) {
if (!authResult.userAuthInfo().isPresent()) { SoyMapData soyMapData = new SoyMapData();
response.setStatus(SC_MOVED_TEMPORARILY); data.forEach((key, value) -> soyMapData.put(key, value));
String location;
try { soyMapData.put("integrationEmail", integrationEmail);
location = userService.createLoginURL(req.getRequestURI()); soyMapData.put("supportEmail", supportEmail);
} catch (IllegalArgumentException e) { soyMapData.put("announcementsEmail", announcementsEmail);
// UserServiceImpl.createLoginURL() throws IllegalArgumentException if underlying API call soyMapData.put("supportPhoneNumber", supportPhoneNumber);
// returns an error code of NOT_ALLOWED. createLoginURL() assumes that the error is caused soyMapData.put("technicalDocsUrl", technicalDocsUrl);
// by an invalid URL. But in fact, the error can also occur if UserService doesn't have any
// user information, which happens when the request has been authenticated as internal. In
// this case, we want to avoid dying before we can send the redirect, so just redirect to
// the root path.
location = "/";
}
response.setHeader(LOCATION, location);
return;
}
User user = authResult.userAuthInfo().get().user();
response.setContentType(MediaType.HTML_UTF_8);
response.setHeader(X_FRAME_OPTIONS, "SAMEORIGIN"); // Disallow iframing.
response.setHeader("X-Ui-Compatible", "IE=edge"); // Ask IE not to be silly.
SoyMapData data = new SoyMapData();
data.put("logoFilename", logoFilename);
data.put("productName", productName);
data.put("integrationEmail", integrationEmail);
data.put("supportEmail", supportEmail);
data.put("announcementsEmail", announcementsEmail);
data.put("supportPhoneNumber", supportPhoneNumber);
data.put("technicalDocsUrl", technicalDocsUrl);
data.put("analyticsConfig", analyticsConfig);
if (!enabled) { if (!enabled) {
response.setStatus(SC_SERVICE_UNAVAILABLE); response.setStatus(SC_SERVICE_UNAVAILABLE);
response.setPayload( response.setPayload(
@ -162,23 +105,20 @@ public final class ConsoleUiAction implements Runnable {
.get() .get()
.newRenderer(ConsoleSoyInfo.DISABLED) .newRenderer(ConsoleSoyInfo.DISABLED)
.setCssRenamingMap(CSS_RENAMING_MAP_SUPPLIER.get()) .setCssRenamingMap(CSS_RENAMING_MAP_SUPPLIER.get())
.setData(data) .setData(soyMapData)
.render()); .render());
return; return;
} }
data.put("username", user.getNickname());
data.put("logoutUrl", userService.createLogoutURL(PATH));
data.put("xsrfToken", xsrfTokenManager.generateToken(user.getEmail()));
ImmutableSetMultimap<String, Role> roleMap = registrarAccessor.getAllClientIdWithRoles(); ImmutableSetMultimap<String, Role> roleMap = registrarAccessor.getAllClientIdWithRoles();
data.put("allClientIds", roleMap.keySet()); soyMapData.put("allClientIds", roleMap.keySet());
data.put("environment", RegistryEnvironment.get().toString()); soyMapData.put("environment", RegistryEnvironment.get().toString());
// We set the initial value to the value that will show if guessClientId throws. // We set the initial value to the value that will show if guessClientId throws.
String clientId = "<null>"; String clientId = "<null>";
try { try {
clientId = paramClientId.orElse(registrarAccessor.guessClientId()); clientId = paramClientId.orElse(registrarAccessor.guessClientId());
data.put("clientId", clientId); soyMapData.put("clientId", clientId);
data.put("isOwner", roleMap.containsEntry(clientId, OWNER)); soyMapData.put("isOwner", roleMap.containsEntry(clientId, OWNER));
data.put("isAdmin", roleMap.containsEntry(clientId, ADMIN)); soyMapData.put("isAdmin", roleMap.containsEntry(clientId, ADMIN));
// We want to load the registrar even if we won't use it later (even if we remove the // We want to load the registrar even if we won't use it later (even if we remove the
// requireFeeExtension) - to make sure the user indeed has access to the guessed registrar. // requireFeeExtension) - to make sure the user indeed has access to the guessed registrar.
@ -197,7 +137,7 @@ public final class ConsoleUiAction implements Runnable {
.get() .get()
.newRenderer(ConsoleSoyInfo.WHOAREYOU) .newRenderer(ConsoleSoyInfo.WHOAREYOU)
.setCssRenamingMap(CSS_RENAMING_MAP_SUPPLIER.get()) .setCssRenamingMap(CSS_RENAMING_MAP_SUPPLIER.get())
.setData(data) .setData(soyMapData)
.render()); .render());
registrarConsoleMetrics.registerConsoleRequest( registrarConsoleMetrics.registerConsoleRequest(
clientId, paramClientId.isPresent(), roleMap.get(clientId), "FORBIDDEN"); clientId, paramClientId.isPresent(), roleMap.get(clientId), "FORBIDDEN");
@ -213,10 +153,15 @@ public final class ConsoleUiAction implements Runnable {
.get() .get()
.newRenderer(ConsoleSoyInfo.MAIN) .newRenderer(ConsoleSoyInfo.MAIN)
.setCssRenamingMap(CSS_RENAMING_MAP_SUPPLIER.get()) .setCssRenamingMap(CSS_RENAMING_MAP_SUPPLIER.get())
.setData(data) .setData(soyMapData)
.render(); .render();
response.setPayload(payload); response.setPayload(payload);
registrarConsoleMetrics.registerConsoleRequest( registrarConsoleMetrics.registerConsoleRequest(
clientId, paramClientId.isPresent(), roleMap.get(clientId), "SUCCESS"); clientId, paramClientId.isPresent(), roleMap.get(clientId), "SUCCESS");
} }
@Override
public String getPath() {
return PATH;
}
} }

View file

@ -0,0 +1,109 @@
// 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.net.HttpHeaders.LOCATION;
import static com.google.common.net.HttpHeaders.X_FRAME_OPTIONS;
import static javax.servlet.http.HttpServletResponse.SC_MOVED_TEMPORARILY;
import com.google.appengine.api.users.User;
import com.google.appengine.api.users.UserService;
import com.google.common.flogger.FluentLogger;
import com.google.common.net.MediaType;
import google.registry.config.RegistryConfig.Config;
import google.registry.request.Action;
import google.registry.request.RequestMethod;
import google.registry.request.Response;
import google.registry.request.auth.AuthResult;
import google.registry.security.XsrfTokenManager;
import java.util.HashMap;
import java.util.Map;
import javax.inject.Inject;
import javax.servlet.http.HttpServletRequest;
/**
* Handles some of the nitty-gritty of responding to requests that should return HTML, including
* login, redirects, analytics, and some headers.
*
* If the user is not logged in, this will redirect to the login URL.
*/
public abstract class HtmlAction implements Runnable {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@Inject HttpServletRequest req;
@Inject Response response;
@Inject UserService userService;
@Inject XsrfTokenManager xsrfTokenManager;
@Inject AuthResult authResult;
@Inject @RequestMethod Action.Method method;
@Inject
@Config("logoFilename")
String logoFilename;
@Inject
@Config("productName")
String productName;
@Inject
@Config("analyticsConfig")
Map<String, Object> analyticsConfig;
@Override
public void run() {
response.setHeader(X_FRAME_OPTIONS, "SAMEORIGIN"); // Disallow iframing.
response.setHeader("X-Ui-Compatible", "IE=edge"); // Ask IE not to be silly.
if (!authResult.userAuthInfo().isPresent()) {
response.setStatus(SC_MOVED_TEMPORARILY);
String location;
try {
location = userService.createLoginURL(req.getRequestURI());
} catch (IllegalArgumentException e) {
// UserServiceImpl.createLoginURL() throws IllegalArgumentException if underlying API call
// returns an error code of NOT_ALLOWED. createLoginURL() assumes that the error is caused
// by an invalid URL. But in fact, the error can also occur if UserService doesn't have any
// user information, which happens when the request has been authenticated as internal. In
// this case, we want to avoid dying before we can send the redirect, so just redirect to
// the root path.
location = "/";
}
response.setHeader(LOCATION, location);
return;
}
response.setContentType(MediaType.HTML_UTF_8);
User user = authResult.userAuthInfo().get().user();
// Using HashMap to allow null values
HashMap<String, Object> data = new HashMap<>();
data.put("logoFilename", logoFilename);
data.put("productName", productName);
data.put("username", user.getNickname());
data.put("logoutUrl", userService.createLogoutURL(getPath()));
data.put("analyticsConfig", analyticsConfig);
data.put("xsrfToken", xsrfTokenManager.generateToken(user.getEmail()));
logger.atInfo().log(
"User %s is accessing %s. Method= %s",
authResult.userIdForLogging(), getClass().getName(), method);
runAfterLogin(data);
}
public abstract void runAfterLogin(HashMap<String, Object> data);
public abstract String getPath();
}

View file

@ -0,0 +1,21 @@
// 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;
/**
* Marker interface for {@link google.registry.request.Action}s that serve GET requests and return
* JSON, rather than HTML.
*/
public interface JsonGetAction extends Runnable {}

View file

@ -16,7 +16,6 @@ package google.registry.ui.server.registrar;
import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.collect.ImmutableList.toImmutableList; 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.security.JsonResponseHelper.Status.SUCCESS;
import static google.registry.ui.server.registrar.RegistrarConsoleModule.PARAM_CLIENT_ID; 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_FORBIDDEN;
@ -58,7 +57,7 @@ import org.joda.time.DateTime;
service = Action.Service.DEFAULT, service = Action.Service.DEFAULT,
path = RegistryLockGetAction.PATH, path = RegistryLockGetAction.PATH,
auth = Auth.AUTH_PUBLIC_LOGGED_IN) auth = Auth.AUTH_PUBLIC_LOGGED_IN)
public final class RegistryLockGetAction implements Runnable { public final class RegistryLockGetAction implements JsonGetAction {
public static final String PATH = "/registry-lock-get"; public static final String PATH = "/registry-lock-get";
@ -98,8 +97,6 @@ public final class RegistryLockGetAction implements Runnable {
checkArgument(authResult.userAuthInfo().isPresent(), "User auth info must be present"); checkArgument(authResult.userAuthInfo().isPresent(), "User auth info must be present");
checkArgument(paramClientId.isPresent(), "clientId must be present"); checkArgument(paramClientId.isPresent(), "clientId must be present");
response.setContentType(MediaType.JSON_UTF_8); 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 { try {
ImmutableMap<String, ?> resultMap = getLockedDomainsMap(paramClientId.get()); ImmutableMap<String, ?> resultMap = getLockedDomainsMap(paramClientId.get());

View file

@ -0,0 +1,59 @@
// 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;
import static com.google.common.truth.Truth.assertWithMessage;
import com.google.common.collect.ImmutableSet;
import google.registry.request.Action;
import google.registry.request.JsonActionRunner;
import google.registry.ui.server.registrar.HtmlAction;
import google.registry.ui.server.registrar.JsonGetAction;
import io.github.classgraph.ClassGraph;
import io.github.classgraph.ScanResult;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;
@RunWith(JUnit4.class)
public final class ActionMembershipTest {
@Test
public void testAllActionsEitherHtmlOrJson() {
// All UI actions should serve valid HTML or JSON. There are three valid options:
// 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
ImmutableSet.Builder<String> failingClasses = new ImmutableSet.Builder<>();
try (ScanResult scanResult =
new ClassGraph().enableAnnotationInfo().whitelistPackages("google.registry.ui").scan()) {
scanResult
.getClassesWithAnnotation(Action.class.getName())
.forEach(
classInfo -> {
if (!classInfo.extendsSuperclass(HtmlAction.class.getName())
&& !classInfo.implementsInterface(JsonActionRunner.JsonAction.class.getName())
&& !classInfo.implementsInterface(JsonGetAction.class.getName())) {
failingClasses.add(classInfo.getName());
}
});
}
assertWithMessage(
"All UI actions must implement / extend HtmlAction, JsonGetAction, "
+ "or JsonActionRunner.JsonAction. Failing classes:")
.that(failingClasses.build())
.isEmpty();
}
}

View file

@ -28,6 +28,7 @@ import com.google.appengine.api.users.UserServiceFactory;
import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSetMultimap; import com.google.common.collect.ImmutableSetMultimap;
import com.google.common.net.MediaType; import com.google.common.net.MediaType;
import google.registry.request.Action.Method;
import google.registry.request.auth.AuthLevel; import google.registry.request.auth.AuthLevel;
import google.registry.request.auth.AuthResult; import google.registry.request.auth.AuthResult;
import google.registry.request.auth.AuthenticatedRegistrarAccessor; import google.registry.request.auth.AuthenticatedRegistrarAccessor;
@ -77,6 +78,7 @@ public class ConsoleUiActionTest {
action.registrarConsoleMetrics = new RegistrarConsoleMetrics(); action.registrarConsoleMetrics = new RegistrarConsoleMetrics();
action.userService = UserServiceFactory.getUserService(); action.userService = UserServiceFactory.getUserService();
action.xsrfTokenManager = new XsrfTokenManager(new FakeClock(), action.userService); action.xsrfTokenManager = new XsrfTokenManager(new FakeClock(), action.userService);
action.method = Method.GET;
action.paramClientId = Optional.empty(); action.paramClientId = Optional.empty();
action.authResult = AuthResult.create(AuthLevel.USER, UserAuthInfo.create(user, false)); action.authResult = AuthResult.create(AuthLevel.USER, UserAuthInfo.create(user, false));
action.analyticsConfig = ImmutableMap.of("googleAnalyticsId", "sampleId"); action.analyticsConfig = ImmutableMap.of("googleAnalyticsId", "sampleId");