PasswordResetResource.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 net.tanesha.recaptcha.ReCaptchaImpl;
import net.tanesha.recaptcha.ReCaptchaResponse;
import org.apache.commons.lang.RandomStringUtils;
import org.entrystore.Entry;
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.EntryStoreApplication;
import org.entrystore.rest.auth.LoginTokenCache;
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.URLDecoder;
import java.security.SecureRandom;
import java.util.Date;

import static java.nio.charset.StandardCharsets.UTF_8;
import static org.restlet.data.Status.CLIENT_ERROR_REQUEST_ENTITY_TOO_LARGE;

/**
 * Supports resetting a user's password. Based on SignupResource.
 *
 * @author Hannes Ebner
 */
public class PasswordResetResource extends BaseResource {

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

	private final SimpleHTML html = new SimpleHTML("Password reset");

	@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);
			return html.representation("The confirmation token is invalid or has been used already.");
		}
		tc.removeToken(token);

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

			Entry userEntry = pm.getPrincipalEntry(ci.getEmail());
			User u = null;
			if (userEntry != null) {
				log.debug("Loaded user entry via email adress");
				u = (User) userEntry.getResource();
			} else {
				log.debug("Trying to load user entry via external ID");
				u = pm.getUserByExternalID(ci.getEmail());
			}
			if (u == null) {
				getResponse().setStatus(Status.CLIENT_ERROR_NOT_FOUND);
				if (ci.getUrlFailure() != null) {
					getResponse().redirectTemporary(URLDecoder.decode(ci.getUrlFailure(), UTF_8));
					return new EmptyRepresentation();
				} else {
					return html.representation("User with provided email address does not exist.");
				}
			}

			// Reset password
			if (u.setSaltedHashedSecret(ci.getSaltedHashedPassword())) {
				LoginTokenCache loginTokenCache = ((EntryStoreApplication)getApplication()).getLoginTokenCache();
				loginTokenCache.removeTokens(ci.getEmail());
				log.debug("Removed any authentication tokens belonging to user " + u.getURI());
				Email.sendPasswordChangeConfirmation(getRM().getConfiguration(), u.getEntry());
				log.info("Reset password for user " + u.getURI());
			} else {
				log.error("Error when resetting password for user " + u.getURI());
				getResponse().setStatus(Status.SERVER_ERROR_INTERNAL);
				if (ci.getUrlFailure() != null) {
					getResponse().redirectTemporary(URLDecoder.decode(ci.getUrlFailure(), UTF_8));
					return new EmptyRepresentation();
				} else {
					return html.representation("Unable to reset password due to internal error.");
				}
			}
		} finally {
			pm.setAuthenticatedUserURI(authUser);
		}

		if (ci.getUrlSuccess() != null) {
			getResponse().redirectTemporary(URLDecoder.decode(ci.getUrlSuccess(), UTF_8));
			return new EmptyRepresentation();
		} else {
			return html.representation("Password reset was 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
		String rcChallenge = null;
		String rcResponse = null;
		String rcResponseV2 = null;
		String password = null;

		if (MediaType.APPLICATION_JSON.equals(r.getMediaType())) {
			try {
				JSONObject siJson = new JSONObject(r.getText());
				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"));
				}
			} catch (Exception e) {
				getResponse().setStatus(Status.CLIENT_ERROR_BAD_REQUEST);
				return;
			}
		} else {
			Form form = new Form(getRequest().getEntity());
			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));
		}

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

		if (password.trim().length() < 8) {
			getResponse().setStatus(Status.CLIENT_ERROR_BAD_REQUEST);
			getResponse().setEntity(html.representation("The password has to consist of at least 8 characters."));
			return;
		}

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

		Config config = getRM().getConfiguration();

		log.info("Received password reset 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_BAD_REQUEST);
				getResponse().setEntity(html.representation("Invalid reCaptcha received."));
				return;
			}
		}

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

			Entry userEntry = pm.getPrincipalEntry(ci.getEmail());
			User u = null;
			if (userEntry != null) {
				log.debug("Loaded user entry via email adress");
				u = (User) userEntry.getResource();
			} else {
				log.debug("Trying to load user entry via external ID");
				u = pm.getUserByExternalID(ci.getEmail());
			}

			// to avoid spamming etc we only send emails to users that exist
			if (u != null) {
				String token = RandomStringUtils.random(16, 0, 0, true, true, null, new SecureRandom());
				String confirmationLink = getRM().getRepositoryURL().toExternalForm() + "auth/pwreset?confirm=" + token;
				log.info("Generated password reset token for " + ci.getEmail());

				boolean sendSuccessful = Email.sendPasswordResetConfirmation(getRM().getConfiguration(), 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;
				}
			} else {
				log.info("Ignoring password reset attempt for non-existing user " + ci.getEmail());
			}
		} finally {
			pm.setAuthenticatedUserURI(authUser);
		}

		getResponse().setStatus(Status.SUCCESS_OK);
		getResponse().setEntity(html.representation("A confirmation message was sent to " + ci.getEmail() + " if the user exists."));
	}

	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("E-Mail address<br/><input type=\"text\" name=\"email\"><br/>\n");
		sb.append("New 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 site key must be configured; rendering form without reCaptcha");
			} else {
				/* reCaptcha 1.0 (deprecated)
				String publicKey = config.getString(Settings.AUTH_RECAPTCHA_PUBLIC_KEY);
				ReCaptcha c = ReCaptchaFactory.newReCaptcha(publicKey, privateKey, false);
				reCaptchaHtml = c.createRecaptchaHtml(null, null);
				*/

				// 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=\"Reset password\" />\n");
		sb.append("</form>\n");
		sb.append(html.footer());
		return sb.toString();
	}

}