diff --git a/Project.toml b/Project.toml index 6087bef..e48b9c4 100644 --- a/Project.toml +++ b/Project.toml @@ -1,17 +1,25 @@ name = "GenieSessionFileSession" uuid = "5c4fdc26-39e3-47cf-9034-e533e09961c2" -authors = ["Adrian Salceanu and contributors"] version = "1.1.0" +authors = ["Adrian Salceanu and contributors"] [deps] +Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" +Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" Genie = "c43c736e-a2d1-11e8-161f-af95117fbd1e" GenieSession = "03cc5b98-4f21-4eb6-99f2-22eced81f962" +JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" Logging = "56ddb016-857b-54e1-b83d-db4d58db5568" Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b" [compat] +Base64 = "1.11.0" +Dates = "1.11.0" Genie = "5" GenieSession = "1" +JSON = "1.4.0" +Logging = "1.11.0" +Serialization = "1.11.0" julia = "1.6" [extras] diff --git a/src/GenieSessionFileSession.jl b/src/GenieSessionFileSession.jl index e1543d3..85b8525 100644 --- a/src/GenieSessionFileSession.jl +++ b/src/GenieSessionFileSession.jl @@ -1,131 +1,304 @@ module GenieSessionFileSession +""" + GenieSessionFileSession + +Persist and load web session data as encrypted files on disk. + +Creates the sessions folder if missing, writes sessions atomically, +and enforces per-session expiration via the `Session.expires_at` field +managed by `GenieSession`. + +See also +- [`write_session(session::GenieSession.Session)`](@ref) +- [`read(session_id::String)`](@ref) +- [`GenieSession.persist(req,res,params)`](@ref) +- [`GenieSession.load(session_id)`](@ref) +""" + import Genie, GenieSession -import Serialization, Logging +import Dates, Logging, Serialization, Base64 + +const SESSIONS_PATH=Ref{String}(Genie.Configuration.isprod() ? + "sessions" : mktempdir()) -const SESSIONS_PATH = Ref{String}(Genie.Configuration.isprod() ? "sessions" : mktempdir()) +""" + sessions_path(path::String) +Set the directory where session files are stored. +All subsequent reads/writes will use this path. +""" function sessions_path(path::String) - SESSIONS_PATH[] = normpath(path) |> abspath + SESSIONS_PATH[]=normpath(path)|>abspath end -function sessions_path() + +""" + sessions_path()::String + +Return the current sessions directory. +""" +function sessions_path()::String SESSIONS_PATH[] end +""" + setup_folder()::Nothing -function setup_folder() +Ensure that the sessions directory exists on disk, creating it if needed. +""" +function setup_folder()::Nothing if ! isdir(sessions_path()) @debug "Attempting to create sessions folder at $(sessions_path())" - mkpath(sessions_path()) end + + nothing end +""" + __init__()::Nothing -function __init__() +Module initializer: called once when GenieSessionFileSession is loaded. +Creates the sessions folder if missing. +""" +function __init__()::Nothing setup_folder() end - """ - write(session::GenieSession.Session) :: GenieSession.Session + write(session::GenieSession.Session)::GenieSession.Session -Persists the `Session` object to the file system, using the configured sessions folder and returns it. +Use a high-level interface for persisting a `Session`. +It ensures the folder exists, then calls `write_session`. +Last it returns the session unchanged. """ -function write(session::GenieSession.Session) :: GenieSession.Session +function write(session::GenieSession.Session)::GenieSession.Session try + setup_folder() write_session(session) - - return session + session catch ex @error "Failed to store session data" @error ex + + try + @error "Resetting session" + session=GenieSession.Session(GenieSession.id()) + + Genie.Cookies.set!( + Genie.Router.params(Genie.Router.PARAMS_RESPONSE_KEY), + GenieSession.session_key_name(), + session.id, + GenieSession.session_options() + ) + + write_session(session) + Genie.Router.params(GenieSession.PARAMS_SESSION_KEY, session) + + session + catch ex2 + @error "Failed to regenerate and store session data. Giving up." + @error ex2 + session + end end +end - try - @error "Resetting session" +""" + write_session(session::GenieSession.Session)::GenieSession.Session - session = GenieSession.Session(GenieSession.id()) - Genie.Cookies.set!(Genie.Router.params(Genie.Router.PARAMS_RESPONSE_KEY), GenieSession.session_key_name(), session.id, GenieSession.session_options()) - write_session(session) - Genie.Router.params(GenieSession.PARAMS_SESSION_KEY, session) +Serialize the entire session object to a binary blob using Julia’s Serialization, +Base64-encode that blob, encrypt the resulting string (producing a hex token), +and atomically write it to disk at `sessions_path()/session.id`. - return session - catch ex - @error "Failed to regenerate and store session data. Giving up." - @error ex - end +After writing, the function returns the same session object. - session -end +See also [`read(session_id::String)`](@ref), +[`GenieSession.persist`](@ref) and [`Genie.Encryption.encrypt`](@ref). +""" +function write_session(session::GenieSession.Session)::GenieSession.Session + setup_folder() + + # 1) serialize to bytes + buf=IOBuffer() + Serialization.serialize(buf, session) + raw=take!(buf) # Vector{UInt8} + # 2) base64 → String + b64=Base64.base64encode(raw) -function write_session(session::GenieSession.Session) - isdir(sessions_path()) || mkpath(sessions_path()) + # 3) encrypt → hex string + token=Genie.Encryption.encrypt(b64) # String (hex) - open(joinpath(sessions_path(), session.id), "w") do io - Serialization.serialize(io, session) + # 4) atomic write + f=joinpath(sessions_path(), session.id) + mkpath(dirname(f)) + tmp=f*".tmp" + + open(tmp, "w") do io + Base.write(io, token) end -end + mv(tmp, f; force=true) -""" - read(session_id::Union{String,Symbol}) :: Union{Nothing,GenieSession.Session} - read(session::GenieSession.Session) :: Union{Nothing,GenieSession.Session} + session +end -Attempts to read from file the session object serialized as `session_id`. """ -function read(session_id::String) :: Union{Nothing,GenieSession.Session} - try - isfile(joinpath(sessions_path(), session_id)) || return nothing + read(session_id::String)::Union{Nothing,GenieSession.Session} + +Load a session by ID as follows: +1. Read the on-disk hex token from the file located at + `sessions_path()/session_id`. +2. Decrypt the token to recover the Base64-encoded session data. +3. Base64-decode the result to obtain the raw serialized bytes. +4. Deserialize those bytes to reconstruct the original `Session` object. +5. Enforce the session’s TTL: if the current time exceeds `session.expires_at`, + the session is considered expired and the function returns `nothing`. + +See also [`write_session(session::GenieSession.Session)`](@ref), +[`GenieSession.load`](@ref) and [`Genie.Encryption.decrypt`](@ref). +""" +function read(session_id::String)::Union{Nothing,GenieSession.Session} + f=joinpath(sessions_path(), session_id) + ! isfile(f) && return nothing + + # 1) read the encrypted hex string + token=try + Base.read(f, String) catch ex @debug "Failed to read session data" @debug ex + return nothing + end + # 2) decrypt → base64 + b64=try + Genie.Encryption.decrypt(token) + catch ex + @debug "Failed to decrypt session $session_id" + @debug ex return nothing end - try - open(joinpath(sessions_path(), session_id), "r") do (io) - Serialization.deserialize(io) - end + isempty(b64) && return nothing + + # 3) base64 → raw bytes + raw=try + Base64.base64decode(b64) catch ex - @debug "Can't read session" - # @error ex + @debug "Failed to base64-decode session $session_id" + @debug ex + return nothing + end + + # 4) deserialize → Session + session=try + io=IOBuffer(raw) + Serialization.deserialize(io) + catch ex + @debug "Failed to deserialize session $session_id" + @debug ex + return nothing + end + + # ensure it is our Session type + isa(session, GenieSession.Session) || return nothing + + # 5) TTL check: respect Session.expires_at if present + if Base.hasfield(GenieSession.Session, :expires_at) + if Dates.now()>session.expires_at + @debug "Session $session_id expired at $(session.expires_at)" + return nothing + end end + + session end -function read(session::GenieSession.Session) :: Union{Nothing,GenieSession.Session} +""" + read(session::GenieSession.Session)::Union{GenieSession.Session,Nothing} + +Read a session from disk using its `session.id` (helper overload). +""" +function read(session::GenieSession.Session)::Union{Nothing,GenieSession.Session} read(session.id) end -#===# -# IMPLEMENTATION - """ - persist(s::Session) :: Session + GenieSession.persist(req::GenieSession.HTTP.Request, + res::GenieSession.HTTP.Response, + params::Dict{Symbol,Any}) + +Persist the session stored in `params[:SESSION]` to disk, then return +the unchanged `(req, res, params)` tuple. -Generic method for persisting session data - delegates to the underlying `SessionAdapter`. +See also [`write_session(session)`](@ref), +[`GenieSession.persist(s::Session)`](@ref) and [`GenieSession.load(session_id)`](@ref). """ -function GenieSession.persist(req::GenieSession.HTTP.Request, res::GenieSession.HTTP.Response, params::Dict{Symbol,Any}) :: Tuple{GenieSession.HTTP.Request,GenieSession.HTTP.Response,Dict{Symbol,Any}} +function GenieSession.persist( + req::GenieSession.HTTP.Request, + res::GenieSession.HTTP.Response, + params::Dict{Symbol,Any} +)::Tuple{GenieSession.HTTP.Request,GenieSession.HTTP.Response,Dict{Symbol,Any}} write(params[GenieSession.PARAMS_SESSION_KEY]) - req, res, params end -function GenieSession.persist(s::GenieSession.Session) :: GenieSession.Session + +""" + GenieSession.persist(s::GenieSession.Session)::GenieSession.Session + +Persist the in-memory session `s` to disk using the configured session adapter +and return the same `Session` object. + +See also [`write_session(session)`](@ref) and +[`GenieSession.load(session_id)`](@ref). +""" +function GenieSession.persist(s::GenieSession.Session)::GenieSession.Session write(s) end +""" + GenieSession.load(session_id::String)::GenieSession.Session + +Load and decrypt the session identified by `session_id`. If no valid +session file exists or it has expired, return a fresh `Session(session_id)`. +See also [`read(session_id)`](@ref) and +[`GenieSession.persist(req,res,params)`](@ref). """ - load(session_id::String) :: Session +function GenieSession.load(session_id::String)::GenieSession.Session + session=read(session_id) + session===nothing ? GenieSession.Session(session_id) : session +end -Loads session data from persistent storage. """ -function GenieSession.load(session_id::String) :: GenieSession.Session - session = read(session_id) + rotate_file_session!( + sess::GenieSession.Session, + req::GenieSession.HTTP.Request, + res::GenieSession.HTTP.Response; + options=GenieSession.session_options() + )::GenieSession.Session + +Rotate the session ID using `GenieSession.rotate!`, then delete the old +on-disk session file managed by this adapter. Use this helper when you +know you are using file-backed sessions and want to clean up the old +session store on rotation. +""" +function rotate_file_session!( + sess::GenieSession.Session, + req::GenieSession.HTTP.Request, + res::GenieSession.HTTP.Response; + options::Dict{String,Any}=GenieSession.session_options() +)::GenieSession.Session + oldid=sess.id + + GenieSession.rotate!(sess, req, res; options=options) + + f=joinpath(sessions_path(), oldid) + isfile(f) && rm(f; force=true) - session === nothing ? GenieSession.Session(session_id) : (session) + sess end end