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}