1
0
mirror of https://github.com/google/nomulus synced 2026-04-24 10:10:46 +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

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

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

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

@@ -75,6 +75,8 @@ CONSOLE /console-api/domain-list ConsoleDomainListAct
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
@@ -84,4 +86,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