Java tutorial
/** * Copyright 2017-2019 Nitor Creations Oy * * 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 io.nitor.api.backend.session; import io.vertx.core.json.JsonObject; import io.vertx.core.logging.Logger; import io.vertx.core.logging.LoggerFactory; import io.vertx.ext.web.Cookie; import java.security.SecureRandom; import java.util.Base64; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; import static io.nitor.api.backend.session.ByteHelpers.read16; import static io.nitor.api.backend.session.ByteHelpers.read32; import static io.nitor.api.backend.session.ByteHelpers.write16; import static io.nitor.api.backend.session.ByteHelpers.write32; import static io.nitor.api.backend.session.Compressor.compress; import static io.nitor.api.backend.session.Compressor.decompress; import static io.nitor.api.backend.session.MapSerializer.stringToMap; import static io.nitor.api.backend.session.MapSerializer.mapToString; import static io.vertx.ext.web.Cookie.cookie; import static java.lang.System.arraycopy; import static java.nio.charset.StandardCharsets.UTF_8; import static java.util.Base64.getUrlDecoder; import static java.util.Base64.getUrlEncoder; public class CookieConverter { private static final Logger LOG = LoggerFactory.getLogger(CookieConverter.class); private static ThreadLocal<SecureRandom> RANDOM = ThreadLocal.withInitial(SecureRandom::new); private static final Base64.Encoder BASE64ENC = getUrlEncoder().withoutPadding(); private static final Base64.Decoder BASE64DEC = getUrlDecoder(); private static final int MAX_RANDOM_PADDING = 4; private static final int COOKIE_VERSION = 0; private static final ConcurrentHashMap<String, StatelessSession> cookieCache = new ConcurrentHashMap<>(); private static final AtomicInteger cachePutCount = new AtomicInteger(); final String cookieName; private final Encryptor encryptor; private final int maxAge; private final int maxCacheSize; public CookieConverter(JsonObject sessionConf, int maxAge) { this.cookieName = sessionConf.getString("cookieName", "__Host-auth"); this.encryptor = new Encryptor(sessionConf); this.maxAge = maxAge; this.maxCacheSize = sessionConf.getInteger("maxCookieCacheSize", 10_000); } public Cookie sessionToCookie(StatelessSession session) { byte[] randomPadding = generateRandomPaddingBytes(); byte[] data = mapToString(session.sessionData).getBytes(UTF_8); byte[] cookieBytes = new byte[randomPadding.length + 2 + 4 + data.length + session.sourceIpSessionExpirationTimes.size() * 8]; int len = cookieBytes.length + cookieName.length() + 42; // 42 = "; Secure; HttpOnly; MaxAge=123123; Path=/" if (len > 4095) { throw new RuntimeException("Too large cookie: " + len); } int pos = 0; arraycopy(randomPadding, 0, cookieBytes, pos, randomPadding.length); pos += randomPadding.length; write16(cookieBytes, pos, data.length); pos += 2; write32(cookieBytes, pos, session.contextHash + COOKIE_VERSION); pos += 4; arraycopy(data, 0, cookieBytes, pos, data.length); pos += data.length; for (Map.Entry<Integer, Integer> entry : session.sourceIpSessionExpirationTimes.entrySet()) { write32(cookieBytes, pos, entry.getKey()); pos += 4; write32(cookieBytes, pos, entry.getValue()); pos += 4; } byte[] compressed = compress(cookieBytes); byte[] encrypted = encryptor.encrypt(compressed); String cookieValue = BASE64ENC.encodeToString(encrypted); return secureCookie(cookie(cookieName, cookieValue)); } public Cookie secureCookie(Cookie cookie) { return secureCookie(cookie, maxAge); } public static Cookie secureCookie(Cookie cookie, int maxAge) { return cookie.setHttpOnly(true).setSecure(true).setMaxAge(maxAge).setPath("/"); } private byte[] generateRandomPaddingBytes() { SecureRandom random = RANDOM.get(); byte[] randomPadding = new byte[1 + random.nextInt(MAX_RANDOM_PADDING)]; random.nextBytes(randomPadding); for (int i = 0; i < randomPadding.length - 1; ++i) { randomPadding[i] &= 0xFE; } randomPadding[randomPadding.length - 1] |= 1; return randomPadding; } public StatelessSession cookieToSession(Cookie cookie) { String value = cookie.getValue(); StatelessSession session = cookieCache.get(value); if (session != null) { return new StatelessSession(session); } session = new StatelessSession(); try { byte[] encrypted = BASE64DEC.decode(value); byte[] decrypted = encryptor.decrypt(encrypted); byte[] cookieBytes = decompress(decrypted); int pos = skipRandomPadding(cookieBytes); int dataLength = read16(cookieBytes, pos); pos += 2; session.contextHash = read32(cookieBytes, pos) - COOKIE_VERSION; pos += 4; session.sessionData.putAll(stringToMap(new String(cookieBytes, pos, dataLength, UTF_8))); pos += dataLength; while (pos < cookieBytes.length) { session.sourceIpSessionExpirationTimes.put(read32(cookieBytes, pos), read32(cookieBytes, pos + 4)); pos += 8; } } catch (Exception ex) { LOG.warn("Invalid cookie", ex); return null; } cookieCache.put(value, new StatelessSession(session)); if (cachePutCount.incrementAndGet() > maxCacheSize) { cachePutCount.set(0); cookieCache.clear(); } return session; } private int skipRandomPadding(byte[] cookieBytes) { for (int i = 0; i <= MAX_RANDOM_PADDING; ++i) { if ((cookieBytes[i] & 0x1) != 0) { return i + 1; } } throw new RuntimeException("invalid random bytes"); } public StatelessSession getSession(Iterable<Cookie> cookies) { for (Cookie cookie : cookies) { if (cookieName.equals(cookie.getName())) { return cookieToSession(cookie); } } return null; } }