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