♻️ Refactor session management

This commit is contained in:
Andrey Antukh 2022-08-04 22:50:02 +02:00
parent 5febd35cfe
commit adbadc8743
4 changed files with 168 additions and 163 deletions

View file

@ -20,7 +20,7 @@
io.lettuce/lettuce-core {:mvn/version "6.1.8.RELEASE"} io.lettuce/lettuce-core {:mvn/version "6.1.8.RELEASE"}
java-http-clj/java-http-clj {:mvn/version "0.4.3"} java-http-clj/java-http-clj {:mvn/version "0.4.3"}
funcool/yetti {:git/tag "v9.3" :git/sha "c6e2d0d" funcool/yetti {:git/tag "v9.8" :git/sha "fbe1d7d"
:git/url "https://github.com/funcool/yetti.git" :git/url "https://github.com/funcool/yetti.git"
:exclusions [org.slf4j/slf4j-api]} :exclusions [org.slf4j/slf4j-api]}

View file

@ -42,8 +42,7 @@
data)) data))
(def defaults (def defaults
{ {:database-uri "postgresql://postgres/penpot"
:database-uri "postgresql://postgres/penpot"
:database-username "penpot" :database-username "penpot"
:database-password "penpot" :database-password "penpot"
@ -101,10 +100,14 @@
(s/def ::blocking-executor-parallelism ::us/integer) (s/def ::blocking-executor-parallelism ::us/integer)
(s/def ::worker-executor-parallelism ::us/integer) (s/def ::worker-executor-parallelism ::us/integer)
(s/def ::authenticated-cookie-domain ::us/string)
(s/def ::authenticated-cookie-name ::us/string)
(s/def ::auth-token-cookie-name ::us/string)
(s/def ::auth-token-cookie-max-age ::dt/duration)
(s/def ::secret-key ::us/string) (s/def ::secret-key ::us/string)
(s/def ::allow-demo-users ::us/boolean) (s/def ::allow-demo-users ::us/boolean)
(s/def ::assets-path ::us/string) (s/def ::assets-path ::us/string)
(s/def ::authenticated-cookie-domain ::us/string)
(s/def ::database-password (s/nilable ::us/string)) (s/def ::database-password (s/nilable ::us/string))
(s/def ::database-uri ::us/string) (s/def ::database-uri ::us/string)
(s/def ::database-username (s/nilable ::us/string)) (s/def ::database-username (s/nilable ::us/string))
@ -140,7 +143,6 @@
(s/def ::http-server-max-multipart-body-size ::us/integer) (s/def ::http-server-max-multipart-body-size ::us/integer)
(s/def ::http-server-io-threads ::us/integer) (s/def ::http-server-io-threads ::us/integer)
(s/def ::http-server-worker-threads ::us/integer) (s/def ::http-server-worker-threads ::us/integer)
(s/def ::http-session-idle-max-age ::dt/duration)
(s/def ::http-session-updater-batch-max-age ::dt/duration) (s/def ::http-session-updater-batch-max-age ::dt/duration)
(s/def ::http-session-updater-batch-max-size ::us/integer) (s/def ::http-session-updater-batch-max-size ::us/integer)
(s/def ::initial-project-skey ::us/string) (s/def ::initial-project-skey ::us/string)
@ -206,6 +208,9 @@
::allow-demo-users ::allow-demo-users
::audit-log-archive-uri ::audit-log-archive-uri
::audit-log-gc-max-age ::audit-log-gc-max-age
::auth-token-cookie-name
::auth-token-cookie-max-age
::authenticated-cookie-name
::authenticated-cookie-domain ::authenticated-cookie-domain
::database-password ::database-password
::database-uri ::database-uri
@ -246,7 +251,6 @@
::http-server-max-multipart-body-size ::http-server-max-multipart-body-size
::http-server-io-threads ::http-server-io-threads
::http-server-worker-threads ::http-server-worker-threads
::http-session-idle-max-age
::http-session-updater-batch-max-age ::http-session-updater-batch-max-age
::http-session-updater-batch-max-size ::http-session-updater-batch-max-size
::initial-project-skey ::initial-project-skey

View file

@ -7,33 +7,35 @@
(ns app.http.session (ns app.http.session
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.logging :as l] [app.common.logging :as l]
[app.config :as cfg] [app.config :as cf]
[app.db :as db] [app.db :as db]
[app.db.sql :as sql] [app.db.sql :as sql]
[app.metrics :as mtx]
[app.util.async :as aa]
[app.util.time :as dt] [app.util.time :as dt]
[app.worker :as wrk] [app.worker :as wrk]
[clojure.core.async :as a]
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
[integrant.core :as ig] [integrant.core :as ig]
[promesa.core :as p] [promesa.core :as p]
[promesa.exec :as px] [promesa.exec :as px]
[yetti.request :as yrq])) [yetti.request :as yrq]))
;; A default cookie name for storing the session. We don't allow to configure it. ;; A default cookie name for storing the session.
(def token-cookie-name "auth-token") (def default-auth-token-cookie-name "auth-token")
;; A cookie that we can use to check from other sites of the same domain if a user ;; A cookie that we can use to check from other sites of the same
;; is registered. Is not intended for on premise installations, although nothing ;; domain if a user is authenticated.
;; prevents using it if some one wants to. (def default-authenticated-cookie-name "authenticated")
(def authenticated-cookie-name "authenticated")
;; Default value for cookie max-age
(def default-cookie-max-age (dt/duration {:days 7}))
;; Default age for automatic session renewal
(def default-renewal-max-age (dt/duration {:hours 6}))
(defprotocol ISessionStore (defprotocol ISessionStore
(read-session [store key]) (read-session [store key])
(write-session [store key data]) (write-session [store key data])
(update-session [store data])
(delete-session [store key])) (delete-session [store key]))
(defn- make-database-store (defn- make-database-store
@ -47,18 +49,25 @@
(px/with-dispatch executor (px/with-dispatch executor
(let [profile-id (:profile-id data) (let [profile-id (:profile-id data)
user-agent (:user-agent data) user-agent (:user-agent data)
now (dt/now) created-at (or (:created-at data) (dt/now))
token (tokens :generate {:iss "authentication" token (tokens :generate {:iss "authentication"
:iat now :iat created-at
:uid profile-id}) :uid profile-id})
params {:user-agent user-agent params {:user-agent user-agent
:profile-id profile-id :profile-id profile-id
:created-at now :created-at created-at
:updated-at now :updated-at created-at
:id token}] :id token}]
(db/insert! pool :http-session params) (db/insert! pool :http-session params))))
token)))
(update-session [_ data]
(let [updated-at (dt/now)]
(px/with-dispatch executor
(db/update! pool :http-session
{:updated-at updated-at}
{:id (:id data)})
(assoc data :updated-at updated-at))))
(delete-session [_ token] (delete-session [_ token]
(px/with-dispatch executor (px/with-dispatch executor
@ -76,15 +85,23 @@
(p/do (p/do
(let [profile-id (:profile-id data) (let [profile-id (:profile-id data)
user-agent (:user-agent data) user-agent (:user-agent data)
created-at (or (:created-at data) (dt/now))
token (tokens :generate {:iss "authentication" token (tokens :generate {:iss "authentication"
:iat (dt/now) :iat created-at
:uid profile-id}) :uid profile-id})
params {:user-agent user-agent params {:user-agent user-agent
:created-at created-at
:updated-at created-at
:profile-id profile-id :profile-id profile-id
:id token}] :id token}]
(swap! cache assoc token params) (swap! cache assoc token params)
token))) params)))
(update-session [_ data]
(let [updated-at (dt/now)]
(swap! cache update (:id data) assoc :updated-at updated-at)
(assoc data :updated-at updated-at)))
(delete-session [_ token] (delete-session [_ token]
(p/do (p/do
@ -107,77 +124,123 @@
;; --- IMPL ;; --- IMPL
(defn- create-session! (defn- create-session!
[store request profile-id] [store profile-id user-agent]
(let [params {:user-agent (yrq/get-header request "user-agent") (let [params {:user-agent user-agent
:profile-id profile-id}] :profile-id profile-id}]
(write-session store nil params))) (write-session store nil params)))
(defn- update-session!
[store session]
(update-session store session))
(defn- delete-session! (defn- delete-session!
[store {:keys [cookies] :as request}] [store {:keys [cookies] :as request}]
(when-let [token (get-in cookies [token-cookie-name :value])] (let [name (cf/get :auth-token-cookie-name default-auth-token-cookie-name)]
(delete-session store token))) (when-let [token (get-in cookies [name :value])]
(delete-session store token))))
(defn- retrieve-session (defn- retrieve-session
[store request] [store request]
(when-let [cookie (yrq/get-cookie request token-cookie-name)] (let [cookie-name (cf/get :auth-token-cookie-name default-auth-token-cookie-name)]
(-> (read-session store (:value cookie)) (when-let [cookie (yrq/get-cookie request cookie-name)]
(p/then (fn [session] (read-session store (:value cookie)))))
(when session
{:session-id (:id session)
:profile-id (:profile-id session)}))))))
(defn- add-cookies (defn assign-auth-token-cookie
[response token] [response {token :id updated-at :updated-at}]
(let [cors? (contains? cfg/flags :cors) (let [max-age (cf/get :auth-token-cookie-max-age default-cookie-max-age)
secure? (contains? cfg/flags :secure-session-cookies) created-at (or updated-at (dt/now))
authenticated-cookie-domain (cfg/get :authenticated-cookie-domain)] renewal (dt/plus created-at default-renewal-max-age)
(update response :cookies expires (dt/plus created-at max-age)
(fn [cookies] secure? (contains? cf/flags :secure-session-cookies)
(cond-> cookies cors? (contains? cf/flags :cors)
:always name (cf/get :auth-token-cookie-name default-auth-token-cookie-name)
(assoc token-cookie-name {:path "/" comment (str "Renewal at: " (dt/format-instant renewal :rfc1123))
:http-only true cookie {:path "/"
:value token :http-only true
:same-site (if cors? :none :lax) :expires expires
:secure secure?}) :value token
:comment comment
:same-site (if cors? :none :lax)
:secure secure?}]
(update response :cookies assoc name cookie)))
(some? authenticated-cookie-domain) (defn assign-authenticated-cookie
(assoc authenticated-cookie-name {:domain authenticated-cookie-domain [response {updated-at :updated-at}]
:path "/" (let [max-age (cf/get :auth-token-cookie-max-age default-cookie-max-age)
:value true created-at (or updated-at (dt/now))
:same-site :strict renewal (dt/plus created-at default-renewal-max-age)
:secure secure?})))))) expires (dt/plus created-at max-age)
comment (str "Renewal at: " (dt/format-instant renewal :rfc1123))
secure? (contains? cf/flags :secure-session-cookies)
domain (cf/get :authenticated-cookie-domain)
name (cf/get :authenticated-cookie-name "authenticated")
cookie {:domain domain
:expires expires
:path "/"
:comment comment
:value true
:same-site :strict
:secure secure?}]
(cond-> response
(string? domain)
(update :cookies assoc name cookie))))
(defn- clear-cookies (defn clear-auth-token-cookie
[response] [response]
(let [authenticated-cookie-domain (cfg/get :authenticated-cookie-domain)] (let [name (cf/get :auth-token-cookie-name default-auth-token-cookie-name)]
(assoc response :cookies (update response :cookies assoc name {:path "/" :value "" :max-age -1})))
{token-cookie-name {:path "/"
:value "" (defn- clear-authenticated-cookie
:max-age -1} [response]
authenticated-cookie-name {:domain authenticated-cookie-domain (let [name (cf/get :authenticated-cookie-name default-authenticated-cookie-name)
:path "/" domain (cf/get :authenticated-cookie-domain)]
:value "" (cond-> response
:max-age -1}}))) (string? domain)
(update :cookies assoc name {:domain domain :path "/" :value "" :max-age -1}))))
(defn- make-middleware (defn- make-middleware
[{:keys [::events-ch store] :as cfg}] [{:keys [store] :as cfg}]
{:name :session (letfn [;; Check if time reached for automatic session renewal
:compile (fn [& _] (renew-session? [{:keys [updated-at] :as session}]
(fn [handler] (and (dt/instant? updated-at)
(fn [request respond raise] (let [elapsed (dt/diff updated-at (dt/now))]
(try (neg? (compare default-renewal-max-age elapsed)))))
(-> (retrieve-session store request)
(p/then' #(merge request %)) ;; Wrap respond with session renewal code
(p/finally (fn [request cause] (wrap-respond [respond session]
(if cause (fn [response]
(raise cause) (p/let [session (update-session! store session)]
(do (-> response
(when-let [session-id (:session-id request)] (assign-auth-token-cookie session)
(a/offer! events-ch session-id)) (assign-authenticated-cookie session)
(handler request respond raise)))))) (respond)))))]
(catch Throwable cause
(raise cause))))))}) {:name :session
:compile (fn [& _]
(fn [handler]
(fn [request respond raise]
(try
(-> (retrieve-session store request)
(p/finally (fn [session cause]
(cond
(some? cause)
(raise cause)
(nil? session)
(handler request respond raise)
:else
(let [request (-> request
(assoc :profile-id (:profile-id session))
(assoc :session-id (:id session)))
respond (cond-> respond
(renew-session? session)
(wrap-respond session))]
(handler request respond raise))))))
(catch Throwable cause
(raise cause))))))}))
;; --- STATE INIT: SESSION ;; --- STATE INIT: SESSION
@ -194,77 +257,23 @@
(defmethod ig/init-key :app.http/session (defmethod ig/init-key :app.http/session
[_ {:keys [store] :as cfg}] [_ {:keys [store] :as cfg}]
(let [events-ch (a/chan (a/dropping-buffer (:buffer-size cfg))) (-> cfg
cfg (assoc cfg ::events-ch events-ch)] (assoc :middleware (make-middleware cfg))
(assoc :create (fn [profile-id]
(-> cfg (fn [request response]
(assoc :middleware (make-middleware cfg)) (p/let [uagent (yrq/get-header request "user-agent")
(assoc :create (fn [profile-id] session (create-session! store profile-id uagent)]
(fn [request response]
(p/let [token (create-session! store request profile-id)]
(add-cookies response token)))))
(assoc :delete (fn [request response]
(p/do
(delete-session! store request)
(-> response (-> response
(assoc :status 204) (assign-auth-token-cookie session)
(assoc :body nil) (assign-authenticated-cookie session))))))
(clear-cookies)))))))) (assoc :delete (fn [request response]
(p/do
(defmethod ig/halt-key! :app.http/session (delete-session! store request)
[_ data] (-> response
(a/close! (::events-ch data))) (assoc :status 204)
(assoc :body nil)
;; --- STATE INIT: SESSION UPDATER (clear-auth-token-cookie)
(clear-authenticated-cookie)))))))
(declare update-sessions)
(s/def ::session map?)
(s/def ::max-batch-age ::cfg/http-session-updater-batch-max-age)
(s/def ::max-batch-size ::cfg/http-session-updater-batch-max-size)
(defmethod ig/pre-init-spec ::updater [_]
(s/keys :req-un [::db/pool ::wrk/executor ::mtx/metrics ::session]
:opt-un [::max-batch-age ::max-batch-size]))
(defmethod ig/prep-key ::updater
[_ cfg]
(merge {:max-batch-age (dt/duration {:minutes 5})
:max-batch-size 200}
(d/without-nils cfg)))
(defmethod ig/init-key ::updater
[_ {:keys [session metrics] :as cfg}]
(l/info :action "initialize session updater"
:max-batch-age (str (:max-batch-age cfg))
:max-batch-size (str (:max-batch-size cfg)))
(let [input (aa/batch (::events-ch session)
{:max-batch-size (:max-batch-size cfg)
:max-batch-age (inst-ms (:max-batch-age cfg))})]
(a/go-loop []
(when-let [[reason batch] (a/<! input)]
(let [result (a/<! (update-sessions cfg batch))]
(mtx/run! metrics {:id :session-update-total :inc 1})
(cond
(ex/exception? result)
(l/error :task "updater"
:hint "unexpected error on update sessions"
:cause result)
(= :size reason)
(l/debug :task "updater"
:hint "update sessions"
:reason (name reason)
:count result))
(recur))))))
(defn- update-sessions
[{:keys [pool executor]} ids]
(aa/with-thread executor
(db/exec-one! pool ["update http_session set updated_at=now() where id = ANY(?)"
(into-array String ids)])
(count ids)))
;; --- STATE INIT: SESSION GC ;; --- STATE INIT: SESSION GC
@ -278,7 +287,7 @@
(defmethod ig/prep-key ::gc-task (defmethod ig/prep-key ::gc-task
[_ cfg] [_ cfg]
(merge {:max-age (dt/duration {:days 15})} (merge {:max-age default-cookie-max-age}
(d/without-nils cfg))) (d/without-nils cfg)))
(defmethod ig/init-key ::gc-task (defmethod ig/init-key ::gc-task

View file

@ -98,15 +98,7 @@
:app.http.session/gc-task :app.http.session/gc-task
{:pool (ig/ref :app.db/pool) {:pool (ig/ref :app.db/pool)
:max-age (cf/get :http-session-idle-max-age)} :max-age (cf/get :auth-token-cookie-max-age)}
:app.http.session/updater
{:pool (ig/ref :app.db/pool)
:metrics (ig/ref :app.metrics/metrics)
:executor (ig/ref [::worker :app.worker/executor])
:session (ig/ref :app.http/session)
:max-batch-age (cf/get :http-session-updater-batch-max-age)
:max-batch-size (cf/get :http-session-updater-batch-max-size)}
:app.http.awsns/handler :app.http.awsns/handler
{:tokens (ig/ref :app.tokens/tokens) {:tokens (ig/ref :app.tokens/tokens)