/*******************************************************************************
* Copyright (c) 2013, 2014 Lectorius, Inc.
* Authors:
* Vijay Pandurangan (vijayp@mitro.co)
* Evan Jones (ej@mitro.co)
* Adam Hilss (ahilss@mitro.co)
*
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* You can contact the authors at inbound@mitro.co.
*******************************************************************************/
package co.mitro.core.servlets;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.sql.SQLException;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
import javax.servlet.annotation.WebServlet;
import org.keyczar.exceptions.KeyczarException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import co.mitro.core.crypto.KeyInterfaces.CryptoError;
import co.mitro.core.crypto.KeyInterfaces.KeyFactory;
import co.mitro.core.crypto.KeyInterfaces.PublicKeyInterface;
import co.mitro.core.exceptions.DoEmailVerificationException;
import co.mitro.core.exceptions.DoTwoFactorAuthException;
import co.mitro.core.exceptions.MitroServletException;
import co.mitro.core.server.ManagerFactory;
import co.mitro.core.server.data.DBAudit;
import co.mitro.core.server.data.DBEmailQueue;
import co.mitro.core.server.data.DBIdentity;
import co.mitro.core.server.data.RPC;
import co.mitro.core.server.data.RPC.GetMyPrivateKeyRequest;
import co.mitro.core.server.data.RPC.MitroRPC;
import co.mitro.twofactor.CryptoForBackupCodes;
import co.mitro.twofactor.TwoFactorCodeChecker;
import co.mitro.twofactor.TwoFactorSigningService;
import com.google.common.base.Strings;
import com.j256.ormlite.dao.GenericRawResults;
import com.j256.ormlite.stmt.QueryBuilder;
@WebServlet("/api/GetMyPrivateKey")
public class GetMyPrivateKey extends MitroServlet {
private static final Logger logger = LoggerFactory.getLogger(MitroServlet.class);
private static final long serialVersionUID = 1L;
public static final String DEMO_ACCOUNT = "mitro.demo@gmail.com";
public static boolean doEmailVerification = true;
public GetMyPrivateKey(ManagerFactory mf, KeyFactory keyFactory) {
super(mf, keyFactory);
// Crash on startup if secrets aren't loaded
TwoFactorSigningService.checkInitialized();
}
@Override
protected MitroRPC processCommand(MitroRequestContext context) throws MitroServletException, DoTwoFactorAuthException, DoEmailVerificationException, SQLException {
RPC.GetMyPrivateKeyRequest in = gson.fromJson(context.jsonRequest,
RPC.GetMyPrivateKeyRequest.class);
boolean okToProvideKey = false;
DBIdentity identity = null;
String deviceKeyString = null;
boolean twoFactorRequired = true;
try {
identity = DBIdentity.getIdentityForUserName(context.manager, in.userId);
if (null == identity) {
// Throw this so we don't leak info about which email accounts are valid.
throw new DoEmailVerificationException();
}
twoFactorRequired = identity.isTwoFactorAuthEnabled();
deviceKeyString = GetMyDeviceKey.maybeGetClientKeyForLogin(context.manager, identity, in.deviceId, context.platform);
} catch (SQLException | MitroServletException e) {
throw new DoEmailVerificationException(e);
}
// see if we know about this device. If not, we make the user log in by clicking on an email.
if (null == deviceKeyString) {
if (identity.getChangePasswordOnNextLogin()) {
// Hack for invited users: Auto-register devices
// Previously we limited this to 1, but users who abandoned it and tried later ran into
// weird auto-login loops. Easier to permit multiple devices until they change password?
// TODO: Verify a token/nonce to ensure the user came from the invite email?
deviceKeyString = GetMyDeviceKey.maybeGetOrCreateDeviceKey(
context.manager, identity, in.deviceId, false, context.platform);
} else if (!identity.getName().equals(DEMO_ACCOUNT) && doEmailVerification) {
if (in.automatic) {
// The device is unknown but the user has a token, and the device is automatically logging in.
// this used to cause a login loop, and we should not send an email
logger.info("Not actually sending email for {} because it's an automatic login", identity.getName());
throw new DoEmailVerificationException();
}
// we need to do an email verification now.
sendEmailAndThrow(in, context, identity);
}
}
if (!Strings.isNullOrEmpty(in.loginToken)) {
// TODO: this is because the crypto code will throw a NullPointerException
// if you pass in an empty signature.
if (null == in.loginTokenSignature) {
in.loginTokenSignature = "";
}
RPC.LoginToken signedToken = gson.fromJson(in.loginToken,
RPC.LoginToken.class);
if (!signedToken.email.equals(identity.getName())) {
//throw new MitroServletException("mismatched token");
throw new DoEmailVerificationException();
}
if (!signedToken.twoFactorAuthVerified) {
// this should be signed by the user.
try {
PublicKeyInterface key = keyFactory.loadPublicKey(identity
.getPublicKeyString());
if (!key.verify(in.loginToken, in.loginTokenSignature)) {
//throw new MitroServletException("invalid token");
throw new DoEmailVerificationException();
}
okToProvideKey = true;
} catch (CryptoError e) {
// crypto errors mean the signature or key is not valid
throw new DoEmailVerificationException(e);
}
} else { // signed token is a tfa token
// 2fa was used! verify key and signature
if (!TwoFactorSigningService.verifySignatureAndTimestamp(
in.loginToken, in.loginTokenSignature, signedToken.timestampMs)) {
throw new MitroServletException("invalid 2fa token");
}
okToProvideKey = true;
}
} // end if(token)
if (!okToProvideKey) {
// we don't have a token, so this is a new login. We need to verify email and maybe TFA
// we know about the device
if (twoFactorRequired) {
boolean twoFactorCodeCorrect = false;
if (!Strings.isNullOrEmpty(in.twoFactorCode)) {
long twoFactorCode = Long.parseLong(in.twoFactorCode);
twoFactorCodeCorrect = TwoFactorCodeChecker.checkCode(
identity.getTwoFactorSecret(), twoFactorCode, System.currentTimeMillis())
|| CryptoForBackupCodes.tryBackupCode(identity.getTwoFactorSecret(),
in.twoFactorCode, identity, context.manager);
}
if (!twoFactorCodeCorrect) {
sendToTFAAndThrow(context, in, identity);
} else {
okToProvideKey = true;
}
}
}
RPC.GetMyPrivateKeyResponse out = new RPC.GetMyPrivateKeyResponse();
out.myUserId = identity.getName();
out.encryptedPrivateKey = identity.getEncryptedPrivateKeyString();
out.changePasswordOnNextLogin = identity.getChangePasswordOnNextLogin();
out.verified = identity.isVerified();
out.unsignedLoginToken = makeLoginTokenString(identity, in.extensionId, in.deviceId);
if (!okToProvideKey) {
// in this case we have an authorized device but no token.
// We provide the encrypted key but not the device key.
out.deviceKeyString = null;
} else {
out.deviceKeyString = deviceKeyString;
}
try {
context.manager.addAuditLog(DBAudit.ACTION.GET_PRIVATE_KEY, identity, null, null, null,
null);
} catch (SQLException e) {
throw new DoEmailVerificationException(e);
}
// authorize this device if it has not yet been authorized.
assert(!Strings.isNullOrEmpty(in.deviceId));
if (deviceKeyString == null) {
GetMyDeviceKey.maybeGetOrCreateDeviceKey(context.manager, identity, in.deviceId, false, context.platform);
}
return out;
}
private void sendToTFAAndThrow(MitroRequestContext context,
GetMyPrivateKeyRequest in,
DBIdentity identity) throws MitroServletException {
// make token check password
String token = makeLoginTokenString(identity, in.extensionId, in.deviceId);
try {
String tokenSignature = TwoFactorSigningService.signToken(token);
throw new DoTwoFactorAuthException(
context.requestServerUrl +
"/mitro-core/TwoFactorAuth?token="
+ URLEncoder.encode(token, "UTF-8") + "&signature="
+ URLEncoder.encode(tokenSignature, "UTF-8"));
} catch (KeyczarException e) {
throw new MitroServletException(e);
} catch (UnsupportedEncodingException e) {
// this will never happen: UTF-8 must be supported by all JVMs
throw new MitroServletException(e);
}
}
private void sendEmailAndThrow(GetMyPrivateKeyRequest in, MitroRequestContext context, DBIdentity identity) throws MitroServletException {
try {
String token = makeLoginTokenString(identity, in.extensionId, in.deviceId);
String tokenSignature = TwoFactorSigningService.signToken(token);
GenericRawResults<String[]> queuedEmails = context.manager.emailDao.queryRaw(
"SELECT type_string, arg_string FROM ("
+ "(SELECT type_string, arg_string FROM email_queue)"
+ " UNION "
+ "(SELECT type_string, arg_string FROM email_queue_sent) "
+ ") AS email_queues_merged WHERE type_string=? AND arg_string LIKE ?",
DBEmailQueue.Type.LOGIN_ON_NEW_DEVICE.getValue(),
"%" + identity.getName() + "%"
);
boolean recentEmail = false;
// this should be UTC as per the JDK documentation
final long now = System.currentTimeMillis();
for(String[] emailArguments : queuedEmails) {
String[] args = DBEmailQueue.decodeArguments(emailArguments[1]);
String emailRecipient = args[0];
DBEmailQueue.DeviceVerificationArguments arguments = DBEmailQueue.decodeDeviceVerificationArguments(args[1]);
// Gautier: People trying to sign in a few times in a row is a common
// pattern I've seen in the logs so I'd like to make sure we don't
// flood their inboxes and trigger spam filters. A new token valid
// for 12 hours is generated every time the users try to sign in on a
// new device. With this PR, users are able to resend 15 minutes
// after the previous try.
//
// timestampMs is used at the token creating in the
// makeLoginTokenString function using System.currentTimeMillis
if (emailRecipient.equals(identity.getName())
&& arguments.deviceId != null && in.deviceId != null && arguments.deviceId.equals(in.deviceId)
&& (now - arguments.timestampMs) < 1000 * 60 * 15) {
recentEmail = true;
break;
}
}
if (!recentEmail) {
if (!context.manager.isReadOnly()) {
DBEmailQueue email = DBEmailQueue.makeNewDeviceVerification(identity.getName(), token, tokenSignature, context.platform, context.requestServerUrl, context.sourceIp);
context.manager.emailDao.create(email);
context.manager.commitTransaction();
}
// TODO: throw ReadOnlyServerException if isReadOnly, after we don't retry on the secondary!
logger.info("Forcing user {} to verify device {}", identity.getName(), in.deviceId);
} else {
logger.info("Forcing user {} to verify device {} (email already sent)", identity.getName(), in.deviceId);
}
throw new DoEmailVerificationException();
} catch (SQLException|KeyczarException e) {
throw new MitroServletException(e);
}
}
public static String makeLoginTokenString(DBIdentity identity, String extensionId, String deviceId) {
// TODO: add device id in here.
RPC.LoginToken lt = new RPC.LoginToken();
lt.email = identity.getName();
lt.extensionId = extensionId;
lt.timestampMs = System.currentTimeMillis();
lt.deviceId = deviceId;
return gson.toJson(lt);
}
}