From fa6193cbece76c7b1b13c24d1558b518e6bdec36 Mon Sep 17 00:00:00 2001 From: Coby Tamayo Date: Wed, 25 Mar 2026 23:01:46 -0700 Subject: [PATCH 01/38] ForgotPasswordPage component --- .../systems/bread/alpha/cms/theme/rise.cljc | 42 ++++++++++++++++++- plugins/auth/auth.i18n.edn | 6 +++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/cms/themes/rise/src/systems/bread/alpha/cms/theme/rise.cljc b/cms/themes/rise/src/systems/bread/alpha/cms/theme/rise.cljc index d6a4578a..f290f30a 100644 --- a/cms/themes/rise/src/systems/bread/alpha/cms/theme/rise.cljc +++ b/cms/themes/rise/src/systems/bread/alpha/cms/theme/rise.cljc @@ -1,6 +1,5 @@ (ns systems.bread.alpha.cms.theme.rise (:require - [clojure.java.io :as io] [clojure.string :as string] [systems.bread.alpha.cms.theme :as theme] @@ -231,6 +230,46 @@ (ErrorMessage {:message (:auth/invalid-username-password i18n)}))) (Submit (:auth/login i18n))]])})) +(defc ForgotPasswordPage + [{:keys [config hook i18n ring/anti-forgery-token-field ring/request-method]}] + {:extends Page + :doc + "The standard Bread forgot password page, designed to work with the + `::auth/forgot-password=>` dispatcher. + dispatcher. You typically won't need to call this component from your code, + except to reference it from your route if implementing custom routing." + :doc/preview? true + :doc/default-data + {:config {:site/name "Site name"} + :hook (fn hook [_ x & _] x) + :ring/anti-forgery-token-field (constantly nil)} + :examples + '[{:doc "Forgot password" + :description "Initial form" + :args ({:ring/request-method :get})} + {:doc "Submitted" + :description "Submitted form" + :args ({:ring/request-method :post})} + ,]} + (let [post? (= :post request-method)] + {:title (:auth/forgot-password i18n) + :content + (if post? + [:main + (hook ::html.forgot-heading [:h1 (:auth/forgot-password i18n)]) + (hook ::html.forgot-acknowledgement + [:p.instruct (:auth/reset-email-sent i18n)])] + [:main + [:form.flex.col {:name :bread-login :method :post} + (anti-forgery-token-field) + (hook ::html.forgot-heading [:h1 (:auth/forgot-password i18n)]) + (hook ::html.enter-confirm-new-password + [:p.instruct (:auth/enter-username i18n)]) + (Field :username + :label (:auth/username i18n) + :input-attrs {:maxlength (:auth/max-password-length config)}) + (Submit (:auth/reset-password i18n))]])})) + (defc ResetPasswordPage [{:as data :keys [config hook i18n session dir ring/anti-forgery-token-field] @@ -825,6 +864,7 @@ ErrorMessage Page LoginPage + ForgotPasswordPage ResetPasswordPage AccountNav SettingsPage diff --git a/plugins/auth/auth.i18n.edn b/plugins/auth/auth.i18n.edn index 2ff959b2..161944a4 100644 --- a/plugins/auth/auth.i18n.edn +++ b/plugins/auth/auth.i18n.edn @@ -17,9 +17,12 @@ #:auth{ :account-locked "Account locked" :continue "Continue" + :enter-confirm-new-password "Please enter and confirm your new password." :enter-totp "Please enter the one-time code from your authenticator app." :enter-totp-next "Next, enter the one-time code from your authenticator app." :enter-username-password "Please enter your username and password." + :enter-username "Please enter your username." + :forgot-password "Forgot password" :invalid-totp "Invalid code. Please try again." :invalid-username-password "Invalid username or password." :login "Login" @@ -35,6 +38,9 @@ :passwords-must-match "Password and confirmation must match." :please-scan-qr-code"Please scan the QR code to finish setting up multi-factor authentication." :qr-code "QR code" + :reset "Reset" + :reset-password "Reset password" + :reset-email-sent "If you have an account, an email has been sent to your primary email" :too-many-attempts "You have made too many attempts to log in. Please try again later." :username "Username" :verify "Verify" From 653cf122c8c464167b6ea921a04645db801d6c47 Mon Sep 17 00:00:00 2001 From: Coby Tamayo Date: Wed, 25 Mar 2026 23:28:07 -0700 Subject: [PATCH 02/38] ResetPasswordPage --- .../systems/bread/alpha/cms/theme/rise.cljc | 36 ++++++++++++------- plugins/auth/auth.i18n.edn | 1 + 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/cms/themes/rise/src/systems/bread/alpha/cms/theme/rise.cljc b/cms/themes/rise/src/systems/bread/alpha/cms/theme/rise.cljc index f290f30a..9edba7a3 100644 --- a/cms/themes/rise/src/systems/bread/alpha/cms/theme/rise.cljc +++ b/cms/themes/rise/src/systems/bread/alpha/cms/theme/rise.cljc @@ -272,8 +272,8 @@ (defc ResetPasswordPage [{:as data - :keys [config hook i18n session dir ring/anti-forgery-token-field] - :auth/keys [result]}] + :keys [auth/result config hook i18n session dir ring/anti-forgery-token-field + ring/request-method user validation]}] {:extends Page :doc "The standard Bread password reset page, designed to work with the `::auth/reset=>` @@ -283,14 +283,21 @@ {:config {:site/name "Site name"} :hook (fn hook [_ x & _] x) :ring/anti-forgery-token-field (constantly nil)} + :doc/preview? true :examples '[{:doc "Password reset" - :preview? true :description "A valid code must be present in the query string." - :args ({})}]} - (let [user (or (:user session) (:auth/user session)) - error? (false? (:valid result))] + :args ({:validation [true nil]})} + {:doc "With error" + :args ({:ring/request-method :get + :validation [false :auth/invalid-reset]})} + {:doc "Submitted with error" + :args ({:ring/request-method :post + :validation [false :auth/passwords-must-match]})} + ,]} + (let [get? (= :get request-method) + [_ error-key] validation] {:title (:auth/reset-password i18n) :content (cond @@ -301,10 +308,12 @@ (hook ::html.locked-heading [:h2 (:auth/account-locked i18n)]) (hook ::html.locked-explanation [:p (:auth/too-many-attempts i18n)])]] - ;; Forgot password - - ;; MFA + (and get? error-key) + [:main + (hook ::html.reset-heading [:h1 (:auth/reset-password i18n)]) + (hook ::html.reset-invalid (ErrorMessage {:message (get i18n error-key)}))] + ;; Happy path for both :post and :get :default [:main [:form.flex.col {:name :bread-login :method :post} @@ -312,9 +321,9 @@ (hook ::html.reset-heading [:h1 (:auth/reset-password i18n)]) (hook ::html.enter-confirm-new-password [:p.instruct (:auth/enter-confirm-new-password i18n)]) - (when error? - (hook ::html.invalid-password - (ErrorMessage {:message (:auth/invalid-password i18n)}))) + (when error-key + (hook ::html.reset-error + (ErrorMessage {:message (get i18n error-key)}))) (Field :password :type :password :label (:auth/password i18n) @@ -720,6 +729,7 @@ {:invitation/_invited-by [* :thing/created-at {:invitation/email [*]}]}]} {:title (:invitations/invitations i18n) + :head (hook ::invitations/html.head [:<>]) :content [:main.flex.col ;; TODO @@ -832,7 +842,7 @@ (:auth/max-password-length config)])]) (Submit (:signup/create-account i18n))]])}) -(defmethod Section :flash [{:keys [session ring/flash i18n]} _] +(defmethod Section :flash [{:keys [ring/flash i18n]} _] [:<> (when-let [success-key (:success-key flash)] (SuccessMessage {:message (i18n/t i18n success-key)})) diff --git a/plugins/auth/auth.i18n.edn b/plugins/auth/auth.i18n.edn index 161944a4..ac1f4174 100644 --- a/plugins/auth/auth.i18n.edn +++ b/plugins/auth/auth.i18n.edn @@ -24,6 +24,7 @@ :enter-username "Please enter your username." :forgot-password "Forgot password" :invalid-totp "Invalid code. Please try again." + :invalid-reset "This reset link is invalid or has already been used." :invalid-username-password "Invalid username or password." :login "Login" :logout "Logout" From a98daabe3a28eda17f2dabeaa30b737f2f2ee1bf Mon Sep 17 00:00:00 2001 From: Coby Tamayo Date: Wed, 25 Mar 2026 23:30:33 -0700 Subject: [PATCH 03/38] WiP reset-password dispatcher --- dev/main.edn | 1 + .../auth/systems/bread/alpha/plugin/auth.cljc | 49 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/dev/main.edn b/dev/main.edn index 0360ec7e..f45d37e0 100644 --- a/dev/main.edn +++ b/dev/main.edn @@ -24,6 +24,7 @@ :auth {:secret-key #env AUTH_SECRET_KEY :protected-prefixes ["/~/"] :login-uri "/~/login" + :reset-password-uri "/~/reset" ;:require-mfa? true :min-password-length 4 :store-session-ip? true diff --git a/plugins/auth/systems/bread/alpha/plugin/auth.cljc b/plugins/auth/systems/bread/alpha/plugin/auth.cljc index e211ccf7..21aba117 100644 --- a/plugins/auth/systems/bread/alpha/plugin/auth.cljc +++ b/plugins/auth/systems/bread/alpha/plugin/auth.cljc @@ -406,6 +406,55 @@ [{:action/name ::=>logged-in :action/description "Redirect after login"}]}}))) +;; TODO ::forgot-password=> + +(defmethod bread/dispatch ::reset-password=> + [{:keys [params request-method session] :as req}] + (let [{:auth/keys [step]} session + ;; NOTE: we reuse failed-login-count for pw resets. + max-failed-reset-count (bread/config req :auth/max-failed-login-count) + lock-seconds (bread/config req :auth/lock-seconds) + get? (= :get request-method) + post? (= :post request-method) + redirect-to (bread/config req :auth/login-uri) + user-keys (cond-> [:db/id + :user/username + :user/totp-key + :user/locked-at + :user/failed-login-count]) + hashed-code (sha-512 (:code params "")) + user-expansion + {:expansion/name ::db/query + :expansion/key :reset/result + :expansion/description "Find the user matching the reset code." + :expansion/db (database req) + :expansion/args + [{:find [(list 'pull '?e user-keys) '.] + :in '[$ ?code] + :where '[[?code :reset/code ?e]]} + hashed-code]}] + (cond + (empty? (:code params)) + [:div "NO CODE"] + + ;; Reset + post? + {:expansions + [user-expansion + {:expansion/name ::authenticate-reset + :expansion/key :auth/result + :lock-seconds lock-seconds + :plaintext-password (:password params)}] + :effects + [{:effect/name ::log-attempt + :effect/description + "Record this reset attempt, locking account after too many." + :max-failed-login-count max-failed-reset-count + :lock-seconds lock-seconds + :conn (db/connection req)}]} + + ))) + (def ^{:doc "Schema for authentication."} schema From 6799418c5896f3aab5f9127d58b24832d4e91612 Mon Sep 17 00:00:00 2001 From: Coby Tamayo Date: Wed, 25 Mar 2026 23:41:44 -0700 Subject: [PATCH 04/38] forgot/reset routes --- cms/core/src/systems/bread/alpha/cms/main.cljc | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cms/core/src/systems/bread/alpha/cms/main.cljc b/cms/core/src/systems/bread/alpha/cms/main.cljc index b527824a..d62c40eb 100644 --- a/cms/core/src/systems/bread/alpha/cms/main.cljc +++ b/cms/core/src/systems/bread/alpha/cms/main.cljc @@ -107,6 +107,14 @@ :dispatcher/type ::marx/media.library=> :dispatcher/component #'marx/MediaLibrary}]]] ["_" + ["/forgot" + {:name :forgot-password + :dispatcher/type ::auth/forgot-password=> + :dispatcher/component #'rise/ForgotPasswordPage}] + ["/reset" + {:name :reset-password + :dispatcher/type ::auth/reset-password=> + :dispatcher/component #'rise/ResetPasswordPage}] ["/confirm-email" {:name :confirm-email :dispatcher/type ::email/confirm=> From 7d9f2180a7c514585ec34e0d79075916db7c55eb Mon Sep 17 00:00:00 2001 From: Coby Tamayo Date: Wed, 25 Mar 2026 23:42:05 -0700 Subject: [PATCH 05/38] wip reset dispatcher --- .../auth/systems/bread/alpha/plugin/auth.cljc | 52 +++++++++---------- 1 file changed, 24 insertions(+), 28 deletions(-) diff --git a/plugins/auth/systems/bread/alpha/plugin/auth.cljc b/plugins/auth/systems/bread/alpha/plugin/auth.cljc index 21aba117..d69850f1 100644 --- a/plugins/auth/systems/bread/alpha/plugin/auth.cljc +++ b/plugins/auth/systems/bread/alpha/plugin/auth.cljc @@ -409,42 +409,37 @@ ;; TODO ::forgot-password=> (defmethod bread/dispatch ::reset-password=> - [{:keys [params request-method session] :as req}] - (let [{:auth/keys [step]} session - ;; NOTE: we reuse failed-login-count for pw resets. + [{:keys [params request-method] :as req}] + (let [;; NOTE: we reuse failed-login-count for pw resets. max-failed-reset-count (bread/config req :auth/max-failed-login-count) lock-seconds (bread/config req :auth/lock-seconds) get? (= :get request-method) post? (= :post request-method) redirect-to (bread/config req :auth/login-uri) - user-keys (cond-> [:db/id - :user/username - :user/totp-key - :user/locked-at - :user/failed-login-count]) - hashed-code (sha-512 (:code params "")) - user-expansion - {:expansion/name ::db/query - :expansion/key :reset/result - :expansion/description "Find the user matching the reset code." - :expansion/db (database req) - :expansion/args - [{:find [(list 'pull '?e user-keys) '.] - :in '[$ ?code] - :where '[[?code :reset/code ?e]]} - hashed-code]}] + validation-expansion {:expansion/name ::authenticate-reset + :expansion/description "Authentication reset code." + :expansion/key :validation + ;; TODO lock-seconds ? + } + user-expansion {:expansion/name ::db/query + :expansion/key :user + :expansion/description "Find the user matching the reset code." + :expansion/db (database req) + :expansion/args + [{:find [(list 'pull '?e [:db/id + :user/username + :user/totp-key + :user/locked-at + :user/failed-login-count]) '.] + :in '[$ ?code] + :where '[[?code :reset/code ?e]]} + (sha-512 (:code params ""))]}] (cond - (empty? (:code params)) - [:div "NO CODE"] + get? + {:expansions [user-expansion validation-expansion]} - ;; Reset post? - {:expansions - [user-expansion - {:expansion/name ::authenticate-reset - :expansion/key :auth/result - :lock-seconds lock-seconds - :plaintext-password (:password params)}] + {:expansions [user-expansion validation-expansion] :effects [{:effect/name ::log-attempt :effect/description @@ -502,6 +497,7 @@ :attr/label "Reset code" :db/doc "Short-lived code for password reset" :db/valueType :db.type/string + :db/unique :db.unique/identity :db/cardinality :db.cardinality/one :attr/sensitive? true :attr/migration "migration.authentication"} From d13886bbfe8559d3a6a87f73ad4c2c7e72d2e691 Mon Sep 17 00:00:00 2001 From: Coby Tamayo Date: Mon, 13 Apr 2026 13:29:04 -0700 Subject: [PATCH 06/38] fix reset/code query --- plugins/auth/systems/bread/alpha/plugin/auth.cljc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/auth/systems/bread/alpha/plugin/auth.cljc b/plugins/auth/systems/bread/alpha/plugin/auth.cljc index d69850f1..7f065993 100644 --- a/plugins/auth/systems/bread/alpha/plugin/auth.cljc +++ b/plugins/auth/systems/bread/alpha/plugin/auth.cljc @@ -432,7 +432,7 @@ :user/locked-at :user/failed-login-count]) '.] :in '[$ ?code] - :where '[[?code :reset/code ?e]]} + :where '[[?e :reset/code ?code]]} (sha-512 (:code params ""))]}] (cond get? From 98c4d356a536e9bfa9fda736d9c38a5144bca0b1 Mon Sep 17 00:00:00 2001 From: Coby Tamayo Date: Mon, 13 Apr 2026 15:28:14 -0700 Subject: [PATCH 07/38] fix pw reset error-key --- cms/themes/rise/src/systems/bread/alpha/cms/theme/rise.cljc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/themes/rise/src/systems/bread/alpha/cms/theme/rise.cljc b/cms/themes/rise/src/systems/bread/alpha/cms/theme/rise.cljc index 9edba7a3..65aec1be 100644 --- a/cms/themes/rise/src/systems/bread/alpha/cms/theme/rise.cljc +++ b/cms/themes/rise/src/systems/bread/alpha/cms/theme/rise.cljc @@ -323,7 +323,7 @@ [:p.instruct (:auth/enter-confirm-new-password i18n)]) (when error-key (hook ::html.reset-error - (ErrorMessage {:message (get i18n error-key)}))) + (ErrorMessage {:message (i18n/t i18n error-key)}))) (Field :password :type :password :label (:auth/password i18n) From 641ed3681dd97d24227bf905d8dcd52a94c837cb Mon Sep 17 00:00:00 2001 From: Coby Tamayo Date: Mon, 13 Apr 2026 15:28:23 -0700 Subject: [PATCH 08/38] seed reset code --- dev/main.edn | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dev/main.edn b/dev/main.edn index f45d37e0..5394f8b8 100644 --- a/dev/main.edn +++ b/dev/main.edn @@ -91,6 +91,10 @@ #{{:ability/key :publish-posts} {:ability/key :edit-posts} {:ability/key :delete-posts}}}}} + {:thing/created-at #seconds-ago 0 + :thing/updated-at #seconds-ago 0 + :reset/user "user.admin" + :reset/code #sha-512 #join [#env AUTH_SECRET_KEY ":" "RESETCODE"]} {:user/username "reader" :user/name "Reader User" ;; No emails yet! From 67ddd6d80dda0acfbf5124c27f455d8cd27a9b65 Mon Sep 17 00:00:00 2001 From: Coby Tamayo Date: Mon, 13 Apr 2026 15:28:48 -0700 Subject: [PATCH 09/38] :reset/reset-at schema --- plugins/auth/systems/bread/alpha/plugin/auth.cljc | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/plugins/auth/systems/bread/alpha/plugin/auth.cljc b/plugins/auth/systems/bread/alpha/plugin/auth.cljc index 7f065993..a95634e2 100644 --- a/plugins/auth/systems/bread/alpha/plugin/auth.cljc +++ b/plugins/auth/systems/bread/alpha/plugin/auth.cljc @@ -501,6 +501,12 @@ :db/cardinality :db.cardinality/one :attr/sensitive? true :attr/migration "migration.authentication"} + {:db/ident :reset/reset-at + :attr/label "Reset at" + :db/doc "When this reset was completed, if ever" + :db/valueType :db.type/instant + :db/cardinality :db.cardinality/one + :attr/migration "migration.authentication"} {:db/ident :reset/user :attr/label "Reset user" :db/doc "The user resetting their password" From f7962b5fddeec35cbede032b81dc84e40cfd7627 Mon Sep 17 00:00:00 2001 From: Coby Tamayo Date: Mon, 13 Apr 2026 15:29:18 -0700 Subject: [PATCH 10/38] :signup fmt --- plugins/auth/systems/bread/alpha/plugin/signup.cljc | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/auth/systems/bread/alpha/plugin/signup.cljc b/plugins/auth/systems/bread/alpha/plugin/signup.cljc index df4fdf68..fe12942b 100644 --- a/plugins/auth/systems/bread/alpha/plugin/signup.cljc +++ b/plugins/auth/systems/bread/alpha/plugin/signup.cljc @@ -170,6 +170,6 @@ :action/description "Merge strings for signup into global strings." :strings (i18n/read-strings "signup.i18n.edn")}]} :config - {:signup/invite-only? invite-only? - :signup/invitation-expiration-seconds invitation-expiration-seconds - :signup/signup-uri signup-uri}})) + #:signup{:invite-only? invite-only? + :invitation-expiration-seconds invitation-expiration-seconds + :signup-uri signup-uri}})) From 5a19699ebf80117d8b363194ac548ffcfa3676c1 Mon Sep 17 00:00:00 2001 From: Coby Tamayo Date: Mon, 13 Apr 2026 15:37:00 -0700 Subject: [PATCH 11/38] ::ring/redirect-when --- src/systems/bread/alpha/ring.cljc | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/systems/bread/alpha/ring.cljc b/src/systems/bread/alpha/ring.cljc index c53ed609..5b3e4a20 100644 --- a/src/systems/bread/alpha/ring.cljc +++ b/src/systems/bread/alpha/ring.cljc @@ -151,6 +151,14 @@ (defmethod bread/action ::redirect redirect- [res action _] (redirect res action)) +(defmethod bread/action ::redirect-when redirect-when + [{:as res :keys [::bread/data]} + {:as action :keys [error-status path] :or {error-status 400}} _] + (let [redirect? (get-in data path)] + (if redirect? + (redirect res action) + (assoc res :status error-status)))) + (defn redirect=> [redir] {:hooks {::bread/expand From 823bbee6b8b7ca7b212b8fa3272382798e202f03 Mon Sep 17 00:00:00 2001 From: Coby Tamayo Date: Mon, 13 Apr 2026 15:41:08 -0700 Subject: [PATCH 12/38] :auth/reset-expiration-seconds --- plugins/auth/systems/bread/alpha/plugin/auth.cljc | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugins/auth/systems/bread/alpha/plugin/auth.cljc b/plugins/auth/systems/bread/alpha/plugin/auth.cljc index a95634e2..f2887dde 100644 --- a/plugins/auth/systems/bread/alpha/plugin/auth.cljc +++ b/plugins/auth/systems/bread/alpha/plugin/auth.cljc @@ -546,7 +546,7 @@ ([{:keys [secret-key hash-algorithm max-failed-login-count lock-seconds next-param login-uri reset-password-uri protected-prefixes require-mfa? mfa-issuer min-password-length max-password-length generous-totp-window? - store-session-ip? store-session-user-agent?] + store-session-ip? store-session-user-agent? reset-expiration-seconds] :or {min-password-length 12 max-password-length 72 hash-algorithm :bcrypt+blake2b-512 @@ -555,6 +555,7 @@ next-param :next login-uri "/login" reset-password-uri "/reset" + reset-expiration-seconds (* 10 60) generous-totp-window? true ;; Don't track Personally Identfiable Information (PII) by default. store-session-ip? false @@ -598,5 +599,6 @@ :next-param next-param :login-uri login-uri :reset-password-uri reset-password-uri + :reset-expiration-seconds reset-expiration-seconds :store-session-ip? store-session-ip? :store-session-user-agent? store-session-user-agent?}})) From a30581d232f57f15784a8810c0dd0db39bee0ce3 Mon Sep 17 00:00:00 2001 From: Coby Tamayo Date: Mon, 13 Apr 2026 15:41:41 -0700 Subject: [PATCH 13/38] implement password reset step --- .../auth/systems/bread/alpha/plugin/auth.cljc | 143 +++++++++++++----- 1 file changed, 105 insertions(+), 38 deletions(-) diff --git a/plugins/auth/systems/bread/alpha/plugin/auth.cljc b/plugins/auth/systems/bread/alpha/plugin/auth.cljc index f2887dde..f3b52e40 100644 --- a/plugins/auth/systems/bread/alpha/plugin/auth.cljc +++ b/plugins/auth/systems/bread/alpha/plugin/auth.cljc @@ -13,7 +13,8 @@ [systems.bread.alpha.core :as bread] [systems.bread.alpha.i18n :as i18n] [systems.bread.alpha.internal.interop :refer [sha-512]] - [systems.bread.alpha.internal.time :as t]) + [systems.bread.alpha.internal.time :as t] + [systems.bread.alpha.ring :as ring]) (:import [java.lang IllegalArgumentException] [java.net URLEncoder] @@ -408,47 +409,113 @@ ;; TODO ::forgot-password=> +(defmethod bread/expand ::authenticate-reset + [{:keys [reset-expiration-seconds]} + {{:as reset :keys [reset/user]} :reset}] + (let [earliest-valid (t/seconds-ago reset-expiration-seconds) + updated-at (:thing/updated-at reset) + valid? (and updated-at (.after updated-at earliest-valid))] + (prn 'USER user) + ;; TODO check :user/locked-at + (if valid? + [true nil] + [false :auth/invalid-reset]))) + +(defmethod bread/expand ::validate-reset + [{:keys [min-password-length max-password-length] + {:keys [password password-confirmation]} :params + :or {password "" password-confirmation ""}} + {:keys [validation]}] + (let [[valid?] validation] + (if (not valid?) + validation ;; Bad code; don't bother validating user input. + (let [password-fields-match? (= password password-confirmation) + password-gte-min? (>= (count password) min-password-length) + password-lte-max? (<= (count password) max-password-length) + valid? (and password-fields-match? + password-gte-min? + password-lte-max?) + error (when-not valid? + (cond + (or (empty? password) (empty? password-confirmation)) + :auth/enter-confirm-new-password + (not password-fields-match?) :auth/passwords-must-match + (not password-gte-min?) + [:auth/password-must-be-at-least min-password-length] + (not password-lte-max?) + [:auth/password-must-be-at-most max-password-length]))] + [valid? error])))) + +(defmethod bread/effect ::reset-password + [{:keys [params hash-algorithm conn]} {:keys [reset validation]}] + (let [user (:reset/user reset) + [valid?] validation] + (when valid? + (let [now (t/now) + ;; TODO secret-key + hashed (hashers/derive (:password params) {:alg hash-algorithm}) + user-tx {:db/id (:db/id user) + :user/password hashed + :thing/updated-at now} + reset-tx {:db/id (:db/id reset) + :reset/reset-at now + :thing/updated-at now} + txs [user-tx reset-tx]] + {:effects + [{:effect/name ::db/transact + :conn conn + :txs txs}]})))) + (defmethod bread/dispatch ::reset-password=> [{:keys [params request-method] :as req}] - (let [;; NOTE: we reuse failed-login-count for pw resets. - max-failed-reset-count (bread/config req :auth/max-failed-login-count) - lock-seconds (bread/config req :auth/lock-seconds) - get? (= :get request-method) + (let [get? (= :get request-method) post? (= :post request-method) - redirect-to (bread/config req :auth/login-uri) - validation-expansion {:expansion/name ::authenticate-reset - :expansion/description "Authentication reset code." - :expansion/key :validation - ;; TODO lock-seconds ? - } - user-expansion {:expansion/name ::db/query - :expansion/key :user - :expansion/description "Find the user matching the reset code." - :expansion/db (database req) - :expansion/args - [{:find [(list 'pull '?e [:db/id - :user/username - :user/totp-key - :user/locked-at - :user/failed-login-count]) '.] - :in '[$ ?code] - :where '[[?e :reset/code ?code]]} - (sha-512 (:code params ""))]}] - (cond - get? - {:expansions [user-expansion validation-expansion]} - - post? - {:expansions [user-expansion validation-expansion] + authenticate-reset {:expansion/name ::authenticate-reset + :expansion/description "Authentication reset code." + :expansion/key :validation + :reset-expiration-seconds + (bread/config req :auth/reset-expiration-seconds)} + secret-key (bread/config req :auth/secret-key) + query-user {:expansion/name ::db/query + :expansion/key :reset + :expansion/description "Find the user matching the reset code." + :expansion/db (database req) + :expansion/args + [{:find [(list 'pull '?e [:db/id + :thing/updated-at + {:reset/user + [:db/id + :user/username + :user/totp-key + :user/locked-at + :user/failed-login-count]}]) '.] + :in '[$ ?code] + :where '[[?e :reset/code ?code] + (not [?e :reset/reset-at])]} + (sha-512 (str secret-key ":" (:code params "")))]}] + (if post? + {:expansions + [query-user + authenticate-reset + {:expansion/name ::validate-reset + :expansion/description "Validate password update." + :expansion/key :validation + :params params + :min-password-length (bread/config req :auth/min-password-length) + :max-password-length (bread/config req :auth/max-password-length)}] :effects - [{:effect/name ::log-attempt - :effect/description - "Record this reset attempt, locking account after too many." - :max-failed-login-count max-failed-reset-count - :lock-seconds lock-seconds - :conn (db/connection req)}]} - - ))) + [{:effect/name ::reset-password + :effect/description "Update password upon valid submission." + :params params + :hash-algorithm (bread/config req :auth/hash-algorithm) + :conn (db/connection req)}] + :hooks + {::bread/render + [{:action/name ::ring/redirect-when + :action/description "Render reset page or redirect to login." + :to (bread/config req :auth/login-uri) + :path [:validation 0]}]}} + {:expansions [query-user authenticate-reset]}))) (def ^{:doc "Schema for authentication."} From 73f1710a22d00ad089f2dae3d3b991a55a25c3fd Mon Sep 17 00:00:00 2001 From: Coby Tamayo Date: Mon, 13 Apr 2026 15:46:07 -0700 Subject: [PATCH 14/38] bump up default password minimum per NIST rec --- plugins/auth/systems/bread/alpha/plugin/auth.cljc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/auth/systems/bread/alpha/plugin/auth.cljc b/plugins/auth/systems/bread/alpha/plugin/auth.cljc index f3b52e40..a556ac48 100644 --- a/plugins/auth/systems/bread/alpha/plugin/auth.cljc +++ b/plugins/auth/systems/bread/alpha/plugin/auth.cljc @@ -614,7 +614,7 @@ login-uri reset-password-uri protected-prefixes require-mfa? mfa-issuer min-password-length max-password-length generous-totp-window? store-session-ip? store-session-user-agent? reset-expiration-seconds] - :or {min-password-length 12 + :or {min-password-length 15 max-password-length 72 hash-algorithm :bcrypt+blake2b-512 max-failed-login-count 5 From 7f40530919e86b6df110909691455af88b7e0ed0 Mon Sep 17 00:00:00 2001 From: Coby Tamayo Date: Mon, 13 Apr 2026 15:53:52 -0700 Subject: [PATCH 15/38] pw reset guidelines --- .../rise/src/systems/bread/alpha/cms/theme/rise.cljc | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/cms/themes/rise/src/systems/bread/alpha/cms/theme/rise.cljc b/cms/themes/rise/src/systems/bread/alpha/cms/theme/rise.cljc index 65aec1be..cdf09ca1 100644 --- a/cms/themes/rise/src/systems/bread/alpha/cms/theme/rise.cljc +++ b/cms/themes/rise/src/systems/bread/alpha/cms/theme/rise.cljc @@ -280,7 +280,9 @@ dispatcher. You typically won't need to call this component from your code, except to reference it from your route if implementing custom routing." :doc/default-data - {:config {:site/name "Site name"} + {:config {:site/name "Site name" + :auth/min-password-length 15 + :auth/max-password-length 72} :hook (fn hook [_ x & _] x) :ring/anti-forgery-token-field (constantly nil)} :doc/preview? true @@ -313,7 +315,7 @@ (hook ::html.reset-heading [:h1 (:auth/reset-password i18n)]) (hook ::html.reset-invalid (ErrorMessage {:message (get i18n error-key)}))] - ;; Happy path for both :post and :get + ;; Happy path for :get, error path for :post. :default [:main [:form.flex.col {:name :bread-login :method :post} @@ -332,6 +334,11 @@ :type :password :label (:auth/password-confirmation i18n) :input-attrs {:maxlength (:auth/max-password-length config)}) + (hook ::html.password-guidelines + [:p.instruct + (i18n/t i18n [:auth/password-must-be-between + (:auth/min-password-length config) + (:auth/max-password-length config)])]) (Submit (:auth/reset i18n))]])})) (defc AccountNav [{:as data :keys [config]}] From fc80358f9257610e1817c873bbb186f6e5e40576 Mon Sep 17 00:00:00 2001 From: Coby Tamayo Date: Mon, 13 Apr 2026 16:04:38 -0700 Subject: [PATCH 16/38] tweak :auth/reset-email-sent i18n --- plugins/auth/auth.i18n.edn | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/auth/auth.i18n.edn b/plugins/auth/auth.i18n.edn index ac1f4174..6f6f42d8 100644 --- a/plugins/auth/auth.i18n.edn +++ b/plugins/auth/auth.i18n.edn @@ -41,7 +41,7 @@ :qr-code "QR code" :reset "Reset" :reset-password "Reset password" - :reset-email-sent "If you have an account, an email has been sent to your primary email" + :reset-email-sent "A reset email has been sent to your primary email address." :too-many-attempts "You have made too many attempts to log in. Please try again later." :username "Username" :verify "Verify" From 6af00743271338a491f60132d7cce6762eb1feba Mon Sep 17 00:00:00 2001 From: Coby Tamayo Date: Mon, 13 Apr 2026 16:07:23 -0700 Subject: [PATCH 17/38] lint --- plugins/auth/systems/bread/alpha/plugin/auth.cljc | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugins/auth/systems/bread/alpha/plugin/auth.cljc b/plugins/auth/systems/bread/alpha/plugin/auth.cljc index a556ac48..9cbd9367 100644 --- a/plugins/auth/systems/bread/alpha/plugin/auth.cljc +++ b/plugins/auth/systems/bread/alpha/plugin/auth.cljc @@ -468,8 +468,7 @@ (defmethod bread/dispatch ::reset-password=> [{:keys [params request-method] :as req}] - (let [get? (= :get request-method) - post? (= :post request-method) + (let [post? (= :post request-method) authenticate-reset {:expansion/name ::authenticate-reset :expansion/description "Authentication reset code." :expansion/key :validation From d9603dac4911b4f4e0687a33a6363d4ae5abd736 Mon Sep 17 00:00:00 2001 From: Coby Tamayo Date: Mon, 13 Apr 2026 16:43:18 -0700 Subject: [PATCH 18/38] rm unused import --- plugins/auth/systems/bread/alpha/plugin/auth.cljc | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/auth/systems/bread/alpha/plugin/auth.cljc b/plugins/auth/systems/bread/alpha/plugin/auth.cljc index 9cbd9367..962adc6b 100644 --- a/plugins/auth/systems/bread/alpha/plugin/auth.cljc +++ b/plugins/auth/systems/bread/alpha/plugin/auth.cljc @@ -8,7 +8,6 @@ [one-time.qrgen :as qr] [ring.middleware.session.store :as ss] - [systems.bread.alpha.component :as component :refer [defc]] [systems.bread.alpha.database :as db] [systems.bread.alpha.core :as bread] [systems.bread.alpha.i18n :as i18n] From 7dbcda3e98d15f29127fbf3a9f4a839a74e26511 Mon Sep 17 00:00:00 2001 From: Coby Tamayo Date: Mon, 13 Apr 2026 18:16:21 -0700 Subject: [PATCH 19/38] sandbox user --- cms/core/src/systems/bread/alpha/cms/main.cljc | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cms/core/src/systems/bread/alpha/cms/main.cljc b/cms/core/src/systems/bread/alpha/cms/main.cljc index d62c40eb..bfb205b4 100644 --- a/cms/core/src/systems/bread/alpha/cms/main.cljc +++ b/cms/core/src/systems/bread/alpha/cms/main.cljc @@ -603,7 +603,7 @@ :where [[?e :session/id]]}) (map (comp #(update % :session/data edn/read-string) first))) - (def coby + (def $user (-> (q '{:find [(pull ?e [:db/id :thing/created-at :user/username @@ -623,21 +623,21 @@ {:user/sessions [*]}]) .] :in [$ ?username] :where [[?e :user/username ?username]]} - "coby") + "bread") (update :user/sessions (fn [sessions] (map #(update % :session/data edn/read-string) sessions))))) (q '{:find [(pull ?e [:db/id *])] :where [[?e :invitation/code]]}) - (user/can? coby :edit-posts) + (user/can? $user :edit-posts) (defn retraction [{e :db/id :as entity}] (mapv #(vector :db/retract e %) (filter #(not= :db/id %) (keys entity)))) - (retraction coby) + (retraction $user) (db/transact (db/connection (:bread/app @system)) - (retraction coby)) + (retraction $user)) (db/transact (db/connection (:bread/app @system)) - [{:user/username "coby" + [{:user/username "bread" :user/locked-at (java.util.Date.)}]) From f257fdc3aa1f8cb9432c8881921005339034c33f Mon Sep 17 00:00:00 2001 From: Coby Tamayo Date: Mon, 13 Apr 2026 18:16:35 -0700 Subject: [PATCH 20/38] update dev reset URI --- dev/main.edn | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev/main.edn b/dev/main.edn index 5394f8b8..4841ead2 100644 --- a/dev/main.edn +++ b/dev/main.edn @@ -24,7 +24,7 @@ :auth {:secret-key #env AUTH_SECRET_KEY :protected-prefixes ["/~/"] :login-uri "/~/login" - :reset-password-uri "/~/reset" + :reset-password-uri "/_/reset" ;:require-mfa? true :min-password-length 4 :store-session-ip? true From 4c119ffe538eed45b0fe36a6031533f8e8744e9f Mon Sep 17 00:00:00 2001 From: Coby Tamayo Date: Mon, 13 Apr 2026 18:17:07 -0700 Subject: [PATCH 21/38] John Locke --- dev/main.edn | 3 +++ 1 file changed, 3 insertions(+) diff --git a/dev/main.edn b/dev/main.edn index 4841ead2..135adc03 100644 --- a/dev/main.edn +++ b/dev/main.edn @@ -95,6 +95,9 @@ :thing/updated-at #seconds-ago 0 :reset/user "user.admin" :reset/code #sha-512 #join [#env AUTH_SECRET_KEY ":" "RESETCODE"]} + {:user/username "locke" + :user/name "John Locke" + :user/locked-at #seconds-ago 10} {:user/username "reader" :user/name "Reader User" ;; No emails yet! From 1cb69f9bea0dde6621637d9cc868819b4670de26 Mon Sep 17 00:00:00 2001 From: Coby Tamayo Date: Mon, 13 Apr 2026 18:19:09 -0700 Subject: [PATCH 22/38] forgot password --- plugins/auth/auth.i18n.edn | 3 + .../auth/systems/bread/alpha/plugin/auth.cljc | 88 ++++++++++++++++++- 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/plugins/auth/auth.i18n.edn b/plugins/auth/auth.i18n.edn index 6f6f42d8..f9a93d4d 100644 --- a/plugins/auth/auth.i18n.edn +++ b/plugins/auth/auth.i18n.edn @@ -42,6 +42,9 @@ :reset "Reset" :reset-password "Reset password" :reset-email-sent "A reset email has been sent to your primary email address." + :reset-password-email-subject "Password reset link" + :reset-password-email-body + "If you need to reset your password for %s, go here:\n\n%s\n\nIf you did not request a password reset, you can ignore this message." :too-many-attempts "You have made too many attempts to log in. Please try again later." :username "Username" :verify "Verify" diff --git a/plugins/auth/systems/bread/alpha/plugin/auth.cljc b/plugins/auth/systems/bread/alpha/plugin/auth.cljc index 962adc6b..4c2918e1 100644 --- a/plugins/auth/systems/bread/alpha/plugin/auth.cljc +++ b/plugins/auth/systems/bread/alpha/plugin/auth.cljc @@ -13,6 +13,7 @@ [systems.bread.alpha.i18n :as i18n] [systems.bread.alpha.internal.interop :refer [sha-512]] [systems.bread.alpha.internal.time :as t] + [systems.bread.alpha.plugin.email :as email] [systems.bread.alpha.ring :as ring]) (:import [java.lang IllegalArgumentException] @@ -406,7 +407,92 @@ [{:action/name ::=>logged-in :action/description "Redirect after login"}]}}))) -;; TODO ::forgot-password=> +(defmethod bread/effect ::forgot-password + [{:keys [conn secret-key]} {:keys [user config]}] + (let [primary-email (first (filter :email/primary? (:user/emails user))) + primary-confirmed? (boolean (:email/confirmed-at primary-email)) + locked-at (:user/locked-at user) + locked-period-start (t/seconds-ago (:auth/lock-seconds config)) + unlocked? (or (not locked-at) (.before locked-at locked-period-start)) + allow-reset? (and primary-confirmed? unlocked?)] + (when allow-reset? + (let [now (t/now) + code (random/hex 32) + hashed (sha-512 (str secret-key ":" code)) + existing-reset (->> user :reset/_user + (filter (complement :reset/reset-at)) + first) + reset-tx (merge {:reset/code hashed + :reset/user (:db/id user) + :thing/updated-at now} + (if existing-reset + {:db/id (:db/id existing-reset)} + {:thing/created-at now}))] + {:effects + [{:effect/name ::db/transact + :effect/description "Create a password reset." + :conn conn + :txs [reset-tx]} + {:effect/name ::reset-password-email + :effect/decsription "Create password reset message." + :to (:email/address primary-email) + :code code}]})))) + +(defn reset-link [{:keys [config + reset/code + ring/scheme + ring/server-name + ring/server-port]}] + (format "%s://%s%s%s?code=%s" + (name scheme) server-name (when server-port (str ":" server-port)) + (:auth/reset-password-uri config) (URLEncoder/encode code))) + +(defmethod bread/effect ::reset-password-email + [{:keys [to code]} {:as data :keys [config i18n ring/server-name]}] + (let [link (reset-link (assoc data :reset/code code)) + site-name (:site/name config server-name) + body (i18n/t i18n [:auth/reset-password-email-body site-name link])] + {:effects + [{:effect/name ::email/send! + :effect/description "Send reset password email." + :message {:from (:email/smtp-from-email config) + :to to + :subject (:auth/reset-password-email-subject i18n) + :body body}}]})) + +(comment + (:user data) + (bread/effect effect data) + (let [{:keys [effects]} (bread/effect effect data) + email-effect (second effects)] + (bread/effect email-effect data)) + ,) + +(defmethod bread/dispatch ::forgot-password=> + [{:as req :keys [params request-method]}] + (let [post? (= :post request-method)] + (when post? + {:expansions + [{:expansion/name ::db/query + :expansion/description "Query user by username." + :expansion/key :user + :expansion/db (db/database req) + :expansion/args ['{:find [(pull ?e [:db/id + :user/locked-at + {:reset/_user + [:db/id + :thing/updated-at + :reset/reset-at]} + {:user/emails [*]}]) .] + :in [$ ?username] + :where [[?e :user/username ?username]]} + (:username params)]}] + :effects + [{:effect/name ::forgot-password + :effect/description + "Send user a reset link, if they have a confirmed email." + :conn (db/connection req) + :secret-key (bread/config req :auth/secret-key)}]}))) (defmethod bread/expand ::authenticate-reset [{:keys [reset-expiration-seconds]} From 14fcba03ca327153c17824acccc8103088621de1 Mon Sep 17 00:00:00 2001 From: Coby Tamayo Date: Mon, 13 Apr 2026 18:27:10 -0700 Subject: [PATCH 23/38] link to forgot password page --- cms/themes/rise/resources/rise/css/base.css | 1 + dev/main.edn | 1 + .../auth/systems/bread/alpha/plugin/auth.cljc | 23 +++++++++++++++---- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/cms/themes/rise/resources/rise/css/base.css b/cms/themes/rise/resources/rise/css/base.css index ee15f215..0b86df20 100644 --- a/cms/themes/rise/resources/rise/css/base.css +++ b/cms/themes/rise/resources/rise/css/base.css @@ -218,6 +218,7 @@ button:hover { .row { flex-flow: row nowrap; justify-content: space-between; + align-items: center; } .spacer { flex: 1; diff --git a/dev/main.edn b/dev/main.edn index 135adc03..55303394 100644 --- a/dev/main.edn +++ b/dev/main.edn @@ -24,6 +24,7 @@ :auth {:secret-key #env AUTH_SECRET_KEY :protected-prefixes ["/~/"] :login-uri "/~/login" + :forgot-password-uri "/_/forgot" :reset-password-uri "/_/reset" ;:require-mfa? true :min-password-length 4 diff --git a/plugins/auth/systems/bread/alpha/plugin/auth.cljc b/plugins/auth/systems/bread/alpha/plugin/auth.cljc index 4c2918e1..9d20742a 100644 --- a/plugins/auth/systems/bread/alpha/plugin/auth.cljc +++ b/plugins/auth/systems/bread/alpha/plugin/auth.cljc @@ -694,10 +694,23 @@ (defn plugin ([] (plugin {})) - ([{:keys [secret-key hash-algorithm max-failed-login-count lock-seconds next-param - login-uri reset-password-uri protected-prefixes require-mfa? mfa-issuer - min-password-length max-password-length generous-totp-window? - store-session-ip? store-session-user-agent? reset-expiration-seconds] + ([{:keys [forgot-password-uri + generous-totp-window? + hash-algorithm + lock-seconds + login-uri + max-failed-login-count + max-password-length + mfa-issuer + min-password-length + next-param + protected-prefixes + require-mfa? + reset-expiration-seconds + reset-password-uri + secret-key + store-session-ip? + store-session-user-agent?] :or {min-password-length 15 max-password-length 72 hash-algorithm :bcrypt+blake2b-512 @@ -705,6 +718,7 @@ lock-seconds 3600 next-param :next login-uri "/login" + forgot-password-uri "/forgot" reset-password-uri "/reset" reset-expiration-seconds (* 10 60) generous-totp-window? true @@ -749,6 +763,7 @@ :lock-seconds lock-seconds :next-param next-param :login-uri login-uri + :forgot-password-uri forgot-password-uri :reset-password-uri reset-password-uri :reset-expiration-seconds reset-expiration-seconds :store-session-ip? store-session-ip? From 34616e0992c86af4af24c6f6b82f0db54e97cbda Mon Sep 17 00:00:00 2001 From: Coby Tamayo Date: Mon, 13 Apr 2026 18:38:45 -0700 Subject: [PATCH 24/38] fix cylic dependency, lint warnings --- plugins/email/systems/bread/alpha/plugin/email.cljc | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/plugins/email/systems/bread/alpha/plugin/email.cljc b/plugins/email/systems/bread/alpha/plugin/email.cljc index 3508ede4..71fe2701 100644 --- a/plugins/email/systems/bread/alpha/plugin/email.cljc +++ b/plugins/email/systems/bread/alpha/plugin/email.cljc @@ -1,7 +1,5 @@ (ns systems.bread.alpha.plugin.email (:require - [clojure.edn :as edn] - [clojure.java.io :as io] [clojure.string :as string] [crypto.random :as random] [postal.core :as postal] @@ -11,9 +9,7 @@ [systems.bread.alpha.database :as db] [systems.bread.alpha.i18n :as i18n] [systems.bread.alpha.internal.time :as t] - [systems.bread.alpha.plugin.auth :as auth] - [systems.bread.alpha.ring :as ring] - [systems.bread.alpha.thing :as thing]) + [systems.bread.alpha.ring :as ring]) (:import [java.net URLEncoder])) From b26776ac014b88ab155bf29b9faaa57b4343648c Mon Sep 17 00:00:00 2001 From: Coby Tamayo Date: Mon, 13 Apr 2026 18:51:01 -0700 Subject: [PATCH 25/38] restore default password reqiurement --- plugins/auth/systems/bread/alpha/plugin/auth.cljc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/auth/systems/bread/alpha/plugin/auth.cljc b/plugins/auth/systems/bread/alpha/plugin/auth.cljc index 9d20742a..3dfdc723 100644 --- a/plugins/auth/systems/bread/alpha/plugin/auth.cljc +++ b/plugins/auth/systems/bread/alpha/plugin/auth.cljc @@ -711,7 +711,7 @@ secret-key store-session-ip? store-session-user-agent?] - :or {min-password-length 15 + :or {min-password-length 12 max-password-length 72 hash-algorithm :bcrypt+blake2b-512 max-failed-login-count 5 From d19452b91a0f800dfab125b6198892e8152ed6ae Mon Sep 17 00:00:00 2001 From: Coby Tamayo Date: Mon, 13 Apr 2026 20:47:54 -0700 Subject: [PATCH 26/38] mock derive, sha-512 helpers --- test/core/systems/bread/alpha/test_helpers.clj | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/core/systems/bread/alpha/test_helpers.clj b/test/core/systems/bread/alpha/test_helpers.clj index 9a243bad..2a15ad2c 100644 --- a/test/core/systems/bread/alpha/test_helpers.clj +++ b/test/core/systems/bread/alpha/test_helpers.clj @@ -142,6 +142,12 @@ map->router to pass to route/plugin." (route/plugin {:router (map->router routes)})) +(defn mock-derive [pw {:keys [alg]}] + (str "[" alg "+" pw "]")) + +(defn mock-sha-512 [s] + (str "sha-512[" s "]")) + (comment (def $router (map->router {"/one" {:name :one} "/two" {:name :two}})) From e1ab84d3b5dfbd245d77624e9626c3e5ba08db86 Mon Sep 17 00:00:00 2001 From: Coby Tamayo Date: Mon, 13 Apr 2026 21:28:29 -0700 Subject: [PATCH 27/38] use mock-derive, mock-sha-512 from signup-test --- test/cms/systems/bread/alpha/plugin/signup_test.clj | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/test/cms/systems/bread/alpha/plugin/signup_test.clj b/test/cms/systems/bread/alpha/plugin/signup_test.clj index e98f1d25..409085a7 100644 --- a/test/cms/systems/bread/alpha/plugin/signup_test.clj +++ b/test/cms/systems/bread/alpha/plugin/signup_test.clj @@ -3,14 +3,16 @@ [buddy.hashers :as hashers] [clojure.test :refer [deftest are]] - [systems.bread.alpha.test-helpers :refer [db->plugin - plugins->loaded]] [systems.bread.alpha.core :as bread] [systems.bread.alpha.database :as db] [systems.bread.alpha.internal.interop :refer [sha-512]] [systems.bread.alpha.internal.time :as t] [systems.bread.alpha.plugin.signup :as signup] - [systems.bread.alpha.plugin.auth :as auth]) + [systems.bread.alpha.plugin.auth :as auth] + [systems.bread.alpha.test-helpers :refer [db->plugin + mock-derive + mock-sha-512 + plugins->loaded]]) (:import [java.util Date])) @@ -28,9 +30,8 @@ (signup/plugin signup-config)]) req* (merge app req {::bread/dispatcher dispatcher})] (binding [t/*now* !now] - (with-redefs [hashers/derive (fn [pw {:keys [alg]}] - (str "[" alg "+" pw "]")) - sha-512 #(str "sha-512[" % "]")] + (with-redefs [hashers/derive mock-derive + sha-512 mock-sha-512] (bread/dispatch req*))))) ;; Just loading the signup page. From 8c14ca93a88c56521833aba549b539161204acb7 Mon Sep 17 00:00:00 2001 From: Coby Tamayo Date: Mon, 13 Apr 2026 21:29:59 -0700 Subject: [PATCH 28/38] test ::auth/reset-password=> --- .../bread/alpha/reset_password_test.clj | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 test/cms/systems/bread/alpha/reset_password_test.clj diff --git a/test/cms/systems/bread/alpha/reset_password_test.clj b/test/cms/systems/bread/alpha/reset_password_test.clj new file mode 100644 index 00000000..bff0a808 --- /dev/null +++ b/test/cms/systems/bread/alpha/reset_password_test.clj @@ -0,0 +1,180 @@ +(ns systems.bread.alpha.reset-password-test + (:require + [buddy.hashers :as hashers] + [clojure.test :refer [deftest are]] + + [systems.bread.alpha.test-helpers :refer [db->plugin + plugins->loaded + mock-derive + mock-sha-512]] + [systems.bread.alpha.core :as bread] + [systems.bread.alpha.database :as db] + [systems.bread.alpha.internal.interop :refer [sha-512]] + [systems.bread.alpha.internal.time :as t] + [systems.bread.alpha.plugin.auth :as auth] + [systems.bread.alpha.ring :as ring]) + (:import + [java.util Date])) + +(deftest test-reset-password=> + (let [!now (Date.) + db-plugin (db->plugin ::FAKEDB) + db-conn (:db/connection (:config db-plugin)) + code->expansion + (fn [code] + {:expansion/args + ['{:find [(pull + ?e + [:db/id + :thing/updated-at + {:reset/user [:db/id + :user/username + :user/totp-key + :user/locked-at + :user/failed-login-count]}]) .] + :in [$ ?code] + :where [[?e :reset/code ?code] (not [?e :reset/reset-at])]} + (mock-sha-512 code)] + :expansion/db ::FAKEDB + :expansion/description "Find the user matching the reset code." + :expansion/key :reset + :expansion/name ::db/query}) + seconds->authenticate-expansion + (fn [seconds] + {:expansion/name ::auth/authenticate-reset + :expansion/description "Authentication reset code." + :expansion/key :validation + :reset-expiration-seconds seconds})] + (are + [expected config req] + (= expected (let [dispatcher {:dispatcher/type ::auth/reset-password=>} + auth-config (merge {:secret-key "secret"} config) + app (plugins->loaded [db-plugin (auth/plugin auth-config)]) + req* (merge app req {::bread/dispatcher dispatcher})] + (binding [t/*now* !now] + (with-redefs [hashers/derive mock-derive + sha-512 mock-sha-512] + (bread/dispatch req*))))) + + ;; Just loading the reset page. + {:expansions [(code->expansion "secret:qwerty") + (seconds->authenticate-expansion 600)]} + nil + {:request-method :get + :uri "/reset" + :params {:code "qwerty"}} + + ;; Loading the reset page, different code. + {:expansions [(code->expansion "secret:foo") + (seconds->authenticate-expansion 600)]} + nil + {:request-method :get + :uri "/reset" + :params {:code "foo"}} + + ;; Loading reset page with non-default expiration seconds. + {:expansions [(code->expansion "secret:foo") + (seconds->authenticate-expansion 42)]} + {:reset-expiration-seconds 42} + {:request-method :get + :uri "/reset" + :params {:code "foo"}} + + ;; Submitting reset page. + {:expansions [(code->expansion "secret:foo") + (seconds->authenticate-expansion 600) + {:expansion/name ::auth/validate-reset + :expansion/key :validation + :expansion/description "Validate password update." + :params {:code "foo" + :password "newpassword" + :password-confirmation "newpassword"} + :min-password-length 12 + :max-password-length 72}] + :effects [{:effect/name ::auth/reset-password + :effect/description "Update password upon valid submission." + :hash-algorithm :bcrypt+blake2b-512 + :params {:code "foo" + :password "newpassword" + :password-confirmation "newpassword"} + :conn db-conn}] + :hooks {::bread/render + [{:action/name ::ring/redirect-when + :action/description "Render reset page or redirect to login." + :to "/login" + :path [:validation 0]}]}} + {} + {:request-method :post + :uri "/reset" + :params {:code "foo" + :password "newpassword" + :password-confirmation "newpassword"}} + + ;; Submitting reset page. + {:expansions [(code->expansion "secret:foo") + (seconds->authenticate-expansion 600) + {:expansion/name ::auth/validate-reset + :expansion/key :validation + :expansion/description "Validate password update." + :params {:code "foo" + :password "newpassword" + :password-confirmation "newpassword"} + :min-password-length 12 + :max-password-length 72}] + :effects [{:effect/name ::auth/reset-password + :effect/description "Update password upon valid submission." + :hash-algorithm :bcrypt+blake2b-512 + :params {:code "foo" + :password "newpassword" + :password-confirmation "newpassword"} + :conn db-conn}] + :hooks {::bread/render + [{:action/name ::ring/redirect-when + :action/description "Render reset page or redirect to login." + :to "/login" + :path [:validation 0]}]}} + {} + {:request-method :post + :uri "/reset" + :params {:code "foo" + :password "newpassword" + :password-confirmation "newpassword"}} + + ;; Submitting reset page with custom auth config. + {:expansions [(code->expansion "different:foo") + (seconds->authenticate-expansion 42) + {:expansion/name ::auth/validate-reset + :expansion/key :validation + :expansion/description "Validate password update." + :params {:code "foo" + :password "newpassword" + :password-confirmation "newpassword"} + :min-password-length 3 + :max-password-length 33}] + :effects [{:effect/name ::auth/reset-password + :effect/description "Update password upon valid submission." + :hash-algorithm :bcrypt+blake2b-512 + :params {:code "foo" + :password "newpassword" + :password-confirmation "newpassword"} + :conn db-conn}] + :hooks {::bread/render + [{:action/name ::ring/redirect-when + :action/description "Render reset page or redirect to login." + :to "/login" + :path [:validation 0]}]}} + {:secret-key "different" + :reset-expiration-seconds 42 + :min-password-length 3 + :max-password-length 33} + {:request-method :post + :uri "/reset" + :params {:code "foo" + :password "newpassword" + :password-confirmation "newpassword"}} + + ,))) + +(comment + (require '[kaocha.repl :as k]) + (k/run {:color? false})) From 7302293754198394e192f4afbec8ef4247d6dffe Mon Sep 17 00:00:00 2001 From: Coby Tamayo Date: Mon, 13 Apr 2026 21:34:43 -0700 Subject: [PATCH 29/38] cleanup --- .../systems/bread/alpha/reset_password_test.clj | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/test/cms/systems/bread/alpha/reset_password_test.clj b/test/cms/systems/bread/alpha/reset_password_test.clj index bff0a808..24f59c00 100644 --- a/test/cms/systems/bread/alpha/reset_password_test.clj +++ b/test/cms/systems/bread/alpha/reset_password_test.clj @@ -10,15 +10,13 @@ [systems.bread.alpha.core :as bread] [systems.bread.alpha.database :as db] [systems.bread.alpha.internal.interop :refer [sha-512]] - [systems.bread.alpha.internal.time :as t] [systems.bread.alpha.plugin.auth :as auth] [systems.bread.alpha.ring :as ring]) (:import [java.util Date])) (deftest test-reset-password=> - (let [!now (Date.) - db-plugin (db->plugin ::FAKEDB) + (let [db-plugin (db->plugin ::FAKEDB) db-conn (:db/connection (:config db-plugin)) code->expansion (fn [code] @@ -33,7 +31,8 @@ :user/locked-at :user/failed-login-count]}]) .] :in [$ ?code] - :where [[?e :reset/code ?code] (not [?e :reset/reset-at])]} + :where [[?e :reset/code ?code] + (not [?e :reset/reset-at])]} (mock-sha-512 code)] :expansion/db ::FAKEDB :expansion/description "Find the user matching the reset code." @@ -51,10 +50,9 @@ auth-config (merge {:secret-key "secret"} config) app (plugins->loaded [db-plugin (auth/plugin auth-config)]) req* (merge app req {::bread/dispatcher dispatcher})] - (binding [t/*now* !now] - (with-redefs [hashers/derive mock-derive - sha-512 mock-sha-512] - (bread/dispatch req*))))) + (with-redefs [hashers/derive mock-derive + sha-512 mock-sha-512] + (bread/dispatch req*)))) ;; Just loading the reset page. {:expansions [(code->expansion "secret:qwerty") From 87d51bd629eb2e41c42fdac4a33f0a9d80d49117 Mon Sep 17 00:00:00 2001 From: Coby Tamayo Date: Mon, 13 Apr 2026 21:52:05 -0700 Subject: [PATCH 30/38] test ::auth/forgot-password=> --- .../bread/alpha/reset_password_test.clj | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/test/cms/systems/bread/alpha/reset_password_test.clj b/test/cms/systems/bread/alpha/reset_password_test.clj index 24f59c00..6a7fbae0 100644 --- a/test/cms/systems/bread/alpha/reset_password_test.clj +++ b/test/cms/systems/bread/alpha/reset_password_test.clj @@ -173,6 +173,59 @@ ,))) +(deftest test-forgot-password=> + (let [db-plugin (db->plugin ::FAKEDB) + db-conn (:db/connection (:config db-plugin)) + username->expansion + (fn [username] + {:expansion/name ::db/query + :expansion/description "Query user by username." + :expansion/key :user + :expansion/db ::FAKEDB + :expansion/args ['{:find [(pull ?e [:db/id + :user/locked-at + {:reset/_user + [:db/id + :thing/updated-at + :reset/reset-at]} + {:user/emails [*]}]) .] + :in [$ ?username] + :where [[?e :user/username ?username]]} + username]}) + forgot-effect + {:effect/name ::auth/forgot-password + :effect/description "Send user a reset link, if they have a confirmed email." + :conn db-conn + :secret-key "secret"}] + (are + [expected config req] + (= expected (let [dispatcher {:dispatcher/type ::auth/forgot-password=>} + auth-config (merge {:secret-key "secret"} config) + app (plugins->loaded [db-plugin (auth/plugin auth-config)]) + req* (merge app req {::bread/dispatcher dispatcher})] + (bread/dispatch req*))) + + ;; Just loading the forgot password page. No special logic for GET requests. + nil nil {:request-method :get :uri "/forgot"} + + ;; Submitting the forgot page. + {:expansions [(username->expansion "test")] + :effects [forgot-effect]} + nil + {:request-method :post + :uri "/forgot" + :params {:username "test"}} + + ;; Submitting the forgot page with a differen username. + {:expansions [(username->expansion "soandso")] + :effects [forgot-effect]} + nil + {:request-method :post + :uri "/forgot" + :params {:username "soandso"}} + + ,))) + (comment (require '[kaocha.repl :as k]) (k/run {:color? false})) From 95cfacc0c94adccc709e00399116d278cb4cc800 Mon Sep 17 00:00:00 2001 From: Coby Tamayo Date: Mon, 13 Apr 2026 22:05:03 -0700 Subject: [PATCH 31/38] bang! --- plugins/auth/systems/bread/alpha/plugin/auth.cljc | 12 ++++++------ test/cms/systems/bread/alpha/reset_password_test.clj | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/plugins/auth/systems/bread/alpha/plugin/auth.cljc b/plugins/auth/systems/bread/alpha/plugin/auth.cljc index 3dfdc723..eebfe53d 100644 --- a/plugins/auth/systems/bread/alpha/plugin/auth.cljc +++ b/plugins/auth/systems/bread/alpha/plugin/auth.cljc @@ -407,7 +407,7 @@ [{:action/name ::=>logged-in :action/description "Redirect after login"}]}}))) -(defmethod bread/effect ::forgot-password +(defmethod bread/effect ::forgot-password! [{:keys [conn secret-key]} {:keys [user config]}] (let [primary-email (first (filter :email/primary? (:user/emails user))) primary-confirmed? (boolean (:email/confirmed-at primary-email)) @@ -433,7 +433,7 @@ :effect/description "Create a password reset." :conn conn :txs [reset-tx]} - {:effect/name ::reset-password-email + {:effect/name ::reset-password-email! :effect/decsription "Create password reset message." :to (:email/address primary-email) :code code}]})))) @@ -447,7 +447,7 @@ (name scheme) server-name (when server-port (str ":" server-port)) (:auth/reset-password-uri config) (URLEncoder/encode code))) -(defmethod bread/effect ::reset-password-email +(defmethod bread/effect ::reset-password-email! [{:keys [to code]} {:as data :keys [config i18n ring/server-name]}] (let [link (reset-link (assoc data :reset/code code)) site-name (:site/name config server-name) @@ -488,7 +488,7 @@ :where [[?e :user/username ?username]]} (:username params)]}] :effects - [{:effect/name ::forgot-password + [{:effect/name ::forgot-password! :effect/description "Send user a reset link, if they have a confirmed email." :conn (db/connection req) @@ -531,7 +531,7 @@ [:auth/password-must-be-at-most max-password-length]))] [valid? error])))) -(defmethod bread/effect ::reset-password +(defmethod bread/effect ::reset-password! [{:keys [params hash-algorithm conn]} {:keys [reset validation]}] (let [user (:reset/user reset) [valid?] validation] @@ -588,7 +588,7 @@ :min-password-length (bread/config req :auth/min-password-length) :max-password-length (bread/config req :auth/max-password-length)}] :effects - [{:effect/name ::reset-password + [{:effect/name ::reset-password! :effect/description "Update password upon valid submission." :params params :hash-algorithm (bread/config req :auth/hash-algorithm) diff --git a/test/cms/systems/bread/alpha/reset_password_test.clj b/test/cms/systems/bread/alpha/reset_password_test.clj index 6a7fbae0..717bdfb6 100644 --- a/test/cms/systems/bread/alpha/reset_password_test.clj +++ b/test/cms/systems/bread/alpha/reset_password_test.clj @@ -89,7 +89,7 @@ :password-confirmation "newpassword"} :min-password-length 12 :max-password-length 72}] - :effects [{:effect/name ::auth/reset-password + :effects [{:effect/name ::auth/reset-password! :effect/description "Update password upon valid submission." :hash-algorithm :bcrypt+blake2b-512 :params {:code "foo" @@ -119,7 +119,7 @@ :password-confirmation "newpassword"} :min-password-length 12 :max-password-length 72}] - :effects [{:effect/name ::auth/reset-password + :effects [{:effect/name ::auth/reset-password! :effect/description "Update password upon valid submission." :hash-algorithm :bcrypt+blake2b-512 :params {:code "foo" @@ -149,7 +149,7 @@ :password-confirmation "newpassword"} :min-password-length 3 :max-password-length 33}] - :effects [{:effect/name ::auth/reset-password + :effects [{:effect/name ::auth/reset-password! :effect/description "Update password upon valid submission." :hash-algorithm :bcrypt+blake2b-512 :params {:code "foo" @@ -193,7 +193,7 @@ :where [[?e :user/username ?username]]} username]}) forgot-effect - {:effect/name ::auth/forgot-password + {:effect/name ::auth/forgot-password! :effect/description "Send user a reset link, if they have a confirmed email." :conn db-conn :secret-key "secret"}] From 7971622e12bd87de9f228225af5e0bf7d40010f1 Mon Sep 17 00:00:00 2001 From: Coby Tamayo Date: Mon, 13 Apr 2026 22:52:30 -0700 Subject: [PATCH 32/38] test ::forgot-password! --- .../bread/alpha/reset_password_test.clj | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/test/cms/systems/bread/alpha/reset_password_test.clj b/test/cms/systems/bread/alpha/reset_password_test.clj index 717bdfb6..c174569f 100644 --- a/test/cms/systems/bread/alpha/reset_password_test.clj +++ b/test/cms/systems/bread/alpha/reset_password_test.clj @@ -2,6 +2,7 @@ (:require [buddy.hashers :as hashers] [clojure.test :refer [deftest are]] + [crypto.random :as random] [systems.bread.alpha.test-helpers :refer [db->plugin plugins->loaded @@ -10,6 +11,7 @@ [systems.bread.alpha.core :as bread] [systems.bread.alpha.database :as db] [systems.bread.alpha.internal.interop :refer [sha-512]] + [systems.bread.alpha.internal.time :as t] [systems.bread.alpha.plugin.auth :as auth] [systems.bread.alpha.ring :as ring]) (:import @@ -226,6 +228,143 @@ ,))) +(deftest test-forgot-password! + (let [!now (Date.)] + (are + [expected effect data] + (= expected (with-redefs [sha-512 mock-sha-512 + random/hex (constantly "randomhex")] + (binding [t/*now* !now] + (bread/effect effect data)))) + + ;; Invalid request; noop. + nil {:effect/name ::auth/reset-password!} nil + nil {:effect/name ::auth/reset-password!} {} + + ;; Bad username. + nil {:effect/name ::auth/reset-password!} {:user false} + + ;; User with no emails setup. + nil + {:effect/name ::auth/forgot-password! + :secret-key "secret" + :conn ::DBCONN} + {:user {:db/id 123} + :config {:auth/lock-seconds 3600}} + + ;; User with no confirmed emails. + nil + {:effect/name ::auth/forgot-password! + :secret-key "secret" + :conn ::DBCONN} + {:user {:db/id 123 + :user/emails [{:email/address "whatever"}]} + :config {:auth/lock-seconds 3600}} + + ;; Unconfirmed primary email. This is an abnormal situation, but we want to assert + ;; assert here that both conditions (primary, confirmed) should be true. + nil + {:effect/name ::auth/forgot-password! + :secret-key "secret" + :conn ::DBCONN} + {:user {:db/id 123 + :user/emails [{:email/address "whatever" + :email/primary? true}]} + :config {:auth/lock-seconds 3600}} + + ;; Confirmed, non-primary email. Also abnormal, per above. + nil + {:effect/name ::auth/forgot-password! + :secret-key "secret" + :conn ::DBCONN} + {:user {:db/id 123 + :user/emails [{:email/address "whatever" + :email/confirmed-at (t/seconds-ago 1)}]} + :config {:auth/lock-seconds 3600}} + + ;; First forgot request. + {:effects + [{:effect/name ::db/transact + :effect/description "Create a password reset." + :conn ::DBCONN + :txs [{:reset/code "sha-512[secret:randomhex]" + :reset/user 123 + :thing/updated-at !now + :thing/created-at !now}]} + {:effect/name ::auth/reset-password-email! + :effect/decsription "Create password reset message." + :to "someone@example.com" + :code "randomhex"}]} + {:effect/name ::auth/forgot-password! + :secret-key "secret" + :conn ::DBCONN} + {:user {:db/id 123 + :user/emails [{:email/address "someone@example.com" + :email/primary? true + :email/confirmed-at (t/seconds-ago 1)}]} + :config {:auth/lock-seconds 3600}} + + ;; Subsequent forgot request. + {:effects + [{:effect/name ::db/transact + :effect/description "Create a password reset." + :conn ::DBCONN + :txs [{:db/id 456 ;; no created-at + :reset/code "sha-512[secret:randomhex]" + :reset/user 123 + :thing/updated-at !now}]} + {:effect/name ::auth/reset-password-email! + :effect/decsription "Create password reset message." + :to "someone@example.com" + :code "randomhex"}]} + {:effect/name ::auth/forgot-password! + :secret-key "secret" + :conn ::DBCONN} + {:user {:db/id 123 + :user/emails [{:email/address "someone@example.com" + :email/primary? true + :email/confirmed-at (t/seconds-ago 1)}] + :reset/_user [{:db/id 456 + :reset/code "oldcode" + :thing/updated-at (t/seconds-ago 60)}]} + :config {:auth/lock-seconds 3600}} + + ;; Subsequent forgot request; multiple confirmed emails. + {:effects + [{:effect/name ::db/transact + :effect/description "Create a password reset." + :conn ::DBCONN + :txs [{:db/id 456 ;; no created-at + :reset/code "sha-512[secret:randomhex]" + :reset/user 123 + :thing/updated-at !now}]} + {:effect/name ::auth/reset-password-email! + :effect/decsription "Create password reset message." + :to "primary@example.com" + :code "randomhex"}]} + {:effect/name ::auth/forgot-password! + :secret-key "secret" + :conn ::DBCONN} + {:user {:db/id 123 + :user/emails [{:email/address "primary@example.com" + :email/primary? true + :email/confirmed-at (t/seconds-ago 100)} + {:email/address "second@example.com" + :email/confirmed-at (t/seconds-ago 10)} + {:email/address "first@example.com" + :email/confirmed-at (t/seconds-ago 1)}] + :reset/_user [{:db/id 456 + :reset/code "oldcode" + :thing/updated-at (t/seconds-ago 60)}]} + :config {:auth/lock-seconds 3600}} + + ,))) + +;; TODO ::reset-password-email! effect +;; TODO ::authenticate-reset expansion +;; TODO ::validate-reset expansion +;; TODO ::reset-password! + (comment (require '[kaocha.repl :as k]) (k/run {:color? false})) From 3544f49b78a6719e74e93d7ae75596f8426d6b07 Mon Sep 17 00:00:00 2001 From: Coby Tamayo Date: Mon, 13 Apr 2026 23:11:49 -0700 Subject: [PATCH 33/38] test ::auth/reset-password-email! --- .../auth/systems/bread/alpha/plugin/auth.cljc | 2 +- .../bread/alpha/reset_password_test.clj | 49 ++++++++++++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/plugins/auth/systems/bread/alpha/plugin/auth.cljc b/plugins/auth/systems/bread/alpha/plugin/auth.cljc index eebfe53d..96a2e518 100644 --- a/plugins/auth/systems/bread/alpha/plugin/auth.cljc +++ b/plugins/auth/systems/bread/alpha/plugin/auth.cljc @@ -444,7 +444,7 @@ ring/server-name ring/server-port]}] (format "%s://%s%s%s?code=%s" - (name scheme) server-name (when server-port (str ":" server-port)) + (name scheme) server-name (if server-port (str ":" server-port) "") (:auth/reset-password-uri config) (URLEncoder/encode code))) (defmethod bread/effect ::reset-password-email! diff --git a/test/cms/systems/bread/alpha/reset_password_test.clj b/test/cms/systems/bread/alpha/reset_password_test.clj index c174569f..4374f3ea 100644 --- a/test/cms/systems/bread/alpha/reset_password_test.clj +++ b/test/cms/systems/bread/alpha/reset_password_test.clj @@ -13,6 +13,7 @@ [systems.bread.alpha.internal.interop :refer [sha-512]] [systems.bread.alpha.internal.time :as t] [systems.bread.alpha.plugin.auth :as auth] + [systems.bread.alpha.plugin.email :as email] [systems.bread.alpha.ring :as ring]) (:import [java.util Date])) @@ -360,7 +361,53 @@ ,))) -;; TODO ::reset-password-email! effect +(deftest test-reset-password-email! + (are + [expected effect data] + (= expected (with-redefs [random/hex (constantly "randomhex")] + (bread/effect effect data))) + + {:effects + [{:effect/name ::email/send! + :effect/description "Send reset password email." + :message {:from "app@bread.systems" + :to "someone@example.com" + :subject "reset yr pwd" + :body (str "site: Example Site " + "link: https://bread.systems/reset?code=qwerty")}}]} + {:effect/name ::auth/reset-password-email! + :to "someone@example.com" + :code "qwerty"} + {:config {:site/name "Example Site" + :auth/reset-password-uri "/reset" + :email/smtp-from-email "app@bread.systems"} + :i18n {:auth/reset-password-email-body "site: %s link: %s" + :auth/reset-password-email-subject "reset yr pwd"} + :ring/scheme :https + :ring/server-name "bread.systems"} + + ;; Without a configured :site/name. + {:effects + [{:effect/name ::email/send! + :effect/description "Send reset password email." + :message {:from "alt@bread.systems" + :to "other@example.com" + :subject "reset yr pwd" + :body (str "site: bread.systems " + "link: http://bread.systems:8080/reset?code=mycode")}}]} + {:effect/name ::auth/reset-password-email! + :to "other@example.com" + :code "mycode"} + {:config {:auth/reset-password-uri "/reset" + :email/smtp-from-email "alt@bread.systems"} + :i18n {:auth/reset-password-email-body "site: %s link: %s" + :auth/reset-password-email-subject "reset yr pwd"} + :ring/scheme :http + :ring/server-port 8080 + :ring/server-name "bread.systems"} + + ,)) + ;; TODO ::authenticate-reset expansion ;; TODO ::validate-reset expansion ;; TODO ::reset-password! From e9459881b45f441a79b58e910e433c0bd4750268 Mon Sep 17 00:00:00 2001 From: Coby Tamayo Date: Mon, 13 Apr 2026 23:40:28 -0700 Subject: [PATCH 34/38] test ::auth/reset-password! --- .../bread/alpha/reset_password_test.clj | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/test/cms/systems/bread/alpha/reset_password_test.clj b/test/cms/systems/bread/alpha/reset_password_test.clj index 4374f3ea..bfe589bb 100644 --- a/test/cms/systems/bread/alpha/reset_password_test.clj +++ b/test/cms/systems/bread/alpha/reset_password_test.clj @@ -410,7 +410,54 @@ ;; TODO ::authenticate-reset expansion ;; TODO ::validate-reset expansion -;; TODO ::reset-password! + +(deftest test-reset-password! + (let [!now (Date.)] + (are + [expected effect data] + (= expected (with-redefs [hashers/derive mock-derive] + (binding [t/*now* !now] + (bread/effect effect data)))) + + nil {:effect/name ::auth/reset-password!} nil + nil {:effect/name ::auth/reset-password!} {} + nil {:effect/name ::auth/reset-password!} {:validation nil} + nil {:effect/name ::auth/reset-password!} {:validation []} + nil {:effect/name ::auth/reset-password!} {:validation [false :whatever]} + + {:effects [{:effect/name ::db/transact + :conn ::DBCONN + :txs [{:db/id 123 + ;; TODO secret-key + :user/password "[:algo+password123]" + :thing/updated-at !now} + {:db/id 456 + :reset/reset-at !now + :thing/updated-at !now}]}]} + {:effect/name ::auth/reset-password! + :params {:password "password123"} + :conn ::DBCONN + :hash-algorithm :algo} + {:validation [true nil] + :reset {:db/id 456 :reset/user {:db/id 123}}} + + {:effects [{:effect/name ::db/transact + :conn ::DBCONN + :txs [{:db/id 567 + ;; TODO secret-key + :user/password "[:roll-yr-own-crypto+newpass]" + :thing/updated-at !now} + {:db/id 345 + :reset/reset-at !now + :thing/updated-at !now}]}]} + {:effect/name ::auth/reset-password! + :params {:password "newpass"} + :conn ::DBCONN + :hash-algorithm :roll-yr-own-crypto} + {:validation [true nil] + :reset {:db/id 345 :reset/user {:db/id 567}}} + + ,))) (comment (require '[kaocha.repl :as k]) From b2c7109a239dca77fdd89637efa31e4a72d2a0f9 Mon Sep 17 00:00:00 2001 From: Coby Tamayo Date: Tue, 14 Apr 2026 01:28:12 -0700 Subject: [PATCH 35/38] lock-seconds in ::auth/reset-password=> --- plugins/auth/systems/bread/alpha/plugin/auth.cljc | 4 +++- test/cms/systems/bread/alpha/reset_password_test.clj | 10 ++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/plugins/auth/systems/bread/alpha/plugin/auth.cljc b/plugins/auth/systems/bread/alpha/plugin/auth.cljc index 96a2e518..f6dae4ee 100644 --- a/plugins/auth/systems/bread/alpha/plugin/auth.cljc +++ b/plugins/auth/systems/bread/alpha/plugin/auth.cljc @@ -558,7 +558,9 @@ :expansion/description "Authentication reset code." :expansion/key :validation :reset-expiration-seconds - (bread/config req :auth/reset-expiration-seconds)} + (bread/config req :auth/reset-expiration-seconds) + :lock-seconds + (bread/config req :auth/lock-seconds)} secret-key (bread/config req :auth/secret-key) query-user {:expansion/name ::db/query :expansion/key :reset diff --git a/test/cms/systems/bread/alpha/reset_password_test.clj b/test/cms/systems/bread/alpha/reset_password_test.clj index bfe589bb..09060910 100644 --- a/test/cms/systems/bread/alpha/reset_password_test.clj +++ b/test/cms/systems/bread/alpha/reset_password_test.clj @@ -42,11 +42,12 @@ :expansion/key :reset :expansion/name ::db/query}) seconds->authenticate-expansion - (fn [seconds] + (fn [expiration-seconds & [lock-seconds]] {:expansion/name ::auth/authenticate-reset :expansion/description "Authentication reset code." :expansion/key :validation - :reset-expiration-seconds seconds})] + :reset-expiration-seconds expiration-seconds + :lock-seconds (or lock-seconds 3600)})] (are [expected config req] (= expected (let [dispatcher {:dispatcher/type ::auth/reset-password=>} @@ -75,8 +76,9 @@ ;; Loading reset page with non-default expiration seconds. {:expansions [(code->expansion "secret:foo") - (seconds->authenticate-expansion 42)]} - {:reset-expiration-seconds 42} + (seconds->authenticate-expansion 42 420)]} + {:reset-expiration-seconds 42 + :lock-seconds 420} {:request-method :get :uri "/reset" :params {:code "foo"}} From e8011e7d6a429539149054767f660a78da3e04e3 Mon Sep 17 00:00:00 2001 From: Coby Tamayo Date: Tue, 14 Apr 2026 01:39:06 -0700 Subject: [PATCH 36/38] test ::authenticate-reset --- .../auth/systems/bread/alpha/plugin/auth.cljc | 9 ++-- .../bread/alpha/reset_password_test.clj | 45 ++++++++++++++++++- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/plugins/auth/systems/bread/alpha/plugin/auth.cljc b/plugins/auth/systems/bread/alpha/plugin/auth.cljc index f6dae4ee..bd0cd190 100644 --- a/plugins/auth/systems/bread/alpha/plugin/auth.cljc +++ b/plugins/auth/systems/bread/alpha/plugin/auth.cljc @@ -495,13 +495,14 @@ :secret-key (bread/config req :auth/secret-key)}]}))) (defmethod bread/expand ::authenticate-reset - [{:keys [reset-expiration-seconds]} + [{:keys [reset-expiration-seconds lock-seconds]} {{:as reset :keys [reset/user]} :reset}] (let [earliest-valid (t/seconds-ago reset-expiration-seconds) updated-at (:thing/updated-at reset) - valid? (and updated-at (.after updated-at earliest-valid))] - (prn 'USER user) - ;; TODO check :user/locked-at + locked-period-start (t/seconds-ago lock-seconds) + locked-at (:user/locked-at user) + unlocked? (or (not locked-at) (.before locked-at locked-period-start)) + valid? (and unlocked? updated-at (.after updated-at earliest-valid))] (if valid? [true nil] [false :auth/invalid-reset]))) diff --git a/test/cms/systems/bread/alpha/reset_password_test.clj b/test/cms/systems/bread/alpha/reset_password_test.clj index 09060910..0fcc2325 100644 --- a/test/cms/systems/bread/alpha/reset_password_test.clj +++ b/test/cms/systems/bread/alpha/reset_password_test.clj @@ -410,7 +410,50 @@ ,)) -;; TODO ::authenticate-reset expansion +(deftest test-authenticate-reset + (are + [expected expansion data] + (= expected (bread/expand expansion data)) + + [false :auth/invalid-reset] + {:expansion/name ::auth/authenticate-reset + :reset-expiration-seconds 1 + :lock-seconds 3600} + {:reset false} + + [false :auth/invalid-reset] + {:expansion/name ::auth/authenticate-reset + :reset-expiration-seconds 60 + :lock-seconds 3600} + {:reset false} + + [true nil] + {:expansion/name ::auth/authenticate-reset + :reset-expiration-seconds 60 + :lock-seconds 3600} + {:reset {:thing/updated-at (t/seconds-ago 59) + :reset/user {:db/id 123}}} + + ;; Attempting to reset a locked account. + [false :auth/invalid-reset] + {:expansion/name ::auth/authenticate-reset + :reset-expiration-seconds 60 + :lock-seconds 3600} + {:reset {:thing/updated-at (t/seconds-ago 59) + :reset/user {:db/id 123 + :user/locked-at (t/seconds-ago 3599)}}} + + ;; Previously locked account. + [true nil] + {:expansion/name ::auth/authenticate-reset + :reset-expiration-seconds 60 + :lock-seconds 3600} + {:reset {:thing/updated-at (t/seconds-ago 59) + :reset/user {:db/id 123 + :user/locked-at (t/seconds-ago 3601)}}} + + ,)) + ;; TODO ::validate-reset expansion (deftest test-reset-password! From bdce039f57d423831ab07d8f09bcc5fc06d627b9 Mon Sep 17 00:00:00 2001 From: Coby Tamayo Date: Tue, 14 Apr 2026 10:10:08 -0700 Subject: [PATCH 37/38] test ::validate-reset --- .../bread/alpha/reset_password_test.clj | 100 +++++++++++++++++- 1 file changed, 99 insertions(+), 1 deletion(-) diff --git a/test/cms/systems/bread/alpha/reset_password_test.clj b/test/cms/systems/bread/alpha/reset_password_test.clj index 0fcc2325..ce07cf6e 100644 --- a/test/cms/systems/bread/alpha/reset_password_test.clj +++ b/test/cms/systems/bread/alpha/reset_password_test.clj @@ -454,7 +454,105 @@ ,)) -;; TODO ::validate-reset expansion +(deftest test-validate-reset + (are + [expected expansion data] + (= expected (bread/expand expansion data)) + + ;; Prior validation. Pass-through. + [false :auth/invalid-reset] + {:expansion/name ::auth/validate-reset} + {:validation [false :auth/invalid-reset]} + + ;; No params present. + [false :auth/enter-confirm-new-password] + {:expansion/name ::auth/validate-reset + :min-password-length 1 + :max-password-length 2 + :params {}} + {:validation [true nil]} + + ;; No params present. + [false :auth/enter-confirm-new-password] + {:expansion/name ::auth/validate-reset + :min-password-length 1 + :max-password-length 2 + :params {;; Code does not come into play at this stage. + :password "" + :password-confirmation ""}} + {:validation [true nil]} + + ;; Password but no confirmation. + [false :auth/enter-confirm-new-password] + {:expansion/name ::auth/validate-reset + :min-password-length 1 + :max-password-length 2 + :params {;; Code does not come into play at this stage. + :password "asdf" + :password-confirmation ""}} + {:validation [true nil]} + + ;; Confirmation; no password. + [false :auth/enter-confirm-new-password] + {:expansion/name ::auth/validate-reset + :min-password-length 1 + :max-password-length 2 + :params {;; Code does not come into play at this stage. + :password "" + :password-confirmation "asdf"}} + {:validation [true nil]} + + ;; Password mismatch. + [false :auth/passwords-must-match] + {:expansion/name ::auth/validate-reset + :min-password-length 3 + :max-password-length 10 + :params {;; Code does not come into play at this stage. + :password "one" + :password-confirmation "two"}} + {:validation [true nil]} + + ;; Password under minimum length. + [false [:auth/password-must-be-at-least 12]] + {:expansion/name ::auth/validate-reset + :min-password-length 12 + :max-password-length 72 + :params {;; Code does not come into play at this stage. + :password "elevenchars" + :password-confirmation "elevenchars"}} + {:validation [true nil]} + + ;; Password under minimum length. + [false [:auth/password-must-be-at-least 12]] + {:expansion/name ::auth/validate-reset + :min-password-length 12 + :max-password-length 72 + :params {;; Code does not come into play at this stage. + :password "2short" + :password-confirmation "2short"}} + {:validation [true nil]} + + ;; Password over maximum length. + [false [:auth/password-must-be-at-most 12]] + {:expansion/name ::auth/validate-reset + :min-password-length 12 + :max-password-length 12 + :params {;; Code does not come into play at this stage. + :password "thirteenchars" + :password-confirmation "thirteenchars"}} + {:validation [true nil]} + + ;; Password over maximum length. + [false [:auth/password-must-be-at-most 12]] + {:expansion/name ::auth/validate-reset + :min-password-length 12 + :max-password-length 12 + :params {;; Code does not come into play at this stage. + :password "this password is way too long" + :password-confirmation "this password is way too long"}} + {:validation [true nil]} + + ,)) (deftest test-reset-password! (let [!now (Date.)] From bd6b0d7d236cafb4ff7492bac073c38eb9522d38 Mon Sep 17 00:00:00 2001 From: Coby Tamayo Date: Tue, 14 Apr 2026 10:38:16 -0700 Subject: [PATCH 38/38] forgot password link from login --- cms/themes/rise/src/systems/bread/alpha/cms/theme/rise.cljc | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cms/themes/rise/src/systems/bread/alpha/cms/theme/rise.cljc b/cms/themes/rise/src/systems/bread/alpha/cms/theme/rise.cljc index cdf09ca1..d19dc9f6 100644 --- a/cms/themes/rise/src/systems/bread/alpha/cms/theme/rise.cljc +++ b/cms/themes/rise/src/systems/bread/alpha/cms/theme/rise.cljc @@ -228,7 +228,11 @@ (when error? (hook ::html.invalid-login (ErrorMessage {:message (:auth/invalid-username-password i18n)}))) - (Submit (:auth/login i18n))]])})) + [:.flex.row + [:.spacer] + [:a {:href (:auth/forgot-password-uri config)} + (:auth/forgot-password i18n)] + (Submit (:auth/login i18n))]]])})) (defc ForgotPasswordPage [{:keys [config hook i18n ring/anti-forgery-token-field ring/request-method]}]