Skip to content

Commit 546980e

Browse files
committed
Add password reset request and verify console actions
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.
1 parent d4bcff0 commit 546980e

File tree

11 files changed

+720
-26
lines changed

11 files changed

+720
-26
lines changed

core/src/main/java/google/registry/module/RequestComponent.java

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,8 @@
122122
import google.registry.ui.server.console.ConsoleUpdateRegistrarAction;
123123
import google.registry.ui.server.console.ConsoleUserDataAction;
124124
import google.registry.ui.server.console.ConsoleUsersAction;
125+
import google.registry.ui.server.console.PasswordResetRequestAction;
126+
import google.registry.ui.server.console.PasswordResetVerifyAction;
125127
import google.registry.ui.server.console.RegistrarsAction;
126128
import google.registry.ui.server.console.domains.ConsoleBulkDomainAction;
127129
import google.registry.ui.server.console.settings.ContactAction;
@@ -249,6 +251,10 @@ interface RequestComponent {
249251

250252
NordnVerifyAction nordnVerifyAction();
251253

254+
PasswordResetRequestAction passwordResetRequestAction();
255+
256+
PasswordResetVerifyAction passwordResetVerifyAction();
257+
252258
PublishDnsUpdatesAction publishDnsUpdatesAction();
253259

254260
PublishInvoicesAction uploadInvoicesAction();
@@ -281,6 +287,8 @@ interface RequestComponent {
281287

282288
RdapNameserverSearchAction rdapNameserverSearchAction();
283289

290+
RdapRegistrarFieldsAction rdapRegistrarFieldsAction();
291+
284292
RdeReportAction rdeReportAction();
285293

286294
RdeReporter rdeReporter();
@@ -332,9 +340,7 @@ interface RequestComponent {
332340
WhoisAction whoisAction();
333341

334342
WhoisHttpAction whoisHttpAction();
335-
336-
RdapRegistrarFieldsAction rdapRegistrarFieldsAction();
337-
343+
338344
WipeOutContactHistoryPiiAction wipeOutContactHistoryPiiAction();
339345

340346
@Subcomponent.Builder

core/src/main/java/google/registry/module/frontend/FrontendRequestComponent.java

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
import google.registry.ui.server.console.ConsoleUpdateRegistrarAction;
3939
import google.registry.ui.server.console.ConsoleUserDataAction;
4040
import google.registry.ui.server.console.ConsoleUsersAction;
41+
import google.registry.ui.server.console.PasswordResetRequestAction;
42+
import google.registry.ui.server.console.PasswordResetVerifyAction;
4143
import google.registry.ui.server.console.RegistrarsAction;
4244
import google.registry.ui.server.console.domains.ConsoleBulkDomainAction;
4345
import google.registry.ui.server.console.settings.ContactAction;
@@ -84,6 +86,12 @@ public interface FrontendRequestComponent {
8486

8587
FlowComponent.Builder flowComponentBuilder();
8688

89+
PasswordResetRequestAction passwordResetRequestAction();
90+
91+
PasswordResetVerifyAction passwordResetVerifyAction();
92+
93+
RdapRegistrarFieldsAction rdapRegistrarFieldsAction();
94+
8795
ReadinessProbeActionFrontend readinessProbeActionFrontend();
8896

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

93101
SecurityAction securityAction();
94102

95-
RdapRegistrarFieldsAction rdapRegistrarFieldsAction();
96-
97103
@Subcomponent.Builder
98104
abstract class Builder implements RequestComponentBuilder<FrontendRequestComponent> {
99105
@Override public abstract Builder requestModule(RequestModule requestModule);

core/src/main/java/google/registry/ui/server/console/ConsoleModule.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import google.registry.ui.server.console.ConsoleOteAction.OteCreateData;
3737
import google.registry.ui.server.console.ConsoleRegistryLockAction.ConsoleRegistryLockPostInput;
3838
import google.registry.ui.server.console.ConsoleUsersAction.UserData;
39+
import google.registry.ui.server.console.PasswordResetRequestAction.PasswordResetRequestData;
3940
import jakarta.servlet.http.HttpServletRequest;
4041
import java.util.Optional;
4142
import org.joda.time.DateTime;
@@ -246,6 +247,12 @@ public static String provideBulkDomainAction(HttpServletRequest req) {
246247
return extractRequiredParameter(req, "bulkDomainAction");
247248
}
248249

250+
@Provides
251+
@Parameter("verificationCode")
252+
public static String provideVerificationCode(HttpServletRequest req) {
253+
return extractRequiredParameter(req, "verificationCode");
254+
}
255+
249256
@Provides
250257
@Parameter("eppPasswordChangeRequest")
251258
public static Optional<EppPasswordData> provideEppPasswordChangeRequest(
@@ -273,4 +280,21 @@ public static Optional<ConsoleRegistryLockPostInput> provideRegistryLockPostInpu
273280
Gson gson, @OptionalJsonPayload Optional<JsonElement> payload) {
274281
return payload.map(e -> gson.fromJson(e, ConsoleRegistryLockPostInput.class));
275282
}
283+
284+
@Provides
285+
@Parameter("passwordResetRequestData")
286+
public static PasswordResetRequestData providePasswordResetRequestData(
287+
Gson gson, @OptionalJsonPayload Optional<JsonElement> payload) {
288+
return payload
289+
.map(e -> gson.fromJson(e, PasswordResetRequestData.class))
290+
.orElseThrow(
291+
() -> new IllegalArgumentException("Must provide password request reset data"));
292+
}
293+
294+
@Provides
295+
@Parameter("newPassword")
296+
public static Optional<String> provideNewPassword(
297+
Gson gson, @OptionalJsonPayload Optional<JsonElement> payload) {
298+
return payload.map(e -> gson.fromJson(e, String.class));
299+
}
276300
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
// Copyright 2025 The Nomulus Authors. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package google.registry.ui.server.console;
16+
17+
import static com.google.common.base.Preconditions.checkArgument;
18+
import static google.registry.persistence.transaction.TransactionManagerFactory.tm;
19+
20+
import com.google.gson.annotations.Expose;
21+
import google.registry.model.console.ConsolePermission;
22+
import google.registry.model.console.PasswordResetRequest;
23+
import google.registry.model.console.User;
24+
import google.registry.model.registrar.RegistrarPoc;
25+
import google.registry.persistence.transaction.QueryComposer;
26+
import google.registry.request.Action;
27+
import google.registry.request.Parameter;
28+
import google.registry.request.auth.Auth;
29+
import google.registry.util.EmailMessage;
30+
import jakarta.inject.Inject;
31+
import jakarta.mail.internet.AddressException;
32+
import jakarta.mail.internet.InternetAddress;
33+
import jakarta.servlet.http.HttpServletResponse;
34+
import javax.annotation.Nullable;
35+
36+
@Action(
37+
service = Action.GaeService.DEFAULT,
38+
gkeService = Action.GkeService.CONSOLE,
39+
path = PasswordResetRequestAction.PATH,
40+
method = Action.Method.POST,
41+
auth = Auth.AUTH_PUBLIC_LOGGED_IN)
42+
public class PasswordResetRequestAction extends ConsoleApiAction {
43+
44+
static final String PATH = "/console-api/password-reset-request";
45+
static final String VERIFICATION_EMAIL_TEMPLATE =
46+
"""
47+
Please click the link below to perform the requested password reset. Note: this\
48+
code will expire in one hour.
49+
50+
%s\
51+
""";
52+
53+
private final PasswordResetRequestData passwordResetRequestData;
54+
55+
@Inject
56+
public PasswordResetRequestAction(
57+
ConsoleApiParams consoleApiParams,
58+
@Parameter("passwordResetRequestData") PasswordResetRequestData passwordResetRequestData) {
59+
super(consoleApiParams);
60+
this.passwordResetRequestData = passwordResetRequestData;
61+
}
62+
63+
@Override
64+
protected void postHandler(User user) {
65+
tm().transact(() -> performRequest(user));
66+
consoleApiParams.response().setStatus(HttpServletResponse.SC_OK);
67+
}
68+
69+
private void performRequest(User user) {
70+
checkArgument(passwordResetRequestData.type != null, "Type cannot be null");
71+
checkArgument(passwordResetRequestData.registrarId != null, "Registrar ID cannot be null");
72+
PasswordResetRequest.Type type = passwordResetRequestData.type;
73+
String registrarId = passwordResetRequestData.registrarId;
74+
75+
ConsolePermission requiredPermission;
76+
String destinationEmail;
77+
String emailSubject;
78+
switch (type) {
79+
case EPP:
80+
requiredPermission = ConsolePermission.EDIT_REGISTRAR_DETAILS;
81+
destinationEmail = getAdminPocEmail(registrarId);
82+
emailSubject = "EPP password reset request";
83+
break;
84+
case REGISTRY_LOCK:
85+
checkArgument(
86+
passwordResetRequestData.registryLockEmail != null,
87+
"Must provide registry lock email to reset");
88+
requiredPermission = ConsolePermission.MANAGE_USERS;
89+
destinationEmail = passwordResetRequestData.registryLockEmail;
90+
checkUserExistsWithRegistryLockEmail(destinationEmail);
91+
emailSubject = "Registry lock password reset request";
92+
break;
93+
default:
94+
throw new IllegalArgumentException("Unknown type " + type);
95+
}
96+
97+
checkPermission(user, registrarId, requiredPermission);
98+
99+
InternetAddress destinationAddress;
100+
try {
101+
destinationAddress = new InternetAddress(destinationEmail);
102+
} catch (AddressException e) {
103+
// Shouldn't happen
104+
throw new RuntimeException(e);
105+
}
106+
107+
PasswordResetRequest resetRequest =
108+
new PasswordResetRequest.Builder()
109+
.setRequester(user.getEmailAddress())
110+
.setRegistrarId(registrarId)
111+
.setType(type)
112+
.setDestinationEmail(destinationEmail)
113+
.build();
114+
tm().put(resetRequest);
115+
String verificationUrl =
116+
String.format(
117+
"https://%s/console/#/password-reset-verify?verificationCode=%s",
118+
consoleApiParams.request().getServerName(), resetRequest.getVerificationCode());
119+
String body = String.format(VERIFICATION_EMAIL_TEMPLATE, verificationUrl);
120+
consoleApiParams
121+
.sendEmailUtils()
122+
.gmailClient
123+
.sendEmail(EmailMessage.create(emailSubject, body, destinationAddress));
124+
}
125+
126+
static User checkUserExistsWithRegistryLockEmail(String destinationEmail) {
127+
return tm().createQueryComposer(User.class)
128+
.where("registryLockEmailAddress", QueryComposer.Comparator.EQ, destinationEmail)
129+
.first()
130+
.orElseThrow(
131+
() -> new IllegalArgumentException("Unknown user with lock email " + destinationEmail));
132+
}
133+
134+
private String getAdminPocEmail(String registrarId) {
135+
return RegistrarPoc.loadForRegistrar(registrarId).stream()
136+
.filter(poc -> poc.getTypes().contains(RegistrarPoc.Type.ADMIN))
137+
.map(RegistrarPoc::getEmailAddress)
138+
.findAny()
139+
.orElseThrow(() -> new IllegalStateException("No admin contacts found for " + registrarId));
140+
}
141+
142+
public record PasswordResetRequestData(
143+
@Expose PasswordResetRequest.Type type,
144+
@Expose String registrarId,
145+
@Expose @Nullable String registryLockEmail) {}
146+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// Copyright 2025 The Nomulus Authors. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package google.registry.ui.server.console;
16+
17+
import static com.google.common.base.Preconditions.checkArgument;
18+
import static google.registry.persistence.transaction.TransactionManagerFactory.tm;
19+
import static google.registry.request.Action.Method.GET;
20+
import static google.registry.request.Action.Method.POST;
21+
import static google.registry.ui.server.console.PasswordResetRequestAction.checkUserExistsWithRegistryLockEmail;
22+
23+
import com.google.common.base.Strings;
24+
import com.google.common.collect.ImmutableMap;
25+
import google.registry.model.console.ConsolePermission;
26+
import google.registry.model.console.PasswordResetRequest;
27+
import google.registry.model.console.User;
28+
import google.registry.model.registrar.Registrar;
29+
import google.registry.persistence.VKey;
30+
import google.registry.request.Action;
31+
import google.registry.request.Parameter;
32+
import google.registry.request.auth.Auth;
33+
import jakarta.inject.Inject;
34+
import jakarta.servlet.http.HttpServletResponse;
35+
import java.util.Optional;
36+
import org.joda.time.Duration;
37+
38+
@Action(
39+
service = Action.GaeService.DEFAULT,
40+
gkeService = Action.GkeService.CONSOLE,
41+
path = PasswordResetVerifyAction.PATH,
42+
method = {GET, POST},
43+
auth = Auth.AUTH_PUBLIC_LOGGED_IN)
44+
public class PasswordResetVerifyAction extends ConsoleApiAction {
45+
46+
static final String PATH = "/console-api/password-reset-verify";
47+
48+
private final String verificationCode;
49+
private final Optional<String> newPassword;
50+
51+
@Inject
52+
public PasswordResetVerifyAction(
53+
ConsoleApiParams consoleApiParams,
54+
@Parameter("verificationCode") String verificationCode,
55+
@Parameter("newPassword") Optional<String> newPassword) {
56+
super(consoleApiParams);
57+
this.verificationCode = verificationCode;
58+
this.newPassword = newPassword;
59+
}
60+
61+
@Override
62+
protected void getHandler(User user) {
63+
PasswordResetRequest request = tm().transact(() -> loadAndValidateResetRequest(user));
64+
ImmutableMap<String, ?> result =
65+
ImmutableMap.of("type", request.getType(), "registrarId", request.getRegistrarId());
66+
consoleApiParams.response().setPayload(consoleApiParams.gson().toJson(result));
67+
consoleApiParams.response().setStatus(HttpServletResponse.SC_OK);
68+
}
69+
70+
@Override
71+
protected void postHandler(User user) {
72+
checkArgument(!Strings.isNullOrEmpty(newPassword.orElse(null)), "Password must be provided");
73+
tm().transact(
74+
() -> {
75+
PasswordResetRequest request = loadAndValidateResetRequest(user);
76+
switch (request.getType()) {
77+
case EPP -> handleEppPasswordReset(request);
78+
case REGISTRY_LOCK -> handleRegistryLockPasswordReset(request);
79+
}
80+
tm().put(request.asBuilder().setFulfillmentTime(tm().getTransactionTime()).build());
81+
});
82+
consoleApiParams.response().setStatus(HttpServletResponse.SC_OK);
83+
}
84+
85+
private void handleEppPasswordReset(PasswordResetRequest request) {
86+
Registrar registrar = Registrar.loadByRegistrarId(request.getRegistrarId()).get();
87+
tm().put(registrar.asBuilder().setPassword(newPassword.get()).build());
88+
}
89+
90+
private void handleRegistryLockPasswordReset(PasswordResetRequest request) {
91+
User affectedUser = checkUserExistsWithRegistryLockEmail(request.getDestinationEmail());
92+
tm().put(
93+
affectedUser
94+
.asBuilder()
95+
.removeRegistryLockPassword()
96+
.setRegistryLockPassword(newPassword.get())
97+
.build());
98+
}
99+
100+
private PasswordResetRequest loadAndValidateResetRequest(User user) {
101+
PasswordResetRequest request =
102+
tm().loadByKeyIfPresent(VKey.create(PasswordResetRequest.class, verificationCode))
103+
.orElseThrow(this::createVerificationCodeException);
104+
ConsolePermission requiredVerifyPermission =
105+
switch (request.getType()) {
106+
case EPP -> ConsolePermission.MANAGE_USERS;
107+
case REGISTRY_LOCK -> ConsolePermission.REGISTRY_LOCK;
108+
};
109+
checkPermission(user, request.getRegistrarId(), requiredVerifyPermission);
110+
if (request
111+
.getRequestTime()
112+
.plus(Duration.standardHours(1))
113+
.isBefore(tm().getTransactionTime())) {
114+
throw createVerificationCodeException();
115+
}
116+
return request;
117+
}
118+
119+
private IllegalArgumentException createVerificationCodeException() {
120+
return new IllegalArgumentException(
121+
"Unknown, invalid, or expired verification code " + verificationCode);
122+
}
123+
}

core/src/test/java/google/registry/testing/DatabaseHelper.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1042,6 +1042,8 @@ public static User createAdminUser(String emailAddress) {
10421042
.setGlobalRole(GlobalRole.FTE)
10431043
.setIsAdmin(true)
10441044
.build())
1045+
.setRegistryLockEmailAddress("registrylock" + emailAddress)
1046+
.setRegistryLockPassword("password")
10451047
.build();
10461048
tm().put(user);
10471049
return user;

0 commit comments

Comments
 (0)