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,