Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/db/postgres/lrsql/postgres/record.clj
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@
(add-statement-to-actor-cascading-delete! tx))
(when (some? (query-varchar-exists tx))
(convert-varchars-to-text! tx))
(create-blocked-jwt-table! tx))
(create-blocked-jwt-table! tx)
(alter-blocked-jwt-add-one-time-id! tx))

bp/BackendUtil
(-txn-retry? [_ ex]
Expand Down Expand Up @@ -210,10 +211,16 @@
bp/JWTBlocklistBackend
(-insert-blocked-jwt! [_ tx input]
(insert-blocked-jwt! tx input))
(-insert-one-time-jwt! [_ tx input]
(insert-one-time-jwt! tx input))
(-update-one-time-jwt! [_ tx input]
(update-one-time-jwt! tx input))
(-delete-blocked-jwt-by-time! [_ tx input]
(delete-blocked-jwt-by-time! tx input))
(-query-blocked-jwt [_ tx input]
(query-blocked-jwt-exists tx input))
(-query-one-time-jwt [_ tx input]
(query-one-time-jwt-exists tx input))

bp/CredentialBackend
(-insert-credential! [_ tx input]
Expand Down
7 changes: 7 additions & 0 deletions src/db/postgres/lrsql/postgres/sql/ddl.sql
Original file line number Diff line number Diff line change
Expand Up @@ -499,3 +499,10 @@ CREATE TABLE IF NOT EXISTS blocked_jwt (
evict_time TIMESTAMP WITH TIME ZONE
);
CREATE INDEX IF NOT EXISTS blocked_jwt_evict_time_idx ON blocked_jwt(evict_time);

/* Migration 2025-03-05 - Add One-Time ID to Blocklist Table */

-- :name alter-blocked-jwt-add-one-time-id!
-- :command :execute
-- :doc Add the column `blocked_jwt.one_time_id` for one-time JWTs; JWTs with one-time IDs are not considered blocked yet.
ALTER TABLE IF EXISTS blocked_jwt ADD COLUMN IF NOT EXISTS one_time_id UUID UNIQUE;
10 changes: 10 additions & 0 deletions src/db/postgres/lrsql/postgres/sql/insert.sql
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,13 @@ INSERT INTO blocked_jwt (
) VALUES (
:jwt, :eviction-time
);

-- :name insert-one-time-jwt!
-- :command :insert
-- :result :affected
-- :doc Insert a `:jwt` and a `:eviction-time` with `:one-time-id` into the blocklist.
INSERT INTO blocked_jwt (
jwt, evict_time, one_time_id
) VALUES (
:jwt, :eviction-time, :one-time-id
);
13 changes: 11 additions & 2 deletions src/db/postgres/lrsql/postgres/sql/query.sql
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,15 @@ WHERE reaction_id IS NOT NULL;
-- :name query-blocked-jwt-exists
-- :command :query
-- :result :one
-- :doc Query that `:jwt` is in the blocklist.
-- :doc Query that `:jwt` is in the blocklist. Excludes JWTs where `one_time_id` is not null.
SELECT 1 FROM blocked_jwt
WHERE jwt = :jwt;
WHERE jwt = :jwt
AND one_time_id IS NULL;

-- :name query-one-time-jwt-exists
-- :command :query
-- :result :one
-- :doc Query that `:jwt` with `:one-time-id` exists.
SELECT 1 FROM blocked_jwt
WHERE jwt = :jwt
AND one_time_id = :one-time-id;
9 changes: 9 additions & 0 deletions src/db/postgres/lrsql/postgres/sql/update.sql
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,15 @@ SET
passhash = :new-passhash
WHERE id = :account-id;

-- :name update-one-time-jwt!
-- :command :execute
-- :result :affected
-- :doc Update `blocked_jwt.one_time_id` to be null, thus blocking the JWT.
UPDATE blocked_jwt
SET
one_time_id = NULL
WHERE one_time_id = :one-time-id;

-- :name update-reaction!
-- :command :execute
-- :result :affected
Expand Down
9 changes: 9 additions & 0 deletions src/db/sqlite/lrsql/sqlite/record.clj
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@
(update-schema-simple! tx alter-statement-to-actor-add-cascade-delete!))
(create-blocked-jwt-table! tx)
(create-blocked-jwt-evict-time-idx! tx)
(when-not (some? (query-blocked-jwt-one-time-id-exists tx))
(alter-blocked-jwt-add-one-time-id! tx)
(alter-blocked-jwt-add-one-time-id-idx! tx))
(log/infof "sqlite schema_version: %d"
(:schema_version (query-schema-version tx))))

Expand Down Expand Up @@ -247,10 +250,16 @@
bp/JWTBlocklistBackend
(-insert-blocked-jwt! [_ tx input]
(insert-blocked-jwt! tx input))
(-insert-one-time-jwt! [_ tx input]
(insert-one-time-jwt! tx input))
(-update-one-time-jwt! [_ tx input]
(update-one-time-jwt! tx input))
(-delete-blocked-jwt-by-time! [_ tx input]
(delete-blocked-jwt-by-time! tx input))
(-query-blocked-jwt [_ tx input]
(query-blocked-jwt-exists tx input))
(-query-one-time-jwt [_ tx input]
(query-one-time-jwt-exists tx input))

bp/CredentialBackend
(-insert-credential! [_ tx input]
Expand Down
20 changes: 20 additions & 0 deletions src/db/sqlite/lrsql/sqlite/sql/ddl.sql
Original file line number Diff line number Diff line change
Expand Up @@ -538,3 +538,23 @@ CREATE TABLE IF NOT EXISTS blocked_jwt (
-- :command :execute
-- :doc Create the `blocked_jwt_evict_time_idx` table if it does not exist yet.
CREATE INDEX IF NOT EXISTS blocked_jwt_evict_time_idx ON blocked_jwt(evict_time);

/* Migration 2025-03-05 - Add One-Time ID to Blocklist Table */

-- :name query-blocked-jwt-one-time-id-exists
-- :command :query
-- :result :one
-- :doc Query to see if `blocked_jwt.one_time_id` exists.
SELECT 1 FROM pragma_table_info('blocked_jwt') WHERE name = 'one_time_id';

-- :name alter-blocked-jwt-add-one-time-id!
-- :command :execute
-- :result :one
-- :doc Add the column `blocked_jwt.one_time_id` for one-time JWTs; JWTs with one-time IDs are not considered blocked yet.
ALTER TABLE blocked_jwt ADD COLUMN one_time_id TEXT;

-- :name alter-blocked-jwt-add-one-time-id-idx!
-- :command :execute
-- :result :one
-- :doc Add a unique index on `blocked_jwt.one_time_id` (since SQLite does not allow directly adding unique columns).
CREATE UNIQUE INDEX IF NOT EXISTS blocked_jwt_one_time_id_idx ON blocked_jwt(one_time_id);
10 changes: 10 additions & 0 deletions src/db/sqlite/lrsql/sqlite/sql/insert.sql
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,13 @@ INSERT INTO blocked_jwt (
) VALUES (
:jwt, :eviction-time
);

-- :name insert-one-time-jwt!
-- :command :insert
-- :result :affected
-- :doc Insert a `:jwt` and a `:eviction-time` with `:one-time-id` into the blocklist.
INSERT INTO blocked_jwt (
jwt, evict_time, one_time_id
) VALUES (
:jwt, :eviction-time, :one-time-id
);
11 changes: 10 additions & 1 deletion src/db/sqlite/lrsql/sqlite/sql/query.sql
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,15 @@ WHERE reaction_id IS NOT NULL;
-- :name query-blocked-jwt-exists
-- :command :query
-- :result :one
-- :doc Query that `:jwt` is in the blocklist.
-- :doc Query that `:jwt` is in the blocklist. Excludes JWTs where `one_time_id` is not null.
SELECT 1 FROM blocked_jwt
WHERE jwt = :jwt
AND one_time_id IS NULL;

-- :name query-one-time-jwt-exists
-- :command :query
-- :result :one
-- :doc Query that `:jwt` with `:one-time-id` exists.
SELECT 1 FROM blocked_jwt
WHERE jwt = :jwt
AND one_time_id = :one-time-id;
10 changes: 10 additions & 0 deletions src/db/sqlite/lrsql/sqlite/sql/update.sql
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,16 @@ SET
passhash = :new-passhash
WHERE id = :account-id

-- :name update-one-time-jwt!
-- :command :execute
-- :result :affected
-- :doc Update `blocked_jwt.one_time_id` to be null, thus blocking the JWT.
UPDATE blocked_jwt
SET
one_time_id = NULL
WHERE jwt = :jwt
AND one_time_id = :one-time-id;

-- :name update-reaction!
-- :command :execute
-- :result :affected
Expand Down
3 changes: 2 additions & 1 deletion src/main/lrsql/admin/interceptors/account.clj
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@

;; JWT interceptors for admin

;; See also: `admin.interceptors.lrs-management/generate-one-time-jwt`
(defn generate-jwt
"Upon account login, generate a new JSON web token."
[secret exp ref leeway]
Expand Down Expand Up @@ -316,7 +317,7 @@
(fn add-jwt-to-blocklist [ctx]
(if-not no-val?
(let [{lrs :com.yetanalytics/lrs
{:keys [jwt account-id]} :lrsql.admin.interceptors.jwt/data}
{:keys [jwt account-id]} ::jwt/data}
ctx]
(adp/-purge-blocklist lrs leeway) ; Update blocklist upon logout
(adp/-block-jwt lrs jwt exp)
Expand Down
37 changes: 36 additions & 1 deletion src/main/lrsql/admin/interceptors/jwt.clj
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@

(defn validate-jwt
"Validate that the header JWT is valid (e.g. not expired and signed properly).
If no-val? is true run an entirely separate decoding that gets the username
If `no-val?` is true run an entirely separate decoding that gets the username
and issuer claims, verifies a role and ensures the account if necessary."
[secret leeway {:keys [no-val?] :as no-val-opts}]
(interceptor
Expand Down Expand Up @@ -80,6 +80,41 @@
{:status 401
:body {:error "Unauthorized JSON Web Token!"}}))))}))

(defn validate-one-time-jwt
"Validate one-time JWTs. Checks that they are not expired and are signed
properly just like regular JWTs, then automatically revoke them."
[secret leeway]
(interceptor
{:name ::validate-one-time-jwt
:enter
(fn validate-one-time-jwt [ctx]
(let [{lrs :com.yetanalytics/lrs} ctx
token (get-in ctx [:request :params :token])
result (validate-jwt* lrs token secret leeway)]
(cond
(or (= :lrsql.admin/unauthorized-token-error result)
(nil? (:one-time-id result)))
(assoc (chain/terminate ctx)
:response
{:status 401
:body {:error "Unauthorized JSON Web Token!"}})
(map? result)
(let [{:keys [one-time-id]} result
block-result
(adp/-block-one-time-jwt lrs token one-time-id)]
(if-some [_ (:error block-result)]
(assoc (chain/terminate ctx)
:response
{:status 401
:body {:error "Unauthorized, JSON Web Token was not issued!"}})
;; Success!
(-> ctx ; So far :form-params and :edn-params are not implemented
(update-in [:request :params] dissoc :token)
(update-in [:request :query-params] dissoc :token)
(update-in [:request :json-params] dissoc :token)
(assoc-in [::data] result)
(assoc-in [:request :session ::data] result)))))))}))

(def validate-jwt-account
"Check that the account ID stored in the JWT exists in the account table.
This should go after `validate-jwt`, and MUST be present if `account-id`
Expand Down
21 changes: 21 additions & 0 deletions src/main/lrsql/admin/interceptors/lrs_management.clj
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
[io.pedestal.interceptor.chain :as chain]
[lrsql.admin.protocol :as adp]
[lrsql.spec.admin :as ads]
[lrsql.util.admin :as admin-u]
[lrsql.admin.interceptors.jwt :as jwt]
[com.yetanalytics.lrs.pedestal.interceptor.xapi :as i-xapi]
[com.yetanalytics.lrs-reactions.spec :as rs])
(:import [javax.servlet ServletOutputStream]))
Expand Down Expand Up @@ -45,6 +47,25 @@
;; CSV Download
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

;; See also: `admin.interceptors.account/generate-jwt`
(defn generate-one-time-jwt
[secret exp]
(interceptor
{:name ::convert-jwt
:enter
(fn convert-jwt [ctx]
(let [{lrs :com.yetanalytics/lrs
{:keys [account-id] :as jwt-claim} ::jwt/data}
ctx
{new-jwt :jwt exp :exp one-time-id :oti}
(admin-u/one-time-jwt jwt-claim secret exp)]
(adp/-create-one-time-jwt lrs new-jwt exp one-time-id)
(assoc (chain/terminate ctx)
:response
{:status 200
:body {:account-id account-id
:json-web-token new-jwt}})))}))

(def validate-property-paths
(interceptor
{:name ::validate-property-paths
Expand Down
4 changes: 4 additions & 0 deletions src/main/lrsql/admin/protocol.clj
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,12 @@
(defprotocol AdminJWTManager
(-purge-blocklist [this leeway]
"Purge the blocklist of any JWTs that have expired since they were added.")
(-create-one-time-jwt [this jwt exp one-time-id]
"Add a one-time JWT that will be blocked after it is validated.")
(-block-jwt [this jwt expiration]
"Block `jwt` and apply an associated `expiration` number of seconds. Returns an error if `jwt` is already in the blocklist.")
(-block-one-time-jwt [this jwt one-time-id]
"Similar to `-block-jwt` but specific to blocking one-time JWTs. Returns an error if `jwt` and `one-time-id` cannot be found or updated.")
(-jwt-blocked? [this jwt]
"Is `jwt` on the blocklist?"))

Expand Down
16 changes: 11 additions & 5 deletions src/main/lrsql/admin/routes.clj
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,8 @@
(ji/validate-jwt
jwt-secret jwt-leeway no-val-opts)
ji/validate-jwt-account
ai/no-content)]
ai/no-content)
:route-name :lrsql.admin.verify/get]
{:description "Verify that querying account is logged in"
:operationId :verify-own-account
:security [{:bearerAuth []}]
Expand Down Expand Up @@ -308,18 +309,23 @@
:route-name :lrsql.admin.reaction/delete]})

(defn admin-lrs-management-routes
[common-interceptors jwt-secret jwt-leeway no-val-opts]
[common-interceptors jwt-secret jwt-exp jwt-leeway no-val-opts]
#{["/admin/agents" :delete (conj common-interceptors
lm/validate-delete-actor-params
(ji/validate-jwt jwt-secret jwt-leeway no-val-opts)
ji/validate-jwt-account
lm/delete-actor)
:route-name :lrsql.lrs-management/delete-actor]
["/admin/csv/auth" :get (conj common-interceptors
(ji/validate-jwt
jwt-secret jwt-leeway no-val-opts)
ji/validate-jwt-account
(lm/generate-one-time-jwt jwt-secret jwt-exp))
:route-name :lrsql.lrs-management/download-csv-auth]
["/admin/csv" :get (conj common-interceptors
lm/validate-property-paths
lm/validate-query-params
#_(ji/validate-jwt jwt-secret jwt-leeway no-val-opts)
#_ji/validate-jwt-account
(ji/validate-one-time-jwt jwt-secret jwt-leeway)
lm/download-statement-csv)
:route-name :lrsql.lrs-management/download-csv]})

Expand Down Expand Up @@ -390,7 +396,7 @@
common-interceptors-oidc secret leeway no-val-opts))
(when enable-admin-delete-actor
(admin-lrs-management-routes
common-interceptors-oidc secret leeway no-val-opts)))))
common-interceptors-oidc secret exp leeway no-val-opts)))))

(defn add-openapi-route [{:keys [lrs head-opts version]} routes]
(let [common-interceptors (make-common-interceptors lrs head-opts)]
Expand Down
5 changes: 4 additions & 1 deletion src/main/lrsql/backend/protocol.clj
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,12 @@
(defprotocol JWTBlocklistBackend
;; Commands
(-insert-blocked-jwt! [this tx input])
(-insert-one-time-jwt! [this tx input])
(-update-one-time-jwt! [this tx input])
(-delete-blocked-jwt-by-time! [this tx input])
;; Queries
(-query-blocked-jwt [this tx input]))
(-query-blocked-jwt [this tx input])
(-query-one-time-jwt [this tx input]))

(defprotocol CredentialBackend
;; Commands
Expand Down
23 changes: 23 additions & 0 deletions src/main/lrsql/input/admin/jwt.clj
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,26 @@
(defn purge-blocklist-input
[leeway]
{:current-time (current-time leeway)})

;; One-time JWTs

(s/fdef insert-one-time-jwt-input
:args (s/cat :jwt ::jwts/jwt
:exp ::jwts/exp
:oti ::jwts/one-time-id)
:ret jwts/insert-one-time-jwt-input-spec)

(defn insert-one-time-jwt-input
[jwt exp oti]
{:jwt jwt
:eviction-time (eviction-time exp)
:one-time-id oti})

(s/fdef update-one-time-jwt-input
:args (s/cat :jwt ::jwts/jwt
:oti ::jwts/one-time-id))

(defn update-one-time-jwt-input
[jwt oti]
{:jwt jwt
:one-time-id oti})
Loading
Loading