🚧 Initial work on password recovery and register refactor.

This commit is contained in:
Andrey Antukh 2020-01-13 23:52:31 +01:00
parent bd5f25eabf
commit 9e68041326
28 changed files with 607 additions and 561 deletions

View file

@ -2,7 +2,10 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) 2019 Andrey Antukh <niwi@niwi.nz>
;; This Source Code Form is "Incompatible With Secondary Licenses", as
;; defined by the Mozilla Public License, v. 2.0.
;;
;; Copyright (c) 2019-2020 Andrey Antukh <niwi@niwi.nz>
(ns uxbox.core
(:require

View file

@ -23,16 +23,6 @@
{:static media/resolve-asset
:comment (constantly nil)})
;; --- Register Email
(s/def ::name ::us/string)
(s/def ::register
(s/keys :req-un [::name]))
(def register
"A new profile registration welcome email."
(emails/build ::register default-context))
;; --- Public API
(defn render
@ -56,3 +46,22 @@
values ($1, $2) returning *"]
(-> (db/query-one db/pool [sql data priority])
(p/then' (constantly nil)))))
;; --- Emails
(s/def ::name ::us/string)
(s/def ::register
(s/keys :req-un [::name]))
(def register
"A new profile registration welcome email."
(emails/build ::register default-context))
(s/def ::token ::us/string)
(s/def ::password-recovery
(s/keys :req-un [::name ::token]))
(def password-recovery
"A password recovery notification email."
(emails/build ::password-recovery default-context))

View file

@ -51,12 +51,6 @@
:timeout 200
:name "login-handler"})
echo-handler (rl/ratelimit handlers/echo-handler
{:limit 1
:period 5000
:timeout 10
:name "echo-handler"})
routes [["/sub/:file-id" {:interceptors [(vxi/cookies)
(vxi/cors cors-opts)
interceptors/format-response-body
@ -64,10 +58,9 @@
:get ws/handler}]
["/api" {:interceptors interceptors}
["/echo" {:all echo-handler}]
["/echo" {:all handlers/echo-handler}]
["/login" {:post login-handler}]
["/logout" {:post handlers/logout-handler}]
["/register" {:post handlers/register-handler}]
["/debug"
["/emails" {:get debug/emails-list}]
["/emails/:id" {:get debug/email}]]

View file

@ -16,28 +16,49 @@
[vertx.web :as vw]
[vertx.eventbus :as ve]))
(def mutation-types-hierarchy
(-> (make-hierarchy)
(derive :login ::unauthenticated)
(derive :logout ::unauthenticated)
(derive :register-profile ::unauthenticated)
(derive :request-profile-recovery ::unauthenticated)
(derive :recover-profile ::unauthenticated)))
(def query-types-hierarchy
(make-hierarchy))
(defn query-handler
[req]
(let [type (get-in req [:path-params :type])
(let [type (keyword (get-in req [:path-params :type]))
data (merge (:params req)
{::sq/type (keyword type)
{::sq/type type
:user (:user req)})]
(-> (sq/handle (with-meta data {:req req}))
(p/then' (fn [result]
{:status 200
:body result})))))
(if (or (:user req)
(isa? query-types-hierarchy type ::unauthenticated))
(-> (sq/handle (with-meta data {:req req}))
(p/then' (fn [result]
{:status 200
:body result})))
{:status 403
:body {:type :authentication
:code :unauthorized}})))
(defn mutation-handler
[req]
(let [type (get-in req [:path-params :type])
(let [type (keyword (get-in req [:path-params :type]))
data (merge (:params req)
(:body-params req)
(:uploads req)
{::sm/type (keyword type)
{::sm/type type
:user (:user req)})]
(-> (sm/handle (with-meta data {:req req}))
(p/then' (fn [result]
{:status 200 :body result})))))
(if (or (:user req)
(isa? mutation-types-hierarchy type ::unauthenticated))
(-> (sm/handle (with-meta data {:req req}))
(p/then' (fn [result]
{:status 200 :body result})))
{:status 403
:body {:type :authentication
:code :unauthorized}})))
(defn login-handler
[req]
@ -60,23 +81,20 @@
:cookies {"auth-token" nil}
:body ""})))))
(defn register-handler
[req]
(let [data (merge (:body-params req)
{::sm/type :register-profile})
user-agent (get-in req [:headers "user-agent"])]
(-> (sm/handle (with-meta data {:req req}))
(p/then (fn [{:keys [id] :as user}]
(session/create id user-agent)))
(p/then' (fn [token]
{:status 204
:cookies {"auth-token" {:value token}}
:body ""})))))
;; (defn register-handler
;; [req]
;; (let [data (merge (:body-params req)
;; {::sm/type :register-profile})
;; user-agent (get-in req [:headers "user-agent"])]
;; (-> (sm/handle (with-meta data {:req req}))
;; (p/then (fn [{:keys [id] :as user}]
;; (session/create id user-agent)))
;; (p/then' (fn [token]
;; {:status 204
;; :body ""})))))
(defn echo-handler
[req]
;; (locking echo-handler
;; (prn "echo-handler" (Thread/currentThread)))
{:status 200
:body {:params (:params req)
:cookies (:cookies req)

View file

@ -17,9 +17,10 @@
(defn retrieve
"Retrieves a user id associated with the provided auth token."
[token]
(let [sql "select user_id from sessions where id = $1"]
(-> (db/query-one db/pool [sql token])
(p/then' (fn [row] (when row (:user-id row)))))))
(when token
(let [sql "select user_id from sessions where id = $1"]
(-> (db/query-one db/pool [sql token])
(p/then' (fn [row] (when row (:user-id row))))))))
(defn create
[user-id user-agent]
@ -52,11 +53,5 @@
(p/then' (fn [user-id]
(if user-id
(update data :request assoc :user user-id)
(spx/terminate (assoc data ::unauthorized true)))))
(vc/handle-on-context))))
:leave (fn [data]
(if (::unauthorized data)
(update data :response
assoc :status 403 :body {:type :authentication
:code :unauthorized})
data))})
data)))
(vc/handle-on-context))))})

View file

@ -75,7 +75,7 @@
:pass (:smtp-password config)
:ssl (:smtp-ssl config)
:tls (:smtp-tls config)
:noop (not (:smtp-enabled config))})
:enabled (:smtp-enabled config)})
(defn- send-email-to-console
[email]
@ -98,9 +98,9 @@
[email]
(p/future
(let [config (get-smtp-config cfg/config)
result (if (:noop config)
(send-email-to-console email)
(postal/send-message config email))]
result (if (:enabled config)
(postal/send-message config email)
(send-email-to-console email))]
(when (not= (:error result) :SUCCESS)
(ex/raise :type :sendmail-error
:code :email-not-sent

View file

@ -2,7 +2,10 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) 2016-2019 Andrey Antukh <niwi@niwi.nz>
;; This Source Code Form is "Incompatible With Secondary Licenses", as
;; defined by the Mozilla Public License, v. 2.0.
;;
;; Copyright (c) 2016-2020 Andrey Antukh <niwi@niwi.nz>
(ns uxbox.main
(:require [mount.core :as mount]

View file

@ -2,7 +2,10 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) 2019 Andrey Antukh <niwi@niwi.nz>
;; This Source Code Form is "Incompatible With Secondary Licenses", as
;; defined by the Mozilla Public License, v. 2.0.
;;
;; Copyright (c) 2019-2020 Andrey Antukh <niwi@niwi.nz>
(ns uxbox.services.init
"A initialization of services."
@ -26,8 +29,7 @@
(require 'uxbox.services.mutations.projects)
(require 'uxbox.services.mutations.project-files)
(require 'uxbox.services.mutations.project-pages)
(require 'uxbox.services.mutations.auth)
(require 'uxbox.services.mutations.users)
(require 'uxbox.services.mutations.profile)
(require 'uxbox.services.mutations.user-attrs))
(defstate query-services

View file

@ -2,7 +2,10 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) 2019 Andrey Antukh <niwi@niwi.nz>
;; This Source Code Form is "Incompatible With Secondary Licenses", as
;; defined by the Mozilla Public License, v. 2.0.
;;
;; Copyright (c) 2019-2020 Andrey Antukh <niwi@niwi.nz>
(ns uxbox.services.mutations
(:require

View file

@ -1,48 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) 2019 Andrey Antukh <niwi@niwi.nz>
(ns uxbox.services.mutations.auth
(:require
[clojure.spec.alpha :as s]
[sodi.pwhash :as pwhash]
[promesa.core :as p]
[uxbox.config :as cfg]
[uxbox.common.exceptions :as ex]
[uxbox.common.spec :as us]
[uxbox.db :as db]
[uxbox.services.mutations :as sm]))
(def ^:private user-by-username-sql
"select id, password
from users
where username=$1 or email=$1
and deleted_at is null")
(s/def ::username ::us/string)
(s/def ::password ::us/string)
(s/def ::scope ::us/string)
(s/def ::login
(s/keys :req-un [::username ::password]
:opt-un [::scope]))
(sm/defmutation ::login
[{:keys [username password scope] :as params}]
(letfn [(check-password [user password]
(let [result (pwhash/verify password (:password user))]
(:valid result)))
(check-user [user]
(when-not user
(ex/raise :type :validation
:code ::wrong-credentials))
(when-not (check-password user password)
(ex/raise :type :validation
:code ::wrong-credentials))
{:id (:id user)})]
(-> (db/query-one db/pool [user-by-username-sql username])
(p/then' check-user))))

View file

@ -2,19 +2,24 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) 2016 Andrey Antukh <niwi@niwi.nz>
;; This Source Code Form is "Incompatible With Secondary Licenses", as
;; defined by the Mozilla Public License, v. 2.0.
;;
;; Copyright (c) 2016-2020 Andrey Antukh <niwi@niwi.nz>
(ns uxbox.services.mutations.users
(ns uxbox.services.mutations.profile
(:require
[sodi.pwhash :as pwhash]
[clojure.spec.alpha :as s]
[datoteka.core :as fs]
[datoteka.storages :as ds]
[promesa.core :as p]
[promesa.exec :as px]
[uxbox.config :as cfg]
[sodi.prng]
[sodi.pwhash]
[sodi.util]
[uxbox.common.exceptions :as ex]
[uxbox.common.spec :as us]
[uxbox.config :as cfg]
[uxbox.db :as db]
[uxbox.emails :as emails]
[uxbox.images :as images]
@ -40,6 +45,46 @@
(s/def ::user ::us/uuid)
(s/def ::username ::us/string)
;; --- Utilities
(su/defstr sql:user-by-username-or-email
"select u.*
from users as u
where u.username=$1 or u.email=$1
and u.deleted_at is null")
(defn- retrieve-user
[conn username]
(db/query-one conn [sql:user-by-username-or-email username]))
;; --- Mutation: Login
(s/def ::username ::us/string)
(s/def ::password ::us/string)
(s/def ::scope ::us/string)
(s/def ::login
(s/keys :req-un [::username ::password]
:opt-un [::scope]))
(sm/defmutation ::login
[{:keys [username password scope] :as params}]
(letfn [(check-password [user password]
(let [result (sodi.pwhash/verify password (:password user))]
(:valid result)))
(check-user [user]
(when-not user
(ex/raise :type :validation
:code ::wrong-credentials))
(when-not (check-password user password)
(ex/raise :type :validation
:code ::wrong-credentials))
{:id (:id user)})]
(-> (retrieve-user db/pool username)
(p/then' check-user))))
;; --- Mutation: Update Profile (own)
(defn- check-username-and-email!
@ -55,7 +100,7 @@
and id != $1
) as val"]
(p/let [res1 (db/query-one conn [sql1 id username])
res2 (db/query-one conn [sql2 id email])]
res2 (db/query-one conn [sql2 id email])]
(when (:val res1)
(ex/raise :type :validation
:code ::username-already-exists))
@ -64,17 +109,21 @@
:code ::email-already-exists))
params)))
(su/defstr sql:update-profile
"update users
set username = $2,
email = $3,
fullname = $4,
metadata = $5
where id = $1
and deleted_at is null
returning *")
(defn- update-profile
[conn {:keys [id username email fullname metadata] :as params}]
(let [sql "update users
set username = $2,
email = $3,
fullname = $4,
metadata = $5
where id = $1
and deleted_at is null
returning *"]
(-> (db/query-one conn [sql id username email fullname (blob/encode metadata)])
(let [sqlv [sql:update-profile id username
email fullname (blob/encode metadata)]]
(-> (db/query-one conn sqlv)
(p/then' su/raise-not-found-if-nil)
(p/then' decode-profile-row)
(p/then' strip-private-attrs))))
@ -94,7 +143,7 @@
(defn- validate-password
[conn {:keys [user old-password] :as params}]
(p/let [profile (get-profile conn user)
result (pwhash/verify old-password (:password profile))]
result (sodi.pwhash/verify old-password (:password profile))]
(when-not (:valid result)
(ex/raise :type :validation
:code ::old-password-not-match))
@ -125,7 +174,6 @@
;; --- Mutation: Update Photo
(s/def :uxbox$upload/name ::us/string)
(s/def :uxbox$upload/size ::us/integer)
(s/def :uxbox$upload/mtype ::us/string)
@ -194,7 +242,7 @@
[conn {:keys [id username fullname email password metadata] :as params}]
(let [id (or id (uuid/next))
metadata (blob/encode metadata)
password (pwhash/derive password)
password (sodi.pwhash/derive password)
sqlv [create-user-sql
id
fullname
@ -209,19 +257,15 @@
[conn params]
(-> (create-profile conn params)
(p/then' strip-private-attrs)
#_(p/then (fn [profile]
(-> (emails/send! {::emails/id :users/register
::emails/to (:email params)
::emails/priority :high
:name (:fullname params)})
(p/then' (constantly profile)))))))
(p/then (fn [profile]
(-> (emails/send! emails/register {:to (:email params)
:name (:fullname params)})
(p/then' (constantly profile)))))))
(s/def ::register-profile
(s/keys :req-un [::username ::email ::password ::fullname]))
(sm/defmutation :register-profile
{:doc "Register new user."
:spec ::register-profile}
(sm/defmutation ::register-profile
[params]
(when-not (:registration-enabled cfg/config)
(ex/raise :type :restriction
@ -231,115 +275,56 @@
(p/then (partial check-profile-existence! conn))
(p/then (partial register-profile conn)))))
;; --- Password Recover
;; --- Mutation: Request Profile Recovery
;; (defn- recovery-token-exists?
;; "Checks if the token exists in the system. Just
;; return `true` or `false`."
;; [conn token]
;; (let [sqlv (sql/recovery-token-exists? {:token token})
;; result (db/fetch-one conn sqlv)]
;; (:token_exists result)))
(s/def ::request-profile-recovery
(s/keys :req-un [::username]))
;; (defn- retrieve-user-for-recovery-token
;; "Retrieve a user id (uuid) for the given token. If
;; no user is found, an exception is raised."
;; [conn token]
;; (let [sqlv (sql/get-recovery-token {:token token})
;; data (db/fetch-one conn sqlv)]
;; (or (:user data)
;; (ex/raise :type :validation
;; :code ::invalid-token))))
(su/defstr sql:insert-recovery-token
"insert into tokens (user_id, token) values ($1, $2)")
;; (defn- mark-token-as-used
;; [conn token]
;; (let [sqlv (sql/mark-recovery-token-used {:token token})]
;; (pos? (db/execute conn sqlv))))
(sm/defmutation ::request-profile-recovery
[{:keys [username] :as params}]
(letfn [(create-recovery-token [conn {:keys [id] :as user}]
(let [token (-> (sodi.prng/random-bytes 32)
(sodi.util/bytes->b64s))
sql sql:insert-recovery-token]
(-> (db/query-one conn [sql id token])
(p/then (constantly (assoc user :token token))))))
(send-email-notification [conn user]
(emails/send! emails/password-recovery
{:to (:email user)
:token (:token user)
:name (:fullname user)}))]
(db/with-atomic [conn db/pool]
(-> (retrieve-user conn username)
(p/then' su/raise-not-found-if-nil)
(p/then #(create-recovery-token conn %))
(p/then #(send-email-notification conn %))
(p/then (constantly nil))))))
;; (defn- recover-password
;; "Given a token and password, resets the password
;; to corresponding user or raise an exception."
;; [conn {:keys [token password]}]
;; (let [user (retrieve-user-for-recovery-token conn token)]
;; (update-password conn {:user user :password password})
;; (mark-token-as-used conn token)
;; nil))
;; --- Mutation: Recover Profile
;; (defn- create-recovery-token
;; "Creates a new recovery token for specified user and return it."
;; [conn userid]
;; (let [token (token/random)
;; sqlv (sql/create-recovery-token {:user userid
;; :token token})]
;; (db/execute conn sqlv)
;; token))
(s/def ::token ::us/not-empty-string)
(s/def ::recover-profile
(s/keys :req-un [::token ::password]))
;; (defn- retrieve-user-for-password-recovery
;; [conn username]
;; (let [user (find-user-by-username-or-email conn username)]
;; (when-not user
;; (ex/raise :type :validation :code ::user-does-not-exists))
;; user))
;; (defn- request-password-recovery
;; "Creates a new recovery password token and sends it via email
;; to the correspondig to the given username or email address."
;; [conn username]
;; (let [user (retrieve-user-for-password-recovery conn username)
;; token (create-recovery-token conn (:id user))]
;; (emails/send! {:email/name :users/password-recovery
;; :email/to (:email user)
;; :name (:fullname user)
;; :token token})
;; token))
;; (defmethod core/query :validate-profile-password-recovery-token
;; [{:keys [token]}]
;; (us/assert ::us/token token)
;; (with-open [conn (db/connection)]
;; (recovery-token-exists? conn token)))
;; (defmethod core/novelty :request-profile-password-recovery
;; [{:keys [username]}]
;; (us/assert ::us/username username)
;; (with-open [conn (db/connection)]
;; (db/atomic conn
;; (request-password-recovery conn username))))
;; (s/def ::recover-password
;; (s/keys :req-un [::us/token ::us/password]))
;; (defmethod core/novelty :recover-profile-password
;; [params]
;; (us/assert ::recover-password params)
;; (with-open [conn (db/connection)]
;; (db/apply-atomic conn recover-password params)))
;; --- Query Helpers
;; (defn find-full-user-by-id
;; "Find user by its id. This function is for internal
;; use only because it returns a lot of sensitive information.
;; If no user is found, `nil` is returned."
;; [conn id]
;; (let [sqlv (sql/get-profile {:id id})]
;; (some-> (db/fetch-one conn sqlv)
;; (data/normalize-attrs))))
;; (defn find-user-by-id
;; "Find user by its id. If no user is found, `nil` is returned."
;; [conn id]
;; (let [sqlv (sql/get-profile {:id id})]
;; (some-> (db/fetch-one conn sqlv)
;; (data/normalize-attrs)
;; (trim-user-attrs)
;; (dissoc :password))))
;; (defn find-user-by-username-or-email
;; "Finds a user in the database by username and email. If no
;; user is found, `nil` is returned."
;; [conn username]
;; (let [sqlv (sql/get-profile-by-username {:username username})]
;; (some-> (db/fetch-one conn sqlv)
;; (trim-user-attrs))))
(su/defstr sql:remove-recovery-token
"delete from tokenes where user_id=$1 and token=$2")
(sm/defmutation ::recover-profile
[{:keys [token password]}]
(letfn [(validate-token [conn token]
(let [sql "delete from tokens where token=$1 returning *"
sql "select * from tokens where token=$1"]
(-> (db/query-one conn [sql token])
(p/then' :user-id)
(p/then' su/raise-not-found-if-nil))))
(update-password [conn user-id]
(let [sql "update users set password=$2 where id=$1"
pwd (sodi.pwhash/derive password)]
(-> (db/query-one conn [sql user-id pwd])
(p/then' (constantly nil)))))]
(db/with-atomic [conn db/pool]
(-> (validate-token conn token)
(p/then (fn [user-id] (update-password conn user-id)))))))

View file

@ -2,7 +2,10 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) 2019 Andrey Antukh <niwi@niwi.nz>
;; This Source Code Form is "Incompatible With Secondary Licenses", as
;; defined by the Mozilla Public License, v. 2.0.
;;
;; Copyright (c) 2019-2020 Andrey Antukh <niwi@niwi.nz>
(ns uxbox.services.mutations.project-files
(:require