Skip to content

Commit ce9055a

Browse files
authored
Add kaptcha verification to signup module (#638)
- Added server-side kaptcha verification to SignUpApiAction and BeginAction - Extracted shared helpers so both action paths follow the same verification sequence in the same order - Added emailConfirm field requiring users to enter their email address twice - Fixed two API path bugs: EmailValidator.isValid was not called, and a sendEmail failure was falling through to status=USER_ADDED - Wrapped TempUser insert + confirmation email in a transaction so the row is rolled back if email delivery fails - Rewrote signupPage.jsp to use <labkey:form> and <labkey:input> taglibs
1 parent 335bfd9 commit ce9055a

2 files changed

Lines changed: 190 additions & 74 deletions

File tree

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

Lines changed: 163 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package org.labkey.signup;
1818

19+
import jakarta.mail.MessagingException;
1920
import org.apache.commons.lang3.StringUtils;
2021
import org.apache.commons.validator.routines.EmailValidator;
2122
import org.apache.logging.log4j.LogManager;
@@ -54,6 +55,7 @@
5455
import org.labkey.api.security.permissions.ReadPermission;
5556
import org.labkey.api.settings.LookAndFeelProperties;
5657
import org.labkey.api.util.ButtonBuilder;
58+
import org.labkey.api.util.ConfigurationException;
5759
import org.labkey.api.util.DOM;
5860
import org.labkey.api.util.PageFlowUtil;
5961
import org.labkey.api.util.URLHelper;
@@ -62,10 +64,12 @@
6264
import org.labkey.api.view.HtmlView;
6365
import org.labkey.api.view.HttpView;
6466
import org.labkey.api.view.JspView;
67+
import org.labkey.api.view.LabKeyKaptchaServlet;
6568
import org.labkey.api.view.NavTree;
6669
import org.labkey.api.view.WebPartView;
6770
import org.springframework.validation.BindException;
6871
import org.springframework.validation.Errors;
72+
import org.springframework.validation.ObjectError;
6973
import org.springframework.web.servlet.ModelAndView;
7074

7175
import java.util.ArrayList;
@@ -527,7 +531,8 @@ public class BeginAction extends FormViewAction<SignupForm>
527531
@Override
528532
public void validateCommand(SignupForm form, Errors errors)
529533
{
530-
validateSignupForm(form, errors);
534+
// All validation happens in handlePost so the order matches SignUpApiAction
535+
// (captcha first, then blank-field checks, then email parsing, etc.).
531536
}
532537

533538
@Override
@@ -559,54 +564,50 @@ public ModelAndView getView(SignupForm form, boolean reshow, BindException error
559564
@Override
560565
public boolean handlePost(SignupForm signupForm, BindException errors) throws Exception
561566
{
562-
// Validate with EmailValidator first. ValidEmail(email) will not throw an exception if the domain is
563-
// missing from the email. The default domain configured for the server is appended.
564-
EmailValidator validator = EmailValidator.getInstance();
565-
if(!validator.isValid(signupForm.getEmail()))
567+
String kaptchaError = verifyCaptcha(signupForm.getKaptchaText(), signupForm.getEmail());
568+
if (kaptchaError != null)
566569
{
567-
errors.reject(ERROR_MSG,"'" + signupForm.getEmail() + "' is not a valid email address.");
570+
errors.reject(ERROR_MSG, kaptchaError);
568571
return false;
569572
}
570573

571-
ValidEmail email;
572-
try
574+
validateSignupForm(signupForm, errors);
575+
if (errors.hasErrors())
573576
{
574-
email = new ValidEmail(signupForm.getEmail());
577+
return false;
575578
}
576-
catch (ValidEmail.InvalidEmailException iee)
579+
580+
ValidEmail email = parseAndValidateEmail(signupForm, errors);
581+
if (email == null)
577582
{
578-
errors.reject(ERROR_MSG, iee.getMessage());
579583
return false;
580584
}
581585

582-
if(UserManager.userExists(email))
586+
if (UserManager.userExists(email))
583587
{
584588
// If the user already exists forward them to a page where they can click on a link to recover their password, if required
585589
signupForm.setAccountExists(true);
586590
signupForm.setNewSignUp(false);
587591
return false;
588592
}
589593

590-
// If the user does not exit in LabKey's core database, check in our temporaryusers table
591-
TempUser tempUser = getTempUser(signupForm, email);
592-
593-
// Send email to the user.
594-
ActionURL confirmationUrl = getConfirmationURL(getContainer(), email, tempUser.getKey());
595594
try
596595
{
597-
User mockUser = new User();
598-
mockUser.setEmail(email.getEmailAddress());
599-
SecurityManager.sendEmail(getContainer(), mockUser, SecurityManager.getRegistrationMessage(null, false), email.getEmailAddress(), confirmationUrl);
596+
createUserAndSendEmail(signupForm, email);
600597
}
601-
catch(Exception e)
598+
catch (MessagingException | ConfigurationException e)
602599
{
603-
String systemEmail = LookAndFeelProperties.getInstance(getContainer()).getSystemEmailAddress();
604-
errors.reject(ERROR_MSG, "Could not send new user registration email. Please contact your server administrator at " + systemEmail);
605-
errors.reject(ERROR_MSG, e.getMessage());
600+
errors.reject(ERROR_MSG, sendEmailErrorMessage(getContainer()));
601+
if (e.getMessage() != null)
602+
{
603+
errors.reject(ERROR_MSG, e.getMessage());
604+
}
606605
return false;
607606
}
608607

608+
clearCaptcha();
609609

610+
// Re-render the JSP with the CONFIRMATION_SENT message.
610611
signupForm.setNewSignUp(false);
611612
return false;
612613
}
@@ -641,6 +642,94 @@ private void validateSignupForm(SignupForm form, Errors errors)
641642
{
642643
errors.reject(ERROR_MSG, "Email cannot be blank.");
643644
}
645+
if(StringUtils.isBlank(form.getEmailConfirm()))
646+
{
647+
errors.reject(ERROR_MSG, "Confirm email cannot be blank.");
648+
}
649+
else if(!StringUtils.isBlank(form.getEmail()) && !form.getEmail().equalsIgnoreCase(form.getEmailConfirm()))
650+
{
651+
errors.reject(ERROR_MSG, "Email addresses do not match.");
652+
}
653+
}
654+
655+
// On success returns null. On failure returns a user-facing error message.
656+
// Does not clear the session attribute — callers clear it after the full operation
657+
// succeeds so the user can retry with the same captcha if a later step fails.
658+
// Logging matches LoginController's RegisterUserAction.
659+
private String verifyCaptcha(String submittedText, String emailForLogging)
660+
{
661+
var session = getViewContext().getRequest().getSession(true);
662+
String expected = (String) session.getAttribute(LabKeyKaptchaServlet.SESSION_KEY_VALUE);
663+
if (expected == null)
664+
{
665+
_log.info("Captcha not initialized for signup attempt");
666+
return "Captcha not initialized, please retry.";
667+
}
668+
if (!expected.equalsIgnoreCase(StringUtils.trimToNull(submittedText)))
669+
{
670+
_log.warn("Captcha text did not match for signup attempt for {}", emailForLogging);
671+
return "Verification text does not match, please retry.";
672+
}
673+
return null;
674+
}
675+
676+
private void clearCaptcha()
677+
{
678+
getViewContext().getRequest().getSession(true).removeAttribute(LabKeyKaptchaServlet.SESSION_KEY_VALUE);
679+
}
680+
681+
// Returns a parsed ValidEmail, or null if the address is invalid (errors populated).
682+
// Uses EmailValidator first because ValidEmail's constructor does not throw on bare
683+
// strings like "foo" - it silently appends the server's default domain.
684+
private ValidEmail parseAndValidateEmail(SignupForm form, Errors errors)
685+
{
686+
EmailValidator validator = EmailValidator.getInstance();
687+
if (!validator.isValid(form.getEmail()))
688+
{
689+
errors.reject(ERROR_MSG, "'" + form.getEmail() + "' is not a valid email address.");
690+
return null;
691+
}
692+
try
693+
{
694+
return new ValidEmail(form.getEmail());
695+
}
696+
catch (ValidEmail.InvalidEmailException iee)
697+
{
698+
errors.reject(ERROR_MSG, iee.getMessage());
699+
return null;
700+
}
701+
}
702+
703+
// Creates (or reuses) a TempUser row and sends the confirmation email in a single
704+
// transaction. On send failure the exception propagates and the transaction rolls back
705+
// automatically so a freshly inserted TempUser row does not persist.
706+
private void createUserAndSendEmail(SignupForm form, ValidEmail email)
707+
throws MessagingException, ConfigurationException, java.sql.SQLException
708+
{
709+
try (DbScope.Transaction transaction = SignUpManager.getSchema().getScope().ensureTransaction())
710+
{
711+
TempUser tempUser = getTempUser(form, email);
712+
ActionURL confirmationUrl = getConfirmationURL(getContainer(), email, tempUser.getKey());
713+
User mockUser = new User();
714+
mockUser.setEmail(email.getEmailAddress());
715+
SecurityManager.sendEmail(getContainer(), mockUser,
716+
SecurityManager.getRegistrationMessage(null, false),
717+
email.getEmailAddress(), confirmationUrl);
718+
transaction.commit();
719+
}
720+
}
721+
722+
private static List<String> errorsToMessages(Errors errors)
723+
{
724+
return errors.getAllErrors().stream()
725+
.map(ObjectError::getDefaultMessage)
726+
.toList();
727+
}
728+
729+
private static String sendEmailErrorMessage(Container container)
730+
{
731+
return "Could not send new user registration email. Please contact your server administrator at "
732+
+ LookAndFeelProperties.getInstance(container).getSystemEmailAddress();
644733
}
645734

646735
public static ActionURL getConfirmationURL(Container c, ValidEmail email, String key)
@@ -657,6 +746,7 @@ public static class SignupForm extends ReturnUrlForm
657746
private String _lastName;
658747
private String _organization;
659748
private String _email;
749+
private String _emailConfirm;
660750
private boolean _accountExists;
661751
private boolean _newSignUp = true;
662752

@@ -700,6 +790,16 @@ public void setEmail(String email)
700790
_email = email;
701791
}
702792

793+
public String getEmailConfirm()
794+
{
795+
return _emailConfirm;
796+
}
797+
798+
public void setEmailConfirm(String emailConfirm)
799+
{
800+
_emailConfirm = emailConfirm;
801+
}
802+
703803
public boolean isAccountExists()
704804
{
705805
return _accountExists;
@@ -719,6 +819,18 @@ public void setNewSignUp(boolean newSignUp)
719819
{
720820
_newSignUp = newSignUp;
721821
}
822+
823+
private String _kaptchaText;
824+
825+
public String getKaptchaText()
826+
{
827+
return _kaptchaText;
828+
}
829+
830+
public void setKaptchaText(String kaptchaText)
831+
{
832+
_kaptchaText = kaptchaText;
833+
}
722834
}
723835

724836
@RequiresLogin
@@ -778,52 +890,56 @@ public ApiResponse execute(SignupForm signupForm, BindException errors) throws E
778890
{
779891
ApiSimpleResponse response = new ApiSimpleResponse();
780892

781-
ValidEmail email;
782-
try
893+
String kaptchaError = verifyCaptcha(signupForm.getKaptchaText(), signupForm.getEmail());
894+
if (kaptchaError != null)
783895
{
784-
email = new ValidEmail(signupForm.getEmail());
896+
response.put("status", "ERROR");
897+
response.put("error_message", List.of(kaptchaError));
898+
return response;
785899
}
786-
catch (ValidEmail.InvalidEmailException iee)
900+
901+
validateSignupForm(signupForm, errors);
902+
if (errors.hasErrors())
787903
{
788-
errors.reject(ERROR_MSG, iee.getMessage());
904+
response.put("status", "ERROR");
905+
response.put("error_message", errorsToMessages(errors));
789906
return response;
790907
}
791908

792-
if(UserManager.userExists(email))
909+
ValidEmail email = parseAndValidateEmail(signupForm, errors);
910+
if (email == null)
793911
{
794-
response.put("status", "USER_EXISTS");
912+
response.put("status", "ERROR");
913+
response.put("error_message", errorsToMessages(errors));
795914
return response;
796915
}
797916

798-
validateSignupForm(signupForm, errors);
799-
if(errors.hasErrors())
917+
if (UserManager.userExists(email))
800918
{
919+
response.put("status", "USER_EXISTS");
801920
return response;
802921
}
803922

804-
TempUser tempUser = getTempUser(signupForm, email);
805-
806-
807-
// Send email to the user.
808-
ActionURL confirmationUrl = getConfirmationURL(getContainer(), email, tempUser.getKey());
809923
try
810924
{
811-
User mockUser = new User();
812-
mockUser.setEmail(email.getEmailAddress());
813-
SecurityManager.sendEmail(getContainer(), mockUser, SecurityManager.getRegistrationMessage(null, false), email.getEmailAddress(), confirmationUrl);
925+
createUserAndSendEmail(signupForm, email);
814926
}
815-
catch(Exception e)
927+
catch (MessagingException | ConfigurationException e)
816928
{
817-
String systemEmail = LookAndFeelProperties.getInstance(getContainer()).getSystemEmailAddress();
929+
response.put("status", "ERROR");
818930
List<String> messages = new ArrayList<>();
819-
messages.add("Could not send new user registration email. Please contact your server administrator at " + systemEmail);
820-
messages.add(e.getMessage());
931+
messages.add(sendEmailErrorMessage(getContainer()));
932+
if (e.getMessage() != null)
933+
{
934+
messages.add(e.getMessage());
935+
}
821936
response.put("error_message", messages);
937+
return response;
822938
}
823939

824-
signupForm.setNewSignUp(false); // TODO: Most likely not needed here
825-
response.put("status", "USER_ADDED");
940+
clearCaptcha();
826941

942+
response.put("status", "USER_ADDED");
827943
return response;
828944
}
829945
}
Lines changed: 27 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,38 @@
1-
<%@ page import="org.labkey.api.view.ActionURL" %>
21
<%@ page import="org.labkey.api.view.HttpView" %>
32
<%@ page import="org.labkey.signup.SignUpController.BeginAction" %>
43
<%@ page import="org.labkey.signup.SignUpController.SignupForm" %>
54
<%@ page extends="org.labkey.api.jsp.JspBase" %>
65
<%@ taglib prefix="labkey" uri="http://www.labkey.org/taglib" %>
76
<%
87
SignupForm form = (SignupForm)HttpView.currentModel();
9-
ActionURL url = urlFor(BeginAction.class);
8+
String contextPath = request.getContextPath();
109
%>
1110

12-
<!-- Display errors here -->
1311
<labkey:errors/>
1412

15-
<form action="<%=h(url)%>" method=post>
16-
<labkey:csrf/>
17-
<table>
18-
<tr>
19-
<td class="labkey-form-label"><label for="firstName">First Name</label> *</td>
20-
<td nowrap><input size="20" type="text" id="firstName" name="firstName" value="<%=h(form.getFirstName())%>"/></td>
21-
</tr>
22-
<tr>
23-
<td class="labkey-form-label"><label for="lastName">Last Name</label> *</td>
24-
<td nowrap><input size="20" type="text" id="lastName" name="lastName" value="<%=h(form.getLastName())%>"/></td>
25-
</tr>
26-
<tr>
27-
<td class="labkey-form-label"><label for="organization">Organization</label> *</td>
28-
<td nowrap><input size="20" type="text" id="organization" name="organization" value="<%=h(form.getOrganization())%>"/></td>
29-
</tr>
30-
<tr>
31-
<td class="labkey-form-label"><label for="email">Email</label> *</td>
32-
<td nowrap><input size="20" type="text" id="email" name="email" value="<%=h(form.getEmail())%>"/></td>
33-
</tr>
34-
<tr>
35-
<td colspan="2"><labkey:button text="Submit" /></td>
36-
</tr>
37-
</table>
38-
</form>
13+
<labkey:form method="post" action="<%=urlFor(BeginAction.class)%>" layout="horizontal" autoComplete="off" style="max-width:600px;">
14+
<labkey:input name="firstName" id="firstName" label="First Name" isRequired="true" size="50" value="<%=form.getFirstName()%>"/>
15+
<labkey:input name="lastName" id="lastName" label="Last Name" isRequired="true" size="50" value="<%=form.getLastName()%>"/>
16+
<labkey:input name="organization" id="organization" label="Organization" isRequired="true" size="50" value="<%=form.getOrganization()%>"/>
17+
<labkey:input name="email" id="email" label="Email" isRequired="true" size="50" value="<%=form.getEmail()%>"/>
18+
<labkey:input name="emailConfirm" id="emailConfirm" label="Confirm Email" isRequired="true" size="50" value="<%=form.getEmailConfirm()%>"/>
19+
20+
<%-- Verification: standalone full-width block --%>
21+
<div style="margin-top:20px;"><strong>Verification</strong></div>
22+
<p style="margin:8px 0 4px 0;">Please enter the characters shown below (case-insensitive).</p>
23+
<p style="margin:0 0 8px 0;"><a id="kaptchaReload" href="#">Get a new image.</a></p>
24+
<img id="kaptchaImg" src="<%=h(contextPath)%>/kaptcha.jpg" alt="Captcha" width="200" height="50" style="border: 1px solid #ccc; display:block; margin-bottom:6px;"/>
25+
<input type="text" id="kaptchaText" name="kaptchaText" aria-label="Verification code" style="width:200px;"/>
26+
27+
<div style="margin-top:20px; clear:both;">
28+
<button type="submit" class="btn btn-default labkey-button">Register</button>
29+
</div>
30+
</labkey:form>
31+
32+
<script type="text/javascript" nonce="<%=getScriptNonce()%>">
33+
document.getElementById("kaptchaReload").addEventListener("click", function(e) {
34+
e.preventDefault();
35+
var img = document.getElementById("kaptchaImg");
36+
img.src = img.src.split("?")[0] + "?ts=" + new Date().getTime();
37+
});
38+
</script>

0 commit comments

Comments
 (0)