1
0
mirror of https://github.com/google/nomulus synced 2025-12-23 06:15:42 +00:00

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.
This commit is contained in:
gbrodman
2025-07-17 17:33:29 -04:00
committed by GitHub
parent c5644d5c8b
commit 2948dcc1be
11 changed files with 766 additions and 26 deletions

View File

@@ -122,6 +122,8 @@ import google.registry.ui.server.console.ConsoleRegistryLockVerifyAction;
import google.registry.ui.server.console.ConsoleUpdateRegistrarAction; import google.registry.ui.server.console.ConsoleUpdateRegistrarAction;
import google.registry.ui.server.console.ConsoleUserDataAction; import google.registry.ui.server.console.ConsoleUserDataAction;
import google.registry.ui.server.console.ConsoleUsersAction; 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.RegistrarsAction;
import google.registry.ui.server.console.domains.ConsoleBulkDomainAction; import google.registry.ui.server.console.domains.ConsoleBulkDomainAction;
import google.registry.ui.server.console.settings.ContactAction; import google.registry.ui.server.console.settings.ContactAction;
@@ -249,6 +251,10 @@ interface RequestComponent {
NordnVerifyAction nordnVerifyAction(); NordnVerifyAction nordnVerifyAction();
PasswordResetRequestAction passwordResetRequestAction();
PasswordResetVerifyAction passwordResetVerifyAction();
PublishDnsUpdatesAction publishDnsUpdatesAction(); PublishDnsUpdatesAction publishDnsUpdatesAction();
PublishInvoicesAction uploadInvoicesAction(); PublishInvoicesAction uploadInvoicesAction();
@@ -281,6 +287,8 @@ interface RequestComponent {
RdapNameserverSearchAction rdapNameserverSearchAction(); RdapNameserverSearchAction rdapNameserverSearchAction();
RdapRegistrarFieldsAction rdapRegistrarFieldsAction();
RdeReportAction rdeReportAction(); RdeReportAction rdeReportAction();
RdeReporter rdeReporter(); RdeReporter rdeReporter();
@@ -333,8 +341,6 @@ interface RequestComponent {
WhoisHttpAction whoisHttpAction(); WhoisHttpAction whoisHttpAction();
RdapRegistrarFieldsAction rdapRegistrarFieldsAction();
WipeOutContactHistoryPiiAction wipeOutContactHistoryPiiAction(); WipeOutContactHistoryPiiAction wipeOutContactHistoryPiiAction();
@Subcomponent.Builder @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.ConsoleUpdateRegistrarAction;
import google.registry.ui.server.console.ConsoleUserDataAction; import google.registry.ui.server.console.ConsoleUserDataAction;
import google.registry.ui.server.console.ConsoleUsersAction; 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.RegistrarsAction;
import google.registry.ui.server.console.domains.ConsoleBulkDomainAction; import google.registry.ui.server.console.domains.ConsoleBulkDomainAction;
import google.registry.ui.server.console.settings.ContactAction; import google.registry.ui.server.console.settings.ContactAction;
@@ -84,6 +86,12 @@ public interface FrontendRequestComponent {
FlowComponent.Builder flowComponentBuilder(); FlowComponent.Builder flowComponentBuilder();
PasswordResetRequestAction passwordResetRequestAction();
PasswordResetVerifyAction passwordResetVerifyAction();
RdapRegistrarFieldsAction rdapRegistrarFieldsAction();
ReadinessProbeActionFrontend readinessProbeActionFrontend(); ReadinessProbeActionFrontend readinessProbeActionFrontend();
ReadinessProbeConsoleAction readinessProbeConsoleAction(); ReadinessProbeConsoleAction readinessProbeConsoleAction();
@@ -92,8 +100,6 @@ public interface FrontendRequestComponent {
SecurityAction securityAction(); SecurityAction securityAction();
RdapRegistrarFieldsAction rdapRegistrarFieldsAction();
@Subcomponent.Builder @Subcomponent.Builder
abstract class Builder implements RequestComponentBuilder<FrontendRequestComponent> { abstract class Builder implements RequestComponentBuilder<FrontendRequestComponent> {
@Override public abstract Builder requestModule(RequestModule requestModule); @Override public abstract Builder requestModule(RequestModule requestModule);

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.ConsoleOteAction.OteCreateData;
import google.registry.ui.server.console.ConsoleRegistryLockAction.ConsoleRegistryLockPostInput; import google.registry.ui.server.console.ConsoleRegistryLockAction.ConsoleRegistryLockPostInput;
import google.registry.ui.server.console.ConsoleUsersAction.UserData; import google.registry.ui.server.console.ConsoleUsersAction.UserData;
import google.registry.ui.server.console.PasswordResetRequestAction.PasswordResetRequestData;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import java.util.Optional; import java.util.Optional;
import org.joda.time.DateTime; import org.joda.time.DateTime;
@@ -246,6 +247,12 @@ public final class ConsoleModule {
return extractRequiredParameter(req, "bulkDomainAction"); return extractRequiredParameter(req, "bulkDomainAction");
} }
@Provides
@Parameter("resetRequestVerificationCode")
public static String provideResetRequestVerificationCode(HttpServletRequest req) {
return extractRequiredParameter(req, "resetRequestVerificationCode");
}
@Provides @Provides
@Parameter("eppPasswordChangeRequest") @Parameter("eppPasswordChangeRequest")
public static Optional<EppPasswordData> provideEppPasswordChangeRequest( public static Optional<EppPasswordData> provideEppPasswordChangeRequest(
@@ -273,4 +280,21 @@ public final class ConsoleModule {
Gson gson, @OptionalJsonPayload Optional<JsonElement> payload) { Gson gson, @OptionalJsonPayload Optional<JsonElement> payload) {
return payload.map(e -> gson.fromJson(e, ConsoleRegistryLockPostInput.class)); 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

@@ -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

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

View File

@@ -215,7 +215,7 @@ class ConsoleUpdateRegistrarActionTest extends ConsoleActionBaseTestCase {
return ConsoleApiParamsUtils.createFake(authResult); 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()); when(consoleApiParams.request().getMethod()).thenReturn(Action.Method.POST.toString());
doReturn(new BufferedReader(new StringReader(requestData))) doReturn(new BufferedReader(new StringReader(requestData)))
.when(consoleApiParams.request()) .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

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

@@ -75,6 +75,8 @@ CONSOLE /console-api/domain-list ConsoleDomainListAct
CONSOLE /console-api/dum-download ConsoleDumDownloadAction 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/eppPassword ConsoleEppPasswordAction POST n USER PUBLIC
CONSOLE /console-api/ote ConsoleOteAction GET,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/registrar ConsoleUpdateRegistrarAction POST n USER PUBLIC
CONSOLE /console-api/registrars RegistrarsAction GET,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 ConsoleRegistryLockAction GET,POST n USER PUBLIC