1
0
mirror of https://github.com/google/nomulus synced 2026-02-11 23:31:37 +00:00

Compare commits

...

3 Commits

Author SHA1 Message Date
Pavlo Tkach
e2e9d4cfc7 Add console history api (#2782) 2025-07-18 18:46:21 +00:00
gbrodman
2948dcc1be Add password reset request and verify console actions (#2775)
This works fairly similarly to the registry lock request and
verification mechanism. The request action generates a UUI which is
emailed (in link form) to the user in question. The frontend will send a
request to the verify action with the UUID and hopefully the action
should be finalized.

EPP password requests can be sent by anyone with edit-registrar
permissions and must be approved by an admin POC email.

Registry lock password resets can only be sent by primary contacts, and
are verified/performed by the user in question.
2025-07-17 21:33:29 +00:00
Pavlo Tkach
c5644d5c8b Add stream to the console dum download (#2783) 2025-07-16 18:56:20 +00:00
27 changed files with 1196 additions and 71 deletions

View File

@@ -16,6 +16,8 @@ package google.registry.model.console;
/** Permissions that users may have in the UI, either per-registrar or globally. */
public enum ConsolePermission {
AUDIT_ACTIVITY_BY_USER,
AUDIT_ACTIVITY_BY_REGISTRAR,
/** View basic information about a registrar. */
VIEW_REGISTRAR_DETAILS,
/** Edit basic information about a registrar. */

View File

@@ -55,6 +55,8 @@ public class ConsoleRoleDefinitions {
new ImmutableSet.Builder<ConsolePermission>()
.addAll(SUPPORT_AGENT_PERMISSIONS)
.add(
ConsolePermission.AUDIT_ACTIVITY_BY_USER,
ConsolePermission.AUDIT_ACTIVITY_BY_REGISTRAR,
ConsolePermission.MANAGE_REGISTRARS,
ConsolePermission.GET_REGISTRANT_EMAIL,
ConsolePermission.SUSPEND_DOMAIN,
@@ -111,6 +113,7 @@ public class ConsoleRoleDefinitions {
new ImmutableSet.Builder<ConsolePermission>()
.addAll(TECH_CONTACT_PERMISSIONS)
.add(ConsolePermission.MANAGE_USERS)
.add(ConsolePermission.AUDIT_ACTIVITY_BY_REGISTRAR)
.build();
private ConsoleRoleDefinitions() {}

View File

@@ -16,6 +16,7 @@ package google.registry.model.console;
import static google.registry.util.PreconditionsUtils.checkArgumentNotNull;
import com.google.gson.annotations.Expose;
import google.registry.model.Buildable;
import google.registry.model.ImmutableObject;
import google.registry.model.annotations.IdAllocation;
@@ -45,6 +46,7 @@ public class ConsoleUpdateHistory extends ImmutableObject implements Buildable {
@Id @IdAllocation @Column Long revisionId;
@Column(nullable = false)
@Expose
DateTime modificationTime;
/** The HTTP method (e.g. POST, PUT) used to make this modification. */
@@ -54,6 +56,7 @@ public class ConsoleUpdateHistory extends ImmutableObject implements Buildable {
/** The type of modification. */
@Column(nullable = false)
@Enumerated(EnumType.STRING)
@Expose
Type type;
/** The URL of the action that was used to make the modification. */
@@ -61,11 +64,12 @@ public class ConsoleUpdateHistory extends ImmutableObject implements Buildable {
String url;
/** An optional further description of the action. */
String description;
@Expose String description;
/** The user that performed the modification. */
@JoinColumn(name = "actingUser", referencedColumnName = "emailAddress", nullable = false)
@ManyToOne
@Expose
User actingUser;
public Long getRevisionId() {
@@ -102,18 +106,24 @@ public class ConsoleUpdateHistory extends ImmutableObject implements Buildable {
}
public enum Type {
DUM_DOWNLOAD,
DOMAIN_DELETE,
DOMAIN_SUSPEND,
DOMAIN_UNSUSPEND,
EPP_PASSWORD_UPDATE,
REGISTRAR_CREATE,
REGISTRAR_CONTACTS_UPDATE,
REGISTRAR_SECURITY_UPDATE,
REGISTRAR_UPDATE,
REGISTRY_LOCK,
REGISTRY_UNLOCK,
USER_CREATE,
USER_DELETE,
USER_UPDATE
USER_UPDATE,
}
public static final String DESCRIPTION_SEPARATOR = "|";
public static class Builder extends Buildable.Builder<ConsoleUpdateHistory> {
public Builder() {}

View File

@@ -115,6 +115,7 @@ import google.registry.ui.server.console.ConsoleDomainGetAction;
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.ConsoleHistoryDataAction;
import google.registry.ui.server.console.ConsoleModule;
import google.registry.ui.server.console.ConsoleOteAction;
import google.registry.ui.server.console.ConsoleRegistryLockAction;
@@ -122,6 +123,8 @@ import google.registry.ui.server.console.ConsoleRegistryLockVerifyAction;
import google.registry.ui.server.console.ConsoleUpdateRegistrarAction;
import google.registry.ui.server.console.ConsoleUserDataAction;
import google.registry.ui.server.console.ConsoleUsersAction;
import google.registry.ui.server.console.PasswordResetRequestAction;
import google.registry.ui.server.console.PasswordResetVerifyAction;
import google.registry.ui.server.console.RegistrarsAction;
import google.registry.ui.server.console.domains.ConsoleBulkDomainAction;
import google.registry.ui.server.console.settings.ContactAction;
@@ -183,6 +186,8 @@ interface RequestComponent {
ConsoleEppPasswordAction consoleEppPasswordAction();
ConsoleHistoryDataAction consoleHistoryDataAction();
ConsoleOteAction consoleOteAction();
ConsoleRegistryLockAction consoleRegistryLockAction();
@@ -249,6 +254,10 @@ interface RequestComponent {
NordnVerifyAction nordnVerifyAction();
PasswordResetRequestAction passwordResetRequestAction();
PasswordResetVerifyAction passwordResetVerifyAction();
PublishDnsUpdatesAction publishDnsUpdatesAction();
PublishInvoicesAction uploadInvoicesAction();
@@ -281,6 +290,8 @@ interface RequestComponent {
RdapNameserverSearchAction rdapNameserverSearchAction();
RdapRegistrarFieldsAction rdapRegistrarFieldsAction();
RdeReportAction rdeReportAction();
RdeReporter rdeReporter();
@@ -332,9 +343,7 @@ interface RequestComponent {
WhoisAction whoisAction();
WhoisHttpAction whoisHttpAction();
RdapRegistrarFieldsAction rdapRegistrarFieldsAction();
WipeOutContactHistoryPiiAction wipeOutContactHistoryPiiAction();
@Subcomponent.Builder

View File

@@ -38,6 +38,8 @@ import google.registry.ui.server.console.ConsoleRegistryLockVerifyAction;
import google.registry.ui.server.console.ConsoleUpdateRegistrarAction;
import google.registry.ui.server.console.ConsoleUserDataAction;
import google.registry.ui.server.console.ConsoleUsersAction;
import google.registry.ui.server.console.PasswordResetRequestAction;
import google.registry.ui.server.console.PasswordResetVerifyAction;
import google.registry.ui.server.console.RegistrarsAction;
import google.registry.ui.server.console.domains.ConsoleBulkDomainAction;
import google.registry.ui.server.console.settings.ContactAction;
@@ -84,6 +86,12 @@ public interface FrontendRequestComponent {
FlowComponent.Builder flowComponentBuilder();
PasswordResetRequestAction passwordResetRequestAction();
PasswordResetVerifyAction passwordResetVerifyAction();
RdapRegistrarFieldsAction rdapRegistrarFieldsAction();
ReadinessProbeActionFrontend readinessProbeActionFrontend();
ReadinessProbeConsoleAction readinessProbeConsoleAction();
@@ -92,8 +100,6 @@ public interface FrontendRequestComponent {
SecurityAction securityAction();
RdapRegistrarFieldsAction rdapRegistrarFieldsAction();
@Subcomponent.Builder
abstract class Builder implements RequestComponentBuilder<FrontendRequestComponent> {
@Override public abstract Builder requestModule(RequestModule requestModule);

View File

@@ -14,7 +14,6 @@
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 org.joda.time.DateTimeZone.UTC;
@@ -24,6 +23,7 @@ import com.google.common.flogger.FluentLogger;
import com.google.common.net.MediaType;
import google.registry.config.RegistryConfig.Config;
import google.registry.model.console.ConsolePermission;
import google.registry.model.console.ConsoleUpdateHistory;
import google.registry.model.console.User;
import google.registry.request.Action;
import google.registry.request.Action.GaeService;
@@ -34,7 +34,6 @@ import google.registry.util.Clock;
import jakarta.inject.Inject;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVPrinter;
import org.joda.time.DateTime;
@@ -100,27 +99,40 @@ public class ConsoleDumDownloadAction extends ConsoleApiAction {
consoleApiParams.response().setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
return;
}
tm().transact(
() -> {
finishAndPersistConsoleUpdateHistory(
new ConsoleUpdateHistory.Builder()
.setType(ConsoleUpdateHistory.Type.DUM_DOWNLOAD)
.setDescription(registrarId));
});
consoleApiParams.response().setStatus(HttpServletResponse.SC_OK);
}
private void writeCsv(CSVPrinter printer) throws IOException {
String sql = SQL_TEMPLATE.replaceAll(":now", clock.nowUtc().toString());
// We deliberately don't want to use ImmutableList.copyOf because underlying list may contain
// large amount of records and that will degrade performance.
List<String> queryResult =
tm().transact(
() ->
tm().getEntityManager()
.createNativeQuery(sql)
.setParameter("registrarId", registrarId)
.setHint("org.hibernate.fetchSize", 1000)
.getResultList());
ImmutableList<String[]> formattedRecords =
queryResult.stream().map(r -> r.split(",")).collect(toImmutableList());
printer.printRecord(
ImmutableList.of("Domain Name", "Creation Time", "Expiration Time", "Domain Statuses"));
printer.printRecords(formattedRecords);
tm().transact(
() -> {
try (var resultStream =
tm().getEntityManager()
.createNativeQuery(sql, String.class)
.setParameter("registrarId", registrarId)
.setHint("org.hibernate.fetchSize", 1000)
.getResultStream()) {
resultStream.forEach(
row -> {
try {
printer.printRecord((Object[]) ((String) row).split(","));
} catch (IOException e) {
throw new RuntimeException(e);
}
});
}
});
}
}

View File

@@ -0,0 +1,116 @@
// Copyright 2025 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.base.Preconditions.checkArgument;
import static google.registry.persistence.transaction.TransactionManagerFactory.tm;
import static google.registry.request.Action.Method.GET;
import static jakarta.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
import static jakarta.servlet.http.HttpServletResponse.SC_OK;
import com.google.common.base.Strings;
import google.registry.model.console.ConsolePermission;
import google.registry.model.console.ConsoleUpdateHistory;
import google.registry.model.console.User;
import google.registry.request.Action;
import google.registry.request.Action.GaeService;
import google.registry.request.Action.GkeService;
import google.registry.request.Parameter;
import google.registry.request.auth.Auth;
import jakarta.inject.Inject;
import java.util.List;
import java.util.Optional;
@Action(
service = GaeService.DEFAULT,
gkeService = GkeService.CONSOLE,
path = ConsoleHistoryDataAction.PATH,
method = {GET},
auth = Auth.AUTH_PUBLIC_LOGGED_IN)
public class ConsoleHistoryDataAction extends ConsoleApiAction {
private static final String SQL_USER_HISTORY =
"""
SELECT * FROM "ConsoleUpdateHistory"
WHERE acting_user = :actingUser
""";
private static final String SQL_REGISTRAR_HISTORY =
"""
SELECT *
FROM "ConsoleUpdateHistory"
WHERE SPLIT_PART(description, '|', 1) = :registrarId;
""";
public static final String PATH = "/console-api/history";
private final String registrarId;
private final Optional<String> consoleUserEmail;
@Inject
public ConsoleHistoryDataAction(
ConsoleApiParams consoleApiParams,
@Parameter("registrarId") String registrarId,
@Parameter("consoleUserEmail") Optional<String> consoleUserEmail) {
super(consoleApiParams);
this.registrarId = registrarId;
this.consoleUserEmail = consoleUserEmail;
}
@Override
protected void getHandler(User user) {
if (this.consoleUserEmail.isPresent()) {
this.historyByUser(user, this.consoleUserEmail.get());
return;
}
this.historyByRegistrarId(user, this.registrarId);
}
private void historyByUser(User user, String consoleUserEmail) {
if (!user.getUserRoles().hasGlobalPermission(ConsolePermission.AUDIT_ACTIVITY_BY_USER)) {
setFailedResponse(
"User doesn't have a permission to check audit activity by user", SC_BAD_REQUEST);
return;
}
List<ConsoleUpdateHistory> queryResult =
tm().transact(
() ->
tm().getEntityManager()
.createNativeQuery(SQL_USER_HISTORY, ConsoleUpdateHistory.class)
.setParameter("actingUser", consoleUserEmail)
.setHint("org.hibernate.fetchSize", 1000)
.getResultList());
consoleApiParams.response().setPayload(consoleApiParams.gson().toJson(queryResult));
consoleApiParams.response().setStatus(SC_OK);
}
private void historyByRegistrarId(User user, String registrarId) {
checkArgument(!Strings.isNullOrEmpty(registrarId), "Empty registrarId param");
checkPermission(user, registrarId, ConsolePermission.AUDIT_ACTIVITY_BY_REGISTRAR);
List<ConsoleUpdateHistory> queryResult =
tm().transact(
() ->
tm().getEntityManager()
.createNativeQuery(SQL_REGISTRAR_HISTORY, ConsoleUpdateHistory.class)
.setParameter("registrarId", registrarId)
.setHint("org.hibernate.fetchSize", 1000)
.getResultList());
consoleApiParams.response().setPayload(consoleApiParams.gson().toJson(queryResult));
consoleApiParams.response().setStatus(SC_OK);
}
}

View File

@@ -36,6 +36,7 @@ import google.registry.ui.server.console.ConsoleEppPasswordAction.EppPasswordDat
import google.registry.ui.server.console.ConsoleOteAction.OteCreateData;
import google.registry.ui.server.console.ConsoleRegistryLockAction.ConsoleRegistryLockPostInput;
import google.registry.ui.server.console.ConsoleUsersAction.UserData;
import google.registry.ui.server.console.PasswordResetRequestAction.PasswordResetRequestData;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Optional;
import org.joda.time.DateTime;
@@ -246,6 +247,12 @@ public final class ConsoleModule {
return extractRequiredParameter(req, "bulkDomainAction");
}
@Provides
@Parameter("resetRequestVerificationCode")
public static String provideResetRequestVerificationCode(HttpServletRequest req) {
return extractRequiredParameter(req, "resetRequestVerificationCode");
}
@Provides
@Parameter("eppPasswordChangeRequest")
public static Optional<EppPasswordData> provideEppPasswordChangeRequest(
@@ -273,4 +280,21 @@ public final class ConsoleModule {
Gson gson, @OptionalJsonPayload Optional<JsonElement> payload) {
return payload.map(e -> gson.fromJson(e, ConsoleRegistryLockPostInput.class));
}
@Provides
@Parameter("passwordResetRequestData")
public static PasswordResetRequestData providePasswordResetRequestData(
Gson gson, @OptionalJsonPayload Optional<JsonElement> payload) {
return payload
.map(e -> gson.fromJson(e, PasswordResetRequestData.class))
.orElseThrow(
() -> new IllegalArgumentException("Must provide password request reset data"));
}
@Provides
@Parameter("newPassword")
public static Optional<String> provideNewPassword(
Gson gson, @OptionalJsonPayload Optional<JsonElement> payload) {
return payload.map(e -> gson.fromJson(e, String.class));
}
}

View File

@@ -14,10 +14,12 @@
package google.registry.ui.server.console;
import static google.registry.persistence.transaction.TransactionManagerFactory.tm;
import static google.registry.request.Action.Method.GET;
import com.google.common.base.Ascii;
import com.google.gson.annotations.Expose;
import google.registry.model.console.ConsoleUpdateHistory;
import google.registry.model.console.User;
import google.registry.model.domain.RegistryLock;
import google.registry.request.Action;
@@ -64,6 +66,21 @@ public class ConsoleRegistryLockVerifyAction extends ConsoleApiAction {
RegistryLockVerificationResponse lockResponse =
new RegistryLockVerificationResponse(
Ascii.toLowerCase(action.toString()), lock.getDomainName(), lock.getRegistrarId());
tm().transact(
() -> {
finishAndPersistConsoleUpdateHistory(
new ConsoleUpdateHistory.Builder()
.setType(
action == RegistryLockAction.LOCKED
? ConsoleUpdateHistory.Type.REGISTRY_LOCK
: ConsoleUpdateHistory.Type.REGISTRY_UNLOCK)
.setDescription(
String.format(
"%s%s%s",
lock.getRegistrarId(),
ConsoleUpdateHistory.DESCRIPTION_SEPARATOR,
lockResponse)));
});
consoleApiParams.response().setPayload(consoleApiParams.gson().toJson(lockResponse));
consoleApiParams.response().setStatus(HttpServletResponse.SC_OK);
}

View File

@@ -35,6 +35,7 @@ import com.google.common.collect.ImmutableSet;
import com.google.gson.annotations.Expose;
import google.registry.config.RegistryConfig.Config;
import google.registry.model.console.ConsolePermission;
import google.registry.model.console.ConsoleUpdateHistory;
import google.registry.model.console.RegistrarRole;
import google.registry.model.console.User;
import google.registry.model.console.UserRoles;
@@ -177,6 +178,12 @@ public class ConsoleUsersAction extends ConsoleApiAction {
tm().delete(key);
User.revokeIapPermission(email, maybeGroupEmailAddress, cloudTasksUtils, null, iamClient);
sendConfirmationEmail(registrarId, email, "Deleted user");
finishAndPersistConsoleUpdateHistory(
new ConsoleUpdateHistory.Builder()
.setType(ConsoleUpdateHistory.Type.USER_DELETE)
.setDescription(
String.format(
"%s%s%s", registrarId, ConsoleUpdateHistory.DESCRIPTION_SEPARATOR, email)));
}
consoleApiParams.response().setStatus(SC_OK);
@@ -231,6 +238,12 @@ public class ConsoleUsersAction extends ConsoleApiAction {
consoleApiParams
.gson()
.toJson(new UserData(newEmail, ACCOUNT_MANAGER.toString(), newUser.getPassword())));
finishAndPersistConsoleUpdateHistory(
new ConsoleUpdateHistory.Builder()
.setType(ConsoleUpdateHistory.Type.USER_CREATE)
.setDescription(
String.format(
"%s%s%s", registrarId, ConsoleUpdateHistory.DESCRIPTION_SEPARATOR, newEmail)));
}
private void runUpdateInTransaction() {
@@ -245,6 +258,15 @@ public class ConsoleUsersAction extends ConsoleApiAction {
sendConfirmationEmail(registrarId, this.userData.get().emailAddress, "Updated user");
consoleApiParams.response().setStatus(SC_OK);
finishAndPersistConsoleUpdateHistory(
new ConsoleUpdateHistory.Builder()
.setType(ConsoleUpdateHistory.Type.USER_UPDATE)
.setDescription(
String.format(
"%s%s%s",
registrarId,
ConsoleUpdateHistory.DESCRIPTION_SEPARATOR,
this.userData.get().emailAddress)));
}
private boolean isModifyingRequestValid() {

View File

@@ -0,0 +1,150 @@
// Copyright 2025 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.base.Preconditions.checkArgument;
import static google.registry.persistence.transaction.TransactionManagerFactory.tm;
import com.google.gson.annotations.Expose;
import google.registry.model.console.ConsolePermission;
import google.registry.model.console.PasswordResetRequest;
import google.registry.model.console.User;
import google.registry.model.registrar.RegistrarPoc;
import google.registry.persistence.transaction.QueryComposer;
import google.registry.request.Action;
import google.registry.request.Parameter;
import google.registry.request.auth.Auth;
import google.registry.util.EmailMessage;
import jakarta.inject.Inject;
import jakarta.mail.internet.AddressException;
import jakarta.mail.internet.InternetAddress;
import jakarta.servlet.http.HttpServletResponse;
import javax.annotation.Nullable;
@Action(
service = Action.GaeService.DEFAULT,
gkeService = Action.GkeService.CONSOLE,
path = PasswordResetRequestAction.PATH,
method = Action.Method.POST,
auth = Auth.AUTH_PUBLIC_LOGGED_IN)
public class PasswordResetRequestAction extends ConsoleApiAction {
static final String PATH = "/console-api/password-reset-request";
static final String VERIFICATION_EMAIL_TEMPLATE =
"""
Please click the link below to perform the requested password reset. Note: this\
code will expire in one hour.
%s\
""";
private final PasswordResetRequestData passwordResetRequestData;
@Inject
public PasswordResetRequestAction(
ConsoleApiParams consoleApiParams,
@Parameter("passwordResetRequestData") PasswordResetRequestData passwordResetRequestData) {
super(consoleApiParams);
this.passwordResetRequestData = passwordResetRequestData;
}
@Override
protected void postHandler(User user) {
// Temporary flag when testing email sending etc
if (!user.getUserRoles().isAdmin()) {
setFailedResponse("", HttpServletResponse.SC_FORBIDDEN);
}
tm().transact(() -> performRequest(user));
consoleApiParams.response().setStatus(HttpServletResponse.SC_OK);
}
private void performRequest(User user) {
checkArgument(passwordResetRequestData.type != null, "Type cannot be null");
checkArgument(passwordResetRequestData.registrarId != null, "Registrar ID cannot be null");
PasswordResetRequest.Type type = passwordResetRequestData.type;
String registrarId = passwordResetRequestData.registrarId;
ConsolePermission requiredPermission;
String destinationEmail;
String emailSubject;
switch (type) {
case EPP:
requiredPermission = ConsolePermission.EDIT_REGISTRAR_DETAILS;
destinationEmail = getAdminPocEmail(registrarId);
emailSubject = "EPP password reset request";
break;
case REGISTRY_LOCK:
checkArgument(
passwordResetRequestData.registryLockEmail != null,
"Must provide registry lock email to reset");
requiredPermission = ConsolePermission.MANAGE_USERS;
destinationEmail = passwordResetRequestData.registryLockEmail;
checkUserExistsWithRegistryLockEmail(destinationEmail);
emailSubject = "Registry lock password reset request";
break;
default:
throw new IllegalArgumentException("Unknown type " + type);
}
checkPermission(user, registrarId, requiredPermission);
InternetAddress destinationAddress;
try {
destinationAddress = new InternetAddress(destinationEmail);
} catch (AddressException e) {
// Shouldn't happen
throw new RuntimeException(e);
}
PasswordResetRequest resetRequest =
new PasswordResetRequest.Builder()
.setRequester(user.getEmailAddress())
.setRegistrarId(registrarId)
.setType(type)
.setDestinationEmail(destinationEmail)
.build();
tm().put(resetRequest);
String verificationUrl =
String.format(
"https://%s/console/#/password-reset-verify?resetRequestVerificationCode=%s",
consoleApiParams.request().getServerName(), resetRequest.getVerificationCode());
String body = String.format(VERIFICATION_EMAIL_TEMPLATE, verificationUrl);
consoleApiParams
.sendEmailUtils()
.gmailClient
.sendEmail(EmailMessage.create(emailSubject, body, destinationAddress));
}
static User checkUserExistsWithRegistryLockEmail(String destinationEmail) {
return tm().createQueryComposer(User.class)
.where("registryLockEmailAddress", QueryComposer.Comparator.EQ, destinationEmail)
.first()
.orElseThrow(
() -> new IllegalArgumentException("Unknown user with lock email " + destinationEmail));
}
private String getAdminPocEmail(String registrarId) {
return RegistrarPoc.loadForRegistrar(registrarId).stream()
.filter(poc -> poc.getTypes().contains(RegistrarPoc.Type.ADMIN))
.map(RegistrarPoc::getEmailAddress)
.findAny()
.orElseThrow(() -> new IllegalStateException("No admin contacts found for " + registrarId));
}
public record PasswordResetRequestData(
@Expose PasswordResetRequest.Type type,
@Expose String registrarId,
@Expose @Nullable String registryLockEmail) {}
}

View File

@@ -0,0 +1,131 @@
// Copyright 2025 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.base.Preconditions.checkArgument;
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.ui.server.console.PasswordResetRequestAction.checkUserExistsWithRegistryLockEmail;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableMap;
import google.registry.model.console.ConsolePermission;
import google.registry.model.console.PasswordResetRequest;
import google.registry.model.console.User;
import google.registry.model.registrar.Registrar;
import google.registry.persistence.VKey;
import google.registry.request.Action;
import google.registry.request.Parameter;
import google.registry.request.auth.Auth;
import jakarta.inject.Inject;
import jakarta.servlet.http.HttpServletResponse;
import java.util.Optional;
import org.joda.time.Duration;
@Action(
service = Action.GaeService.DEFAULT,
gkeService = Action.GkeService.CONSOLE,
path = PasswordResetVerifyAction.PATH,
method = {GET, POST},
auth = Auth.AUTH_PUBLIC_LOGGED_IN)
public class PasswordResetVerifyAction extends ConsoleApiAction {
static final String PATH = "/console-api/password-reset-verify";
private final String verificationCode;
private final Optional<String> newPassword;
@Inject
public PasswordResetVerifyAction(
ConsoleApiParams consoleApiParams,
@Parameter("resetRequestVerificationCode") String verificationCode,
@Parameter("newPassword") Optional<String> newPassword) {
super(consoleApiParams);
this.verificationCode = verificationCode;
this.newPassword = newPassword;
}
@Override
protected void getHandler(User user) {
// Temporary flag when testing email sending etc
if (!user.getUserRoles().isAdmin()) {
setFailedResponse("", HttpServletResponse.SC_FORBIDDEN);
}
PasswordResetRequest request = tm().transact(() -> loadAndValidateResetRequest(user));
ImmutableMap<String, ?> result =
ImmutableMap.of("type", request.getType(), "registrarId", request.getRegistrarId());
consoleApiParams.response().setPayload(consoleApiParams.gson().toJson(result));
consoleApiParams.response().setStatus(HttpServletResponse.SC_OK);
}
@Override
protected void postHandler(User user) {
// Temporary flag when testing email sending etc
if (!user.getUserRoles().isAdmin()) {
setFailedResponse("", HttpServletResponse.SC_FORBIDDEN);
}
checkArgument(!Strings.isNullOrEmpty(newPassword.orElse(null)), "Password must be provided");
tm().transact(
() -> {
PasswordResetRequest request = loadAndValidateResetRequest(user);
switch (request.getType()) {
case EPP -> handleEppPasswordReset(request);
case REGISTRY_LOCK -> handleRegistryLockPasswordReset(request);
}
tm().put(request.asBuilder().setFulfillmentTime(tm().getTransactionTime()).build());
});
consoleApiParams.response().setStatus(HttpServletResponse.SC_OK);
}
private void handleEppPasswordReset(PasswordResetRequest request) {
Registrar registrar = Registrar.loadByRegistrarId(request.getRegistrarId()).get();
tm().put(registrar.asBuilder().setPassword(newPassword.get()).build());
}
private void handleRegistryLockPasswordReset(PasswordResetRequest request) {
User affectedUser = checkUserExistsWithRegistryLockEmail(request.getDestinationEmail());
tm().put(
affectedUser
.asBuilder()
.removeRegistryLockPassword()
.setRegistryLockPassword(newPassword.get())
.build());
}
private PasswordResetRequest loadAndValidateResetRequest(User user) {
PasswordResetRequest request =
tm().loadByKeyIfPresent(VKey.create(PasswordResetRequest.class, verificationCode))
.orElseThrow(this::createVerificationCodeException);
ConsolePermission requiredVerifyPermission =
switch (request.getType()) {
case EPP -> ConsolePermission.MANAGE_USERS;
case REGISTRY_LOCK -> ConsolePermission.REGISTRY_LOCK;
};
checkPermission(user, request.getRegistrarId(), requiredVerifyPermission);
if (request
.getRequestTime()
.plus(Duration.standardHours(1))
.isBefore(tm().getTransactionTime())) {
throw createVerificationCodeException();
}
return request;
}
private IllegalArgumentException createVerificationCodeException() {
return new IllegalArgumentException(
"Unknown, invalid, or expired verification code " + verificationCode);
}
}

View File

@@ -30,6 +30,7 @@ import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Multimap;
import com.google.common.flogger.FluentLogger;
import google.registry.model.console.ConsolePermission;
import google.registry.model.console.ConsoleUpdateHistory;
import google.registry.model.console.User;
import google.registry.model.registrar.Registrar;
import google.registry.model.registrar.RegistrarPoc;
@@ -84,6 +85,7 @@ public class ContactAction extends ConsoleApiAction {
protected void deleteHandler(User user) {
updateContacts(
user,
"Deleted " + contact.get().getEmailAddress(),
(registrar, oldContacts) ->
oldContacts.stream()
.filter(
@@ -96,6 +98,7 @@ public class ContactAction extends ConsoleApiAction {
protected void postHandler(User user) {
updateContacts(
user,
"Created " + contact.get().getEmailAddress(),
(registrar, oldContacts) -> {
RegistrarPoc newContact = contact.get();
return ImmutableSet.<RegistrarPoc>builder()
@@ -121,6 +124,7 @@ public class ContactAction extends ConsoleApiAction {
protected void putHandler(User user) {
updateContacts(
user,
"Updated " + contact.get().getEmailAddress(),
(registrar, oldContacts) -> {
RegistrarPoc updatedContact = contact.get();
return oldContacts.stream()
@@ -146,6 +150,7 @@ public class ContactAction extends ConsoleApiAction {
private void updateContacts(
User user,
String historyDescription,
BiFunction<Registrar, ImmutableSet<RegistrarPoc>, ImmutableSet<RegistrarPoc>>
contactsUpdater) {
checkPermission(user, registrarId, ConsolePermission.EDIT_REGISTRAR_DETAILS);
@@ -176,6 +181,15 @@ public class ContactAction extends ConsoleApiAction {
tm().put(updatedRegistrar);
sendExternalUpdatesIfNecessary(
EmailInfo.create(registrar, updatedRegistrar, oldContacts, newContacts));
finishAndPersistConsoleUpdateHistory(
new ConsoleUpdateHistory.Builder()
.setType(ConsoleUpdateHistory.Type.REGISTRAR_CONTACTS_UPDATE)
.setDescription(
String.format(
"%s%s%s",
registrarId,
ConsoleUpdateHistory.DESCRIPTION_SEPARATOR,
historyDescription)));
});
consoleApiParams.response().setStatus(SC_OK);
}

View File

@@ -35,6 +35,7 @@ import google.registry.ui.server.console.ConsoleApiAction;
import google.registry.ui.server.console.ConsoleApiParams;
import jakarta.inject.Inject;
import java.util.Optional;
import java.util.StringJoiner;
/**
* Console action for editing fields on a registrar that are visible in WHOIS/RDAP.
@@ -82,19 +83,37 @@ public class RdapRegistrarFieldsAction extends ConsoleApiAction {
return;
}
Registrar newRegistrar =
savedRegistrar
.asBuilder()
.setLocalizedAddress(providedRegistrar.getLocalizedAddress())
.setPhoneNumber(providedRegistrar.getPhoneNumber())
.setFaxNumber(providedRegistrar.getFaxNumber())
.setEmailAddress(providedRegistrar.getEmailAddress())
.build();
StringJoiner updates = new StringJoiner(",");
var newRegistrarBuilder = savedRegistrar.asBuilder();
if (!providedRegistrar.getLocalizedAddress().equals(savedRegistrar.getLocalizedAddress())) {
newRegistrarBuilder.setLocalizedAddress(providedRegistrar.getLocalizedAddress());
updates.add("ADDRESS");
}
if (!providedRegistrar.getPhoneNumber().equals(savedRegistrar.getPhoneNumber())) {
newRegistrarBuilder.setPhoneNumber(providedRegistrar.getPhoneNumber());
updates.add("PHONE");
}
if (!providedRegistrar.getFaxNumber().equals(savedRegistrar.getPhoneNumber())) {
newRegistrarBuilder.setFaxNumber(providedRegistrar.getFaxNumber());
updates.add("FAX");
}
if (!providedRegistrar.getEmailAddress().equals(savedRegistrar.getEmailAddress())) {
newRegistrarBuilder.setEmailAddress(providedRegistrar.getEmailAddress());
updates.add("EMAIL");
}
var newRegistrar = newRegistrarBuilder.build();
tm().put(newRegistrar);
finishAndPersistConsoleUpdateHistory(
new ConsoleUpdateHistory.Builder()
.setType(ConsoleUpdateHistory.Type.REGISTRAR_UPDATE)
.setDescription(newRegistrar.getRegistrarId()));
.setDescription(
String.format(
"%s%s%s",
newRegistrar.getRegistrarId(),
ConsoleUpdateHistory.DESCRIPTION_SEPARATOR,
updates)));
sendExternalUpdatesIfNecessary(
EmailInfo.create(
savedRegistrar,

View File

@@ -39,6 +39,7 @@ import google.registry.ui.server.console.ConsoleApiAction;
import google.registry.ui.server.console.ConsoleApiParams;
import jakarta.inject.Inject;
import java.util.Optional;
import java.util.StringJoiner;
@Action(
service = GaeService.DEFAULT,
@@ -86,10 +87,15 @@ public class SecurityAction extends ConsoleApiAction {
private void setResponse(Registrar savedRegistrar) {
Registrar registrarParameter = registrar.get();
Registrar.Builder updatedRegistrarBuilder =
savedRegistrar
.asBuilder()
.setIpAddressAllowList(registrarParameter.getIpAddressAllowList());
Registrar.Builder updatedRegistrarBuilder = savedRegistrar.asBuilder();
StringJoiner updates = new StringJoiner(",");
if (!savedRegistrar
.getIpAddressAllowList()
.equals(registrarParameter.getIpAddressAllowList())) {
updatedRegistrarBuilder.setIpAddressAllowList(registrarParameter.getIpAddressAllowList());
updates.add("IP_CHANGE");
}
try {
if (!savedRegistrar
@@ -99,6 +105,7 @@ public class SecurityAction extends ConsoleApiAction {
String newClientCert = registrarParameter.getClientCertificate().get();
certificateChecker.validateCertificate(newClientCert);
updatedRegistrarBuilder.setClientCertificate(newClientCert, tm().getTransactionTime());
updates.add("PRIMARY_SSL_CERT_CHANGE");
}
}
if (!savedRegistrar
@@ -109,6 +116,7 @@ public class SecurityAction extends ConsoleApiAction {
certificateChecker.validateCertificate(newFailoverCert);
updatedRegistrarBuilder.setFailoverClientCertificate(
newFailoverCert, tm().getTransactionTime());
updates.add("FAILOVER_SSL_CERT_CHANGE");
}
}
} catch (InsecureCertificateException e) {
@@ -121,7 +129,9 @@ public class SecurityAction extends ConsoleApiAction {
finishAndPersistConsoleUpdateHistory(
new ConsoleUpdateHistory.Builder()
.setType(ConsoleUpdateHistory.Type.REGISTRAR_SECURITY_UPDATE)
.setDescription(registrarId));
.setDescription(
String.format(
"%s%s%s", registrarId, ConsoleUpdateHistory.DESCRIPTION_SEPARATOR, updates)));
sendExternalUpdatesIfNecessary(
EmailInfo.create(savedRegistrar, updatedRegistrar, ImmutableSet.of(), ImmutableSet.of()));

View File

@@ -29,6 +29,7 @@ import google.registry.testing.TestDataHelper;
import java.util.HashSet;
import java.util.Set;
import java.util.stream.Collectors;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
/** Unit tests for {@link RequestComponent}. */
@@ -49,6 +50,7 @@ public class RequestComponentTest {
}
@Test
@Disabled("To be removed with GAE components")
void testGaeToJettyRoutingCoverage() {
Set<Route> jettyRoutes = getRoutes(RequestComponent.class, "routing.txt");
Set<Route> gaeRoutes = new HashSet<>();

View File

@@ -1042,6 +1042,8 @@ public final class DatabaseHelper {
.setGlobalRole(GlobalRole.FTE)
.setIsAdmin(true)
.build())
.setRegistryLockEmailAddress("registrylock" + emailAddress)
.setRegistryLockPassword("password")
.build();
tm().put(user);
return user;

View File

@@ -0,0 +1,153 @@
// Copyright 2025 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 com.google.common.truth.Truth.assertThat;
import static jakarta.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
import static jakarta.servlet.http.HttpServletResponse.SC_FORBIDDEN;
import static jakarta.servlet.http.HttpServletResponse.SC_OK;
import static org.mockito.Mockito.when;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.gson.Gson;
import google.registry.model.console.ConsoleUpdateHistory;
import google.registry.model.console.RegistrarRole;
import google.registry.model.console.User;
import google.registry.model.console.UserRoles;
import google.registry.request.auth.AuthResult;
import google.registry.testing.ConsoleApiParamsUtils;
import google.registry.testing.DatabaseHelper;
import google.registry.testing.FakeResponse;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class ConsoleHistoryDataActionTest extends ConsoleActionBaseTestCase {
private static final Gson GSON = new Gson();
private User noPermissionUser;
@BeforeEach
void beforeEach() {
noPermissionUser =
DatabaseHelper.persistResource(
new User.Builder()
.setEmailAddress("no.perms@example.com")
.setUserRoles(
new UserRoles.Builder()
.setRegistrarRoles(
ImmutableMap.of("TheRegistrar", RegistrarRole.ACCOUNT_MANAGER))
.build())
.build());
DatabaseHelper.persistResources(
ImmutableList.of(
new ConsoleUpdateHistory.Builder()
.setType(ConsoleUpdateHistory.Type.REGISTRAR_UPDATE)
.setActingUser(fteUser)
.setDescription("TheRegistrar|Some change")
.setModificationTime(clock.nowUtc())
.setUrl("/test")
.setMethod("POST")
.build(),
new ConsoleUpdateHistory.Builder()
.setType(ConsoleUpdateHistory.Type.REGISTRAR_UPDATE)
.setActingUser(noPermissionUser)
.setDescription("TheRegistrar|Another change")
.setModificationTime(clock.nowUtc())
.setUrl("/test")
.setMethod("POST")
.build(),
new ConsoleUpdateHistory.Builder()
.setType(ConsoleUpdateHistory.Type.REGISTRAR_UPDATE)
.setActingUser(fteUser)
.setDescription("OtherRegistrar|Some change")
.setModificationTime(clock.nowUtc())
.setUrl("/test")
.setMethod("POST")
.build()));
}
@Test
void testSuccess_getByRegistrar() {
ConsoleHistoryDataAction action =
createAction(AuthResult.createUser(fteUser), "TheRegistrar", Optional.empty());
action.run();
assertThat(response.getStatus()).isEqualTo(SC_OK);
List<Map<String, Object>> payload = GSON.fromJson(response.getPayload(), List.class);
assertThat(payload.stream().map(record -> record.get("description")).collect(toImmutableList()))
.containsExactly("TheRegistrar|Some change", "TheRegistrar|Another change");
}
@Test
void testSuccess_getByUser() {
ConsoleHistoryDataAction action =
createAction(AuthResult.createUser(fteUser), "TheRegistrar", Optional.of("fte@email.tld"));
action.run();
assertThat(response.getStatus()).isEqualTo(SC_OK);
List<Map<String, Object>> payload = GSON.fromJson(response.getPayload(), List.class);
assertThat(payload.stream().map(record -> record.get("description")).collect(toImmutableList()))
.containsExactly("TheRegistrar|Some change", "OtherRegistrar|Some change");
}
@Test
void testSuccess_noResults() {
ConsoleHistoryDataAction action =
createAction(AuthResult.createUser(fteUser), "NoHistoryRegistrar", Optional.empty());
action.run();
assertThat(response.getStatus()).isEqualTo(SC_OK);
assertThat(response.getPayload()).isEqualTo("[]");
}
@Test
void testFailure_getByRegistrar_noPermission() {
ConsoleHistoryDataAction action =
createAction(AuthResult.createUser(noPermissionUser), "TheRegistrar", Optional.empty());
action.run();
assertThat(response.getStatus()).isEqualTo(SC_FORBIDDEN);
}
@Test
void testFailure_getByUser_noPermission() {
ConsoleHistoryDataAction action =
createAction(
AuthResult.createUser(noPermissionUser), "TheRegistrar", Optional.of("fte@email.tld"));
action.run();
assertThat(response.getStatus()).isEqualTo(SC_BAD_REQUEST);
assertThat(response.getPayload())
.contains("User doesn't have a permission to check audit activity by user");
}
@Test
void testFailure_emptyRegistrarId() {
ConsoleHistoryDataAction action =
createAction(AuthResult.createUser(fteUser), "", Optional.empty());
action.run();
assertThat(response.getStatus()).isEqualTo(SC_BAD_REQUEST);
assertThat(response.getPayload()).contains("Empty registrarId param");
}
private ConsoleHistoryDataAction createAction(
AuthResult authResult, String registrarId, Optional<String> consoleUserEmail) {
consoleApiParams = ConsoleApiParamsUtils.createFake(authResult);
when(consoleApiParams.request().getMethod()).thenReturn("GET");
response = (FakeResponse) consoleApiParams.response();
return new ConsoleHistoryDataAction(consoleApiParams, registrarId, consoleUserEmail);
}
}

View File

@@ -56,16 +56,17 @@ public class ConsoleRegistryLockVerifyActionTest extends ConsoleActionBaseTestCa
createTld("test");
defaultDomain = persistActiveDomain("example.test");
user =
new User.Builder()
.setEmailAddress("user@theregistrar.com")
.setRegistryLockEmailAddress("registrylock@theregistrar.com")
.setUserRoles(
new UserRoles.Builder()
.setRegistrarRoles(
ImmutableMap.of("TheRegistrar", RegistrarRole.PRIMARY_CONTACT))
.build())
.setRegistryLockPassword("registryLockPassword")
.build();
persistResource(
new User.Builder()
.setEmailAddress("user@theregistrar.com")
.setRegistryLockEmailAddress("registrylock@theregistrar.com")
.setUserRoles(
new UserRoles.Builder()
.setRegistrarRoles(
ImmutableMap.of("TheRegistrar", RegistrarRole.PRIMARY_CONTACT))
.build())
.setRegistryLockPassword("registryLockPassword")
.build());
action = createAction(DEFAULT_CODE);
}

View File

@@ -215,7 +215,7 @@ class ConsoleUpdateRegistrarActionTest extends ConsoleActionBaseTestCase {
return ConsoleApiParamsUtils.createFake(authResult);
}
ConsoleUpdateRegistrarAction createAction(String requestData) throws IOException {
private ConsoleUpdateRegistrarAction createAction(String requestData) throws IOException {
when(consoleApiParams.request().getMethod()).thenReturn(Action.Method.POST.toString());
doReturn(new BufferedReader(new StringReader(requestData)))
.when(consoleApiParams.request())

View File

@@ -0,0 +1,218 @@
// Copyright 2025 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.truth.Truth.assertThat;
import static google.registry.model.ImmutableObjectSubject.assertAboutImmutableObjects;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.google.common.collect.ImmutableMap;
import google.registry.model.console.PasswordResetRequest;
import google.registry.model.console.RegistrarRole;
import google.registry.model.console.User;
import google.registry.model.console.UserRoles;
import google.registry.request.Action;
import google.registry.request.auth.AuthResult;
import google.registry.testing.ConsoleApiParamsUtils;
import google.registry.testing.DatabaseHelper;
import google.registry.testing.FakeResponse;
import google.registry.ui.server.console.PasswordResetRequestAction.PasswordResetRequestData;
import google.registry.util.EmailMessage;
import jakarta.mail.internet.InternetAddress;
import jakarta.servlet.http.HttpServletResponse;
import javax.annotation.Nullable;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
/** Tests for {@link PasswordResetRequestAction}. */
public class PasswordResetRequestActionTest extends ConsoleActionBaseTestCase {
@Test
void testSuccess_epp() throws Exception {
PasswordResetRequestAction action =
createAction(PasswordResetRequest.Type.EPP, "TheRegistrar", null);
action.run();
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
PasswordResetRequest actualRequest =
DatabaseHelper.loadSingleton(PasswordResetRequest.class).get();
assertAboutImmutableObjects()
.that(actualRequest)
.isEqualExceptFields(
new PasswordResetRequest.Builder()
.setDestinationEmail("johndoe@theregistrar.com")
.setRequester("fte@email.tld")
.setType(PasswordResetRequest.Type.EPP)
.setRegistrarId("TheRegistrar")
.build(),
"requestTime",
"verificationCode");
EmailMessage expectedMessage =
EmailMessage.create(
"EPP password reset request",
"""
Please click the link below to perform the requested password reset. Note: this\
code will expire in one hour.
https://registrarconsole.tld/console/#/password-reset-verify?resetRequestVerificationCode=\
"""
+ actualRequest.getVerificationCode(),
new InternetAddress("johndoe@theregistrar.com"));
verify(consoleApiParams.sendEmailUtils().gmailClient).sendEmail(expectedMessage);
}
@Test
void testSuccess_registryLock() throws Exception {
DatabaseHelper.persistResource(
new User.Builder()
.setEmailAddress("email@registry.tld")
.setUserRoles(
new UserRoles.Builder()
.setRegistrarRoles(
ImmutableMap.of(
"TheRegistrar", RegistrarRole.ACCOUNT_MANAGER_WITH_REGISTRY_LOCK))
.build())
.setRegistryLockEmailAddress("registrylock@theregistrar.com")
.setRegistryLockPassword("password")
.build());
PasswordResetRequestAction action =
createAction(
PasswordResetRequest.Type.REGISTRY_LOCK,
"TheRegistrar",
"registrylock@theregistrar.com");
action.run();
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
PasswordResetRequest actualRequest =
DatabaseHelper.loadSingleton(PasswordResetRequest.class).get();
assertAboutImmutableObjects()
.that(actualRequest)
.isEqualExceptFields(
new PasswordResetRequest.Builder()
.setDestinationEmail("registrylock@theregistrar.com")
.setRequester("fte@email.tld")
.setType(PasswordResetRequest.Type.REGISTRY_LOCK)
.setRegistrarId("TheRegistrar")
.build(),
"requestTime",
"verificationCode");
EmailMessage expectedMessage =
EmailMessage.create(
"Registry lock password reset request",
"""
Please click the link below to perform the requested password reset. Note: this\
code will expire in one hour.
https://registrarconsole.tld/console/#/password-reset-verify?resetRequestVerificationCode=\
"""
+ actualRequest.getVerificationCode(),
new InternetAddress("registrylock@theregistrar.com"));
verify(consoleApiParams.sendEmailUtils().gmailClient).sendEmail(expectedMessage);
}
@Test
void testFailure_nullType() throws Exception {
PasswordResetRequestAction action = createAction(null, "TheRegistrar", "email@email.test");
action.run();
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_BAD_REQUEST);
assertThat(response.getPayload()).isEqualTo("Type cannot be null");
}
@Test
void testFailure_nullRegistrarId() throws Exception {
PasswordResetRequestAction action =
createAction(PasswordResetRequest.Type.EPP, null, "email@email.test");
action.run();
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_BAD_REQUEST);
assertThat(response.getPayload()).isEqualTo("Registrar ID cannot be null");
}
@Test
void testFailure_registryLock_nullEmail() throws Exception {
PasswordResetRequestAction action =
createAction(PasswordResetRequest.Type.REGISTRY_LOCK, "TheRegistrar", null);
action.run();
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_BAD_REQUEST);
assertThat(response.getPayload()).isEqualTo("Must provide registry lock email to reset");
}
@Test
void testFailure_registryLock_invalidEmail() throws Exception {
PasswordResetRequestAction action =
createAction(
PasswordResetRequest.Type.REGISTRY_LOCK, "TheRegistrar", "nonexistent@email.com");
action.run();
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_BAD_REQUEST);
assertThat(response.getPayload())
.isEqualTo("Unknown user with lock email nonexistent@email.com");
}
@Test
@Disabled("Enable when testing is done in sandbox and isAdmin check is removed")
void testFailure_epp_noPermission() throws Exception {
User user =
new User.Builder()
.setEmailAddress("email@email.test")
.setUserRoles(
new UserRoles.Builder()
.setRegistrarRoles(
ImmutableMap.of("TheRegistrar", RegistrarRole.ACCOUNT_MANAGER))
.build())
.build();
PasswordResetRequestAction action =
createAction(user, PasswordResetRequest.Type.EPP, "TheRegistrar", null);
action.run();
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_FORBIDDEN);
}
@Test
@Disabled("Enable when testing is done in sandbox and isAdmin check is removed")
void testFailure_lock_noPermission() throws Exception {
User user =
new User.Builder()
.setEmailAddress("email@email.test")
.setUserRoles(
new UserRoles.Builder()
.setRegistrarRoles(ImmutableMap.of("TheRegistrar", RegistrarRole.TECH_CONTACT))
.build())
.build();
PasswordResetRequestAction action =
createAction(
user,
PasswordResetRequest.Type.REGISTRY_LOCK,
"TheRegistrar",
"registrylockfte@email.tld");
action.run();
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_FORBIDDEN);
}
private PasswordResetRequestAction createAction(
User user,
PasswordResetRequest.Type type,
String registrarId,
@Nullable String registryLockEmail) {
consoleApiParams = ConsoleApiParamsUtils.createFake(AuthResult.createUser(user));
return createAction(type, registrarId, registryLockEmail);
}
private PasswordResetRequestAction createAction(
PasswordResetRequest.Type type, String registrarId, @Nullable String registryLockEmail) {
when(consoleApiParams.request().getMethod()).thenReturn(Action.Method.POST.toString());
when(consoleApiParams.request().getServerName()).thenReturn("registrarconsole.tld");
response = (FakeResponse) consoleApiParams.response();
PasswordResetRequestData data =
new PasswordResetRequestData(type, registrarId, registryLockEmail);
return new PasswordResetRequestAction(consoleApiParams, data);
}
}

View File

@@ -0,0 +1,199 @@
// Copyright 2025 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.truth.Truth.assertThat;
import static google.registry.testing.DatabaseHelper.loadByEntity;
import static google.registry.testing.DatabaseHelper.persistResource;
import static org.mockito.Mockito.when;
import com.google.common.collect.ImmutableMap;
import google.registry.model.console.PasswordResetRequest;
import google.registry.model.console.RegistrarRole;
import google.registry.model.console.User;
import google.registry.model.console.UserRoles;
import google.registry.model.registrar.Registrar;
import google.registry.request.auth.AuthResult;
import google.registry.testing.ConsoleApiParamsUtils;
import google.registry.testing.FakeResponse;
import jakarta.servlet.http.HttpServletResponse;
import java.util.Map;
import java.util.Optional;
import javax.annotation.Nullable;
import org.joda.time.Duration;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
/** Tests for {@link PasswordResetVerifyAction}. */
public class PasswordResetVerifyActionTest extends ConsoleActionBaseTestCase {
private String verificationCode;
@BeforeEach
void beforeEach() {
verificationCode = saveRequest(PasswordResetRequest.Type.EPP).getVerificationCode();
}
@Test
void testSuccess_get_epp() throws Exception {
createAction("GET", verificationCode, null).run();
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
assertThat(GSON.fromJson(response.getPayload(), Map.class))
.isEqualTo(ImmutableMap.of("registrarId", "TheRegistrar", "type", "EPP"));
}
@Test
void testSuccess_get_lock() throws Exception {
verificationCode = saveRequest(PasswordResetRequest.Type.REGISTRY_LOCK).getVerificationCode();
createAction("GET", verificationCode, null).run();
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
assertThat(GSON.fromJson(response.getPayload(), Map.class))
.isEqualTo(ImmutableMap.of("registrarId", "TheRegistrar", "type", "REGISTRY_LOCK"));
}
@Test
void testSuccess_post_epp() throws Exception {
assertThat(Registrar.loadByRegistrarId("TheRegistrar").get().verifyPassword("password2"))
.isTrue();
createAction("POST", verificationCode, "newEppPassword").run();
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
assertThat(Registrar.loadByRegistrarId("TheRegistrar").get().verifyPassword("password2"))
.isFalse();
assertThat(Registrar.loadByRegistrarId("TheRegistrar").get().verifyPassword("newEppPassword"))
.isTrue();
}
@Test
void testSuccess_post_lock() throws Exception {
assertThat(loadByEntity(fteUser).verifyRegistryLockPassword("password")).isTrue();
verificationCode = saveRequest(PasswordResetRequest.Type.REGISTRY_LOCK).getVerificationCode();
createAction("POST", verificationCode, "newRegistryLockPassword").run();
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
assertThat(loadByEntity(fteUser).verifyRegistryLockPassword("newRegistryLockPassword"))
.isTrue();
}
@Test
void testFailure_get_invalidVerificationCode() throws Exception {
createAction("GET", "invalid", null).run();
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_BAD_REQUEST);
}
@Test
void testFailure_post_invalidVerificationCode() throws Exception {
createAction("POST", "invalid", "newPassword").run();
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_BAD_REQUEST);
}
@Test
void testFailure_nullPassword() throws Exception {
createAction("POST", verificationCode, null).run();
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_BAD_REQUEST);
}
@Test
void testFailure_emptyPassword() throws Exception {
createAction("POST", verificationCode, "").run();
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_BAD_REQUEST);
}
@Test
@Disabled("Enable when testing is done in sandbox and isAdmin check is removed")
void testFailure_get_epp_badPermission() throws Exception {
createAction(createTechUser(), "GET", verificationCode, null).run();
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_FORBIDDEN);
}
@Test
@Disabled("Enable when testing is done in sandbox and isAdmin check is removed")
void testFailure_get_lock_badPermission() throws Exception {
createAction(createAccountManager(), "GET", verificationCode, null).run();
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_FORBIDDEN);
}
@Test
@Disabled("Enable when testing is done in sandbox and isAdmin check is removed")
void testFailure_post_epp_badPermission() throws Exception {
createAction(createTechUser(), "POST", verificationCode, "newPassword").run();
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_FORBIDDEN);
}
@Test
@Disabled("Enable when testing is done in sandbox and isAdmin check is removed")
void testFailure_post_lock_badPermission() throws Exception {
createAction(createAccountManager(), "POST", verificationCode, "newPassword").run();
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_FORBIDDEN);
}
@Test
void testFailure_get_expired() throws Exception {
clock.advanceBy(Duration.standardDays(1));
createAction("GET", verificationCode, null).run();
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_BAD_REQUEST);
}
@Test
void testFailure_post_expired() throws Exception {
clock.advanceBy(Duration.standardDays(1));
createAction("POST", verificationCode, "newPassword").run();
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_BAD_REQUEST);
}
private User createTechUser() {
return new User.Builder()
.setEmailAddress("tech@example.tld")
.setUserRoles(
new UserRoles.Builder()
.setRegistrarRoles(ImmutableMap.of("TheRegistrar", RegistrarRole.TECH_CONTACT))
.build())
.build();
}
private User createAccountManager() {
return new User.Builder()
.setEmailAddress("accountmanager@example.tld")
.setUserRoles(
new UserRoles.Builder()
.setRegistrarRoles(ImmutableMap.of("TheRegistrar", RegistrarRole.ACCOUNT_MANAGER))
.build())
.build();
}
private PasswordResetRequest saveRequest(PasswordResetRequest.Type type) {
return persistResource(
new PasswordResetRequest.Builder()
// use the built-in user registry lock email
.setDestinationEmail("registrylockfte@email.tld")
.setRequester("requester@email.tld")
.setRegistrarId("TheRegistrar")
.setType(type)
.build());
}
private PasswordResetVerifyAction createAction(
User user, String method, String verificationCode, @Nullable String newPassword) {
consoleApiParams = ConsoleApiParamsUtils.createFake(AuthResult.createUser(user));
return createAction(method, verificationCode, newPassword);
}
private PasswordResetVerifyAction createAction(
String method, String verificationCode, @Nullable String newPassword) {
when(consoleApiParams.request().getMethod()).thenReturn(method);
response = (FakeResponse) consoleApiParams.response();
return new PasswordResetVerifyAction(
consoleApiParams, verificationCode, Optional.ofNullable(newPassword));
}
}

View File

@@ -112,7 +112,7 @@ public class RdapRegistrarFieldsActionTest extends ConsoleActionBaseTestCase {
.isEqualExceptFields(oldRegistrar, "localizedAddress", "phoneNumber", "faxNumber");
ConsoleUpdateHistory history = loadSingleton(ConsoleUpdateHistory.class).get();
assertThat(history.getType()).isEqualTo(ConsoleUpdateHistory.Type.REGISTRAR_UPDATE);
assertThat(history.getDescription()).hasValue("TheRegistrar");
assertThat(history.getDescription()).hasValue("TheRegistrar|ADDRESS,PHONE,FAX");
}
@Test

View File

@@ -87,7 +87,7 @@ class SecurityActionTest extends ConsoleActionBaseTestCase {
assertThat(r.getIpAddressAllowList().get(0).getNetmask()).isEqualTo(32);
ConsoleUpdateHistory history = loadSingleton(ConsoleUpdateHistory.class).get();
assertThat(history.getType()).isEqualTo(ConsoleUpdateHistory.Type.REGISTRAR_SECURITY_UPDATE);
assertThat(history.getDescription()).hasValue("registrarId");
assertThat(history.getDescription()).hasValue("registrarId|IP_CHANGE,PRIMARY_SSL_CERT_CHANGE");
}
private SecurityAction createAction(String registrarId) throws IOException {

View File

@@ -1,19 +1,21 @@
SERVICE PATH CLASS METHODS OK MIN USER_POLICY
FRONTEND /_dr/epp EppTlsAction POST n APP ADMIN
FRONTEND /ready/frontend ReadinessProbeActionFrontend GET n NONE PUBLIC
CONSOLE /console-api/bulk-domain ConsoleBulkDomainAction POST n USER PUBLIC
CONSOLE /console-api/domain ConsoleDomainGetAction GET n USER PUBLIC
CONSOLE /console-api/domain-list ConsoleDomainListAction GET n USER PUBLIC
CONSOLE /console-api/dum-download ConsoleDumDownloadAction GET n USER PUBLIC
CONSOLE /console-api/eppPassword ConsoleEppPasswordAction POST n USER PUBLIC
CONSOLE /console-api/ote ConsoleOteAction GET,POST n USER PUBLIC
CONSOLE /console-api/registrar ConsoleUpdateRegistrarAction POST n USER PUBLIC
CONSOLE /console-api/registrars RegistrarsAction GET,POST n USER PUBLIC
CONSOLE /console-api/registry-lock ConsoleRegistryLockAction GET,POST n USER PUBLIC
CONSOLE /console-api/registry-lock-verify ConsoleRegistryLockVerifyAction GET n USER PUBLIC
CONSOLE /console-api/settings/contacts ContactAction GET,POST,DELETE,PUT n USER PUBLIC
CONSOLE /console-api/settings/rdap-fields RdapRegistrarFieldsAction POST n USER PUBLIC
CONSOLE /console-api/settings/security SecurityAction POST n USER PUBLIC
CONSOLE /console-api/userdata ConsoleUserDataAction GET n USER PUBLIC
CONSOLE /console-api/users ConsoleUsersAction GET,POST,DELETE,PUT n USER PUBLIC
CONSOLE /ready/console ReadinessProbeConsoleAction GET n NONE PUBLIC
SERVICE PATH CLASS METHODS OK MIN USER_POLICY
FRONTEND /_dr/epp EppTlsAction POST n APP ADMIN
FRONTEND /ready/frontend ReadinessProbeActionFrontend GET n NONE PUBLIC
CONSOLE /console-api/bulk-domain ConsoleBulkDomainAction POST n USER PUBLIC
CONSOLE /console-api/domain ConsoleDomainGetAction GET n USER PUBLIC
CONSOLE /console-api/domain-list ConsoleDomainListAction GET n USER PUBLIC
CONSOLE /console-api/dum-download ConsoleDumDownloadAction GET n USER PUBLIC
CONSOLE /console-api/eppPassword ConsoleEppPasswordAction POST n USER PUBLIC
CONSOLE /console-api/ote ConsoleOteAction GET,POST n USER PUBLIC
CONSOLE /console-api/password-reset-request PasswordResetRequestAction POST n USER PUBLIC
CONSOLE /console-api/password-reset-verify PasswordResetVerifyAction GET,POST n USER PUBLIC
CONSOLE /console-api/registrar ConsoleUpdateRegistrarAction POST n USER PUBLIC
CONSOLE /console-api/registrars RegistrarsAction GET,POST n USER PUBLIC
CONSOLE /console-api/registry-lock ConsoleRegistryLockAction GET,POST n USER PUBLIC
CONSOLE /console-api/registry-lock-verify ConsoleRegistryLockVerifyAction GET n USER PUBLIC
CONSOLE /console-api/settings/contacts ContactAction GET,POST,DELETE,PUT n USER PUBLIC
CONSOLE /console-api/settings/rdap-fields RdapRegistrarFieldsAction POST n USER PUBLIC
CONSOLE /console-api/settings/security SecurityAction POST n USER PUBLIC
CONSOLE /console-api/userdata ConsoleUserDataAction GET n USER PUBLIC
CONSOLE /console-api/users ConsoleUsersAction GET,POST,DELETE,PUT n USER PUBLIC
CONSOLE /ready/console ReadinessProbeConsoleAction GET n NONE PUBLIC

View File

@@ -74,7 +74,10 @@ CONSOLE /console-api/domain ConsoleDomainGetActi
CONSOLE /console-api/domain-list ConsoleDomainListAction GET n USER PUBLIC
CONSOLE /console-api/dum-download ConsoleDumDownloadAction GET n USER PUBLIC
CONSOLE /console-api/eppPassword ConsoleEppPasswordAction POST n USER PUBLIC
CONSOLE /console-api/history ConsoleHistoryDataAction GET n USER PUBLIC
CONSOLE /console-api/ote ConsoleOteAction GET,POST n USER PUBLIC
CONSOLE /console-api/password-reset-request PasswordResetRequestAction POST n USER PUBLIC
CONSOLE /console-api/password-reset-verify PasswordResetVerifyAction GET,POST n USER PUBLIC
CONSOLE /console-api/registrar ConsoleUpdateRegistrarAction POST n USER PUBLIC
CONSOLE /console-api/registrars RegistrarsAction GET,POST n USER PUBLIC
CONSOLE /console-api/registry-lock ConsoleRegistryLockAction GET,POST n USER PUBLIC
@@ -84,4 +87,4 @@ CONSOLE /console-api/settings/rdap-fields RdapRegistrarFieldsA
CONSOLE /console-api/settings/security SecurityAction POST n USER PUBLIC
CONSOLE /console-api/userdata ConsoleUserDataAction GET n USER PUBLIC
CONSOLE /console-api/users ConsoleUsersAction GET,POST,DELETE,PUT n USER PUBLIC
CONSOLE /ready/console ReadinessProbeConsoleAction GET n NONE PUBLIC
CONSOLE /ready/console ReadinessProbeConsoleAction GET n NONE PUBLIC

View File

@@ -139,7 +139,7 @@
description text,
method text not null,
modification_time timestamp(6) with time zone not null,
type text not null check (type in ('DOMAIN_DELETE','DOMAIN_SUSPEND','DOMAIN_UNSUSPEND','EPP_PASSWORD_UPDATE','REGISTRAR_CREATE','REGISTRAR_SECURITY_UPDATE','REGISTRAR_UPDATE','USER_CREATE','USER_DELETE','USER_UPDATE')),
type text not null check (type in ('DUM_DOWNLOAD','DOMAIN_DELETE','DOMAIN_SUSPEND','DOMAIN_UNSUSPEND','EPP_PASSWORD_UPDATE','REGISTRAR_CREATE','REGISTRAR_CONTACTS_UPDATE','REGISTRAR_SECURITY_UPDATE','REGISTRAR_UPDATE','REGISTRY_LOCK','REGISTRY_UNLOCK','USER_CREATE','USER_DELETE','USER_UPDATE')),
url text not null,
acting_user text not null,
primary key (revision_id)