/***********************************************************************
* This file is part of iDempiere ERP Open Source *
* http://www.idempiere.org *
* *
* Copyright (C) Contributors *
* *
* 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 2 *
* 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, write to the Free Software *
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, *
* MA 02110-1301, USA. *
* *
* Contributors: *
* - Carlos Ruiz (sponsored by FH) *
**********************************************************************/
package org.idempiere.mfa;
import static dev.samstevens.totp.util.Utils.getDataUriForImage;
import java.net.UnknownHostException;
import java.sql.Timestamp;
import java.util.Properties;
import org.adempiere.exceptions.AdempiereException;
import org.compiere.model.IMFAMechanism;
import org.compiere.model.MMFAMethod;
import org.compiere.model.MMFARegistration;
import org.compiere.model.MSysConfig;
import org.compiere.model.MUser;
import org.compiere.util.Env;
import org.compiere.util.Msg;
import org.compiere.util.Util;
import dev.samstevens.totp.code.CodeGenerator;
import dev.samstevens.totp.code.DefaultCodeGenerator;
import dev.samstevens.totp.code.DefaultCodeVerifier;
import dev.samstevens.totp.exceptions.QrGenerationException;
import dev.samstevens.totp.qr.QrData;
import dev.samstevens.totp.qr.QrGenerator;
import dev.samstevens.totp.qr.ZxingPngQrGenerator;
import dev.samstevens.totp.secret.DefaultSecretGenerator;
import dev.samstevens.totp.secret.SecretGenerator;
import dev.samstevens.totp.time.NtpTimeProvider;
import dev.samstevens.totp.time.SystemTimeProvider;
import dev.samstevens.totp.time.TimeProvider;
/**
* Time-based one-time password (TOTP) based multi-factor authentication implementation
*/
public class TOTPMechanism implements IMFAMechanism {
/**
* Implement the registration mechanism for TOTP
* Generate the secret and the qrcode and return in the array
* @param ctx
* @param method
* @param prm optional - assigned name from the user
* @param trxName
* @return Object[] - first object is the String with the instructions to follow
* second object is the registration generated
* third message qrcode
* fourth qrcode
* fifth message secret
* sixth secret
*/
@Override
public Object[] register(Properties ctx, MMFAMethod method, String prm, String trxName) {
if (Util.isEmpty(method.getMFAIssuer()))
throw new AdempiereException(Msg.getMsg(ctx, "MFATOTPIssuerRequired"));
if (MMFARegistration.alreadyExistsValid(method, prm))
throw new AdempiereException(Msg.getMsg(ctx, "MFAMethodAlreadyRegistered"));
MUser user = MUser.get(ctx);
String label;
boolean email_login = MSysConfig.getBooleanValue(MSysConfig.USE_EMAIL_FOR_LOGIN, false);
if (email_login)
label = user.getEMail();
else
label = user.getName();
SecretGenerator secretGenerator = new DefaultSecretGenerator();
String secret = secretGenerator.generate();
QrData data = new QrData.Builder()
.label(label)
.secret(secret)
.issuer(method.getMFAIssuer())
.build();
QrGenerator generator = new ZxingPngQrGenerator();
byte[] imageData;
try {
imageData = generator.generate(data);
} catch (QrGenerationException e) {
throw new AdempiereException(Msg.getMsg(Env.getCtx(), "MFATOTPErrorGeneratingQR"), e);
}
String mimeType = generator.getImageMimeType();
String dataUri = getDataUriForImage(imageData, mimeType);
int expireMinutes = method.getExpireInMinutes();
MMFARegistration reg = new MMFARegistration(ctx, 0, trxName);
reg.set_ValueOfColumn(MMFARegistration.COLUMNNAME_AD_Client_ID, user.getAD_Client_ID());
reg.setAD_Org_ID(0);
if (! Util.isEmpty(prm)) {
reg.setName(prm);
reg.setParameterValue(prm);
} else {
reg.setName(label);
}
reg.setMFA_Method_ID(method.getMFA_Method_ID());
reg.setAD_User_ID(user.getAD_User_ID());
reg.setMFASecret(secret);
reg.setIsValid(false);
reg.setIsUserMFAPreferred(false);
if (expireMinutes > 0)
reg.setExpiration(new Timestamp(System.currentTimeMillis() + (expireMinutes*60000)));
saveRegistration(reg);
// Invalidate any other previous pending registration with same method
MMFARegistration.invalidatePreviousPending(method, prm, reg);
// Notify the user that TOTP mechanism was registered and follow next step
Object[] ret = new Object[6];
ret[0] = Msg.getMsg(Env.getCtx(), "MFATOTPRegistered");
ret[1] = reg;
ret[2] = Msg.getMsg(Env.getCtx(), "MFATOTPImage");
ret[3] = dataUri;
ret[4] = Msg.getMsg(Env.getCtx(), "MFATOTPSecret");
ret[5] = secret;
return ret;
}
/**
* Complete/Validate a previous TOTP registration
* @param ctx
* @param reg The registration object
* @param p_MFAValidationCode The code to be validated
* @param p_Name Optional - a name to assign the registration
* @param trxName
* @return msg A message indicating success, errors throw exception
*/
@Override
public String complete(Properties ctx, MMFARegistration reg, String code, String name, boolean preferred, String trxName) {
if (! isValidCode(ctx, reg, code, trxName))
throw new AdempiereException(Msg.getMsg(ctx, "MFACodeInvalid"));
// valid code
reg.setIsValid(true);
reg.setMFAValidatedAt(new Timestamp(System.currentTimeMillis()));
reg.setExpiration(null);
if (!Util.isEmpty(name))
reg.setName(name);
if (preferred)
reg.setIsUserMFAPreferred(true);
saveRegistration(reg);
return Msg.getMsg(ctx, "MFARegistrationCompleted");
}
/**
* @param reg
* @param code
* @param trxName
* @return
*/
private boolean isValidCode(Properties ctx, MMFARegistration reg, String code, String trxName) {
MMFAMethod method = new MMFAMethod(ctx, reg.getMFA_Method_ID(), trxName);
TimeProvider timeProvider;
if (MMFAMethod.MFATIMEPROVIDER_Ntp.equals(method.getMFATimeProvider())) {
String ntpServer = method.getMFATimeServer();
if (Util.isEmpty(ntpServer))
ntpServer = "pool.ntp.org";
int timeout = MSysConfig.getIntValue(MSysConfig.MFA_NTP_TIMEOUT_IN_MILLISECONDS, 5000);
try {
timeProvider = new NtpTimeProvider(ntpServer, timeout);
} catch (UnknownHostException e) {
throw new AdempiereException(Msg.getMsg(ctx, "MFANTPServerError") + e.getLocalizedMessage(), e);
}
} else { // default to System
timeProvider = new SystemTimeProvider();
}
CodeGenerator codeGenerator = new DefaultCodeGenerator();
DefaultCodeVerifier verifier = new DefaultCodeVerifier(codeGenerator, timeProvider);
if (method.getMFAAllowedTimeDiscrepancy() > 0) {
// allow codes valid for the time periods configured before/after to pass as valid (default is 1)
verifier.setAllowedTimePeriodDiscrepancy(method.getMFAAllowedTimeDiscrepancy());
}
boolean valid = verifier.isValidCode(reg.getMFASecret(), code);
if (valid) {
reg.setMFALastSecret(code);
reg.setLastSuccess(new Timestamp(System.currentTimeMillis()));
reg.setFailedLoginCount(0);
} else {
reg.setLastFailure(new Timestamp(System.currentTimeMillis()));
reg.setFailedLoginCount(reg.getFailedLoginCount() + 1);
}
saveRegistration(reg);
return valid;
}
/**
* Generate a validation code - do nothing for TOTP
* @param reg
* @return
*/
@Override
public String generateValidationCode(MMFARegistration reg) {
return Msg.getMsg(Env.getCtx(), "MFATOTPEnterValidationCode");
}
/**
* Validate a code
* @param reg
* @param code
* @param setPreferred
* @return message on error, null when OK
*/
@Override
public String validateCode(MMFARegistration reg, String code, boolean setPreferred) {
Properties ctx = reg.getCtx();
if (code.equals(reg.getMFALastSecret()))
return Msg.getMsg(ctx, "MFACodeAlreadyConsumed");
if (! isValidCode(ctx, reg, code, reg.get_TrxName()))
return Msg.getMsg(ctx, "MFACodeInvalid");
if (setPreferred) {
reg.setIsUserMFAPreferred(true);
saveRegistration(reg);
}
return null;
}
/**
* Save the registration record allowing cross-tenant (saving for a user in System tenant)
* @param reg
*/
private void saveRegistration(MMFARegistration reg) {
reg.saveCrossTenantSafeEx();
}
}