Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Identifying requests from one tab across refreshes and server-side pageloads #118

Closed
dillonforrest opened this issue Mar 28, 2015 · 11 comments

Comments

@dillonforrest
Copy link

My problem: I want to know which "blarble" is associated with each client->server request, ideally as part of ring-req, but really as any part of the entire request map which comes in on the server's ch-recv. Unfortunately I don't know and can't figure out how to do this.

I made up the word "blarble" because I didn't know the correct word. To avoid all ambiguity and confusion, I decided to make up a word. I define a blarble as 1 tab in 1 browser on 1 device including all page refreshes and any traditional server-side page loads. If a user refreshes and the socket handshake is repeated, or the user clicks on a link which takes him/her to another server-side rendered page which prompts another socket handshake, then I want my server to still recognize all requests from all these separate handshakes as from the same single blarble since they still came from the same tab.

I want to contrast blarbles with Sente clients (1 tab in 1 browser on 1 device, but every refresh in the same tab is a new client) and Sente sessions (1+ tabs in 1 browser on 1 device, but these refresh after every refresh as well).

What can I do to make my server recognize blarbles?

Disclaimer 1: I'm relatively new to both clojure and back-end web development, so I totally realize the answer might be trivial and I'm just missing it.

Disclaimer 2: I realize that the answer might not be within Sente and rather within Ring or (maybe less likely) Compojure.

@dillonforrest
Copy link
Author

I realize now my original post was very wordy and maybe difficult to understand! My attempt at brevity: I just want to uniquely identify 1 tab in 1 browser on 1 device, but have that id persist across user refreshes or clicking links or anything which will prompt a second initial socket handshake within the same tab. How do I do that? o_O

@danielytics
Copy link

I have something like this working[1] in one of my projects. What I do is I add something like <script src="/init" type="text/javascript"></script> to my HTML and then in my compojure routes:

  (GET "/init" req 
       (let [uid (or (get-in req [:session :uid])
                         (base64 8))]
         {:status 200
          :headers {"Content-Type" "text/javascript; charset=utf-8"}
          :session (assoc (:session req) :uid uid)}))

That way each time the page is loaded, that route gets run and if the session does not already contain a uid, it generates a new one.

[1] This may not be quite what you want because, as its session based, if you have more than one tab, they will all get the same id.

@dillonforrest
Copy link
Author

@danielytics thanks for the quick reply! :) And thanks for the code snippet!

I was hoping for a completely stateless solution, but I think I'll have to use sessionStorage D: I can't think of any other way to isolate identity down to the tab level, but persist that identity across pageloads and refreshes.

@ptaoussanis
Copy link
Member

Hey Dillon,

I'll quickly recap our earlier email exchange for others that might be following this or have similar questions.


Sente currently uses two identifiers: a client-id and a user-id.

Client id (determined client-side)

A client is one particular ClojureScript invocation of make-channel-socket!. In practice this usually means one particular browser tab [on one device].

Default client-id value: a random uuid generated when make-channel-socket! is invoked.

Notes:

  1. Each client chooses its own client-id with no input from server.
  2. By default, each tab has its own client-id.
  3. By default, reloading a tab (or closing a tab + opening a new one) means a new client-id.

Overriding default client-id value: (sente/make-channel-socket! <path> {:client-id <client-id>}).
I.e. you can override a client's (random uuid) client-id by providing a :client-id key to the (ClojureScript-side) make-channel-socket! opts map.

User id (determined server-side)

Sente supports async event pushing from the server (Clojure-side) to clients (ClojureScript-side) using the server-side (fn chsk-send! [user-id event]) function.

So a user-id just specifies destination/s for async event pushing from the server.

A user-id is determined server-side as: (fn user-id [ring-req client-id]). I.e. as a function of the channel socket Ring request, and of the requesting client's client-id.

Default user-id value: (get-in ring-req [:session :uid]). I.e. a sessionized :uid value.

Notes:

  1. The definition we have allows one user-id to refer/map to zero, one, or many destination clients.
  2. By default (i.e. with the sessionized :uid value), user-ids are persistent and shared among multiple tabs in one browser. This is just a property of how browser sessions work.

Overriding default user-id value: (sente/make-channel-socket! <web-server-adapter> {:user-id-fn (fn [ring-req-with-client-id])}). I.e. you can override the server's default ring-req-with-client-id->user-id function with your own.


So the knobs you have to work with are:
ClojureScript (client) side: make-channel-socket!'s :client-id opt.
Clojure (server) side: make-channel-socket!'s :user-id-fn opt.

Between these two, you should have the control necessary to do just about anything you'd like.

Some examples

You want a per-tab transient user-id

Each tab has its own user-id, and reloading a tab means a new user-id.

  1. :client-id: leave unchanged.
  2. :user-id-fn: (fn [ring-req] (:client-id ring-req))

I.e. we don't use sessions for anything. User-ids are equal to client-ids, which are random per-tab uuids.

You want a per-tab transient user-id with session security

As above, but users must be signed in with a session.

  1. :client-id: leave unchanged.
  2. :user-id-fn: (fn [ring-req] (str (get-in ring-req [:session :base-user-id]) "/" (:client-id ring-req)))

I.e. sessions (+ some kind of login procedure) are used to determine a :base-user-id. That base user-id is then joined with each unique client-id. Each tab therefore retains its own user-id, but each user-id is dependent on a secure login procedure.

You want a per-tab persistent user-id (i.e. that survives tab reloading)

I believe this is the one you were asking about?

This one's tricky since you'll need a way of persisting tab identity, and I'm not sure if browsers even have a concept of persistent tab identity or what that might look like. The first step would be to very clearly define what behaviour you're actually looking for and then Google around to see if the necessary info is actually available to browsers client-side.

If it is, you can pass it along as your :client-id value. Does that make sense?

Something like HTML5 LocalStorage might be useful, but I haven't looked into the specifics to confirm.


Hope some of that was useful, just let me know if I can help further clarify anything :-)

Cheers!

@dillonforrest
Copy link
Author

Hey Peter! Yes, I was planning on using either sessionStorage or localStorage to persist a uuid, and then setting the :client-id to that uuid on each handshake.

My initial hope was to find a completely stateless way to accomplish my goals, but I got some great info from here telling me that my ideal stateless method might not exist: ring-clojure/ring#197

Thanks so much!! 🍻

@ptaoussanis
Copy link
Member

Thanks so much!! 🍻

No problem, this question seems to come up often enough - will update the README to incl. the summary from here.

Small thing to note: it's still not obvious to me how you'd use LocalStorage/SessionStorage to get the particular behaviour you want. The underlying difficulty is that (afaik) browsers lack a notion of tab identity. And this isn't a browser issue per se, it's just an intrinsically difficult thing to define.

If you reload a tab, is it the same tab? What if you click a link in a tab, which takes you to a different page, then you eventually end up at the original URL again? What if you close a tab, then immediately open a new tab to the same URL?

I'd start by deciding if/why you really need persistent tab-level identity. That may give you some clarity re: the very specific behaviour you want - then you can see if it'd be something possible/worth hacking together.

Good luck! :-)

@altV
Copy link

altV commented Apr 30, 2016

is there a way to access client-ids inside one user-id?
(connected-uids has only user-ids, so I went with assigning user-ids as [user-id client-id remote-ip some-browser-info])

@theasp
Copy link
Collaborator

theasp commented Apr 30, 2016

@altV, I do this a bit differently. I have a server generated UUID (you can use gensym too) for uid rather than any actual user information, and store each client's unique uid in a map with their real uid. Doing this I can send to one specific client, or loop over each uid a user has. Using this method I can handle authentication over a sente channel:

(defn register-uid [state uid send-fn]
  (assoc-in state [:clients uid :send-fn] (partial send-fn uid)))

(defn register-user [state uid user]
  (-> state
      (assoc-in [:sente :clients uid :user] user)
      (assoc-in [:sente :connections (:uid user) uid]
                (get-in state [:sente :clients uid :send-fn]))))

(defn deregister-user [state uid]
  (if-let [user (get-in state [:clients uid :user])]
    (-> state
        (update-in [:sente :clients] dissoc uid)
        (update-in [:sente :connections (:uid user)] dissoc uid))
    (update state :clients dissoc uid)))

(defn auth [{:keys [send-fn uid ?data ?reply-fn store state] :as ev-msg}]
  (if-let [user (first (store/query! store ["SELECT * FROM users WHERE name=$1" (:name ?data)]))]
    (do
      (if (= (get-in user [:data :password]) (:password ?data)) ;; Protip: don't store plain text passwords!
        (do
          (swap! state register-user)
          (?reply-fn :xxx/login-ok))
        (?reply-fn :xxx/login-failed)))
    (?reply-fn :xxx/login-failed)))

(defn event-msg-handler [this {:keys [id] :as ev-msg}]
  (tracef "Event: %s" (:event ev-msg))

  (condp = id
    :chsk/uidport-open
    (swap! state register-uid uid send-fn)

    :chsk/uidport-close
    (swap! state deregister-user uid)

    :chsk/ws-ping
    (tracef "Ping from client: %s" uid)

    :xxx/auth
    (async/thread
      (auth (assoc ev-msg :state state :store store)))))

@raymcdermott
Copy link

@theasp I like what you did there and would like to emulate your approach.

Am I to assume that state and store are some external / global vars?

Also (swap! state register-user) seems to be missing arguments. Or am I missing something?

I can come up with an alternative but wanted to check my understanding with you.

@theasp
Copy link
Collaborator

theasp commented Mar 21, 2018

Hi @raymcdermott,

My example was simplified from the actual code. state would be an atom for holding, and store is a db connection. You are correct, register-user is missing arguments.

@raymcdermott
Copy link

raymcdermott commented Mar 21, 2018 via email

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

6 participants