🐛 Fix corner cases on invitation/signup flows.

This commit is contained in:
Andrey Antukh 2021-02-16 17:31:22 +01:00 committed by Andrés Moya
parent 784a4f8ecd
commit 4991cae5ad
11 changed files with 223 additions and 149 deletions

View file

@ -19,6 +19,7 @@
- Have language change notification written in the new language [Taiga #1205](https://tree.taiga.io/project/penpot/issue/1205) - Have language change notification written in the new language [Taiga #1205](https://tree.taiga.io/project/penpot/issue/1205)
- Properly handle errors on github, gitlab and ldap auth backends. - Properly handle errors on github, gitlab and ldap auth backends.
- Properly mark profile auth backend (on first register/ auth with 3rd party auth provider). - Properly mark profile auth backend (on first register/ auth with 3rd party auth provider).
- Fix corner cases on invitation/signup flows.
## 1.2.0-alpha ## 1.2.0-alpha

View file

@ -5,7 +5,7 @@
;; This Source Code Form is "Incompatible With Secondary Licenses", as ;; This Source Code Form is "Incompatible With Secondary Licenses", as
;; defined by the Mozilla Public License, v. 2.0. ;; defined by the Mozilla Public License, v. 2.0.
;; ;;
;; Copyright (c) 2020 UXBOX Labs SL ;; Copyright (c) 2020-2021 UXBOX Labs SL
(ns app.http.auth.google (ns app.http.auth.google
(:require (:require
@ -43,6 +43,7 @@
req {:method :post req {:method :post
:headers {"content-type" "application/x-www-form-urlencoded"} :headers {"content-type" "application/x-www-form-urlencoded"}
:uri "https://oauth2.googleapis.com/token" :uri "https://oauth2.googleapis.com/token"
:timeout 6000
:body (u/map->query-string params)} :body (u/map->query-string params)}
res (http/send! req)] res (http/send! req)]
@ -69,54 +70,85 @@
(log/error e "unexpected exception on get-user-info") (log/error e "unexpected exception on get-user-info")
nil))) nil)))
(defn- auth (defn- retrieve-info
[{:keys [tokens] :as cfg} _req] [{:keys [tokens] :as cfg} request]
(let [token (tokens :generate {:iss :google-oauth :exp (dt/in-future "15m")}) (let [token (get-in request [:params :state])
params {:scope scope state (tokens :verify {:token token :iss :google-oauth})
:access_type "offline" info (some->> (get-in request [:params :code])
:include_granted_scopes true (get-access-token cfg)
:state token (get-user-info))]
:response_type "code" (when-not info
:redirect_uri (build-redirect-url cfg) (ex/raise :type :internal
:client_id (:client-id cfg)} :code :unable-to-auth))
query (u/map->query-string params)
uri (-> (u/uri base-goauth-uri) (cond-> info
(assoc :query query))] (some? (:invitation-token state))
(assoc :invitation-token (:invitation-token state)))))
(defn- register-profile
[{:keys [rpc] :as cfg} info]
(let [method-fn (get-in rpc [:methods :mutation :login-or-register])
profile (method-fn {:email (:email info)
:backend "google"
:fullname (:fullname info)})]
(cond-> profile
(some? (:invitation-token info))
(assoc :invitation-token (:invitation-token info)))))
(defn- generate-redirect-uri
[{:keys [tokens] :as cfg} profile]
(let [token (or (:invitation-token profile)
(tokens :generate {:iss :auth
:exp (dt/in-future "15m")
:profile-id (:id profile)}))]
(-> (u/uri (:public-uri cfg))
(assoc :path "/#/auth/verify-token")
(assoc :query (u/map->query-string {:token token})))))
(defn- generate-error-redirect-uri
[cfg]
(-> (u/uri (:public-uri cfg))
(assoc :path "/#/auth/login")
(assoc :query (u/map->query-string {:error "unable-to-auth"}))))
(defn- redirect-response
[uri]
{:status 302
:headers {"location" (str uri)}
:body ""})
(defn- auth-handler
[{:keys [tokens] :as cfg} request]
(let [invitation (get-in request [:params :invitation-token])
state (tokens :generate
{:iss :google-oauth
:invitation-token invitation
:exp (dt/in-future "15m")})
params {:scope scope
:access_type "offline"
:include_granted_scopes true
:state state
:response_type "code"
:redirect_uri (build-redirect-url cfg)
:client_id (:client-id cfg)}
query (u/map->query-string params)
uri (-> (u/uri base-goauth-uri)
(assoc :query query))]
{:status 200 {:status 200
:body {:redirect-uri (str uri)}})) :body {:redirect-uri (str uri)}}))
(defn- callback (defn- callback-handler
[{:keys [tokens rpc session] :as cfg} request] [{:keys [session] :as cfg} request]
(try (try
(let [token (get-in request [:params :state]) (let [info (retrieve-info cfg request)
_ (tokens :verify {:token token :iss :google-oauth}) profile (register-profile cfg info)
info (some->> (get-in request [:params :code]) uri (generate-redirect-uri cfg profile)
(get-access-token cfg) sxf ((:create session) (:id profile))]
(get-user-info)) (sxf request (redirect-response uri)))
_ (when-not info
(ex/raise :type :internal
:code :unable-to-auth))
method-fn (get-in rpc [:methods :mutation :login-or-register])
profile (method-fn {:email (:email info)
:backend "google"
:fullname (:fullname info)})
token (tokens :generate {:iss :auth
:exp (dt/in-future "15m")
:profile-id (:id profile)})
uri (-> (u/uri (:public-uri cfg))
(assoc :path "/#/auth/verify-token")
(assoc :query (u/map->query-string {:token token})))
sxf ((:create session) (:id profile))
rsp {:status 302 :headers {"location" (str uri)} :body ""}]
(sxf request rsp))
(catch Exception _e (catch Exception _e
(let [uri (-> (u/uri (:public-uri cfg)) (-> (generate-error-redirect-uri cfg)
(assoc :path "/#/auth/login") (redirect-response)))))
(assoc :query (u/map->query-string {:error "unable-to-auth"})))]
{:status 302
:headers {"location" (str uri)}
:body ""}))))
(s/def ::client-id ::us/not-empty-string) (s/def ::client-id ::us/not-empty-string)
(s/def ::client-secret ::us/not-empty-string) (s/def ::client-secret ::us/not-empty-string)
@ -139,7 +171,7 @@
[_ cfg] [_ cfg]
(if (and (:client-id cfg) (if (and (:client-id cfg)
(:client-secret cfg)) (:client-secret cfg))
{:auth-handler #(auth cfg %) {:auth-handler #(auth-handler cfg %)
:callback-handler #(callback cfg %)} :callback-handler #(callback-handler cfg %)}
{:auth-handler default-handler {:auth-handler default-handler
:callback-handler default-handler})) :callback-handler default-handler}))

View file

@ -19,7 +19,6 @@
[app.media :as media] [app.media :as media]
[app.rpc.mutations.projects :as projects] [app.rpc.mutations.projects :as projects]
[app.rpc.mutations.teams :as teams] [app.rpc.mutations.teams :as teams]
[app.rpc.mutations.verify-token :refer [process-token]]
[app.rpc.queries.profile :as profile] [app.rpc.queries.profile :as profile]
[app.storage :as sto] [app.storage :as sto]
[app.tasks :as tasks] [app.tasks :as tasks]
@ -48,13 +47,13 @@
(declare create-profile-relations) (declare create-profile-relations)
(declare email-domain-in-whitelist?) (declare email-domain-in-whitelist?)
(s/def ::token ::us/not-empty-string) (s/def ::invitation-token ::us/not-empty-string)
(s/def ::register-profile (s/def ::register-profile
(s/keys :req-un [::email ::password ::fullname] (s/keys :req-un [::email ::password ::fullname]
:opt-un [::token])) :opt-un [::invitation-token]))
(sv/defmethod ::register-profile {:auth false :rlimit :password} (sv/defmethod ::register-profile {:auth false :rlimit :password}
[{:keys [pool tokens session] :as cfg} {:keys [token] :as params}] [{:keys [pool tokens session] :as cfg} params]
(when-not (cfg/get :registration-enabled) (when-not (cfg/get :registration-enabled)
(ex/raise :type :restriction (ex/raise :type :restriction
:code :registration-disabled)) :code :registration-disabled))
@ -69,30 +68,18 @@
(create-profile-relations conn))] (create-profile-relations conn))]
(create-profile-initial-data conn profile) (create-profile-initial-data conn profile)
(if token (if-let [token (:invitation-token params)]
;; If token comes in params, this is because the user comes ;; If invitation token comes in params, this is because the
;; from team-invitation process; in this case we revalidate ;; user comes from team-invitation process; in this case,
;; the token and process the token claims again with the new ;; regenerate token and send back to the user a new invitation
;; profile data. ;; token (and mark current session as logged).
(let [claims (tokens :verify {:token token :iss :team-invitation}) (let [claims (tokens :verify {:token token :iss :team-invitation})
claims (assoc claims :member-id (:id profile)) claims (assoc claims
params (assoc params :profile-id (:id profile)) :member-id (:id profile)
cfg (assoc cfg :conn conn)] :member-email (:email profile))
token (tokens :generate claims)]
(process-token cfg params claims) (with-meta
{:invitation-token token}
;; Automatically mark the created profile as active because
;; we already have the verification of email with the
;; team-invitation token.
(db/update! conn :profile
{:is-active true}
{:id (:id profile)})
;; Return profile data and create http session for
;; automatically login the profile.
(with-meta (assoc profile
:is-active true
:claims claims)
{:transform-response ((:create session) (:id profile))})) {:transform-response ((:create session) (:id profile))}))
;; If no token is provided, send a verification email ;; If no token is provided, send a verification email
@ -117,7 +104,6 @@
:name (:fullname profile) :name (:fullname profile)
:token vtoken :token vtoken
:extra-data ptoken}) :extra-data ptoken})
profile))))) profile)))))
(defn email-domain-in-whitelist? (defn email-domain-in-whitelist?

View file

@ -83,49 +83,78 @@
:internal.tokens.team-invitation/member-email] :internal.tokens.team-invitation/member-email]
:opt-un [:internal.tokens.team-invitation/member-id])) :opt-un [:internal.tokens.team-invitation/member-id]))
(defn- accept-invitation
[{:keys [conn] :as cfg} {:keys [member-id team-id role] :as claims}]
(let [params (merge {:team-id team-id
:profile-id member-id}
(teams/role->params role))
member (profile/retrieve-profile conn member-id)]
;; Insert the invited member to the team
(db/insert! conn :team-profile-rel params {:on-conflict-do-nothing true})
;; If profile is not yet verified, mark it as verified because
;; accepting an invitation link serves as verification.
(when-not (:is-active member)
(db/update! conn :profile
{:is-active true}
{:id member-id}))
(assoc member :is-active true)))
(defmethod process-token :team-invitation (defmethod process-token :team-invitation
[{:keys [conn session] :as cfg} {:keys [profile-id token]} {:keys [member-id team-id role] :as claims}] [{:keys [session] :as cfg} {:keys [profile-id token]} {:keys [member-id] :as claims}]
(us/assert ::team-invitation-claims claims) (us/assert ::team-invitation-claims claims)
(if (uuid? member-id) (cond
(let [params (merge {:team-id team-id ;; This happens when token is filled with member-id and current
:profile-id member-id} ;; user is already logged in with some account.
(teams/role->params role)) (and (uuid? profile-id)
claims (assoc claims :state :created) (uuid? member-id))
member (profile/retrieve-profile conn member-id)] (do
(accept-invitation cfg claims)
(db/insert! conn :team-profile-rel params (if (= member-id profile-id)
{:on-conflict-do-nothing true})
;; If profile is not yet verified, mark it as verified because
;; accepting an invitation link serves as verification.
(when-not (:is-active member)
(db/update! conn :profile
{:is-active true}
{:id member-id}))
(if (and (uuid? profile-id)
(= member-id profile-id))
;; If the current session is already matches the invited ;; If the current session is already matches the invited
;; member, then just return the token and leave the frontend ;; member, then just return the token and leave the frontend
;; app redirect to correct team. ;; app redirect to correct team.
claims (assoc claims :status :created)
;; If the session does not matches the invited member id, ;; If the session does not matches the invited member, replace
;; replace the session with a new one matching the invited ;; the session with a new one matching the invited member.
;; member. This techinique should be considered secure because ;; This techinique should be considered secure because the
;; the user clicking the link he already has access to the ;; user clicking the link he already has access to the email
;; email account. ;; account.
(with-meta claims (with-meta
(assoc claims :status :created)
{:transform-response ((:create session) member-id)}))) {:transform-response ((:create session) member-id)})))
;; This happens when member-id is not filled in the invitation but
;; the user already has an account (probably with other mail) and
;; is already logged-in.
(and (uuid? profile-id)
(nil? member-id))
(do
(accept-invitation cfg (assoc claims :member-id profile-id))
(assoc claims :state :created))
;; This happens when member-id is filled but the accessing user is
;; not logged-in. In this case we proceed to accept invitation and
;; leave the user logged-in.
(and (nil? profile-id)
(uuid? member-id))
(do
(accept-invitation cfg claims)
(with-meta
(assoc claims :state :created)
{:transform-response ((:create session) member-id)}))
;; In this case, we wait until frontend app redirect user to ;; In this case, we wait until frontend app redirect user to
;; registeration page, the user is correctly registered and the ;; registeration page, the user is correctly registered and the
;; register mutation call us again with the same token to finally ;; register mutation call us again with the same token to finally
;; create the corresponding team-profile relation from the first ;; create the corresponding team-profile relation from the first
;; condition of this if. ;; condition of this if.
(assoc claims :else
:token token {:invitation-token token
:state :pending))) :iss :team-invitation
:state :pending}))
;; --- Default ;; --- Default

View file

@ -830,6 +830,8 @@
"es" : "Actualizado: %s" "es" : "Actualizado: %s"
} }
}, },
"errors.google-auth-not-enabled" : { "errors.google-auth-not-enabled" : {
"translations" : { "translations" : {
"en" : "Authentication with google disabled on backend", "en" : "Authentication with google disabled on backend",
@ -837,6 +839,13 @@
} }
}, },
"errors.unexpected-token" : {
"translations" : {
"en" : "Unknown token",
"es" : "Token desconocido"
}
},
"errors.profile-is-muted" : { "errors.profile-is-muted" : {
"translations" : { "translations" : {
"en" : "Your profile has emails muted (spam reports or high bounces).", "en" : "Your profile has emails muted (spam reports or high bounces).",
@ -1899,6 +1908,26 @@
"es" : "Quitar" "es" : "Quitar"
} }
}, },
"labels.create-team": {
"translations" : {
"en" : "Create new team",
"es" : "Crea un nuevo equipo"
}
},
"labels.update-team": {
"translations" : {
"en" : "Update team",
"es" : "Actualiza el equipo"
}
},
"labels.rename-team": {
"translations" : {
"en" : "Rename team",
"es" : "Renomba el equipo"
}
},
"labels.rename" : { "labels.rename" : {
"used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:314", "src/app/main/ui/dashboard/files.cljs:84", "src/app/main/ui/dashboard/grid.cljs:178" ], "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:314", "src/app/main/ui/dashboard/files.cljs:84", "src/app/main/ui/dashboard/grid.cljs:178" ],
"translations" : { "translations" : {

View file

@ -354,7 +354,6 @@
} }
input[type=submit] { input[type=submit] {
width: 120px;
margin-bottom: 0px; margin-bottom: 0px;
} }
} }

View file

@ -81,19 +81,19 @@
(defmethod mutation :login-with-google (defmethod mutation :login-with-google
[id params] [id params]
(let [uri (str cfg/public-uri "/api/oauth/google")] (let [uri (str cfg/public-uri "/api/oauth/google")]
(->> (http/send! {:method :post :uri uri}) (->> (http/send! {:method :post :uri uri :query params})
(rx/mapcat handle-response)))) (rx/mapcat handle-response))))
(defmethod mutation :login-with-gitlab (defmethod mutation :login-with-gitlab
[id params] [id params]
(let [uri (str cfg/public-uri "/api/oauth/gitlab")] (let [uri (str cfg/public-uri "/api/oauth/gitlab")]
(->> (http/send! {:method :post :uri uri}) (->> (http/send! {:method :post :uri uri :query params})
(rx/mapcat handle-response)))) (rx/mapcat handle-response))))
(defmethod mutation :login-with-github (defmethod mutation :login-with-github
[id params] [id params]
(let [uri (str cfg/public-uri "/api/oauth/github")] (let [uri (str cfg/public-uri "/api/oauth/github")]
(->> (http/send! {:method :post :uri uri}) (->> (http/send! {:method :post :uri uri :query params})
(rx/mapcat handle-response)))) (rx/mapcat handle-response))))
(defmethod mutation :upload-file-media-object (defmethod mutation :upload-file-media-object

View file

@ -5,7 +5,7 @@
;; This Source Code Form is "Incompatible With Secondary Licenses", as ;; This Source Code Form is "Incompatible With Secondary Licenses", as
;; defined by the Mozilla Public License, v. 2.0. ;; defined by the Mozilla Public License, v. 2.0.
;; ;;
;; Copyright (c) 2020 UXBOX Labs SL ;; Copyright (c) 2020-2021 UXBOX Labs SL
(ns app.main.ui.auth.login (ns app.main.ui.auth.login
(:require (:require
@ -33,25 +33,25 @@
(s/keys :req-un [::email ::password])) (s/keys :req-un [::email ::password]))
(defn- login-with-google (defn- login-with-google
[event] [event params]
(dom/prevent-default event) (dom/prevent-default event)
(->> (rp/mutation! :login-with-google {}) (->> (rp/mutation! :login-with-google params)
(rx/subs (fn [{:keys [redirect-uri] :as rsp}] (rx/subs (fn [{:keys [redirect-uri] :as rsp}]
(.replace js/location redirect-uri)) (.replace js/location redirect-uri))
(fn [{:keys [type] :as error}] (fn [{:keys [type] :as error}]
(st/emit! (dm/error (tr "errors.google-auth-not-enabled"))))))) (st/emit! (dm/error (tr "errors.google-auth-not-enabled")))))))
(defn- login-with-gitlab (defn- login-with-gitlab
[event] [event params]
(dom/prevent-default event) (dom/prevent-default event)
(->> (rp/mutation! :login-with-gitlab {}) (->> (rp/mutation! :login-with-gitlab params)
(rx/subs (fn [{:keys [redirect-uri] :as rsp}] (rx/subs (fn [{:keys [redirect-uri] :as rsp}]
(.replace js/location redirect-uri))))) (.replace js/location redirect-uri)))))
(defn- login-with-github (defn- login-with-github
[event] [event params]
(dom/prevent-default event) (dom/prevent-default event)
(->> (rp/mutation! :login-with-github {}) (->> (rp/mutation! :login-with-github params)
(rx/subs (fn [{:keys [redirect-uri] :as rsp}] (rx/subs (fn [{:keys [redirect-uri] :as rsp}]
(.replace js/location redirect-uri))))) (.replace js/location redirect-uri)))))

View file

@ -81,11 +81,8 @@
(mf/use-callback (mf/use-callback
(fn [form data] (fn [form data]
(reset! submitted? false) (reset! submitted? false)
(if (and (:is-active data) (:claims data)) (if-let [token (:invitation-token data)]
(let [message (tr "auth.notifications.team-invitation-accepted")] (st/emit! (rt/nav :auth-verify-token {} {:token token}))
(st/emit! (rt/nav :dashboard-projects {:team-id (get-in data [:claims :team-id])})
(du/fetch-profile)
(dm/success message)))
(st/emit! (rt/nav :auth-register-success {} {:email (:email data)}))))) (st/emit! (rt/nav :auth-register-success {} {:email (:email data)})))))
on-submit on-submit
@ -161,19 +158,19 @@
(when cfg/google-client-id (when cfg/google-client-id
[:a.btn-ocean.btn-large.btn-google-auth [:a.btn-ocean.btn-large.btn-google-auth
{:on-click login/login-with-google} {:on-click #(login/login-with-google % params)}
"Login with Google"]) "Login with Google"])
(when cfg/gitlab-client-id (when cfg/gitlab-client-id
[:a.btn-ocean.btn-large.btn-gitlab-auth [:a.btn-ocean.btn-large.btn-gitlab-auth
{:on-click login/login-with-gitlab} {:on-click #(login/login-with-gitlab % params)}
[:img.logo [:img.logo
{:src "/images/icons/brand-gitlab.svg"}] {:src "/images/icons/brand-gitlab.svg"}]
(tr "auth.login-with-gitlab-submit")]) (tr "auth.login-with-gitlab-submit")])
(when cfg/github-client-id (when cfg/github-client-id
[:a.btn-ocean.btn-large.btn-github-auth [:a.btn-ocean.btn-large.btn-github-auth
{:on-click login/login-with-github} {:on-click #(login/login-with-github % params)}
[:img.logo [:img.logo
{:src "/images/icons/brand-github.svg"}] {:src "/images/icons/brand-github.svg"}]
(tr "auth.login-with-github-submit")])]) (tr "auth.login-with-github-submit")])])

View file

@ -58,12 +58,14 @@
(dm/success message))) (dm/success message)))
:pending :pending
(st/emit! (rt/nav :auth-register {} {:token (:token tdata)})))) (let [token (:invitation-token tdata)]
(st/emit! (rt/nav :auth-register {} {:invitation-token token})))))
(defmethod handle-token :default (defmethod handle-token :default
[tdata] [tdata]
(js/console.log "Unhandled token:" (pr-str tdata)) (st/emit!
(st/emit! (rt/nav :auth-login))) (rt/nav :auth-login)
(dm/warn (tr "errors.unexpected-token"))))
(mf/defc verify-token (mf/defc verify-token
[{:keys [route] :as props}] [{:keys [route] :as props}]

View file

@ -18,10 +18,9 @@
[app.main.data.modal :as modal] [app.main.data.modal :as modal]
[app.main.repo :as rp] [app.main.repo :as rp]
[app.main.store :as st] [app.main.store :as st]
[app.main.ui.components.forms :refer [input submit-button form]] [app.main.ui.components.forms :as fm]
[app.main.ui.icons :as i] [app.main.ui.icons :as i]
[app.util.dom :as dom] [app.util.dom :as dom]
[app.util.forms :as fm]
[app.util.i18n :as i18n :refer [t tr]] [app.util.i18n :as i18n :refer [t tr]]
[app.util.keyboard :as kbd] [app.util.keyboard :as kbd]
[app.util.object :as obj] [app.util.object :as obj]
@ -90,28 +89,28 @@
[:div.modal-overlay [:div.modal-overlay
[:div.modal-container.team-form-modal [:div.modal-container.team-form-modal
[:div.modal-header [:& fm/form {:form form
[:div.modal-header-title :on-submit on-submit}
(if team
[:h2 "Rename team"]
[:h2 "Create new team"])]
[:div.modal-close-button
{:on-click (st/emitf (modal/hide))} i/close]]
[:div.modal-content.generic-form [:div.modal-header
[:form [:div.modal-header-title
[:& input {:type "text" (if team
:form form [:h2 (tr "labels.rename-team")]
:name :name [:h2 (tr "labels.create-team")])]
:label "Enter new team name:"}]]] [:div.modal-close-button
{:on-click (st/emitf (modal/hide))} i/close]]
[:div.modal-footer [:div.modal-content.generic-form
[:div.action-buttons [:& fm/input {:type "text"
[:& submit-button :form form
{:form form :name :name
:on-click on-submit :label "Enter new team name:"}]]
:label (if team
"Update team" [:div.modal-footer
"Create team")}]]]]])) [:div.action-buttons
[:& fm/submit-button
{:label (if team
(tr "labels.update-team")
(tr "labels.create-team"))}]]]]]]))