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.not_nil!.tls = config.ssl | ||||
| 
 | ||||
|     Kemal::Sessions.run_reaper! | ||||
| 
 | ||||
|     unless Kemal.config.error_handlers.has_key?(404) | ||||
|       error 404 do |env| | ||||
|         render_404 | ||||
|  |  | |||
|  | @ -19,5 +19,10 @@ class HTTP::Server | |||
|     def route_defined? | ||||
|       route_lookup.found? | ||||
|     end | ||||
| 
 | ||||
|     def session | ||||
|       @session ||= Kemal::Sessions.new(self) | ||||
|       @session.not_nil! | ||||
|     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