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