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