0
Fork 0
mirror of https://github.com/penpot/penpot.git synced 2025-03-16 09:41:41 -05:00

🎉 Add authentication with google.

This commit is contained in:
Andrey Antukh 2020-05-26 12:28:35 +02:00
parent 5268a7663f
commit 19cd84597d
23 changed files with 589 additions and 276 deletions

View file

@ -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);

View file

@ -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

View file

@ -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))

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) 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]))

View file

@ -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]

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) 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]}

View file

@ -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 ""})

View file

@ -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 <niwi@niwi.nz>
(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 ""})))

View file

@ -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 <niwi@niwi.nz>
;; 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

View file

@ -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 <niwi@niwi.nz>
;; 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))

View file

@ -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 <niwi@niwi.nz>
;; Copyright (c) 2020 UXBOX Labs SL
(ns uxbox.main
(:require

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 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) 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

View file

@ -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"

View file

@ -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)]

View file

@ -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))

View file

@ -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))))))

View file

@ -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]

View file

@ -52,4 +52,9 @@
.form-container {
width: 368px;
}
.btn-google-auth {
margin-bottom: $medium;
text-decoration: none;
}
}

View file

@ -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 <niwi@niwi.nz>
;; 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

View file

@ -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.)]

View file

@ -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)

View file

@ -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))

View file

@ -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")]]]]))