hubssolib 3.3.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b434d369e25dc81a74c87385df70cade7dfd40c47abfae2c05732b471b6e1f5e
4
- data.tar.gz: 41d2303f125f52a5f957f851f1a8d5ae7067ab8233fcfa5359640f88e3bceb06
3
+ metadata.gz: 8fc6eb926595d43f0fe51803fb2dd817930adf4385a742461c13197c87ebd8a5
4
+ data.tar.gz: 3f532eca169d7497d595aeb15a31d47650ee13fd4973859041d40f66144217cc
5
5
  SHA512:
6
- metadata.gz: 7dc3e04c0b1e75eecf504c61d19834151179666b7e601d974cbf74509f1ab1197bbf74cf347c63b4e1aef61d744bceb96621b7fccb8017654f6d4266eea21246
7
- data.tar.gz: ba3e8e1985dd91185c6049e25212dcade4dfe064faf31c4e1c1d013ac8851984f3b48ee47df5de16d097863ea36c9165c93790e05703f8fa640908e7260c3b4b
6
+ metadata.gz: 1c645f57251251d560c8940d25aaaade1e98b83fe7cf8ab0dff194c3738bfb0c0e8699a1aed7c4889b656e7c0da81f005722773c1af52205eb233f7f9b7ef3dc
7
+ data.tar.gz: 6927b5bd57c5d5c7eddb65cb36c20fc89f24d6cf8ac1ad341da2feedf4ae6e12f452351416d089eb046214a4895d2e7becf7c098bb4c1cd0c011bf8eaafd36c2
data/CHANGELOG.md CHANGED
@@ -1,3 +1,35 @@
1
+ ## 3.5.0, 25-Mar-2025
2
+
3
+ Builds on the cleaner session interface with some changes and improvements:
4
+
5
+ * Removed the 'clean up other sessions under that user ID' code, since that stopped you being able to log in on more than one browser - notably, both e.g. a desktop/laptop computer's browser and a mobile device's browser. This does carry a risk of stale session cookies for a user building up over time, but the sweeper Rake task will attend to those in due course.
6
+ * On the other hand the Hub core explicitly say whether or not it is trying to read or create a session, so the internal lazy-create no longer triggers for outdated session cookies, resulting in unnecessary "empty" sessions where all the user properties are "nil". This prevents accumulation of sessions for some cases.
7
+
8
+ Minor version number arises due to a new feature:
9
+
10
+ * If the DRb server receives signals `TERM` (e.g. default `kill`) or `INT` (e.g. `Ctrl`+`C`) it now gracefully shuts down, dumping user sessions to a YAML file in a location of `~/.hub_ses_arc` by default, or the filename in environment variable `HUB_SESSION_ARCHIVE`, if defined.
11
+ * On startup, this file is loaded. If exceptions are encountered they are ignored and session data is ignored, leading to a clean start. Likewise simply deleting the file leads to a clean start - otherwise, it becomes possible to cycle the DRb server and retain session data! **A self-sweep of ancient sessions is conducted at startup**, which takes the same action as the `hub_sessions:sweep` Rake task.
12
+ * Once the server is running the file is deleted, so in case of hard crash or forced exit via other signals (e.g. `kill -9`) - so a new session dump is _not_ made - then session data is lost, by design. The assumption in such circumstances is that session data is unreliable and may even have been the cause of a crash.
13
+
14
+ ## 3.4.0, 24-Mar-2025
15
+
16
+ The session interface has now been cleaned up. While the public API is unchanged, you:
17
+
18
+ * ...are encouraged to change uses of `hubssolib_current_user = foo` over to `hubssolib_log_in()`
19
+ * ...are likewise encouraged to change `hubssolib_current_user = nil` over to `hubssolib_log_out()`
20
+
21
+ There are many fixes in the overhauled session management:
22
+
23
+ * Session data is only stored when a user logs in, else no session cookie is built
24
+ * If a user logs in, possible stale older sessions they might have are swept out
25
+ * When the user logs out their session data is entirely deleted
26
+ * The Hub app includes a Rake task that makes use of a new internal session factory feature to clean up sessions with a very old idle timeout (more than 3 times the usual in-app 'you were logged out' notification mechanism's delay via `HUB_IDLE_TIME_LIMIT`, or 2 days, whichever is larger) and delete such sessions - yes, this means that if a user did then come in even later on a now-deleted session ID, they'd simply be logged out without the warning message about why, but it was a serious oversight prior to allow the sessions to just accumulate.
27
+
28
+ In addition:
29
+
30
+ * Iteration over the sessions object for active user enumeration could cause failures. The process could be time consuming and, during it, the client side is iterating over a remote object that the DRb server maintains for any new, inbound sessions which may be started by other web server request threads into the Hub app. This would lead to `can't add a new key into hash during iteration` exceptions. This is fixed via more aggressive mutex use.
31
+
32
+
1
33
  ## 3.3.0, 16-Feb-2025
2
34
 
3
35
  Sentry support, for use by the DRb server. If you use Sentry, define your account's `SENTRY_DSN` in the environment where the DRb server runs and exceptions will be reported.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- hubssolib (3.1.0)
4
+ hubssolib (3.4.0)
5
5
  base64 (~> 0.2)
6
6
  drb (~> 2.2)
7
7
 
@@ -29,7 +29,7 @@ GEM
29
29
  psych (5.2.3)
30
30
  date
31
31
  stringio
32
- rdoc (6.12.0)
32
+ rdoc (6.13.0)
33
33
  psych (>= 4.0.0)
34
34
  reline (0.6.0)
35
35
  io-console (~> 0.5)
@@ -52,7 +52,7 @@ GEM
52
52
  simplecov_json_formatter (~> 0.1)
53
53
  simplecov-html (0.13.1)
54
54
  simplecov_json_formatter (0.1.4)
55
- stringio (3.1.3)
55
+ stringio (3.1.5)
56
56
 
57
57
  PLATFORMS
58
58
  ruby
data/README.md CHANGED
@@ -202,6 +202,16 @@ The four arguments are guaranteed to be present and non-empty, with leading or t
202
202
 
203
203
 
204
204
 
205
+ ## Session maintenance
206
+
207
+ User-idle session expiry is routinely handled in the "beforehand" callback so that we can see the session is valid but expired, expire it and add a via-flash warning about what happened so the user is fully informed. The Hub gem tries to be nice about `POST`, `PATCH` and `PUT` operations too and allows those to complete before the expected subsequent redirection `GET` causing expiry, to try and avoid users losing form submission data entirely.
208
+
209
+ Users who log in but then go idle will not cause any natural session expiry by virtue of there being no new page fetch activity provoking the idle timeout check. For users who've just gone away full stop, this means a session record is left hanging around in DRb server memory (or if future iterations support other storage methods, they'd persist in whatever storage that is). For this reason, the Hub _application_ provides a Rake task which sweeps sessions based on things idle for three times the `HUB_IDLE_TIME_LIMIT`, or two days, whichever is longer (so short idle timers still give users a couple of days to potentially come back to their old session and get the "nice" expiry message). Run `bundle exec rake hub_sessions:sweep` whenever you want (in practice, once a day is enough). **Be sure to set up any custom Hub environment variables for your setup** so that the Rake task knows where to find the DRb socket for the running server, what your expiry time is if so customised, and so-on.
210
+
211
+ * **Note:** The DRb server persists sessions upon graceful shutdown (`INT` or `TERM` signals) into file `~/.hub_ses_arc` by default, or the filename in environment variable `HUB_SESSION_ARCHIVE` if defined. When the server restarts, it loads this data **and runs the same very old session expiry** algorithm. This means that shutting down and restarting the DRb server is another way to expire very old sessions, but that results in downtime, even if only brief; so for general maintenance with uptime maintained, the Rake task should be used.
212
+
213
+
214
+
205
215
  ## Hub library API
206
216
 
207
217
  The Hub component interfaces that should be used by application authors when integrating with the Hub single sign-on mechanism are described below. If you want a complete list of all public interfaces, consult the file `hub_sso_lib.rb` inside the Hub gem. All functions and classes therein are fully commented to describe the purpose of each class, along with the purpose, input parameters and return values of class methods and instance methods.
data/hubssolib.gemspec CHANGED
@@ -4,7 +4,7 @@ spec = Gem::Specification.new do |s|
4
4
  s.platform = Gem::Platform::RUBY
5
5
  s.name = 'hubssolib'
6
6
 
7
- s.version = '3.3.0'
7
+ s.version = '3.5.0'
8
8
  s.author = 'Andrew Hodgkinson and others'
9
9
  s.email = 'ahodgkin@rowing.org.uk'
10
10
  s.homepage = 'http://pond.org.uk/'
data/lib/hub_sso_lib.rb CHANGED
@@ -20,8 +20,10 @@ module HubSsoLib
20
20
  require 'drb'
21
21
  require 'securerandom'
22
22
  require 'json'
23
+ require 'yaml'
23
24
 
24
- # DRb connection
25
+ # DRb connection.
26
+ #
25
27
  HUB_CONNECTION_URI = ENV['HUB_CONNECTION_URI'] || 'drbunix:' + File.join( ENV['HOME'] || '/', '/.hub_drb')
26
28
 
27
29
  unless HUB_CONNECTION_URI.downcase.start_with?('drbunix:')
@@ -34,7 +36,8 @@ module HubSsoLib
34
36
  raise 'Exiting'
35
37
  end
36
38
 
37
- # External application command registry for on-user-change events
39
+ # External application command registry for on-user-change events.
40
+ #
38
41
  HUB_COMMAND_REGISTRY = ENV['HUB_COMMAND_REGISTRY'] || File.join( ENV['HOME'] || '/', '/.hub_cmd_reg')
39
42
 
40
43
  unless Dir.exist?(File.dirname(HUB_COMMAND_REGISTRY))
@@ -47,13 +50,27 @@ module HubSsoLib
47
50
  raise 'Exiting'
48
51
  end
49
52
 
53
+ # DRb session on-shutdown dumped cache/archive.
54
+ #
55
+ HUB_SESSION_ARCHIVE = ENV['HUB_SESSION_ARCHIVE'] || File.join( ENV['HOME'] || '/', '/.hub_ses_arc')
56
+
57
+ unless Dir.exist?(File.dirname(HUB_SESSION_ARCHIVE))
58
+ puts
59
+ puts '*' * 80
60
+ puts "Invalid path specified by HUB_SESSION_ARCHIVE (#{ HUB_SESSION_ARCHIVE.inspect })"
61
+ puts '*' * 80
62
+ puts
63
+
64
+ raise 'Exiting'
65
+ end
66
+
50
67
  # Location of Hub application root.
51
68
  #
52
69
  HUB_PATH_PREFIX = ENV['HUB_PATH_PREFIX'] || ''
53
70
 
54
- # Time limit, *in seconds*, for the account inactivity timeout.
55
- # If a user performs no Hub actions during this time they will
56
- # be automatically logged out upon their next action.
71
+ # Time limit, *in seconds*, for the account inactivity timeout. If a user
72
+ # performs no Hub actions during this time they will be automatically logged
73
+ # out upon their next action, with a Flash message set to explain why.
57
74
  #
58
75
  HUB_IDLE_TIME_LIMIT = ENV['HUB_IDLE_TIME_LIMIT']&.to_i || 4 * 60 * 60
59
76
 
@@ -402,16 +419,16 @@ module HubSsoLib
402
419
  attr_accessor :session_return_to
403
420
  attr_accessor :session_flash
404
421
  attr_accessor :session_user
405
- attr_accessor :session_key_rotation
422
+ attr_accessor :session_rotated_key
406
423
  attr_accessor :session_ip
407
424
 
408
425
  def initialize
409
- @session_last_used = Time.now.utc
410
- @session_return_to = nil
411
- @session_flash = {}
412
- @session_user = HubSsoLib::User.new
413
- @session_key_rotation = nil
414
- @session_ip = nil
426
+ @session_last_used = Time.now.utc
427
+ @session_return_to = nil
428
+ @session_flash = {}
429
+ @session_user = HubSsoLib::User.new
430
+ @session_rotated_key = nil
431
+ @session_ip = nil
415
432
 
416
433
  rescue => e
417
434
  Sentry.capture_exception(e) if defined?(Sentry) && Sentry.respond_to?(:capture_exception)
@@ -423,8 +440,10 @@ module HubSsoLib
423
440
  # Class: SessionFactory #
424
441
  # (C) Hipposoft 2006 #
425
442
  # #
426
- # Purpose: Build Session objects for DRb server clients. Maintains a #
427
- # hash of Session objects. #
443
+ # Purpose: Manage Session objects for DRb server clients. This class #
444
+ # implements the API exposed by the HubSsoLib::Server DRb #
445
+ # endpoint, so this is the remote object that clients will #
446
+ # be calling into. #
428
447
  # #
429
448
  # Author: A.D.Hodgkinson #
430
449
  # #
@@ -436,7 +455,33 @@ module HubSsoLib
436
455
  @hub_be_quiet = ! ENV['HUB_QUIET_SERVER'].nil?
437
456
  @hub_sessions = {}
438
457
 
439
- puts "Session factory: Awaken" unless @hub_be_quiet
458
+ puts "Session factory: Awakening..." unless @hub_be_quiet
459
+
460
+ if File.exist?(HUB_SESSION_ARCHIVE)
461
+ begin
462
+ restored_sessions = ::YAML.load_file(
463
+ HUB_SESSION_ARCHIVE,
464
+ permitted_classes: [
465
+ ::HubSsoLib::Session,
466
+ ::HubSsoLib::User,
467
+ Time
468
+ ]
469
+ )
470
+
471
+ @hub_sessions = restored_sessions || {}
472
+ self.destroy_ancient_sessions()
473
+ puts "Session factory: Reloaded #{@hub_sessions.keys.size} from archive" unless @hub_be_quiet
474
+
475
+ rescue => e
476
+ puts "Session factory: Ignored archive due to error #{e.message.inspect}" unless @hub_be_quiet
477
+
478
+ ensure
479
+ File.unlink(HUB_SESSION_ARCHIVE)
480
+
481
+ end
482
+ end
483
+
484
+ puts "Session factory: ...Awakened" unless @hub_be_quiet
440
485
 
441
486
  rescue => e
442
487
  Sentry.capture_exception(e) if defined?(Sentry) && Sentry.respond_to?(:capture_exception)
@@ -448,32 +493,42 @@ module HubSsoLib
448
493
  # recorded in existing session data.
449
494
  #
450
495
  # Whether new or pre-existing, the returned session will have changed key
451
- # as a result of being read; check the #session_key_rotation property to
496
+ # as a result of being read; check the #session_rotated_key property to
452
497
  # find out the new key. If you fail to do this, you'll lose access to the
453
498
  # session data as you won't know which key it lies under.
454
499
  #
455
500
  # The returned object is proxied via DRb - it is shared between processes.
456
501
  #
457
502
  # +key+:: Session key; lazy-initialises a new session under this key
458
- # if none is found, then immediately rotates it.
503
+ # if none is found, then immediately rotates it by default,
504
+ # but may return no session for unrecognised keys depending
505
+ # on the +create+ parameter, described below.
459
506
  #
460
507
  # +remote_ip+:: Request's remote IP address. If there is an existing
461
508
  # session which matches this, it's returned. If there is an
462
509
  # existing session but the IP mismatches, it's treated as
463
510
  # invalid and discarded.
464
511
  #
465
- def get_hub_session_proxy(key, remote_ip)
466
- hub_session = @hub_sessions[key]
467
- message = hub_session.nil? ? 'Created' : 'Retrieving'
468
- new_key = SecureRandom.uuid
512
+ # In addition, the following optional named parameters can be given:
513
+ #
514
+ # +create+:: Default +true+ - an unknown key causes creation of an empty,
515
+ # new session under that key. If +false+, attempts to read with
516
+ # an unrecognised key yield +nil+.
517
+ #
518
+ def get_hub_session_proxy(key, remote_ip, create: true)
519
+ hub_session = HUB_MUTEX.synchronize { @hub_sessions[key] }
520
+ return nil if create == false && hub_session.nil? # NOTE EARLY EXIT
521
+
522
+ message = hub_session.nil? ? 'Created' : 'Retrieving'
523
+ new_key = SecureRandom.uuid
469
524
 
470
525
  unless @hub_be_quiet
471
- puts "#{ message } session for key #{ key } and rotating to #{ new_key }"
526
+ puts "Session factory: #{ message } session for key #{ key } and rotating to #{ new_key }"
472
527
  end
473
528
 
474
529
  unless hub_session.nil? || hub_session.session_ip == remote_ip
475
530
  unless @hub_be_quiet
476
- puts "WARNING: IP address changed from #{ hub_session.session_ip } to #{ remote_ip } -> discarding session"
531
+ puts "Session factory: WARNING: IP address changed from #{ hub_session.session_ip } to #{ remote_ip } -> discarding session"
477
532
  end
478
533
 
479
534
  hub_session = nil
@@ -484,10 +539,12 @@ module HubSsoLib
484
539
  hub_session.session_ip = remote_ip
485
540
  end
486
541
 
487
- @hub_sessions.delete(key)
488
- @hub_sessions[new_key] = hub_session
542
+ HUB_MUTEX.synchronize do
543
+ @hub_sessions.delete(key)
544
+ @hub_sessions[new_key] = hub_session
545
+ end
489
546
 
490
- hub_session.session_key_rotation = new_key
547
+ hub_session.session_rotated_key = new_key
491
548
  return hub_session
492
549
 
493
550
  rescue => e
@@ -495,6 +552,26 @@ module HubSsoLib
495
552
  raise
496
553
  end
497
554
 
555
+ # Enumerate all currently known sessions. The format is a Hash, with the
556
+ # session key UUIDs as keys and the related HubSsoLib::Session instances as
557
+ # values.
558
+ #
559
+ # WARNING: This returns all Hub sessions maintained by the server instance
560
+ # but it's returning a reference to the live object with everything being
561
+ # managed over DRb. If a caller iterates over this object, then it can fall
562
+ # foul of other request threads making changes to the underlying data and
563
+ # Ruby raising exceptions about e.g. hashes being modified during
564
+ # enumeration.
565
+ #
566
+ # To prevent this, if intending to iterate over the collection, really the
567
+ # only safe way is to duplicate it first on "your side" (the client side)
568
+ # of the DRb connection. This of course consumes RAM, so you might choose
569
+ # to evaluate e.g. the number of keys in the returned session data and only
570
+ # permit enumeration if they fall below some previously-measured "allowed"
571
+ # value wherein RAM consumption is acceptable.
572
+ #
573
+ # See HubSsoLib::Core#hubssolib_enumerate_users for an example.
574
+ #
498
575
  def enumerate_hub_sessions()
499
576
  @hub_sessions
500
577
 
@@ -502,6 +579,121 @@ module HubSsoLib
502
579
  Sentry.capture_exception(e) if defined?(Sentry) && Sentry.respond_to?(:capture_exception)
503
580
  raise
504
581
  end
582
+
583
+ # Given a session key (which, if a session has been looked up and the key
584
+ # thus rotated, ought to be that new, rotated key), destroy the associated
585
+ # session data. Does nothing if the key is not found.
586
+ #
587
+ def destroy_session_by_key(key)
588
+ HUB_MUTEX.synchronize do
589
+ @hub_sessions.delete(key)
590
+ end
591
+
592
+ rescue => e
593
+ Sentry.capture_exception(e) if defined?(Sentry) && Sentry.respond_to?(:capture_exception)
594
+ raise
595
+ end
596
+
597
+ # WARNING: Comparatively slow
598
+ #
599
+ # This is called in rare cases such as user deletion or being asked for a
600
+ # session under an old key, indicating loss of key rotation sequence.
601
+ # Removes all sessions found for a given user ID.
602
+ #
603
+ # IN THE CURRENT IMPLEMENTATION THIS JUST SEQUENTIALLY SCANS ALL ACTIVE
604
+ # SESSIONS IN THE HASH and must therefore lock on mutex for the duration.
605
+ #
606
+ def destroy_sessions_by_user_id(user_id)
607
+ HUB_MUTEX.synchronize do
608
+ @hub_sessions.reject! do | key, session |
609
+ session&.session_user&.user_id == user_id
610
+ end
611
+ end
612
+
613
+ rescue => e
614
+ Sentry.capture_exception(e) if defined?(Sentry) && Sentry.respond_to?(:capture_exception)
615
+ raise
616
+ end
617
+
618
+ # WARNING: Slow
619
+ #
620
+ # This is a housekeeping task which checks sessions against Hub expiry and,
621
+ # if the session keys look to be substantially older than the value set in
622
+ # HUB_IDLE_TIME_LIMIT, the session simply deleted. If a user does return
623
+ # later, they'll see themselves in logged out state without the Flash
624
+ # warning them of an expired session, but we can't allow session keys to
625
+ # just hang around forever so *some* kind of sweep is needed.
626
+ #
627
+ # This method clearly needs to iterate over all sessions under a mutex and
628
+ # makes relatively complex checks for each, so it's fairly slow compared
629
+ # to most methods. Call it infrequently; any and all other attempts to read
630
+ # session data while the method runs will block until method finishes.
631
+ #
632
+ def destroy_ancient_sessions
633
+ time_limit = HUB_IDLE_TIME_LIMIT * 3 # (TODO: This is fairly arbitrary...)
634
+ time_limit = 172_800 if time_limit < 172_800 # (2 days)
635
+ destroyed = 0
636
+
637
+ unless @hub_be_quiet
638
+ puts "Session factory: Sweeping sessions inactive for more than #{ time_limit } seconds..."
639
+ end
640
+
641
+ HUB_MUTEX.synchronize do
642
+ count_before = @hub_sessions.keys.size
643
+
644
+ @hub_sessions.reject! do | key, session |
645
+ last_used = session&.session_last_used
646
+ last_used.nil? || Time.now.utc - last_used > time_limit
647
+ end
648
+
649
+ count_after = @hub_sessions.keys.size
650
+ destroyed = count_before - count_after
651
+ end
652
+
653
+ unless @hub_be_quiet
654
+ puts "Session factory: ...Destroyed #{destroyed} session(s)"
655
+ end
656
+
657
+ rescue => e
658
+ Sentry.capture_exception(e) if defined?(Sentry) && Sentry.respond_to?(:capture_exception)
659
+ raise
660
+ end
661
+
662
+ # Lock the session store and dump all sessions with a non-nil session user
663
+ # ID to a YAML file at HUB_SESSION_ARCHIVE. This is expected to only be
664
+ # called by the graceful shutdown code in HubSsoLib::Server.
665
+ #
666
+ def dump_sessions!
667
+ written_record_count = 0
668
+
669
+ # Why not just do ::YAML.dump(@hub_sessions)? Well, it'd be faster, but
670
+ # it builds the YAML data all in RAM which would cause a huge RAM spike
671
+ # of unknown size (depends on live session count) and that's Bad.
672
+ #
673
+ # If any no-user sessions have crept in for any reason, this also gives
674
+ # us a chance to skip them.
675
+ #
676
+ HUB_MUTEX.synchronize do
677
+ File.open(HUB_SESSION_ARCHIVE, 'w') do | f |
678
+ f.write("---\n") # (document marker)
679
+
680
+ @hub_sessions.each do | key, session |
681
+ next if session&.session_user&.user_id.nil? # NOTE EARLY LOOP RESTART
682
+
683
+ dump = ::YAML.dump({key => session})
684
+ dump.sub!(/^---\n/, '') # (avoid multiple document markers)
685
+
686
+ f.write(dump)
687
+ written_record_count += 1
688
+ end
689
+ end
690
+ end
691
+
692
+ # Simple if slightly inefficient way to deal with zero actual useful
693
+ # session records being present - an unusual real-world edge case.
694
+ #
695
+ File.unlink(HUB_SESSION_ARCHIVE) if written_record_count == 0
696
+ end
505
697
  end
506
698
 
507
699
  #######################################################################
@@ -522,16 +714,41 @@ module HubSsoLib
522
714
 
523
715
  module Server
524
716
  def hubssolib_launch_server
525
- puts "Server: Starting at #{ HUB_CONNECTION_URI }" unless ENV['HUB_QUIET_SERVER'].nil?
717
+ ::HubSsoLib::Server::Runner.run
718
+ end
526
719
 
527
- @@hub_session_factory = HubSsoLib::SessionFactory.new
528
- DRb.start_service(HUB_CONNECTION_URI, @@hub_session_factory, { :safe_level => 1 })
529
- DRb.thread.join
720
+ class Runner
721
+ QUEUE = ::Queue.new
530
722
 
531
- rescue => e
532
- Sentry.capture_exception(e) if defined?(Sentry) && Sentry.respond_to?(:capture_exception)
533
- raise
534
- end
723
+ def self.run
724
+ puts "Server: Starting at #{ HUB_CONNECTION_URI }" if ENV['HUB_QUIET_SERVER'].nil?
725
+
726
+ @@hub_session_factory = HubSsoLib::SessionFactory.new
727
+
728
+ Signal.trap('INT' ) { QUEUE << :INT }
729
+ Signal.trap('TERM') { QUEUE << :TERM }
730
+
731
+ DRb.start_service(HUB_CONNECTION_URI, @@hub_session_factory, { :safe_level => 1 })
732
+
733
+ QUEUE.pop
734
+
735
+ self.shutdown()
736
+ exit
737
+
738
+ rescue => e
739
+ Sentry.capture_exception(e) if defined?(Sentry) && Sentry.respond_to?(:capture_exception)
740
+ raise
741
+ end
742
+
743
+ def self.shutdown
744
+ puts "Server: Graceful shutdown..."
745
+
746
+ @@hub_session_factory.dump_sessions!
747
+ DRb.stop_service
748
+
749
+ puts "Server: ...completed."
750
+ end
751
+ end # Runner class
535
752
  end # Server module
536
753
 
537
754
  #######################################################################
@@ -550,14 +767,58 @@ module HubSsoLib
550
767
 
551
768
  module Core
552
769
 
553
- # Returns true or false if the user is logged in.
770
+ # Log in the user. This is just syntax sugar for setting the current user
771
+ # via #hubssolib_current_user, really. You can freely use either approach
772
+ # according to your preferred aesthetics, but this method is preferred.
773
+ #
774
+ # +user+:: A valid HubSsoLib::User instance. If this has a +nil+ value for
775
+ # +user_id+, or if +user+ is itself +nil+, you'll cause the same
776
+ # effect as if explicitly logging *out*.
777
+ #
778
+ def hubssolib_log_in(user)
779
+ self.hubssolib_current_user = user # (which deals with all related session and cookie consequences)
780
+ end
781
+
782
+ # Log out the user. Very few applications should ever need to call this,
783
+ # though Hub certainly does and it gets used internally too.
554
784
  #
555
- # Preloads @hubssolib_current_user with user data if logged in.
785
+ def hubssolib_log_out
786
+ self.hubssolib_current_user = nil # (which deals with all related session and cookie consequences)
787
+ end
788
+
789
+ # Returns true or false if a user is logged in or not, respectively.
556
790
  #
557
791
  def hubssolib_logged_in?
558
792
  !!self.hubssolib_current_user
559
793
  end
560
794
 
795
+ # Accesses the current user, via the DRb server if necessary. Returns a
796
+ # HubSsoLib::User object, or +nil+ if none is available (this indicates
797
+ # that nobody is logged in, but #hubssolib_logged_in? should be used to
798
+ # check that, to allow for possible future more advanced logic within).
799
+ #
800
+ def hubssolib_current_user
801
+ hub_session = self.hubssolib_get_session()
802
+ user = hub_session&.session_user
803
+
804
+ return (user&.user_id.nil? ? nil : user)
805
+ end
806
+
807
+ # Sets the currently signed in user. Note that although this works and is
808
+ # maintained, it is recommended that #hubssolib_log_in gets called instead.
809
+ #
810
+ # +user+:: A valid HubSsoLib::User. This will replace any existing logged
811
+ # in user. If there is no session yet, one will be created.
812
+ #
813
+ def hubssolib_current_user=(user)
814
+ if user.nil?
815
+ self.hubssolib_destroy_session!
816
+ else
817
+ hub_session = self.hubssolib_create_session()
818
+ hub_session.session_user = user
819
+ end
820
+ end
821
+
561
822
  # Returns markup for a link that leads to Hub's conditional login endpoint,
562
823
  # inline-styled as a red "Log in" or green "Account" button. This can be
563
824
  # used in page templates to avoid needing any additional images or other
@@ -570,7 +831,6 @@ module HubSsoLib
570
831
  #
571
832
  def hubssolib_account_link
572
833
  logged_in = self.hubssolib_logged_in?()
573
-
574
834
  ui_href = "#{HUB_PATH_PREFIX}/account/login_conditional"
575
835
  noscript_img_src = "#{HUB_PATH_PREFIX}/account/login_indication.png"
576
836
  noscript_img_tag = helpers.image_tag(noscript_img_src, size: '90x22', border: '0', alt: 'Log in or out')
@@ -667,51 +927,12 @@ module HubSsoLib
667
927
  return (puser && !puser.empty? && puser != pnormal)
668
928
  end
669
929
 
670
- # Log out the user. Very few applications should ever need to call this,
671
- # though Hub certainly does and it gets used internally too.
672
- #
673
- def hubssolib_log_out
674
- # Causes the "hubssolib_current_[foo]=" methods to run, which
675
- # deal with everything else.
676
- self.hubssolib_current_user = nil
677
- @hubssolib_current_session_proxy = nil
678
- end
679
-
680
- # Accesses the current session from the cookie. Creates a new session
681
- # object if need be, but can return +nil+ if e.g. attempting to access
682
- # session cookie data without SSL.
683
- #
684
- def hubssolib_current_session
685
- @hubssolib_current_session_proxy ||= hubssolib_get_session_proxy()
686
- end
687
-
688
- # Accesses the current user, via the DRb server if necessary.
689
- #
690
- def hubssolib_current_user
691
- hub_session = self.hubssolib_current_session
692
- user = hub_session.nil? ? nil : hub_session.session_user
693
-
694
- if (user && user.user_id)
695
- return user
696
- else
697
- return nil
698
- end
699
- end
700
-
701
- # Store the given user data in the cookie
702
- #
703
- def hubssolib_current_user=(user)
704
- hub_session = self.hubssolib_current_session
705
- hub_session.session_user = user unless hub_session.nil?
706
- end
707
-
708
930
  # Public read-only accessor methods for common user activities:
709
931
  # return the current user's roles as a Roles object, or nil if
710
932
  # there's no user.
711
933
  #
712
934
  def hubssolib_get_user_roles
713
- user = self.hubssolib_current_user
714
- user ? user.user_roles.to_authenticated_roles : nil
935
+ self.hubssolib_current_user&.user_roles&.to_authenticated_roles
715
936
  end
716
937
 
717
938
  # Public read-only accessor methods for common user activities:
@@ -719,8 +940,7 @@ module HubSsoLib
719
940
  # no user. See also hubssolib_unique_name.
720
941
  #
721
942
  def hubssolib_get_user_name
722
- user = self.hubssolib_current_user
723
- user ? user.user_real_name : nil
943
+ self.hubssolib_current_user&.user_real_name
724
944
  end
725
945
 
726
946
  # Public read-only accessor methods for common user activities:
@@ -728,8 +948,7 @@ module HubSsoLib
728
948
  # nil if there's no user. See also hubssolib_unique_name.
729
949
  #
730
950
  def hubssolib_get_user_id
731
- user = self.hubssolib_current_user
732
- user ? user.user_id : nil
951
+ self.hubssolib_current_user&.user_id
733
952
  end
734
953
 
735
954
  # Public read-only accessor methods for common user activities:
@@ -737,8 +956,7 @@ module HubSsoLib
737
956
  # no user.
738
957
  #
739
958
  def hubssolib_get_user_address
740
- user = self.hubssolib_current_user
741
- user ? user.user_email : nil
959
+ self.hubssolib_current_user&.user_email
742
960
  end
743
961
 
744
962
  # Return a human-readable unique ID for a user. We don't want to
@@ -863,15 +1081,13 @@ module HubSsoLib
863
1081
  cookies.delete(HUB_LOGIN_INDICATOR_COOKIE, domain: :all, path: '/')
864
1082
 
865
1083
  if login_is_required
866
- hubssolib_store_location
867
- return hubssolib_must_login
1084
+ hubssolib_store_location()
1085
+ return hubssolib_must_login()
868
1086
  else
869
1087
  return true
870
1088
  end
871
1089
  end
872
1090
 
873
- # Definitely logged in.
874
- #
875
1091
  cookies[HUB_LOGIN_INDICATOR_COOKIE] = {
876
1092
  value: HUB_LOGIN_INDICATOR_COOKIE_VALUE,
877
1093
  path: '/',
@@ -892,8 +1108,8 @@ module HubSsoLib
892
1108
  # if OK, else indicate that access is denied.
893
1109
 
894
1110
  if (hubssolib_session_expired?)
895
- hubssolib_store_location
896
- hubssolib_log_out
1111
+ hubssolib_store_location()
1112
+ hubssolib_log_out()
897
1113
  hubssolib_set_flash(:attention, 'Sorry, your session timed out; you need to log in again to continue.')
898
1114
 
899
1115
  # We mean this: redirect_to :controller => 'account', :action => 'login'
@@ -902,7 +1118,7 @@ module HubSsoLib
902
1118
  redirect_to HUB_PATH_PREFIX + '/account/login'
903
1119
  else
904
1120
  hubssolib_set_last_used(Time.now.utc)
905
- return hubssolib_authorized? ? true : hubssolib_access_denied
1121
+ return hubssolib_authorized? ? true : hubssolib_access_denied()
906
1122
  end
907
1123
 
908
1124
  else
@@ -955,7 +1171,7 @@ module HubSsoLib
955
1171
  # Redirect to the URI stored by the most recent store_location call or
956
1172
  # to the passed default.
957
1173
  def hubssolib_redirect_back_or_default(default)
958
- url = hubssolib_get_return_to
1174
+ url = hubssolib_get_return_to()
959
1175
  hubssolib_set_return_to(nil)
960
1176
 
961
1177
  redirect_to(url || default)
@@ -987,26 +1203,29 @@ module HubSsoLib
987
1203
  end
988
1204
  end
989
1205
 
990
- # Public methods to set some data that would normally go in @session,
991
- # but can't because it needs to be accessed across applications. It is
992
- # put in an insecure support cookie instead. There are some related
993
- # private methods for things like session expiry too.
1206
+ # Flash data can be carried across the Hub session, stored in the DRb
1207
+ # server as a result, and is thus cleared automatically if a session gets
1208
+ # dropped. However, we also want this to work without being logged in, so
1209
+ # in that case it uses the normal flash as a backup when *writing*.
994
1210
  #
995
- def hubssolib_get_flash()
996
- f = self.hubssolib_current_session ? self.hubssolib_current_session.session_flash : nil
997
- return f || {}
1211
+ def hubssolib_get_flash
1212
+ session = self.hubssolib_get_session()
1213
+ session&.session_flash || {}
998
1214
  end
999
1215
 
1000
1216
  def hubssolib_set_flash(symbol, message)
1001
- return unless self.hubssolib_current_session
1002
- f = hubssolib_get_flash
1003
- f[symbol.to_s] = message
1004
- self.hubssolib_current_session.session_flash = f
1217
+ session = self.hubssolib_get_session()
1218
+ f = hubssolib_get_flash() unless session.nil?
1219
+ f = self.flash if f.nil? && self.respond_to?(:flash)
1220
+
1221
+ f[symbol] = message
1222
+
1223
+ session.session_flash = f unless session.nil?
1005
1224
  end
1006
1225
 
1007
1226
  def hubssolib_clear_flash
1008
- return unless self.hubssolib_current_session
1009
- self.hubssolib_current_session.session_flash = {}
1227
+ session = self.hubssolib_get_session()
1228
+ session.session_flash = {} unless session.nil?
1010
1229
  end
1011
1230
 
1012
1231
  # Return flash data for known keys, then all remaining keys, from both
@@ -1063,21 +1282,22 @@ module HubSsoLib
1063
1282
  hubssolib_get_exception_data(CGI::unescape(id_data))
1064
1283
  end
1065
1284
 
1066
- # Inclusion hook to make various methods available as ActionView
1067
- # helper methods.
1285
+ # Inclusion hook to make various methods available as ActionView helpers.
1068
1286
  #
1069
1287
  def self.included(base)
1070
- base.send :helper_method,
1071
- :hubssolib_current_user,
1072
- :hubssolib_unique_name,
1073
- :hubssolib_logged_in?,
1074
- :hubssolib_account_link,
1075
- :hubssolib_authorized?,
1076
- :hubssolib_privileged?,
1077
- :hubssolib_flash_data
1078
- rescue
1079
- # We're not always included in controllers...
1080
- nil
1288
+ if base.respond_to?(:helper_method)
1289
+ base.send(
1290
+ :helper_method,
1291
+
1292
+ :hubssolib_current_user,
1293
+ :hubssolib_unique_name,
1294
+ :hubssolib_logged_in?,
1295
+ :hubssolib_authorized?,
1296
+ :hubssolib_privileged?,
1297
+ :hubssolib_account_link,
1298
+ :hubssolib_flash_data
1299
+ )
1300
+ end
1081
1301
  end
1082
1302
 
1083
1303
  private
@@ -1104,6 +1324,110 @@ module HubSsoLib
1104
1324
  HUB_BYPASS_SSL || ! Rails.env.production?
1105
1325
  end
1106
1326
 
1327
+ # Create a new session. This MUST ONLY BE CALLED at a "log in" phase, since
1328
+ # it discards any existing session and creates a new, valid one, whether or
1329
+ # not a valid session key was being presented for the old session. It is,
1330
+ # in essence, a "clean slate" start.
1331
+ #
1332
+ # On exit, @hubssolib_session is updated; see #hubssolib_get_session for
1333
+ # the significance of that.
1334
+ #
1335
+ def hubssolib_create_session
1336
+ old_session = self.hubssolib_get_session()
1337
+ self.hubssolib_destroy_session! unless old_session.nil?
1338
+
1339
+ start_key = SecureRandom.uuid
1340
+ @hubssolib_session = hubssolib_factory().get_hub_session_proxy(start_key, request.remote_ip, create: true)
1341
+
1342
+ # The session is now stored under the rotated key, so put that into the
1343
+ # Hub session cookie so that on the next request, we can retrieve the
1344
+ # session data (and at that time, once again rotate the key).
1345
+ #
1346
+ next_key = @hubssolib_session.session_rotated_key
1347
+ self.hubssolib_store_key(next_key)
1348
+
1349
+ return @hubssolib_session
1350
+ end
1351
+
1352
+ # Gets session data based on the current +request+ details.
1353
+ #
1354
+ # * If there is no session cookie, returns +nil+
1355
+ # * If there is a session cookie but the session key is invalid, returns
1356
+ # +nil+.
1357
+ # * If there is a session cookie with valid key, session data is retrieved
1358
+ # and returned; the session key is rotated and the session cookie updated
1359
+ # with new key (that is to say that this request's response, under normal
1360
+ # conditions, will include an appropriate Set-Cookie header).
1361
+ #
1362
+ # The ivar @hubssolib_session is consulted first to avoid repeating work
1363
+ # during request processing, where it's likely that more than one call
1364
+ # will occur. This ivar is an known internal implementation detail; it is
1365
+ # also set by #hubssolib_create_session, and is cleared by calls to
1366
+ # #hubssolib_destroy_session!.
1367
+ #
1368
+ def hubssolib_get_session
1369
+ if @hubssolib_session.nil?
1370
+ key = cookies[HUB_COOKIE_NAME]
1371
+
1372
+ # See #hubssolib_create_session - valid keys are 36-char UUIDs
1373
+ #
1374
+ if key.nil? || key.size != 36
1375
+ @hubssolib_session = nil
1376
+ else
1377
+ hub_session = hubssolib_factory().get_hub_session_proxy(key, request.remote_ip, create: false)
1378
+
1379
+ if hub_session.nil? # Invalid key in cookie
1380
+ @hubssolib_session = nil
1381
+ else
1382
+ @hubssolib_session = hub_session
1383
+
1384
+ next_key = @hubssolib_session.session_rotated_key
1385
+ self.hubssolib_store_key(next_key)
1386
+ end
1387
+ end
1388
+ end
1389
+
1390
+ return @hubssolib_session
1391
+ end
1392
+
1393
+ # Destroys the current session. If there isn't one, then it has few side
1394
+ # effects other than making sure all session-related cookies are deleted.
1395
+ #
1396
+ def hubssolib_destroy_session!
1397
+ session = self.hubssolib_get_session()
1398
+
1399
+ # Remember, if creating or retrieving a session for a request, the key is
1400
+ # rotated and stored in the session under #session_rotated_key. To delete
1401
+ # that session within that same request - since rotation happens even if
1402
+ # the first place the session gets read is right here, in this method -
1403
+ # we must use the rotated key.
1404
+ #
1405
+ unless session.nil?
1406
+ hubssolib_factory().destroy_session_by_key(session.session_rotated_key)
1407
+ end
1408
+
1409
+ @hubssolib_session = nil
1410
+
1411
+ cookies.delete(HUB_COOKIE_NAME, domain: :all, path: '/')
1412
+ cookies.delete(HUB_LOGIN_INDICATOR_COOKIE, domain: :all, path: '/')
1413
+ end
1414
+
1415
+ # Store the Hub's session key in the Hub cookie.
1416
+ #
1417
+ # +key+:: Session key used to retrieve the session again on the *next*
1418
+ # request. The session has already been stored under that new key
1419
+ # internally. Do not call with +nil+ or blank keys.
1420
+ #
1421
+ def hubssolib_store_key(key)
1422
+ cookies[HUB_COOKIE_NAME] = {
1423
+ value: key,
1424
+ path: '/',
1425
+ domain: :all,
1426
+ secure: ! hub_bypass_ssl?,
1427
+ httponly: true
1428
+ }
1429
+ end
1430
+
1107
1431
  # Indicate that the user must log in to complete their request.
1108
1432
  # Returns false to enable a before_filter to return through this
1109
1433
  # method while ensuring that the previous action processing is
@@ -1164,64 +1488,45 @@ module HubSsoLib
1164
1488
  # have not logged out anyway, and the Hub isn't intended for Fort Knox.
1165
1489
  # At the time of writing the trade-off of usability vs security is
1166
1490
  # considered acceptable, though who knows, the view may change in future.
1491
+ #
1492
+ # Same applies for PATCH and PUT.
1493
+ #
1494
+ last_used = self.hubssolib_get_last_used
1167
1495
 
1168
- last_used = hubssolib_get_last_used
1169
- (request.method != :post && last_used && Time.now.utc - last_used > HUB_IDLE_TIME_LIMIT)
1170
- end
1171
-
1172
- def hubssolib_get_session_proxy
1173
- # If we're not using SSL, forget it
1174
- return nil unless request.ssl? || hub_bypass_ssl?
1175
-
1176
- key = cookies[HUB_COOKIE_NAME] || SecureRandom.uuid
1177
- hub_session = hubssolib_factory().get_hub_session_proxy(key, request.remote_ip)
1178
- key = hub_session.session_key_rotation unless hub_session.nil?
1179
-
1180
- cookies[HUB_COOKIE_NAME] = {
1181
- value: key,
1182
- path: '/',
1183
- domain: :all,
1184
- secure: ! hub_bypass_ssl?,
1185
- httponly: true
1186
- }
1187
-
1188
- return hub_session
1189
-
1190
- rescue Exception => e
1191
-
1192
- # At this point there tends to be no Session data, so we're
1193
- # going to have to encode the exception data into the URI...
1194
- # It must be escaped twice, as many servers treat "%2F" in a
1195
- # URI as a "/" and Apache may flat refuse to serve the page,
1196
- # raising a 404 error unless "AllowEncodedSlashes on" is
1197
- # specified in its configuration.
1198
-
1199
- suffix = '/' + CGI::escape(CGI::escape(hubssolib_set_exception_data(e)))
1200
- new_path = HUB_PATH_PREFIX + '/tasks/service'
1201
- redirect_to(new_path + suffix) unless request.path.include?(new_path)
1202
-
1203
- return nil
1204
- end
1496
+ (
1497
+ request.method != :post &&
1498
+ request.method != :patch &&
1499
+ request.method != :put &&
1205
1500
 
1206
- def hubssolib_set_session_data(session)
1207
- # Nothing to do presently - DRb handles everything
1501
+ last_used && Time.now.utc - last_used > HUB_IDLE_TIME_LIMIT
1502
+ )
1208
1503
  end
1209
1504
 
1210
- # Return an array of Hub User objects representing users based
1211
- # on a list of known sessions returned by the DRb server. Note
1212
- # that if an application exposes this method to a view, it is
1213
- # up to the application to ensure sufficient access permission
1214
- # protection for that view according to the webmaster's choice
1215
- # of site security level. Generally, normal users should not
1216
- # be allowed access.
1505
+ # Return an array of Hub User objects representing users based on a list of
1506
+ # known sessions returned by the DRb server. Note that if an application
1507
+ # exposes this method to a view, it is up to the application to ensure
1508
+ # sufficient access permission protection for that view according to the
1509
+ # webmaster's choice of site security level. Generally, normal users should
1510
+ # not be allowed access!
1511
+ #
1512
+ # Due to the session pool being held in the DRb server and subject to
1513
+ # alteration at any time by other requests (assuming the server supports
1514
+ # more than just one request at a time, that is!) then the session data
1515
+ # must be copied locally before iterating. Otherwise, exceptions arise from
1516
+ # attempts to alter an under-iteration Hash. This in turn raises a worry
1517
+ # about RAM usage. For that reason, a (somewhat arbitrary) limit of
1518
+ # 2000 active users is applied. More than that and the method returns an
1519
+ # empty array.
1217
1520
  #
1218
1521
  def hubssolib_enumerate_users
1219
- sessions = hubssolib_factory().enumerate_hub_sessions()
1220
- users = []
1221
-
1222
- sessions.each do |key, value|
1223
- user = value.session_user
1224
- users.push(user) if (user && user.respond_to?(:user_id) && user.user_id)
1522
+ hub_session_data = hubssolib_factory().enumerate_hub_sessions()
1523
+ return [] if hub_session_data.keys.size > 2000 # NOTE EARLY EXIT
1524
+
1525
+ sessions = hub_session_data.values.dup
1526
+ users = sessions.inject( [] ) do | memo, session |
1527
+ user = session.session_user
1528
+ memo << user unless user&.user_id.nil?
1529
+ memo
1225
1530
  end
1226
1531
 
1227
1532
  return users
@@ -1263,23 +1568,21 @@ module HubSsoLib
1263
1568
  # the session data is available, else return default values.
1264
1569
 
1265
1570
  def hubssolib_get_last_used
1266
- session = self.hubssolib_current_session
1267
- session ? session.session_last_used : Time.now.utc
1571
+ self.hubssolib_get_session()&.session_last_used || Time.now.utc
1268
1572
  end
1269
1573
 
1270
1574
  def hubssolib_set_last_used(time)
1271
- return unless self.hubssolib_current_session
1272
- self.hubssolib_current_session.session_last_used = time
1575
+ session = self.hubssolib_get_session()
1576
+ session.session_last_used = time unless session.nil?
1273
1577
  end
1274
1578
 
1275
1579
  def hubssolib_get_return_to
1276
- session = self.hubssolib_current_session
1277
- session ? session.session_return_to : nil
1580
+ self.hubssolib_get_session()&.session_return_to
1278
1581
  end
1279
1582
 
1280
1583
  def hubssolib_set_return_to(uri)
1281
- return unless self.hubssolib_current_session
1282
- self.hubssolib_current_session.session_return_to = uri
1584
+ session = self.hubssolib_get_session()
1585
+ session.session_return_to = uri unless session.nil?
1283
1586
  end
1284
1587
 
1285
1588
  end # Core module
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hubssolib
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.3.0
4
+ version: 3.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Hodgkinson and others
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-02-16 00:00:00.000000000 Z
10
+ date: 2025-03-25 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: drb