-
Notifications
You must be signed in to change notification settings - Fork 330
Use TOTP for magic codes #2398
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
base: main
Are you sure you want to change the base?
Use TOTP for magic codes #2398
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| alter table apps drop column totp_secret_key_enc; | ||
| alter table apps drop column totp_expiry_minutes; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| alter table apps add column totp_secret_key_enc bytea; | ||
| alter table apps add column totp_expiry_minutes integer; |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,10 +1,12 @@ | ||||||||||||||||||||||||||||||||||||||||||||||
| (ns instant.model.app-user-magic-code | ||||||||||||||||||||||||||||||||||||||||||||||
| (:require | ||||||||||||||||||||||||||||||||||||||||||||||
| [instant.flags :as flags] | ||||||||||||||||||||||||||||||||||||||||||||||
| [instant.jdbc.aurora :as aurora] | ||||||||||||||||||||||||||||||||||||||||||||||
| [instant.model.app :as app-model] | ||||||||||||||||||||||||||||||||||||||||||||||
| [instant.model.app-user :as app-user-model] | ||||||||||||||||||||||||||||||||||||||||||||||
| [instant.model.instant-user :as instant-user-model] | ||||||||||||||||||||||||||||||||||||||||||||||
| [instant.system-catalog-ops :refer [update-op]] | ||||||||||||||||||||||||||||||||||||||||||||||
| [instant.totp :as totp] | ||||||||||||||||||||||||||||||||||||||||||||||
| [instant.util.crypt :as crypt-util] | ||||||||||||||||||||||||||||||||||||||||||||||
| [instant.util.exception :as ex] | ||||||||||||||||||||||||||||||||||||||||||||||
| [instant.util.string :refer [rand-num-str]]) | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -24,47 +26,81 @@ | |||||||||||||||||||||||||||||||||||||||||||||
| (let [created-at ^Date (:created_at magic-code)] | ||||||||||||||||||||||||||||||||||||||||||||||
| (< (+ (.getTime created-at) ttl-ms) (System/currentTimeMillis))))) | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| (defn totp-secret-key [app-id ^String email] | ||||||||||||||||||||||||||||||||||||||||||||||
| (let [app-secret-key (app-model/get-totp-secret-key {:id app-id}) | ||||||||||||||||||||||||||||||||||||||||||||||
| derived-key (crypt-util/hmac-256 app-secret-key (.getBytes email))] | ||||||||||||||||||||||||||||||||||||||||||||||
| derived-key)) | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| (defn generate-totp [app-id ^String email] | ||||||||||||||||||||||||||||||||||||||||||||||
| (let [secret-key (totp-secret-key app-id email)] | ||||||||||||||||||||||||||||||||||||||||||||||
| (totp/generate-totp secret-key))) | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| (defn create! | ||||||||||||||||||||||||||||||||||||||||||||||
| ([params] | ||||||||||||||||||||||||||||||||||||||||||||||
| (create! (aurora/conn-pool :write) params)) | ||||||||||||||||||||||||||||||||||||||||||||||
| ([conn {:keys [app-id email id code]}] | ||||||||||||||||||||||||||||||||||||||||||||||
| ([conn {:keys [app-id email id]}] | ||||||||||||||||||||||||||||||||||||||||||||||
| (let [id (or id (random-uuid)) | ||||||||||||||||||||||||||||||||||||||||||||||
| code (or code (rand-code))] | ||||||||||||||||||||||||||||||||||||||||||||||
| (update-op | ||||||||||||||||||||||||||||||||||||||||||||||
| conn | ||||||||||||||||||||||||||||||||||||||||||||||
| {:app-id app-id | ||||||||||||||||||||||||||||||||||||||||||||||
| :etype etype} | ||||||||||||||||||||||||||||||||||||||||||||||
| (fn [{:keys [transact! get-entity]}] | ||||||||||||||||||||||||||||||||||||||||||||||
| (transact! [{:id id | ||||||||||||||||||||||||||||||||||||||||||||||
| :etype etype | ||||||||||||||||||||||||||||||||||||||||||||||
| :codeHash (-> code | ||||||||||||||||||||||||||||||||||||||||||||||
| crypt-util/str->sha256 | ||||||||||||||||||||||||||||||||||||||||||||||
| crypt-util/bytes->hex-string) | ||||||||||||||||||||||||||||||||||||||||||||||
| :email email}]) | ||||||||||||||||||||||||||||||||||||||||||||||
| (assoc (get-entity id) | ||||||||||||||||||||||||||||||||||||||||||||||
| :code code)))))) | ||||||||||||||||||||||||||||||||||||||||||||||
| code (if (flags/generate-with-totp?) | ||||||||||||||||||||||||||||||||||||||||||||||
| (generate-totp app-id email) | ||||||||||||||||||||||||||||||||||||||||||||||
| (rand-code))] | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| (when (or (not (flags/validate-with-totp?)) | ||||||||||||||||||||||||||||||||||||||||||||||
| (not (flags/generate-with-totp?)) | ||||||||||||||||||||||||||||||||||||||||||||||
| (flags/dual-write-totp?)) | ||||||||||||||||||||||||||||||||||||||||||||||
| (update-op | ||||||||||||||||||||||||||||||||||||||||||||||
| conn | ||||||||||||||||||||||||||||||||||||||||||||||
| {:app-id app-id | ||||||||||||||||||||||||||||||||||||||||||||||
| :etype etype} | ||||||||||||||||||||||||||||||||||||||||||||||
| (fn [{:keys [transact!]}] | ||||||||||||||||||||||||||||||||||||||||||||||
| (transact! [{:id id | ||||||||||||||||||||||||||||||||||||||||||||||
| :etype etype | ||||||||||||||||||||||||||||||||||||||||||||||
| :codeHash (-> code | ||||||||||||||||||||||||||||||||||||||||||||||
| crypt-util/str->sha256 | ||||||||||||||||||||||||||||||||||||||||||||||
| crypt-util/bytes->hex-string) | ||||||||||||||||||||||||||||||||||||||||||||||
| :email email}])))) | ||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+47
to
+60
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cutting off the DB write removes both issuance tracking and one-time use. When Also applies to: 84-103 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||
| code))) | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| (defn validate-totp! [app-id ^String email ^String code] | ||||||||||||||||||||||||||||||||||||||||||||||
| (let [secret-key (totp-secret-key app-id email) | ||||||||||||||||||||||||||||||||||||||||||||||
| expiry-periods (or (some-> (app-model/get-by-id! {:id app-id}) | ||||||||||||||||||||||||||||||||||||||||||||||
| :totp_expiry_minutes | ||||||||||||||||||||||||||||||||||||||||||||||
| (max 5) ;; Minimum of 5 minutes | ||||||||||||||||||||||||||||||||||||||||||||||
| (min 1440) ;; Maximum of 1 day | ||||||||||||||||||||||||||||||||||||||||||||||
| (* 60) | ||||||||||||||||||||||||||||||||||||||||||||||
| (/ totp/default-time-step) | ||||||||||||||||||||||||||||||||||||||||||||||
| (Math/ceil)) | ||||||||||||||||||||||||||||||||||||||||||||||
| (/ (flags/totp-default-expiry-seconds) totp/default-time-step))] | ||||||||||||||||||||||||||||||||||||||||||||||
| ;; Have to add 1 extra period in case the code was generated near the | ||||||||||||||||||||||||||||||||||||||||||||||
| ;; end of a period | ||||||||||||||||||||||||||||||||||||||||||||||
| (when-not (totp/valid-totp? secret-key (inc expiry-periods) code) | ||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+66
to
+76
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested fix- (when-not (totp/valid-totp? secret-key (inc expiry-periods) code)
+ (when-not (totp/valid-totp? secret-key expiry-periods code)📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||
| (ex/throw-expiration-err! :app-user-magic-code {:args [{:code code | ||||||||||||||||||||||||||||||||||||||||||||||
| :email email}]})))) | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| (defn consume! | ||||||||||||||||||||||||||||||||||||||||||||||
| ([params] | ||||||||||||||||||||||||||||||||||||||||||||||
| (consume! (aurora/conn-pool :write) params)) | ||||||||||||||||||||||||||||||||||||||||||||||
| ([conn {:keys [email code app-id] :as params}] | ||||||||||||||||||||||||||||||||||||||||||||||
| (update-op | ||||||||||||||||||||||||||||||||||||||||||||||
| conn | ||||||||||||||||||||||||||||||||||||||||||||||
| {:app-id app-id | ||||||||||||||||||||||||||||||||||||||||||||||
| :etype etype} | ||||||||||||||||||||||||||||||||||||||||||||||
| (fn [{:keys [get-entity-where delete-entity!]}] | ||||||||||||||||||||||||||||||||||||||||||||||
| (let [code-hash (-> code | ||||||||||||||||||||||||||||||||||||||||||||||
| crypt-util/str->sha256 | ||||||||||||||||||||||||||||||||||||||||||||||
| crypt-util/bytes->hex-string) | ||||||||||||||||||||||||||||||||||||||||||||||
| {code-id :id} (get-entity-where | ||||||||||||||||||||||||||||||||||||||||||||||
| {:codeHash code-hash | ||||||||||||||||||||||||||||||||||||||||||||||
| :email email})] | ||||||||||||||||||||||||||||||||||||||||||||||
| (ex/assert-record! code-id :app-user-magic-code {:args [params]}) | ||||||||||||||||||||||||||||||||||||||||||||||
| (let [code (delete-entity! code-id)] | ||||||||||||||||||||||||||||||||||||||||||||||
| (ex/assert-record! code :app-user-magic-code {:args [params]}) | ||||||||||||||||||||||||||||||||||||||||||||||
| (when (expired? code) | ||||||||||||||||||||||||||||||||||||||||||||||
| (ex/throw-expiration-err! :app-user-magic-code {:args [params]})) | ||||||||||||||||||||||||||||||||||||||||||||||
| code)))))) | ||||||||||||||||||||||||||||||||||||||||||||||
| (when (or (not (flags/validate-with-totp?)) | ||||||||||||||||||||||||||||||||||||||||||||||
| (flags/dual-write-totp?)) | ||||||||||||||||||||||||||||||||||||||||||||||
| (update-op | ||||||||||||||||||||||||||||||||||||||||||||||
| conn | ||||||||||||||||||||||||||||||||||||||||||||||
| {:app-id app-id | ||||||||||||||||||||||||||||||||||||||||||||||
| :etype etype} | ||||||||||||||||||||||||||||||||||||||||||||||
| (fn [{:keys [get-entity-where delete-entity!]}] | ||||||||||||||||||||||||||||||||||||||||||||||
| (let [code-hash (-> code | ||||||||||||||||||||||||||||||||||||||||||||||
| crypt-util/str->sha256 | ||||||||||||||||||||||||||||||||||||||||||||||
| crypt-util/bytes->hex-string) | ||||||||||||||||||||||||||||||||||||||||||||||
| {code-id :id} (get-entity-where | ||||||||||||||||||||||||||||||||||||||||||||||
| {:codeHash code-hash | ||||||||||||||||||||||||||||||||||||||||||||||
| :email email})] | ||||||||||||||||||||||||||||||||||||||||||||||
| (ex/assert-record! code-id :app-user-magic-code {:args [params]}) | ||||||||||||||||||||||||||||||||||||||||||||||
| (let [code (delete-entity! code-id)] | ||||||||||||||||||||||||||||||||||||||||||||||
| (ex/assert-record! code :app-user-magic-code {:args [params]}) | ||||||||||||||||||||||||||||||||||||||||||||||
| (when (expired? code) | ||||||||||||||||||||||||||||||||||||||||||||||
| (ex/throw-expiration-err! :app-user-magic-code {:args [params]}))))))) | ||||||||||||||||||||||||||||||||||||||||||||||
| (when (flags/validate-with-totp?) | ||||||||||||||||||||||||||||||||||||||||||||||
| (validate-totp! app-id email code)))) | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| (comment | ||||||||||||||||||||||||||||||||||||||||||||||
| (def instant-user (instant-user-model/get-by-email | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -117,12 +117,12 @@ | |
| :email "stopa@instantdb.com"})) | ||
|
|
||
| (def m (app-user-magic-code-model/create! | ||
| {:id (random-uuid) :user-id (:id runtime-user) :code (app-user-magic-code-model/rand-code) | ||
| {:id (random-uuid) :user-id (:id runtime-user) | ||
| :app-id (:id app)})) | ||
| (verify-magic-code-post {:body {:email "stopainstantdb.com" :code (:code m)}}) | ||
| (verify-magic-code-post {:body {:email "stopa@instantdb.com" :code (:code m)}}) | ||
| (verify-magic-code-post {:body {:email "stopainstantdb.com" :code m}}) | ||
| (verify-magic-code-post {:body {:email "stopa@instantdb.com" :code m}}) | ||
| (verify-magic-code-post {:body {:email "stopa@instantdb.com" :code "0" :app-id (:id app)}}) | ||
| (verify-magic-code-post {:body {:email "stopa@instantdb.com" :code (:code m) :app-id (:id app)}})) | ||
| (verify-magic-code-post {:body {:email "stopa@instantdb.com" :code m :app-id (:id app)}})) | ||
|
Comment on lines
119
to
+125
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Check the current signature of app-user-magic-code-model/create!
rg -n "defn create!" server/src/instant/model/app_user_magic_code.clj -A 10Repository: instantdb/instant Length of output: 458 🏁 Script executed: #!/bin/bash
# Get the complete create! function implementation
rg -n "defn create!" server/src/instant/model/app_user_magic_code.clj -A 30Repository: instantdb/instant Length of output: 1276 🏁 Script executed: #!/bin/bash
# Search for other usages of create! in the test files to see correct parameter usage
rg "app-user-magic-code-model/create!" server/src/instant/runtime/routes.clj -B 2 -A 5Repository: instantdb/instant Length of output: 517 🏁 Script executed: #!/bin/bash
# Look for test files that might show how create! should be used
fd "test.*\.clj$" | xargs rg "app-user-magic-code-model/create!" -l 2>/dev/null | head -5Repository: instantdb/instant Length of output: 43 The comment block test code has incorrect parameters for The 🤖 Prompt for AI Agents |
||
|
|
||
| ;; ----- | ||
| ;; Guest sign in | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| (ns instant.totp | ||
| (:require | ||
| [instant.util.crypt :as crypt-util] | ||
| [clojure.string]) | ||
| (:import | ||
| (java.time Instant))) | ||
|
|
||
| ;; Time step is 5 minutes (in seconds), which half the default expiration. If an | ||
| ;; app accepts tokens for longer, we just check all of the previous 5 minute | ||
| ;; intervals until we find a matching token or exceed the max time. In the worst | ||
| ;; case (24 hours), we'll have to do 288 checks. | ||
| ;; We can't change this without adding extra code for backwards compatibility | ||
| (def default-time-step 300) | ||
|
|
||
| (def digit-count 6) | ||
|
|
||
| ;; TOTP generation follows the reference impl in https://www.rfc-editor.org/rfc/rfc6238#page-9 | ||
|
|
||
| (defn left-pad [s len] | ||
| (let [pad-len (- len (count s))] | ||
| (if (pos? pad-len) | ||
| (str (clojure.string/join (repeat pad-len "0")) s) | ||
| s))) | ||
|
|
||
| (def digits-power | ||
| [1 | ||
| 10 | ||
| 100 | ||
| 1000 | ||
| 10000 | ||
| 100000 | ||
| 1000000 | ||
| 10000000 | ||
| 100000000]) | ||
|
|
||
| ;; For use in testing | ||
| (def ^:dynamic *now* nil) | ||
|
|
||
| (defn generate-totp | ||
| "Generates a TOTP code. For testing, it accepts the number of | ||
| digits and a time step, but you should always use the default | ||
| values in production." | ||
| ([^bytes secret-key] | ||
| (generate-totp secret-key (or *now* (Instant/now)))) | ||
| ([^bytes secret-key ^Instant time] | ||
| (generate-totp secret-key time 6 default-time-step)) | ||
| ([^bytes secret-key ^Instant time code-digits time-step] | ||
| (let [t (/ (.getEpochSecond time) | ||
| time-step) | ||
| t-bytes (-> t | ||
| (Long/toHexString) | ||
| (.toUpperCase) | ||
| (left-pad 16) | ||
| (crypt-util/hex-string->bytes)) | ||
| hash (crypt-util/hmac-256 secret-key t-bytes) | ||
| offset (bit-and (aget hash (dec (alength hash))) | ||
| 0xf) | ||
| binary (bit-or | ||
| (bit-shift-left (bit-and (aget hash offset) 0x7f) | ||
| 24) | ||
| (bit-shift-left (bit-and (aget hash (+ offset 1)) 0xff) | ||
| 16) | ||
| (bit-shift-left (bit-and (aget hash (+ offset 2)) 0xff) | ||
| 8) | ||
| (bit-and (aget hash (+ offset 3)) 0xff)) | ||
| otp (mod binary (nth digits-power code-digits))] | ||
| (left-pad (Integer/toString otp) code-digits)))) | ||
|
|
||
| (defn valid-totp? | ||
| "Returns true if the totp code is valid. Will go back up to max-10-minute-intervals." | ||
| ([^bytes secret-key max-5-minute-intervals ^String code] | ||
| (valid-totp? secret-key (or *now* (Instant/now)) max-5-minute-intervals code)) | ||
| ([^bytes secret-key | ||
| ^Instant time | ||
| max-5-minute-intervals | ||
| ^String code] | ||
| (loop [remaining-intervals max-5-minute-intervals | ||
| time time] | ||
| (when (pos? remaining-intervals) | ||
| (if (crypt-util/constant-string= code (generate-totp secret-key time)) | ||
| true | ||
| (recur (dec remaining-intervals) | ||
| (.minusSeconds time default-time-step))))))) |
Uh oh!
There was an error while loading. Please reload this page.