/**
* Copyright (C) 2009 eXo Platform SAS.
*
* This is free software; you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as
* published by the Free Software Foundation; either version 2.1 of
* the License, or (at your option) any later version.
*
* This software 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
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this software; if not, write to the Free
* Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
* 02110-1301 USA, or see the FSF site: http://www.fsf.org.
*/
package org.exoplatform.web.security.security;
import org.chromattic.api.ChromatticSession;
import org.chromattic.api.query.QueryResult;
import org.exoplatform.commons.chromattic.ChromatticLifeCycle;
import org.exoplatform.commons.chromattic.ChromatticManager;
import org.exoplatform.commons.chromattic.ContextualTask;
import org.exoplatform.commons.chromattic.SessionContext;
import org.exoplatform.container.xml.InitParams;
import org.exoplatform.container.xml.ObjectParameter;
import org.exoplatform.portal.pom.config.Utils;
import org.exoplatform.web.security.GateInToken;
import org.exoplatform.web.security.codec.AbstractCodec;
import org.exoplatform.web.security.codec.CodecInitializer;
import org.exoplatform.web.security.hash.JCASaltedHashService;
import org.exoplatform.web.security.hash.SaltedHashException;
import org.exoplatform.web.security.hash.SaltedHashService;
import org.gatein.common.logging.Logger;
import org.gatein.common.logging.LoggerFactory;
import org.gatein.wci.security.Credentials;
import java.util.Date;
import java.util.List;
/**
* <p>
* Created by The eXo Platform SAS Author : liem.nguyen ncliam@gmail.com Jun 5, 2009
* </p>
* <p>
* On 2013-01-02 the followig was added by ppalaga@redhat.com:
* <ul>
* <li>Passwords encrypted symmetrically before they are stored. The functionaliy was taken from <a
* href="https://github.com/exoplatform/exogtn/commit/5ef8b0fa2d639f4d834444468426dfb2c8485ae9"
* >https://github.com/exoplatform/exogtn/commit/5ef8b0fa2d639f4d834444468426dfb2c8485ae9</a> with minor modifications. See
* {@link #codec}</li>
* <li>The tokens are not stored in plain text, but intead only their salted hash is stored. See {@link #saltedHashService}. To
* enable this, the following was done:
* <ul>
* <li>The structure of the underlying JCR store was changed from
*
* <pre>
* autologin
* |- plain-token1 user="user1" password="***" expiration="..."
* |- plain-token2 user="user2" password="***" expiration="..."
* `- ...
* </pre>
*
* to
*
* <pre>
* autologin
* |- user1
* | |- plain-token1 user="user1" password="***" expiration="..."
* | |- plain-token2 user="user1" password="***" expiration="..."
* | `- ...
* |- user2
* | |- plain-token3 user="user2" password="***" expiration="..."
* | |- plain-token4 user="user2" password="***" expiration="..."
* | `- ...
* `- ...
* </pre>
*
* </li>
* <li>The value of the token was changed from {@code "rememberme" + randomString} to {@code userName + '.' + randomString}</li>
* </ul>
* </li>
* </ul>
* </p>
* <p>
* It should be considered in the future if the password field can be removed altogether from {@link TokenEntry}.
* </p>
*
*/
public class CookieTokenService extends AbstractTokenService<GateInToken, String> {
/** . */
public static final String LIFECYCLE_NAME = "lifecycle-name";
public static final String HASH_SERVICE_INIT_PARAM = "hash.service";
/** . */
private ChromatticLifeCycle chromatticLifeCycle;
/** . */
private String lifecycleName = "autologin";
/**
* {@link AbstractCodec} used to symmetrically encrypt passwords before storing them.
*/
private AbstractCodec codec;
private SaltedHashService saltedHashService;
private final Logger log = LoggerFactory.getLogger(CookieTokenService.class);
public CookieTokenService(InitParams initParams, ChromatticManager chromatticManager, CodecInitializer codecInitializer)
throws TokenServiceInitializationException {
super(initParams);
List<?> serviceConfig = initParams.getValuesParam(SERVICE_CONFIG).getValues();
if (serviceConfig.size() > 3) {
lifecycleName = (String) serviceConfig.get(3);
}
this.chromatticLifeCycle = chromatticManager.getLifeCycle(lifecycleName);
ObjectParameter hashServiceParam = initParams.getObjectParam(HASH_SERVICE_INIT_PARAM);
if (hashServiceParam == null || hashServiceParam.getObject() == null) {
/* the default */
saltedHashService = new JCASaltedHashService();
} else {
saltedHashService = (SaltedHashService) hashServiceParam.getObject();
}
this.codec = codecInitializer.getCodec();
}
/*
* (non-Javadoc)
*
* @see org.exoplatform.web.security.security.AbstractTokenService#start()
*/
@Override
public void start() {
/* clean the legacy tokens */
new TokenTask<Void>() {
@Override
protected Void execute(SessionContext context) {
ChromatticSession session = context.getSession();
TokenContainer container = session.findByPath(TokenContainer.class, lifecycleName);
if (container != null) {
/* if the container does not exist, it makes no sense to clean the legacy tokens */
container.cleanLegacyTokens();
} else {
session.insert(TokenContainer.class, lifecycleName);
}
return null;
}
}.executeWith(chromatticLifeCycle);
super.start();
}
public String createToken(final Credentials credentials) {
if (validityMillis < 0) {
throw new IllegalArgumentException();
}
if (credentials == null) {
throw new NullPointerException();
}
return new TokenTask<String>() {
@Override
protected String execute(SessionContext context) {
String cookieTokenString = null;
TokenContainer tokenContainer = getTokenContainer();
while (cookieTokenString == null) {
String randomString = nextTokenId();
String id = nextRandom();
cookieTokenString = new CookieToken(id, randomString).toString();
String hashedRandomString = hashToken(randomString);
long expirationTimeMillis = System.currentTimeMillis() + validityMillis;
/* the symmetric encryption happens here */
String encryptedPassword = codec.encode(credentials.getPassword());
Credentials encodedCredentials = new Credentials(credentials.getUsername(), encryptedPassword);
try {
tokenContainer.saveToken(context.getSession(), id, hashedRandomString, encodedCredentials, new Date(expirationTimeMillis));
} catch (TokenExistsException e) {
cookieTokenString = null;
}
}
return cookieTokenString;
}
}.executeWith(chromatticLifeCycle);
}
@Override
protected String nextTokenId() {
return nextRandom();
}
@Override
public GateInToken getToken(String cookieTokenString) {
CookieToken token = null;
try {
token = new CookieToken(cookieTokenString);
return new RemovableGetTokenTask(token, false).executeWith(chromatticLifeCycle);
} catch (TokenParseException e) {
log.warn("Could not parse cookie token:"+ e.getMessage());
}
return null;
}
@Override
public GateInToken deleteToken(String cookieTokenString) {
CookieToken token = null;
try {
token = new CookieToken(cookieTokenString);
return new RemovableGetTokenTask(token, true).executeWith(chromatticLifeCycle);
} catch (TokenParseException e) {
log.warn("Could not parse cookie token:"+ e.getMessage());
}
return null;
}
/**
* The UI should offer a way to delete all existing tokens of the current user.
*
* @param user
*/
public void deleteTokensOfUser(final String user) {
new TokenTask<Void>() {
@Override
protected Void execute(SessionContext context) {
QueryResult<TokenEntry> result = findTokensOfUser(user);
while (result.hasNext()) {
TokenEntry en = result.next();
en.remove();
}
return null;
}
}.executeWith(chromatticLifeCycle);
}
/**
* Removes all tokens stored in the {@link TokenContainer}.
*/
public void deleteAll() {
new TokenTask<Void>() {
@Override
protected Void execute(SessionContext context) {
getTokenContainer().removeAll();
return null;
}
}.executeWith(chromatticLifeCycle);
}
@Override
public void cleanExpiredTokens() {
new TokenTask<Void>() {
@Override
protected Void execute(SessionContext context) {
getTokenContainer().cleanExpiredTokens();
return null;
}
}.executeWith(chromatticLifeCycle);
}
@Override
public long size() {
return new TokenTask<Long>() {
@Override
protected Long execute(SessionContext context) {
return (long) getTokenContainer().size();
}
}.executeWith(chromatticLifeCycle);
}
@Override
protected String decodeKey(String stringKey) {
return stringKey;
}
private String hashToken(String tokenId) {
if (saltedHashService != null) {
try {
return saltedHashService.getSaltedHash(tokenId);
} catch (Exception e) {
throw new RuntimeException(e);
}
} else {
/* no hash if saltedHashService is null */
return tokenId;
}
}
/**
* Wraps token store logic conveniently.
*
* @param <V> the return type
*/
private abstract class TokenTask<V> extends ContextualTask<V> {
protected final TokenContainer getTokenContainer() {
SessionContext ctx = chromatticLifeCycle.getContext();
ChromatticSession session = ctx.getSession();
return session.findByPath(TokenContainer.class, lifecycleName);
}
protected final <A> A getMixin(Object o, Class<A> type) {
SessionContext ctx = chromatticLifeCycle.getContext();
ChromatticSession session = ctx.getSession();
return session.getEmbedded(o, type);
}
protected final QueryResult<TokenEntry> findTokensOfUser(String user) {
SessionContext ctx = chromatticLifeCycle.getContext();
ChromatticSession session = ctx.getSession();
TokenContainer tokenContainer = getTokenContainer();
String statement = new StringBuilder(128).append("jcr:path LIKE '").append(session.getPath(tokenContainer))
.append("/%'").append(" AND username='").append(Utils.queryEscape(user)).append("'").toString();
return session.createQueryBuilder(TokenEntry.class).where(statement).get().objects();
}
}
private class RemovableGetTokenTask extends TokenTask<GateInToken> {
private final CookieToken token;
private final boolean remove;
/**
* @param token
*/
public RemovableGetTokenTask(CookieToken token, boolean remove) {
super();
this.token = token;
this.remove = remove;
}
@Override
protected GateInToken execute(SessionContext context) {
TokenEntry en = getTokenContainer().getTokens().get(token.getId());
if (en != null) {
HashedToken hashedToken = getMixin(en, HashedToken.class);
if (hashedToken != null && hashedToken.getHashedToken() != null) {
try {
if (saltedHashService.validate(token.getRandomString(), hashedToken.getHashedToken())) {
GateInToken encryptedToken = en.getToken();
Credentials encryptedCredentials = encryptedToken.getPayload();
Credentials decryptedCredentials = new Credentials(encryptedCredentials.getUsername(),
codec.decode(encryptedCredentials.getPassword()));
if (remove) {
en.remove();
}
return new GateInToken(encryptedToken.getExpirationTimeMillis(), decryptedCredentials);
}
} catch (SaltedHashException e) {
log.warn("Could not validate cookie token against its salted hash.", e);
}
}
}
return null;
}
}
}