ask-guests 0.1.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 +7 -0
- data/CHANGELOG.md +27 -0
- data/LICENSE +21 -0
- data/README.md +195 -0
- data/lib/ask/guests/active_record/json_value.rb +56 -0
- data/lib/ask/guests/active_record/session_base.rb +69 -0
- data/lib/ask/guests/active_record/session_store.rb +97 -0
- data/lib/ask/guests/budget/policy.rb +42 -0
- data/lib/ask/guests/budget/token_quota.rb +24 -0
- data/lib/ask/guests/budget/turn_limit.rb +24 -0
- data/lib/ask/guests/budget/unlimited.rb +16 -0
- data/lib/ask/guests/budget.rb +68 -0
- data/lib/ask/guests/claim.rb +49 -0
- data/lib/ask/guests/errors.rb +15 -0
- data/lib/ask/guests/rails/claim_on_authentication.rb +51 -0
- data/lib/ask/guests/rails/controller.rb +130 -0
- data/lib/ask/guests/rails/sweep_job.rb +24 -0
- data/lib/ask/guests/session.rb +103 -0
- data/lib/ask/guests/stores/base.rb +65 -0
- data/lib/ask/guests/stores/memory.rb +69 -0
- data/lib/ask/guests/sweep.rb +28 -0
- data/lib/ask/guests/token.rb +59 -0
- data/lib/ask/guests/version.rb +7 -0
- data/lib/ask/guests.rb +158 -0
- data/lib/ask-guests/active_record.rb +8 -0
- data/lib/ask-guests/rails.rb +9 -0
- data/lib/ask-guests.rb +3 -0
- data/lib/generators/ask/guests/install/install_generator.rb +66 -0
- data/lib/generators/ask/guests/install/templates/initializer.rb +39 -0
- data/lib/generators/ask/guests/install/templates/migration.rb +16 -0
- metadata +159 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Guests
|
|
5
|
+
# Transfers everything a guest session owns to a real owner, exactly once.
|
|
6
|
+
#
|
|
7
|
+
# Ask::Guests.claimable(:work_requests) do |session, owner|
|
|
8
|
+
# WorkRequest.where(guest_session_id: session.id)
|
|
9
|
+
# .update_all(owner_id: owner.id, guest_session_id: nil)
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# Claim.new(session: guest, owner: account_user).call # => true
|
|
13
|
+
# Claim.new(session: guest, owner: account_user).call # => false (already claimed)
|
|
14
|
+
#
|
|
15
|
+
# Claiming is race-safe: the session is re-read under a lock (when the
|
|
16
|
+
# store supports it) inside the store's transaction, and the converted
|
|
17
|
+
# flag is written in the same transaction as the record transfer. A
|
|
18
|
+
# double-submit or a replayed cookie can never claim twice.
|
|
19
|
+
class Claim
|
|
20
|
+
def initialize(session:, owner:, store: Guests.store, claimables: Guests.claimables, clock: Guests.configuration.clock)
|
|
21
|
+
@session = session
|
|
22
|
+
@owner = owner
|
|
23
|
+
@store = store
|
|
24
|
+
@claimables = claimables
|
|
25
|
+
@clock = clock
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call
|
|
29
|
+
return false if @session.nil? || @owner.nil?
|
|
30
|
+
return false if @session.converted?
|
|
31
|
+
|
|
32
|
+
claimed = false
|
|
33
|
+
@store.transaction do
|
|
34
|
+
current = @store.lock(@session.id)
|
|
35
|
+
break if current.nil? || current.converted?
|
|
36
|
+
|
|
37
|
+
@claimables.each_value { |handler| handler.call(current, @owner) }
|
|
38
|
+
current.claimed_by!(@owner)
|
|
39
|
+
@store.update(current)
|
|
40
|
+
|
|
41
|
+
# Reflect the claim on the caller's object so it sees the new state.
|
|
42
|
+
@session.claimed_by!(@owner)
|
|
43
|
+
claimed = true
|
|
44
|
+
end
|
|
45
|
+
claimed
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Guests
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when a token must be signed or verified but no secret is
|
|
8
|
+
# configured.
|
|
9
|
+
class MissingSecret < Error
|
|
10
|
+
def initialize(message = "Ask::Guests.secret is not configured. Set it in Ask::Guests.configure.")
|
|
11
|
+
super
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Guests
|
|
5
|
+
module Rails
|
|
6
|
+
# Claims the visitor's guest records right after they authenticate.
|
|
7
|
+
#
|
|
8
|
+
# class Users::RegistrationsController < Devise::RegistrationsController
|
|
9
|
+
# include Ask::Guests::Rails::ClaimOnAuthentication
|
|
10
|
+
#
|
|
11
|
+
# after_action :claim_guest_session, only: :create
|
|
12
|
+
#
|
|
13
|
+
# private
|
|
14
|
+
#
|
|
15
|
+
# def guest_claim_owner
|
|
16
|
+
# current_user.account_users.first # or wherever records should land
|
|
17
|
+
# end
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# It is a no-op when there is no unclaimed guest cookie. On success it
|
|
21
|
+
# flashes the configured notice, deletes the guest cookie, and lets the
|
|
22
|
+
# authentication flow continue.
|
|
23
|
+
module ClaimOnAuthentication
|
|
24
|
+
extend ActiveSupport::Concern
|
|
25
|
+
include Ask::Guests::Rails::Controller
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def claim_guest_session
|
|
30
|
+
session = resume_guest_session
|
|
31
|
+
return if session.nil? || session.converted?
|
|
32
|
+
|
|
33
|
+
# The host defines #guest_claim_owner (often on a shared auth
|
|
34
|
+
# concern); we only check for it so this module never shadows it.
|
|
35
|
+
unless respond_to?(:guest_claim_owner, true)
|
|
36
|
+
raise NotImplementedError,
|
|
37
|
+
"#{self.class} must implement #guest_claim_owner to use ClaimOnAuthentication"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
owner = guest_claim_owner
|
|
41
|
+
return if owner.nil?
|
|
42
|
+
|
|
43
|
+
if Guests::Claim.new(session: session, owner: owner).call
|
|
44
|
+
flash[:notice] ||= Guests.configuration.claim_notice
|
|
45
|
+
end_guest_session
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Guests
|
|
5
|
+
module Rails
|
|
6
|
+
# Controller support for guest access.
|
|
7
|
+
#
|
|
8
|
+
# class Kawifiles::WorkRequestsController < ApplicationController
|
|
9
|
+
# include Ask::Guests::Rails::Controller
|
|
10
|
+
#
|
|
11
|
+
# allow_guest_access
|
|
12
|
+
# before_action :require_visitor
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# +allow_guest_access+ resumes the visitor's guest session from its
|
|
16
|
+
# signed cookie on every request. +require_visitor+ guarantees an
|
|
17
|
+
# actor: an authenticated user when your controller reports one
|
|
18
|
+
# (it responds to +user_signed_in?+ or +signed_in?+), otherwise a
|
|
19
|
+
# guest session, created on the spot.
|
|
20
|
+
#
|
|
21
|
+
# The current session is available to views through +guest_session?+
|
|
22
|
+
# and +current_guest_session+ (registered as helper methods in Rails).
|
|
23
|
+
module Controller
|
|
24
|
+
extend ActiveSupport::Concern
|
|
25
|
+
|
|
26
|
+
included do
|
|
27
|
+
if respond_to?(:helper_method)
|
|
28
|
+
helper_method :guest_session?, :current_guest_session
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class_methods do
|
|
33
|
+
# Resume the guest session from the cookie for the actions this is
|
|
34
|
+
# applied to. Pair with #require_visitor on guest-capable actions.
|
|
35
|
+
def allow_guest_access(**options)
|
|
36
|
+
before_action :resume_guest_session, **options
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def current_guest_session
|
|
41
|
+
@current_guest_session
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def guest_session?
|
|
45
|
+
!current_guest_session.nil?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# Guarantees an actor for the action: a signed-in user when the
|
|
51
|
+
# controller has one, otherwise a guest session.
|
|
52
|
+
def require_visitor
|
|
53
|
+
return if visitor_authenticated?
|
|
54
|
+
|
|
55
|
+
ensure_guest_session
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def ensure_guest_session
|
|
59
|
+
resume_guest_session || start_guest_session!
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Returns the session from the signed cookie, or nil. Torn or
|
|
63
|
+
# tampered cookies and swept sessions resolve to nil — the next
|
|
64
|
+
# require_visitor mints a fresh session.
|
|
65
|
+
def resume_guest_session
|
|
66
|
+
return @current_guest_session if @current_guest_session
|
|
67
|
+
|
|
68
|
+
token = cookies.signed[Guests.configuration.cookie_name]
|
|
69
|
+
id = Guests::Token.verify(token, secret: Guests.secret)
|
|
70
|
+
return nil if id.nil?
|
|
71
|
+
|
|
72
|
+
session = Guests.store.find(id)
|
|
73
|
+
return nil if session.nil?
|
|
74
|
+
|
|
75
|
+
touch_guest_session(session)
|
|
76
|
+
@current_guest_session = session
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def start_guest_session!
|
|
80
|
+
session = Guests.store.create(metadata: guest_request_metadata)
|
|
81
|
+
token = Guests::Token.sign(session.id, secret: Guests.secret)
|
|
82
|
+
cookies.signed.permanent[Guests.configuration.cookie_name] = {
|
|
83
|
+
value: token,
|
|
84
|
+
httponly: true,
|
|
85
|
+
same_site: :lax
|
|
86
|
+
}
|
|
87
|
+
@current_guest_session = session
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Retires the cookie. The converted session row stays for audit;
|
|
91
|
+
# a later anonymous visit starts a clean session.
|
|
92
|
+
def end_guest_session
|
|
93
|
+
cookies.delete(Guests.configuration.cookie_name)
|
|
94
|
+
@current_guest_session = nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Records activity, throttled to once per touch interval so ordinary
|
|
98
|
+
# page views don't write on every request.
|
|
99
|
+
def touch_guest_session(session)
|
|
100
|
+
now = Guests.configuration.now
|
|
101
|
+
last = session.last_seen_at
|
|
102
|
+
return session if last && (now - last) < Guests.configuration.touch_interval
|
|
103
|
+
|
|
104
|
+
session.touch_seen!(now)
|
|
105
|
+
Guests.store.update(session)
|
|
106
|
+
session
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def visitor_authenticated?
|
|
110
|
+
if respond_to?(:user_signed_in?, true)
|
|
111
|
+
user_signed_in?
|
|
112
|
+
elsif respond_to?(:signed_in?, true)
|
|
113
|
+
signed_in?
|
|
114
|
+
else
|
|
115
|
+
false
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def guest_request_metadata
|
|
120
|
+
metadata = {}
|
|
121
|
+
return metadata unless respond_to?(:request, true) && request
|
|
122
|
+
|
|
123
|
+
metadata[:ip_address] = request.remote_ip if request.respond_to?(:remote_ip)
|
|
124
|
+
metadata[:user_agent] = request.user_agent if request.respond_to?(:user_agent)
|
|
125
|
+
metadata
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_job"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module Guests
|
|
7
|
+
module Rails
|
|
8
|
+
# Destroys stale unclaimed guest sessions on a schedule. Wire it into
|
|
9
|
+
# your recurring jobs config (Solid Queue, Sidekiq, etc.):
|
|
10
|
+
#
|
|
11
|
+
# production:
|
|
12
|
+
# guests_sweep:
|
|
13
|
+
# class: "Ask::Guests::Rails::SweepJob"
|
|
14
|
+
# schedule: every day at 4am
|
|
15
|
+
class SweepJob < ActiveJob::Base
|
|
16
|
+
queue_as { Ask::Guests.configuration.sweep_queue }
|
|
17
|
+
|
|
18
|
+
def perform
|
|
19
|
+
Ask::Guests.sweep.call
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Guests
|
|
5
|
+
# A guest session value object: identity, lifecycle timestamps, the
|
|
6
|
+
# owner it was claimed by, and the counters used for usage metering.
|
|
7
|
+
#
|
|
8
|
+
# Stores return these objects (never raw rows); adapters translate
|
|
9
|
+
# between the value object and their persistence layer.
|
|
10
|
+
class Session
|
|
11
|
+
attr_reader :id, :created_at, :last_seen_at, :converted_at,
|
|
12
|
+
:owner_type, :owner_id, :counters, :metadata
|
|
13
|
+
|
|
14
|
+
def initialize(id:, created_at: nil, last_seen_at: nil, converted_at: nil,
|
|
15
|
+
owner_type: nil, owner_id: nil, counters: {}, metadata: {})
|
|
16
|
+
@id = id
|
|
17
|
+
@created_at = created_at
|
|
18
|
+
@last_seen_at = last_seen_at
|
|
19
|
+
@converted_at = converted_at
|
|
20
|
+
@owner_type = owner_type
|
|
21
|
+
@owner_id = owner_id
|
|
22
|
+
@counters = stringify(counters)
|
|
23
|
+
@metadata = stringify(metadata)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def converted?
|
|
27
|
+
!@converted_at.nil?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def unclaimed?
|
|
31
|
+
!converted?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Marks the session as claimed by +owner+ (any object responding to
|
|
35
|
+
# +id+; polymorphic name used when available). The caller persists the
|
|
36
|
+
# session through the store.
|
|
37
|
+
def claimed_by!(owner)
|
|
38
|
+
@owner_type = owner_type_for(owner)
|
|
39
|
+
@owner_id = owner.id
|
|
40
|
+
@converted_at = @converted_at || Guests.configuration.now
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def touch_seen!(at = Guests.configuration.now)
|
|
45
|
+
@last_seen_at = at
|
|
46
|
+
self
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Raw counter value for +name+ on +date+ ("YYYY-MM-DD"). Counters roll
|
|
50
|
+
# over daily: a value recorded on another day reads as 0.
|
|
51
|
+
def counter_value(name, date)
|
|
52
|
+
counter = @counters[name.to_s]
|
|
53
|
+
return 0 unless counter.is_a?(Hash)
|
|
54
|
+
return 0 unless counter["on"] == date.to_s
|
|
55
|
+
|
|
56
|
+
counter["count"].to_i
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def set_counter!(name, count, date)
|
|
60
|
+
@counters[name.to_s] = {"count" => count.to_i, "on" => date.to_s}
|
|
61
|
+
self
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def to_h
|
|
65
|
+
{
|
|
66
|
+
id: @id,
|
|
67
|
+
created_at: @created_at,
|
|
68
|
+
last_seen_at: @last_seen_at,
|
|
69
|
+
converted_at: @converted_at,
|
|
70
|
+
owner_type: @owner_type,
|
|
71
|
+
owner_id: @owner_id,
|
|
72
|
+
counters: @counters,
|
|
73
|
+
metadata: @metadata
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def ==(other)
|
|
78
|
+
other.is_a?(Session) && other.id.to_s == id.to_s
|
|
79
|
+
end
|
|
80
|
+
alias_method :eql?, :==
|
|
81
|
+
|
|
82
|
+
def hash
|
|
83
|
+
id.to_s.hash
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def owner_type_for(owner)
|
|
89
|
+
if owner.respond_to?(:class) && owner.class.respond_to?(:polymorphic_name)
|
|
90
|
+
owner.class.polymorphic_name
|
|
91
|
+
else
|
|
92
|
+
owner.class.name
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def stringify(hash)
|
|
97
|
+
(hash || {}).each_with_object({}) do |(key, value), result|
|
|
98
|
+
result[key.to_s] = value
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module Guests
|
|
7
|
+
module Stores
|
|
8
|
+
# Persistence port for guest sessions. Implement this to plug in any
|
|
9
|
+
# backend (ActiveRecord ships in ask-guests/active_record), or use the
|
|
10
|
+
# in-memory store for tests and single-process apps.
|
|
11
|
+
#
|
|
12
|
+
# Implementations must return {Guests::Session} value objects and treat
|
|
13
|
+
# ids as opaque strings.
|
|
14
|
+
class Base
|
|
15
|
+
# @param attributes [Hash] initial session attributes
|
|
16
|
+
# @return [Session]
|
|
17
|
+
def create(attributes = {})
|
|
18
|
+
raise NotImplementedError
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @param id [String, Object]
|
|
22
|
+
# @return [Session, nil]
|
|
23
|
+
def find(id)
|
|
24
|
+
raise NotImplementedError
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Persists a session returned by {#create}/{#find} after mutation.
|
|
28
|
+
# @param session [Session]
|
|
29
|
+
# @return [Session]
|
|
30
|
+
def update(session)
|
|
31
|
+
raise NotImplementedError
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param session [Session]
|
|
35
|
+
def destroy(session)
|
|
36
|
+
raise NotImplementedError
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Destroys unclaimed sessions last seen (or created) before the
|
|
40
|
+
# cutoff, along with anything they own.
|
|
41
|
+
# @param before [Time]
|
|
42
|
+
# @return [Integer] number of sessions destroyed
|
|
43
|
+
def sweep_stale(before:)
|
|
44
|
+
raise NotImplementedError
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Re-reads the session under a lock when the backend supports row
|
|
48
|
+
# locking. Defaults to a plain read; override for race-safe claiming.
|
|
49
|
+
# @return [Session, nil]
|
|
50
|
+
def lock(id)
|
|
51
|
+
find(id)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Runs the block in a transaction when supported.
|
|
55
|
+
def transaction
|
|
56
|
+
yield
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def supports_transactions?
|
|
60
|
+
false
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module Guests
|
|
7
|
+
module Stores
|
|
8
|
+
# In-memory session store. Useful for tests and single-process apps;
|
|
9
|
+
# sessions do not survive process restarts.
|
|
10
|
+
class Memory < Base
|
|
11
|
+
def initialize
|
|
12
|
+
@sessions = {}
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def create(attributes = {})
|
|
17
|
+
attributes = attributes.transform_keys(&:to_sym)
|
|
18
|
+
id = (attributes.delete(:id) || SecureRandom.uuid).to_s
|
|
19
|
+
now = Guests.configuration.now
|
|
20
|
+
|
|
21
|
+
session = Session.new(
|
|
22
|
+
id: id,
|
|
23
|
+
created_at: now,
|
|
24
|
+
last_seen_at: now,
|
|
25
|
+
**attributes
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
@mutex.synchronize { @sessions[id] = session }
|
|
29
|
+
session
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def find(id)
|
|
33
|
+
return nil if id.nil?
|
|
34
|
+
|
|
35
|
+
@mutex.synchronize { @sessions[id.to_s]&.dup }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def update(session)
|
|
39
|
+
@mutex.synchronize { @sessions[session.id.to_s] = session }
|
|
40
|
+
session
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def destroy(session)
|
|
44
|
+
@mutex.synchronize { @sessions.delete(session.id.to_s) }
|
|
45
|
+
session
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def sweep_stale(before:)
|
|
49
|
+
stale = @mutex.synchronize do
|
|
50
|
+
@sessions.values.select { |s| s.unclaimed? && last_activity(s) < before }
|
|
51
|
+
end
|
|
52
|
+
stale.each { |session| destroy(session) }
|
|
53
|
+
stale.size
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Test helper: number of stored sessions.
|
|
57
|
+
def size
|
|
58
|
+
@mutex.synchronize { @sessions.size }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def last_activity(session)
|
|
64
|
+
session.last_seen_at || session.created_at || Time.at(0)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Guests
|
|
5
|
+
# Deletes unclaimed guest sessions (and, via the store, everything they
|
|
6
|
+
# own) once they have been inactive for the configured retention period.
|
|
7
|
+
# Keeps anonymous traffic from accumulating storage and personal data
|
|
8
|
+
# forever.
|
|
9
|
+
#
|
|
10
|
+
# Ask::Guests.sweep.call # => number of sessions destroyed
|
|
11
|
+
class Sweep
|
|
12
|
+
def initialize(store: Guests.store, retention: Guests.configuration.retention, clock: Guests.configuration.clock)
|
|
13
|
+
@store = store
|
|
14
|
+
@retention = retention
|
|
15
|
+
@clock = clock
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
@store.sweep_stale(before: @clock.call - @retention)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Cutoff time before which sessions are stale.
|
|
23
|
+
def cutoff
|
|
24
|
+
@clock.call - @retention
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
module Ask
|
|
7
|
+
module Guests
|
|
8
|
+
# Signs and verifies guest session tokens with HMAC-SHA256.
|
|
9
|
+
#
|
|
10
|
+
# A token embeds only the session id — a random opaque value assigned by
|
|
11
|
+
# the store — and a signature. Sessions are looked up in the store by id,
|
|
12
|
+
# so a token for a swept or converted session simply resolves to nothing,
|
|
13
|
+
# and there is nothing to enumerate without the secret.
|
|
14
|
+
#
|
|
15
|
+
# token = Token.sign(session.id, secret: "s3cr3t")
|
|
16
|
+
# Token.verify(token, secret: "s3cr3t") # => "the-session-id"
|
|
17
|
+
# Token.verify(tampered, secret: "s3cr3t") # => nil
|
|
18
|
+
module Token
|
|
19
|
+
SEPARATOR = "--"
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
def sign(id, secret: Guests.secret)
|
|
23
|
+
raise MissingSecret if secret.nil? || secret.empty?
|
|
24
|
+
|
|
25
|
+
payload = Base64.urlsafe_encode64(id.to_s, padding: false)
|
|
26
|
+
"#{payload}#{SEPARATOR}#{digest(payload, secret)}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Returns the session id for a valid token, nil for anything else
|
|
30
|
+
# (nil, empty, malformed, or wrong signature). Never raises for bad
|
|
31
|
+
# input — callers treat nil as "no session".
|
|
32
|
+
def verify(token, secret: Guests.secret)
|
|
33
|
+
return nil if token.nil? || secret.nil? || secret.empty?
|
|
34
|
+
return nil if token.to_s.empty?
|
|
35
|
+
|
|
36
|
+
payload, separator, signature = token.to_s.rpartition(SEPARATOR)
|
|
37
|
+
return nil if separator.empty? || payload.empty? || signature.empty?
|
|
38
|
+
return nil unless secure_compare(signature, digest(payload, secret))
|
|
39
|
+
|
|
40
|
+
Base64.urlsafe_decode64(payload)
|
|
41
|
+
rescue ArgumentError
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def digest(payload, secret)
|
|
48
|
+
OpenSSL::HMAC.hexdigest("SHA256", secret.to_s, payload)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def secure_compare(a, b)
|
|
52
|
+
return false unless a.bytesize == b.bytesize
|
|
53
|
+
|
|
54
|
+
OpenSSL.fixed_length_secure_compare(a, b)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|