mirror of
https://gitea.invidious.io/iv-org/shard-kemal.git
synced 2024-08-15 00:53:36 +00:00
Merge pull request #170 from mperham/master
Implement basic in-memory session store
This commit is contained in:
commit
7e49237468
4 changed files with 181 additions and 0 deletions
58
spec/session_spec.cr
Normal file
58
spec/session_spec.cr
Normal file
|
@ -0,0 +1,58 @@
|
|||
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"]?
|
||||
sess.delete("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
|
||||
|
|
116
src/kemal/session.cr
Normal file
116
src/kemal/session.cr
Normal file
|
@ -0,0 +1,116 @@
|
|||
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.
|
||||
#
|
||||
# Kemal handlers can access the session like so:
|
||||
#
|
||||
# get("/") do |env|
|
||||
# env.session["abc"] = "xyz"
|
||||
# uid = env.session["user_id"]?
|
||||
# end
|
||||
#
|
||||
# Note that only String values are allowed.
|
||||
#
|
||||
# Sessions are pruned hourly after 48 hours of inactivity.
|
||||
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
|
||||
|
||||
def delete(key : String)
|
||||
@last_access_at = Time.now.epoch_ms
|
||||
@store.delete(key)
|
||||
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 delete(key : String)
|
||||
STORE[@id]?.try &.delete(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…
Reference in a new issue