🐛 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

@ -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) 2020 UXBOX Labs SL
;; Copyright (c) 2020-2021 UXBOX Labs SL
(ns app.http.auth.google
(:require
@ -43,6 +43,7 @@
req {:method :post
:headers {"content-type" "application/x-www-form-urlencoded"}
:uri "https://oauth2.googleapis.com/token"
:timeout 6000
:body (u/map->query-string params)}
res (http/send! req)]
@ -69,54 +70,85 @@
(log/error e "unexpected exception on get-user-info")
nil)))
(defn- auth
[{:keys [tokens] :as cfg} _req]
(let [token (tokens :generate {:iss :google-oauth :exp (dt/in-future "15m")})
params {:scope scope
:access_type "offline"
:include_granted_scopes true
:state token
: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))]
(defn- retrieve-info
[{:keys [tokens] :as cfg} request]
(let [token (get-in request [:params :state])
state (tokens :verify {:token token :iss :google-oauth})
info (some->> (get-in request [:params :code])
(get-access-token cfg)
(get-user-info))]
(when-not info
(ex/raise :type :internal
:code :unable-to-auth))
(cond-> info
(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
:body {:redirect-uri (str uri)}}))
(defn- callback
[{:keys [tokens rpc session] :as cfg} request]
(defn- callback-handler
[{:keys [session] :as cfg} request]
(try
(let [token (get-in request [:params :state])
_ (tokens :verify {:token token :iss :google-oauth})
info (some->> (get-in request [:params :code])
(get-access-token cfg)
(get-user-info))
_ (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))
(let [info (retrieve-info cfg request)
profile (register-profile cfg info)
uri (generate-redirect-uri cfg profile)
sxf ((:create session) (:id profile))]
(sxf request (redirect-response uri)))
(catch Exception _e
(let [uri (-> (u/uri (:public-uri cfg))
(assoc :path "/#/auth/login")
(assoc :query (u/map->query-string {:error "unable-to-auth"})))]
{:status 302
:headers {"location" (str uri)}
:body ""}))))
(-> (generate-error-redirect-uri cfg)
(redirect-response)))))
(s/def ::client-id ::us/not-empty-string)
(s/def ::client-secret ::us/not-empty-string)
@ -139,7 +171,7 @@
[_ cfg]
(if (and (:client-id cfg)
(:client-secret cfg))
{:auth-handler #(auth cfg %)
:callback-handler #(callback cfg %)}
{:auth-handler #(auth-handler cfg %)
:callback-handler #(callback-handler cfg %)}
{:auth-handler default-handler
:callback-handler default-handler}))

View file

@ -19,7 +19,6 @@
[app.media :as media]
[app.rpc.mutations.projects :as projects]
[app.rpc.mutations.teams :as teams]
[app.rpc.mutations.verify-token :refer [process-token]]
[app.rpc.queries.profile :as profile]
[app.storage :as sto]
[app.tasks :as tasks]
@ -48,13 +47,13 @@
(declare create-profile-relations)
(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/keys :req-un [::email ::password ::fullname]
:opt-un [::token]))
:opt-un [::invitation-token]))
(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)
(ex/raise :type :restriction
:code :registration-disabled))
@ -69,30 +68,18 @@
(create-profile-relations conn))]
(create-profile-initial-data conn profile)
(if token
;; If token comes in params, this is because the user comes
;; from team-invitation process; in this case we revalidate
;; the token and process the token claims again with the new
;; profile data.
(if-let [token (:invitation-token params)]
;; If invitation token comes in params, this is because the
;; user comes from team-invitation process; in this case,
;; regenerate token and send back to the user a new invitation
;; token (and mark current session as logged).
(let [claims (tokens :verify {:token token :iss :team-invitation})
claims (assoc claims :member-id (:id profile))
params (assoc params :profile-id (:id profile))
cfg (assoc cfg :conn conn)]
(process-token cfg params claims)
;; 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)
claims (assoc claims
:member-id (:id profile)
:member-email (:email profile))
token (tokens :generate claims)]
(with-meta
{:invitation-token token}
{:transform-response ((:create session) (:id profile))}))
;; If no token is provided, send a verification email
@ -117,7 +104,6 @@
:name (:fullname profile)
:token vtoken
:extra-data ptoken})
profile)))))
(defn email-domain-in-whitelist?

View file

@ -83,49 +83,78 @@
:internal.tokens.team-invitation/member-email]
: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
[{: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)
(if (uuid? member-id)
(let [params (merge {:team-id team-id
:profile-id member-id}
(teams/role->params role))
claims (assoc claims :state :created)
member (profile/retrieve-profile conn member-id)]
(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}))
(if (and (uuid? profile-id)
(= member-id profile-id))
(cond
;; This happens when token is filled with member-id and current
;; user is already logged in with some account.
(and (uuid? profile-id)
(uuid? member-id))
(do
(accept-invitation cfg claims)
(if (= member-id profile-id)
;; If the current session is already matches the invited
;; member, then just return the token and leave the frontend
;; app redirect to correct team.
claims
(assoc claims :status :created)
;; If the session does not matches the invited member id,
;; replace the session with a new one matching the invited
;; member. This techinique should be considered secure because
;; the user clicking the link he already has access to the
;; email account.
(with-meta claims
;; If the session does not matches the invited member, replace
;; the session with a new one matching the invited member.
;; This techinique should be considered secure because the
;; user clicking the link he already has access to the email
;; account.
(with-meta
(assoc claims :status :created)
{: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
;; registeration page, the user is correctly registered and the
;; register mutation call us again with the same token to finally
;; create the corresponding team-profile relation from the first
;; condition of this if.
(assoc claims
:token token
:state :pending)))
:else
{:invitation-token token
:iss :team-invitation
:state :pending}))
;; --- Default