001/*
002 * JGrapes Event Driven Framework
003 * Copyright (C) 2017-2022 Michael N. Lipp
004 * 
005 * This program is free software; you can redistribute it and/or modify it 
006 * under the terms of the GNU Affero General Public License as published by 
007 * the Free Software Foundation; either version 3 of the License, or 
008 * (at your option) any later version.
009 * 
010 * This program is distributed in the hope that it will be useful, but 
011 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
012 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License 
013 * for more details.
014 * 
015 * You should have received a copy of the GNU Affero General Public License along 
016 * with this program; if not, see <http://www.gnu.org/licenses/>.
017 */
018
019package org.jgrapes.http;
020
021import java.lang.management.ManagementFactory;
022import java.lang.ref.WeakReference;
023import java.net.HttpCookie;
024import java.security.SecureRandom;
025import java.time.Duration;
026import java.time.Instant;
027import java.util.HashSet;
028import java.util.Optional;
029import java.util.Set;
030import java.util.function.Supplier;
031import javax.management.InstanceAlreadyExistsException;
032import javax.management.InstanceNotFoundException;
033import javax.management.MBeanRegistrationException;
034import javax.management.MBeanServer;
035import javax.management.MalformedObjectNameException;
036import javax.management.NotCompliantMBeanException;
037import javax.management.ObjectName;
038import org.jdrupes.httpcodec.protocols.http.HttpField;
039import org.jdrupes.httpcodec.protocols.http.HttpRequest;
040import org.jdrupes.httpcodec.protocols.http.HttpResponse;
041import org.jdrupes.httpcodec.types.CacheControlDirectives;
042import org.jdrupes.httpcodec.types.Converters;
043import org.jdrupes.httpcodec.types.Converters.SameSiteAttribute;
044import org.jdrupes.httpcodec.types.CookieList;
045import org.jdrupes.httpcodec.types.Directive;
046import org.jgrapes.core.Associator;
047import org.jgrapes.core.Channel;
048import org.jgrapes.core.Component;
049import org.jgrapes.core.Components;
050import org.jgrapes.core.Components.Timer;
051import org.jgrapes.core.annotation.Handler;
052import org.jgrapes.core.internal.EventBase;
053import org.jgrapes.http.annotation.RequestHandler;
054import org.jgrapes.http.events.DiscardSession;
055import org.jgrapes.http.events.ProtocolSwitchAccepted;
056import org.jgrapes.http.events.Request;
057import org.jgrapes.io.IOSubchannel;
058
059/**
060 * A base class for session managers. A session manager associates 
061 * {@link Request} events with a 
062 * {@link Supplier {@code Supplier<Optional<Session>>}}
063 * for a {@link Session} using `Session.class` as association identifier
064 * (see {@link Session#from}). Note that the `Optional` will never by
065 * empty. The return type has been chosen to be in accordance with
066 * {@link Associator#associatedGet(Class)}.
067 * 
068 * The {@link Request} handler has a default priority of 1000.
069 * 
070 * Managers track requests using a cookie with a given name and path. The 
071 * path is a prefix that has to be matched by the request, often "/".
072 * If no cookie with the given name (see {@link #idName()}) is found,
073 * a new cookie with that name and the specified path is created.
074 * The cookie's value is the unique session id that is used to lookup
075 * the session object.
076 * 
077 * Session managers provide additional support for web sockets. If a
078 * web socket is accepted, the session associated with the request
079 * is automatically made available to the {@link IOSubchannel} that
080 * is subsequently used for the web socket events. This allows
081 * handlers for web socket messages to access the session like
082 * {@link Request} handlers (see {@link #onProtocolSwitchAccepted}).
083 * 
084 * @see EventBase#setAssociated(Object, Object)
085 * @see "[OWASP Session Management Cheat Sheet](https://www.owasp.org/index.php/Session_Management_Cheat_Sheet)"
086 */
087@SuppressWarnings({ "PMD.DataClass", "PMD.AvoidPrintStackTrace",
088    "PMD.DataflowAnomalyAnalysis", "PMD.TooManyMethods" })
089public abstract class SessionManager extends Component {
090
091    private static SecureRandom secureRandom = new SecureRandom();
092
093    private String idName = "id";
094    @SuppressWarnings("PMD.ImmutableField")
095    private String path = "/";
096    private long absoluteTimeout = 9 * 60 * 60 * 1000;
097    private long idleTimeout = 30 * 60 * 1000;
098    private int maxSessions = 1000;
099    private Timer nextPurge;
100
101    /**
102     * Creates a new session manager with its channel set to
103     * itself and the path set to "/". The manager handles
104     * all {@link Request} events.
105     */
106    public SessionManager() {
107        this("/");
108    }
109
110    /**
111     * Creates a new session manager with its channel set to
112     * itself and the path set to the given path. The manager
113     * handles all requests that match the given path, using the
114     * same rules as browsers do for selecting the cookies that
115     * are to be sent.
116     * 
117     * @param path the path
118     */
119    public SessionManager(String path) {
120        this(Channel.SELF, path);
121    }
122
123    /**
124     * Creates a new session manager with its channel set to
125     * the given channel and the path to "/". The manager handles
126     * all {@link Request} events.
127     * 
128     * @param componentChannel the component channel
129     */
130    public SessionManager(Channel componentChannel) {
131        this(componentChannel, "/");
132    }
133
134    /**
135     * Creates a new session manager with the given channel and path.
136     * The manager handles all requests that match the given path, using
137     * the same rules as browsers do for selecting the cookies that
138     * are to be sent.
139     *  
140     * @param componentChannel the component channel
141     * @param path the path
142     */
143    public SessionManager(Channel componentChannel, String path) {
144        this(componentChannel, derivePattern(path), 1000, path);
145    }
146
147    /**
148     * Returns the path.
149     *
150     * @return the string
151     */
152    public String path() {
153        return path;
154    }
155
156    /**
157     * Derives the resource pattern from the path.
158     *
159     * @param path the path
160     * @return the pattern
161     */
162    @SuppressWarnings("PMD.DataflowAnomalyAnalysis")
163    protected static String derivePattern(String path) {
164        String pattern;
165        if ("/".equals(path)) {
166            pattern = "/**";
167        } else {
168            String patternBase = path;
169            if (patternBase.endsWith("/")) {
170                patternBase = path.substring(0, path.length() - 1);
171            }
172            pattern = patternBase + "|," + patternBase + "/**";
173        }
174        return pattern;
175    }
176
177    /**
178     * Creates a new session manager using the given channel and path.
179     * The manager handles only requests that match the given pattern.
180     * The handler is registered with the given priority.
181     * 
182     * This constructor can be used if special handling of top level
183     * requests is needed.
184     *
185     * @param componentChannel the component channel
186     * @param pattern the path part of a {@link ResourcePattern}
187     * @param priority the priority
188     * @param path the path
189     */
190    public SessionManager(Channel componentChannel, String pattern,
191            int priority, String path) {
192        super(componentChannel);
193        this.path = path;
194        RequestHandler.Evaluator.add(this, "onRequest", pattern, priority);
195        MBeanView.addManager(this);
196    }
197
198    private Optional<Long> minTimeout() {
199        if (absoluteTimeout > 0 && idleTimeout > 0) {
200            return Optional.of(Math.min(absoluteTimeout, idleTimeout));
201        }
202        if (absoluteTimeout > 0) {
203            return Optional.of(absoluteTimeout);
204        }
205        if (idleTimeout > 0) {
206            return Optional.of(idleTimeout);
207        }
208        return Optional.empty();
209    }
210
211    private void startPurger() {
212        synchronized (this) {
213            if (nextPurge == null) {
214                minTimeout().ifPresent(timeout -> Components
215                    .schedule(this::purgeAction, Duration.ofMillis(timeout)));
216            }
217        }
218    }
219
220    @SuppressWarnings("PMD.UnusedFormalParameter")
221    private void purgeAction(Timer timer) {
222        nextPurge = startDiscarding(absoluteTimeout, idleTimeout)
223            .map(nextAt -> Components.schedule(this::purgeAction, nextAt))
224            .orElse(null);
225    }
226
227    /**
228     * The name used for the session id cookie. Defaults to "`id`".
229     * 
230     * @return the id name
231     */
232    public String idName() {
233        return idName;
234    }
235
236    /**
237     * @param idName the id name to set
238     * 
239     * @return the session manager for easy chaining
240     */
241    public SessionManager setIdName(String idName) {
242        this.idName = idName;
243        return this;
244    }
245
246    /**
247     * Set the maximum number of sessions. If the value is zero or less,
248     * an unlimited number of sessions is supported. The default value
249     * is 1000.
250     * 
251     * If adding a new session would exceed the limit, first all
252     * sessions older than {@link #absoluteTimeout()} are removed.
253     * If this doesn't free a slot, the least recently used session
254     * is removed.
255     * 
256     * @param maxSessions the maxSessions to set
257     * @return the session manager for easy chaining
258     */
259    public SessionManager setMaxSessions(int maxSessions) {
260        this.maxSessions = maxSessions;
261        return this;
262    }
263
264    /**
265     * @return the maxSessions
266     */
267    public int maxSessions() {
268        return maxSessions;
269    }
270
271    /**
272     * Sets the absolute timeout for a session. The absolute
273     * timeout is the time after which a session is invalidated (relative
274     * to its creation time). Defaults to 9 hours. Zero or less disables
275     * the timeout.
276     * 
277     * @param timeout the absolute timeout
278     * @return the session manager for easy chaining
279     */
280    public SessionManager setAbsoluteTimeout(Duration timeout) {
281        this.absoluteTimeout = timeout.toMillis();
282        return this;
283    }
284
285    /**
286     * @return the absolute session timeout (in seconds)
287     */
288    public Duration absoluteTimeout() {
289        return Duration.ofMillis(absoluteTimeout);
290    }
291
292    /**
293     * Sets the idle timeout for a session. Defaults to 30 minutes.
294     * Zero or less disables the timeout. 
295     * 
296     * @param timeout the absolute timeout
297     * @return the session manager for easy chaining
298     */
299    public SessionManager setIdleTimeout(Duration timeout) {
300        this.idleTimeout = timeout.toMillis();
301        return this;
302    }
303
304    /**
305     * @return the idle timeout
306     */
307    public Duration idleTimeout() {
308        return Duration.ofMillis(idleTimeout);
309    }
310
311    /**
312     * Associates the event with a {@link Session} object
313     * using `Session.class` as association identifier.
314     * 
315     * @param event the event
316     */
317    @RequestHandler(dynamic = true)
318    public void onRequest(Request.In event) {
319        if (event.associated(Session.class).isPresent()) {
320            return;
321        }
322        final HttpRequest request = event.httpRequest();
323        Optional<String> requestedSessionId = request.findValue(
324            HttpField.COOKIE, Converters.COOKIE_LIST)
325            .flatMap(cookies -> cookies.stream().filter(
326                cookie -> cookie.getName().equals(idName()))
327                .findFirst().map(HttpCookie::getValue));
328        if (requestedSessionId.isPresent()) {
329            String sessionId = requestedSessionId.get();
330            synchronized (this) {
331                Optional<Session> session = lookupSession(sessionId);
332                if (session.isPresent()) {
333                    setSessionSupplier(event, sessionId);
334                    session.get().updateLastUsedAt();
335                    return;
336                }
337            }
338        }
339        Session session = createSession(
340            addSessionCookie(request.response().get(), createSessionId()));
341        setSessionSupplier(event, session.id());
342        startPurger();
343    }
344
345    /**
346     * Associated the associator with a session supplier for the 
347     * given session id and note `this` as session manager.
348     *
349     * @param holder the channel
350     * @param sessionId the session id
351     */
352    protected void setSessionSupplier(Associator holder, String sessionId) {
353        holder.setAssociated(SessionManager.class, this);
354        holder.setAssociated(Session.class,
355            new SessionSupplier(holder, sessionId));
356    }
357
358    /**
359     * Supports obtaining a {@link Session} from an {@link IOSubchannel}. 
360     */
361    private class SessionSupplier implements Supplier<Optional<Session>> {
362
363        private final Associator holder;
364        private final String sessionId;
365
366        /**
367         * Instantiates a new session supplier.
368         *
369         * @param holder the channel
370         * @param sessionId the session id
371         */
372        public SessionSupplier(Associator holder, String sessionId) {
373            this.holder = holder;
374            this.sessionId = sessionId;
375        }
376
377        @Override
378        public Optional<Session> get() {
379            Optional<Session> session = lookupSession(sessionId);
380            if (session.isPresent()) {
381                session.get().updateLastUsedAt();
382                return session;
383            }
384            Session newSession = createSession(createSessionId());
385            setSessionSupplier(holder, newSession.id());
386            return Optional.of(newSession);
387        }
388
389    }
390
391    /**
392     * Creates a session id and adds the corresponding cookie to the
393     * response.
394     * 
395     * @param response the response
396     * @return the session id
397     */
398    protected String addSessionCookie(HttpResponse response, String sessionId) {
399        HttpCookie sessionCookie = new HttpCookie(idName(), sessionId);
400        sessionCookie.setPath(path);
401        sessionCookie.setHttpOnly(true);
402        response.computeIfAbsent(HttpField.SET_COOKIE,
403            () -> new CookieList(SameSiteAttribute.STRICT))
404            .value().add(sessionCookie);
405        response.computeIfAbsent(
406            HttpField.CACHE_CONTROL, CacheControlDirectives::new).value()
407            .add(new Directive("no-cache", "SetCookie, Set-Cookie2"));
408        return sessionId;
409    }
410
411    private String createSessionId() {
412        StringBuilder sessionIdBuilder = new StringBuilder();
413        byte[] bytes = new byte[16];
414        secureRandom.nextBytes(bytes);
415        for (byte b : bytes) {
416            sessionIdBuilder.append(Integer.toHexString(b & 0xff));
417        }
418        return sessionIdBuilder.toString();
419    }
420
421    /**
422     * Checks if the absolute or idle timeout has been reached.
423     *
424     * @param session the session
425     * @return true, if successful
426     */
427    protected boolean hasTimedOut(Session session) {
428        Instant now = Instant.now();
429        return absoluteTimeout > 0 && Duration
430            .between(session.createdAt(), now).toMillis() > absoluteTimeout
431            || idleTimeout > 0 && Duration.between(session.lastUsedAt(),
432                now).toMillis() > idleTimeout;
433    }
434
435    /**
436     * Start discarding all sessions (generate {@link DiscardSession} events)
437     * that have reached their absolute or idle timeout. Do not
438     * make the sessions unavailable yet. 
439     * 
440     * Returns the time when the next timeout occurs. This method is 
441     * called only if at least one of the timeouts has been specified.
442     * 
443     * Implementations have to take care that sessions are only discarded
444     * once. As they must remain available while the {@link DiscardSession}
445     * event is handled this may require marking them as being discarded. 
446     *
447     * @param absoluteTimeout the absolute timeout
448     * @param idleTimeout the idle timeout
449     * @return the next timeout (empty if no sessions left)
450     */
451    protected abstract Optional<Instant> startDiscarding(long absoluteTimeout,
452            long idleTimeout);
453
454    /**
455     * Creates a new session with the given id.
456     * 
457     * @param sessionId
458     * @return the session
459     */
460    protected abstract Session createSession(String sessionId);
461
462    /**
463     * Lookup the session with the given id. Lookup will fail if
464     * the session has timed out.
465     * 
466     * @param sessionId
467     * @return the session
468     */
469    protected abstract Optional<Session> lookupSession(String sessionId);
470
471    /**
472     * Removes the given session from the cache.
473     * 
474     * @param sessionId the session id
475     */
476    protected abstract void removeSession(String sessionId);
477
478    /**
479     * Return the number of established sessions.
480     * 
481     * @return the result
482     */
483    protected abstract int sessionCount();
484
485    /**
486     * Discards the given session. The handler has a priority of -1000,
487     * thus allowing other handler to make use of the session (for a
488     * time) before it becomes unavailable.
489     * 
490     * @param event the event
491     */
492    @Handler(channels = Channel.class, priority = -1000)
493    public void onDiscard(DiscardSession event) {
494        removeSession(event.session().id());
495        event.session().close();
496    }
497
498    /**
499     * Associates the channel with a 
500     * {@link Supplier {@code Supplier<Optional<Session>>}} 
501     * for the session. Initially, the associated session is the session
502     * associated with the protocol switch event. If this session times out,
503     * a new session is returned as a fallback, thus making sure that
504     * the `Optional` is never empty. The new session is, however, created 
505     * independently of any new session created by {@link #onRequest}.
506     * 
507     * Applications should avoid any ambiguity by executing a proper 
508     * cleanup of the web application in response to a 
509     * {@link DiscardSession} event (including reestablishing the web
510     * socket connections from new requests).
511     * 
512     * @param event the event
513     * @param channel the channel
514     */
515    @Handler(priority = 1000)
516    public void onProtocolSwitchAccepted(
517            ProtocolSwitchAccepted event, IOSubchannel channel) {
518        Request.In request = event.requestEvent();
519        request.associated(SessionManager.class).filter(sm -> sm == this)
520            .ifPresent(
521                sm -> setSessionSupplier(channel, Session.from(request).id()));
522    }
523
524    /**
525     * An MBean interface for getting information about the 
526     * established sessions.
527     */
528    @SuppressWarnings("PMD.CommentRequired")
529    public interface SessionManagerMXBean {
530
531        String getComponentPath();
532
533        String getPath();
534
535        int getMaxSessions();
536
537        long getAbsoluteTimeout();
538
539        long getIdleTimeout();
540
541        int getSessionCount();
542    }
543
544    /**
545     * The session manager information.
546     */
547    public static class SessionManagerInfo implements SessionManagerMXBean {
548
549        private static MBeanServer mbs
550            = ManagementFactory.getPlatformMBeanServer();
551
552        private ObjectName mbeanName;
553        private final WeakReference<SessionManager> sessionManagerRef;
554
555        /**
556         * Instantiates a new session manager info.
557         *
558         * @param sessionManager the session manager
559         */
560        @SuppressWarnings({ "PMD.AvoidCatchingGenericException",
561            "PMD.EmptyCatchBlock" })
562        public SessionManagerInfo(SessionManager sessionManager) {
563            try {
564                mbeanName = new ObjectName("org.jgrapes.http:type="
565                    + SessionManager.class.getSimpleName() + ",name="
566                    + ObjectName.quote(Components.simpleObjectName(
567                        sessionManager)));
568            } catch (MalformedObjectNameException e) {
569                // Won't happen
570            }
571            sessionManagerRef = new WeakReference<>(sessionManager);
572            try {
573                mbs.unregisterMBean(mbeanName);
574            } catch (Exception e) {
575                // Just in case, should not work
576            }
577            try {
578                mbs.registerMBean(this, mbeanName);
579            } catch (InstanceAlreadyExistsException | MBeanRegistrationException
580                    | NotCompliantMBeanException e) {
581                // Have to live with that
582            }
583        }
584
585        /**
586         * Returns the session manager.
587         *
588         * @return the optional session manager
589         */
590        @SuppressWarnings({ "PMD.AvoidCatchingGenericException",
591            "PMD.EmptyCatchBlock" })
592        public Optional<SessionManager> manager() {
593            SessionManager manager = sessionManagerRef.get();
594            if (manager == null) {
595                try {
596                    mbs.unregisterMBean(mbeanName);
597                } catch (MBeanRegistrationException
598                        | InstanceNotFoundException e) {
599                    // Should work.
600                }
601            }
602            return Optional.ofNullable(manager);
603        }
604
605        @Override
606        public String getComponentPath() {
607            return manager().map(mgr -> mgr.componentPath())
608                .orElse("<removed>");
609        }
610
611        @Override
612        public String getPath() {
613            return manager().map(mgr -> mgr.path).orElse("<unknown>");
614        }
615
616        @Override
617        public int getMaxSessions() {
618            return manager().map(mgr -> mgr.maxSessions()).orElse(0);
619        }
620
621        @Override
622        public long getAbsoluteTimeout() {
623            return manager().map(mgr -> mgr.absoluteTimeout().toMillis())
624                .orElse(0L);
625        }
626
627        @Override
628        public long getIdleTimeout() {
629            return manager().map(mgr -> mgr.idleTimeout().toMillis())
630                .orElse(0L);
631        }
632
633        @Override
634        public int getSessionCount() {
635            return manager().map(mgr -> mgr.sessionCount()).orElse(0);
636        }
637    }
638
639    /**
640     * An MBean interface for getting information about all session
641     * managers.
642     * 
643     * There is currently no summary information. However, the (periodic)
644     * invocation of {@link SessionManagerSummaryMXBean#getManagers()} ensures
645     * that entries for removed {@link SessionManager}s are unregistered.
646     */
647    public interface SessionManagerSummaryMXBean {
648
649        /**
650         * Gets the managers.
651         *
652         * @return the managers
653         */
654        Set<SessionManagerMXBean> getManagers();
655    }
656
657    /**
658     * The MBean view.
659     */
660    private static class MBeanView implements SessionManagerSummaryMXBean {
661        private static Set<SessionManagerInfo> managerInfos = new HashSet<>();
662
663        /**
664         * Adds a manager.
665         *
666         * @param manager the manager
667         */
668        public static void addManager(SessionManager manager) {
669            synchronized (managerInfos) {
670                managerInfos.add(new SessionManagerInfo(manager));
671            }
672        }
673
674        @Override
675        public Set<SessionManagerMXBean> getManagers() {
676            Set<SessionManagerInfo> expired = new HashSet<>();
677            synchronized (managerInfos) {
678                for (SessionManagerInfo managerInfo : managerInfos) {
679                    if (!managerInfo.manager().isPresent()) {
680                        expired.add(managerInfo);
681                    }
682                }
683                managerInfos.removeAll(expired);
684            }
685            @SuppressWarnings("unchecked")
686            Set<SessionManagerMXBean> result
687                = (Set<SessionManagerMXBean>) (Object) managerInfos;
688            return result;
689        }
690    }
691
692    static {
693        try {
694            MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();
695            ObjectName mxbeanName = new ObjectName("org.jgrapes.http:type="
696                + SessionManager.class.getSimpleName() + "s");
697            mbs.registerMBean(new MBeanView(), mxbeanName);
698        } catch (MalformedObjectNameException | InstanceAlreadyExistsException
699                | MBeanRegistrationException | NotCompliantMBeanException e) {
700            // Does not happen
701            e.printStackTrace();
702        }
703    }
704}