Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions core/src/main/java/google/registry/module/RequestComponent.java
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@
import google.registry.ui.server.console.ConsoleUpdateRegistrarAction;
import google.registry.ui.server.console.ConsoleUserDataAction;
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.domains.ConsoleBulkDomainAction;
import google.registry.ui.server.console.settings.ContactAction;
Expand Down Expand Up @@ -249,6 +251,10 @@ interface RequestComponent {

NordnVerifyAction nordnVerifyAction();

PasswordResetRequestAction passwordResetRequestAction();

PasswordResetVerifyAction passwordResetVerifyAction();

PublishDnsUpdatesAction publishDnsUpdatesAction();

PublishInvoicesAction uploadInvoicesAction();
Expand Down Expand Up @@ -281,6 +287,8 @@ interface RequestComponent {

RdapNameserverSearchAction rdapNameserverSearchAction();

RdapRegistrarFieldsAction rdapRegistrarFieldsAction();

RdeReportAction rdeReportAction();

RdeReporter rdeReporter();
Expand Down Expand Up @@ -332,9 +340,7 @@ interface RequestComponent {
WhoisAction whoisAction();

WhoisHttpAction whoisHttpAction();

RdapRegistrarFieldsAction rdapRegistrarFieldsAction();


WipeOutContactHistoryPiiAction wipeOutContactHistoryPiiAction();

@Subcomponent.Builder
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
import google.registry.ui.server.console.ConsoleUpdateRegistrarAction;
import google.registry.ui.server.console.ConsoleUserDataAction;
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.domains.ConsoleBulkDomainAction;
import google.registry.ui.server.console.settings.ContactAction;
Expand Down Expand Up @@ -84,6 +86,12 @@ public interface FrontendRequestComponent {

FlowComponent.Builder flowComponentBuilder();

PasswordResetRequestAction passwordResetRequestAction();

PasswordResetVerifyAction passwordResetVerifyAction();

RdapRegistrarFieldsAction rdapRegistrarFieldsAction();

ReadinessProbeActionFrontend readinessProbeActionFrontend();

ReadinessProbeConsoleAction readinessProbeConsoleAction();
Expand All @@ -92,8 +100,6 @@ public interface FrontendRequestComponent {

SecurityAction securityAction();

RdapRegistrarFieldsAction rdapRegistrarFieldsAction();

@Subcomponent.Builder
abstract class Builder implements RequestComponentBuilder<FrontendRequestComponent> {
@Override public abstract Builder requestModule(RequestModule requestModule);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
import google.registry.ui.server.console.ConsoleOteAction.OteCreateData;
import google.registry.ui.server.console.ConsoleRegistryLockAction.ConsoleRegistryLockPostInput;
import google.registry.ui.server.console.ConsoleUsersAction.UserData;
import google.registry.ui.server.console.PasswordResetRequestAction.PasswordResetRequestData;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Optional;
import org.joda.time.DateTime;
Expand Down Expand Up @@ -246,6 +247,12 @@ public static String provideBulkDomainAction(HttpServletRequest req) {
return extractRequiredParameter(req, "bulkDomainAction");
}

@Provides
@Parameter("resetRequestVerificationCode")
public static String provideResetRequestVerificationCode(HttpServletRequest req) {
return extractRequiredParameter(req, "resetRequestVerificationCode");
}

@Provides
@Parameter("eppPasswordChangeRequest")
public static Optional<EppPasswordData> provideEppPasswordChangeRequest(
Expand Down Expand Up @@ -273,4 +280,21 @@ public static Optional<ConsoleRegistryLockPostInput> provideRegistryLockPostInpu
Gson gson, @OptionalJsonPayload Optional<JsonElement> payload) {
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));
}
}
Original file line number Diff line number Diff line change
@@ -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) {}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading