diff --git a/src/source/db/honey.clj b/src/source/db/honey.clj index 029377a..def8879 100644 --- a/src/source/db/honey.clj +++ b/src/source/db/honey.clj @@ -3,7 +3,8 @@ [camel-snake-kebab.extras :as cske] [honey.sql.helpers :as hsql] [pg.core :as pg] - [pg.honey :as pgh])) + [pg.honey :as pgh] + [source.db.util :as db.util])) (defn execute! "computes a prepared statement for an sql map and executes select one @@ -91,15 +92,15 @@ (comment (hsql/where :or [:= :id 1] [:= :id 2]) - (def ds {}) + (def ds (db.util/conn)) (insert! ds {:tname :sectors :values {:name "something"} :ret :*}) (delete! ds - {:tname :feeds - :where [:= :id 6] + {:tname :users + :where [:= :id 20] :ret :*}) (update! ds @@ -107,6 +108,7 @@ :where [:= :id 3] :values {:type "creator"}}) - (find ds {:tname :bundles}) + (find ds {:tname :users + :where [:= :email "keaganncollins@gmail.com"]}) ()) diff --git a/src/source/email/templates.clj b/src/source/email/templates.clj index ab184d2..55e56f0 100644 --- a/src/source/email/templates.clj +++ b/src/source/email/templates.clj @@ -29,7 +29,7 @@ [:tr [:td {:class "footer" :style "background-color: #0F172A; padding: 20px; text-align: center; color: white; font-size: 14px;"} - "Copyright © 2025 | Wearesource"]]) + "Copyright © 2026 | Wearesource"]]) (defn feed-rejection "Returns the completed HTML for a feed rejection email" @@ -178,3 +178,40 @@ :style "padding: 40px; text-align: left; font-size: 11px; line-height: 1.6;"} "This is an automated message. Please do not reply directly to this email."]] (footer)]]]]]))) + +(defn password-reset-email + "Returns the completed HTML for a password reset link email" + [{:keys [password-hash]}] + (h/html5 + {:lang "en"} + (head-metadata) + [:body {:style "font-family: 'Switzer', sans-serif"} + [:table {:width "100%" :border "0" :cellspacing "0" :cellpadding "0"} + [:tr + [:td {:align "center" :style "padding: 20px;"} + [:table {:class "content" + :width "600" + :border "0" + :cellspacing "0" + :cellpadding "0" + :style "border-collapse: collapse; border: 1px solid #cccccc;"} + (header) + [:tr + [:td {:class "body" + :style "padding: 40px; text-align: left; font-size: 16px; line-height: 1.6;"} + "Welcome to Source!" + [:br] [:br] + "We received a request to reset your password. Please click the button below to set a new password for your account."]] + (button {:text "Reset Password" + :redirect (str (conf/read-value :cors-origin) "/password/" password-hash)}) + [:tr + [:td {:class "body" + :style "padding: 40px; text-align: left; font-size: 16px; line-height: 1.6;"} + "If you did not make this request, you can safely ignore this email."]] + [:tr + [:td {:class "body" + :style "padding: 40px; text-align: left; font-size: 16px; line-height: 1.6;"} + "Regards," + [:br] + "The Source Team"]] + (footer)]]]]])) diff --git a/src/source/migrations/019_password_hash.clj b/src/source/migrations/019_password_hash.clj new file mode 100644 index 0000000..9ac86fc --- /dev/null +++ b/src/source/migrations/019_password_hash.clj @@ -0,0 +1,18 @@ +(ns source.migrations.019-password-hash + (:require [source.db.master] + [source.db.honey :as hon] + [honey.sql.helpers :as hsql])) + +(defn run-up! [context] + (let [ds-master (:db-master context)] + (hon/execute! + ds-master + (-> (hsql/alter-table :users) + (hsql/add-column :password-hash :text))))) + +(defn run-down! [context] + (let [ds-master (:db-master context)] + (hon/execute! + ds-master + (-> (hsql/alter-table :users) + (hsql/drop-column :password-hash))))) diff --git a/src/source/routes/feeds.clj b/src/source/routes/feeds.clj index 3768d25..0705f86 100644 --- a/src/source/routes/feeds.clj +++ b/src/source/routes/feeds.clj @@ -14,6 +14,7 @@ [{:keys [ds user] :as _request}] (-> (hon/find ds {:tname :feeds + :order-by :created-at :where [:= :user-id (:id user)]}) (res/response))) diff --git a/src/source/routes/me.clj b/src/source/routes/me.clj index e1721c1..a1a07d6 100644 --- a/src/source/routes/me.clj +++ b/src/source/routes/me.clj @@ -8,7 +8,8 @@ [source.workers.users :as users] [source.email.gmail :as gmail] [source.email.templates :as templates] - [source.routes.openapi :as api])) + [source.routes.openapi :as api] + [source.password :as password])) (defn get {:summary "get logged in user by access token" @@ -100,3 +101,53 @@ :body (templates/email-verification {:email-hash email-hash}) :type :text/html}) (res/response {:message "successfully resent email-verification email"}))) + +(defn password-reset-auth + {:summary "Request an email to authenticate a password reset" + :parameters (api/params :body [:map [:email :string]]) + :responses (api/success (api/response-schema))} + [{:keys [ds body] :as _req}] + (let [{:keys [id email]} (hon/find-one ds {:tname :users + :where [:= :email (:email body)]}) + password-hash (util/uuid)] + (hon/update! ds {:tname :users + :where [:= :id id] + :data {:password-hash password-hash}}) + (gmail/send-email {:to email + :subject "Source - Reset your password" + :body (templates/password-reset-email {:password-hash password-hash}) + :type :text/html}) + (res/response {:message "password reset email has been sent successfully"}))) + +(defn reset-password + {:summary "Reset your password provided a valid password reset hash and the new password" + :parameters (api/params :path [:map [:hash {:description "Password Reset Hash"} :string]] + :body api/PasswordResetParams) + :responses (merge + (api/success (api/response-schema)) + (api/unauthenticated))} + [{:keys [ds path-params body]}] + (let [{:keys [password confirm-password]} body + user (users/user-by-password-hash ds (:hash path-params))] + (if (and (some? user) + (= password confirm-password)) + (do + (hon/update! ds {:tname :users + :where [:= :password-hash (:hash path-params)] + :data {:password (password/hash-password password) + :password-hash nil}}) + (res/response {:message "successfully reset password"})) + (res/response {:message "unauthorized"})))) + +(defn verify-password-reset-hash + {:summary "Verify password reset hash" + :parameters (api/params :path [:map [:hash {:description "Password Reset Hash"} :string]]) + :responses (merge + (api/success (api/response-schema)) + (api/unauthenticated))} + [{:keys [ds path-params] :as _req}] + (let [user (users/user-by-password-hash ds (:hash path-params))] + (if (some? user) + (res/response {:message "successfully verified password hash"}) + (-> (res/response {:message "unauthorized"}) + (res/status 401))))) diff --git a/src/source/routes/openapi.clj b/src/source/routes/openapi.clj index 1067dcd..6fe8efc 100644 --- a/src/source/routes/openapi.clj +++ b/src/source/routes/openapi.clj @@ -69,6 +69,7 @@ 400 (error data-schema)))) (defn unauthenticated + ([] (unauthenticated nil)) ([schema] (unauthenticated {} schema)) ([responses schema] (response (if (map? responses) responses {}) @@ -134,6 +135,11 @@ [:email :string] [:password :string]]) +(def PasswordResetParams + [:map + [:password :string] + [:confirm-password :string]]) + (def InsertVocab [:map [:xhosa :string] diff --git a/src/source/routes/reitit.clj b/src/source/routes/reitit.clj index 878d1a1..cab6fec 100644 --- a/src/source/routes/reitit.clj +++ b/src/source/routes/reitit.clj @@ -108,6 +108,10 @@ ["/sectors" (-> (get me-sectors/get) (post me-sectors/post))]] + ["/password/reset" (post me/password-reset-auth)] + ["/password/reset/:hash" (post me/reset-password)] + ["/password/reset/:hash/verify" (get me/verify-password-reset-hash)] + ["/mail" {:tags #{"mail"}} ["/report" (post report/post)]] diff --git a/src/source/routes/user.clj b/src/source/routes/user.clj index d0b73fd..121e324 100644 --- a/src/source/routes/user.clj +++ b/src/source/routes/user.clj @@ -74,25 +74,15 @@ 403 {:body (api/response-schema)}}} [{:keys [ds path-params]}] (let [email-hash (:hash path-params) - _ (prn "email hash from path params" email-hash) user (hon/find-one ds {:tname :users - :where [:= :email-hash email-hash]}) - _ (prn "found user by email hash" user)] - (if (some? user) - (do - (prn "a user exists, updating user details") - (hon/update! ds {:tname :users - :where [:= :id (:id user)] - :data {:email-verified 1 - :email-hash ""}}) - (prn "user has been updated") - (prn "running redirect") - (-> (conf/read-value :cors-origin) - (str "/dashboard/onboarding") - (res/redirect))) - (-> (conf/read-value :cors-origin) - (str "/dashboard/onboarding") - (res/redirect))))) + :where [:= :email-hash email-hash]})] + (when (some? user) + (hon/update! ds {:tname :users + :where [:= :id (:id user)] + :data {:email-verified 1}})) + (-> (conf/read-value :cors-origin) + (str "/verification") + (res/redirect)))) (comment (require '[source.db.interface :as db]) diff --git a/src/source/routes/users.clj b/src/source/routes/users.clj index 841fed0..2a93ab4 100644 --- a/src/source/routes/users.clj +++ b/src/source/routes/users.clj @@ -33,8 +33,7 @@ [{:keys [ds path-params]}] (hon/update! ds {:tname :users :where [:= :id (:id path-params)] - :data {:email-verified 1 - :email-hash ""}}) + :data {:email-verified 1}}) (res/response {:message "successfully verified user"})) (comment diff --git a/src/source/workers/users.clj b/src/source/workers/users.clj index 5abeb63..49ee659 100644 --- a/src/source/workers/users.clj +++ b/src/source/workers/users.clj @@ -68,3 +68,7 @@ :where [:= :id user-id]}) (:removed))] (when (or (nil? removed?) (= removed? 0)) true))) + +(defn user-by-password-hash [ds password-hash] + (hon/find-one ds {:tname :users + :where [:= :password-hash password-hash]}))