From d4bcff0c314015bb1af831baa2a5e22dc5894e8e Mon Sep 17 00:00:00 2001 From: gbrodman Date: Tue, 17 Jun 2025 15:00:50 -0400 Subject: [PATCH] Add password reset Java object (#2765) 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 000000000..d85c7039b --- /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 ffe2abfc7..b3fbe1b59 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 class Registrar extends UpdateAutoTimestampEntity implements Buildable, J 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 49236a3a6..e942c82a3 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 google.registry.util.PasswordUtils.hashPassword; 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.JsonMapBuilder; 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 class RegistrarPoc extends ImmutableObject implements Jsonifiable, Unsafe } } + 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 246392c88..1c9291b71 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.console.User; 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 class ContactAction extends ConsoleApiAction { 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 68bc8679d..8bf8f9ff7 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 000000000..d46ed1fc9 --- /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 67eaa5b20..e73fe7074 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.CursorTest; 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 @@ import org.junit.runner.RunWith; 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 ff8167034..ccd569472 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 @@ td.section { generated on - 2025-06-02 14:41:34 + 2025-06-04 18:53:06 last flyway file @@ -280,7 +280,7 @@ td.section { generated by SchemaCrawler 16.25.2 generated on - 2025-06-02 14:41:34 + 2025-06-04 18:53:06 @@ -2702,7 +2702,7 @@ td.section { <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 d40163df1..dbc3e329a 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 @@ td.section { </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 @@ td.section { <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 @@ td.section { <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 2465a2e86..078675fa8 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,