001/* 002 * This library is part of OpenCms - 003 * the Open Source Content Management System 004 * 005 * Copyright (c) Alkacon Software GmbH & Co. KG (http://www.alkacon.com) 006 * 007 * This library is free software; you can redistribute it and/or 008 * modify it under the terms of the GNU Lesser General Public 009 * License as published by the Free Software Foundation; either 010 * version 2.1 of the License, or (at your option) any later version. 011 * 012 * This library is distributed in the hope that it will be useful, 013 * but WITHOUT ANY WARRANTY; without even the implied warranty of 014 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 015 * Lesser General Public License for more details. 016 * 017 * For further information about Alkacon Software, please see the 018 * company website: http://www.alkacon.com 019 * 020 * For further information about OpenCms, please see the 021 * project website: http://www.opencms.org 022 * 023 * You should have received a copy of the GNU Lesser General Public 024 * License along with this library; if not, write to the Free Software 025 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA 026 */ 027 028package org.opencms.security.twofactor; 029 030import org.opencms.crypto.CmsAESTextEncryption; 031import org.opencms.crypto.CmsEncryptionException; 032import org.opencms.file.CmsObject; 033import org.opencms.file.CmsRequestContext; 034import org.opencms.file.CmsUser; 035import org.opencms.json.JSONException; 036import org.opencms.json.JSONObject; 037import org.opencms.main.CmsLog; 038import org.opencms.main.OpenCms; 039import org.opencms.security.CmsUserLog; 040import org.opencms.util.CmsMacroResolver; 041import org.opencms.workplace.CmsWorkplaceMessages; 042 043import java.util.Locale; 044 045import org.apache.commons.logging.Log; 046 047import dev.samstevens.totp.code.CodeGenerator; 048import dev.samstevens.totp.code.CodeVerifier; 049import dev.samstevens.totp.code.DefaultCodeGenerator; 050import dev.samstevens.totp.code.DefaultCodeVerifier; 051import dev.samstevens.totp.code.HashingAlgorithm; 052import dev.samstevens.totp.exceptions.QrGenerationException; 053import dev.samstevens.totp.qr.QrData; 054import dev.samstevens.totp.qr.QrGenerator; 055import dev.samstevens.totp.qr.ZxingPngQrGenerator; 056import dev.samstevens.totp.secret.DefaultSecretGenerator; 057import dev.samstevens.totp.secret.SecretGenerator; 058import dev.samstevens.totp.time.SystemTimeProvider; 059import dev.samstevens.totp.time.TimeProvider; 060import dev.samstevens.totp.util.Utils; 061 062/** 063 * Implements two-factor authentication for OpenCms users via TOTP. 064 * 065 * <p> 066 * This class can both set up a TOTP second factor for a user, as well as be used to authenticate a user using the verification code generated using their second factor. 067 */ 068public class CmsTwoFactorAuthenticationHandler { 069 070 /** 071 * The hashing algorithm to use. 072 * <p>Other algorithms are technically possible, but potentially unsupported by some apps. 073 */ 074 public static final HashingAlgorithm ALGORITHM = HashingAlgorithm.SHA1; 075 076 /** User info attribute for storing the second factor data. */ 077 public static final String ATTR_TWOFACTOR_INFO = "two_factor_auth"; 078 079 /** 080 * The number of digits to use for verification codes. 081 * <p>Other numbers are technically possible, but potentially unsupported by some apps. 082 */ 083 public static final int DIGITS = 6; 084 085 /** JSON key for storing the shared secret. */ 086 public static final String KEY_SECRET = "secret"; 087 088 /** JSON key for storing the user name. */ 089 public static final String KEY_USER = "user"; 090 091 /** The logger instance for this class. */ 092 private static final Log LOG = CmsLog.getLog(CmsTwoFactorAuthenticationHandler.class); 093 094 /** The stored CMS context. */ 095 private CmsObject m_cms; 096 097 /** The code generator used for TOTP. */ 098 private final CodeGenerator m_codeGenerator = new DefaultCodeGenerator(ALGORITHM, DIGITS); 099 100 /** The configuration object. */ 101 private CmsTwoFactorAuthenticationConfig m_config; 102 103 /** The encryption for the additional infos of a user. */ 104 private CmsAESTextEncryption m_encryption; 105 106 /** Shared secret generator (threadsafe). */ 107 private final SecretGenerator m_secretGenerator = new DefaultSecretGenerator(); 108 109 /** The time provider used for TOTP. */ 110 private final TimeProvider m_timeProvider = new SystemTimeProvider(); 111 112 /** The code verifier used for TOTP. */ 113 private final CodeVerifier m_verifier = new DefaultCodeVerifier(m_codeGenerator, m_timeProvider); 114 115 /** 116 * Creates a new instance. 117 * 118 * @param adminCms an Admin CMS context 119 * @param config the configuration for the two-factor authentication 120 */ 121 public CmsTwoFactorAuthenticationHandler(CmsObject adminCms, CmsTwoFactorAuthenticationConfig config) { 122 123 m_config = config; 124 m_cms = adminCms; 125 if (config != null) { 126 m_encryption = new CmsAESTextEncryption(config.getSecret()); 127 } 128 } 129 130 /** 131 * Generates the information needed to share a secret with the user for the purpose of setting up 2FA. 132 * 133 * <p>This contains both the data for a scannable QR code as well as the secret in textual form. 134 * 135 * @param user the user 136 * @return the second factor setup information 137 */ 138 public CmsSecondFactorSetupInfo generateSetupInfo(CmsUser user) { 139 140 checkEnabled(); 141 try { 142 String secret = m_secretGenerator.generate(); 143 // apart from the secret, the QR code contains other data like user name, etc. 144 String issuer = m_config.getIssuer(); 145 int period = 30; 146 // use full name as fallback if macro resolution fails 147 String label = user.getFullName(); 148 QrData qrData = new QrData.Builder().label(label).secret(secret).issuer(issuer).algorithm(ALGORITHM).digits( 149 DIGITS).period(period).build(); 150 QrGenerator generator = new ZxingPngQrGenerator(); 151 byte[] imageData = generator.generate(qrData); 152 String qrImageData = Utils.getDataUriForImage(imageData, "image/png"); 153 return new CmsSecondFactorSetupInfo(secret, qrImageData); 154 } catch (QrGenerationException e) { 155 throw new RuntimeException(e); 156 } 157 } 158 159 /** 160 * Gets the message to display during two-factor authentication setup. 161 * 162 * @param locale the locale 163 * @return the message 164 */ 165 public String getSetupMessage(Locale locale) { 166 167 String rawMessage = m_config.getSetupMessage(); 168 CmsMacroResolver resolver = new CmsMacroResolver(); 169 CmsWorkplaceMessages messages = OpenCms.getWorkplaceManager().getMessages(locale); 170 resolver.setMessages(messages); 171 return resolver.resolveMacros(rawMessage); 172 } 173 174 /** 175 * Checks if there is already a second factor configured for the given user. 176 * 177 * <p>For users excluded from two-factor authentication, this will usually return false, while for users who should use two-factor authentication, the result depends 178 * on whether the second factor has already been set up. 179 * 180 * 181 * @param user the user to check 182 * @return true if there is a second factor set up for the given user 183 */ 184 public boolean hasSecondFactor(CmsUser user) { 185 186 return user.getAdditionalInfo().containsKey(ATTR_TWOFACTOR_INFO); 187 188 } 189 190 /** 191 * Checks if two-factor authentication is enabled. 192 * 193 * @return true if two-factor auth is enabled 194 */ 195 public boolean isEnabled() { 196 197 return (m_config != null) && m_config.isEnabled(); 198 } 199 200 /** 201 * Checks if two-factor authentication should be used for the given user. 202 * 203 * @param user the user to check 204 * @return true if two-factor authentication should be used 205 */ 206 public boolean needsTwoFactorAuthentication(CmsUser user) { 207 208 if (!isEnabled()) { 209 return false; 210 } 211 212 boolean result = m_config.getPolicy().shouldUseTwoFactorAuthentication(m_cms, user); 213 return result; 214 } 215 216 /** 217 * Deletes the two-factor authentication in the user object, but does not write the user to the database. 218 * 219 * @param user the user for whom 2FA should be reset 220 */ 221 public void resetTwoFactorAuthentication(CmsUser user) { 222 223 user.deleteAdditionalInfo(ATTR_TWOFACTOR_INFO); 224 } 225 226 /** 227 * Sets up the second factor for the given user, and immediately verifies it with the authentication code given. 228 * 229 * @param newUser the user for whom to set up the second factor 230 * @param code contains both the shared secret and the authentication code generated by the user 231 * @return true if the second factor could be set up, false if the verification failed 232 * 233 * @throws CmsSecondFactorSetupException in unexpected circumstances, e.g. if the user already has a second factor set up or there is no authentication code 234 */ 235 public boolean setUpAndVerifySecondFactor(CmsUser newUser, CmsSecondFactorInfo code) 236 throws CmsSecondFactorSetupException { 237 238 checkEnabled(); 239 String secret = code.getSecret(); 240 if (secret == null) { 241 throw new CmsSecondFactorSetupException("Secret must not be null."); 242 } 243 JSONObject secondFactorInfo = decodeSecondFactor(newUser); 244 if (secondFactorInfo != null) { 245 // we shouldn't get here during the normal operation of the system 246 throw new CmsSecondFactorSetupException("Two-factor authentication already set up."); 247 } 248 try { 249 secondFactorInfo = new JSONObject(); 250 secondFactorInfo.put(KEY_SECRET, secret); 251 // we store the user name (and check it later) so you can't just transfer the stored second factor data to a different user via the GUI 252 secondFactorInfo.put(KEY_USER, newUser.getName()); 253 if (m_verifier.isValidCode(secret, code.getCode())) { 254 encodeSecondFactor(newUser, secondFactorInfo); 255 return true; 256 } 257 return false; 258 } catch (JSONException e) { 259 // should not happen 260 throw new CmsSecondFactorSetupException(e); 261 } 262 263 } 264 265 /** 266 * Gets called when a user is changed so we can check if the second factor information 267 * was changed and generate appropriate log messages. 268 * 269 * @param requestContext the current request context 270 * 271 * @param oldUser the user before modification 272 * @param newUser the user after modification 273 */ 274 @SuppressWarnings("null") 275 public void trackUserChange(CmsRequestContext requestContext, CmsUser oldUser, CmsUser newUser) { 276 277 String info1 = (String)oldUser.getAdditionalInfo(ATTR_TWOFACTOR_INFO); 278 String info2 = (String)newUser.getAdditionalInfo(ATTR_TWOFACTOR_INFO); 279 if ((info1 == null) && (info2 == null)) { 280 return; 281 } else if ((info1 == null) && (info2 != null)) { 282 CmsUserLog.logSecondFactorAdded(requestContext, oldUser.getName()); 283 } else if ((info1 != null) && (info2 == null)) { 284 CmsUserLog.logSecondFactorReset(requestContext, oldUser.getName()); 285 } else if (!info1.equals(info2)) { 286 CmsUserLog.logSecondFactorInfoModified(requestContext, oldUser.getName()); 287 } 288 289 } 290 291 /** 292 * Verifies the second factor information for a user. 293 * 294 * <p>Note that this method assumes that two-factor authentication should be applied to the given user, and always checks the second factor. 295 * 296 * @param user the user 297 * @param secondFactorInfo the second factor information 298 * @return true if the verification was successful 299 */ 300 public boolean verifySecondFactor(CmsUser user, CmsSecondFactorInfo secondFactorInfo) { 301 302 if (secondFactorInfo == null) { 303 return false; 304 } 305 if (secondFactorInfo.getSecret() != null) { 306 LOG.warn("Secret set in second-factor information for non-setup case", new Exception()); 307 } 308 309 JSONObject secondFactorJson = decodeSecondFactor(user); 310 if (!user.getName().equals(secondFactorJson.optString(KEY_USER))) { 311 LOG.error("User mismatch for two-factor authentication data for user: " + user.getName()); 312 return false; 313 } 314 String secret = secondFactorJson.optString(KEY_SECRET); 315 boolean result = m_verifier.isValidCode(secret, secondFactorInfo.getCode()); 316 return result; 317 318 } 319 320 /** 321 * Verifies that the verification code is correct for a secret. 322 * 323 * @param secondFactorInfo object containing the secret and verification code 324 * 325 * @return true if the verification is successful 326 */ 327 public boolean verifySecondFactorSetup(CmsSecondFactorInfo secondFactorInfo) { 328 329 return m_verifier.isValidCode(secondFactorInfo.getSecret(), secondFactorInfo.getCode()); 330 331 } 332 333 /** 334 * Throws an exception if 2FA is disabled. 335 */ 336 private void checkEnabled() { 337 338 if (!isEnabled()) { 339 throw new UnsupportedOperationException("Two-factor authentication is disabled"); 340 } 341 342 } 343 344 /** 345 * Helper method to decode the second factor information for a user. 346 * 347 * @param user the user 348 * @return the JSON representing the second factor information 349 */ 350 private JSONObject decodeSecondFactor(CmsUser user) { 351 352 try { 353 String val = (String)(user.getAdditionalInfo().get(ATTR_TWOFACTOR_INFO)); 354 if (val == null) { 355 return null; 356 } 357 JSONObject result = new JSONObject(m_encryption.decrypt(val)); 358 return result; 359 } catch (JSONException | CmsEncryptionException e) { 360 LOG.error(e.getLocalizedMessage(), e); 361 return null; 362 } 363 } 364 365 /** 366 * Helper method to encode the second factor information for a user. 367 * 368 * @param user the user 369 * @param json the JSON data to encode 370 */ 371 private void encodeSecondFactor(CmsUser user, JSONObject json) { 372 373 try { 374 user.getAdditionalInfo().put(ATTR_TWOFACTOR_INFO, m_encryption.encrypt(json.toString())); 375 } catch (CmsEncryptionException e) { 376 // shouldn't happen 377 throw new RuntimeException(e); 378 } 379 } 380 381}