001/*
002 * JGrapes Event Driven Framework
003 * Copyright (C) 2017-2018 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;
030
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;
038
039import org.jdrupes.httpcodec.protocols.http.HttpField;
040import org.jdrupes.httpcodec.protocols.http.HttpRequest;
041import org.jdrupes.httpcodec.protocols.http.HttpResponse;
042import org.jdrupes.httpcodec.types.CacheControlDirectives;
043import org.jdrupes.httpcodec.types.Converters;
044import org.jdrupes.httpcodec.types.CookieList;
045import org.jdrupes.httpcodec.types.Directive;
046import org.jgrapes.core.Channel;
047import org.jgrapes.core.Component;
048import org.jgrapes.core.Components;
049import org.jgrapes.core.annotation.Handler;
050import org.jgrapes.core.internal.EventBase;
051import org.jgrapes.http.annotation.RequestHandler;
052import org.jgrapes.http.events.DiscardSession;
053import org.jgrapes.http.events.ProtocolSwitchAccepted;
054import org.jgrapes.http.events.Request;
055import org.jgrapes.io.IOSubchannel;
056
057/**
058 * A base class for session managers. A session manager associates 
059 * {@link Request} events with a {@link Session} object using 
060 * `Session.class` as association identifier. The {@link Request}
061 * handler has a default priority of 1000.
062 * 
063 * Managers track requests using a cookie with a given name and path. The 
064 * path is a prefix that has to be matched by the request, often "/".
065 * If no cookie with the given name (see {@link #idName()}) is found,
066 * a new cookie with that name and the specified path is created.
067 * The cookie's value is the unique session id that is used to lookup
068 * the session object.
069 * 
070 * Session managers provide additional support for web sockets. If a
071 * web socket is accepted, the session associated with the request
072 * is automatically associated with the {@link IOSubchannel} that
073 * is subsequently used for the web socket events. This allows
074 * handlers for web socket messages to access the session like
075 * {@link Request} handlers.
076 * 
077 * @see EventBase#setAssociated(Object, Object)
078 * @see "[OWASP Session Management Cheat Sheet](https://www.owasp.org/index.php/Session_Management_Cheat_Sheet)"
079 */
080@SuppressWarnings({ "PMD.DataClass", "PMD.AvoidPrintStackTrace" })
081public abstract class SessionManager extends Component {
082
083    private static SecureRandom secureRandom = new SecureRandom();
084
085    private String idName = "id";
086    private String path = "/";
087    private long absoluteTimeout = 9 * 60 * 60 * 1000;
088    private long idleTimeout = 30 * 60 * 1000;
089    private int maxSessions = 1000;
090
091    /**
092     * Creates a new session manager with its channel set to
093     * itself and the path set to "/". The manager handles
094     * all {@link Request} events.
095     */
096    public SessionManager() {
097        this("/");
098    }
099
100    /**
101     * Creates a new session manager with its channel set to
102     * itself and the path set to the given path. The manager
103     * handles all requests that match the given path, using the
104     * same rules as browsers do for selecting the cookies that
105     * are to be sent.
106     * 
107     * @param path the path
108     */
109    public SessionManager(String path) {
110        this(Channel.SELF, path);
111    }
112
113    /**
114     * Creates a new session manager with its channel set to
115     * the given channel and the path to "/". The manager handles
116     * all {@link Request} events.
117     * 
118     * @param componentChannel the component channel
119     */
120    public SessionManager(Channel componentChannel) {
121        this(componentChannel, "/");
122    }
123
124    /**
125     * Creates a new session manager with the given channel and path.
126     * The manager handles all requests that match the given path, using
127     * the same rules as browsers do for selecting the cookies that
128     * are to be sent.
129     *  
130     * @param componentChannel the component channel
131     * @param path the path
132     */
133    public SessionManager(Channel componentChannel, String path) {
134        this(componentChannel, derivePattern(path), 1000, path);
135    }
136
137    /**
138     * Derives the resource pattern from the path.
139     *
140     * @param path the path
141     * @return the pattern
142     */
143    @SuppressWarnings("PMD.DataflowAnomalyAnalysis")
144    protected static String derivePattern(String path) {
145        String pattern;
146        if ("/".equals(path)) {
147            pattern = "/**";
148        } else {
149            String patternBase = path;
150            if (patternBase.endsWith("/")) {
151                patternBase = path.substring(0, path.length() - 1);
152            }
153            pattern = path + "," + path + "/**";
154        }
155        return pattern;
156    }
157
158    /**
159     * Creates a new session manager using the given channel and path.
160     * The manager handles only requests that match the given pattern.
161     * The handler is registered with the given priority.
162     * 
163     * This constructor can be used if special handling of top level
164     * requests is needed.
165     *
166     * @param componentChannel the component channel
167     * @param pattern the path part of a {@link ResourcePattern}
168     * @param priority the priority
169     * @param path the path
170     */
171    public SessionManager(Channel componentChannel, String pattern,
172            int priority, String path) {
173        super(componentChannel);
174        this.path = path;
175        RequestHandler.Evaluator.add(this, "onRequest", pattern, priority);
176        MBeanView.addManager(this);
177    }
178
179    /**
180     * The name used for the session id cookie. Defaults to "`id`".
181     * 
182     * @return the id name
183     */
184    public String idName() {
185        return idName;
186    }
187
188    /**
189     * @param idName the id name to set
190     * 
191     * @return the session manager for easy chaining
192     */
193    public SessionManager setIdName(String idName) {
194        this.idName = idName;
195        return this;
196    }
197
198    /**
199     * Set the maximum number of sessions. If the value is zero or less,
200     * an unlimited number of sessions is supported. The default value
201     * is 1000.
202     * 
203     * If adding a new session would exceed the limit, first all
204     * sessions older than {@link #absoluteTimeout()} are removed.
205     * If this doesn't free a slot, the least recently used session
206     * is removed.
207     * 
208     * @param maxSessions the maxSessions to set
209     * @return the session manager for easy chaining
210     */
211    public SessionManager setMaxSessions(int maxSessions) {
212        this.maxSessions = maxSessions;
213        return this;
214    }
215
216    /**
217     * @return the maxSessions
218     */
219    public int maxSessions() {
220        return maxSessions;
221    }
222
223    /**
224     * Sets the absolute timeout for a session in seconds. The absolute
225     * timeout is the time after which a session is invalidated (relative
226     * to its creation time). Defaults to 9 hours. Zero or less disables
227     * the timeout.
228     * 
229     * @param absoluteTimeout the absolute timeout
230     * @return the session manager for easy chaining
231     */
232    public SessionManager setAbsoluteTimeout(int absoluteTimeout) {
233        this.absoluteTimeout = absoluteTimeout * 1000;
234        return this;
235    }
236
237    /**
238     * @return the absolute session timeout (in seconds)
239     */
240    public int absoluteTimeout() {
241        return (int) (absoluteTimeout / 1000);
242    }
243
244    /**
245     * Sets the idle timeout for a session in seconds. Defaults to 30 minutes.
246     * Zero or less disables the timeout. 
247     * 
248     * @param idleTimeout the absolute timeout
249     * @return the session manager for easy chaining
250     */
251    public SessionManager setIdleTimeout(int idleTimeout) {
252        this.idleTimeout = idleTimeout * 1000;
253        return this;
254    }
255
256    /**
257     * @return the idle timeout (in seconds)
258     */
259    public int idleTimeout() {
260        return (int) (idleTimeout / 1000);
261    }
262
263    /**
264     * Associates the event with a {@link Session} object
265     * using `Session.class` as association identifier.
266     * 
267     * @param event the event
268     */
269    @RequestHandler(dynamic = true)
270    public void onRequest(Request.In event) {
271        if (event.associated(Session.class).isPresent()) {
272            return;
273        }
274        final HttpRequest request = event.httpRequest();
275        Optional<String> requestedSessionId = request.findValue(
276            HttpField.COOKIE, Converters.COOKIE_LIST)
277            .flatMap(cookies -> cookies.stream().filter(
278                cookie -> cookie.getName().equals(idName()))
279                .findFirst().map(HttpCookie::getValue));
280        if (requestedSessionId.isPresent()) {
281            String sessionId = requestedSessionId.get();
282            synchronized (this) {
283                Optional<Session> session = lookupSession(sessionId);
284                if (session.isPresent()) {
285                    Instant now = Instant.now();
286                    if ((absoluteTimeout <= 0
287                        || Duration.between(session.get().createdAt(),
288                            now).toMillis() < absoluteTimeout)
289                        && (idleTimeout <= 0
290                            || Duration.between(session.get().lastUsedAt(),
291                                now).toMillis() < idleTimeout)) {
292                        event.setAssociated(Session.class, session.get());
293                        session.get().updateLastUsedAt();
294                        return;
295                    }
296                    // Invalidate, too old
297                    removeSession(sessionId);
298                }
299            }
300        }
301        String sessionId = createSessionId(request.response().get());
302        Session session = createSession(sessionId);
303        event.setAssociated(Session.class, session);
304    }
305
306    /**
307     * Creates a new session with the given id.
308     * 
309     * @param sessionId
310     * @return the session
311     */
312    protected abstract Session createSession(String sessionId);
313
314    /**
315     * Lookup the session with the given id.
316     * 
317     * @param sessionId
318     * @return the session
319     */
320    protected abstract Optional<Session> lookupSession(String sessionId);
321
322    /**
323     * Removed the given session.
324     * 
325     * @param sessionId the session id
326     */
327    protected abstract void removeSession(String sessionId);
328
329    /**
330     * Return the number of established sessions.
331     * 
332     * @return the result
333     */
334    protected abstract int sessionCount();
335
336    /**
337     * Creates a session id and adds the corresponding cookie to the
338     * response.
339     * 
340     * @param response the response
341     * @return the session id
342     */
343    protected String createSessionId(HttpResponse response) {
344        StringBuilder sessionIdBuilder = new StringBuilder();
345        byte[] bytes = new byte[16];
346        secureRandom.nextBytes(bytes);
347        for (byte b : bytes) {
348            sessionIdBuilder.append(Integer.toHexString(b & 0xff));
349        }
350        String sessionId = sessionIdBuilder.toString();
351        HttpCookie sessionCookie = new HttpCookie(idName(), sessionId);
352        sessionCookie.setPath(path);
353        sessionCookie.setHttpOnly(true);
354        response.computeIfAbsent(HttpField.SET_COOKIE, CookieList::new)
355            .value().add(sessionCookie);
356        response.computeIfAbsent(
357            HttpField.CACHE_CONTROL, CacheControlDirectives::new)
358            .value().add(new Directive("no-cache", "SetCookie, Set-Cookie2"));
359        return sessionId;
360    }
361
362    /**
363     * Discards the given session.
364     * 
365     * @param event the event
366     */
367    @Handler(channels = Channel.class)
368    public void discard(DiscardSession event) {
369        removeSession(event.session().id());
370    }
371
372    /**
373     * Associates the channel with the session from the upgrade request.
374     * 
375     * @param event the event
376     * @param channel the channel
377     */
378    @Handler(priority = 1000)
379    public void onProtocolSwitchAccepted(
380            ProtocolSwitchAccepted event, IOSubchannel channel) {
381        event.requestEvent().associated(Session.class)
382            .ifPresent(session -> {
383                channel.setAssociated(Session.class, session);
384            });
385    }
386
387    /**
388     * An MBean interface for getting information about the 
389     * established sessions.
390     */
391    @SuppressWarnings("PMD.CommentRequired")
392    public interface SessionManagerMXBean {
393
394        String getComponentPath();
395
396        String getPath();
397
398        int getMaxSessions();
399
400        int getAbsoluteTimeout();
401
402        int getIdleTimeout();
403
404        int getSessionCount();
405    }
406
407    /**
408     * The session manager information.
409     */
410    public static class SessionManagerInfo implements SessionManagerMXBean {
411
412        private static MBeanServer mbs
413            = ManagementFactory.getPlatformMBeanServer();
414
415        private ObjectName mbeanName;
416        private final WeakReference<SessionManager> sessionManagerRef;
417
418        /**
419         * Instantiates a new session manager info.
420         *
421         * @param sessionManager the session manager
422         */
423        @SuppressWarnings({ "PMD.AvoidCatchingGenericException",
424            "PMD.EmptyCatchBlock" })
425        public SessionManagerInfo(SessionManager sessionManager) {
426            try {
427                mbeanName = new ObjectName("org.jgrapes.http:type="
428                    + SessionManager.class.getSimpleName() + ",name="
429                    + ObjectName.quote(Components.simpleObjectName(
430                        sessionManager)));
431            } catch (MalformedObjectNameException e) {
432                // Won't happen
433            }
434            sessionManagerRef = new WeakReference<>(sessionManager);
435            try {
436                mbs.unregisterMBean(mbeanName);
437            } catch (Exception e) {
438                // Just in case, should not work
439            }
440            try {
441                mbs.registerMBean(this, mbeanName);
442            } catch (InstanceAlreadyExistsException | MBeanRegistrationException
443                    | NotCompliantMBeanException e) {
444                // Have to live with that
445            }
446        }
447
448        /**
449         * Returns the session manager.
450         *
451         * @return the optional session manager
452         */
453        @SuppressWarnings({ "PMD.AvoidCatchingGenericException",
454            "PMD.EmptyCatchBlock" })
455        public Optional<SessionManager> manager() {
456            SessionManager manager = sessionManagerRef.get();
457            if (manager == null) {
458                try {
459                    mbs.unregisterMBean(mbeanName);
460                } catch (MBeanRegistrationException
461                        | InstanceNotFoundException e) {
462                    // Should work.
463                }
464            }
465            return Optional.ofNullable(manager);
466        }
467
468        @Override
469        public String getComponentPath() {
470            return manager().map(mgr -> mgr.componentPath())
471                .orElse("<removed>");
472        }
473
474        @Override
475        public String getPath() {
476            return manager().map(mgr -> mgr.path).orElse("<unknown>");
477        }
478
479        @Override
480        public int getMaxSessions() {
481            return manager().map(mgr -> mgr.maxSessions()).orElse(0);
482        }
483
484        @Override
485        public int getAbsoluteTimeout() {
486            return manager().map(mgr -> mgr.absoluteTimeout()).orElse(0);
487        }
488
489        @Override
490        public int getIdleTimeout() {
491            return manager().map(mgr -> mgr.idleTimeout()).orElse(0);
492        }
493
494        @Override
495        public int getSessionCount() {
496            return manager().map(mgr -> mgr.sessionCount()).orElse(0);
497        }
498    }
499
500    /**
501     * An MBean interface for getting information about all session
502     * managers.
503     * 
504     * There is currently no summary information. However, the (periodic)
505     * invocation of {@link SessionManagerSummaryMXBean#getManagers()} ensures
506     * that entries for removed {@link SessionManager}s are unregistered.
507     */
508    public interface SessionManagerSummaryMXBean {
509
510        /**
511         * Gets the managers.
512         *
513         * @return the managers
514         */
515        Set<SessionManagerMXBean> getManagers();
516    }
517
518    /**
519     * The MBean view.
520     */
521    private static class MBeanView implements SessionManagerSummaryMXBean {
522        private static Set<SessionManagerInfo> managerInfos = new HashSet<>();
523
524        /**
525         * Adds a manager.
526         *
527         * @param manager the manager
528         */
529        public static void addManager(SessionManager manager) {
530            synchronized (managerInfos) {
531                managerInfos.add(new SessionManagerInfo(manager));
532            }
533        }
534
535        @Override
536        public Set<SessionManagerMXBean> getManagers() {
537            Set<SessionManagerInfo> expired = new HashSet<>();
538            synchronized (managerInfos) {
539                for (SessionManagerInfo managerInfo : managerInfos) {
540                    if (!managerInfo.manager().isPresent()) {
541                        expired.add(managerInfo);
542                    }
543                }
544                managerInfos.removeAll(expired);
545            }
546            @SuppressWarnings("unchecked")
547            Set<SessionManagerMXBean> result
548                = (Set<SessionManagerMXBean>) (Object) managerInfos;
549            return result;
550        }
551    }
552
553    static {
554        try {
555            MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();
556            ObjectName mxbeanName = new ObjectName("org.jgrapes.http:type="
557                + SessionManager.class.getSimpleName() + "s");
558            mbs.registerMBean(new MBeanView(), mxbeanName);
559        } catch (MalformedObjectNameException | InstanceAlreadyExistsException
560                | MBeanRegistrationException | NotCompliantMBeanException e) {
561            // Does not happen
562            e.printStackTrace();
563        }
564    }
565}