Skip to content

Commit 2e82448

Browse files
Merge 26.7 to 26.8
2 parents 46dc56d + 8797b6c commit 2e82448

2 files changed

Lines changed: 512 additions & 58 deletions

File tree

signup/src/org/labkey/signup/SignUpController.java

Lines changed: 144 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
import org.labkey.api.action.SimpleViewAction;
3333
import org.labkey.api.action.SpringActionController;
3434
import org.labkey.api.admin.AdminUrls;
35+
import org.labkey.api.audit.AuditLogService;
36+
import org.labkey.api.audit.ClientApiAuditProvider;
3537
import org.labkey.api.data.Container;
3638
import org.labkey.api.data.ContainerManager;
3739
import org.labkey.api.data.CoreSchema;
@@ -44,6 +46,7 @@
4446
import org.labkey.api.security.DbLoginService;
4547
import org.labkey.api.security.Group;
4648
import org.labkey.api.security.LoginManager;
49+
import org.labkey.api.security.LoginUrls;
4750
import org.labkey.api.security.RequiresLogin;
4851
import org.labkey.api.security.RequiresNoPermission;
4952
import org.labkey.api.security.RequiresPermission;
@@ -52,11 +55,16 @@
5255
import org.labkey.api.security.User;
5356
import org.labkey.api.security.UserManager;
5457
import org.labkey.api.security.ValidEmail;
58+
import org.labkey.api.security.permissions.AdminPermission;
59+
import org.labkey.api.security.permissions.DeletePermission;
60+
import org.labkey.api.security.permissions.InsertPermission;
5561
import org.labkey.api.security.permissions.ReadPermission;
62+
import org.labkey.api.security.permissions.UpdatePermission;
5663
import org.labkey.api.settings.LookAndFeelProperties;
5764
import org.labkey.api.util.ButtonBuilder;
5865
import org.labkey.api.util.ConfigurationException;
5966
import org.labkey.api.util.DOM;
67+
import org.labkey.api.util.MailHelper;
6068
import org.labkey.api.util.PageFlowUtil;
6169
import org.labkey.api.util.URLHelper;
6270
import org.labkey.api.util.CsrfInput;
@@ -72,6 +80,7 @@
7280
import org.springframework.validation.ObjectError;
7381
import org.springframework.web.servlet.ModelAndView;
7482

83+
import java.sql.SQLException;
7584
import java.util.ArrayList;
7685
import java.util.Arrays;
7786
import java.util.List;
@@ -153,6 +162,8 @@ public boolean handlePost(AddPropertyForm addPropertyForm, BindException errors)
153162
}
154163
m.put(SignUpModule.SIGNUP_GROUP_NAME, addPropertyForm.getGroupName());
155164
m.save();
165+
AuditLogService.get().addEvent(getUser(),
166+
new ClientApiAuditProvider.ClientApiAuditEvent(c, "Signup target group set to '" + addPropertyForm.getGroupName() + "'."));
156167
return true;
157168
}
158169

@@ -184,6 +195,8 @@ public boolean handlePost(ContainerIdForm containerIdForm, BindException errors)
184195
WritablePropertyMap m = PropertyManager.getWritableProperties(c, SignUpModule.SIGNUP_CATEGORY, true);
185196
m.remove(SignUpModule.SIGNUP_GROUP_NAME);
186197
m.save();
198+
AuditLogService.get().addEvent(getUser(),
199+
new ClientApiAuditProvider.ClientApiAuditEvent(c, "Signup target group property removed."));
187200

188201
return true;
189202
}
@@ -221,6 +234,10 @@ public boolean handlePost(AddGroupChangeForm addGroupChangeForm, BindException e
221234
m.put(String.valueOf(addGroupChangeForm.getOldgroup()), newProperties);
222235

223236
m.save();
237+
AuditLogService.get().addEvent(getUser(),
238+
new ClientApiAuditProvider.ClientApiAuditEvent(ContainerManager.getRoot(),
239+
"Signup group-change rule added: members of group " + groupLabel(addGroupChangeForm.getOldgroup())
240+
+ " may move to group " + groupLabel(addGroupChangeForm.getNewgroup()) + "."));
224241
return true;
225242
}
226243

@@ -246,19 +263,34 @@ public boolean handlePost(AddGroupChangeForm addGroupChangeForm, BindException e
246263
int oldgroup = addGroupChangeForm.getOldgroup();
247264
int newgroup = addGroupChangeForm.getNewgroup();
248265
if(oldgroup == 0 || newgroup == 0)
266+
{
267+
errors.addError(new LabKeyError("Please select both a source and a target group."));
249268
return false;
269+
}
250270
WritablePropertyMap m = PropertyManager.getWritableProperties(SignUpModule.SIGNUP_GROUP_TO_GROUP, true);
251271
String existingRules = m.get(String.valueOf(oldgroup));
272+
if(existingRules == null) // no rules configured for this source group - nothing to remove
273+
{
274+
errors.addError(new LabKeyError("No group-change rule exists for the selected source group."));
275+
return false;
276+
}
252277
ArrayList<String> rules = new ArrayList<>(Arrays.asList(existingRules.split(",")));
253278
if(!rules.contains(String.valueOf(newgroup)))
279+
{
280+
errors.addError(new LabKeyError("No group-change rule exists from the selected source group to the selected target group."));
254281
return false;
282+
}
255283
rules.remove(String.valueOf(newgroup));
256284
String newProperties = StringUtils.join(rules, ',');
257285
m.put(String.valueOf(oldgroup), newProperties);
258286
if(rules.isEmpty() || (rules.size() == 1 && rules.contains("")))
259-
m.remove(oldgroup);
287+
m.remove(String.valueOf(oldgroup)); // keys are stored as String.valueOf(oldgroup)
260288

261289
m.save();
290+
AuditLogService.get().addEvent(getUser(),
291+
new ClientApiAuditProvider.ClientApiAuditEvent(ContainerManager.getRoot(),
292+
"Signup group-change rule removed: members of group " + groupLabel(oldgroup)
293+
+ " may no longer move to group " + groupLabel(newgroup) + "."));
262294
return true;
263295
}
264296

@@ -547,17 +579,9 @@ public ModelAndView getView(SignupForm form, boolean reshow, BindException error
547579
}
548580
else
549581
{
550-
String message = form.isAccountExists() ? SignUpManager.USER_ALREADY_EXISTS : SignUpManager.CONFIRMATION_SENT;
551-
message = String.format(message, form.getEmail());
552-
if(form.isAccountExists())
553-
{
554-
errors.addError(new LabKeyError(message));
555-
return new SimpleErrorView(errors);
556-
}
557-
else
558-
{
559-
return HtmlView.of(message);
560-
}
582+
// Same response whether or not the account already exists (avoids user enumeration).
583+
String message = String.format(SignUpManager.CONFIRMATION_SENT, form.getEmail());
584+
return HtmlView.of(message);
561585
}
562586
}
563587

@@ -583,25 +607,22 @@ public boolean handlePost(SignupForm signupForm, BindException errors) throws Ex
583607
return false;
584608
}
585609

586-
if (UserManager.userExists(email))
587-
{
588-
// If the user already exists forward them to a page where they can click on a link to recover their password, if required
589-
signupForm.setAccountExists(true);
590-
signupForm.setNewSignUp(false);
591-
return false;
592-
}
593-
594610
try
595611
{
596-
createUserAndSendEmail(signupForm, email);
612+
// Do not reveal whether an account already exists (avoids user enumeration): both paths send
613+
// an email and re-render the same "confirmation sent" message. Any failure is caught below and
614+
// turned into the same generic error, so the outcome never depends on whether the account existed.
615+
if (UserManager.userExists(email))
616+
sendExistingAccountEmail(email); // never modifies the existing account
617+
else
618+
createUserAndSendEmail(signupForm, email);
597619
}
598-
catch (MessagingException | ConfigurationException e)
620+
catch (MessagingException | ConfigurationException | SQLException e)
599621
{
622+
// Same generic response for any of these failures, so the outcome never reveals whether the
623+
// account existed. Logged server-side only, never leaked to the caller.
624+
_log.error("Signup submission failed", e);
600625
errors.reject(ERROR_MSG, sendEmailErrorMessage(getContainer()));
601-
if (e.getMessage() != null)
602-
{
603-
errors.reject(ERROR_MSG, e.getMessage());
604-
}
605626
return false;
606627
}
607628

@@ -719,6 +740,27 @@ private void createUserAndSendEmail(SignupForm form, ValidEmail email)
719740
}
720741
}
721742

743+
// Sends an informational email to the owner of an already-registered address when someone submits the
744+
// signup form for that address. The existing account is never modified. A send failure propagates to the
745+
// caller, which returns the same generic error as a failed new-signup send, so the response stays uniform
746+
// whether or not the account exists (avoids user enumeration).
747+
private void sendExistingAccountEmail(ValidEmail email) throws MessagingException
748+
{
749+
Container c = getContainer();
750+
String siteName = LookAndFeelProperties.getInstance(c).getShortName();
751+
ActionURL loginUrl = PageFlowUtil.urlProvider(LoginUrls.class).getLoginURL(c, null);
752+
String body = "We received a request to create an account on " + siteName
753+
+ " using this email address. An account already exists for " + email.getEmailAddress() + ".\n\n"
754+
+ "If this was you and you have forgotten your password, go to the sign-in page and use "
755+
+ "the \"Forgot your password?\" link to reset it:\n" + loginUrl.getURIString() + "\n\n"
756+
+ "If you did not make this request, you can ignore this email.";
757+
MailHelper.ViewMessage m = MailHelper.createMessage(
758+
LookAndFeelProperties.getInstance(c).getSystemEmailAddress(), email.getEmailAddress());
759+
m.setSubject("You already have an account on " + siteName);
760+
m.setText(body);
761+
MailHelper.send(m, getUser(), c);
762+
}
763+
722764
private static List<String> errorsToMessages(Errors errors)
723765
{
724766
return errors.getAllErrors().stream()
@@ -728,10 +770,51 @@ private static List<String> errorsToMessages(Errors errors)
728770

729771
private static String sendEmailErrorMessage(Container container)
730772
{
731-
return "Could not send new user registration email. Please contact your server administrator at "
773+
return "Could not send email. Please contact your server administrator at "
732774
+ LookAndFeelProperties.getInstance(container).getSystemEmailAddress();
733775
}
734776

777+
// Returns null if the requested self-service group change is allowed, otherwise a short reason.
778+
// Both groups must be project groups in the same project, and the target must grant no more than
779+
// read access. This bounds the damage if an admin maps a low-privilege group to a privileged,
780+
// write-enabled, or site group in the transition rule map.
781+
private static String validateGroupChangeTarget(Group oldgroup, Group newgroup)
782+
{
783+
if (oldgroup == null || newgroup == null)
784+
return "source or target group no longer exists";
785+
if (!oldgroup.isProjectGroup() || !newgroup.isProjectGroup())
786+
return "site groups are not valid for self-service changes";
787+
if (!newgroup.getContainer().equals(oldgroup.getContainer()))
788+
return "target group is in a different project than the source group";
789+
Container project = ContainerManager.getForId(newgroup.getContainer());
790+
if (project == null)
791+
return "project no longer exists";
792+
// The self-service flow may only drop a user into a read-only group. Reject a target that carries
793+
// write (Editor/Author/Submitter) or admin access anywhere in the project tree, so a misconfigured
794+
// rule cannot be used to gain more than read access.
795+
for (Container c : ContainerManager.getAllChildren(project))
796+
{
797+
if (c.hasPermission(newgroup, AdminPermission.class)
798+
|| c.hasPermission(newgroup, InsertPermission.class)
799+
|| c.hasPermission(newgroup, UpdatePermission.class)
800+
|| c.hasPermission(newgroup, DeletePermission.class))
801+
return "target group grants more than read access";
802+
}
803+
return null;
804+
}
805+
806+
// Renders a group as "name (id)" for audit messages.
807+
private static String groupLabel(Group group)
808+
{
809+
return group.getName() + " (" + group.getUserId() + ")";
810+
}
811+
812+
private static String groupLabel(int groupId)
813+
{
814+
Group group = SecurityManager.getGroup(groupId);
815+
return group != null ? groupLabel(group) : "(" + groupId + ")";
816+
}
817+
735818
public static ActionURL getConfirmationURL(Container c, ValidEmail email, String key)
736819
{
737820
ActionURL url = new ActionURL(ConfirmAction.class, c);
@@ -747,7 +830,6 @@ public static class SignupForm extends ReturnUrlForm
747830
private String _organization;
748831
private String _email;
749832
private String _emailConfirm;
750-
private boolean _accountExists;
751833
private boolean _newSignUp = true;
752834

753835
public String getFirstName()
@@ -800,16 +882,6 @@ public void setEmailConfirm(String emailConfirm)
800882
_emailConfirm = emailConfirm;
801883
}
802884

803-
public boolean isAccountExists()
804-
{
805-
return _accountExists;
806-
}
807-
808-
public void setAccountExists(boolean accountExists)
809-
{
810-
_accountExists = accountExists;
811-
}
812-
813885
public boolean isNewSignUp()
814886
{
815887
return _newSignUp;
@@ -861,17 +933,34 @@ public ApiResponse execute(AddGroupChangeForm addGroupChangeForm, BindException
861933
response.put("status", "NO_PERMISSIONS");
862934
return response;
863935
}
864-
// once reached here we can assume that group a and b exist and that a rule exists allowing
865-
// a user to change from group a to group b
936+
// A matching rule exists, but the rule map is admin-configured and could point at a
937+
// privileged target. Re-validate the resolved groups before mutating membership so a
938+
// misconfigured rule cannot be used to self-escalate (see validateGroupChangeTarget).
939+
final Group oldgroup = SecurityManager.getGroup(addGroupChangeForm.getOldgroup());
940+
final Group newgroup = SecurityManager.getGroup(addGroupChangeForm.getNewgroup());
941+
String denyReason = validateGroupChangeTarget(oldgroup, newgroup);
942+
if (denyReason != null)
943+
{
944+
_log.warn("Rejected self-service group change for user {} (group {} -> {}): {}",
945+
user.getEmail(), addGroupChangeForm.getOldgroup(), addGroupChangeForm.getNewgroup(), denyReason);
946+
// A configured rule points at a target the self-service flow must never grant (privileged,
947+
// write-enabled, site, or other-project). Return the same generic NO_PERMISSIONS as an
948+
// ineligible caller so the response does not reveal which rules are misconfigured; the
949+
// specific reason is logged server-side only, not returned to the caller.
950+
response.put("status", "NO_PERMISSIONS");
951+
return response;
952+
}
866953
try (DbScope.Transaction transaction = CoreSchema.getInstance().getSchema().getScope().ensureTransaction())
867954
{
868-
final Group oldgroup = SecurityManager.getGroup(addGroupChangeForm.getOldgroup());
869-
final Group newgroup = SecurityManager.getGroup(addGroupChangeForm.getNewgroup());
870955
SecurityManager.addMember(newgroup, user);
871956
SecurityManager.deleteMember(oldgroup, user);
872957
UserManager.updateUser(user, user);
873958
addGroupChangeForm.setLabkeyUserId(user.getUserId());
874959
Table.insert(user, SignUpSchema.getTableInfoMovedUsers(), addGroupChangeForm);
960+
AuditLogService.get().addEvent(user,
961+
new ClientApiAuditProvider.ClientApiAuditEvent(getContainer(),
962+
"Self-service group change: user " + user.getEmail() + " moved from group "
963+
+ groupLabel(oldgroup) + " to group " + groupLabel(newgroup) + "."));
875964
response.put("status", "USER_MOVED_SUCCESS"); // success status
876965
transaction.commit();
877966
}
@@ -914,32 +1003,29 @@ public ApiResponse execute(SignupForm signupForm, BindException errors) throws E
9141003
return response;
9151004
}
9161005

917-
if (UserManager.userExists(email))
918-
{
919-
response.put("status", "USER_EXISTS");
920-
return response;
921-
}
922-
9231006
try
9241007
{
925-
createUserAndSendEmail(signupForm, email);
1008+
// Do not reveal whether an account already exists (avoids user enumeration): both paths send
1009+
// an email and return the same response. Any failure is caught below and turned into the same
1010+
// generic ERROR, so the outcome never depends on whether the account existed.
1011+
if (UserManager.userExists(email))
1012+
sendExistingAccountEmail(email); // never modifies the existing account
1013+
else
1014+
createUserAndSendEmail(signupForm, email);
9261015
}
927-
catch (MessagingException | ConfigurationException e)
1016+
catch (MessagingException | ConfigurationException | SQLException e)
9281017
{
1018+
// Same generic response for any of these failures, so the outcome never reveals whether the
1019+
// account existed. Logged server-side only, never leaked to the caller.
1020+
_log.error("Signup submission failed", e);
9291021
response.put("status", "ERROR");
930-
List<String> messages = new ArrayList<>();
931-
messages.add(sendEmailErrorMessage(getContainer()));
932-
if (e.getMessage() != null)
933-
{
934-
messages.add(e.getMessage());
935-
}
936-
response.put("error_message", messages);
1022+
response.put("error_message", List.of(sendEmailErrorMessage(getContainer())));
9371023
return response;
9381024
}
9391025

9401026
clearCaptcha();
9411027

942-
response.put("status", "USER_ADDED");
1028+
response.put("status", "SUCCESS");
9431029
return response;
9441030
}
9451031
}

0 commit comments

Comments
 (0)