SignupResource.java

  1. /*
  2.  * Copyright (c) 2007-2017 MetaSolutions AB
  3.  *
  4.  * Licensed under the Apache License, Version 2.0 (the "License");
  5.  * you may not use this file except in compliance with the License.
  6.  * You may obtain a copy of the License at
  7.  *
  8.  *     http://www.apache.org/licenses/LICENSE-2.0
  9.  *
  10.  * Unless required by applicable law or agreed to in writing, software
  11.  * distributed under the License is distributed on an "AS IS" BASIS,
  12.  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13.  * See the License for the specific language governing permissions and
  14.  * limitations under the License.
  15.  */

  16. package org.entrystore.rest.resources;

  17. import com.google.common.base.Joiner;
  18. import net.tanesha.recaptcha.ReCaptchaImpl;
  19. import net.tanesha.recaptcha.ReCaptchaResponse;
  20. import org.apache.commons.lang.RandomStringUtils;
  21. import org.apache.commons.lang3.StringUtils;
  22. import org.entrystore.Context;
  23. import org.entrystore.Entry;
  24. import org.entrystore.GraphType;
  25. import org.entrystore.PrincipalManager;
  26. import org.entrystore.User;
  27. import org.entrystore.config.Config;
  28. import org.entrystore.repository.config.Settings;
  29. import org.entrystore.repository.security.Password;
  30. import org.entrystore.rest.auth.Signup;
  31. import org.entrystore.rest.auth.SignupInfo;
  32. import org.entrystore.rest.auth.SignupTokenCache;
  33. import org.entrystore.rest.util.Email;
  34. import org.entrystore.rest.util.EmailValidator;
  35. import org.entrystore.rest.util.HttpUtil;
  36. import org.entrystore.rest.util.RecaptchaVerifier;
  37. import org.entrystore.rest.util.SimpleHTML;
  38. import org.json.JSONObject;
  39. import org.restlet.data.Form;
  40. import org.restlet.data.Language;
  41. import org.restlet.data.MediaType;
  42. import org.restlet.data.Status;
  43. import org.restlet.representation.EmptyRepresentation;
  44. import org.restlet.representation.Representation;
  45. import org.restlet.representation.StringRepresentation;
  46. import org.restlet.resource.Get;
  47. import org.restlet.resource.Post;
  48. import org.restlet.resource.ResourceException;
  49. import org.slf4j.Logger;
  50. import org.slf4j.LoggerFactory;

  51. import java.net.URI;
  52. import java.net.URL;
  53. import java.net.URLDecoder;
  54. import java.nio.charset.StandardCharsets;
  55. import java.security.SecureRandom;
  56. import java.time.Instant;
  57. import java.time.temporal.ChronoUnit;
  58. import java.util.ArrayList;
  59. import java.util.Arrays;
  60. import java.util.HashMap;
  61. import java.util.HashSet;
  62. import java.util.Iterator;
  63. import java.util.List;
  64. import java.util.Set;

  65. import static org.restlet.data.Status.CLIENT_ERROR_REQUEST_ENTITY_TOO_LARGE;

  66. /**
  67.  * Resource to handle manual sign-ups.
  68.  *
  69.  * @author Hannes Ebner
  70.  */
  71. public class SignupResource extends BaseResource {

  72.     private static final Logger log = LoggerFactory.getLogger(SignupResource.class);

  73.     protected SimpleHTML html = new SimpleHTML("Sign-up");

  74.     private static Set<String> domainWhitelist = null;

  75.     private static final Object mutex = new Object();

  76.     @Override
  77.     public void doInit() {
  78.         synchronized (mutex) {
  79.             if (domainWhitelist == null) {
  80.                 Config config = getRM().getConfiguration();
  81.                 List<String> tmpDomainWhitelist = config.getStringList(Settings.SIGNUP_WHITELIST, new ArrayList<>());
  82.                 domainWhitelist = new HashSet<>();
  83.                 // we normalize the list to lower case and to not contain null
  84.                 for (String domain : tmpDomainWhitelist) {
  85.                     if (domain != null) {
  86.                         domainWhitelist.add(domain.toLowerCase());
  87.                     }
  88.                 }
  89.                 if (!domainWhitelist.isEmpty()) {
  90.                     log.info("Sign-up whitelist initialized with following domains: {}", Joiner.on(", ").join(domainWhitelist));
  91.                 } else {
  92.                     log.info("No domains provided for sign-up whitelist; sign-ups for any domain are allowed");
  93.                 }
  94.             }
  95.         }
  96.     }

  97.     @Get
  98.     public Representation represent() throws ResourceException {
  99.         if (!parameters.containsKey("confirm")) {
  100.             boolean reCaptcha = "on".equalsIgnoreCase(getRM().getConfiguration().getString(Settings.AUTH_RECAPTCHA, "off"));
  101.             return new StringRepresentation(constructHtmlForm(reCaptcha), MediaType.TEXT_HTML, Language.ENGLISH);
  102.         }

  103.         String token = parameters.get("confirm");
  104.         SignupTokenCache tc = SignupTokenCache.getInstance();
  105.         SignupInfo ci = tc.getTokenValue(token);
  106.         if (ci == null) {
  107.             getResponse().setStatus(Status.CLIENT_ERROR_BAD_REQUEST);
  108.             URL bURL = getRM().getRepositoryURL();
  109.             String appURL = bURL.getProtocol() + "://" + bURL.getHost() + (Arrays.asList(-1, 80, 443).contains(bURL.getPort()) ? "" : ":" + bURL.getPort());
  110.             return html.representation("<h4>Invalid confirmation link.</h4>" +
  111.                     "This may be due to one of the following reasons:<br/>" +
  112.                     "<ul><li>You have clicked the link twice and you already have an account.</li>" +
  113.                     "<li>The confirmation link has expired.</li>" +
  114.                     "<li>The link's confirmation token has never existed.</li></ul>" +
  115.                     "Click here to sign up again and to receive a new confirmation link:<br/>" +
  116.                     "<a href=\"" + appURL + "\"><pre>" + appURL + "</pre></a>");
  117.         }
  118.         tc.removeToken(token);

  119.         PrincipalManager pm = getPM();
  120.         URI authUser = pm.getAuthenticatedUserURI();
  121.         try {
  122.             pm.setAuthenticatedUserURI(pm.getAdminUser().getURI());

  123.             Entry userEntry = pm.getPrincipalEntry(ci.getEmail());
  124.             if ((userEntry != null && GraphType.User.equals(userEntry.getGraphType())) ||
  125.                     pm.getUserByExternalID(ci.getEmail()) != null) {
  126.                 getResponse().setStatus(Status.CLIENT_ERROR_CONFLICT);
  127.                 return html.representation("User with submitted email address exists already.");
  128.             }

  129.             // Create user
  130.             Entry entry = pm.createResource(null, GraphType.User, null, null);
  131.             if (entry == null) {
  132.                 if (ci.getUrlFailure() != null) {
  133.                     getResponse().redirectTemporary(URLDecoder.decode(ci.getUrlFailure(), StandardCharsets.UTF_8));
  134.                     return new EmptyRepresentation();
  135.                 } else {
  136.                     getResponse().setStatus(Status.SERVER_ERROR_INTERNAL);
  137.                 }
  138.                 return html.representation("Unable to create user.");
  139.             }

  140.             // Set alias, metadata and password
  141.             pm.setPrincipalName(entry.getResourceURI(), ci.getEmail());
  142.             Signup.setFoafMetadata(entry, new org.restlet.security.User("", "", ci.getFirstName(), ci.getLastName(), ci.getEmail()));
  143.             User u = (User) entry.getResource();
  144.             u.setSaltedHashedSecret(ci.getSaltedHashedPassword());
  145.             if (ci.getCustomProperties() != null) {
  146.                 u.setCustomProperties(ci.getCustomProperties());
  147.             }
  148.             log.info("Created user {}", u.getURI());

  149.             if ("on".equalsIgnoreCase(getRM().getConfiguration().getString(Settings.SIGNUP_CREATE_HOME_CONTEXT, "off"))) {
  150.                 // Create context and set ACL and alias
  151.                 Entry homeContext = getCM().createResource(null, GraphType.Context, null, null);
  152.                 homeContext.addAllowedPrincipalsFor(PrincipalManager.AccessProperty.Administer, u.getURI());
  153.                 getCM().setName(homeContext.getEntryURI(), ci.getEmail());
  154.                 log.info("Created context {}", homeContext.getResourceURI());

  155.                 // Set home context of user
  156.                 u.setHomeContext((Context) homeContext.getResource());
  157.                 log.info("Set home context of user {} to {}", u.getURI(), homeContext.getResourceURI());
  158.             }
  159.         } finally {
  160.             pm.setAuthenticatedUserURI(authUser);
  161.         }

  162.         if (ci.getUrlSuccess() != null) {
  163.             getResponse().redirectTemporary(URLDecoder.decode(ci.getUrlSuccess(), StandardCharsets.UTF_8));
  164.             return new EmptyRepresentation();
  165.         }
  166.         getResponse().setStatus(Status.SUCCESS_CREATED);
  167.         return html.representation("Sign-up successful.");

  168.     }

  169.     @Post
  170.     public void acceptRepresentation(Representation r) {
  171.         if (HttpUtil.isLargerThan(r, 32768)) {
  172.             log.warn("The size of the representation is larger than 32KB or unknown, request blocked");
  173.             getResponse().setStatus(CLIENT_ERROR_REQUEST_ENTITY_TOO_LARGE);
  174.             return;
  175.         }

  176.         SignupInfo ci = new SignupInfo(getRM());
  177.         ci.setExpirationDate(Instant.now().plus(1, ChronoUnit.DAYS)); // 24 hours later
  178.         ci.setCustomProperties(new HashMap<>());
  179.         String rcChallenge = null;
  180.         String rcResponse = null;
  181.         String rcResponseV2 = null;
  182.         String customPropPrefix = "custom_";
  183.         String password = null;

  184.         if (MediaType.APPLICATION_JSON.equals(r.getMediaType())) {
  185.             try {
  186.                 JSONObject siJson = new JSONObject(r.getText());
  187.                 if (siJson.has("firstname")) {
  188.                     ci.setFirstName(siJson.getString("firstname"));
  189.                 }
  190.                 if (siJson.has("lastname")) {
  191.                     ci.setLastName(siJson.getString("lastname"));
  192.                 }
  193.                 if (siJson.has("email")) {
  194.                     ci.setEmail(siJson.getString("email"));
  195.                 }
  196.                 if (siJson.has("password")) {
  197.                     password = siJson.getString("password");
  198.                 }
  199.                 if (siJson.has("recaptcha_challenge_field")) {
  200.                     rcChallenge = siJson.getString("recaptcha_challenge_field");
  201.                 }
  202.                 if (siJson.has("recaptcha_response_field")) {
  203.                     rcResponse = siJson.getString("recaptcha_response_field");
  204.                 }
  205.                 if (siJson.has("grecaptcharesponse")) {
  206.                     rcResponseV2 = siJson.getString("grecaptcharesponse");
  207.                 }
  208.                 if (siJson.has("urlfailure")) {
  209.                     ci.setUrlFailure(siJson.getString("urlfailure"));
  210.                 }
  211.                 if (siJson.has("urlsuccess")) {
  212.                     ci.setUrlSuccess(siJson.getString("urlsuccess"));
  213.                 }

  214.                 // Extract custom properties
  215.                 Iterator<String> siJsonKeyIt = siJson.keys();
  216.                 while (siJsonKeyIt.hasNext()) {
  217.                     String key = siJsonKeyIt.next();
  218.                     if (key.startsWith(customPropPrefix) && (key.length() > customPropPrefix.length())) {
  219.                         ci.getCustomProperties().put(key.substring(customPropPrefix.length()), siJson.getString(key));
  220.                     }
  221.                 }
  222.             } catch (Exception e) {
  223.                 getResponse().setStatus(Status.CLIENT_ERROR_BAD_REQUEST);
  224.                 return;
  225.             }
  226.         } else {
  227.             Form form = new Form(getRequest().getEntity());
  228.             ci.setFirstName(form.getFirstValue("firstname", true));
  229.             ci.setLastName(form.getFirstValue("lastname", true));
  230.             ci.setEmail(form.getFirstValue("email", true));
  231.             password = form.getFirstValue("password", true);
  232.             rcChallenge = form.getFirstValue("recaptcha_challenge_field", true);
  233.             rcResponse = form.getFirstValue("recaptcha_response_field", true);
  234.             rcResponseV2 = form.getFirstValue("g-recaptcha-response", true);
  235.             ci.setUrlFailure(form.getFirstValue("urlfailure", true));
  236.             ci.setUrlSuccess(form.getFirstValue("urlsuccess", true));

  237.             // Extract custom properties
  238.             for (String key : form.getNames()) {
  239.                 if (key.startsWith(customPropPrefix) && (key.length() > customPropPrefix.length())) {
  240.                     ci.getCustomProperties().put(key.substring(customPropPrefix.length()), form.getFirstValue(key));
  241.                 }
  242.             }
  243.         }

  244.         if (ci.getFirstName() == null || ci.getLastName() == null || ci.getEmail() == null || password == null) {
  245.             getResponse().setStatus(Status.CLIENT_ERROR_BAD_REQUEST);
  246.             getResponse().setEntity(html.representation("One or more parameters are missing."));
  247.             return;
  248.         }

  249.         password = password.trim();

  250.         if (isInvalidName(ci.getFirstName()) || isInvalidName(ci.getLastName())) {
  251.             getResponse().setStatus(Status.CLIENT_ERROR_BAD_REQUEST);
  252.             getResponse().setEntity(html.representation("Invalid name."));
  253.             return;
  254.         }

  255.         if (!Password.conformsToRules(password)) {
  256.             getResponse().setStatus(Status.CLIENT_ERROR_BAD_REQUEST);
  257.             getResponse().setEntity(html.representation("The password must conform to the configured rules."));
  258.             return;
  259.         }

  260.         if (!EmailValidator.getInstance().isValid(ci.getEmail())) {
  261.             getResponse().setStatus(Status.CLIENT_ERROR_BAD_REQUEST);
  262.             getResponse().setEntity(html.representation("Invalid email address: " + ci.getEmail()));
  263.             return;
  264.         }

  265.         if (!domainWhitelist.isEmpty()) {
  266.             String emailDomain = ci.getEmail().substring(ci.getEmail().indexOf("@") + 1).toLowerCase();
  267.             if (!domainWhitelist.contains(emailDomain)) {
  268.                 getResponse().setStatus(Status.CLIENT_ERROR_EXPECTATION_FAILED);
  269.                 getResponse().setEntity(html.representation("The email domain is not allowed for sign-up: " + emailDomain));
  270.                 return;
  271.             }
  272.         }

  273.         Config config = getRM().getConfiguration();

  274.         log.info("Received sign-up request for {}", ci.getEmail());

  275.         if ("on".equalsIgnoreCase(config.getString(Settings.AUTH_RECAPTCHA, "off"))
  276.                 && config.getString(Settings.AUTH_RECAPTCHA_PRIVATE_KEY) != null) {
  277.             if ((rcChallenge == null || rcResponse == null) && rcResponseV2 == null) {
  278.                 getResponse().setStatus(Status.CLIENT_ERROR_BAD_REQUEST);
  279.                 getResponse().setEntity(html.representation("reCaptcha information missing"));
  280.                 return;
  281.             }
  282.             log.info("Checking reCaptcha for {}", ci.getEmail());

  283.             String remoteAddr = getRequest().getClientInfo().getUpstreamAddress();
  284.             boolean reCaptchaIsValid;

  285.             if (rcResponseV2 != null) {
  286.                 RecaptchaVerifier rcVerifier = new RecaptchaVerifier(config.getString(Settings.AUTH_RECAPTCHA_PRIVATE_KEY));
  287.                 reCaptchaIsValid = rcVerifier.verify(rcResponseV2, remoteAddr);
  288.             } else {
  289.                 ReCaptchaImpl captcha = new ReCaptchaImpl();
  290.                 captcha.setPrivateKey(config.getString(Settings.AUTH_RECAPTCHA_PRIVATE_KEY));
  291.                 ReCaptchaResponse reCaptchaResponse = captcha.checkAnswer(remoteAddr, rcChallenge, rcResponse);
  292.                 reCaptchaIsValid = reCaptchaResponse.isValid();
  293.             }

  294.             if (reCaptchaIsValid) {
  295.                 log.info("Valid reCaptcha for {}", ci.getEmail());
  296.             } else {
  297.                 log.info("Invalid reCaptcha for {}", ci.getEmail());
  298.                 getResponse().setStatus(Status.CLIENT_ERROR_EXPECTATION_FAILED);
  299.                 getResponse().setEntity(html.representation("Invalid reCaptcha received."));
  300.                 return;
  301.             }
  302.         }

  303.         String token = RandomStringUtils.random(16, 0, 0, true, true, null, new SecureRandom());
  304.         String confirmationLink = getRM().getRepositoryURL().toExternalForm() + "auth/signup?confirm=" + token;
  305.         log.info("Generated sign-up token for " + ci.getEmail());

  306.         boolean sendSuccessful = Email.sendSignupConfirmation(getRM().getConfiguration(), ci.getFirstName() + " " + ci.getLastName(), ci.getEmail(), confirmationLink);
  307.         if (sendSuccessful) {
  308.             ci.setSaltedHashedPassword(Password.getSaltedHash(password));
  309.             SignupTokenCache.getInstance().putToken(token, ci);
  310.             log.info("Sent confirmation request to " + ci.getEmail());
  311.         } else {
  312.             log.info("Failed to send confirmation request to " + ci.getEmail());
  313.             getResponse().setStatus(Status.SERVER_ERROR_INTERNAL);
  314.             return;
  315.         }

  316.         getResponse().setStatus(Status.SUCCESS_OK);
  317.         getResponse().setEntity(html.representation("A confirmation message was sent to " + ci.getEmail()));
  318.     }

  319.     private String constructHtmlForm(boolean reCaptcha) {
  320.         Config config = getRM().getConfiguration();

  321.         StringBuilder sb = new StringBuilder();
  322.         sb.append(html.header());
  323.         sb.append("<form action=\"\" method=\"post\">\n");
  324.         sb.append("First name<br/><input type=\"text\" name=\"firstname\"><br/>\n");
  325.         sb.append("Last name<br/><input type=\"text\" name=\"lastname\"><br/>\n");
  326.         sb.append("E-Mail address<br/><input type=\"text\" name=\"email\"><br/>\n");
  327.         sb.append("Password<br/><input type=\"password\" name=\"password\"><br/>\n");
  328.         if (reCaptcha) {
  329.             String siteKey = config.getString(Settings.AUTH_RECAPTCHA_PUBLIC_KEY);
  330.             if (siteKey == null) {
  331.                 log.warn("reCaptcha keys must be configured; rendering form without reCaptcha");
  332.             } else {
  333.                 // reCaptcha 2.0
  334.                 sb.append("<script src=\"https://www.google.com/recaptcha/api.js\" async defer></script>\n");
  335.                 sb.append("<p>\n<div class=\"g-recaptcha\" data-sitekey=\"").append(siteKey).append("\"></div>\n</p>\n");
  336.             }
  337.         }
  338.         sb.append("<br/>\n<input type=\"submit\" value=\"Sign-up\" />\n");
  339.         sb.append("</form>\n");
  340.         sb.append(html.footer());

  341.         return sb.toString();
  342.     }

  343.     boolean isInvalidName(String name) {
  344.         // must not be null or too short
  345.         if (name == null || name.length() < 2) {
  346.             return true;
  347.         }
  348.         // must not be a URL (covers mailto: and others with slash)
  349.         if (name.contains(":") || name.contains("/")) {
  350.             return true;
  351.         }
  352.         // must not consist of more than five words (counting spaces in between words)
  353.         return StringUtils.countMatches(name, " ") >= 5;
  354.     }

  355. }