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     * 
318     * @param event the event
319     */
320    @RequestHandler(dynamic = true)
321    public void onRequest(Request.In event) {
322        if (event.associated(Session.class).isPresent()) {
323            return;
324        }
325        final HttpRequest request = event.httpRequest();
326        Optional<String> requestedSessionId = request.findValue(
327            HttpField.COOKIE, Converters.COOKIE_LIST)
328            .flatMap(cookies -> cookies.stream().filter(
329                cookie -> cookie.getName().equals(idName()))
330                .findFirst().map(HttpCookie::getValue));
331        if (requestedSessionId.isPresent()) {
332            String sessionId = requestedSessionId.get();
333            synchronized (this) {
334                Optional<Session> session = lookupSession(sessionId);
335                if (session.isPresent()) {
336                    setSessionSupplier(event, sessionId);
337                    session.get().updateLastUsedAt();
338                    return;
339                }
340            }
341        }
342        Session session = createSession(
343            addSessionCookie(request.response().get(), createSessionId()));
344        setSessionSupplier(event, session.id());
345        startPurger();
346    }
347
348    /**
349     * Associated the associator with a session supplier for the 
350     * given session id and note `this` as session manager.
351     *
352     * @param holder the channel
353     * @param sessionId the session id
354     */
355    protected void setSessionSupplier(Associator holder, String sessionId) {
356        holder.setAssociated(SessionManager.class, this);
357        holder.setAssociated(Session.class,
358            new SessionSupplier(holder, sessionId));
359    }
360
361    /**
362     * Supports obtaining a {@link Session} from an {@link IOSubchannel}. 
363     */
364    private class SessionSupplier implements Supplier<Optional<Session>> {
365
366        private final Associator holder;
367        private final String sessionId;
368
369        /**
370         * Instantiates a new session supplier.
371         *
372         * @param holder the channel
373         * @param sessionId the session id
374         */
375        public SessionSupplier(Associator holder, String sessionId) {
376            this.holder = holder;
377            this.sessionId = sessionId;
378        }
379
380        @Override
381        public Optional<Session> get() {
382            Optional<Session> session = lookupSession(sessionId);
383            if (session.isPresent()) {
384                session.get().updateLastUsedAt();
385                return session;
386            }
387            Session newSession = createSession(createSessionId());
388            setSessionSupplier(holder, newSession.id());
389            return Optional.of(newSession);
390        }
391
392    }
393
394    /**
395     * Creates a session id and adds the corresponding cookie to the
396     * response.
397     * 
398     * @param response the response
399     * @return the session id
400     */
401    protected String addSessionCookie(HttpResponse response, String sessionId) {
402        HttpCookie sessionCookie = new HttpCookie(idName(), sessionId);
403        sessionCookie.setPath(path);
404        sessionCookie.setHttpOnly(true);
405        response.computeIfAbsent(HttpField.SET_COOKIE,
406            () -> new CookieList(SameSiteAttribute.STRICT))
407            .value().add(sessionCookie);
408        response.computeIfAbsent(
409            HttpField.CACHE_CONTROL, CacheControlDirectives::new).value()
410            .add(new Directive("no-cache", "SetCookie, Set-Cookie2"));
411        return sessionId;
412    }
413
414    private String createSessionId() {
415        StringBuilder sessionIdBuilder = new StringBuilder();
416        byte[] bytes = new byte[16];
417        secureRandom.nextBytes(bytes);
418        for (byte b : bytes) {
419            sessionIdBuilder.append(Integer.toHexString(b & 0xff));
420        }
421        return sessionIdBuilder.toString();
422    }
423
424    /**
425     * Checks if the absolute or idle timeout has been reached.
426     *
427     * @param session the session
428     * @return true, if successful
429     */
430    protected boolean hasTimedOut(Session session) {
431        Instant now = Instant.now();
432        return absoluteTimeout > 0 && Duration
433            .between(session.createdAt(), now).toMillis() > absoluteTimeout
434            || idleTimeout > 0 && Duration.between(session.lastUsedAt(),
435                now).toMillis() > idleTimeout;
436    }
437
438    /**
439     * Start discarding all sessions (generate {@link DiscardSession} events)
440     * that have reached their absolute or idle timeout. Do not
441     * make the sessions unavailable yet. 
442     * 
443     * Returns the time when the next timeout occurs. This method is 
444     * called only if at least one of the timeouts has been specified.
445     * 
446     * Implementations have to take care that sessions are only discarded
447     * once. As they must remain available while the {@link DiscardSession}
448     * event is handled this may require marking them as being discarded. 
449     *
450     * @param absoluteTimeout the absolute timeout
451     * @param idleTimeout the idle timeout
452     * @return the next timeout (empty if no sessions left)
453     */
454    protected abstract Optional<Instant> startDiscarding(long absoluteTimeout,
455            long idleTimeout);
456
457    /**
458     * Creates a new session with the given id.
459     * 
460     * @param sessionId
461     * @return the session
462     */
463    protected abstract Session createSession(String sessionId);
464
465    /**
466     * Lookup the session with the given id. Lookup will fail if
467     * the session has timed out.
468     * 
469     * @param sessionId
470     * @return the session
471     */
472    protected abstract Optional<Session> lookupSession(String sessionId);
473
474    /**
475     * Removes the given session from the cache.
476     * 
477     * @param sessionId the session id
478     */
479    protected abstract void removeSession(String sessionId);
480
481    /**
482     * Return the number of established sessions.
483     * 
484     * @return the result
485     */
486    protected abstract int sessionCount();
487
488    /**
489     * Discards the given session. The handler has a priority of -1000,
490     * thus allowing other handler to make use of the session (for a
491     * time) before it becomes unavailable.
492     * 
493     * @param event the event
494     */
495    @Handler(channels = Channel.class, priority = -1000)
496    public void onDiscard(DiscardSession event) {
497        removeSession(event.session().id());
498        event.session().close();
499    }
500
501    /**
502     * Associates the channel with a 
503     * {@link Supplier {@code Supplier<Optional<Session>>}} 
504     * for the session. Initially, the associated session is the session
505     * associated with the protocol switch event. If this session times out,
506     * a new session is returned as a fallback, thus making sure that
507     * the `Optional` is never empty. The new session is, however, created 
508     * independently of any new session created by {@link #onRequest}.
509     * 
510     * Applications should avoid any ambiguity by executing a proper 
511     * cleanup of the web application in response to a 
512     * {@link DiscardSession} event (including reestablishing the web
513     * socket connections from new requests).
514     * 
515     * @param event the event
516     * @param channel the channel
517     */
518    @Handler(priority = 1000)
519    public void onProtocolSwitchAccepted(
520            ProtocolSwitchAccepted event, IOSubchannel channel) {
521        Request.In request = event.requestEvent();
522        request.associated(SessionManager.class).filter(sm -> sm == this)
523            .ifPresent(
524                sm -> setSessionSupplier(channel, Session.from(request).id()));
525    }
526
527    /**
528     * An MBean interface for getting information about the 
529     * established sessions.
530     */
531    @SuppressWarnings("PMD.CommentRequired")
532    public interface SessionManagerMXBean {
533
534        String getComponentPath();
535
536        String getPath();
537
538        int getMaxSessions();
539
540        long getAbsoluteTimeout();
541
542        long getIdleTimeout();
543
544        int getSessionCount();
545    }
546
547    /**
548     * The session manager information.
549     */
550    public static class SessionManagerInfo implements SessionManagerMXBean {
551
552        private static MBeanServer mbs
553            = ManagementFactory.getPlatformMBeanServer();
554
555        private ObjectName mbeanName;
556        private final WeakReference<SessionManager> sessionManagerRef;
557
558        /**
559         * Instantiates a new session manager info.
560         *
561         * @param sessionManager the session manager
562         */
563        @SuppressWarnings({ "PMD.AvoidCatchingGenericException",
564            "PMD.EmptyCatchBlock" })
565        public SessionManagerInfo(SessionManager sessionManager) {
566            try {
567                mbeanName = new ObjectName("org.jgrapes.http:type="
568                    + SessionManager.class.getSimpleName() + ",name="
569                    + ObjectName.quote(Components.simpleObjectName(
570                        sessionManager)));
571            } catch (MalformedObjectNameException e) {
572                // Won't happen
573            }
574            sessionManagerRef = new WeakReference<>(sessionManager);
575            try {
576                mbs.unregisterMBean(mbeanName);
577            } catch (Exception e) {
578                // Just in case, should not work
579            }
580            try {
581                mbs.registerMBean(this, mbeanName);
582            } catch (InstanceAlreadyExistsException | MBeanRegistrationException
583                    | NotCompliantMBeanException e) {
584                // Have to live with that
585            }
586        }
587
588        /**
589         * Returns the session manager.
590         *
591         * @return the optional session manager
592         */
593        @SuppressWarnings({ "PMD.AvoidCatchingGenericException",
594            "PMD.EmptyCatchBlock" })
595        public Optional<SessionManager> manager() {
596            SessionManager manager = sessionManagerRef.get();
597            if (manager == null) {
598                try {
599                    mbs.unregisterMBean(mbeanName);
600                } catch (MBeanRegistrationException
601                        | InstanceNotFoundException e) {
602                    // Should work.
603                }
604            }
605            return Optional.ofNullable(manager);
606        }
607
608        @Override
609        public String getComponentPath() {
610            return manager().map(mgr -> mgr.componentPath())
611                .orElse("<removed>");
612        }
613
614        @Override
615        public String getPath() {
616            return manager().map(mgr -> mgr.path).orElse("<unknown>");
617        }
618
619        @Override
620        public int getMaxSessions() {
621            return manager().map(SessionManager::maxSessions).orElse(0);
622        }
623
624        @Override
625        public long getAbsoluteTimeout() {
626            return manager().map(mgr -> mgr.absoluteTimeout().toMillis())
627                .orElse(0L);
628        }
629
630        @Override
631        public long getIdleTimeout() {
632            return manager().map(mgr -> mgr.idleTimeout().toMillis())
633                .orElse(0L);
634        }
635
636        @Override
637        public int getSessionCount() {
638            return manager().map(SessionManager::sessionCount).orElse(0);
639        }
640    }
641
642    /**
643     * An MBean interface for getting information about all session
644     * managers.
645     * 
646     * There is currently no summary information. However, the (periodic)
647     * invocation of {@link SessionManagerSummaryMXBean#getManagers()} ensures
648     * that entries for removed {@link SessionManager}s are unregistered.
649     */
650    public interface SessionManagerSummaryMXBean {
651
652        /**
653         * Gets the managers.
654         *
655         * @return the managers
656         */
657        Set<SessionManagerMXBean> getManagers();
658    }
659
660    /**
661     * The MBean view.
662     */
663    private static final class MBeanView
664            implements SessionManagerSummaryMXBean {
665        private static Set<SessionManagerInfo> managerInfos = new HashSet<>();
666
667        /**
668         * Adds a manager.
669         *
670         * @param manager the manager
671         */
672        public static void addManager(SessionManager manager) {
673            synchronized (managerInfos) {
674                managerInfos.add(new SessionManagerInfo(manager));
675            }
676        }
677
678        @Override
679        public Set<SessionManagerMXBean> getManagers() {
680            Set<SessionManagerInfo> expired = new HashSet<>();
681            synchronized (managerInfos) {
682                for (SessionManagerInfo managerInfo : managerInfos) {
683                    if (!managerInfo.manager().isPresent()) {
684                        expired.add(managerInfo);
685                    }
686                }
687                managerInfos.removeAll(expired);
688            }
689            @SuppressWarnings("unchecked")
690            Set<SessionManagerMXBean> result
691                = (Set<SessionManagerMXBean>) (Object) managerInfos;
692            return result;
693        }
694    }
695
696    static {
697        try {
698            MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();
699            ObjectName mxbeanName = new ObjectName("org.jgrapes.http:type="
700                + SessionManager.class.getSimpleName() + "s");
701            mbs.registerMBean(new MBeanView(), mxbeanName);
702        } catch (MalformedObjectNameException | InstanceAlreadyExistsException
703                | MBeanRegistrationException | NotCompliantMBeanException e) {
704            // Does not happen
705            e.printStackTrace();
706        }
707    }
708}