Skip to content

Commit

Permalink
Merge pull request #435 from yetanalytics/admin-jwt-renew-endpoint
Browse files Browse the repository at this point in the history
[SQL-268] Admin JWT renewal endpoint
  • Loading branch information
kelvinqian00 authored Jan 14, 2025
2 parents 779fff8 + feebfb4 commit 74a4dd4
Show file tree
Hide file tree
Showing 12 changed files with 216 additions and 39 deletions.
1 change: 1 addition & 0 deletions doc/endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ The following examples use `http://example.org` as the URL body. All methods ret

The response body contains a newly generated JSON Web Token (JWT) on success. A `401 UNAUTHORIZED` status code is returned if the credentials are incorrect.
- `POST http://example.org/admin/account/logout`: Log out of the current account. This will revoke any unexpired JWTs associated with the user. (NOTE: This endpoint will return a `400 BAD REQUEST` error if `LRSQL_JWT_NO_VAL` is set to `true`.)
- `GET http://example.org/admin/account/renew`: Renew the current account's login session by issuing a new JWT. For a given JWT, the renewal is only granted if the current time is less than the `ref` timestamp (which is determined by `LRSQL_JWT_REFRESH_EXP_TIME`).
- `POST http://example.org/admin/account/create`: Create a new admin account. The request body must be a JSON object that contains `username` and `password` strings. The endpoint returns a JSON object with the ID (UUID) of the newly created user on success, and returns a `409 CONFLICT` if the account already exists.
- `DELETE http://example.org/admin/account`: Delete an existing account. The JSON request body must contain a UUID `account-id` value. The endpoint returns a JSON object with the ID of the deleted account on success and returns a `404 NOT FOUND` error if the account does not exist.
- `GET http://example.org/admin/account`: Return an array of all admin accounts in the system on success.
Expand Down
5 changes: 4 additions & 1 deletion doc/env_vars.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,11 @@ _NOTE:_ `LRSQL_STMT_RETRY_LIMIT` and `LRSQL_STMT_RETRY_BUDGET` are used to mitig

| Env Var | Config | Description | Default |
| --------------------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- |
| `LRSQL_JWT_EXP_TIME` | `jwtExpTime` | The amount of time, in seconds, after a JWT is created when it expires. Since JWTs are not revocable, **this this time should be short** (i.e. one hour or less). | `3600` (one hour) |
| `LRSQL_JWT_EXP_TIME` | `jwtExpTime` | The amount of time, in seconds, after a JWT is created when it expires. **This this time should be short** (i.e. one hour or less). | `3600` (one hour) |
| `LRSQL_JWT_EXP_LEEWAY` | `jwtExpLeeway` | The amount of time, in seconds, before or after the expiration instant when a JWT should still count as un-expired. Used to compensate for clock desync. Applied to both LRS and OIDC tokens. | `1` (one second) |
| `LRSQL_JWT_REFRESH_EXP_TIME` | `jwtRefreshExpTime` | The amount of time, in seconds, after a JWT is issued upon an initial login (_not_ a login renewal), after which login renewal can no longer be performed. Note: this is unaffected by expiration leeway. | `86400` (one day) |
| `LRSQL_JWT_REFRESH_INTERVAL` | `jwtRefreshInterval` | The amount of time that the client should poll the server in order to refresh the JWT. This **must** be less than `LRSQL_JWT_EXP_TIME`. | `3540` (59 minutes) |
| `LRSQL_JWT_INTERACTION_WINDOW` | `jwtInteractionWindow` | The amount of time before a potential JWT refresh that the client checks for interaction. This **must** be less than or equal to `LRSQL_JWT_REFRESH_INTERVAL`. | `600` (10 minutes) |
| `LRSQL_JWT_NO_VAL` | `jwtNoVal` | (**DANGEROUS!**) This flag removes JWT Token Validation and simply accepts token claims as configured by the associated variables below. It is extremely unlikely that you need this as it is for very specific proxy-overwrite authentication scenarios, and it poses a serious threat to system security if enabled. | `false` |
| `LRSQL_JWT_NO_VAL_UNAME` | `jwtNoValUname` | (**DANGEROUS!** See `LRSQL_JWT_NO_VAL`) This variable configures which claim key to use for the username when token validation is turned off. | Not Set |
| `LRSQL_JWT_NO_VAL_ISSUER` | `jwtNoValIssuer` | (**DANGEROUS!** See `LRSQL_JWT_NO_VAL`) This variable configures which claim key to use for the issuer when token validation is turned off. | Not Set |
Expand Down
3 changes: 3 additions & 0 deletions resources/lrsql/config/prod/default/webserver.edn
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
:key-enable-selfie #boolean #or [#env LRSQL_KEY_ENABLE_SELFIE true]
:jwt-exp-time #long #or [#env LRSQL_JWT_EXP_TIME 3600]
:jwt-exp-leeway #long #or [#env LRSQL_JWT_EXP_LEEWAY 1]
:jwt-refresh-exp-time #long #or [#env LRSQL_JWT_REFRESH_EXP_TIME 86400]
:jwt-refresh-interval #long #or [#env LRSQL_JWT_REFRESH_INTERVAL 3540]
:jwt-interaction-window #long #or [#env LRSQL_JWT_INTERACTION_WINDOW 600]
:jwt-no-val #boolean #or [#env LRSQL_JWT_NO_VAL false]
:jwt-no-val-uname #or [#env LRSQL_JWT_NO_VAL_UNAME nil]
:jwt-no-val-issuer #or [#env LRSQL_JWT_NO_VAL_ISSUER nil]
Expand Down
3 changes: 3 additions & 0 deletions resources/lrsql/config/test/default/webserver.edn
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
:key-enable-selfie true
:jwt-exp-time 3600
:jwt-exp-leeway 1
:jwt-refresh-exp-time 86400
:jwt-refresh-interval 3540
:jwt-interaction-window 600
:jwt-no-val false
:jwt-no-val-uname nil
:jwt-no-val-issuer nil
Expand Down
32 changes: 29 additions & 3 deletions src/main/lrsql/admin/interceptors/account.clj
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
(ns lrsql.admin.interceptors.account
(:require [clojure.spec.alpha :as s]
[java-time.api :as jt]
[io.pedestal.interceptor :refer [interceptor]]
[io.pedestal.interceptor.chain :as chain]
[lrsql.admin.protocol :as adp]
Expand Down Expand Up @@ -262,21 +263,46 @@

(defn generate-jwt
"Upon account login, generate a new JSON web token."
[secret exp]
[secret exp ref leeway]
(interceptor
{:name ::generate-jwt
:enter
(fn generate-jwt [ctx]
(let [{{:keys [account-id]} ::data}
(let [{lrs :com.yetanalytics/lrs
{:keys [account-id]} ::data}
ctx
json-web-token
(admin-u/account-id->jwt account-id secret exp)]
(admin-u/account-id->jwt account-id secret exp ref)]
(adp/-purge-blocklist lrs leeway) ; Update blocklist upon login
(assoc ctx
:response
{:status 200
:body {:account-id account-id
:json-web-token json-web-token}})))}))

(defn renew-admin-jwt
[secret exp]
(interceptor
{:name ::renew-jwt
:enter
(fn renew-jwt [ctx]
(let [{{:keys [account-id refresh-exp]} ::jwt/data} ctx
curr-time (u/current-time)]
(if (jt/before? curr-time refresh-exp)
(let [json-web-token (admin-u/account-id->jwt* account-id
secret
exp
refresh-exp)]
(assoc ctx
:response
{:status 200
:body {:account-id account-id
:json-web-token json-web-token}}))
(assoc (chain/terminate ctx)
:response
{:status 401
:body {:error "Attempting JWT login after refresh expiry."}}))))}))

(def ^:private block-admin-jwt-error-msg
"This operation is unsupported when `LRSQL_JWT_NO_VAL` is set to `true`.")

Expand Down
8 changes: 6 additions & 2 deletions src/main/lrsql/admin/interceptors/ui.clj
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@
config to inject:
:enable-admin-status - boolean, determines if the admin status endpoint is
enabled."
[{:keys [enable-admin-delete-actor
[{:keys [jwt-refresh-interval
jwt-interaction-window
enable-admin-delete-actor
enable-admin-status
admin-language-code
enable-reactions
Expand All @@ -39,7 +41,9 @@
{:status 200
:body
(merge
(cond-> {:url-prefix url-prefix
(cond-> {:jwt-refresh-interval jwt-refresh-interval
:jwt-interaction-window jwt-interaction-window
:url-prefix url-prefix
:proxy-path proxy-path
:enable-admin-delete-actor enable-admin-delete-actor
:enable-admin-status enable-admin-status
Expand Down
25 changes: 21 additions & 4 deletions src/main/lrsql/admin/routes.clj
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,15 @@
(i/lrs-interceptor lrs)])

(defn admin-account-routes
[common-interceptors jwt-secret jwt-exp jwt-leeway {:keys [no-val?] :as no-val-opts}]
[common-interceptors jwt-secret jwt-exp jwt-ref jwt-leeway {:keys [no-val?] :as no-val-opts}]
#{;; Log into an existing account
(gc/annotate
["/admin/account/login" :post (conj common-interceptors
(ai/validate-params
:strict? false)
ai/authenticate-admin
(ai/generate-jwt
jwt-secret jwt-exp))
jwt-secret jwt-exp jwt-ref jwt-leeway))
:route-name :lrsql.admin.account/login]
{:description "Log into an existing account"
:requestBody (g/request (gs/o {:username :t#string
Expand All @@ -67,6 +67,18 @@
(gs/o {:account-id :t#string}))
400 (g/rref :error-400)
401 (g/rref :error-401)}})
;; Renew current account JWT to maintain login
(gc/annotate
["/admin/account/renew" :get (conj common-interceptors
(ji/validate-jwt
jwt-secret jwt-leeway no-val-opts)
ji/validate-jwt-account
(ai/renew-admin-jwt jwt-secret jwt-exp))
:route-name :lrsql.admin.account/renew]
{:description "Renew current account login"
:operationId :renew
:responses {200 (g/response "Account ID and JWT")
401 (g/rref :error-401)}})
;; Create new account
(gc/annotate
["/admin/account/create" :post (conj common-interceptors
Expand Down Expand Up @@ -304,6 +316,7 @@
accounts."
[{:keys [lrs
exp
ref
leeway
secret
no-val?
Expand All @@ -312,6 +325,8 @@
no-val-role-key
no-val-role
no-val-logout-url
refresh-interval
interaction-window
enable-admin-delete-actor
enable-admin-ui
enable-admin-status
Expand All @@ -337,14 +352,16 @@
(cset/union routes
(when enable-account-routes
(admin-account-routes
common-interceptors-oidc secret exp leeway no-val-opts))
common-interceptors-oidc secret exp ref leeway no-val-opts))
(admin-cred-routes
common-interceptors-oidc secret leeway no-val-opts)
(when enable-admin-ui
(admin-ui-routes
(into common-interceptors
oidc-ui-interceptors)
{:enable-admin-status enable-admin-status
{:jwt-refresh-interval refresh-interval
:jwt-interaction-window interaction-window
:enable-admin-status enable-admin-status
:enable-reactions enable-reaction-routes
:no-val? no-val?
:no-val-logout-url no-val-logout-url
Expand Down
12 changes: 11 additions & 1 deletion src/main/lrsql/spec/config.clj
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@

(s/def ::jwt-exp-time pos-int?)
(s/def ::jwt-exp-leeway nat-int?)
(s/def ::jwt-refresh-exp-time pos-int?)
(s/def ::jwt-refresh-interval pos-int?)
(s/def ::jwt-interaction-window pos-int?)
(s/def ::jwt-no-val boolean?)
(s/def ::jwt-no-val-uname (s/nilable string?))
(s/def ::jwt-no-val-issuer (s/nilable string?))
Expand Down Expand Up @@ -210,6 +213,9 @@
::key-enable-selfie
::jwt-exp-time
::jwt-exp-leeway
::jwt-refresh-exp-time
::jwt-refresh-interval
::jwt-interaction-window
::jwt-no-val
::enable-admin-ui
::enable-admin-status
Expand Down Expand Up @@ -246,7 +252,11 @@
(if jwt-no-val
(and jwt-no-val-uname jwt-no-val-issuer jwt-no-val-role-key
jwt-no-val-role)
true))))
true))
;; validation for JWT temporal intervals
(fn [{:keys [jwt-exp-time jwt-refresh-interval jwt-interaction-window]}]
(and (<= jwt-interaction-window jwt-refresh-interval)
(< jwt-refresh-interval jwt-exp-time)))))

(s/def ::tuning
(s/keys :opt-un [::enable-jsonb]))
Expand Down
12 changes: 9 additions & 3 deletions src/main/lrsql/system/webserver.clj
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
sec-head-content
allow-all-origins
allowed-origins
jwt-refresh-interval
jwt-interaction-window
jwt-no-val
jwt-no-val-uname
jwt-no-val-issuer
Expand All @@ -50,8 +52,9 @@
enable-clamav
clamav-host
clamav-port]
jwt-exp :jwt-exp-time
jwt-lwy :jwt-exp-leeway}
jwt-exp :jwt-exp-time
jwt-lwy :jwt-exp-leeway
jwt-ref :jwt-refresh-exp-time}
config
;; Keystore and private key
;; The private key is used as the JWT symmetric secret
Expand Down Expand Up @@ -91,7 +94,10 @@
(add-admin-routes
{:lrs lrs
:exp jwt-exp
:ref jwt-ref
:leeway jwt-lwy
:refresh-interval jwt-refresh-interval
:interaction-window jwt-interaction-window
:no-val? jwt-no-val
:no-val-issuer jwt-no-val-issuer
:no-val-uname jwt-no-val-uname
Expand All @@ -109,7 +115,7 @@
:enable-reaction-routes enable-reactions
:oidc-interceptors oidc-admin-interceptors
:oidc-ui-interceptors oidc-admin-ui-interceptors
:head-opts head-opts})
:head-opts head-opts})
(add-openapi-route
{:lrs lrs
:head-opts head-opts
Expand Down
51 changes: 36 additions & 15 deletions src/main/lrsql/util/admin.clj
Original file line number Diff line number Diff line change
Expand Up @@ -23,20 +23,40 @@
;; JSON Web Tokens
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(defn- jwt-claim
"Create the JWT claim, i.e. payload, containing the account ID `:acc`, the
issued-at time `:iat`, the expiration time `:exp`, and the refresh
expiration time `:ref`.
Time values MUST be a number containing a NumericDate value ie. a JSON
numeric value representing the number of seconds (not milliseconds!) from
the 1970 UTC start."
[account-id ctime etime rtime]
{:acc account-id
:iat (quot (u/time->millis ctime) 1000)
:exp (quot (u/time->millis etime) 1000)
:ref (quot (u/time->millis rtime) 1000)})

(defn account-id->jwt*
"Same as `account-id->jwt`, but uses a pre-existing `rtime` timestamp instead
of an `ref` offset."
[account-id secret exp rtime]
(let [ctime (u/current-time)
etime (u/offset-time ctime exp :seconds)
claim (jwt-claim account-id ctime etime rtime)]
(bj/sign claim secret)))

(defn account-id->jwt
"Generate a new signed JSON Web Token with `account-id` in the claim
as a custom `:acc` field. The issued-at and expiration time are given as
`:iat` and `:exp`, respectively; the expiration time offset is given by
`exp` in seconds."
[account-id secret exp]
as a custom `:acc` field. The issued-at, expiration, and refresh expiration
times are given as `:iat`, `:exp`, and `:ref`, respectively. The token and
refresh expiration time offsets are given by `exp` and `ref`, respectively,
in seconds."
[account-id secret exp ref]
(let [ctime (u/current-time)
etime (u/offset-time ctime exp :seconds)
claim {:acc account-id
;; Time values MUST be a number containing a NumericDate value
;; ie. a JSON numeric value representing the number of seconds
;; (not milliseconds!) from the 1970 UTC start.
:iat (quot (u/time->millis ctime) 1000)
:exp (quot (u/time->millis etime) 1000)}]
rtime (u/offset-time ctime ref :seconds)
claim (jwt-claim account-id ctime etime rtime)]
(bj/sign claim secret)))

(defn header->jwt
Expand All @@ -48,16 +68,17 @@

(defn jwt->payload
"Given the JSON Web Token `tok`, unsign and verify the token using `secret`.
Return a map of `:account-id` and `:expiration` if valid, otherwise return
`:lrsql.admin/unauthorized-token-error`.
Return a map of `:account-id`, `:expiration`, and `:refresh-exp` if valid,
otherwise return `:lrsql.admin/unauthorized-token-error`.
`leeway` is a time amount (in seconds) provided to compensate for
clock drift."
[tok secret leeway]
(if tok ; Avoid encountering a null pointer exception
(try
(let [{:keys [acc exp]} (bj/unsign tok secret {:leeway leeway})]
{:account-id (u/str->uuid acc)
:expiration (u/millis->time (* 1000 exp))})
(let [{:keys [acc exp ref]} (bj/unsign tok secret {:leeway leeway})]
{:account-id (u/str->uuid acc)
:expiration (u/millis->time (* 1000 exp))
:refresh-exp (u/millis->time (* 1000 ref))})
(catch clojure.lang.ExceptionInfo _
:lrsql.admin/unauthorized-token-error))
:lrsql.admin/unauthorized-token-error))
Expand Down
Loading

0 comments on commit 74a4dd4

Please sign in to comment.