/* * Password Management Servlets (PWM) * http://www.pwm-project.org * * Copyright (c) 2006-2009 Novell, Inc. * Copyright (c) 2009-2017 The PWM Project * * 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., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA */ package password.pwm.util.operations; import com.novell.ldapchai.exception.ChaiUnavailableException; import org.apache.commons.codec.binary.Base32; import password.pwm.AppProperty; import password.pwm.PwmApplication; import password.pwm.bean.SessionLabel; import password.pwm.bean.UserIdentity; import password.pwm.config.Configuration; import password.pwm.config.PwmSetting; import password.pwm.config.option.DataStorageMethod; import password.pwm.config.option.OTPStorageFormat; import password.pwm.error.ErrorInformation; import password.pwm.error.PwmError; import password.pwm.error.PwmException; import password.pwm.error.PwmOperationalException; import password.pwm.error.PwmUnrecoverableException; import password.pwm.health.HealthRecord; import password.pwm.http.PwmSession; import password.pwm.ldap.LdapOperationsHelper; import password.pwm.svc.PwmService; import password.pwm.util.java.JavaHelper; import password.pwm.util.java.StringUtil; import password.pwm.util.java.TimeDuration; import password.pwm.util.logging.PwmLogger; import password.pwm.util.macro.MacroMachine; import password.pwm.util.operations.otp.DbOtpOperator; import password.pwm.util.operations.otp.LdapOtpOperator; import password.pwm.util.operations.otp.LocalDbOtpOperator; import password.pwm.util.operations.otp.OTPUserRecord; import password.pwm.util.operations.otp.OtpOperator; import password.pwm.util.operations.otp.PasscodeGenerator; import password.pwm.util.secure.PwmRandom; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.io.IOException; import java.io.Serializable; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Date; import java.util.EnumMap; import java.util.Iterator; import java.util.List; import java.util.Map; /** * @author Menno Pieters, Jason D. Rivard */ public class OtpService implements PwmService { private static final PwmLogger LOGGER = PwmLogger.forClass(OtpService.class); private final Map<DataStorageMethod, OtpOperator> operatorMap = new EnumMap<>(DataStorageMethod.class); private PwmApplication pwmApplication; private OtpSettings settings; public OtpService() { } @Override public void init(final PwmApplication pwmApplication) throws PwmException { this.pwmApplication = pwmApplication; operatorMap.put(DataStorageMethod.LDAP, new LdapOtpOperator(pwmApplication)); operatorMap.put(DataStorageMethod.LOCALDB, new LocalDbOtpOperator(pwmApplication)); operatorMap.put(DataStorageMethod.DB, new DbOtpOperator(pwmApplication)); settings = OtpSettings.fromConfig(pwmApplication.getConfig()); } public boolean validateToken( final PwmSession pwmSession, final UserIdentity userIdentity, final OTPUserRecord otpUserRecord, final String userInput, final boolean allowRecoveryCodes ) throws PwmOperationalException, PwmUnrecoverableException { boolean otpCorrect = false; try { final Base32 base32 = new Base32(); final byte[] rawSecret = base32.decode(otpUserRecord.getSecret()); final Mac mac = Mac.getInstance("HMACSHA1"); mac.init(new SecretKeySpec(rawSecret, "")); final PasscodeGenerator generator = new PasscodeGenerator(mac, settings.getOtpTokenLength(), settings.getTotpIntervalSeconds()); switch (otpUserRecord.getType()) { case TOTP: otpCorrect = generator.verifyTimeoutCode(userInput, settings.getTotpPastIntervals(), settings.getTotpFutureIntervals()); break; //@todo HOTP implementation default: throw new UnsupportedOperationException("OTP type not supported: " + otpUserRecord.getType()); } } catch (Exception e) { LOGGER.error(pwmSession.getLabel(),"error checking otp secret: " + e.getMessage()); } if (!otpCorrect && allowRecoveryCodes && otpUserRecord.getRecoveryCodes() != null && otpUserRecord.getRecoveryInfo() != null) { final OTPUserRecord.RecoveryInfo recoveryInfo = otpUserRecord.getRecoveryInfo(); final String userHashedInput = doRecoveryHash(userInput, recoveryInfo); for (final OTPUserRecord.RecoveryCode code : otpUserRecord.getRecoveryCodes()) { if (code.getHashCode().equals(userInput) || code.getHashCode().equals(userHashedInput)) { if (code.isUsed()) { throw new PwmOperationalException(PwmError.ERROR_OTP_RECOVERY_USED, "recovery code has been previously used"); } code.setUsed(true); try { pwmApplication.getOtpService().writeOTPUserConfiguration(null, userIdentity, otpUserRecord); } catch (ChaiUnavailableException e) { throw new PwmUnrecoverableException(new ErrorInformation(PwmError.ERROR_WRITING_OTP_SECRET,e.getMessage())); } otpCorrect = true; } } } return otpCorrect; } private List<String> createRawRecoveryCodes(final int numRecoveryCodes, final SessionLabel sessionLabel) throws PwmUnrecoverableException { final MacroMachine macroMachine = MacroMachine.forNonUserSpecific(pwmApplication, sessionLabel); final String configuredTokenMacro = settings.getRecoveryTokenMacro(); final List<String> recoveryCodes = new ArrayList<>(); while (recoveryCodes.size() < numRecoveryCodes) { final String code = macroMachine.expandMacros(configuredTokenMacro); recoveryCodes.add(code); } return recoveryCodes; } public List<String> initializeUserRecord( final OTPUserRecord otpUserRecord, final SessionLabel sessionLabel, final String identifier ) throws IOException, PwmUnrecoverableException { otpUserRecord.setIdentifier(identifier); final byte[] rawSecret = generateSecret(); final String otpEncodedSecret = StringUtil.base32Encode(rawSecret); otpUserRecord.setSecret(otpEncodedSecret); switch (settings.getOtpType()) { case HOTP: otpUserRecord.setAttemptCount(PwmRandom.getInstance().nextLong()); otpUserRecord.setType(OTPUserRecord.Type.HOTP); break; case TOTP: otpUserRecord.setType(OTPUserRecord.Type.TOTP); break; default: JavaHelper.unhandledSwitchStatement(settings.getOtpType()); } final List<String> rawRecoveryCodes; if (settings.getOtpStorageFormat().supportsRecoveryCodes()) { rawRecoveryCodes = createRawRecoveryCodes(settings.getRecoveryCodesCount(), sessionLabel); final List<OTPUserRecord.RecoveryCode> recoveryCodeList = new ArrayList<>(); final OTPUserRecord.RecoveryInfo recoveryInfo = new OTPUserRecord.RecoveryInfo(); if (settings.getOtpStorageFormat().supportsHashedRecoveryCodes()) { LOGGER.trace(sessionLabel, "hashing the recovery codes"); final int saltCharLength = Integer.parseInt(pwmApplication.getConfig().readAppProperty(AppProperty.OTP_SALT_CHARLENGTH)); recoveryInfo.setSalt(PwmRandom.getInstance().alphaNumericString(saltCharLength)); recoveryInfo.setHashCount(settings.getRecoveryHashIterations()); recoveryInfo.setHashMethod(settings.getRecoveryHashMethod()); } else { LOGGER.trace(sessionLabel, "not hashing the recovery codes"); recoveryInfo.setSalt(null); recoveryInfo.setHashCount(0); recoveryInfo.setHashMethod(null); } otpUserRecord.setRecoveryInfo(recoveryInfo); for (final String rawCode : rawRecoveryCodes) { final String hashedCode; if (settings.getOtpStorageFormat().supportsHashedRecoveryCodes()) { hashedCode = doRecoveryHash(rawCode, recoveryInfo); } else { hashedCode = rawCode; } final OTPUserRecord.RecoveryCode recoveryCode = new OTPUserRecord.RecoveryCode(); recoveryCode.setHashCode(hashedCode); recoveryCode.setUsed(false); recoveryCodeList.add(recoveryCode); } otpUserRecord.setRecoveryCodes(recoveryCodeList); } else { rawRecoveryCodes = new ArrayList<>(); } return rawRecoveryCodes; } private static byte[] generateSecret() { final byte[] secArray = new byte[10]; PwmRandom.getInstance().nextBytes(secArray); return secArray; } public String doRecoveryHash( final String input, final OTPUserRecord.RecoveryInfo recoveryInfo ) throws IllegalStateException { final String algorithm = settings.getRecoveryHashMethod(); final MessageDigest md; try { md = MessageDigest.getInstance(algorithm); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException("unable to load " + algorithm + " message digest algorithm: " + e.getMessage()); } final String raw = recoveryInfo.getSalt() == null ? input.trim() : recoveryInfo.getSalt().trim() + input.trim(); final int hashCount = recoveryInfo.getHashCount(); byte[] hashedBytes = raw.getBytes(); for (int i = 0; i < hashCount; i++) { hashedBytes = md.digest(hashedBytes); } return StringUtil.base64Encode(hashedBytes); } @Override public STATUS status() { return STATUS.OPEN; } @Override public void close() { for (final OtpOperator operator : operatorMap.values()) { operator.close(); } operatorMap.clear(); } @Override public List<HealthRecord> healthCheck() { return Collections.emptyList(); } public OTPUserRecord readOTPUserConfiguration( final SessionLabel sessionLabel, final UserIdentity userIdentity ) throws PwmUnrecoverableException, ChaiUnavailableException { OTPUserRecord otpConfig = null; final Configuration config = pwmApplication.getConfig(); final Date methodStartTime = new Date(); final List<DataStorageMethod> otpSecretStorageLocations = config.getOtpSecretStorageLocations( PwmSetting.OTP_SECRET_READ_PREFERENCE); if (otpSecretStorageLocations != null) { final String userGUID = readGuidIfNeeded(pwmApplication, sessionLabel, otpSecretStorageLocations, userIdentity); final Iterator<DataStorageMethod> locationIterator = otpSecretStorageLocations.iterator(); while (otpConfig == null && locationIterator.hasNext()) { final DataStorageMethod location = locationIterator.next(); final OtpOperator operator = operatorMap.get(location); if (operator != null) { try { otpConfig = operator.readOtpUserConfiguration(userIdentity, userGUID); } catch (Exception e) { LOGGER.error(sessionLabel, "unexpected error reading stored otp configuration from " + location + " for user " + userIdentity + ", error: " + e.getMessage()); } } else { LOGGER.warn(sessionLabel,String.format("storage location %s not implemented", location.toString())); } } } LOGGER.trace(sessionLabel,"readOTPUserConfiguration completed in " + TimeDuration.fromCurrent(methodStartTime).asCompactString() + (otpConfig == null ? ", no otp record found" : ", recordType=" + otpConfig.getType() + ", identifier=" + otpConfig.getIdentifier() + ", timestamp=" + JavaHelper.toIsoDate(otpConfig.getTimestamp())) ); return otpConfig; } public void writeOTPUserConfiguration( final PwmSession pwmSession, final UserIdentity userIdentity, final OTPUserRecord otp ) throws PwmOperationalException, ChaiUnavailableException, PwmUnrecoverableException { int attempts = 0; int successes = 0; final Configuration config = pwmApplication.getConfig(); final List<DataStorageMethod> otpSecretStorageLocations = config.getOtpSecretStorageLocations( PwmSetting.OTP_SECRET_READ_PREFERENCE); final String userGUID = readGuidIfNeeded(pwmApplication, pwmSession == null ? null : pwmSession.getLabel(), otpSecretStorageLocations, userIdentity); final StringBuilder errorMsgs = new StringBuilder(); if (otpSecretStorageLocations != null) { for (final DataStorageMethod otpSecretStorageLocation : otpSecretStorageLocations) { attempts++; final OtpOperator operator = operatorMap.get(otpSecretStorageLocation); if (operator != null) { try { operator.writeOtpUserConfiguration(pwmSession, userIdentity, userGUID, otp); successes++; } catch (PwmUnrecoverableException e) { LOGGER.error(pwmSession, "error writing to " + otpSecretStorageLocation + ", error: " + e.getMessage()); errorMsgs.append(otpSecretStorageLocation).append(" error: ").append(e.getMessage()); } } else { LOGGER.warn(pwmSession, String.format("storage location %s not implemented", otpSecretStorageLocation.toString())); } } } if (attempts == 0) { final String errorMsg = "no OTP secret save methods are available or configured"; final ErrorInformation errorInfo = new ErrorInformation(PwmError.ERROR_WRITING_OTP_SECRET, errorMsg); throw new PwmOperationalException(errorInfo); } if (attempts != successes) { // should be impossible to read here, but just in case. final String errorMsg = "OTP secret write only partially successful; attempts=" + attempts + ", successes=" + successes + ", errors: " + errorMsgs.toString(); final ErrorInformation errorInfo = new ErrorInformation(PwmError.ERROR_WRITING_OTP_SECRET, errorMsg); throw new PwmOperationalException(errorInfo); } } public void clearOTPUserConfiguration( final PwmSession pwmSession, final UserIdentity userIdentity ) throws PwmOperationalException, ChaiUnavailableException, PwmUnrecoverableException { LOGGER.trace(pwmSession, "beginning clear otp user configuration"); int attempts = 0; int successes = 0; final Configuration config = pwmApplication.getConfig(); final List<DataStorageMethod> otpSecretStorageLocations = config.getOtpSecretStorageLocations(PwmSetting.OTP_SECRET_READ_PREFERENCE); final String userGUID = readGuidIfNeeded(pwmApplication, pwmSession.getLabel(), otpSecretStorageLocations, userIdentity); final StringBuilder errorMsgs = new StringBuilder(); if (otpSecretStorageLocations != null) { for (final DataStorageMethod otpSecretStorageLocation : otpSecretStorageLocations) { attempts++; final OtpOperator operator = operatorMap.get(otpSecretStorageLocation); if (operator != null) { try { operator.clearOtpUserConfiguration(pwmSession, userIdentity, userGUID); successes++; } catch (PwmUnrecoverableException e) { LOGGER.error(pwmSession, "error clearing " + otpSecretStorageLocation + ", error: " + e.getMessage()); errorMsgs.append(otpSecretStorageLocation).append(" error: ").append(e.getMessage()); } } else { LOGGER.warn(pwmSession, String.format("Storage location %s not implemented", otpSecretStorageLocation.toString())); } } } if (attempts == 0) { final String errorMsg = "no OTP secret clear methods are available or configured"; //@todo: replace error message final ErrorInformation errorInfo = new ErrorInformation(PwmError.ERROR_WRITING_OTP_SECRET, errorMsg); throw new PwmOperationalException(errorInfo); } if (attempts != successes) { // should be impossible to read here, but just in case. final String errorMsg = "OTP secret clearing only partially successful; attempts=" + attempts + ", successes=" + successes + ", error: " + errorMsgs.toString(); //@todo: replace error message final ErrorInformation errorInfo = new ErrorInformation(PwmError.ERROR_WRITING_OTP_SECRET, errorMsg); throw new PwmOperationalException(errorInfo); } } public OtpSettings getSettings() { return settings; } public ServiceInfo serviceInfo() { return new ServiceInfo(Collections.<DataStorageMethod>emptyList()); } private static String readGuidIfNeeded( final PwmApplication pwmApplication, final SessionLabel sessionLabel, final Collection<DataStorageMethod> otpSecretStorageLocations, final UserIdentity userIdentity ) throws ChaiUnavailableException, PwmUnrecoverableException { final String userGUID; if (otpSecretStorageLocations.contains(DataStorageMethod.DB) || otpSecretStorageLocations.contains( DataStorageMethod.LOCALDB)) { userGUID = LdapOperationsHelper.readLdapGuidValue(pwmApplication, sessionLabel, userIdentity, false); } else { userGUID = null; } return userGUID; } public static class OtpSettings implements Serializable { private OTPStorageFormat otpStorageFormat; private OTPUserRecord.Type otpType = OTPUserRecord.Type.TOTP; private int recoveryCodesCount; private int totpPastIntervals; private int totpFutureIntervals; private int totpIntervalSeconds; private int otpTokenLength; private String recoveryTokenMacro; private int recoveryHashIterations; private String recoveryHashMethod; public OTPStorageFormat getOtpStorageFormat() { return otpStorageFormat; } public OTPUserRecord.Type getOtpType() { return otpType; } public int getRecoveryCodesCount() { return recoveryCodesCount; } public int getTotpPastIntervals() { return totpPastIntervals; } public int getTotpFutureIntervals() { return totpFutureIntervals; } public int getTotpIntervalSeconds() { return totpIntervalSeconds; } public int getOtpTokenLength() { return otpTokenLength; } public String getRecoveryTokenMacro() { return recoveryTokenMacro; } public int getRecoveryHashIterations() { return recoveryHashIterations; } public String getRecoveryHashMethod() { return recoveryHashMethod; } public static OtpSettings fromConfig(final Configuration config) { final OtpSettings otpSettings = new OtpSettings(); otpSettings.otpStorageFormat = config.readSettingAsEnum(PwmSetting.OTP_SECRET_STORAGEFORMAT,OTPStorageFormat.class); otpSettings.recoveryCodesCount = (int)config.readSettingAsLong(PwmSetting.OTP_RECOVERY_CODES); otpSettings.totpPastIntervals = Integer.parseInt(config.readAppProperty(AppProperty.TOTP_PAST_INTERVALS)); otpSettings.totpFutureIntervals = Integer.parseInt(config.readAppProperty(AppProperty.TOTP_FUTURE_INTERVALS)); otpSettings.totpIntervalSeconds = Integer.parseInt(config.readAppProperty(AppProperty.TOTP_INTERVAL)); otpSettings.otpTokenLength = Integer.parseInt(config.readAppProperty(AppProperty.OTP_TOKEN_LENGTH)); otpSettings.recoveryTokenMacro = config.readAppProperty(AppProperty.OTP_RECOVERY_TOKEN_MACRO); otpSettings.recoveryHashIterations = Integer.parseInt(config.readAppProperty(AppProperty.OTP_RECOVERY_HASH_COUNT)); otpSettings.recoveryHashMethod = config.readAppProperty(AppProperty.OTP_RECOVERY_HASH_METHOD); return otpSettings; } } }