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;
029
030import org.opencms.file.CmsObject;
031import org.opencms.file.CmsUser;
032import org.opencms.main.CmsException;
033import org.opencms.main.CmsLog;
034import org.opencms.util.CmsStringUtil;
035
036import java.io.UnsupportedEncodingException;
037import java.util.List;
038import java.util.Map;
039
040import org.apache.commons.codec.binary.Hex;
041import org.apache.commons.lang3.RandomStringUtils;
042import org.apache.commons.logging.Log;
043
044import com.google.common.collect.Lists;
045
046/**
047 * Creates and validates persisten login tokens for users.<p>
048 *
049 * When a token is created for a user, a special additional info item is stored on the user, such that
050 * the token uniquely identifies that info item. The value of the info item is the expiration date of the token.
051 * A token is validated by looking up the additional info item for the user and checking whether the token is still valid
052 * according to the stored expiration date.<p>
053 */
054public class CmsPersistentLoginTokenHandler {
055
056    /**
057     * Bean representing the data encoded in a login token (user name and key).<p>
058     */
059    public static class Token {
060
061        /** Separator to use for the encoded token string. */
062        public static final String SEPARATOR = "|";
063
064        /** The key. */
065        private String m_key;
066
067        /** The name. */
068        private String m_name;
069
070        /**
071         * Creates a new token object from the encoded representation.<p>
072         *
073         * @param token a string containing the token data
074         */
075        public Token(String token) {
076
077            if (token != null) {
078                List<String> parts = CmsStringUtil.splitAsList(token, SEPARATOR);
079                if (parts.size() == 2) {
080                    m_name = decodeName(parts.get(0));
081                    m_key = parts.get(1);
082                }
083            }
084        }
085
086        /**
087         * Creates a token object from the given name and key.<p>
088         *
089         * @param name the name
090         * @param key the key
091         */
092        public Token(String name, String key) {
093
094            m_name = name;
095            m_key = key;
096
097        }
098
099        /**
100         * Gets the encoded token string  representation.<p>
101         *
102         * @return the token string
103         */
104        public String encode() {
105
106            return encodeName(m_name) + SEPARATOR + m_key;
107        }
108
109        /**
110         * Gets the additional info key to use for this token.<p>
111         *
112         * @return the additional info key
113         */
114        public String getAdditionalInfoKey() {
115
116            return KEY_PREFIX + m_key;
117        }
118
119        /**
120         * Gets the key for this token.<p>
121         *
122         * @return the key
123         */
124        public String getKey() {
125
126            return m_key;
127
128        }
129
130        /**
131         * Gets the user name for this token.<p>
132         *
133         * @return the user name
134         */
135        public String getName() {
136
137            return m_name;
138        }
139
140        /**
141         * Checks if this token is valid.<p>
142         *
143         * @return true if this token is valid
144         */
145        public boolean isValid() {
146
147            return (m_name != null) && (m_key != null);
148        }
149
150        /**
151         * Decodes the user name from a hexadecimal string, and returns null if this is not possible.<p>
152         *
153         * @param nameHex the encoded name
154         * @return the decoded name
155         */
156        @SuppressWarnings("synthetic-access")
157        private String decodeName(String nameHex) {
158
159            try {
160                return new String(Hex.decodeHex(nameHex.toCharArray()), "UTF-8");
161            } catch (Exception e) {
162                LOG.warn(e.getLocalizedMessage(), e);
163                return null;
164            }
165        }
166
167        /**
168         * Encodes a user name as a hex string for storing in the cookie.<p>
169         *
170         * @param name the user name
171         *
172         * @return the encoded name
173         */
174        private String encodeName(String name) {
175
176            try {
177                return Hex.encodeHexString(name.getBytes("UTF-8"));
178            } catch (UnsupportedEncodingException e) {
179                // shouldn't happen
180                throw new IllegalStateException("UTF8 not supported");
181            }
182        }
183    }
184
185    /** Default token lifetime. */
186    public static final long DEFAULT_LIFETIME = 1000 * 60 * 60 * 8;
187
188    /** Prefix used for the keys for the additional infos this class creates. */
189    public static final String KEY_PREFIX = "logintoken_";
190
191    /** The logger for this class. */
192    private static final Log LOG = CmsLog.getLog(CmsPersistentLoginTokenHandler.class);
193
194    /** Admin CMS context. */
195    private static CmsObject m_adminCms;
196
197    /** The lifetime for created tokens. */
198    private long m_lifetime = DEFAULT_LIFETIME;
199
200    /**
201     * Creates a new instance.<p>
202     */
203    public CmsPersistentLoginTokenHandler() {
204
205        // Default constructor, do nothing
206    }
207
208    /**
209     * Static method used to give this class access to an admin cms context.<p>
210     *
211     * @param adminCms the admin cms context to set
212     */
213    public static void setAdminCms(CmsObject adminCms) {
214
215        if (m_adminCms == null) {
216            m_adminCms = adminCms;
217        }
218    }
219
220    /**
221     * Generates a new login token for a given user and registers the token in the user's additional info.<p>
222     *
223     * @param cms the CMS context for which to create a new token
224     * @return the generated token
225     *
226     * @throws CmsException if something goes wrong
227     */
228    public String createToken(CmsObject cms) throws CmsException {
229
230        CmsUser user = cms.getRequestContext().getCurrentUser();
231        String key = RandomStringUtils.randomAlphanumeric(16);
232        Token tokenObj = new Token(user.getName(), key);
233        String token = tokenObj.encode();
234        String addInfoKey = tokenObj.getAdditionalInfoKey();
235        String value = "" + (System.currentTimeMillis() + m_lifetime);
236        user.getAdditionalInfo().put(addInfoKey, value);
237        removeExpiredTokens(user, System.currentTimeMillis());
238        LOG.info("Generated token for user " + user.getName() + " using key " + key);
239        m_adminCms.writeUser(user);
240
241        return token;
242    }
243
244    /**
245     * Invalidates all tokens for the given user.<p>
246     *
247     * @param user the user
248     * @param token the token string
249     *
250     * @throws CmsException if something goes wrong
251     */
252    public void invalidateToken(CmsUser user, String token) throws CmsException {
253
254        Token tokenObj = new Token(token);
255        if (tokenObj.isValid()) {
256            String addInfoKey = tokenObj.getAdditionalInfoKey();
257            if (null != user.getAdditionalInfo().remove(addInfoKey)) {
258                m_adminCms.writeUser(user);
259            }
260        }
261    }
262
263    /**
264     * Removes  expired tokens from the user's additional infos.<p>
265     *
266     * This method does not write the user back to the database.
267     *
268     * @param user the user for which to remove the additional infos
269     * @param now the current time
270     */
271    public void removeExpiredTokens(CmsUser user, long now) {
272
273        List<String> toRemove = Lists.newArrayList();
274        for (Map.Entry<String, Object> entry : user.getAdditionalInfo().entrySet()) {
275            String key = entry.getKey();
276            if (key.startsWith(KEY_PREFIX)) {
277                try {
278                    long expiry = Long.parseLong((String)entry.getValue());
279                    if (expiry < now) {
280                        toRemove.add(key);
281                    }
282                } catch (NumberFormatException e) {
283                    toRemove.add(key);
284                }
285            }
286        }
287        LOG.info("Removing " + toRemove.size() + " expired tokens for user " + user.getName());
288        for (String removeKey : toRemove) {
289            user.getAdditionalInfo().remove(removeKey);
290        }
291    }
292
293    /**
294     * Sets the token lifetime.<p>
295     *
296     * @param duration the number of milliseconds for which the token should be valid
297     */
298    public void setTokenLifetime(long duration) {
299
300        m_lifetime = duration;
301    }
302
303    /**
304     * Validates a token and returns the matching user for which the token is valid.<p>
305     *
306     * Returns null if no user matching the token is found, or if the token for the user is expired
307     *
308     * @param tokenString the token for which to find the matching user
309     *
310     * @return the matching user for the token, or null if no matching user was found or the token is expired
311     */
312    public CmsUser validateToken(String tokenString) {
313
314        if (CmsStringUtil.isEmpty(tokenString)) {
315            return null;
316        }
317        Token token = new Token(tokenString);
318        if (!token.isValid()) {
319            LOG.warn("Invalid token: " + tokenString);
320            return null;
321        }
322        String name = token.getName();
323        String key = token.getKey();
324        String logContext = "[user=" + name + ",key=" + key + "] ";
325        try {
326            CmsUser user = m_adminCms.readUser(name);
327            String infoKey = token.getAdditionalInfoKey();
328            String addInfoValue = (String)user.getAdditionalInfo().get(infoKey);
329            logContext = logContext + "[value=" + addInfoValue + "]";
330            if (addInfoValue == null) {
331                LOG.warn(logContext + " no matching additional info value found");
332                return null;
333            }
334            try {
335                long expirationDate = Long.parseLong(addInfoValue);
336                if (System.currentTimeMillis() > expirationDate) {
337                    LOG.warn(logContext + "Login token expired");
338                    user.getAdditionalInfo().remove(infoKey);
339                    try {
340                        m_adminCms.writeUser(user);
341                    } catch (Exception e) {
342                        LOG.error(e.getLocalizedMessage(), e);
343                    }
344                    return null;
345                }
346            } catch (NumberFormatException e) {
347                LOG.warn(logContext + "Invalid format for login token additional info");
348                return null;
349            }
350            return user;
351        } catch (Exception e) {
352            LOG.warn(logContext + "error validating token", e);
353            return null;
354        }
355    }
356}