diff --git a/backend/resources/migrations/0010-add-http-session-table.sql b/backend/resources/migrations/0010-add-http-session-table.sql new file mode 100644 index 0000000000..d23ab67aea --- /dev/null +++ b/backend/resources/migrations/0010-add-http-session-table.sql @@ -0,0 +1,14 @@ +DROP TABLE session; + +CREATE TABLE http_session ( + id text PRIMARY KEY, + + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + modified_at timestamptz NOT NULL DEFAULT clock_timestamp(), + + profile_id uuid REFERENCES profile(id) ON DELETE CASCADE, + user_agent text NULL +); + +CREATE INDEX http_session__profile_id__idx + ON http_session(profile_id); diff --git a/backend/src/uxbox/config.clj b/backend/src/uxbox/config.clj index 409108cb2f..a500b1f87d 100644 --- a/backend/src/uxbox/config.clj +++ b/backend/src/uxbox/config.clj @@ -26,7 +26,8 @@ :database-username "uxbox" :database-password "uxbox" - :public-url "http://localhost:3449" + :public-uri "http://localhost:3449" + :backend-uri "http://localhost:6060" :redis-uri "redis://redis/0" :media-directory "resources/public/media" @@ -69,13 +70,19 @@ (s/def ::registration-enabled ::us/boolean) (s/def ::registration-domain-whitelist ::us/string) (s/def ::debug-humanize-transit ::us/boolean) -(s/def ::public-url ::us/string) +(s/def ::public-uri ::us/string) +(s/def ::backend-uri ::us/string) + +(s/def ::google-client-id ::us/string) +(s/def ::google-client-secret ::us/string) (s/def ::config (s/keys :opt-un [::http-server-cors ::http-server-debug ::http-server-port - ::public-url + ::google-client-id + ::google-client-secret + ::public-uri ::database-username ::database-password ::database-uri diff --git a/backend/src/uxbox/db.clj b/backend/src/uxbox/db.clj index af5940fe62..e77e88cba4 100644 --- a/backend/src/uxbox/db.clj +++ b/backend/src/uxbox/db.clj @@ -75,8 +75,10 @@ (jdbc/get-connection pool)) (defn exec! - [ds sv] - (jdbc/execute! ds sv {:builder-fn as-kebab-maps})) + ([ds sv] + (exec! ds sv {})) + ([ds sv opts] + (jdbc/execute! ds sv (assoc opts :builder-fn as-kebab-maps)))) (defn exec-one! ([ds sv] (exec-one! ds sv {})) @@ -120,6 +122,15 @@ ([ds table id opts] (get-by-params ds table {:id id} opts))) +(defn query + ([ds table params] + (query ds table params nil)) + ([ds table params opts] + (let [opts (cond-> (merge default-options opts) + (:for-update opts) + (assoc :suffix "for update"))] + (exec! ds (jdbc-bld/for-query table params opts) opts)))) + (defn pgobject? [v] (instance? PGobject v)) diff --git a/backend/src/uxbox/emails.clj b/backend/src/uxbox/emails.clj index 883ac4327c..901e1fb9e9 100644 --- a/backend/src/uxbox/emails.clj +++ b/backend/src/uxbox/emails.clj @@ -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 +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL (ns uxbox.emails "Main api for send emails." @@ -63,7 +66,7 @@ "A password recovery notification email." (emails/build ::password-recovery default-context)) -(s/def ::pending-email ::us/string) +(s/def ::pending-email ::us/email) (s/def ::change-email (s/keys :req-un [::name ::pending-email ::token])) diff --git a/backend/src/uxbox/fixtures.clj b/backend/src/uxbox/fixtures.clj index a8a0e070ca..24dbd58aae 100644 --- a/backend/src/uxbox/fixtures.clj +++ b/backend/src/uxbox/fixtures.clj @@ -17,7 +17,7 @@ [uxbox.db :as db] [uxbox.media :as media] [uxbox.migrations] - [uxbox.services.mutations.profile :as mt.profile] + [uxbox.services.mutations.profile :as profile] [uxbox.util.blob :as blob])) (defn- mk-uuid @@ -66,6 +66,11 @@ [f items] (reduce #(conj %1 (f %2)) [] items)) +(defn- register-profile + [conn params] + (->> (#'profile/create-profile conn params) + (#'profile/create-profile-relations conn))) + (defn impl-run [opts] (let [rng (java.util.Random. 1) @@ -74,12 +79,12 @@ (fn [conn index] (let [id (mk-uuid "profile" index)] (log/info "create profile" id) - (mt.profile/register-profile conn - {:id id - :fullname (str "Profile " index) - :password "123123" - :demo? true - :email (str "profile" index ".test@uxbox.io")}))) + (register-profile conn + {:id id + :fullname (str "Profile " index) + :password "123123" + :demo? true + :email (str "profile" index ".test@uxbox.io")}))) create-profiles (fn [conn] diff --git a/backend/src/uxbox/http.clj b/backend/src/uxbox/http.clj index 4ee8a44c06..5d9838c361 100644 --- a/backend/src/uxbox/http.clj +++ b/backend/src/uxbox/http.clj @@ -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 +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL (ns uxbox.http (:require @@ -14,6 +17,8 @@ [uxbox.http.debug :as debug] [uxbox.http.errors :as errors] [uxbox.http.handlers :as handlers] + [uxbox.http.auth :as auth] + [uxbox.http.auth.google :as google] [uxbox.http.middleware :as middleware] [uxbox.http.session :as session] [uxbox.http.ws :as ws] @@ -31,12 +36,17 @@ [middleware/multipart-params] [middleware/keyword-params] [middleware/cookies]]} + + ["/oauth" + ["/google" {:post google/auth}] + ["/google/callback" {:get google/callback}]] + ["/echo" {:get handlers/echo-handler :post handlers/echo-handler}] - ["/login" {:handler handlers/login-handler + ["/login" {:handler auth/login-handler :method :post}] - ["/logout" {:handler handlers/logout-handler + ["/logout" {:handler auth/logout-handler :method :post}] ["/w" {:middleware [session/auth]} diff --git a/backend/src/uxbox/http/auth.clj b/backend/src/uxbox/http/auth.clj new file mode 100644 index 0000000000..baf1b5d372 --- /dev/null +++ b/backend/src/uxbox/http/auth.clj @@ -0,0 +1,33 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns uxbox.http.auth + (:require + [uxbox.common.exceptions :as ex] + [uxbox.common.uuid :as uuid] + [uxbox.http.session :as session] + [uxbox.services.mutations :as sm])) + +(defn login-handler + [req] + (let [data (:body-params req) + uagent (get-in req [:headers "user-agent"])] + (let [profile (sm/handle (assoc data ::sm/type :login)) + id (session/create (:id profile) uagent)] + {:status 200 + :cookies (session/cookies id) + :body profile}))) + +(defn logout-handler + [req] + (some-> (session/extract-auth-token req) + (session/delete)) + {:status 200 + :cookies (session/cookies "" {:max-age -1}) + :body ""}) diff --git a/backend/src/uxbox/http/auth/google.clj b/backend/src/uxbox/http/auth/google.clj new file mode 100644 index 0000000000..7ca6891f55 --- /dev/null +++ b/backend/src/uxbox/http/auth/google.clj @@ -0,0 +1,133 @@ +;; 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 + +(ns uxbox.http.auth.google + (:require + [clojure.data.json :as json] + [clojure.tools.logging :as log] + [lambdaisland.uri :as uri] + [uxbox.common.exceptions :as ex] + [uxbox.config :as cfg] + [uxbox.db :as db] + [uxbox.services.tokens :as tokens] + [uxbox.services.mutations :as sm] + [uxbox.http.session :as session] + [uxbox.util.http :as http])) + +(def base-goauth-uri "https://accounts.google.com/o/oauth2/v2/auth") + +(def scope + (str "email profile " + "https://www.googleapis.com/auth/userinfo.email " + "https://www.googleapis.com/auth/userinfo.profile " + "openid")) + +(defn- build-redirect-url + [] + (let [public (uri/uri (:backend-uri cfg/config))] + (str (assoc public :path "/api/oauth/google/callback")))) + +(defn- get-access-token + [code] + (let [params {:code code + :client_id (:google-client-id cfg/config) + :client_secret (:google-client-secret cfg/config) + :redirect_uri (build-redirect-url) + :grant_type "authorization_code"} + req {:method :post + :headers {"content-type" "application/x-www-form-urlencoded"} + :uri "https://oauth2.googleapis.com/token" + :body (uri/map->query-string params)} + res (http/send! req)] + + (when (not= 200 (:status res)) + (ex/raise :type :internal + :code :invalid-response-from-google + :context {:status (:status res) + :body (:body res)})) + + (try + (let [data (json/read-str (:body res))] + (get data "access_token")) + (catch Throwable e + (log/error "unexpected error on parsing response body from google access tooken request" e) + nil)))) + + +(defn- get-user-info + [token] + (let [req {:uri "https://openidconnect.googleapis.com/v1/userinfo" + :headers {"Authorization" (str "Bearer " token)} + :method :get} + res (http/send! req)] + + (when (not= 200 (:status res)) + (ex/raise :type :internal + :code :invalid-response-from-google + :context {:status (:status res) + :body (:body res)})) + + (try + (let [data (json/read-str (:body res))] + ;; (clojure.pprint/pprint data) + {:email (get data "email") + :fullname (get data "name")}) + (catch Throwable e + (log/error "unexpected error on parsing response body from google access tooken request" e) + nil)))) + +(defn auth + [req] + (let [token (tokens/create! db/pool {:type :google-oauth}) + params {:scope scope + :access_type "offline" + :include_granted_scopes true + :state token + :response_type "code" + :redirect_uri (build-redirect-url) + :client_id (:google-client-id cfg/config)} + query (uri/map->query-string params) + uri (-> (uri/uri base-goauth-uri) + (assoc :query query))] + {:status 200 + :body {:redirect-uri (str uri)}})) + + +(defn callback + [req] + (let [token (get-in req [:params :state]) + tdata (tokens/retrieve db/pool token) + info (some-> (get-in req [:params :code]) + (get-access-token) + (get-user-info))] + + (when (not= :google-oauth (:type tdata)) + (ex/raise :type :validation + :code ::tokens/invalid-token)) + + (when-not info + (ex/raise :type :authentication + :code ::unable-to-authenticate-with-google)) + + (let [profile (sm/handle {::sm/type :login-or-register + :email (:email info) + :fullname (:fullname info)}) + uagent (get-in req [:headers "user-agent"]) + + tdata {:type :authentication + :profile profile} + token (tokens/create! db/pool tdata {:valid {:minutes 10}}) + + uri (-> (uri/uri (:public-uri cfg/config)) + (assoc :path "/#/auth/verify-token") + (assoc :query (uri/map->query-string {:token token}))) + sid (session/create (:id profile) uagent)] + + {:status 302 + :headers {"location" (str uri)} + :cookies (session/cookies sid) + :body ""}))) + diff --git a/backend/src/uxbox/http/handlers.clj b/backend/src/uxbox/http/handlers.clj index 6a3170db68..1eb16f2721 100644 --- a/backend/src/uxbox/http/handlers.clj +++ b/backend/src/uxbox/http/handlers.clj @@ -2,12 +2,14 @@ ;; 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 +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL (ns uxbox.http.handlers (:require [uxbox.common.exceptions :as ex] - [uxbox.common.uuid :as uuid] [uxbox.emails :as emails] [uxbox.http.session :as session] [uxbox.services.init] @@ -54,11 +56,10 @@ (let [body (sm/handle (with-meta data {:req req}))] (if (= type :delete-profile) (do - (some-> (get-in req [:cookies "auth-token" :value]) - (uuid/uuid) + (some-> (session/extract-auth-token req) (session/delete)) {:status 204 - :cookies {"auth-token" {:value "" :max-age -1}} + :cookies (session/cookies "" {:max-age -1}) :body ""}) {:status 200 :body body})) @@ -66,25 +67,6 @@ :body {:type :authentication :code :unauthorized}}))) -(defn login-handler - [req] - (let [data (:body-params req) - user-agent (get-in req [:headers "user-agent"])] - (let [profile (sm/handle (assoc data ::sm/type :login)) - token (session/create (:id profile) user-agent)] - {:status 200 - :cookies {"auth-token" {:value token :path "/"}} - :body profile}))) - -(defn logout-handler - [req] - (some-> (get-in req [:cookies "auth-token" :value]) - (uuid/uuid) - (session/delete)) - {:status 200 - :cookies {"auth-token" {:value "" :max-age -1}} - :body ""}) - (defn echo-handler [req] {:status 200 diff --git a/backend/src/uxbox/http/session.clj b/backend/src/uxbox/http/session.clj index 35235b982b..4765900ca5 100644 --- a/backend/src/uxbox/http/session.clj +++ b/backend/src/uxbox/http/session.clj @@ -2,49 +2,51 @@ ;; 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 +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL (ns uxbox.http.session (:require [uxbox.db :as db] + [uxbox.services.tokens :as tokens] [uxbox.common.uuid :as uuid])) -;; --- Main API - (defn retrieve "Retrieves a user id associated with the provided auth token." [token] (when token - (let [row (db/get-by-params db/pool :session {:id token})] - (:profile-id row)))) + (-> (db/query db/pool :http-session {:id token}) + (first) + (:profile-id)))) (defn create - [user-id user-agent] - (let [id (uuid/random)] - (db/insert! db/pool :session {:id id - :profile-id user-id - :user-agent user-agent}) - (str id))) + [profile-id user-agent] + (let [id (tokens/next)] + (db/insert! db/pool :http-session {:id id + :profile-id profile-id + :user-agent user-agent}) + id)) (defn delete [token] - (db/delete! db/pool :session {:id token}) + (db/delete! db/pool :http-session {:id token}) nil) -;; --- Interceptor +(defn cookies + ([id] (cookies id {})) + ([id opts] + {"auth-token" (merge opts {:value id :path "/" :http-only true})})) -(defn- parse-token - [request] - (try - (when-let [token (get-in request [:cookies "auth-token"])] - (uuid/uuid (:value token))) - (catch java.lang.IllegalArgumentException e - nil))) +(defn extract-auth-token + [req] + (get-in req [:cookies "auth-token" :value])) (defn wrap-auth [handler] (fn [request] - (let [token (parse-token request) + (let [token (get-in request [:cookies "auth-token" :value]) profile-id (retrieve token)] (if profile-id (handler (assoc request :profile-id profile-id)) diff --git a/backend/src/uxbox/main.clj b/backend/src/uxbox/main.clj index 5e8a9e806c..092c44bbdd 100644 --- a/backend/src/uxbox/main.clj +++ b/backend/src/uxbox/main.clj @@ -5,7 +5,7 @@ ;; 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 +;; Copyright (c) 2020 UXBOX Labs SL (ns uxbox.main (:require diff --git a/backend/src/uxbox/migrations.clj b/backend/src/uxbox/migrations.clj index f356af1875..a8b5415a3a 100644 --- a/backend/src/uxbox/migrations.clj +++ b/backend/src/uxbox/migrations.clj @@ -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 Andrey Antukh +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL (ns uxbox.migrations (:require @@ -44,12 +47,16 @@ :fn (mg/resource "migrations/0007-drop-version-field-from-page-table.sql")} {:desc "Add generic token related tables." - :name "0008-add-generic-token-table.sql" + :name "0008-add-generic-token-table" :fn (mg/resource "migrations/0008-add-generic-token-table.sql")} {:desc "Drop the profile_email table" - :name "0009-drop-profile-email-table.sql" - :fn (mg/resource "migrations/0009-drop-profile-email-table.sql")}]}) + :name "0009-drop-profile-email-table" + :fn (mg/resource "migrations/0009-drop-profile-email-table.sql")} + + {:desc "Add new HTTP session table" + :name "0010-add-http-session-table" + :fn (mg/resource "migrations/0010-add-http-session-table.sql")}]}) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Entry point diff --git a/backend/src/uxbox/services/mutations/demo.clj b/backend/src/uxbox/services/mutations/demo.clj index aa319fa1f3..337b73cfc4 100644 --- a/backend/src/uxbox/services/mutations/demo.clj +++ b/backend/src/uxbox/services/mutations/demo.clj @@ -29,13 +29,15 @@ email (str "demo-" sem ".demo@nodomain.com") fullname (str "Demo User " sem) password (-> (sodi.prng/random-bytes 12) - (sodi.util/bytes->b64s))] + (sodi.util/bytes->b64s)) + params {:id id + :email email + :fullname fullname + :demo? true + :password password}] (db/with-atomic [conn db/pool] - (#'profile/register-profile conn {:id id - :email email - :fullname fullname - :demo? true - :password password}) + (->> (#'profile/create-profile conn params) + (#'profile/create-profile-relations conn)) ;; Schedule deletion of the demo profile (tasks/submit! conn {:name "delete-profile" diff --git a/backend/src/uxbox/services/mutations/profile.clj b/backend/src/uxbox/services/mutations/profile.clj index 6861be038b..42045ec8b0 100644 --- a/backend/src/uxbox/services/mutations/profile.clj +++ b/backend/src/uxbox/services/mutations/profile.clj @@ -25,10 +25,11 @@ [uxbox.emails :as emails] [uxbox.images :as images] [uxbox.media :as media] + [uxbox.services.tokens :as tokens] [uxbox.services.mutations :as sm] [uxbox.services.mutations.images :as imgs] - [uxbox.services.mutations.projects :as mt.projects] - [uxbox.services.mutations.teams :as mt.teams] + [uxbox.services.mutations.projects :as projects] + [uxbox.services.mutations.teams :as teams] [uxbox.services.queries.profile :as profile] [uxbox.tasks :as tasks] [uxbox.util.blob :as blob] @@ -46,12 +47,100 @@ (s/def ::old-password ::us/string) (s/def ::theme ::us/string) -(defn decode-token-row - [{:keys [content] :as row}] - (when row - (cond-> row - content (assoc :content (blob/decode content))))) +;; --- Mutation: Register Profile +(declare check-profile-existence!) +(declare create-profile) +(declare create-profile-relations) + +(s/def ::register-profile + (s/keys :req-un [::email ::password ::fullname])) + +(defn email-domain-in-whitelist? + "Returns true if email's domain is in the given whitelist or if given + whitelist is an empty string." + [whitelist email] + (if (str/blank? whitelist) + true + (let [domains (str/split whitelist #",\s*") + email-domain (second (str/split email #"@"))] + (contains? (set domains) email-domain)))) + +(sm/defmutation ::register-profile + [params] + (when-not (:registration-enabled cfg/config) + (ex/raise :type :restriction + :code ::registration-disabled)) + + (when-not (email-domain-in-whitelist? (:registration-domain-whitelist cfg/config) + (:email params)) + (ex/raise :type :validation + :code ::email-domain-is-not-allowed)) + + (db/with-atomic [conn db/pool] + (check-profile-existence! conn params) + (let [profile (->> (create-profile conn params) + (create-profile-relations conn)) + payload {:type :verify-email + :profile-id (:id profile) + :email (:email profile)} + + token (tokens/create! conn payload {:valid {:days 30}})] + + (emails/send! conn emails/register + {:to (:email profile) + :name (:fullname profile) + :public-url (:public-uri cfg/config) + :token token}) + profile))) + +(def ^:private sql:profile-existence + "select exists (select * from profile + where email = ? + and deleted_at is null) as val") + +(defn- check-profile-existence! + [conn {:keys [email] :as params}] + (let [email (str/lower email) + result (db/exec-one! conn [sql:profile-existence email])] + (when (:val result) + (ex/raise :type :validation + :code ::email-already-exists)) + params)) + +(defn- create-profile + "Create the profile entry on the database with limited input + filling all the other fields with defaults." + [conn {:keys [id fullname email password demo?] :as params}] + (let [id (or id (uuid/next)) + demo? (if (boolean? demo?) demo? false) + password (sodi.pwhash/derive password)] + (db/insert! conn :profile + {:id id + :fullname fullname + :email (str/lower email) + :pending-email (if demo? nil email) + :photo "" + :password password + :is-demo demo?}))) + +(defn- create-profile-relations + [conn profile] + (let [team (teams/create-team conn {:profile-id (:id profile) + :name "Default" + :default? true}) + proj (projects/create-project conn {:profile-id (:id profile) + :team-id (:id team) + :name "Drafts" + :default? true})] + (teams/create-team-profile conn {:team-id (:id team) + :profile-id (:id profile)}) + (projects/create-project-profile conn {:project-id (:id proj) + :profile-id (:id profile)}) + + (merge (profile/strip-private-attrs profile) + {:default-team-id (:id team) + :default-project-id (:id proj)}))) ;; --- Mutation: Login @@ -70,7 +159,7 @@ (let [result (sodi.pwhash/verify password (:password profile))] (:valid result))) - (check-profile [profile] + (validate-profile [profile] (when-not profile (ex/raise :type :validation :code ::wrong-credentials)) @@ -80,14 +169,51 @@ profile)] (db/with-atomic [conn db/pool] (let [prof (-> (retrieve-profile-by-email conn email) - (check-profile) + (validate-profile) (profile/strip-private-attrs)) addt (profile/retrieve-additional-data conn (:id prof))] (merge prof addt))))) +(def sql:profile-by-email + "select * from profile + where email=? and deleted_at is null + for update") + (defn- retrieve-profile-by-email [conn email] - (db/get-by-params conn :profile {:email email} {:for-update true})) + (let [email (str/lower email)] + (db/exec-one! conn [sql:profile-by-email email]))) + + + +;; --- Mutation: Register if not exists + +(sm/defmutation ::login-or-register + [{:keys [email fullname] :as params}] + (letfn [(populate-additional-data [conn profile] + (let [data (profile/retrieve-additional-data conn (:id profile))] + (merge profile data))) + + (create-profile [conn {:keys [fullname email]}] + (db/insert! conn :profile + {:id (uuid/next) + :fullname fullname + :email (str/lower email) + :pending-email nil + :photo "" + :password "!" + :is-demo false})) + + (register-profile [conn params] + (->> (create-profile conn params) + (create-profile-relations conn)))] + + (db/with-atomic [conn db/pool] + (let [profile (retrieve-profile-by-email conn email) + profile (if profile + (populate-additional-data conn profile) + (register-profile conn params))] + (profile/strip-private-attrs profile))))) ;; --- Mutation: Update Profile (own) @@ -182,108 +308,6 @@ nil) -;; --- Mutation: Register Profile - -(declare check-profile-existence!) -(declare register-profile) - -(s/def ::register-profile - (s/keys :req-un [::email ::password ::fullname])) - -(defn email-domain-in-whitelist? - "Returns true if email's domain is in the given whitelist or if given - whitelist is an empty string." - [whitelist email] - (if (str/blank? whitelist) - true - (let [domains (str/split whitelist #",\s*") - email-domain (second (str/split email #"@"))] - (contains? (set domains) email-domain)))) - -(sm/defmutation ::register-profile - [params] - (when-not (:registration-enabled cfg/config) - (ex/raise :type :restriction - :code ::registration-disabled)) - - (when-not (email-domain-in-whitelist? (:registration-domain-whitelist cfg/config) - (:email params)) - (ex/raise :type :validation - :code ::email-domain-is-not-allowed)) - - (db/with-atomic [conn db/pool] - (check-profile-existence! conn params) - (let [profile (register-profile conn params) - token (-> (sodi.prng/random-bytes 32) - (sodi.util/bytes->b64s)) - payload {:type :verify-email - :profile-id (:id profile) - :email (:email profile)}] - - (db/insert! conn :generic-token - {:token token - :valid-until (dt/plus (dt/now) - (dt/duration {:days 30})) - :content (blob/encode payload)}) - - (emails/send! conn emails/register - {:to (:email profile) - :name (:fullname profile) - :public-url (:public-url cfg/config) - :token token}) - profile))) - -(def ^:private sql:profile-existence - "select exists (select * from profile - where email = ? - and deleted_at is null) as val") - -(defn- check-profile-existence! - [conn {:keys [email] :as params}] - (let [email (str/lower email) - result (db/exec-one! conn [sql:profile-existence email])] - (when (:val result) - (ex/raise :type :validation - :code ::email-already-exists)) - params)) - -(defn- create-profile - "Create the profile entry on the database with limited input - filling all the other fields with defaults." - [conn {:keys [id fullname email password demo?] :as params}] - (let [id (or id (uuid/next)) - demo? (if (boolean? demo?) demo? false) - password (sodi.pwhash/derive password)] - (db/insert! conn :profile - {:id id - :fullname fullname - :email (str/lower email) - :pending-email (if demo? nil email) - :photo "" - :password password - :is-demo demo?}))) - -(defn register-profile - [conn params] - (let [prof (create-profile conn params) - team (mt.teams/create-team conn {:profile-id (:id prof) - :name "Default" - :default? true}) - proj (mt.projects/create-project conn {:profile-id (:id prof) - :team-id (:id team) - :name "Drafts" - :default? true})] - (mt.teams/create-team-profile conn {:team-id (:id team) - :profile-id (:id prof)}) - (mt.projects/create-project-profile conn {:project-id (:id proj) - :profile-id (:id prof)}) - - ;; TODO: rename to -default-team-id... - (merge (profile/strip-private-attrs prof) - {:default-team (:id team) - :default-project (:id proj)}))) - - ;; --- Mutation: Request Email Change (declare select-profile-for-update) @@ -296,11 +320,11 @@ (db/with-atomic [conn db/pool] (let [email (str/lower email) profile (select-profile-for-update conn profile-id) - token (-> (sodi.prng/random-bytes 32) - (sodi.util/bytes->b64s)) payload {:type :change-email :profile-id profile-id - :email email}] + :email email} + + token (tokens/create! conn payload)] (when (not= email (:email profile)) (check-profile-existence! conn params)) @@ -309,21 +333,14 @@ {:pending-email email} {:id profile-id}) - (db/insert! conn :generic-token - {:token token - :valid-until (dt/plus (dt/now) - (dt/duration {:hours 48})) - :content (blob/encode payload)}) - (emails/send! conn emails/change-email {:to (:email profile) :name (:fullname profile) - :public-url (:public-url cfg/config) + :public-url (:public-uri cfg/config) :pending-email email :token token}) nil))) - (defn- select-profile-for-update [conn id] (db/get-by-id conn :profile id {:for-update true})) @@ -334,16 +351,14 @@ ;; Generic mutation for perform token based verification for auth ;; domain. -(declare retrieve-token) - (s/def ::verify-profile-token (s/keys :req-un [::token])) (sm/defmutation ::verify-profile-token [{:keys [token] :as params}] - (letfn [(handle-email-change [conn token] - (let [profile (select-profile-for-update conn (:profile-id token))] - (when (not= (:email token) + (letfn [(handle-email-change [conn tdata] + (let [profile (select-profile-for-update conn (:profile-id tdata))] + (when (not= (:email tdata) (:pending-email profile)) (ex/raise :type :validation :code ::email-does-not-match)) @@ -353,48 +368,31 @@ :email (:pending-email profile)} {:id (:id profile)}) - token)) + tdata)) - (handle-email-verify [conn token] - (let [profile (select-profile-for-update conn (:profile-id token))] + (handle-email-verify [conn tdata] + (let [profile (select-profile-for-update conn (:profile-id tdata))] (when (or (not= (:email profile) (:pending-email profile)) (not= (:email profile) - (:email token))) + (:email tdata))) (ex/raise :type :validation - :code ::invalid-token)) + :code ::tokens/invalid-token)) (db/update! conn :profile {:pending-email nil} {:id (:id profile)}) - token))] + tdata))] (db/with-atomic [conn db/pool] - (let [token (retrieve-token conn token)] - (db/delete! conn :generic-token {:token (:token params)}) - - ;; Validate the token expiration - (when (> (inst-ms (dt/now)) - (inst-ms (:valid-until token))) + (let [tdata (tokens/retrieve conn token {:delete true})] + (tokens/delete! conn token) + (case (:type tdata) + :change-email (handle-email-change conn tdata) + :verify-email (handle-email-verify conn tdata) + :authentication tdata (ex/raise :type :validation - :code ::invalid-token)) - - (case (:type token) - :change-email (handle-email-change conn token) - :verify-email (handle-email-verify conn token) - (ex/raise :type :validation - :code ::invalid-token)))))) - -(defn- retrieve-token - [conn token] - (let [row (-> (db/get-by-params conn :generic-token {:token token}) - (decode-token-row))] - (when-not row - (ex/raise :type :validation - :code ::invalid-token)) - (-> row - (dissoc :content) - (merge (:content row))))) + :code ::tokens/invalid-token)))))) ;; --- Mutation: Cancel Email Change @@ -421,20 +419,15 @@ (sm/defmutation ::request-profile-recovery [{:keys [email] :as params}] (letfn [(create-recovery-token [conn {:keys [id] :as profile}] - (let [token (-> (sodi.prng/random-bytes 32) - (sodi.util/bytes->b64s)) - payload {:type :password-recovery-token - :profile-id id}] - (db/insert! conn :generic-token - {:token token - :valid-until (dt/plus (dt/now) (dt/duration {:hours 24})) - :content (blob/encode payload)}) + (let [payload {:type :password-recovery-token + :profile-id id} + token (tokens/create! conn payload)] (assoc profile :token token))) (send-email-notification [conn profile] (emails/send! conn emails/password-recovery {:to (:email profile) - :public-url (:public-url cfg/config) + :public-url (:public-uri cfg/config) :token (:token profile) :name (:fullname profile)}))] @@ -454,13 +447,11 @@ (sm/defmutation ::recover-profile [{:keys [token password]}] (letfn [(validate-token [conn token] - (let [{:keys [token content]} - (-> (db/get-by-params conn :generic-token {:token token}) - (decode-token-row))] - (when (not= (:type content) :password-recovery-token) + (let [tpayload (tokens/retrieve conn token)] + (when (not= (:type tpayload) :password-recovery-token) (ex/raise :type :validation - :code :invalid-token)) - (:profile-id content))) + :code ::tokens/invalid-token)) + (:profile-id tpayload))) (update-password [conn profile-id] (let [pwd (sodi.pwhash/derive password)] diff --git a/backend/src/uxbox/services/queries/profile.clj b/backend/src/uxbox/services/queries/profile.clj index abfac1c2dd..19779d7d23 100644 --- a/backend/src/uxbox/services/queries/profile.clj +++ b/backend/src/uxbox/services/queries/profile.clj @@ -91,5 +91,5 @@ (defn strip-private-attrs "Only selects a publicy visible profile attrs." - [o] - (dissoc o :password :deleted-at)) + [row] + (dissoc row :password :deleted-at)) diff --git a/backend/src/uxbox/services/tokens.clj b/backend/src/uxbox/services/tokens.clj new file mode 100644 index 0000000000..1c48e01e81 --- /dev/null +++ b/backend/src/uxbox/services/tokens.clj @@ -0,0 +1,80 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns uxbox.services.tokens + (:refer-clojure :exclude [next]) + (:require + [clojure.spec.alpha :as s] + [cuerdas.core :as str] + [sodi.prng] + [sodi.util] + [uxbox.common.exceptions :as ex] + [uxbox.common.spec :as us] + [uxbox.common.uuid :as uuid] + [uxbox.config :as cfg] + [uxbox.util.time :as dt] + [uxbox.util.blob :as blob] + [uxbox.db :as db])) + +(defn next + ([] (next 64)) + ([n] + (-> (sodi.prng/random-bytes n) + (sodi.util/bytes->b64s)))) + +(def default-duration + (dt/duration {:hours 48})) + +(defn- decode-row + [{:keys [content] :as row}] + (when row + (cond-> row + content (assoc :content (blob/decode content))))) + +(defn create! + ([conn payload] (create! conn payload {})) + ([conn payload {:keys [valid] :or {valid default-duration}}] + (let [token (next) + until (dt/plus (dt/now) (dt/duration valid))] + (db/insert! conn :generic-token + {:content (blob/encode payload) + :token token + :valid-until until}) + token))) + +(defn delete! + [conn token] + (db/delete! conn :generic-token {:token token})) + +(defn retrieve + ([conn token] (retrieve conn token {})) + ([conn token {:keys [delete] :or {delete false}}] + (let [row (->> (db/query conn :generic-token {:token token}) + (map decode-row) + (first))] + + (when-not row + (ex/raise :type :validation + :code ::invalid-token)) + + ;; Validate the token expiration + (when (> (inst-ms (dt/now)) + (inst-ms (:valid-until row))) + (ex/raise :type :validation + :code ::invalid-token)) + + (when delete + (db/delete! conn :generic-token {:token token})) + + (-> row + (dissoc :content) + (merge (:content row)))))) + + + diff --git a/backend/tests/uxbox/tests/helpers.clj b/backend/tests/uxbox/tests/helpers.clj index ef617ac3e8..bd5f1eb3a5 100644 --- a/backend/tests/uxbox/tests/helpers.clj +++ b/backend/tests/uxbox/tests/helpers.clj @@ -82,10 +82,12 @@ (defn create-profile [conn i] - (#'profile/register-profile conn {:id (mk-uuid "profile" i) - :fullname (str "Profile " i) - :email (str "profile" i ".test@nodomain.com") - :password "123123"})) + (let [params {:id (mk-uuid "profile" i) + :fullname (str "Profile " i) + :email (str "profile" i ".test@nodomain.com") + :password "123123"}] + (->> (#'profile/create-profile conn params) + (#'profile/create-profile-relations conn)))) (defn create-team [conn profile-id i] diff --git a/frontend/resources/styles/main/layouts/login.scss b/frontend/resources/styles/main/layouts/login.scss index aaf9fa39d8..279a5e1285 100644 --- a/frontend/resources/styles/main/layouts/login.scss +++ b/frontend/resources/styles/main/layouts/login.scss @@ -52,4 +52,9 @@ .form-container { width: 368px; } + + .btn-google-auth { + margin-bottom: $medium; + text-decoration: none; + } } diff --git a/frontend/src/uxbox/main/data/auth.cljs b/frontend/src/uxbox/main/data/auth.cljs index f31af56c91..4f79190353 100644 --- a/frontend/src/uxbox/main/data/auth.cljs +++ b/frontend/src/uxbox/main/data/auth.cljs @@ -5,7 +5,7 @@ ;; This Source Code Form is "Incompatible With Secondary Licenses", as ;; defined by the Mozilla Public License, v. 2.0. ;; -;; Copyright (c) 2015-2019 Andrey Antukh +;; Copyright (c) 2020 UXBOX Labs SL (ns uxbox.main.data.auth (:require @@ -34,7 +34,7 @@ (watch [this state stream] (let [team-id (:default-team-id data)] (rx/of (du/profile-fetched data) - (rt/navigate :dashboard-team {:team-id team-id})))))) + (rt/nav :dashboard-team {:team-id team-id})))))) ;; --- Login @@ -63,6 +63,20 @@ (on-error err) (rx/empty))) (rx/map logged-in)))))) + +(defn login-from-token + [{:keys [profile] :as tdata}] + (ptk/reify ::login-from-token + ptk/UpdateEvent + (update [_ state] + (merge state (dissoc initial-state :route :router))) + + ptk/WatchEvent + (watch [this state s] + (let [team-id (:default-team-id profile)] + (rx/of (du/profile-fetched profile) + (rt/nav' :dashboard-team {:team-id team-id})))))) + ;; --- Logout (def clear-user-data diff --git a/frontend/src/uxbox/main/repo.cljs b/frontend/src/uxbox/main/repo.cljs index f7974b2493..24e0a1167f 100644 --- a/frontend/src/uxbox/main/repo.cljs +++ b/frontend/src/uxbox/main/repo.cljs @@ -62,6 +62,12 @@ ([id] (mutation id {})) ([id params] (mutation id params))) +(defmethod mutation :login-with-google + [id params] + (let [url (str url "/api/oauth/google")] + (->> (http/send! {:method :post :url url}) + (rx/mapcat handle-response)))) + (defmethod mutation :upload-image [id params] (let [form (js/FormData.)] diff --git a/frontend/src/uxbox/main/ui/auth.cljs b/frontend/src/uxbox/main/ui/auth.cljs index 0a57483492..0d709da638 100644 --- a/frontend/src/uxbox/main/ui/auth.cljs +++ b/frontend/src/uxbox/main/ui/auth.cljs @@ -13,6 +13,7 @@ [beicon.core :as rx] [rumext.alpha :as mf] [uxbox.main.ui.icons :as i] + [uxbox.main.data.auth :as da] [uxbox.main.data.users :as du] [uxbox.main.data.messages :as dm] [uxbox.main.store :as st] @@ -66,6 +67,10 @@ (st/emit! (rt/nav :settings-profile) du/fetch-profile))) +(defn- handle-authentication + [tdata] + (st/emit! (da/login-from-token tdata))) + (mf/defc verify-token [{:keys [route] :as props}] (let [token (get-in route [:query-params :token])] @@ -73,10 +78,11 @@ (fn [] (->> (rp/mutation :verify-profile-token {:token token}) (rx/subs - (fn [response] - (case (:type response) - :verify-email (handle-email-verified response) - :change-email (handle-email-changed response) + (fn [tdata] + (case (:type tdata) + :verify-email (handle-email-verified tdata) + :change-email (handle-email-changed tdata) + :authentication (handle-authentication tdata) nil)) (fn [error] (case (:code error) diff --git a/frontend/src/uxbox/main/ui/auth/login.cljs b/frontend/src/uxbox/main/ui/auth/login.cljs index 30001a13bb..d9ad2951ff 100644 --- a/frontend/src/uxbox/main/ui/auth/login.cljs +++ b/frontend/src/uxbox/main/ui/auth/login.cljs @@ -10,13 +10,16 @@ (ns uxbox.main.ui.auth.login (:require [cljs.spec.alpha :as s] + [beicon.core :as rx] [rumext.alpha :as mf] [uxbox.common.spec :as us] [uxbox.main.ui.icons :as i] [uxbox.main.data.auth :as da] + [uxbox.main.repo :as rp] [uxbox.main.store :as st] [uxbox.main.data.messages :as dm] [uxbox.main.ui.components.forms :refer [input submit-button form]] + [uxbox.util.object :as obj] [uxbox.util.dom :as dom] [uxbox.util.forms :as fm] [uxbox.util.i18n :refer [tr t]] @@ -38,6 +41,13 @@ {:on-error (partial on-error form)})] (st/emit! (da/login params)))) +(defn- login-with-google + [event] + (dom/prevent-default event) + (->> (rp/mutation! :login-with-google {}) + (rx/subs (fn [{:keys [redirect-uri] :as rsp}] + (.replace js/location redirect-uri))))) + (mf/defc login-form [{:keys [locale] :as props}] [:& form {:on-submit on-submit @@ -60,6 +70,7 @@ (mf/defc login-page [{:keys [locale] :as props}] + [:div.generic-form.login-form [:div.form-container [:h1 (t locale "auth.login-title")] @@ -67,6 +78,10 @@ [:& login-form {:locale locale}] + [:a.btn-secondary.btn-large.btn-google-auth + {:on-click login-with-google} + "Login with google"] + [:div.links [:div.link-entry [:a {:on-click #(st/emit! (rt/nav :auth-recovery-request)) diff --git a/frontend/src/uxbox/main/ui/settings/header.cljs b/frontend/src/uxbox/main/ui/settings/header.cljs index 6a65653498..dfaa50a1f4 100644 --- a/frontend/src/uxbox/main/ui/settings/header.cljs +++ b/frontend/src/uxbox/main/ui/settings/header.cljs @@ -2,6 +2,9 @@ ;; 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/. ;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; ;; Copyright (c) 2020 UXBOX Labs SL (ns uxbox.main.ui.settings.header @@ -55,21 +58,3 @@ {:class "foobar" :on-click #(st/emit! (rt/nav :settings-profile))} (t locale "settings.teams")]]])) - - - - -;; [:div.main-logo -;; {:on-click #(st/emit! (rt/nav :dashboard-team {:team-id team-id}))} -;; i/logo-icon] -;; [:section.main-bar -;; [:nav -;; [:a.nav-item -;; {:class (when profile? "current") -;; :on-click #(st/emit! (rt/nav :settings-profile))} -;; (t locale "settings.profile")] -;; [:a.nav-item -;; {:class (when password? "current") -;; :on-click #(st/emit! (rt/nav :settings-password))} -;; (t locale "settings.password")]]]])) -