package act.util; /*- * #%L * ACT Framework * %% * Copyright (C) 2014 - 2017 ActFramework * %% * 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. * #L% */ import act.Act; import act.app.ActionContext; import act.app.App; import act.conf.AppConfig; import act.plugin.Plugin; import org.osgl.$; import org.osgl.http.H; import org.osgl.http.H.Session; import org.osgl.logging.L; import org.osgl.logging.Logger; import org.osgl.util.*; import javax.enterprise.context.ApplicationScoped; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Map; import static act.Destroyable.Util.tryDestroyAll; import static org.osgl.http.H.Session.KEY_EXPIRATION; import static org.osgl.http.H.Session.KEY_EXPIRE_INDICATOR; /** * Resolve/Persist session/flash */ public class SessionManager extends DestroyableBase { private static Logger logger = L.get(SessionManager.class); private C.List<Listener> registry = C.newList(); private Map<App, CookieResolver> resolvers = C.newMap(); private CookieResolver theResolver = null; public SessionManager() { } @Override protected void releaseResources() { tryDestroyAll(registry, ApplicationScoped.class); registry = null; tryDestroyAll(resolvers.values(), ApplicationScoped.class); resolvers = null; theResolver = null; } public void register(Listener listener) { if (!registry.contains(listener)) registry.add(listener); } public <T extends Listener> T findListener(Class<T> clz) { for (Listener l: registry) { if (clz.isAssignableFrom(l.getClass())) { return (T)l; } } return null; } public Session resolveSession(ActionContext context) { Session session = getResolver(context).resolveSession(context); return session; } public void fireSessionResolved(ActionContext ctx) { sessionResolved(ctx.session(), ctx); } public H.Flash resolveFlash(ActionContext context) { return getResolver(context).resolveFlash(context); } public H.Cookie dissolveSession(ActionContext context) { onSessionDissolve(); return getResolver(context).dissolveSession(context); } public H.Cookie dissolveFlash(ActionContext context) { return getResolver(context).dissolveFlash(context); } private void sessionResolved(Session session, ActionContext context) { for (Listener l : registry) { l.sessionResolved(session, context); } } private void onSessionDissolve() { for (Listener l : registry) { l.onSessionDissolve(); } } private CookieResolver getResolver(ActionContext context) { App app = context.app(); if (Act.multiTenant()) { CookieResolver resolver = resolvers.get(app); if (null == resolver) { resolver = new CookieResolver(app); resolvers.put(app, resolver); } return resolver; } else { if (theResolver == null) { theResolver = new CookieResolver(app); } return theResolver; } } public static abstract class Listener extends DestroyableBase implements Plugin { @Override public void register() { Act.sessionManager().register(this); } /** * Called once a session object has been resolved from session * cookie of the incoming request * <p> * Plugin use this hook to implement specific logic, e.g. * grab username from session to initialize authentication * </p> * * @param session the session object resolved from cookie */ public void sessionResolved(Session session, ActionContext context) {} /** * Called before a session is about to be written to a cookie * <p>Plugin use this hook to release resources associated with the * computational context</p> */ public void onSessionDissolve() {} } static class CookieResolver { private App app; private AppConfig conf; private boolean encryptSession; private boolean persistentSession; private boolean sessionSecure; private long ttl; private boolean sessionWillExpire; private SessionMapper sessionMapper; private String sessionCookieName; private String flashCookieName; CookieResolver(App app) { E.NPE(app); this.app = app; this.conf = app.config(); this.encryptSession = conf.encryptSession(); this.persistentSession = conf.persistSession(); this.sessionSecure = conf.sessionSecure(); long ttl = conf.sessionTtl(); this.ttl = ttl * 1000L; sessionWillExpire = ttl > 0; sessionMapper = conf.sessionMapper(); sessionCookieName = conf.sessionCookieName(); flashCookieName = conf.flashCookieName(); } Session resolveSession(ActionContext context) { H.Request req = context.req(); context.preCheckCsrf(); String val = sessionMapper.deserializeSession(context); Session session = new Session(); long now = $.ms(); if (S.blank(val)) { session = processExpiration(session, now, true, req); } else { resolveFromCookieContent(session, val, true); session = processExpiration(session, now, false, req); } context.checkCsrf(session); return session; } H.Flash resolveFlash(ActionContext context) { H.Flash flash = new H.Flash(); String val = sessionMapper.deserializeFlash(context); if (null != val) { resolveFromCookieContent(flash, val, false); flash.discard(); // prevent cookie content from been output to response again } return flash; } H.Cookie dissolveSession(ActionContext context) { context.setCsrfCookieAndRenderArgs(); Session session = context.session(); if (null == session) { return null; } boolean sessionChanged = session.changed(); if (!sessionChanged && (session.empty() || !sessionWillExpire)) { // Nothing changed and no cookie-expire or empty, consequently send nothing back. return null; } H.Cookie cookie; if (session.empty()) { // session is empty, delete it from cookie cookie = createCookie(sessionCookieName, ""); } else { session.id(); // ensure session ID is generated if (sessionWillExpire && !session.contains(KEY_EXPIRATION)) { // session get cleared before session.put(KEY_EXPIRATION, $.ms() + ttl); } String data = dissolveIntoCookieContent(session, true); cookie = createCookie(sessionCookieName, data); } // I can't clear here b/c template might use it:: session.clear(); return cookie; } H.Cookie dissolveFlash(ActionContext context) { H.Flash flash = context.flash(); if (null == flash || flash.isEmpty()) { return null; } String data = dissolveIntoCookieContent(flash.out(), false); H.Cookie cookie = createCookie(flashCookieName, data); // I can't clear here b/c template might use it:: flash.clear(); return cookie; } void resolveFromCookieContent(H.KV<?> kv, String content, boolean isSession) { String data = Codec.decodeUrl(content, Charsets.UTF_8); if (isSession) { if (encryptSession) { try { data = app.decrypt(data); } catch (Exception e) { return; } } int firstDashIndex = data.indexOf("-"); if (firstDashIndex < 0) { return; } String sign = data.substring(0, firstDashIndex); data = data.substring(firstDashIndex + 1); String sign1 = app.sign(data); if (!sign.equals(sign1)) { return; } } List<char[]> pairs = split(data.toCharArray(), '\u0000'); if (pairs.isEmpty()) return; for (char[] pair: pairs) { List<char[]> kAndV = split(pair, '\u0001'); int sz = kAndV.size(); if (sz != 2) { S.Buffer sb = S.newBuffer(); for (int i = 0; i < sz; ++i) { if (i > 0) sb.append(":"); sb.append(Arrays.toString(kAndV.get(i))); } logger.warn("unexpected KV string: %S", sb.toString()); } else { kv.put(new String(kAndV.get(0)), new String(kAndV.get(1))); } } } private List<char[]> split(char[] content, char separator) { int len = content.length; if (0 == len) { return C.list(); } List<char[]> l = new ArrayList<char[]>(); int start = 0; for (int i = 0; i < len; ++i) { char c = content[i]; if (c == separator) { if (i == start) { start++; continue; } char[] ca = new char[i - start]; System.arraycopy(content, start, ca, 0, i - start); l.add(ca); start = i + 1; } } if (start == 0) { l.add(content); } else { char[] ca = new char[len - start]; System.arraycopy(content, start, ca, 0, len - start); l.add(ca); } return l; } String dissolveIntoCookieContent(H.KV<?> kv, boolean isSession) { S.Buffer sb = S.buffer(); int i = 0; for (String k : kv.keySet()) { if (i > 0) { sb.append("\u0000"); } sb.append(k); sb.append("\u0001"); sb.append(kv.get(k)); i++; } String data = sb.toString(); if (isSession) { String sign = app.sign(data); data = S.concat(sign, "-", data); if (encryptSession) { data = app.encrypt(data); } } data = Codec.encodeUrl(data, Charsets.UTF_8); return data; } private Session processExpiration(Session session, long now, boolean freshSession, H.Request request) { if (!sessionWillExpire) return session; long expiration = now + ttl; if (freshSession) { // no previous cookie to restore; but we need to set the timestamp in the new cookie // note we use `load` API instead of `put` because we don't want to set the dirty flag // in this case session.load(KEY_EXPIRATION, String.valueOf(expiration)); } else { String s = session.get(KEY_EXPIRATION); long oldTimestamp = null == s ? -1 : Long.parseLong(s); long newTimestamp = expiration; // Verify that the session contains a timestamp, and that it's not expired if (oldTimestamp < 0) { // invalid session, reset it session = new Session(); } else { if (oldTimestamp < now) { // Session expired session = new Session(); session.put(KEY_EXPIRE_INDICATOR, true); } else { session.remove(KEY_EXPIRE_INDICATOR); boolean skipUpdateExpiration = S.eq(conf.pingPath(), request.url()); if (skipUpdateExpiration) { newTimestamp = oldTimestamp; } } } session.put(KEY_EXPIRATION, newTimestamp); } return session; } private H.Cookie createCookie(String name, String value) { H.Cookie cookie = new H.Cookie(name, value); cookie.path("/"); cookie.domain(conf.cookieDomain()); cookie.httpOnly(true); cookie.secure(sessionSecure); if (sessionWillExpire && persistentSession) { cookie.maxAge((int) (ttl / 1000)); } return cookie; } } }