mirror of
				https://gitea.invidious.io/iv-org/shard-kemal.git
				synced 2024-08-15 00:53:36 +00:00 
			
		
		
		
	Implement basic in-memory session store
Sessions are stored in a non-persistent Hash. Only String values are allowed. A reaper fiber regularly removes any sessions which expire due to inactivity.
This commit is contained in:
		
							parent
							
								
									0c46bd65da
								
							
						
					
					
						commit
						94db0c8cb8
					
				
					 4 changed files with 160 additions and 0 deletions
				
			
		
							
								
								
									
										57
									
								
								spec/session_spec.cr
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								spec/session_spec.cr
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,57 @@ | ||||||
|  | require "./spec_helper" | ||||||
|  | 
 | ||||||
|  | describe "Session" do | ||||||
|  |   it "can establish a session" do | ||||||
|  |     sid = nil | ||||||
|  |     existing = nil | ||||||
|  |     get "/" do |env| | ||||||
|  |       sess = env.session | ||||||
|  |       existing = sess["token"]? | ||||||
|  |       sid = sess.id | ||||||
|  |       sess["token"] = "abc" | ||||||
|  |       "Hello" | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     # make first request without any cookies/session | ||||||
|  |     request = HTTP::Request.new("GET", "/") | ||||||
|  |     response = call_request_on_app(request) | ||||||
|  |     # dup headers due to Crystal#2920 | ||||||
|  |     headers = response.headers.dup | ||||||
|  | 
 | ||||||
|  |     # verify we got a cookie and session ID | ||||||
|  |     cookie = response.headers["Set-Cookie"]? | ||||||
|  |     cookie.should_not be_nil | ||||||
|  |     response.cookies[Kemal::Sessions::NAME].value.should eq(sid) | ||||||
|  |     lastsid = sid | ||||||
|  |     existing.should be_nil | ||||||
|  | 
 | ||||||
|  |     # make second request with cookies to get session | ||||||
|  |     request = HTTP::Request.new("GET", "/", headers) | ||||||
|  |     response = call_request_on_app(request) | ||||||
|  | 
 | ||||||
|  |     # verify we got cookies and we could see values set | ||||||
|  |     # in the previous request | ||||||
|  |     cookie2 = response.headers["Set-Cookie"]? | ||||||
|  |     cookie2.should_not be_nil | ||||||
|  |     cookie2.should eq(cookie) | ||||||
|  |     response.cookies[Kemal::Sessions::NAME].value.should eq(lastsid) | ||||||
|  |     existing.should eq("abc") | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   it "can prune old sessions" do | ||||||
|  |     s = Kemal::Sessions::STORE | ||||||
|  |     s.clear | ||||||
|  | 
 | ||||||
|  |     Kemal::Sessions.prune! | ||||||
|  | 
 | ||||||
|  |     id = "foo" | ||||||
|  |     s[id] = Kemal::Sessions::Session.new(id) | ||||||
|  |     s.size.should eq(1) | ||||||
|  |     Kemal::Sessions.prune! | ||||||
|  |     s.size.should eq(1) | ||||||
|  | 
 | ||||||
|  |     s[id].last_access_at = (Time.now - 1.week).epoch_ms | ||||||
|  |     Kemal::Sessions.prune! | ||||||
|  |     s.size.should eq(0) | ||||||
|  |   end | ||||||
|  | end | ||||||
|  | @ -12,6 +12,8 @@ module Kemal | ||||||
|     config.server = HTTP::Server.new(config.host_binding.not_nil!, config.port, config.handlers) |     config.server = HTTP::Server.new(config.host_binding.not_nil!, config.port, config.handlers) | ||||||
|     config.server.not_nil!.tls = config.ssl |     config.server.not_nil!.tls = config.ssl | ||||||
| 
 | 
 | ||||||
|  |     Kemal::Sessions.run_reaper! | ||||||
|  | 
 | ||||||
|     unless Kemal.config.error_handlers.has_key?(404) |     unless Kemal.config.error_handlers.has_key?(404) | ||||||
|       error 404 do |env| |       error 404 do |env| | ||||||
|         render_404 |         render_404 | ||||||
|  |  | ||||||
|  | @ -19,5 +19,10 @@ class HTTP::Server | ||||||
|     def route_defined? |     def route_defined? | ||||||
|       route_lookup.found? |       route_lookup.found? | ||||||
|     end |     end | ||||||
|  | 
 | ||||||
|  |     def session | ||||||
|  |       @session ||= Kemal::Sessions.new(self) | ||||||
|  |       @session.not_nil! | ||||||
|  |     end | ||||||
|   end |   end | ||||||
| end | end | ||||||
|  |  | ||||||
							
								
								
									
										96
									
								
								src/kemal/session.cr
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								src/kemal/session.cr
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,96 @@ | ||||||
|  | require "secure_random" | ||||||
|  | 
 | ||||||
|  | module Kemal | ||||||
|  |   # Kemal's default session is in-memory only and holds simple String values only. | ||||||
|  |   # The client-side cookie stores a random ID. | ||||||
|  |   class Sessions | ||||||
|  |     NAME = "SessionId" | ||||||
|  | 
 | ||||||
|  |     # I hate websites which require daily login so the default | ||||||
|  |     # inactivity timeout is 48 hours. | ||||||
|  |     TTL  = 48.hours | ||||||
|  | 
 | ||||||
|  |     # In-memory, ephemeral datastore only. | ||||||
|  |     # | ||||||
|  |     # Implementing Redis or Memcached as a datastore | ||||||
|  |     # is left as an exercise to another reader. | ||||||
|  |     # | ||||||
|  |     # Note that the only thing we store on the client-side | ||||||
|  |     # is an opaque, random String.  If we actually wanted to | ||||||
|  |     # store any data, we'd need to implement encryption, key | ||||||
|  |     # rotation, tamper-detection and that whole iceberg. | ||||||
|  |     STORE = Hash(String, Session).new | ||||||
|  | 
 | ||||||
|  |     class Session | ||||||
|  |       getter! id : String | ||||||
|  |       property! last_access_at : Int64 | ||||||
|  | 
 | ||||||
|  |       def initialize(@id) | ||||||
|  |         @last_access_at = Time.new.epoch_ms | ||||||
|  |         @store = Hash(String, String).new | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       def [](key : String) | ||||||
|  |         @last_access_at = Time.now.epoch_ms | ||||||
|  |         @store[key] | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       def []?(key : String) | ||||||
|  |         @last_access_at = Time.now.epoch_ms | ||||||
|  |         @store[key]? | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       def []=(key : String, value : String) | ||||||
|  |         @last_access_at = Time.now.epoch_ms | ||||||
|  |         @store[key] = value | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     getter! id : String | ||||||
|  | 
 | ||||||
|  |     def initialize(ctx : HTTP::Server::Context) | ||||||
|  |       id = ctx.request.cookies[NAME]?.try &.value | ||||||
|  |       if id && id.size == 32 | ||||||
|  |         # valid | ||||||
|  |       else | ||||||
|  |         # new or invalid | ||||||
|  |         id = SecureRandom.hex | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       ctx.response.cookies[NAME] = id | ||||||
|  |       @id = id | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     def []=(key : String, value : String) | ||||||
|  |       store = STORE[id]? || begin | ||||||
|  |         STORE[id] = Session.new(id) | ||||||
|  |       end | ||||||
|  |       store[key] = value | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     def [](key : String) | ||||||
|  |       STORE[@id][key] | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     def []?(key : String) | ||||||
|  |       STORE[@id]?.try &.[key]? | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     def self.prune!(before = (Time.now - Kemal::Sessions::TTL).epoch_ms) | ||||||
|  |       Kemal::Sessions::STORE.delete_if { |id, entry| entry.last_access_at < before } | ||||||
|  |       nil | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     # This is an hourly job to prune the in-memory hash of any | ||||||
|  |     # sessions which have expired due to inactivity, otherwise | ||||||
|  |     # we'll have a slow memory leak and possible DDoS vector. | ||||||
|  |     def self.run_reaper! | ||||||
|  |       spawn do | ||||||
|  |         loop do | ||||||
|  |           prune! | ||||||
|  |           sleep 3600 | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue