diff --git a/cms/core/src/systems/bread/alpha/cms/main.cljc b/cms/core/src/systems/bread/alpha/cms/main.cljc index b527824a..bfb205b4 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=> @@ -595,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 @@ -615,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.)}]) 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/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..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 @@ -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] @@ -229,29 +228,82 @@ (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]}] + {: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] - :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=>` 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 :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 @@ -262,10 +314,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 :get, error path for :post. :default [:main [:form.flex.col {:name :bread-login :method :post} @@ -273,9 +327,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 (i18n/t i18n error-key)}))) (Field :password :type :password :label (:auth/password i18n) @@ -284,6 +338,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]}] @@ -681,6 +740,7 @@ {:invitation/_invited-by [* :thing/created-at {:invitation/email [*]}]}]} {:title (:invitations/invitations i18n) + :head (hook ::invitations/html.head [:<>]) :content [:main.flex.col ;; TODO @@ -793,7 +853,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)})) @@ -825,6 +885,7 @@ ErrorMessage Page LoginPage + ForgotPasswordPage ResetPasswordPage AccountNav SettingsPage diff --git a/dev/main.edn b/dev/main.edn index 0360ec7e..55303394 100644 --- a/dev/main.edn +++ b/dev/main.edn @@ -24,6 +24,8 @@ :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 :store-session-ip? true @@ -90,6 +92,13 @@ #{{: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 "locke" + :user/name "John Locke" + :user/locked-at #seconds-ago 10} {:user/username "reader" :user/name "Reader User" ;; No emails yet! diff --git a/plugins/auth/auth.i18n.edn b/plugins/auth/auth.i18n.edn index 2ff959b2..f9a93d4d 100644 --- a/plugins/auth/auth.i18n.edn +++ b/plugins/auth/auth.i18n.edn @@ -17,10 +17,14 @@ #: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-reset "This reset link is invalid or has already been used." :invalid-username-password "Invalid username or password." :login "Login" :logout "Logout" @@ -35,6 +39,12 @@ :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 "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 e211ccf7..bd0cd190 100644 --- a/plugins/auth/systems/bread/alpha/plugin/auth.cljc +++ b/plugins/auth/systems/bread/alpha/plugin/auth.cljc @@ -8,12 +8,13 @@ [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] [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.plugin.email :as email] + [systems.bread.alpha.ring :as ring]) (:import [java.lang IllegalArgumentException] [java.net URLEncoder] @@ -406,6 +407,203 @@ [{:action/name ::=>logged-in :action/description "Redirect after login"}]}}))) +(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 (if 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 lock-seconds]} + {{:as reset :keys [reset/user]} :reset}] + (let [earliest-valid (t/seconds-ago reset-expiration-seconds) + updated-at (:thing/updated-at reset) + 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]))) + +(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 [post? (= :post request-method) + authenticate-reset {:expansion/name ::authenticate-reset + :expansion/description "Authentication reset code." + :expansion/key :validation + :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 + :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 ::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."} schema @@ -453,9 +651,16 @@ :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"} + {: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" @@ -492,10 +697,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?] + ([{: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 12 max-password-length 72 hash-algorithm :bcrypt+blake2b-512 @@ -503,7 +721,9 @@ 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 ;; Don't track Personally Identfiable Information (PII) by default. store-session-ip? false @@ -546,6 +766,8 @@ :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? :store-session-user-agent? store-session-user-agent?}})) 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}})) 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])) 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 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. 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..ce07cf6e --- /dev/null +++ b/test/cms/systems/bread/alpha/reset_password_test.clj @@ -0,0 +1,607 @@ +(ns systems.bread.alpha.reset-password-test + (:require + [buddy.hashers :as hashers] + [clojure.test :refer [deftest are]] + [crypto.random :as random] + + [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.plugin.email :as email] + [systems.bread.alpha.ring :as ring]) + (:import + [java.util Date])) + +(deftest test-reset-password=> + (let [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 [expiration-seconds & [lock-seconds]] + {:expansion/name ::auth/authenticate-reset + :expansion/description "Authentication reset code." + :expansion/key :validation + :reset-expiration-seconds expiration-seconds + :lock-seconds (or lock-seconds 3600)})] + (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})] + (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 420)]} + {:reset-expiration-seconds 42 + :lock-seconds 420} + {: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"}} + + ,))) + +(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"}} + + ,))) + +(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}} + + ,))) + +(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"} + + ,)) + +(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)}}} + + ,)) + +(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.)] + (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]) + (k/run {:color? false})) 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}}))