refactor: remove auth system
Remove Bearer token auth, per-user group ACLs, and auth_test.clj. The server now accepts all requests without authentication.
This commit is contained in:
2
bb.edn
2
bb.edn
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
test {:doc "Run all server tests"
|
test {:doc "Run all server tests"
|
||||||
:task (let [expr (str "(require 'pocketbook.db-test 'pocketbook.transit-test"
|
:task (let [expr (str "(require 'pocketbook.db-test 'pocketbook.transit-test"
|
||||||
" 'pocketbook.server-test 'pocketbook.auth-test)"
|
" 'pocketbook.server-test)"
|
||||||
" (let [r (clojure.test/run-all-tests #\"pocketbook\\..*\")]"
|
" (let [r (clojure.test/run-all-tests #\"pocketbook\\..*\")]"
|
||||||
" (System/exit (if (and (zero? (:fail r)) (zero? (:error r))) 0 1)))")]
|
" (System/exit (if (and (zero? (:fail r)) (zero? (:error r))) 0 1)))")]
|
||||||
(shell "clj" "-M:dev" "-e" expr))}
|
(shell "clj" "-M:dev" "-e" expr))}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
cache ;; atom containing {id -> value}
|
cache ;; atom containing {id -> value}
|
||||||
versions ;; atom containing {id -> version}
|
versions ;; atom containing {id -> version}
|
||||||
pending ;; atom containing #{id} — unsynced ids
|
pending ;; atom containing #{id} — unsynced ids
|
||||||
server-opts ;; {:server url :token str} or nil
|
server-opts ;; {:server url} or nil
|
||||||
last-sync ;; atom containing epoch ms
|
last-sync ;; atom containing epoch ms
|
||||||
ready-ch ;; channel, closed when initial load complete
|
ready-ch ;; channel, closed when initial load complete
|
||||||
stop-ch ;; channel to signal stop
|
stop-ch ;; channel to signal stop
|
||||||
@@ -319,15 +319,14 @@
|
|||||||
|
|
||||||
Options:
|
Options:
|
||||||
:server — server URL (e.g. \"http://localhost:8090/sync\")
|
:server — server URL (e.g. \"http://localhost:8090/sync\")
|
||||||
:token — auth token
|
|
||||||
:cache — custom atom to use (e.g. reagent/atom). Default: cljs.core/atom
|
:cache — custom atom to use (e.g. reagent/atom). Default: cljs.core/atom
|
||||||
:interval — sync interval in ms (default 30000)"
|
:interval — sync interval in ms (default 30000)"
|
||||||
[conn group & [{:keys [server token cache interval]
|
[conn group & [{:keys [server cache interval]
|
||||||
:or {interval 30000}}]]
|
:or {interval 30000}}]]
|
||||||
(let [cache-atom (or cache (atom {}))
|
(let [cache-atom (or cache (atom {}))
|
||||||
versions (atom {})
|
versions (atom {})
|
||||||
pending (atom #{})
|
pending (atom #{})
|
||||||
server-opts (when server {:server server :token token})
|
server-opts (when server {:server server})
|
||||||
last-sync (atom 0)
|
last-sync (atom 0)
|
||||||
ready-ch (chan 1)
|
ready-ch (chan 1)
|
||||||
stop-ch (chan 1)
|
stop-ch (chan 1)
|
||||||
|
|||||||
@@ -23,32 +23,8 @@
|
|||||||
{:port 8090
|
{:port 8090
|
||||||
:db-path "pocketbook.db"
|
:db-path "pocketbook.db"
|
||||||
:static-dir nil ;; nil = no static serving, or path like "example/todomvc"
|
:static-dir nil ;; nil = no static serving, or path like "example/todomvc"
|
||||||
:users nil ;; nil = no auth, or {"alice" {:token "abc" :groups #{"todo"}}}
|
|
||||||
:cors true})
|
:cors true})
|
||||||
|
|
||||||
;; ---------------------------------------------------------------------------
|
|
||||||
;; Auth
|
|
||||||
;; ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
(defn- authenticate
|
|
||||||
"Check Authorization header against config. Returns user map or nil."
|
|
||||||
[config req]
|
|
||||||
(if-let [users (:users config)]
|
|
||||||
(let [header (get-in req [:headers "authorization"] "")
|
|
||||||
token (str/replace header #"^Bearer\s+" "")]
|
|
||||||
(some (fn [[username user]]
|
|
||||||
(when (= token (:token user))
|
|
||||||
(assoc user :username username)))
|
|
||||||
users))
|
|
||||||
;; No auth configured — allow all
|
|
||||||
{:username "anonymous" :groups nil}))
|
|
||||||
|
|
||||||
(defn- authorized-group?
|
|
||||||
"Check if user has access to a specific group."
|
|
||||||
[user group]
|
|
||||||
(or (nil? (:groups user)) ;; nil = access to all groups
|
|
||||||
(contains? (:groups user) group)))
|
|
||||||
|
|
||||||
;; ---------------------------------------------------------------------------
|
;; ---------------------------------------------------------------------------
|
||||||
;; SSE — live change notifications
|
;; SSE — live change notifications
|
||||||
;; ---------------------------------------------------------------------------
|
;; ---------------------------------------------------------------------------
|
||||||
@@ -87,64 +63,48 @@
|
|||||||
(update resp :headers merge
|
(update resp :headers merge
|
||||||
{"Access-Control-Allow-Origin" "*"
|
{"Access-Control-Allow-Origin" "*"
|
||||||
"Access-Control-Allow-Methods" "GET, POST, OPTIONS"
|
"Access-Control-Allow-Methods" "GET, POST, OPTIONS"
|
||||||
"Access-Control-Allow-Headers" "Content-Type, Authorization"
|
"Access-Control-Allow-Headers" "Content-Type"
|
||||||
"Access-Control-Max-Age" "86400"}))
|
"Access-Control-Max-Age" "86400"}))
|
||||||
|
|
||||||
(defn- handle-pull
|
(defn- handle-pull
|
||||||
"GET /sync?since=T&group=G — return all docs updated since T in group G."
|
"GET /sync?since=T&group=G — return all docs updated since T in group G."
|
||||||
[ds user req]
|
[ds req]
|
||||||
(let [params (or (:query-params req) (:params req) {})
|
(let [params (or (:query-params req) (:params req) {})
|
||||||
group (get params "group" (get params :group))
|
group (get params "group" (get params :group))
|
||||||
since (parse-long (or (get params "since" (get params :since)) "0"))]
|
since (parse-long (or (get params "since" (get params :since)) "0"))]
|
||||||
(if-not group
|
(if-not group
|
||||||
(transit-response 400 {:error "Missing 'group' parameter"})
|
(transit-response 400 {:error "Missing 'group' parameter"})
|
||||||
(if-not (authorized-group? user group)
|
(let [docs (db/docs-since ds group since)]
|
||||||
(transit-response 403 {:error "Access denied to group"})
|
(transit-response 200 docs)))))
|
||||||
(let [docs (db/docs-since ds group since)]
|
|
||||||
(transit-response 200 docs))))))
|
|
||||||
|
|
||||||
(defn- handle-push
|
(defn- handle-push
|
||||||
"POST /sync — accept a batch of document writes.
|
"POST /sync — accept a batch of document writes.
|
||||||
Body: [{:id ... :value ... :base-version N} ...]
|
Body: [{:id ... :value ... :base-version N} ...]
|
||||||
Entries with :deleted true are treated as deletes."
|
Entries with :deleted true are treated as deletes."
|
||||||
[ds user req]
|
[ds req]
|
||||||
(let [body (t/decode (:body req))
|
(let [body (t/decode (:body req))
|
||||||
docs (if (map? body) [body] body)
|
docs (if (map? body) [body] body)
|
||||||
;; Check all docs belong to authorized groups
|
|
||||||
groups (into #{} (map #(first (str/split (:id %) #":" 2))) docs)
|
groups (into #{} (map #(first (str/split (:id %) #":" 2))) docs)
|
||||||
denied (remove #(authorized-group? user %) groups)]
|
results (mapv (fn [doc]
|
||||||
(if (seq denied)
|
(if (:deleted doc)
|
||||||
(transit-response 403 {:error (str "Access denied to groups: " (str/join ", " denied))})
|
(db/delete! ds {:id (:id doc)
|
||||||
(let [results (mapv (fn [doc]
|
:base-version (:base-version doc 0)})
|
||||||
(if (:deleted doc)
|
(db/upsert! ds {:id (:id doc)
|
||||||
(db/delete! ds {:id (:id doc)
|
:value (:value doc)
|
||||||
:base-version (:base-version doc 0)})
|
:base-version (:base-version doc 0)})))
|
||||||
(db/upsert! ds {:id (:id doc)
|
docs)]
|
||||||
:value (:value doc)
|
;; Notify SSE listeners for affected groups
|
||||||
:base-version (:base-version doc 0)})))
|
(when (some #(= :ok (:status %)) results)
|
||||||
docs)]
|
(sse-notify! groups))
|
||||||
;; Notify SSE listeners for affected groups
|
(transit-response 200 results)))
|
||||||
(when (some #(= :ok (:status %)) results)
|
|
||||||
(sse-notify! groups))
|
|
||||||
(transit-response 200 results)))))
|
|
||||||
|
|
||||||
(defn- handle-events
|
(defn- handle-events
|
||||||
"GET /events?group=G — SSE endpoint. Holds connection open."
|
"GET /events?group=G — SSE endpoint. Holds connection open."
|
||||||
[config req]
|
[_config req]
|
||||||
(let [params (or (:query-params req) {})
|
(let [params (or (:query-params req) {})
|
||||||
group (get params "group")
|
group (get params "group")]
|
||||||
user (authenticate config req)]
|
(if-not group
|
||||||
(cond
|
|
||||||
(not user)
|
|
||||||
(transit-response 401 {:error "Unauthorized"})
|
|
||||||
|
|
||||||
(not group)
|
|
||||||
(transit-response 400 {:error "Missing 'group' parameter"})
|
(transit-response 400 {:error "Missing 'group' parameter"})
|
||||||
|
|
||||||
(not (authorized-group? user group))
|
|
||||||
(transit-response 403 {:error "Access denied to group"})
|
|
||||||
|
|
||||||
:else
|
|
||||||
(http/with-channel req ch
|
(http/with-channel req ch
|
||||||
(http/send! ch {:status 200
|
(http/send! ch {:status 200
|
||||||
:headers {"Content-Type" "text/event-stream"
|
:headers {"Content-Type" "text/event-stream"
|
||||||
@@ -225,13 +185,10 @@
|
|||||||
|
|
||||||
;; Sync endpoints
|
;; Sync endpoints
|
||||||
(= "/sync" (:uri req))
|
(= "/sync" (:uri req))
|
||||||
(let [user (authenticate config req)]
|
(case (:request-method req)
|
||||||
(if-not user
|
:get (handle-pull ds req)
|
||||||
(transit-response 401 {:error "Unauthorized"})
|
:post (handle-push ds req)
|
||||||
(case (:request-method req)
|
(transit-response 405 {:error "Method not allowed"}))
|
||||||
:get (handle-pull ds user req)
|
|
||||||
:post (handle-push ds user req)
|
|
||||||
(transit-response 405 {:error "Method not allowed"}))))
|
|
||||||
|
|
||||||
;; Static files (including / → todomvc.html)
|
;; Static files (including / → todomvc.html)
|
||||||
:else
|
:else
|
||||||
@@ -258,7 +215,6 @@
|
|||||||
server (http/run-server handler {:port (:port config)})]
|
server (http/run-server handler {:port (:port config)})]
|
||||||
(println (str "🔶 Pocketbook server running on http://localhost:" (:port config)))
|
(println (str "🔶 Pocketbook server running on http://localhost:" (:port config)))
|
||||||
(println (str " Database: " (:db-path config)))
|
(println (str " Database: " (:db-path config)))
|
||||||
(println (str " Auth: " (if (:users config) "enabled" "disabled")))
|
|
||||||
(when (:static-dir config)
|
(when (:static-dir config)
|
||||||
(println (str " Static: " (:static-dir config)))
|
(println (str " Static: " (:static-dir config)))
|
||||||
(println (str " App: http://localhost:" (:port config) "/")))
|
(println (str " App: http://localhost:" (:port config) "/")))
|
||||||
|
|||||||
@@ -55,15 +55,14 @@
|
|||||||
(defn pull!
|
(defn pull!
|
||||||
"Pull documents from server updated since `since` for `group`.
|
"Pull documents from server updated since `since` for `group`.
|
||||||
Returns a channel yielding {:ok true :docs [...]} or {:ok false :error str}."
|
Returns a channel yielding {:ok true :docs [...]} or {:ok false :error str}."
|
||||||
[{:keys [server token]} group since]
|
[{:keys [server]} group since]
|
||||||
(let [ch (chan 1)
|
(let [ch (chan 1)
|
||||||
url (str server "?group=" (js/encodeURIComponent group)
|
url (str server "?group=" (js/encodeURIComponent group)
|
||||||
"&since=" since)]
|
"&since=" since)]
|
||||||
(async/go
|
(async/go
|
||||||
(let [result (async/<! (fetch-transit
|
(let [result (async/<! (fetch-transit
|
||||||
{:url url
|
{:url url
|
||||||
:method "GET"
|
:method "GET"}))]
|
||||||
:headers (when token {"Authorization" (str "Bearer " token)})}))]
|
|
||||||
(if (:ok result)
|
(if (:ok result)
|
||||||
(put! ch {:ok true :docs (:body result)})
|
(put! ch {:ok true :docs (:body result)})
|
||||||
(put! ch result))
|
(put! ch result))
|
||||||
@@ -78,14 +77,13 @@
|
|||||||
"Push a batch of documents to the server.
|
"Push a batch of documents to the server.
|
||||||
Each doc: {:id str :value any :base-version int} or {:id str :deleted true :base-version int}.
|
Each doc: {:id str :value any :base-version int} or {:id str :deleted true :base-version int}.
|
||||||
Returns a channel yielding {:ok true :results [...]} or {:ok false :error str}."
|
Returns a channel yielding {:ok true :results [...]} or {:ok false :error str}."
|
||||||
[{:keys [server token]} docs]
|
[{:keys [server]} docs]
|
||||||
(let [ch (chan 1)]
|
(let [ch (chan 1)]
|
||||||
(async/go
|
(async/go
|
||||||
(let [result (async/<! (fetch-transit
|
(let [result (async/<! (fetch-transit
|
||||||
{:url server
|
{:url server
|
||||||
:method "POST"
|
:method "POST"
|
||||||
:body docs
|
:body docs}))]
|
||||||
:headers (when token {"Authorization" (str "Bearer " token)})}))]
|
|
||||||
(if (:ok result)
|
(if (:ok result)
|
||||||
(put! ch {:ok true :results (:body result)})
|
(put! ch {:ok true :results (:body result)})
|
||||||
(put! ch result))
|
(put! ch result))
|
||||||
@@ -99,7 +97,7 @@
|
|||||||
(defn listen-events
|
(defn listen-events
|
||||||
"Open an SSE connection to /events?group=G. Calls `on-change` when the
|
"Open an SSE connection to /events?group=G. Calls `on-change` when the
|
||||||
server signals new data. Returns a cleanup function."
|
server signals new data. Returns a cleanup function."
|
||||||
[{:keys [server token]} group on-change]
|
[{:keys [server]} group on-change]
|
||||||
(let [base-url (-> server
|
(let [base-url (-> server
|
||||||
(str/replace #"/sync$" "")
|
(str/replace #"/sync$" "")
|
||||||
(str/replace #"/$" ""))
|
(str/replace #"/$" ""))
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
(ns pocketbook.auth-test
|
|
||||||
(:require [clojure.test :refer [deftest is testing use-fixtures]]
|
|
||||||
[pocketbook.server :as server]
|
|
||||||
[pocketbook.transit :as t])
|
|
||||||
(:import [java.io File]
|
|
||||||
[java.net URI]
|
|
||||||
[java.net.http HttpClient HttpRequest HttpResponse$BodyHandlers HttpRequest$BodyPublishers]))
|
|
||||||
|
|
||||||
(def ^:dynamic *port* nil)
|
|
||||||
(def ^:dynamic *server* nil)
|
|
||||||
|
|
||||||
(defn- free-port []
|
|
||||||
(with-open [s (java.net.ServerSocket. 0)]
|
|
||||||
(.getLocalPort s)))
|
|
||||||
|
|
||||||
(def test-users
|
|
||||||
{"alice" {:token "alice-secret" :groups #{"todo" "settings"}}
|
|
||||||
"bob" {:token "bob-secret" :groups #{"todo"}}})
|
|
||||||
|
|
||||||
(use-fixtures :each
|
|
||||||
(fn [f]
|
|
||||||
(let [port (free-port)
|
|
||||||
db-path (str (File/createTempFile "pocketbook-auth-test" ".db"))
|
|
||||||
srv (server/start! {:port port :db-path db-path :users test-users})]
|
|
||||||
(Thread/sleep 200)
|
|
||||||
(try
|
|
||||||
(binding [*server* srv *port* port]
|
|
||||||
(f))
|
|
||||||
(finally
|
|
||||||
(server/stop! srv)
|
|
||||||
(.delete (File. db-path)))))))
|
|
||||||
|
|
||||||
(def ^:private client (HttpClient/newHttpClient))
|
|
||||||
|
|
||||||
(defn- url [path & [query]]
|
|
||||||
(str "http://localhost:" *port* path (when query (str "?" query))))
|
|
||||||
|
|
||||||
(defn- get-transit [path query & [token]]
|
|
||||||
(let [req (-> (HttpRequest/newBuilder)
|
|
||||||
(.uri (URI. (url path query)))
|
|
||||||
(.header "Accept" "application/transit+json")
|
|
||||||
(cond-> token (.header "Authorization" (str "Bearer " token)))
|
|
||||||
(.GET)
|
|
||||||
(.build))
|
|
||||||
resp (.send client req (HttpResponse$BodyHandlers/ofByteArray))]
|
|
||||||
{:status (.statusCode resp)
|
|
||||||
:body (t/decode (.body resp))}))
|
|
||||||
|
|
||||||
(defn- post-transit [path body & [token]]
|
|
||||||
(let [bytes (t/encode body)
|
|
||||||
req (-> (HttpRequest/newBuilder)
|
|
||||||
(.uri (URI. (url path)))
|
|
||||||
(.header "Content-Type" "application/transit+json")
|
|
||||||
(.header "Accept" "application/transit+json")
|
|
||||||
(cond-> token (.header "Authorization" (str "Bearer " token)))
|
|
||||||
(.POST (HttpRequest$BodyPublishers/ofByteArray bytes))
|
|
||||||
(.build))
|
|
||||||
resp (.send client req (HttpResponse$BodyHandlers/ofByteArray))]
|
|
||||||
{:status (.statusCode resp)
|
|
||||||
:body (t/decode (.body resp))}))
|
|
||||||
|
|
||||||
(deftest unauthorized-without-token
|
|
||||||
(let [resp (get-transit "/sync" "group=todo&since=0")]
|
|
||||||
(is (= 401 (:status resp)))))
|
|
||||||
|
|
||||||
(deftest unauthorized-with-bad-token
|
|
||||||
(let [resp (get-transit "/sync" "group=todo&since=0" "wrong-token")]
|
|
||||||
(is (= 401 (:status resp)))))
|
|
||||||
|
|
||||||
(deftest alice-can-access-todo
|
|
||||||
(let [resp (post-transit "/sync"
|
|
||||||
[{:id "todo:1" :value {:text "Alice todo"} :base-version 0}]
|
|
||||||
"alice-secret")]
|
|
||||||
(is (= 200 (:status resp)))
|
|
||||||
(is (= :ok (:status (first (:body resp)))))))
|
|
||||||
|
|
||||||
(deftest bob-cannot-access-settings
|
|
||||||
(let [resp (post-transit "/sync"
|
|
||||||
[{:id "settings:theme" :value {:dark true} :base-version 0}]
|
|
||||||
"bob-secret")]
|
|
||||||
(is (= 403 (:status resp)))))
|
|
||||||
|
|
||||||
(deftest alice-can-access-settings
|
|
||||||
(let [resp (post-transit "/sync"
|
|
||||||
[{:id "settings:theme" :value {:dark true} :base-version 0}]
|
|
||||||
"alice-secret")]
|
|
||||||
(is (= 200 (:status resp)))
|
|
||||||
(is (= :ok (:status (first (:body resp)))))))
|
|
||||||
Reference in New Issue
Block a user