1 /*
2  * Copyright 2008-2019 by Emeric Vernat
3  *
4  *     This file is part of Java Melody.
5  *
6  * Licensed under the Apache License, Version 2.0 (the "License");
7  * you may not use this file except in compliance with the License.
8  * You may obtain a copy of the License at
9  *
10  *     http://www.apache.org/licenses/LICENSE-2.0
11  *
12  * Unless required by applicable law or agreed to in writing, software
13  * distributed under the License is distributed on an "AS IS" BASIS,
14  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15  * See the License for the specific language governing permissions and
16  * limitations under the License.
17  */

18 package net.bull.javamelody;
19
20 import java.io.Serializable;
21 import java.util.ArrayList;
22 import java.util.Collection;
23 import java.util.Collections;
24 import java.util.Comparator;
25 import java.util.List;
26 import java.util.Map;
27 import java.util.concurrent.ConcurrentHashMap;
28 import java.util.concurrent.ConcurrentMap;
29 import java.util.concurrent.atomic.AtomicInteger;
30
31 import javax.servlet.ServletContext;
32 import javax.servlet.ServletContextEvent;
33 import javax.servlet.ServletContextListener;
34 import javax.servlet.http.HttpSession;
35 import javax.servlet.http.HttpSessionActivationListener;
36 import javax.servlet.http.HttpSessionEvent;
37 import javax.servlet.http.HttpSessionListener;
38
39 import net.bull.javamelody.internal.common.HttpParameter;
40 import net.bull.javamelody.internal.common.LOG;
41 import net.bull.javamelody.internal.common.Parameters;
42 import net.bull.javamelody.internal.model.SessionInformations;
43
44 /**
45  * Listener de session http ({@link HttpSessionListener}) pour le monitoring.
46  * C'est la classe de ce listener qui doit être déclarée dans le fichier web.xml de la webapp.
47  * Ce listener fait également listener de contexte de servlet ({@link ServletContextListener})
48  * et listener de passivation/activation de sessions ({@link HttpSessionActivationListener}).
49  * @author Emeric Vernat
50  */

51 public class SessionListener implements HttpSessionListener, HttpSessionActivationListener,
52         ServletContextListener, Serializable {
53     public static final String CSRF_TOKEN_SESSION_NAME = "javamelody."
54             + HttpParameter.TOKEN.getName();
55     public static final String SESSION_COUNTRY_KEY = "javamelody.country";
56     public static final String SESSION_REMOTE_ADDR = "javamelody.remoteAddr";
57     public static final String SESSION_REMOTE_USER = "javamelody.remoteUser";
58     public static final String SESSION_USER_AGENT = "javamelody.userAgent";
59
60     private static final String SESSION_ACTIVATION_KEY = "javamelody.sessionActivation";
61
62     private static final long serialVersionUID = -1624944319058843901L;
63     // au lieu d'utiliser un int avec des synchronized partout, on utilise un AtomicInteger
64     private static final AtomicInteger SESSION_COUNT = new AtomicInteger();
65
66     @SuppressWarnings("all")
67     private static final List<String> CONTEXT_PATHS = new ArrayList<>();
68
69     // attention : this est mis en session, cette map doit donc restée statique
70     @SuppressWarnings("all")
71     private static final ConcurrentMap<String, HttpSession> SESSION_MAP_BY_ID = new ConcurrentHashMap<>();
72
73     private static final ThreadLocal<HttpSession> SESSION_CONTEXT = new ThreadLocal<>();
74
75     private static boolean instanceCreated;
76
77     private boolean instanceEnabled;
78
79     static final class SessionInformationsComparator
80             implements Comparator<SessionInformations>, Serializable {
81         private static final long serialVersionUID = 1L;
82
83         /** {@inheritDoc} */
84         @Override
85         public int compare(SessionInformations session1, SessionInformations session2) {
86             if (session1.getLastAccess().before(session2.getLastAccess())) {
87                 return 1;
88             } else if (session1.getLastAccess().after(session2.getLastAccess())) {
89                 return -1;
90             } else {
91                 return 0;
92             }
93         }
94     }
95
96     /**
97      * Constructeur.
98      */

99     public SessionListener() {
100         super();
101         if (instanceCreated) {
102             // ce listener a déjà été chargé précédemment et est chargé une 2ème fois donc on désactive cette 2ème instance
103             // (cela peut arriver par exemple dans glassfish v3 lorsque le listener est déclaré dans le fichier web.xml
104             // et déclaré par ailleurs dans le fichier web-fragment.xml à l'intérieur du jar)
105             // mais il peut être réactivé dans contextInitialized (issue 193)
106             instanceEnabled = false;
107         } else {
108             instanceEnabled = true;
109             setInstanceCreated(true);
110         }
111     }
112
113     /**
114      * Constructeur.
115      * @param instanceEnabled boolean
116      */

117     public SessionListener(boolean instanceEnabled) {
118         super();
119         this.instanceEnabled = instanceEnabled;
120         setInstanceCreated(true);
121     }
122
123     private static void setInstanceCreated(boolean newInstanceCreated) {
124         instanceCreated = newInstanceCreated;
125     }
126
127     public static int getSessionCount() {
128         if (!instanceCreated) {
129             return -1;
130         }
131         // nous pourrions nous contenter d'utiliser SESSION_MAP_BY_ID.size()
132         // mais on se contente de SESSION_COUNT qui est suffisant pour avoir cette valeur
133         // (SESSION_MAP_BY_ID servira pour la fonction d'invalidateAllSessions entre autres)
134         return SESSION_COUNT.get();
135     }
136
137     public static long getSessionAgeSum() {
138         if (!instanceCreated) {
139             return -1;
140         }
141         final long now = System.currentTimeMillis();
142         long result = 0;
143         for (final HttpSession session : SESSION_MAP_BY_ID.values()) {
144             try {
145                 result += now - session.getCreationTime();
146             } catch (final Exception e) {
147                 // Tomcat can throw "java.lang.IllegalStateException: getCreationTime: Session already invalidated"
148                 continue;
149             }
150         }
151         return result;
152     }
153
154     // méthode conservée pour compatibilité ascendante
155     // (notamment https://wiki.jenkins-ci.org/display/JENKINS/Invalidate+Jenkins+HTTP+sessions)
156     static void invalidateAllSessions() {
157         invalidateAllSessionsExceptCurrentSession(null);
158     }
159
160     // since 1.49
161     public static void invalidateAllSessionsExceptCurrentSession(HttpSession currentSession) {
162         for (final HttpSession session : SESSION_MAP_BY_ID.values()) {
163             try {
164                 if (currentSession != null && currentSession.getId().equals(session.getId())) {
165                     // si l'utilisateur exécutant l'action a une session http,
166                     // on ne l'invalide pas
167                     continue;
168                 }
169                 session.invalidate();
170             } catch (final Exception e) {
171                 // Tomcat can throw "java.lang.IllegalStateException: getLastAccessedTime: Session already invalidated"
172                 continue;
173             }
174         }
175     }
176
177     public static void invalidateSession(String sessionId) {
178         final HttpSession session = getSessionById(sessionId);
179         if (session != null) {
180             // dans Jenkins notamment, une session invalidée peut rester un peu dans cette map
181             try {
182                 session.invalidate();
183             } catch (final Exception e) {
184                 // Tomcat can throw "java.lang.IllegalStateException: getLastAccessedTime: Session already invalidated"
185                 return;
186             }
187         }
188     }
189
190     private static HttpSession getSessionById(String sessionId) {
191         final HttpSession session = SESSION_MAP_BY_ID.get(sessionId);
192         if (session == null) {
193             // In some cases (issue 473), Tomcat changes id in session withtout calling sessionCreated.
194             // In servlet 3.1, HttpSessionIdListener.sessionIdChanged could be used.
195             for (final HttpSession other : SESSION_MAP_BY_ID.values()) {
196                 if (other.getId().equals(sessionId)) {
197                     return other;
198                 }
199             }
200         }
201         return session;
202     }
203
204     private static void removeSessionsWithChangedId() {
205         for (final Map.Entry<String, HttpSession> entry : SESSION_MAP_BY_ID.entrySet()) {
206             final String id = entry.getKey();
207             final HttpSession other = entry.getValue();
208             if (!id.equals(other.getId())) {
209                 SESSION_MAP_BY_ID.remove(id);
210             }
211         }
212     }
213
214     private static void fixSessionsWithChangedId() {
215         for (final Map.Entry<String, HttpSession> entry : SESSION_MAP_BY_ID.entrySet()) {
216             final String id = entry.getKey();
217             final HttpSession other = entry.getValue();
218             if (!id.equals(other.getId())) {
219                 SESSION_MAP_BY_ID.remove(id);
220                 SESSION_MAP_BY_ID.put(other.getId(), other);
221             }
222         }
223     }
224
225     private static void addSession(final HttpSession session) {
226         SESSION_MAP_BY_ID.put(session.getId(), session);
227     }
228
229     private static void removeSession(final HttpSession session) {
230         final HttpSession removedSession = SESSION_MAP_BY_ID.remove(session.getId());
231         if (removedSession == null) {
232             // In some cases (issue 473), Tomcat changes id in session withtout calling sessionCreated.
233             // In servlet 3.1, HttpSessionIdListener.sessionIdChanged could be used.
234             fixSessionsWithChangedId();
235             SESSION_MAP_BY_ID.remove(session.getId());
236         }
237     }
238
239     public static List<SessionInformations> getAllSessionsInformations() {
240         final Collection<HttpSession> sessions = SESSION_MAP_BY_ID.values();
241         final List<SessionInformations> sessionsInformations = new ArrayList<>(sessions.size());
242         for (final HttpSession session : sessions) {
243             try {
244                 sessionsInformations.add(new SessionInformations(session, false));
245             } catch (final Exception e) {
246                 // Tomcat can throw "java.lang.IllegalStateException: getLastAccessedTime: Session already invalidated"
247                 continue;
248             }
249         }
250         sortSessions(sessionsInformations);
251         return Collections.unmodifiableList(sessionsInformations);
252     }
253
254     public static void sortSessions(List<SessionInformations> sessionsInformations) {
255         if (sessionsInformations.size() > 1) {
256             Collections.sort(sessionsInformations,
257                     Collections.reverseOrder(new SessionInformationsComparator()));
258         }
259     }
260
261     public static SessionInformations getSessionInformationsBySessionId(String sessionId) {
262         final HttpSession session = getSessionById(sessionId);
263         if (session == null) {
264             return null;
265         }
266         // dans Jenkins notamment, une session invalidée peut rester un peu dans cette map
267         try {
268             return new SessionInformations(session, true);
269         } catch (final Exception e) {
270             // Tomcat can throw "java.lang.IllegalStateException: getLastAccessedTime: Session already invalidated"
271             return null;
272         }
273     }
274
275     /**
276      * Définit la session http pour le thread courant.
277      * @param session HttpSession
278      */

279     public static void bindSession(HttpSession session) {
280         if (session != null) {
281             SESSION_CONTEXT.set(session);
282         }
283     }
284
285     /**
286      * Retourne la session pour le thread courant ou null.
287      * @return HttpSession
288      */

289     public static HttpSession getCurrentSession() {
290         return SESSION_CONTEXT.get();
291     }
292
293     /**
294      * Enlève le lien entre la session et le thread courant.
295      */

296     public static void unbindSession() {
297         SESSION_CONTEXT.remove();
298     }
299
300     /** {@inheritDoc} */
301     @Override
302     public void contextInitialized(ServletContextEvent event) {
303         final long start = System.currentTimeMillis(); // NOPMD
304         // lecture de la propriété système java.io.tmpdir uniquement
305         // pour lancer une java.security.AccessControlException si le SecurityManager est activé,
306         // avant d'avoir une ExceptionInInitializerError pour la classe Parameters
307         System.getProperty("java.io.tmpdir");
308
309         final String contextPath = Parameters.getContextPath(event.getServletContext());
310         if (!instanceEnabled) {
311             if (!CONTEXT_PATHS.contains(contextPath)) {
312                 // si jars dans tomcat/lib, il y a plusieurs instances mais dans des webapps différentes (issue 193)
313                 instanceEnabled = true;
314             } else {
315                 return;
316             }
317         }
318         CONTEXT_PATHS.add(contextPath);
319
320         Parameters.initialize(event.getServletContext());
321
322         LOG.debug("JavaMelody listener init started");
323
324         // on initialise le monitoring des DataSource jdbc même si cette initialisation
325         // sera refaite dans MonitoringFilter au cas où ce listener ait été oublié dans web.xml
326         final JdbcWrapper jdbcWrapper = JdbcWrapper.SINGLETON;
327         jdbcWrapper.initServletContext(event.getServletContext());
328         if (!Parameters.isNoDatabase()) {
329             jdbcWrapper.rebindDataSources();
330         }
331
332         final long duration = System.currentTimeMillis() - start;
333         LOG.debug("JavaMelody listener init done in " + duration + " ms");
334     }
335
336     /** {@inheritDoc} */
337     @Override
338     public void contextDestroyed(ServletContextEvent event) {
339         if (!instanceEnabled) {
340             return;
341         }
342         // nettoyage avant le retrait de la webapp au cas où celui-ci ne suffise pas
343         SESSION_MAP_BY_ID.clear();
344         SESSION_COUNT.set(0);
345
346         // issue 665: in WildFly 10.1.0, the MonitoringFilter may never be initialized neither destroyed.
347         // For this case, it is needed to stop here the JdbcWrapper initialized in contextInitialized
348         JdbcWrapper.SINGLETON.stop();
349
350         // issue 878: NPE at net.bull.javamelody.JspWrapper.createHttpRequestWrapper
351         if (event.getServletContext().getClass().getName().startsWith("io.undertow")) {
352             // issue 848: NPE after SpringBoot hot restart
353             Parameters.initialize((ServletContext) null);
354         }
355
356         LOG.debug("JavaMelody listener destroy done");
357     }
358
359     // Rq : avec les sessions, on pourrait faire des statistiques sur la durée moyenne des sessions
360     // (System.currentTimeMillis() - event.getSession().getCreationTime())
361     // ou le délai entre deux requêtes http par utilisateur
362     // (System.currentTimeMillis() - httpRequest.getSession().getLastAccessedTime())
363
364     /** {@inheritDoc} */
365     @Override
366     public void sessionCreated(HttpSessionEvent event) {
367         if (!instanceEnabled) {
368             return;
369         }
370         // pour être notifié des passivations et activations, on enregistre un HttpSessionActivationListener (this)
371         final HttpSession session = event.getSession();
372         // Since tomcat 6.0.21, because of https://issues.apache.org/bugzilla/show_bug.cgi?id=45255
373         // when tomcat authentication is used, sessionCreated is called twice for 1 session
374         // and each time with different ids, then sessionDestroyed is called once.
375         // So we do not count the 2nd sessionCreated event and we remove the id of the first event.
376
377         // And (issue #795), in Tomcat's cluster after one instance restart
378         // sessions are synced with sessionDidActivate+sessionCreated
379         // so do not increment count for sessionCreated when session.getAttribute(SESSION_ACTIVATION_KEY) != null
380         // (but not == this because of deserialization)
381         if (session.getAttribute(SESSION_ACTIVATION_KEY) != null) {
382             // si la map des sessions selon leurs id contient une session dont la clé
383             // n'est plus égale à son id courant, alors on l'enlève de la map
384             // (et elle sera remise dans la map avec son nouvel id ci-dessous)
385             removeSessionsWithChangedId();
386         } else {
387             session.setAttribute(SESSION_ACTIVATION_KEY, this);
388
389             // pour getSessionCount
390             SESSION_COUNT.incrementAndGet();
391         }
392
393         // pour invalidateAllSession
394         addSession(session);
395     }
396
397     /** {@inheritDoc} */
398     @Override
399     public void sessionDestroyed(HttpSessionEvent event) {
400         if (!instanceEnabled) {
401             return;
402         }
403         final HttpSession session = event.getSession();
404
405         // plus de removeAttribute
406         // (pas nécessaire et Tomcat peut faire une exception "session already invalidated")
407         //        session.removeAttribute(SESSION_ACTIVATION_KEY);
408
409         // pour getSessionCount
410         SESSION_COUNT.decrementAndGet();
411
412         // pour invalidateAllSession
413         removeSession(session);
414     }
415
416     /** {@inheritDoc} */
417     @Override
418     public void sessionDidActivate(HttpSessionEvent event) {
419         if (!instanceEnabled) {
420             return;
421         }
422         // pour getSessionCount
423         SESSION_COUNT.incrementAndGet();
424
425         // pour invalidateAllSession
426         addSession(event.getSession());
427     }
428
429     /** {@inheritDoc} */
430     @Override
431     public void sessionWillPassivate(HttpSessionEvent event) {
432         if (!instanceEnabled) {
433             return;
434         }
435         // pour getSessionCount
436         SESSION_COUNT.decrementAndGet();
437
438         // pour invalidateAllSession
439         removeSession(event.getSession());
440     }
441
442     // pour Jenkins/jira/confluence/bamboo
443     void registerSessionIfNeeded(HttpSession session) {
444         if (session != null) {
445             synchronized (session) {
446                 if (!SESSION_MAP_BY_ID.containsKey(session.getId())) {
447                     sessionCreated(new HttpSessionEvent(session));
448                 }
449             }
450         }
451     }
452
453     // pour Jenkins/jira/confluence/bamboo
454     void unregisterSessionIfNeeded(HttpSession session) {
455         if (session != null) {
456             try {
457                 session.getCreationTime();
458
459                 // https://issues.jenkins-ci.org/browse/JENKINS-20532
460                 // https://bugs.eclipse.org/bugs/show_bug.cgi?id=413019
461                 session.getLastAccessedTime();
462             } catch (final IllegalStateException e) {
463                 // session.getCreationTime() lance IllegalStateException si la session est invalidée
464                 synchronized (session) {
465                     sessionDestroyed(new HttpSessionEvent(session));
466                 }
467             }
468         }
469     }
470
471     // pour Jenkins/jira/confluence/bamboo
472     void unregisterInvalidatedSessions() {
473         for (final Map.Entry<String, HttpSession> entry : SESSION_MAP_BY_ID.entrySet()) {
474             final HttpSession session = entry.getValue();
475             if (session.getId() != null) {
476                 unregisterSessionIfNeeded(session);
477             } else {
478                 // damned JIRA has sessions with null id, when shuting down
479                 final String sessionId = entry.getKey();
480                 SESSION_MAP_BY_ID.remove(sessionId);
481             }
482         }
483         // issue 198: in JIRA 4.4.*, sessionCreated is called two times with different sessionId
484         // but with the same attributes in the second than the attributes added in the first,
485         // so SESSION_COUNT is periodically counted again
486         SESSION_COUNT.set(SESSION_MAP_BY_ID.size());
487     }
488
489     void removeAllActivationListeners() {
490         for (final HttpSession session : SESSION_MAP_BY_ID.values()) {
491             try {
492                 session.removeAttribute(SESSION_ACTIVATION_KEY);
493             } catch (final Exception e) {
494                 // Tomcat can throw "java.lang.IllegalStateException: xxx: Session already invalidated"
495                 continue;
496             }
497         }
498     }
499
500     /** {@inheritDoc} */
501     @Override
502     public String toString() {
503         return getClass().getSimpleName() + "[sessionCount=" + getSessionCount() + ']';
504     }
505 }
506