/*
* Copyright 2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package ratpack.session.clientside.internal;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import com.google.common.collect.Ordering;
import com.google.inject.Inject;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import io.netty.buffer.ByteBufUtil;
import io.netty.buffer.Unpooled;
import io.netty.handler.codec.base64.Base64;
import io.netty.handler.codec.base64.Base64Dialect;
import io.netty.handler.codec.http.cookie.Cookie;
import io.netty.util.AsciiString;
import io.netty.util.CharsetUtil;
import ratpack.exec.Operation;
import ratpack.exec.Promise;
import ratpack.http.Request;
import ratpack.http.Response;
import ratpack.session.SessionCookieConfig;
import ratpack.session.SessionStore;
import ratpack.session.clientside.ClientSideSessionConfig;
import ratpack.session.clientside.Crypto;
import ratpack.session.clientside.Signer;
import javax.inject.Provider;
import java.nio.CharBuffer;
import java.util.Optional;
public class ClientSideSessionStore implements SessionStore {
private static final String SESSION_SEPARATOR = ":";
private final Provider<Request> request;
private final Provider<Response> response;
private final Signer signer;
private final Crypto crypto;
private final ByteBufAllocator bufferAllocator;
private final SessionCookieConfig cookieConfig;
private final ClientSideSessionConfig config;
private final CookieOrdering latCookieOrdering;
private final CookieOrdering dataCookieOrdering;
private static class CookieOrdering extends Ordering<Cookie> {
private final int prefixLen;
private final String prefix;
public CookieOrdering(String prefix) {
this.prefix = prefix;
this.prefixLen = prefix.length() + 1;
}
@Override
public int compare(Cookie left, Cookie right) {
Integer leftNum = Integer.valueOf(left.name().substring(prefixLen));
Integer rightNum = Integer.valueOf(right.name().substring(prefixLen));
return leftNum.compareTo(rightNum);
}
}
@Inject
public ClientSideSessionStore(Provider<Request> request, Provider<Response> response, Signer signer, Crypto crypto, ByteBufAllocator bufferAllocator, SessionCookieConfig cookieConfig, ClientSideSessionConfig config) {
this.request = request;
this.response = response;
this.signer = signer;
this.crypto = crypto;
this.bufferAllocator = bufferAllocator;
this.cookieConfig = cookieConfig;
this.config = config;
this.latCookieOrdering = new CookieOrdering(config.getLastAccessTimeCookieName());
this.dataCookieOrdering = new CookieOrdering(config.getSessionCookieName());
}
@Override
public Operation store(AsciiString sessionId, ByteBuf sessionData) {
return Operation.of(() -> {
CookieStorage cookieStorage = getCookieStorage();
int oldSessionCookiesCount = cookieStorage.data.size();
String[] sessionCookiePartitions = serialize(sessionData);
for (int i = 0; i < sessionCookiePartitions.length; i++) {
addCookie(config.getSessionCookieName() + "_" + i, sessionCookiePartitions[i]);
}
for (int i = sessionCookiePartitions.length; i < oldSessionCookiesCount; i++) {
invalidateCookie(config.getSessionCookieName() + "_" + i);
}
setLastAccessTime(cookieStorage);
});
}
@Override
public Promise<ByteBuf> load(AsciiString sessionId) {
return Promise.sync(() -> {
CookieStorage cookieStorage = getCookieStorage();
if (!isValid(cookieStorage)) {
return Unpooled.EMPTY_BUFFER;
}
setLastAccessTime(cookieStorage);
return deserialize(cookieStorage.data);
});
}
@Override
public Operation remove(AsciiString sessionId) {
return Operation.of(() -> reset(getCookieStorage()));
}
private void reset(CookieStorage cookieStorage) {
cookieStorage.lastAccessToken.forEach(this::invalidateCookie);
cookieStorage.data.forEach(this::invalidateCookie);
cookieStorage.clear();
}
@Override
public Promise<Long> size() {
return Promise.value(-1L);
}
private boolean isValid(CookieStorage cookieStorage) throws Exception {
ByteBuf payload = null;
try {
payload = deserialize(cookieStorage.lastAccessToken);
if (payload.readableBytes() == 0) {
reset(cookieStorage);
return false;
}
long lastAccessTime = payload.readLong();
long currentTime = System.currentTimeMillis();
long maxInactivityIntervalMillis = config.getMaxInactivityInterval().toMillis();
if (currentTime - lastAccessTime > maxInactivityIntervalMillis) {
reset(cookieStorage);
return false;
}
} finally {
if (payload != null) {
payload.release();
}
}
return true;
}
private void setLastAccessTime(CookieStorage cookieStorage) throws Exception {
ByteBuf data = null;
try {
data = Unpooled.buffer();
data.writeLong(System.currentTimeMillis());
int oldCookiesCount = cookieStorage.lastAccessToken.size();
String[] partitions = serialize(data);
for (int i = 0; i < partitions.length; i++) {
addCookie(config.getLastAccessTimeCookieName() + "_" + i, partitions[i]);
}
for (int i = partitions.length; i < oldCookiesCount; i++) {
invalidateCookie(config.getLastAccessTimeCookieName() + "_" + i);
}
} finally {
if (data != null) {
data.release();
}
}
}
private String[] serialize(ByteBuf sessionData) throws Exception {
if (sessionData == null || sessionData.readableBytes() == 0) {
return new String[0];
}
ByteBuf encrypted = null;
ByteBuf digest = null;
try {
encrypted = crypto.encrypt(sessionData, bufferAllocator);
String encryptedBase64 = toBase64(encrypted);
digest = signer.sign(encrypted.resetReaderIndex(), bufferAllocator);
String digestBase64 = toBase64(digest);
String digestedBase64 = encryptedBase64 + SESSION_SEPARATOR + digestBase64;
if (digestedBase64.length() <= config.getMaxSessionCookieSize()) {
return new String[]{digestedBase64};
}
int count = (int) Math.ceil((double) digestedBase64.length() / config.getMaxSessionCookieSize());
String[] partitions = new String[count];
for (int i = 0; i < count; i++) {
int from = i * config.getMaxSessionCookieSize();
int to = Math.min(from + config.getMaxSessionCookieSize(), digestedBase64.length());
partitions[i] = digestedBase64.substring(from, to);
}
return partitions;
} finally {
if (encrypted != null) {
encrypted.release();
}
if (digest != null) {
digest.release();
}
}
}
private ByteBuf deserialize(ImmutableList<Cookie> sessionCookies) throws Exception {
if (sessionCookies.isEmpty()) {
return Unpooled.EMPTY_BUFFER;
}
StringBuilder sessionCookie = new StringBuilder();
for (Cookie cookie : sessionCookies) {
sessionCookie.append(cookie.value());
}
String[] parts = sessionCookie.toString().split(SESSION_SEPARATOR);
if (parts.length != 2) {
return Unpooled.buffer(0, 0);
}
ByteBuf payload = null;
ByteBuf digest = null;
ByteBuf expectedDigest = null;
ByteBuf decryptedPayload = null;
try {
payload = fromBase64(bufferAllocator, parts[0]);
digest = fromBase64(bufferAllocator, parts[1]);
expectedDigest = signer.sign(payload, bufferAllocator);
if (ByteBufUtil.equals(digest, expectedDigest)) {
decryptedPayload = crypto.decrypt(payload.resetReaderIndex(), bufferAllocator);
} else {
decryptedPayload = Unpooled.buffer(0, 0);
}
} finally {
if (payload != null) {
payload.touch().release();
}
if (digest != null) {
digest.release();
}
if (expectedDigest != null) {
expectedDigest.release();
}
}
return decryptedPayload.touch();
}
private String toBase64(ByteBuf byteBuf) {
ByteBuf encoded = Base64.encode(byteBuf, false, Base64Dialect.STANDARD);
try {
return encoded.toString(CharsetUtil.ISO_8859_1);
} finally {
encoded.release();
}
}
private ByteBuf fromBase64(ByteBufAllocator bufferAllocator, String string) {
ByteBuf byteBuf = ByteBufUtil.encodeString(bufferAllocator, CharBuffer.wrap(string), CharsetUtil.ISO_8859_1);
try {
return Base64.decode(byteBuf, Base64Dialect.STANDARD);
} finally {
byteBuf.release();
}
}
private static class CookieStorage {
private ImmutableList<Cookie> lastAccessToken;
private ImmutableList<Cookie> data;
public CookieStorage(ImmutableList<Cookie> lastAccessToken, ImmutableList<Cookie> data) {
this.lastAccessToken = lastAccessToken;
this.data = data;
}
void clear() {
this.lastAccessToken = ImmutableList.of();
this.data = ImmutableList.of();
}
}
private CookieStorage getCookieStorage() {
Request request = this.request.get();
Optional<CookieStorage> cookieStorageOpt = request.maybeGet(CookieStorage.class);
CookieStorage cookieStorage;
if (cookieStorageOpt.isPresent()) {
cookieStorage = cookieStorageOpt.get();
} else {
cookieStorage = new CookieStorage(
readCookies(latCookieOrdering, request),
readCookies(dataCookieOrdering, request)
);
request.add(CookieStorage.class, cookieStorage);
}
return cookieStorage;
}
private ImmutableList<Cookie> readCookies(CookieOrdering cookieOrdering, Request request) {
Iterable<Cookie> iterable = Iterables.filter(request.getCookies(), c -> c.name().startsWith(cookieOrdering.prefix));
return cookieOrdering.immutableSortedCopy(iterable);
}
private void invalidateCookie(Cookie cookie) {
invalidateCookie(cookie.name());
}
private void invalidateCookie(String name) {
Cookie cookie = response.get().expireCookie(name);
if (cookieConfig.getPath() != null) {
cookie.setPath(cookieConfig.getPath());
}
if (cookieConfig.getDomain() != null) {
cookie.setDomain(cookieConfig.getDomain());
}
cookie.setHttpOnly(cookieConfig.isHttpOnly());
cookie.setSecure(cookieConfig.isSecure());
}
private void addCookie(String name, String value) {
Cookie cookie = response.get().cookie(name, value);
if (cookieConfig.getPath() != null) {
cookie.setPath(cookieConfig.getPath());
}
if (cookieConfig.getDomain() != null) {
cookie.setDomain(cookieConfig.getDomain());
}
long expirySeconds = cookieConfig.getExpires() == null ? 0 : cookieConfig.getExpires().getSeconds();
if (expirySeconds > 0) {
cookie.setMaxAge(expirySeconds);
}
cookie.setHttpOnly(cookieConfig.isHttpOnly());
cookie.setSecure(cookieConfig.isSecure());
}
}