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}