From 78f98b06df4e574da341c5df1e3291f94dd4b510 Mon Sep 17 00:00:00 2001 From: Gus Brodman Date: Wed, 4 Jun 2025 14:41:37 -0400 Subject: [PATCH] Add password reset Java object A future PR will add the actions that save and use this object. That future PR will also require loading RegistrarPoc objects given the registrar ID, hence the change in that class. --- .../model/console/PasswordResetRequest.java | 150 ++++++++++++++++++ .../registry/model/registrar/Registrar.java | 9 +- .../model/registrar/RegistrarPoc.java | 8 + .../console/settings/ContactAction.java | 11 +- .../main/resources/META-INF/persistence.xml | 3 +- .../console/PasswordResetRequestTest.java | 65 ++++++++ .../integration/SqlIntegrationTestSuite.java | 2 + .../sql/er_diagram/brief_er_diagram.html | 6 +- .../sql/er_diagram/full_er_diagram.html | 6 +- .../sql/schema/db-schema.sql.generated | 11 ++ 10 files changed, 247 insertions(+), 24 deletions(-) create mode 100644 core/src/main/java/google/registry/model/console/PasswordResetRequest.java create mode 100644 core/src/test/java/google/registry/model/console/PasswordResetRequestTest.java diff --git a/core/src/main/java/google/registry/model/console/PasswordResetRequest.java b/core/src/main/java/google/registry/model/console/PasswordResetRequest.java new file mode 100644 index 00000000000..d85c7039b07 --- /dev/null +++ b/core/src/main/java/google/registry/model/console/PasswordResetRequest.java @@ -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.model.console; + +import static google.registry.util.PreconditionsUtils.checkArgumentNotNull; + +import google.registry.model.Buildable; +import google.registry.model.CreateAutoTimestamp; +import google.registry.model.ImmutableObject; +import google.registry.persistence.WithVKey; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.AttributeOverrides; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import java.util.Optional; +import java.util.UUID; +import org.joda.time.DateTime; + +/** + * Represents a password reset request of some type. + * + *

Password reset requests must be performed within an hour of the time that they were requested, + * as well as requiring that the requester and the fulfiller have the proper respective permissions. + */ +@Entity +@WithVKey(String.class) +public class PasswordResetRequest extends ImmutableObject implements Buildable { + + public enum Type { + EPP, + REGISTRY_LOCK + } + + @Id private String verificationCode; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + Type type; + + @AttributeOverrides({ + @AttributeOverride( + name = "creationTime", + column = @Column(name = "requestTime", nullable = false)) + }) + CreateAutoTimestamp requestTime = CreateAutoTimestamp.create(null); + + @Column(nullable = false) + String requester; + + @Column DateTime fulfillmentTime; + + @Column(nullable = false) + String destinationEmail; + + @Column(nullable = false) + String registrarId; + + public String getVerificationCode() { + return verificationCode; + } + + public Type getType() { + return type; + } + + public DateTime getRequestTime() { + return requestTime.getTimestamp(); + } + + public String getRequester() { + return requester; + } + + public Optional getFulfillmentTime() { + return Optional.ofNullable(fulfillmentTime); + } + + public String getDestinationEmail() { + return destinationEmail; + } + + public String getRegistrarId() { + return registrarId; + } + + @Override + public Builder asBuilder() { + return new Builder(clone(this)); + } + + /** Builder for constructing immutable {@link PasswordResetRequest} objects. */ + public static class Builder extends Buildable.Builder { + + public Builder() {} + + private Builder(PasswordResetRequest instance) { + super(instance); + } + + @Override + public PasswordResetRequest build() { + checkArgumentNotNull(getInstance().type, "Type must be specified"); + checkArgumentNotNull(getInstance().requester, "Requester must be specified"); + checkArgumentNotNull(getInstance().destinationEmail, "Destination email must be specified"); + checkArgumentNotNull(getInstance().registrarId, "Registrar ID must be specified"); + getInstance().verificationCode = UUID.randomUUID().toString(); + return super.build(); + } + + public Builder setType(Type type) { + getInstance().type = type; + return this; + } + + public Builder setRequester(String requester) { + getInstance().requester = requester; + return this; + } + + public Builder setDestinationEmail(String destinationEmail) { + getInstance().destinationEmail = destinationEmail; + return this; + } + + public Builder setRegistrarId(String registrarId) { + getInstance().registrarId = registrarId; + return this; + } + + public Builder setFulfillmentTime(DateTime fulfillmentTime) { + getInstance().fulfillmentTime = fulfillmentTime; + return this; + } + } +} diff --git a/core/src/main/java/google/registry/model/registrar/Registrar.java b/core/src/main/java/google/registry/model/registrar/Registrar.java index ffe2abfc755..b3fbe1b5907 100644 --- a/core/src/main/java/google/registry/model/registrar/Registrar.java +++ b/core/src/main/java/google/registry/model/registrar/Registrar.java @@ -600,13 +600,8 @@ public Optional getWhoisAbuseContact() { return getContacts().stream().filter(RegistrarPoc::getVisibleInDomainWhoisAsAbuse).findFirst(); } - private ImmutableSet getContactPocs() { - return tm().transact( - () -> - tm().query("FROM RegistrarPoc WHERE registrarId = :registrarId", RegistrarPoc.class) - .setParameter("registrarId", registrarId) - .getResultStream() - .collect(toImmutableSet())); + private ImmutableList getContactPocs() { + return tm().transact(() -> RegistrarPoc.loadForRegistrar(registrarId)); } @Override diff --git a/core/src/main/java/google/registry/model/registrar/RegistrarPoc.java b/core/src/main/java/google/registry/model/registrar/RegistrarPoc.java index 49236a3a64f..e942c82a3fe 100644 --- a/core/src/main/java/google/registry/model/registrar/RegistrarPoc.java +++ b/core/src/main/java/google/registry/model/registrar/RegistrarPoc.java @@ -27,6 +27,7 @@ import static java.util.stream.Collectors.joining; import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; import com.google.common.collect.ImmutableSortedSet; import com.google.gson.annotations.Expose; @@ -36,6 +37,7 @@ import google.registry.model.Jsonifiable; import google.registry.model.UnsafeSerializable; import google.registry.persistence.VKey; +import google.registry.persistence.transaction.QueryComposer; import google.registry.util.PasswordUtils; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -432,6 +434,12 @@ public B setRegistryLockPassword(String registryLockPassword) { } } + public static ImmutableList loadForRegistrar(String registrarId) { + return tm().createQueryComposer(RegistrarPoc.class) + .where("registrarId", QueryComposer.Comparator.EQ, registrarId) + .list(); + } + /** Class to represent the composite primary key for {@link RegistrarPoc} entity. */ @VisibleForTesting public static class RegistrarPocId extends ImmutableObject implements Serializable { diff --git a/core/src/main/java/google/registry/ui/server/console/settings/ContactAction.java b/core/src/main/java/google/registry/ui/server/console/settings/ContactAction.java index 246392c88c0..1c9291b7183 100644 --- a/core/src/main/java/google/registry/ui/server/console/settings/ContactAction.java +++ b/core/src/main/java/google/registry/ui/server/console/settings/ContactAction.java @@ -15,7 +15,6 @@ package google.registry.ui.server.console.settings; import static com.google.common.base.Preconditions.checkArgument; -import static com.google.common.collect.ImmutableList.toImmutableList; import static com.google.common.collect.ImmutableSet.toImmutableSet; import static com.google.common.collect.Sets.difference; import static google.registry.persistence.transaction.TransactionManagerFactory.tm; @@ -35,7 +34,6 @@ import google.registry.model.registrar.Registrar; import google.registry.model.registrar.RegistrarPoc; import google.registry.model.registrar.RegistrarPoc.Type; -import google.registry.persistence.transaction.QueryComposer.Comparator; import google.registry.request.Action; import google.registry.request.Action.GaeService; import google.registry.request.Action.GkeService; @@ -77,14 +75,7 @@ public ContactAction( protected void getHandler(User user) { checkPermission(user, registrarId, ConsolePermission.VIEW_REGISTRAR_DETAILS); ImmutableList contacts = - tm().transact( - () -> - tm() - .createQueryComposer(RegistrarPoc.class) - .where("registrarId", Comparator.EQ, registrarId) - .stream() - .collect(toImmutableList())); - + tm().transact(() -> RegistrarPoc.loadForRegistrar(registrarId)); consoleApiParams.response().setStatus(SC_OK); consoleApiParams.response().setPayload(consoleApiParams.gson().toJson(contacts)); } diff --git a/core/src/main/resources/META-INF/persistence.xml b/core/src/main/resources/META-INF/persistence.xml index 68bc8679d2f..8bf8f9ff74a 100644 --- a/core/src/main/resources/META-INF/persistence.xml +++ b/core/src/main/resources/META-INF/persistence.xml @@ -47,13 +47,14 @@ google.registry.model.billing.BillingRecurrence google.registry.model.common.Cursor google.registry.model.common.DnsRefreshRequest + google.registry.model.common.FeatureFlag google.registry.model.console.ConsoleUpdateHistory + google.registry.model.console.PasswordResetRequest google.registry.model.console.User google.registry.model.contact.ContactHistory google.registry.model.contact.Contact google.registry.model.domain.Domain google.registry.model.domain.DomainHistory - google.registry.model.common.FeatureFlag google.registry.model.domain.GracePeriod google.registry.model.domain.GracePeriod$GracePeriodHistory google.registry.model.domain.secdns.DomainDsData diff --git a/core/src/test/java/google/registry/model/console/PasswordResetRequestTest.java b/core/src/test/java/google/registry/model/console/PasswordResetRequestTest.java new file mode 100644 index 00000000000..d46ed1fc90a --- /dev/null +++ b/core/src/test/java/google/registry/model/console/PasswordResetRequestTest.java @@ -0,0 +1,65 @@ +// 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.model.console; + +import static com.google.common.truth.Truth.assertThat; +import static google.registry.model.ImmutableObjectSubject.assertAboutImmutableObjects; +import static google.registry.testing.DatabaseHelper.persistResource; +import static org.junit.Assert.assertThrows; + +import google.registry.model.EntityTestCase; +import google.registry.persistence.VKey; +import google.registry.testing.DatabaseHelper; +import org.junit.jupiter.api.Test; + +/** Tests for {@link PasswordResetRequest}. */ +public class PasswordResetRequestTest extends EntityTestCase { + + PasswordResetRequestTest() { + super(JpaEntityCoverageCheck.ENABLED); + } + + @Test + void testSuccess_persistence() { + PasswordResetRequest request = + new PasswordResetRequest.Builder() + .setRequester("requestor@email.tld") + .setDestinationEmail("destination@email.tld") + .setType(PasswordResetRequest.Type.EPP) + .setRegistrarId("TheRegistrar") + .build(); + String verificationCode = request.getVerificationCode(); + assertThat(verificationCode).isNotEmpty(); + persistResource(request); + PasswordResetRequest fromDatabase = + DatabaseHelper.loadByKey(VKey.create(PasswordResetRequest.class, verificationCode)); + assertAboutImmutableObjects().that(fromDatabase).isEqualExceptFields(request, "requestTime"); + assertThat(fromDatabase.getRequestTime()).isEqualTo(fakeClock.nowUtc()); + } + + @Test + void testFailure_nullFields() { + PasswordResetRequest.Builder builder = new PasswordResetRequest.Builder(); + assertThrows(IllegalArgumentException.class, builder::build); + builder.setType(PasswordResetRequest.Type.EPP); + assertThrows(IllegalArgumentException.class, builder::build); + builder.setRequester("foobar@email.tld"); + assertThrows(IllegalArgumentException.class, builder::build); + builder.setDestinationEmail("email@email.tld"); + assertThrows(IllegalArgumentException.class, builder::build); + builder.setRegistrarId("TheRegistrar"); + builder.build(); + } +} diff --git a/core/src/test/java/google/registry/schema/integration/SqlIntegrationTestSuite.java b/core/src/test/java/google/registry/schema/integration/SqlIntegrationTestSuite.java index 67eaa5b2087..e73fe70740a 100644 --- a/core/src/test/java/google/registry/schema/integration/SqlIntegrationTestSuite.java +++ b/core/src/test/java/google/registry/schema/integration/SqlIntegrationTestSuite.java @@ -25,6 +25,7 @@ import google.registry.model.common.DnsRefreshRequestTest; import google.registry.model.common.FeatureFlagTest; import google.registry.model.console.ConsoleUpdateHistoryTest; +import google.registry.model.console.PasswordResetRequestTest; import google.registry.model.console.UserTest; import google.registry.model.contact.ContactTest; import google.registry.model.domain.DomainSqlTest; @@ -104,6 +105,7 @@ FeatureFlagTest.class, HostHistoryTest.class, LockTest.class, + PasswordResetRequestTest.class, PollMessageTest.class, PremiumListDaoTest.class, RdeRevisionTest.class, diff --git a/db/src/main/resources/sql/er_diagram/brief_er_diagram.html b/db/src/main/resources/sql/er_diagram/brief_er_diagram.html index ff8167034ef..ccd569472c6 100644 --- a/db/src/main/resources/sql/er_diagram/brief_er_diagram.html +++ b/db/src/main/resources/sql/er_diagram/brief_er_diagram.html @@ -261,7 +261,7 @@

System Information

generated on - 2025-06-02 14:41:34 + 2025-06-04 18:53:06 last flyway file @@ -280,7 +280,7 @@

System Information

generated by SchemaCrawler 16.25.2 generated on - 2025-06-02 14:41:34 + 2025-06-04 18:53:06 @@ -2702,7 +2702,7 @@ <h2>Tables</h2> <tr> <td class="spacer"></td> <td class="minwidth"></td> - <td class="minwidth">default '2021-06-01 00:00:00+00'::timestamp with time zone</td> + <td class="minwidth">default '2021-05-31 20:00:00-04'::timestamp with time zone</td> </tr> <tr> <td colspan="3"></td> diff --git a/db/src/main/resources/sql/er_diagram/full_er_diagram.html b/db/src/main/resources/sql/er_diagram/full_er_diagram.html index d40163df17a..dbc3e329a4c 100644 --- a/db/src/main/resources/sql/er_diagram/full_er_diagram.html +++ b/db/src/main/resources/sql/er_diagram/full_er_diagram.html @@ -261,7 +261,7 @@ <h2>System Information</h2> </tr> <tr> <td class="property_name">generated on</td> - <td class="property_value">2025-06-02 14:41:30</td> + <td class="property_value">2025-06-04 18:53:03</td> </tr> <tr> <td class="property_name">last flyway file</td> @@ -280,7 +280,7 @@ <h2>System Information</h2> <text text-anchor="start" x="5435" y="-29.8" font-family="Helvetica,sans-Serif" font-size="14.00">generated by</text> <text text-anchor="start" x="5518" y="-29.8" font-family="Helvetica,sans-Serif" font-size="14.00">SchemaCrawler 16.25.2</text> <text text-anchor="start" x="5434" y="-10.8" font-family="Helvetica,sans-Serif" font-size="14.00">generated on</text> - <text text-anchor="start" x="5518" y="-10.8" font-family="Helvetica,sans-Serif" font-size="14.00">2025-06-02 14:41:30</text> + <text text-anchor="start" x="5518" y="-10.8" font-family="Helvetica,sans-Serif" font-size="14.00">2025-06-04 18:53:03</text> <polygon fill="none" stroke="#888888" points="5431,-4 5431,-44 5667,-44 5667,-4 5431,-4" /> <!-- allocationtoken_a08ccbef --> <g id="node1" class="node"> <title> @@ -4806,7 +4806,7 @@ <h2>Tables</h2> <tr> <td class="spacer"></td> <td class="minwidth"></td> - <td class="minwidth">default '2021-06-01 00:00:00+00'::timestamp with time zone</td> + <td class="minwidth">default '2021-05-31 20:00:00-04'::timestamp with time zone</td> </tr> <tr> <td colspan="3"></td> diff --git a/db/src/main/resources/sql/schema/db-schema.sql.generated b/db/src/main/resources/sql/schema/db-schema.sql.generated index 2465a2e86fd..078675fa80f 100644 --- a/db/src/main/resources/sql/schema/db-schema.sql.generated +++ b/db/src/main/resources/sql/schema/db-schema.sql.generated @@ -562,6 +562,17 @@ primary key (package_promotion_id) ); + create table "PasswordResetRequest" ( + verification_code text not null, + destination_email text not null, + fulfillment_time timestamp(6) with time zone, + registrar_id text not null, + request_time timestamp(6) with time zone not null, + requester text not null, + type text not null check (type in ('EPP','REGISTRY_LOCK')), + primary key (verification_code) + ); + create table "PollMessage" ( type text not null, poll_message_id bigint not null,