0
Fork 0
mirror of https://github.com/penpot/penpot.git synced 2025-03-29 08:01:41 -05:00
penpot/backend/src/app/rpc/commands/auth.clj
2024-06-12 13:56:19 +02:00

514 lines
19 KiB
Clojure

;; 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) KALEIDOS INC
(ns app.rpc.commands.auth
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.features :as cfeat]
[app.common.logging :as l]
[app.common.schema :as sm]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.email :as eml]
[app.email.blacklist :as email.blacklist]
[app.email.whitelist :as email.whitelist]
[app.http.session :as session]
[app.loggers.audit :as audit]
[app.rpc :as-alias rpc]
[app.rpc.climit :as-alias climit]
[app.rpc.commands.profile :as profile]
[app.rpc.commands.teams :as teams]
[app.rpc.doc :as-alias doc]
[app.rpc.helpers :as rph]
[app.setup :as-alias setup]
[app.tokens :as tokens]
[app.util.services :as sv]
[app.util.time :as dt]
[cuerdas.core :as str]))
(def schema:password
[::sm/word-string {:max 500}])
(def schema:token
[::sm/word-string {:max 6000}])
(defn- elapsed-verify-threshold?
[profile]
(let [elapsed (dt/diff (:modified-at profile) (dt/now))
verify-threshold (cf/get :email-verify-threshold)]
(pos? (compare elapsed verify-threshold))))
;; ---- COMMAND: login with password
(defn login-with-password
[cfg {:keys [email password] :as params}]
(when-not (or (contains? cf/flags :login)
(contains? cf/flags :login-with-password))
(ex/raise :type :restriction
:code :login-disabled
:hint "login is disabled in this instance"))
(letfn [(check-password [cfg profile password]
(if (= (:password profile) "!")
(ex/raise :type :validation
:code :account-without-password
:hint "the current account does not have password")
(let [result (profile/verify-password cfg password (:password profile))]
(when (:update result)
(l/trc :hint "updating profile password"
:id (str (:id profile))
:email (:email profile))
(profile/update-profile-password! cfg (assoc profile :password password)))
(:valid result))))
(validate-profile [cfg profile]
(when-not profile
(ex/raise :type :validation
:code :wrong-credentials))
(when-not (:is-active profile)
(ex/raise :type :validation
:code :wrong-credentials))
(when (:is-blocked profile)
(ex/raise :type :restriction
:code :profile-blocked))
(when-not (check-password cfg profile password)
(ex/raise :type :validation
:code :wrong-credentials))
(when-let [deleted-at (:deleted-at profile)]
(when (dt/is-after? (dt/now) deleted-at)
(ex/raise :type :validation
:code :wrong-credentials)))
profile)
(login [{:keys [::db/conn] :as cfg}]
(let [profile (->> (profile/clean-email email)
(profile/get-profile-by-email conn)
(validate-profile cfg)
(profile/strip-private-attrs))
invitation (when-let [token (:invitation-token params)]
(tokens/verify (::setup/props cfg) {:token token :iss :team-invitation}))
;; If invitation member-id does not matches the profile-id, we just proceed to ignore the
;; invitation because invitations matches exactly; and user can't login with other email and
;; accept invitation with other email
response (if (and (some? invitation) (= (:id profile) (:member-id invitation)))
{:invitation-token (:invitation-token params)}
(assoc profile :is-admin (let [admins (cf/get :admins)]
(contains? admins (:email profile)))))]
(-> response
(rph/with-transform (session/create-fn cfg (:id profile)))
(rph/with-meta {::audit/props (audit/profile->props profile)
::audit/profile-id (:id profile)}))))]
(db/tx-run! cfg login)))
(def schema:login-with-password
[:map {:title "login-with-password"}
[:email ::sm/email]
[:password schema:password]
[:invitation-token {:optional true} schema:token]])
(sv/defmethod ::login-with-password
"Performs authentication using penpot password."
{::rpc/auth false
::doc/added "1.15"
::climit/id :auth/global
::sm/params schema:login-with-password}
[cfg params]
(login-with-password cfg params))
;; ---- COMMAND: Logout
(def ^:private schema:logout
[:map {:title "logoug"}
[:profile-id {:optional true} ::sm/uuid]])
(sv/defmethod ::logout
"Clears the authentication cookie and logout the current session."
{::rpc/auth false
::doc/changes [["2.1" "Now requires profile-id passed in the body"]]
::doc/added "1.0"
::sm/params schema:logout}
[cfg params]
(if (= (:profile-id params)
(::rpc/profile-id params))
(rph/with-transform {} (session/delete-fn cfg))
{}))
;; ---- COMMAND: Recover Profile
(defn recover-profile
[{:keys [::db/pool] :as cfg} {:keys [token password]}]
(letfn [(validate-token [token]
(let [tdata (tokens/verify (::setup/props cfg) {:token token :iss :password-recovery})]
(:profile-id tdata)))
(update-password [conn profile-id]
(let [pwd (profile/derive-password cfg password)]
(db/update! conn :profile {:password pwd :is-active true} {:id profile-id})
nil))]
(db/with-atomic [conn pool]
(->> (validate-token token)
(update-password conn))
nil)))
(def schema:recover-profile
[:map {:title "recover-profile"}
[:token schema:token]
[:password schema:password]])
(sv/defmethod ::recover-profile
{::rpc/auth false
::doc/added "1.15"
::sm/params schema:recover-profile
::climit/id :auth/global}
[cfg params]
(recover-profile cfg params))
;; ---- COMMAND: Prepare Register
(defn- validate-register-attempt!
[cfg params]
(when-not (contains? cf/flags :registration)
(when-not (contains? params :invitation-token)
(ex/raise :type :restriction
:code :registration-disabled)))
(when (contains? params :invitation-token)
(let [invitation (tokens/verify (::setup/props cfg)
{:token (:invitation-token params)
:iss :team-invitation})]
(when-not (= (:email params) (:member-email invitation))
(ex/raise :type :restriction
:code :email-does-not-match-invitation
:hint "email should match the invitation"))))
(when (and (email.blacklist/enabled? cfg)
(email.blacklist/contains? cfg (:email params)))
(ex/raise :type :restriction
:code :email-domain-is-not-allowed))
(when (and (email.whitelist/enabled? cfg)
(not (email.whitelist/contains? cfg (:email params))))
(ex/raise :type :restriction
:code :email-domain-is-not-allowed))
;; Perform a basic validation of email & password
(when (= (str/lower (:email params))
(str/lower (:password params)))
(ex/raise :type :validation
:code :email-as-password
:hint "you can't use your email as password")))
(defn prepare-register
[{:keys [::db/pool] :as cfg} {:keys [email] :as params}]
(validate-register-attempt! cfg params)
(let [email (profile/clean-email email)
profile (profile/get-profile-by-email pool email)
params {:email email
:password (:password params)
:invitation-token (:invitation-token params)
:backend "penpot"
:iss :prepared-register
:profile-id (:id profile)
:exp (dt/in-future {:days 7})}
params (d/without-nils params)
token (tokens/generate (::setup/props cfg) params)]
(with-meta {:token token}
{::audit/profile-id uuid/zero})))
(def schema:prepare-register-profile
[:map {:title "prepare-register-profile"}
[:email ::sm/email]
[:password schema:password]
[:invitation-token {:optional true} schema:token]])
(sv/defmethod ::prepare-register-profile
{::rpc/auth false
::doc/added "1.15"
::sm/params schema:prepare-register-profile}
[cfg params]
(prepare-register cfg params))
;; ---- COMMAND: Register Profile
(defn create-profile!
"Create the profile entry on the database with limited set of input
attrs (all the other attrs are filled with default values)."
[conn {:keys [email] :as params}]
(dm/assert! ::sm/email email)
(let [id (or (:id params) (uuid/next))
props (-> (audit/extract-utm-params params)
(merge (:props params))
(merge {:viewed-tutorial? false
:viewed-walkthrough? false
:nudge {:big 10 :small 1}
:v2-info-shown true})
(db/tjson))
password (or (:password params) "!")
locale (:locale params)
locale (when (and (string? locale) (not (str/blank? locale)))
locale)
backend (:backend params "penpot")
is-demo (:is-demo params false)
is-muted (:is-muted params false)
is-active (:is-active params false)
email (str/lower email)
params {:id id
:fullname (:fullname params)
:email email
:auth-backend backend
:lang locale
:password password
:deleted-at (:deleted-at params)
:props props
:is-active is-active
:is-muted is-muted
:is-demo is-demo}]
(try
(-> (db/insert! conn :profile params)
(profile/decode-row))
(catch org.postgresql.util.PSQLException e
(let [state (.getSQLState e)]
(if (not= state "23505")
(throw e)
(ex/raise :type :validation
:code :email-already-exists
:hint "email already exists"
:cause e)))))))
(defn create-profile-rels!
[conn {:keys [id] :as profile}]
(let [features (cfeat/get-enabled-features cf/flags)
team (teams/create-team conn
{:profile-id id
:name "Default"
:features features
:is-default true})]
(-> (db/update! conn :profile
{:default-team-id (:id team)
:default-project-id (:default-project-id team)}
{:id id}
{::db/return-keys true})
(profile/decode-row))))
(defn send-email-verification!
[{:keys [::db/conn] :as cfg} profile]
(let [vtoken (tokens/generate (::setup/props cfg)
{:iss :verify-email
:exp (dt/in-future "72h")
:profile-id (:id profile)
:email (:email profile)})
;; NOTE: this token is mainly used for possible complains
;; identification on the sns webhook
ptoken (tokens/generate (::setup/props cfg)
{:iss :profile-identity
:profile-id (:id profile)
:exp (dt/in-future {:days 30})})]
(eml/send! {::eml/conn conn
::eml/factory eml/register
:public-uri (cf/get :public-uri)
:to (:email profile)
:name (:fullname profile)
:token vtoken
:extra-data ptoken})))
(defn register-profile
[{:keys [::db/conn] :as cfg} {:keys [token fullname] :as params}]
(let [claims (tokens/verify (::setup/props cfg) {:token token :iss :prepared-register})
params (-> claims
(into params)
(assoc :fullname fullname))
profile (if-let [profile-id (:profile-id claims)]
(profile/get-profile conn profile-id)
(let [is-active (or (boolean (:is-active params))
(not (contains? cf/flags :email-verification)))
params (-> params
(assoc :is-active is-active)
(update :password #(profile/derive-password cfg %)))]
(->> (create-profile! conn params)
(create-profile-rels! conn))))
invitation (when-let [token (:invitation-token params)]
(tokens/verify (::setup/props cfg) {:token token :iss :team-invitation}))
props (audit/profile->props profile)]
(cond
;; When profile is blocked, we just ignore it and return plain data
(:is-blocked profile)
(do
(l/wrn :hint "register attempt for already blocked profile"
:profile-id (str (:id profile))
:profile-email (:email profile))
(rph/with-meta {:email (:email profile)}
{::audit/replace-props props
::audit/context {:action "ignore-because-blocked"}
::audit/profile-id (:id profile)
::audit/name "register-profile-retry"}))
;; 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). This happens only if the
;; invitation email matches with the register email.
(and (some? invitation)
(= (:email profile)
(:member-email invitation)))
(let [claims (assoc invitation :member-id (:id profile))
token (tokens/generate (::setup/props cfg) claims)]
(-> {:invitation-token token}
(rph/with-transform (session/create-fn cfg (:id profile)))
(rph/with-meta {::audit/replace-props props
::audit/context {:action "accept-invitation"}
::audit/profile-id (:id profile)})))
;; When a new user is created and it is already activated by
;; configuration or specified by OIDC, we just mark the profile
;; as logged-in
(not (:profile-id claims))
(if (:is-active claims)
(-> (profile/strip-private-attrs profile)
(rph/with-transform (session/create-fn cfg (:id profile)))
(rph/with-meta
{::audit/replace-props props
::audit/context {:action "login"}
::audit/profile-id (:id profile)}))
(do
(send-email-verification! cfg profile)
(rph/with-meta {:email (:email profile)}
{::audit/replace-props props
::audit/context {:action "email-verification"}
::audit/profile-id (:id profile)})))
:else
(let [elapsed? (elapsed-verify-threshold? profile)
bounce? (eml/has-bounce-reports? conn (:email profile))
action (if bounce?
"ignore-because-bounce"
(if elapsed?
"resend-email-verification"
"ignore"))]
(l/wrn :hint "repeated registry detected"
:profile-id (str (:id profile))
:profile-email (:email profile)
:context-action action)
(when (= action "resend-email-verification")
(db/update! conn :profile
{:modified-at (dt/now)}
{:id (:id profile)})
(send-email-verification! cfg profile))
(rph/with-meta {:email (:email profile)}
{::audit/replace-props (audit/profile->props profile)
::audit/context {:action action}
::audit/profile-id (:id profile)
::audit/name "register-profile-retry"})))))
(def schema:register-profile
[:map {:title "register-profile"}
[:token schema:token]
[:fullname [::sm/word-string {:max 100}]]])
(sv/defmethod ::register-profile
{::rpc/auth false
::doc/added "1.15"
::sm/params schema:register-profile
::climit/id :auth/global}
[cfg params]
(db/tx-run! cfg register-profile params))
;; ---- COMMAND: Request Profile Recovery
(defn- request-profile-recovery
[{:keys [::db/pool] :as cfg} {:keys [email] :as params}]
(letfn [(create-recovery-token [{:keys [id] :as profile}]
(let [token (tokens/generate (::setup/props cfg)
{:iss :password-recovery
:exp (dt/in-future "15m")
:profile-id id})]
(assoc profile :token token)))
(send-email-notification [conn profile]
(let [ptoken (tokens/generate (::setup/props cfg)
{:iss :profile-identity
:profile-id (:id profile)
:exp (dt/in-future {:days 30})})]
(eml/send! {::eml/conn conn
::eml/factory eml/password-recovery
:public-uri (cf/get :public-uri)
:to (:email profile)
:token (:token profile)
:name (:fullname profile)
:extra-data ptoken})
nil))]
(db/with-atomic [conn pool]
(let [profile (->> (profile/clean-email email)
(profile/get-profile-by-email conn))]
(cond
(not profile)
(l/wrn :hint "attempt of profile recovery: no profile found"
:profile-email email)
(not (eml/allow-send-emails? conn profile))
(l/wrn :hint "attempt of profile recovery: profile is muted"
:profile-id (str (:id profile))
:profile-email (:email profile))
(eml/has-bounce-reports? conn (:email profile))
(l/wrn :hint "attempt of profile recovery: email has bounces"
:profile-id (str (:id profile))
:profile-email (:email profile))
(not (elapsed-verify-threshold? profile))
(l/wrn :hint "attempt of profile recovery: retry attempt threshold not elapsed"
:profile-id (str (:id profile))
:profile-email (:email profile))
:else
(do
(db/update! conn :profile
{:modified-at (dt/now)}
{:id (:id profile)})
(->> profile
(create-recovery-token)
(send-email-notification conn))))))))
(def schema:request-profile-recovery
[:map {:title "request-profile-recovery"}
[:email ::sm/email]])
(sv/defmethod ::request-profile-recovery
{::rpc/auth false
::doc/added "1.15"
::sm/params schema:request-profile-recovery}
[cfg params]
(request-profile-recovery cfg params))