Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a separate RegistryLock action for the console #2411

Merged
merged 1 commit into from
May 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import static google.registry.util.DateTimeUtils.isBeforeOrAt;
import static google.registry.util.PreconditionsUtils.checkArgumentNotNull;

import com.google.gson.annotations.Expose;
import google.registry.model.Buildable;
import google.registry.model.CreateAutoTimestamp;
import google.registry.model.UpdateAutoTimestampEntity;
Expand Down Expand Up @@ -90,6 +91,7 @@ public final class RegistryLock extends UpdateAutoTimestampEntity implements Bui

// TODO (b/140568328): remove this when everything is in Cloud SQL and we can join on "domain"
@Column(nullable = false)
@Expose
private String domainName;

/**
Expand All @@ -100,30 +102,31 @@ public final class RegistryLock extends UpdateAutoTimestampEntity implements Bui
private String registrarId;

/** The POC that performed the action, or null if it was a superuser. */
private String registrarPocId;
@Expose private String registrarPocId;

/** When the lock is first requested. */
@AttributeOverrides({
@AttributeOverride(
name = "creationTime",
column = @Column(name = "lockRequestTime", nullable = false))
})
@Expose
private final CreateAutoTimestamp lockRequestTime = CreateAutoTimestamp.create(null);

/** When the unlock is first requested. */
private DateTime unlockRequestTime;
@Expose private DateTime unlockRequestTime;

/**
* When the user has verified the lock. If this field is null, it means the lock has not been
* verified yet (and thus not been put into effect).
*/
private DateTime lockCompletionTime;
@Expose private DateTime lockCompletionTime;

/**
* When the user has verified the unlock of this lock. If this field is null, it means the unlock
* action has not been verified yet (and has not been put into effect).
*/
private DateTime unlockCompletionTime;
@Expose private DateTime unlockCompletionTime;

/** The user must provide the random verification code in order to complete the action. */
@Column(nullable = false)
Expand All @@ -134,6 +137,7 @@ public final class RegistryLock extends UpdateAutoTimestampEntity implements Bui
* this case, the action was performed by a registry admin rather than a registrar.
*/
@Column(nullable = false)
@Expose
private boolean isSuperuser;

/** The lock that undoes this lock, if this lock has been unlocked and the domain locked again. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
import google.registry.ui.server.console.ConsoleDomainListAction;
import google.registry.ui.server.console.ConsoleDumDownloadAction;
import google.registry.ui.server.console.ConsoleEppPasswordAction;
import google.registry.ui.server.console.ConsoleRegistryLockAction;
import google.registry.ui.server.console.ConsoleUserDataAction;
import google.registry.ui.server.console.RegistrarsAction;
import google.registry.ui.server.console.settings.ContactAction;
Expand Down Expand Up @@ -186,6 +187,8 @@ interface RequestComponent {

ConsoleRegistrarCreatorAction consoleRegistrarCreatorAction();

ConsoleRegistryLockAction consoleRegistryLockAction();

ConsoleUiAction consoleUiAction();

ConsoleUserDataAction consoleUserDataAction();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import google.registry.ui.server.console.ConsoleDomainListAction;
import google.registry.ui.server.console.ConsoleDumDownloadAction;
import google.registry.ui.server.console.ConsoleEppPasswordAction;
import google.registry.ui.server.console.ConsoleRegistryLockAction;
import google.registry.ui.server.console.ConsoleUserDataAction;
import google.registry.ui.server.console.RegistrarsAction;
import google.registry.ui.server.console.settings.ContactAction;
Expand Down Expand Up @@ -64,6 +65,9 @@ public interface FrontendRequestComponent {

ConsoleOteSetupAction consoleOteSetupAction();
ConsoleRegistrarCreatorAction consoleRegistrarCreatorAction();

ConsoleRegistryLockAction consoleRegistryLockAction();

ConsoleUiAction consoleUiAction();

ConsoleUserDataAction consoleUserDataAction();
Expand Down
14 changes: 14 additions & 0 deletions core/src/main/java/google/registry/request/RequestParameters.java
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,20 @@ public static int extractIntParameter(HttpServletRequest req, String name) {
}
}

/**
* Returns first GET or POST parameter associated with {@code name} as a long.
*
* @throws BadRequestException if request parameter is present but not a valid long
*/
public static Optional<Long> extractOptionalLongParameter(HttpServletRequest req, String name) {
String stringParam = req.getParameter(name);
try {
return isNullOrEmpty(stringParam) ? Optional.empty() : Optional.of(Long.valueOf(stringParam));
} catch (NumberFormatException e) {
throw new BadRequestException("Expected long: " + name);
}
}

/**
* Returns first GET or POST parameter associated with {@code name} as a long.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
// Copyright 2024 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 com.google.common.collect.ImmutableList.toImmutableList;
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 static google.registry.request.RequestParameters.extractBooleanParameter;
import static google.registry.request.RequestParameters.extractOptionalLongParameter;
import static google.registry.request.RequestParameters.extractOptionalParameter;
import static google.registry.request.RequestParameters.extractRequiredParameter;
import static google.registry.ui.server.registrar.RegistryLockPostAction.VERIFICATION_EMAIL_TEMPLATE;

import com.google.api.client.http.HttpStatusCodes;
import com.google.common.collect.ImmutableList;
import com.google.common.flogger.FluentLogger;
import com.google.gson.Gson;
import google.registry.flows.EppException;
import google.registry.flows.domain.DomainFlowUtils;
import google.registry.groups.GmailClient;
import google.registry.model.console.ConsolePermission;
import google.registry.model.console.User;
import google.registry.model.domain.RegistryLock;
import google.registry.model.registrar.Registrar;
import google.registry.model.tld.RegistryLockDao;
import google.registry.request.Action;
import google.registry.request.HttpException;
import google.registry.request.Parameter;
import google.registry.request.Response;
import google.registry.request.auth.Auth;
import google.registry.tools.DomainLockUtils;
import google.registry.ui.server.registrar.ConsoleApiParams;
import google.registry.util.EmailMessage;
import jakarta.servlet.http.HttpServletRequest;
import java.net.URISyntaxException;
import java.util.Optional;
import javax.inject.Inject;
import javax.mail.internet.AddressException;
import javax.mail.internet.InternetAddress;
import org.apache.http.client.utils.URIBuilder;
import org.joda.time.Duration;

/**
* Handler for retrieving / creating registry lock requests in the console.
*
* <p>Note: two-factor verification of the locks occurs separately (TODO: link the verification
* action).
*/
@Action(
service = Action.Service.DEFAULT,
path = ConsoleRegistryLockAction.PATH,
method = {GET, POST},
auth = Auth.AUTH_PUBLIC_LOGGED_IN)
public class ConsoleRegistryLockAction extends ConsoleApiAction {

private static final FluentLogger logger = FluentLogger.forEnclosingClass();

static final String PATH = "/console-api/registry-lock";

private final DomainLockUtils domainLockUtils;
private final GmailClient gmailClient;
private final Gson gson;
private final String registrarId;

@Inject
public ConsoleRegistryLockAction(
ConsoleApiParams consoleApiParams,
DomainLockUtils domainLockUtils,
GmailClient gmailClient,
Gson gson,
@Parameter("registrarId") String registrarId) {
super(consoleApiParams);
this.domainLockUtils = domainLockUtils;
this.gmailClient = gmailClient;
this.gson = gson;
this.registrarId = registrarId;
}

@Override
protected void getHandler(User user) {
if (!user.getUserRoles().hasPermission(registrarId, ConsolePermission.REGISTRY_LOCK)) {
consoleApiParams.response().setStatus(HttpStatusCodes.STATUS_CODE_FORBIDDEN);
return;
}
consoleApiParams.response().setPayload(gson.toJson(getLockedDomains()));
consoleApiParams.response().setStatus(HttpStatusCodes.STATUS_CODE_OK);
}

@Override
protected void postHandler(User user) {
HttpServletRequest req = consoleApiParams.request();
Response response = consoleApiParams.response();
// User must have the proper permission on the registrar
if (!user.getUserRoles().hasPermission(registrarId, ConsolePermission.REGISTRY_LOCK)) {
setFailedResponse("", HttpStatusCodes.STATUS_CODE_FORBIDDEN);
return;
}

// Shouldn't happen, but double-check the registrar has registry lock enabled
Registrar registrar = Registrar.loadByRegistrarIdCached(registrarId).get();
if (!registrar.isRegistryLockAllowed()) {
setFailedResponse(
String.format("Registry lock not allowed for registrar %s", registrarId),
HttpStatusCodes.STATUS_CODE_BAD_REQUEST);
return;
}

// Retrieve and validate the necessary params
String domainName;
boolean isLock;
Optional<String> maybePassword;
Optional<Long> relockDurationMillis;

try {
domainName = extractRequiredParameter(req, "domainName");
isLock = extractBooleanParameter(req, "isLock");
maybePassword = extractOptionalParameter(req, "password");
relockDurationMillis = extractOptionalLongParameter(req, "relockDurationMillis");
DomainFlowUtils.validateDomainName(domainName);
} catch (HttpException.BadRequestException | EppException e) {
logger.atWarning().withCause(e).log("Bad request when attempting registry lock/unlock");
setFailedResponse(e.getMessage(), HttpStatusCodes.STATUS_CODE_BAD_REQUEST);
return;
}

// Passwords aren't required for admin users, otherwise we need to validate it
boolean isAdmin = user.getUserRoles().isAdmin();
if (!isAdmin) {
if (maybePassword.isEmpty()) {
setFailedResponse("No password provided", HttpStatusCodes.STATUS_CODE_BAD_REQUEST);
return;
}
if (!user.verifyRegistryLockPassword(maybePassword.get())) {
setFailedResponse(
"Incorrect registry lock password", HttpStatusCodes.STATUS_CODE_UNAUTHORIZED);
return;
}
}

String userEmail = user.getEmailAddress();
try {
tm().transact(
() -> {
RegistryLock registryLock =
isLock
? domainLockUtils.saveNewRegistryLockRequest(
domainName, registrarId, userEmail, isAdmin)
: domainLockUtils.saveNewRegistryUnlockRequest(
domainName,
registrarId,
isAdmin,
relockDurationMillis.map(Duration::new));
sendVerificationEmail(registryLock, userEmail, isLock);
});
} catch (IllegalArgumentException e) {
// Catch IllegalArgumentExceptions separately to give a nicer error message and code
logger.atWarning().withCause(e).log("Failed to lock/unlock domain");
setFailedResponse(e.getMessage(), HttpStatusCodes.STATUS_CODE_BAD_REQUEST);
return;
} catch (Throwable t) {
logger.atWarning().withCause(t).log("Failed to lock/unlock domain");
setFailedResponse("Internal server error", HttpStatusCodes.STATUS_CODE_SERVER_ERROR);
return;
}
response.setStatus(HttpStatusCodes.STATUS_CODE_OK);
}

private void sendVerificationEmail(RegistryLock lock, String userEmail, boolean isLock) {
try {
String url =
new URIBuilder()
.setScheme("https")
.setHost(consoleApiParams.request().getServerName())
// TODO: replace this with the PATH in ConsoleRegistryLockVerifyAction once it exists
.setPath("/console-api/registry-lock-verify")
.setParameter("lockVerificationCode", lock.getVerificationCode())
.setParameter("isLock", String.valueOf(isLock))
.build()
.toString();
String body = String.format(VERIFICATION_EMAIL_TEMPLATE, lock.getDomainName(), url);
ImmutableList<InternetAddress> recipients =
ImmutableList.of(new InternetAddress(userEmail, true));
String action = isLock ? "lock" : "unlock";
gmailClient.sendEmail(
EmailMessage.newBuilder()
.setBody(body)
.setSubject(String.format("Registry %s verification", action))
.setRecipients(recipients)
.build());
} catch (AddressException | URISyntaxException e) {
throw new RuntimeException(e); // caught above -- this is so we can run in a transaction
}
}

private ImmutableList<RegistryLock> getLockedDomains() {
return tm().transact(
() ->
RegistryLockDao.getLocksByRegistrarId(registrarId).stream()
.filter(lock -> !lock.isLockRequestExpired(tm().getTransactionTime()))
.collect(toImmutableList()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,17 +70,16 @@
auth = Auth.AUTH_PUBLIC_LOGGED_IN)
public class RegistryLockPostAction implements Runnable, JsonActionRunner.JsonAction {
public static final String PATH = "/registry-lock-post";

private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private static final Gson GSON = new Gson();

private static final String VERIFICATION_EMAIL_TEMPLATE =
public static final String VERIFICATION_EMAIL_TEMPLATE =
"""
Please click the link below to perform the lock / unlock action on domain %s. Note: this\
code will expire in one hour.

%s""";

private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private static final Gson GSON = new Gson();

private final HttpServletRequest req;
private final JsonActionRunner jsonActionRunner;
private final AuthResult authResult;
Expand Down
Loading
Loading