All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.jgrapes.http.SessionManager Maven / Gradle / Ivy

The newest version!
/*
 * JGrapes Event Driven Framework
 * Copyright (C) 2017-2022 Michael N. Lipp
 * 
 * This program is free software; you can redistribute it and/or modify it 
 * under the terms of the GNU Affero General Public License as published by 
 * the Free Software Foundation; either version 3 of the License, or 
 * (at your option) any later version.
 * 
 * This program 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 Affero General Public License 
 * for more details.
 * 
 * You should have received a copy of the GNU Affero General Public License along 
 * with this program; if not, see .
 */

package org.jgrapes.http;

import java.lang.management.ManagementFactory;
import java.lang.ref.WeakReference;
import java.net.HttpCookie;
import java.security.SecureRandom;
import java.time.Duration;
import java.time.Instant;
import java.util.HashSet;
import java.util.Optional;
import java.util.Set;
import java.util.function.Supplier;
import javax.management.InstanceAlreadyExistsException;
import javax.management.InstanceNotFoundException;
import javax.management.MBeanRegistrationException;
import javax.management.MBeanServer;
import javax.management.MalformedObjectNameException;
import javax.management.NotCompliantMBeanException;
import javax.management.ObjectName;
import org.jdrupes.httpcodec.protocols.http.HttpField;
import org.jdrupes.httpcodec.protocols.http.HttpRequest;
import org.jdrupes.httpcodec.protocols.http.HttpResponse;
import org.jdrupes.httpcodec.types.CacheControlDirectives;
import org.jdrupes.httpcodec.types.Converters;
import org.jdrupes.httpcodec.types.Converters.SameSiteAttribute;
import org.jdrupes.httpcodec.types.CookieList;
import org.jdrupes.httpcodec.types.Directive;
import org.jgrapes.core.Associator;
import org.jgrapes.core.Channel;
import org.jgrapes.core.Component;
import org.jgrapes.core.Components;
import org.jgrapes.core.Components.Timer;
import org.jgrapes.core.annotation.Handler;
import org.jgrapes.core.internal.EventBase;
import org.jgrapes.http.annotation.RequestHandler;
import org.jgrapes.http.events.DiscardSession;
import org.jgrapes.http.events.ProtocolSwitchAccepted;
import org.jgrapes.http.events.Request;
import org.jgrapes.io.IOSubchannel;

/**
 * A base class for session managers. A session manager associates 
 * {@link Request} events with a 
 * {@link Supplier {@code Supplier>}}
 * for a {@link Session} using `Session.class` as association identifier
 * (see {@link Session#from}). Note that the `Optional` will never by
 * empty. The return type has been chosen to be in accordance with
 * {@link Associator#associatedGet(Class)}.
 * 
 * The {@link Request} handler has a default priority of 1000.
 * 
 * Managers track requests using a cookie with a given name and path. The 
 * path is a prefix that has to be matched by the request, often "/".
 * If no cookie with the given name (see {@link #idName()}) is found,
 * a new cookie with that name and the specified path is created.
 * The cookie's value is the unique session id that is used to lookup
 * the session object.
 * 
 * Session managers provide additional support for web sockets. If a
 * web socket is accepted, the session associated with the request
 * is automatically made available to the {@link IOSubchannel} that
 * is subsequently used for the web socket events. This allows
 * handlers for web socket messages to access the session like
 * {@link Request} handlers (see {@link #onProtocolSwitchAccepted}).
 * 
 * @see EventBase#setAssociated(Object, Object)
 * @see "[OWASP Session Management Cheat Sheet](https://www.owasp.org/index.php/Session_Management_Cheat_Sheet)"
 */
@SuppressWarnings({ "PMD.DataClass", "PMD.AvoidPrintStackTrace",
    "PMD.DataflowAnomalyAnalysis", "PMD.TooManyMethods",
    "PMD.CouplingBetweenObjects" })
public abstract class SessionManager extends Component {

    private static SecureRandom secureRandom = new SecureRandom();

    private String idName = "id";
    @SuppressWarnings("PMD.ImmutableField")
    private String path = "/";
    private long absoluteTimeout = 9 * 60 * 60 * 1000;
    private long idleTimeout = 30 * 60 * 1000;
    private int maxSessions = 1000;
    private Timer nextPurge;

    /**
     * Creates a new session manager with its channel set to
     * itself and the path set to "/". The manager handles
     * all {@link Request} events.
     */
    public SessionManager() {
        this("/");
    }

    /**
     * Creates a new session manager with its channel set to
     * itself and the path set to the given path. The manager
     * handles all requests that match the given path, using the
     * same rules as browsers do for selecting the cookies that
     * are to be sent.
     * 
     * @param path the path
     */
    public SessionManager(String path) {
        this(Channel.SELF, path);
    }

    /**
     * Creates a new session manager with its channel set to
     * the given channel and the path to "/". The manager handles
     * all {@link Request} events.
     * 
     * @param componentChannel the component channel
     */
    public SessionManager(Channel componentChannel) {
        this(componentChannel, "/");
    }

    /**
     * Creates a new session manager with the given channel and path.
     * The manager handles all requests that match the given path, using
     * the same rules as browsers do for selecting the cookies that
     * are to be sent.
     *  
     * @param componentChannel the component channel
     * @param path the path
     */
    public SessionManager(Channel componentChannel, String path) {
        this(componentChannel, derivePattern(path), 1000, path);
    }

    /**
     * Returns the path.
     *
     * @return the string
     */
    public String path() {
        return path;
    }

    /**
     * Derives the resource pattern from the path.
     *
     * @param path the path
     * @return the pattern
     */
    @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis",
        "PMD.AvoidLiteralsInIfCondition" })
    protected static String derivePattern(String path) {
        String pattern;
        if ("/".equals(path)) {
            pattern = "/**";
        } else {
            String patternBase = path;
            if (patternBase.endsWith("/")) {
                patternBase = path.substring(0, path.length() - 1);
            }
            pattern = patternBase + "|," + patternBase + "/**";
        }
        return pattern;
    }

    /**
     * Creates a new session manager using the given channel and path.
     * The manager handles only requests that match the given pattern.
     * The handler is registered with the given priority.
     * 
     * This constructor can be used if special handling of top level
     * requests is needed.
     *
     * @param componentChannel the component channel
     * @param pattern the path part of a {@link ResourcePattern}
     * @param priority the priority
     * @param path the path
     */
    public SessionManager(Channel componentChannel, String pattern,
            int priority, String path) {
        super(componentChannel);
        this.path = path;
        RequestHandler.Evaluator.add(this, "onRequest", pattern, priority);
        MBeanView.addManager(this);
    }

    private Optional minTimeout() {
        if (absoluteTimeout > 0 && idleTimeout > 0) {
            return Optional.of(Math.min(absoluteTimeout, idleTimeout));
        }
        if (absoluteTimeout > 0) {
            return Optional.of(absoluteTimeout);
        }
        if (idleTimeout > 0) {
            return Optional.of(idleTimeout);
        }
        return Optional.empty();
    }

    private void startPurger() {
        synchronized (this) {
            if (nextPurge == null) {
                minTimeout().ifPresent(timeout -> Components
                    .schedule(this::purgeAction, Duration.ofMillis(timeout)));
            }
        }
    }

    @SuppressWarnings({ "PMD.UnusedFormalParameter",
        "PMD.UnusedPrivateMethod" })
    private void purgeAction(Timer timer) {
        nextPurge = startDiscarding(absoluteTimeout, idleTimeout)
            .map(nextAt -> Components.schedule(this::purgeAction, nextAt))
            .orElse(null);
    }

    /**
     * The name used for the session id cookie. Defaults to "`id`".
     * 
     * @return the id name
     */
    public String idName() {
        return idName;
    }

    /**
     * @param idName the id name to set
     * 
     * @return the session manager for easy chaining
     */
    public SessionManager setIdName(String idName) {
        this.idName = idName;
        return this;
    }

    /**
     * Set the maximum number of sessions. If the value is zero or less,
     * an unlimited number of sessions is supported. The default value
     * is 1000.
     * 
     * If adding a new session would exceed the limit, first all
     * sessions older than {@link #absoluteTimeout()} are removed.
     * If this doesn't free a slot, the least recently used session
     * is removed.
     * 
     * @param maxSessions the maxSessions to set
     * @return the session manager for easy chaining
     */
    public SessionManager setMaxSessions(int maxSessions) {
        this.maxSessions = maxSessions;
        return this;
    }

    /**
     * @return the maxSessions
     */
    public int maxSessions() {
        return maxSessions;
    }

    /**
     * Sets the absolute timeout for a session. The absolute
     * timeout is the time after which a session is invalidated (relative
     * to its creation time). Defaults to 9 hours. Zero or less disables
     * the timeout.
     * 
     * @param timeout the absolute timeout
     * @return the session manager for easy chaining
     */
    public SessionManager setAbsoluteTimeout(Duration timeout) {
        this.absoluteTimeout = timeout.toMillis();
        return this;
    }

    /**
     * @return the absolute session timeout (in seconds)
     */
    public Duration absoluteTimeout() {
        return Duration.ofMillis(absoluteTimeout);
    }

    /**
     * Sets the idle timeout for a session. Defaults to 30 minutes.
     * Zero or less disables the timeout. 
     * 
     * @param timeout the absolute timeout
     * @return the session manager for easy chaining
     */
    public SessionManager setIdleTimeout(Duration timeout) {
        this.idleTimeout = timeout.toMillis();
        return this;
    }

    /**
     * @return the idle timeout
     */
    public Duration idleTimeout() {
        return Duration.ofMillis(idleTimeout);
    }

    /**
     * Associates the event with a {@link Session} object
     * using `Session.class` as association identifier.
     * Does nothing if a session is already associated or
     * the request has already been fulfilled.
     * 
     * @param event the event
     */
    @RequestHandler(dynamic = true)
    public void onRequest(Request.In event) {
        if (event.associated(Session.class).isPresent() || event.fulfilled()) {
            return;
        }
        final HttpRequest request = event.httpRequest();
        Optional requestedSessionId = request.findValue(
            HttpField.COOKIE, Converters.COOKIE_LIST)
            .flatMap(cookies -> cookies.stream().filter(
                cookie -> cookie.getName().equals(idName()))
                .findFirst().map(HttpCookie::getValue));
        if (requestedSessionId.isPresent()) {
            String sessionId = requestedSessionId.get();
            synchronized (this) {
                Optional session = lookupSession(sessionId);
                if (session.isPresent()) {
                    setSessionSupplier(event, sessionId);
                    session.get().updateLastUsedAt();
                    return;
                }
            }
        }
        Session session = createSession(
            addSessionCookie(request.response().get(), createSessionId()));
        setSessionSupplier(event, session.id());
        startPurger();
    }

    /**
     * Associated the associator with a session supplier for the 
     * given session id and note `this` as session manager.
     *
     * @param holder the channel
     * @param sessionId the session id
     */
    protected void setSessionSupplier(Associator holder, String sessionId) {
        holder.setAssociated(SessionManager.class, this);
        holder.setAssociated(Session.class,
            new SessionSupplier(holder, sessionId));
    }

    /**
     * Supports obtaining a {@link Session} from an {@link IOSubchannel}. 
     */
    private class SessionSupplier implements Supplier> {

        private final Associator holder;
        private final String sessionId;

        /**
         * Instantiates a new session supplier.
         *
         * @param holder the channel
         * @param sessionId the session id
         */
        public SessionSupplier(Associator holder, String sessionId) {
            this.holder = holder;
            this.sessionId = sessionId;
        }

        @Override
        public Optional get() {
            Optional session = lookupSession(sessionId);
            if (session.isPresent()) {
                session.get().updateLastUsedAt();
                return session;
            }
            Session newSession = createSession(createSessionId());
            setSessionSupplier(holder, newSession.id());
            return Optional.of(newSession);
        }

    }

    /**
     * Creates a session id and adds the corresponding cookie to the
     * response.
     * 
     * @param response the response
     * @return the session id
     */
    protected String addSessionCookie(HttpResponse response, String sessionId) {
        HttpCookie sessionCookie = new HttpCookie(idName(), sessionId);
        sessionCookie.setPath(path);
        sessionCookie.setHttpOnly(true);
        response.computeIfAbsent(HttpField.SET_COOKIE,
            () -> new CookieList(SameSiteAttribute.STRICT))
            .value().add(sessionCookie);
        response.computeIfAbsent(
            HttpField.CACHE_CONTROL, CacheControlDirectives::new).value()
            .add(new Directive("no-cache", "SetCookie, Set-Cookie2"));
        return sessionId;
    }

    private String createSessionId() {
        StringBuilder sessionIdBuilder = new StringBuilder();
        byte[] bytes = new byte[16];
        secureRandom.nextBytes(bytes);
        for (byte b : bytes) {
            sessionIdBuilder.append(Integer.toHexString(b & 0xff));
        }
        return sessionIdBuilder.toString();
    }

    /**
     * Checks if the absolute or idle timeout has been reached.
     *
     * @param session the session
     * @return true, if successful
     */
    protected boolean hasTimedOut(Session session) {
        Instant now = Instant.now();
        return absoluteTimeout > 0 && Duration
            .between(session.createdAt(), now).toMillis() > absoluteTimeout
            || idleTimeout > 0 && Duration.between(session.lastUsedAt(),
                now).toMillis() > idleTimeout;
    }

    /**
     * Start discarding all sessions (generate {@link DiscardSession} events)
     * that have reached their absolute or idle timeout. Do not
     * make the sessions unavailable yet. 
     * 
     * Returns the time when the next timeout occurs. This method is 
     * called only if at least one of the timeouts has been specified.
     * 
     * Implementations have to take care that sessions are only discarded
     * once. As they must remain available while the {@link DiscardSession}
     * event is handled this may require marking them as being discarded. 
     *
     * @param absoluteTimeout the absolute timeout
     * @param idleTimeout the idle timeout
     * @return the next timeout (empty if no sessions left)
     */
    protected abstract Optional startDiscarding(long absoluteTimeout,
            long idleTimeout);

    /**
     * Creates a new session with the given id.
     * 
     * @param sessionId
     * @return the session
     */
    protected abstract Session createSession(String sessionId);

    /**
     * Lookup the session with the given id. Lookup will fail if
     * the session has timed out.
     * 
     * @param sessionId
     * @return the session
     */
    protected abstract Optional lookupSession(String sessionId);

    /**
     * Removes the given session from the cache.
     * 
     * @param sessionId the session id
     */
    protected abstract void removeSession(String sessionId);

    /**
     * Return the number of established sessions.
     * 
     * @return the result
     */
    protected abstract int sessionCount();

    /**
     * Discards the given session. The handler has a priority of -1000,
     * thus allowing other handler to make use of the session (for a
     * time) before it becomes unavailable.
     * 
     * @param event the event
     */
    @Handler(channels = Channel.class, priority = -1000)
    public void onDiscard(DiscardSession event) {
        removeSession(event.session().id());
        event.session().close();
    }

    /**
     * Associates the channel with a 
     * {@link Supplier {@code Supplier>}} 
     * for the session. Initially, the associated session is the session
     * associated with the protocol switch event. If this session times out,
     * a new session is returned as a fallback, thus making sure that
     * the `Optional` is never empty. The new session is, however, created 
     * independently of any new session created by {@link #onRequest}.
     * 
     * Applications should avoid any ambiguity by executing a proper 
     * cleanup of the web application in response to a 
     * {@link DiscardSession} event (including reestablishing the web
     * socket connections from new requests).
     * 
     * @param event the event
     * @param channel the channel
     */
    @Handler(priority = 1000)
    public void onProtocolSwitchAccepted(
            ProtocolSwitchAccepted event, IOSubchannel channel) {
        Request.In request = event.requestEvent();
        request.associated(SessionManager.class).filter(sm -> sm == this)
            .ifPresent(
                sm -> setSessionSupplier(channel, Session.from(request).id()));
    }

    /**
     * An MBean interface for getting information about the 
     * established sessions.
     */
    @SuppressWarnings("PMD.CommentRequired")
    public interface SessionManagerMXBean {

        String getComponentPath();

        String getPath();

        int getMaxSessions();

        long getAbsoluteTimeout();

        long getIdleTimeout();

        int getSessionCount();
    }

    /**
     * The session manager information.
     */
    public static class SessionManagerInfo implements SessionManagerMXBean {

        private static MBeanServer mbs
            = ManagementFactory.getPlatformMBeanServer();

        private ObjectName mbeanName;
        private final WeakReference sessionManagerRef;

        /**
         * Instantiates a new session manager info.
         *
         * @param sessionManager the session manager
         */
        @SuppressWarnings({ "PMD.AvoidCatchingGenericException",
            "PMD.EmptyCatchBlock" })
        public SessionManagerInfo(SessionManager sessionManager) {
            try {
                mbeanName = new ObjectName("org.jgrapes.http:type="
                    + SessionManager.class.getSimpleName() + ",name="
                    + ObjectName.quote(Components.simpleObjectName(
                        sessionManager)));
            } catch (MalformedObjectNameException e) {
                // Won't happen
            }
            sessionManagerRef = new WeakReference<>(sessionManager);
            try {
                mbs.unregisterMBean(mbeanName);
            } catch (Exception e) {
                // Just in case, should not work
            }
            try {
                mbs.registerMBean(this, mbeanName);
            } catch (InstanceAlreadyExistsException | MBeanRegistrationException
                    | NotCompliantMBeanException e) {
                // Have to live with that
            }
        }

        /**
         * Returns the session manager.
         *
         * @return the optional session manager
         */
        @SuppressWarnings({ "PMD.AvoidCatchingGenericException",
            "PMD.EmptyCatchBlock" })
        public Optional manager() {
            SessionManager manager = sessionManagerRef.get();
            if (manager == null) {
                try {
                    mbs.unregisterMBean(mbeanName);
                } catch (MBeanRegistrationException
                        | InstanceNotFoundException e) {
                    // Should work.
                }
            }
            return Optional.ofNullable(manager);
        }

        @Override
        public String getComponentPath() {
            return manager().map(mgr -> mgr.componentPath())
                .orElse("");
        }

        @Override
        public String getPath() {
            return manager().map(mgr -> mgr.path).orElse("");
        }

        @Override
        public int getMaxSessions() {
            return manager().map(SessionManager::maxSessions).orElse(0);
        }

        @Override
        public long getAbsoluteTimeout() {
            return manager().map(mgr -> mgr.absoluteTimeout().toMillis())
                .orElse(0L);
        }

        @Override
        public long getIdleTimeout() {
            return manager().map(mgr -> mgr.idleTimeout().toMillis())
                .orElse(0L);
        }

        @Override
        public int getSessionCount() {
            return manager().map(SessionManager::sessionCount).orElse(0);
        }
    }

    /**
     * An MBean interface for getting information about all session
     * managers.
     * 
     * There is currently no summary information. However, the (periodic)
     * invocation of {@link SessionManagerSummaryMXBean#getManagers()} ensures
     * that entries for removed {@link SessionManager}s are unregistered.
     */
    public interface SessionManagerSummaryMXBean {

        /**
         * Gets the managers.
         *
         * @return the managers
         */
        Set getManagers();
    }

    /**
     * The MBean view.
     */
    private static final class MBeanView
            implements SessionManagerSummaryMXBean {
        private static Set managerInfos = new HashSet<>();

        /**
         * Adds a manager.
         *
         * @param manager the manager
         */
        public static void addManager(SessionManager manager) {
            synchronized (managerInfos) {
                managerInfos.add(new SessionManagerInfo(manager));
            }
        }

        @Override
        public Set getManagers() {
            Set expired = new HashSet<>();
            synchronized (managerInfos) {
                for (SessionManagerInfo managerInfo : managerInfos) {
                    if (!managerInfo.manager().isPresent()) {
                        expired.add(managerInfo);
                    }
                }
                managerInfos.removeAll(expired);
            }
            @SuppressWarnings("unchecked")
            Set result
                = (Set) (Object) managerInfos;
            return result;
        }
    }

    static {
        try {
            MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();
            ObjectName mxbeanName = new ObjectName("org.jgrapes.http:type="
                + SessionManager.class.getSimpleName() + "s");
            mbs.registerMBean(new MBeanView(), mxbeanName);
        } catch (MalformedObjectNameException | InstanceAlreadyExistsException
                | MBeanRegistrationException | NotCompliantMBeanException e) {
            // Does not happen
            e.printStackTrace();
        }
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy