SignupResource.java
/*
* Copyright (c) 2007-2017 MetaSolutions AB
*
* 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 org.entrystore.rest.resources;
import com.google.common.base.Joiner;
import net.tanesha.recaptcha.ReCaptchaImpl;
import net.tanesha.recaptcha.ReCaptchaResponse;
import org.apache.commons.lang.RandomStringUtils;
import org.apache.commons.lang3.StringUtils;
import org.entrystore.Context;
import org.entrystore.Entry;
import org.entrystore.GraphType;
import org.entrystore.PrincipalManager;
import org.entrystore.User;
import org.entrystore.config.Config;
import org.entrystore.repository.config.Settings;
import org.entrystore.repository.security.Password;
import org.entrystore.rest.auth.Signup;
import org.entrystore.rest.auth.SignupInfo;
import org.entrystore.rest.auth.SignupTokenCache;
import org.entrystore.rest.util.Email;
import org.entrystore.rest.util.EmailValidator;
import org.entrystore.rest.util.HttpUtil;
import org.entrystore.rest.util.RecaptchaVerifier;
import org.entrystore.rest.util.SimpleHTML;
import org.json.JSONObject;
import org.restlet.data.Form;
import org.restlet.data.Language;
import org.restlet.data.MediaType;
import org.restlet.data.Status;
import org.restlet.representation.EmptyRepresentation;
import org.restlet.representation.Representation;
import org.restlet.representation.StringRepresentation;
import org.restlet.resource.Get;
import org.restlet.resource.Post;
import org.restlet.resource.ResourceException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URI;
import java.net.URL;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import static org.restlet.data.Status.CLIENT_ERROR_REQUEST_ENTITY_TOO_LARGE;
/**
* Resource to handle manual sign-ups.
*
* @author Hannes Ebner
*/
public class SignupResource extends BaseResource {
private static final Logger log = LoggerFactory.getLogger(SignupResource.class);
protected SimpleHTML html = new SimpleHTML("Sign-up");
private static Set<String> domainWhitelist = null;
private static final Object mutex = new Object();
@Override
public void doInit() {
synchronized (mutex) {
if (domainWhitelist == null) {
Config config = getRM().getConfiguration();
List<String> tmpDomainWhitelist = config.getStringList(Settings.SIGNUP_WHITELIST, new ArrayList<String>());
domainWhitelist = new HashSet<>();
// we normalize the list to lower case and to not contain null
for (String domain : tmpDomainWhitelist) {
if (domain != null) {
domainWhitelist.add(domain.toLowerCase());
}
}
if (domainWhitelist.size() > 0) {
log.info("Sign-up whitelist initialized with following domains: " + Joiner.on(", ").join(domainWhitelist));
} else {
log.info("No domains provided for sign-up whitelist; sign-ups for any domain are allowed");
}
}
}
}
@Get
public Representation represent() throws ResourceException {
if (!parameters.containsKey("confirm")) {
boolean reCaptcha = "on".equalsIgnoreCase(getRM().getConfiguration().getString(Settings.AUTH_RECAPTCHA, "off"));
return new StringRepresentation(constructHtmlForm(reCaptcha), MediaType.TEXT_HTML, Language.ENGLISH);
}
String token = parameters.get("confirm");
SignupTokenCache tc = SignupTokenCache.getInstance();
SignupInfo ci = tc.getTokenValue(token);
if (ci == null) {
getResponse().setStatus(Status.CLIENT_ERROR_BAD_REQUEST);
URL bURL = getRM().getRepositoryURL();
String appURL = bURL.getProtocol() + "://" + bURL.getHost() + (Arrays.asList(-1, 80, 443).contains(bURL.getPort()) ? "" : ":" + bURL.getPort());
return html.representation("<h4>Invalid confirmation link.</h4>" +
"This may be due to one of the following reasons:<br/>" +
"<ul><li>You have clicked the link twice and you already have an account.</li>" +
"<li>The confirmation link has expired.</li>" +
"<li>The link's confirmation token has never existed.</li></ul>" +
"Click here to sign up again and to receive a new confirmation link:<br/>" +
"<a href=\"" + appURL + "\"><pre>" + appURL + "</pre></a>");
}
tc.removeToken(token);
PrincipalManager pm = getPM();
URI authUser = pm.getAuthenticatedUserURI();
try {
pm.setAuthenticatedUserURI(pm.getAdminUser().getURI());
Entry userEntry = pm.getPrincipalEntry(ci.getEmail());
if ((userEntry != null && GraphType.User.equals(userEntry.getGraphType())) ||
pm.getUserByExternalID(ci.getEmail()) != null) {
getResponse().setStatus(Status.CLIENT_ERROR_CONFLICT);
return html.representation("User with submitted email address exists already.");
}
// Create user
Entry entry = pm.createResource(null, GraphType.User, null, null);
if (entry == null) {
if (ci.getUrlFailure() != null) {
getResponse().redirectTemporary(URLDecoder.decode(ci.getUrlFailure(), StandardCharsets.UTF_8));
return new EmptyRepresentation();
} else {
getResponse().setStatus(Status.SERVER_ERROR_INTERNAL);
}
return html.representation("Unable to create user.");
}
// Set alias, metadata and password
pm.setPrincipalName(entry.getResourceURI(), ci.getEmail());
Signup.setFoafMetadata(entry, new org.restlet.security.User("", "", ci.getFirstName(), ci.getLastName(), ci.getEmail()));
User u = (User) entry.getResource();
u.setSaltedHashedSecret(ci.getSaltedHashedPassword());
if (ci.getCustomProperties() != null) {
u.setCustomProperties(ci.getCustomProperties());
}
log.info("Created user " + u.getURI());
if ("on".equalsIgnoreCase(getRM().getConfiguration().getString(Settings.SIGNUP_CREATE_HOME_CONTEXT, "off"))) {
// Create context and set ACL and alias
Entry homeContext = getCM().createResource(null, GraphType.Context, null, null);
homeContext.addAllowedPrincipalsFor(PrincipalManager.AccessProperty.Administer, u.getURI());
getCM().setName(homeContext.getEntryURI(), ci.getEmail());
log.info("Created context " + homeContext.getResourceURI());
// Set home context of user
u.setHomeContext((Context) homeContext.getResource());
log.info("Set home context of user " + u.getURI() + " to " + homeContext.getResourceURI());
}
} finally {
pm.setAuthenticatedUserURI(authUser);
}
if (ci.getUrlSuccess() != null) {
getResponse().redirectTemporary(URLDecoder.decode(ci.getUrlSuccess(), StandardCharsets.UTF_8));
return new EmptyRepresentation();
}
getResponse().setStatus(Status.SUCCESS_CREATED);
return html.representation("Sign-up successful.");
}
@Post
public void acceptRepresentation(Representation r) {
if (HttpUtil.isLargerThan(r, 32768)) {
log.warn("The size of the representation is larger than 32KB or unknown, request blocked");
getResponse().setStatus(CLIENT_ERROR_REQUEST_ENTITY_TOO_LARGE);
return;
}
SignupInfo ci = new SignupInfo(getRM());
ci.setExpirationDate(new Date(new Date().getTime() + (24 * 3600 * 1000))); // 24 hours later
ci.setCustomProperties(new HashMap<>());
String rcChallenge = null;
String rcResponse = null;
String rcResponseV2 = null;
String customPropPrefix = "custom_";
String password = null;
if (MediaType.APPLICATION_JSON.equals(r.getMediaType())) {
try {
JSONObject siJson = new JSONObject(r.getText());
if (siJson.has("firstname")) {
ci.setFirstName(siJson.getString("firstname"));
}
if (siJson.has("lastname")) {
ci.setLastName(siJson.getString("lastname"));
}
if (siJson.has("email")) {
ci.setEmail(siJson.getString("email"));
}
if (siJson.has("password")) {
password = siJson.getString("password");
}
if (siJson.has("recaptcha_challenge_field")) {
rcChallenge = siJson.getString("recaptcha_challenge_field");
}
if (siJson.has("recaptcha_response_field")) {
rcResponse = siJson.getString("recaptcha_response_field");
}
if (siJson.has("grecaptcharesponse")) {
rcResponseV2 = siJson.getString("grecaptcharesponse");
}
if (siJson.has("urlfailure")) {
ci.setUrlFailure(siJson.getString("urlfailure"));
}
if (siJson.has("urlsuccess")) {
ci.setUrlSuccess(siJson.getString("urlsuccess"));
}
// Extract custom properties
Iterator<String> siJsonKeyIt = siJson.keys();
while (siJsonKeyIt.hasNext()) {
String key = (String) siJsonKeyIt.next();
if (key.startsWith(customPropPrefix) && (key.length() > customPropPrefix.length())) {
ci.getCustomProperties().put(key.substring(customPropPrefix.length()), siJson.getString(key));
}
}
} catch (Exception e) {
getResponse().setStatus(Status.CLIENT_ERROR_BAD_REQUEST);
return;
}
} else {
Form form = new Form(getRequest().getEntity());
ci.setFirstName(form.getFirstValue("firstname", true));
ci.setLastName(form.getFirstValue("lastname", true));
ci.setEmail(form.getFirstValue("email", true));
password = form.getFirstValue("password", true);
rcChallenge = form.getFirstValue("recaptcha_challenge_field", true);
rcResponse = form.getFirstValue("recaptcha_response_field", true);
rcResponseV2 = form.getFirstValue("g-recaptcha-response", true);
ci.setUrlFailure(form.getFirstValue("urlfailure", true));
ci.setUrlSuccess(form.getFirstValue("urlsuccess", true));
// Extract custom properties
for (String key : form.getNames()) {
if (key.startsWith(customPropPrefix) && (key.length() > customPropPrefix.length())) {
ci.getCustomProperties().put(key.substring(customPropPrefix.length()), form.getFirstValue(key));
}
}
}
if (ci.getFirstName() == null || ci.getLastName() == null || ci.getEmail() == null || password == null) {
getResponse().setStatus(Status.CLIENT_ERROR_BAD_REQUEST);
getResponse().setEntity(html.representation("One or more parameters are missing."));
return;
}
password = password.trim();
if (isInvalidName(ci.getFirstName()) || isInvalidName(ci.getLastName())) {
getResponse().setStatus(Status.CLIENT_ERROR_BAD_REQUEST);
getResponse().setEntity(html.representation("Invalid name."));
return;
}
if (!Password.conformsToRules(password)) {
getResponse().setStatus(Status.CLIENT_ERROR_BAD_REQUEST);
getResponse().setEntity(html.representation("The password must conform to the configured rules."));
return;
}
if (!EmailValidator.getInstance().isValid(ci.getEmail())) {
getResponse().setStatus(Status.CLIENT_ERROR_BAD_REQUEST);
getResponse().setEntity(html.representation("Invalid email address: " + ci.getEmail()));
return;
}
if (!domainWhitelist.isEmpty()) {
String emailDomain = ci.getEmail().substring(ci.getEmail().indexOf("@") + 1).toLowerCase();
if (!domainWhitelist.contains(emailDomain)) {
getResponse().setStatus(Status.CLIENT_ERROR_EXPECTATION_FAILED);
getResponse().setEntity(html.representation("The email domain is not allowed for sign-up: " + emailDomain));
return;
}
}
Config config = getRM().getConfiguration();
log.info("Received sign-up request for " + ci.getEmail());
if ("on".equalsIgnoreCase(config.getString(Settings.AUTH_RECAPTCHA, "off"))
&& config.getString(Settings.AUTH_RECAPTCHA_PRIVATE_KEY) != null) {
if ((rcChallenge == null || rcResponse == null) && rcResponseV2 == null) {
getResponse().setStatus(Status.CLIENT_ERROR_BAD_REQUEST);
getResponse().setEntity(html.representation("reCaptcha information missing"));
return;
}
log.info("Checking reCaptcha for " + ci.getEmail());
String remoteAddr = getRequest().getClientInfo().getUpstreamAddress();
boolean reCaptchaIsValid = false;
if (rcResponseV2 != null) {
RecaptchaVerifier rcVerifier = new RecaptchaVerifier(config.getString(Settings.AUTH_RECAPTCHA_PRIVATE_KEY));
reCaptchaIsValid = rcVerifier.verify(rcResponseV2, remoteAddr);
} else {
ReCaptchaImpl captcha = new ReCaptchaImpl();
captcha.setPrivateKey(config.getString(Settings.AUTH_RECAPTCHA_PRIVATE_KEY));
ReCaptchaResponse reCaptchaResponse = captcha.checkAnswer(remoteAddr, rcChallenge, rcResponse);
reCaptchaIsValid = reCaptchaResponse.isValid();
}
if (reCaptchaIsValid) {
log.info("Valid reCaptcha for " + ci.getEmail());
} else {
log.info("Invalid reCaptcha for " + ci.getEmail());
getResponse().setStatus(Status.CLIENT_ERROR_EXPECTATION_FAILED);
getResponse().setEntity(html.representation("Invalid reCaptcha received."));
return;
}
}
String token = RandomStringUtils.random(16, 0, 0, true, true, null, new SecureRandom());
String confirmationLink = getRM().getRepositoryURL().toExternalForm() + "auth/signup?confirm=" + token;
log.info("Generated sign-up token for " + ci.getEmail());
boolean sendSuccessful = Email.sendSignupConfirmation(getRM().getConfiguration(), ci.getFirstName() + " " + ci.getLastName(), ci.getEmail(), confirmationLink);
if (sendSuccessful) {
ci.setSaltedHashedPassword(Password.getSaltedHash(password));
SignupTokenCache.getInstance().putToken(token, ci);
log.info("Sent confirmation request to " + ci.getEmail());
} else {
log.info("Failed to send confirmation request to " + ci.getEmail());
getResponse().setStatus(Status.SERVER_ERROR_INTERNAL);
return;
}
getResponse().setStatus(Status.SUCCESS_OK);
getResponse().setEntity(html.representation("A confirmation message was sent to " + ci.getEmail()));
}
private String constructHtmlForm(boolean reCaptcha) {
Config config = getRM().getConfiguration();
StringBuilder sb = new StringBuilder();
sb.append(html.header());
sb.append("<form action=\"\" method=\"post\">\n");
sb.append("First name<br/><input type=\"text\" name=\"firstname\"><br/>\n");
sb.append("Last name<br/><input type=\"text\" name=\"lastname\"><br/>\n");
sb.append("E-Mail address<br/><input type=\"text\" name=\"email\"><br/>\n");
sb.append("Password<br/><input type=\"password\" name=\"password\"><br/>\n");
if (reCaptcha) {
String siteKey = config.getString(Settings.AUTH_RECAPTCHA_PUBLIC_KEY);
if (siteKey == null) {
log.warn("reCaptcha keys must be configured; rendering form without reCaptcha");
} else {
// reCaptcha 2.0
sb.append("<script src=\"https://www.google.com/recaptcha/api.js\" async defer></script>\n");
sb.append("<p>\n<div class=\"g-recaptcha\" data-sitekey=\"").append(siteKey).append("\"></div>\n</p>\n");
}
}
sb.append("<br/>\n<input type=\"submit\" value=\"Sign-up\" />\n");
sb.append("</form>\n");
sb.append(html.footer());
return sb.toString();
}
boolean isInvalidName(String name) {
// must not be null or too short
if (name == null || name.length() < 2) {
return true;
}
// must not be a URL (covers mailto: and others with slash)
if (name.contains(":") || name.contains("/")) {
return true;
}
// must not consist of more than five words (counting spaces in between words)
return StringUtils.countMatches(name, " ") >= 5;
}
}