♻️ Make the SSO code more modular (#7575)
Some checks failed
_STAGING / build-bundle (push) Has been cancelled
_STAGING / build-docker (push) Has been cancelled
_DEVELOP / build-bundle (push) Has been cancelled
_DEVELOP / build-docker (push) Has been cancelled
Commit Message Check / Check Commit Message (push) Has been cancelled

* 📎 Disable by default social auth on devenv

* 🎉 Add the ability to import profile picture from SSO provider

* 📎 Add srepl helper for insert custom sso config

* 🎉 Add custom SSO auth flow
This commit is contained in:
Andrey Antukh
2025-11-12 12:49:10 +01:00
committed by GitHub
parent 05bea14a88
commit 363b4e3778
35 changed files with 1183 additions and 824 deletions

View File

@@ -4,11 +4,46 @@
### :boom: Breaking changes & Deprecations ### :boom: Breaking changes & Deprecations
- The backend RPC API URLS are changed from `/api/rpc/command/<name>` #### Backend RPC API changes
to `/api/main/methods/<name>` (the previou PATH is preserved for
backward compatibility; however, if you are a user of this API, it The backend RPC API URLS are changed from `/api/rpc/command/<name>` to
is strongly recommended that you adapt your code to use the new `/api/main/methods/<name>` (the previou PATH is preserved for backward
PATH. compatibility; however, if you are a user of this API, it is strongly
recommended that you adapt your code to use the new PATH.
#### Updated SSO Callback URL
The OAuth / Single Sign-On (SSO) callback endpoint has changed to
align with the new OpenID Connect (OIDC) implementation.
Old callback URL:
```
https://<your_domain>/api/auth/oauth/<oauth_provider>/callback
```
New callback URL:
```
https://<your_domain>/api/auth/oidc/callback
```
**Action required:**
If you have SSO/Social-Auth configured on your on-premise instance,
the following actions are required before update:
Update your OAuth or SSO provider configuration (e.g., Okta, Google,
Azure AD, etc.) to use the new callback URL. Failure to update may
result in authentication failures after upgrading.
**Reason for change:**
This update standardizes all authentication flows under the single URL
and makis it more modular, enabling the ability to configure SSO auth
provider dinamically.
### :rocket: Epics and highlights ### :rocket: Epics and highlights

View File

@@ -7,12 +7,12 @@ export PENPOT_HOST=devenv
export PENPOT_FLAGS="\ export PENPOT_FLAGS="\
$PENPOT_FLAGS \ $PENPOT_FLAGS \
enable-login-with-ldap \
enable-login-with-password enable-login-with-password
enable-login-with-oidc \ disable-login-with-ldap \
enable-login-with-google \ disable-login-with-oidc \
enable-login-with-github \ disable-login-with-google \
enable-login-with-gitlab \ disable-login-with-github \
disable-login-with-gitlab \
enable-backend-worker \ enable-backend-worker \
enable-backend-asserts \ enable-backend-asserts \
disable-feature-fdata-pointer-map \ disable-feature-fdata-pointer-map \

File diff suppressed because it is too large Load Diff

View File

@@ -168,7 +168,7 @@
[:google-client-id {:optional true} :string] [:google-client-id {:optional true} :string]
[:google-client-secret {:optional true} :string] [:google-client-secret {:optional true} :string]
[:oidc-client-id {:optional true} :string] [:oidc-client-id {:optional true} :string]
[:oidc-user-info-source {:optional true} :keyword] [:oidc-user-info-source {:optional true} [:enum "auto" "userinfo" "token"]]
[:oidc-client-secret {:optional true} :string] [:oidc-client-secret {:optional true} :string]
[:oidc-base-uri {:optional true} :string] [:oidc-base-uri {:optional true} :string]
[:oidc-token-uri {:optional true} :string] [:oidc-token-uri {:optional true} :string]

View File

@@ -9,7 +9,7 @@
[app.common.logging :as l] [app.common.logging :as l]
[app.config :as cf] [app.config :as cf]
[app.db :as db] [app.db :as db]
[app.http.auth :as-alias http.auth] [app.http :as-alias http]
[app.main :as-alias main] [app.main :as-alias main]
[app.setup :as-alias setup] [app.setup :as-alias setup]
[app.tokens :as tokens])) [app.tokens :as tokens]))
@@ -33,16 +33,17 @@
(defn- get-token-data (defn- get-token-data
[pool claims] [pool claims]
(when-not (db/read-only? pool) (when-not (db/read-only? pool)
(when-let [token-id (-> (deref claims) (get :tid))] (when-let [token-id (get claims :tid)]
(some-> (db/exec-one! pool [sql:get-token-data token-id]) (some-> (db/exec-one! pool [sql:get-token-data token-id])
(update :perms db/decode-pgarray #{}))))) (update :perms db/decode-pgarray #{})))))
(defn- wrap-authz (defn- wrap-authz
[handler {:keys [::db/pool]}] [handler {:keys [::db/pool]}]
(fn [{:keys [::http.auth/token-type] :as request}] (fn [request]
(if (= :token token-type) (let [{:keys [type claims]} (get request ::http/auth-data)]
(let [{:keys [perms profile-id expires-at]} (some->> (get request ::http.auth/claims) (if (= :token type)
(get-token-data pool))] (let [{:keys [perms profile-id expires-at]} (some->> claims (get-token-data pool))]
;; FIXME: revisit this, this data looks unused
(handler (cond-> request (handler (cond-> request
(some? perms) (some? perms)
(assoc ::perms perms) (assoc ::perms perms)
@@ -51,7 +52,7 @@
(some? expires-at) (some? expires-at)
(assoc ::expires-at expires-at)))) (assoc ::expires-at expires-at))))
(handler request)))) (handler request)))))
(def authz (def authz
{:name ::authz {:name ::authz

View File

@@ -9,8 +9,7 @@
(:require (:require
[app.common.schema :as sm] [app.common.schema :as sm]
[integrant.core :as ig] [integrant.core :as ig]
[java-http-clj.core :as http] [java-http-clj.core :as http])
[promesa.core :as p])
(:import (:import
java.net.http.HttpClient)) java.net.http.HttpClient))
@@ -29,14 +28,9 @@
(defn send! (defn send!
([client req] (send! client req {})) ([client req] (send! client req {}))
([client req {:keys [response-type sync?] :or {response-type :string sync? false}}] ([client req {:keys [response-type] :or {response-type :string}}]
(assert (client? client) "expected valid http client") (assert (client? client) "expected valid http client")
(if sync? (http/send req {:client client :as response-type})))
(http/send req {:client client :as response-type})
(try
(http/send-async req {:client client :as response-type})
(catch Throwable cause
(p/rejected cause))))))
(defn- resolve-client (defn- resolve-client
[params] [params]
@@ -56,8 +50,8 @@
([cfg-or-client request] ([cfg-or-client request]
(let [client (resolve-client cfg-or-client) (let [client (resolve-client cfg-or-client)
request (update request :uri str)] request (update request :uri str)]
(send! client request {:sync? true}))) (send! client request {})))
([cfg-or-client request options] ([cfg-or-client request options]
(let [client (resolve-client cfg-or-client) (let [client (resolve-client cfg-or-client)
request (update request :uri str)] request (update request :uri str)]
(send! client request (merge {:sync? true} options))))) (send! client request options))))

View File

@@ -23,7 +23,7 @@
(defn request->context (defn request->context
"Extracts error report relevant context data from request." "Extracts error report relevant context data from request."
[request] [request]
(let [claims (some-> (get request ::auth/claims) deref)] (let [{:keys [claims] :as auth} (get request ::http/auth-data)]
(-> (cf/logging-context) (-> (cf/logging-context)
(assoc :request/path (:path request)) (assoc :request/path (:path request))
(assoc :request/method (:method request)) (assoc :request/method (:method request))
@@ -31,6 +31,7 @@
(assoc :request/user-agent (yreq/get-header request "user-agent")) (assoc :request/user-agent (yreq/get-header request "user-agent"))
(assoc :request/ip-addr (inet/parse-request request)) (assoc :request/ip-addr (inet/parse-request request))
(assoc :request/profile-id (get claims :uid)) (assoc :request/profile-id (get claims :uid))
(assoc :request/auth-data auth)
(assoc :version/frontend (or (yreq/get-header request "x-frontend-version") "unknown"))))) (assoc :version/frontend (or (yreq/get-header request "x-frontend-version") "unknown")))))
(defmulti handle-error (defmulti handle-error
@@ -59,7 +60,6 @@
::yres/body data} ::yres/body data}
(binding [l/*context* (request->context request)] (binding [l/*context* (request->context request)]
(l/wrn :hint "restriction error" :cause err)
{::yres/status 400 {::yres/status 400
::yres/body data})))) ::yres/body data}))))

View File

@@ -12,7 +12,7 @@
[app.common.schema :as-alias sm] [app.common.schema :as-alias sm]
[app.common.transit :as t] [app.common.transit :as t]
[app.config :as cf] [app.config :as cf]
[app.http.auth :as-alias auth] [app.http :as-alias http]
[app.http.errors :as errors] [app.http.errors :as errors]
[app.util.pointer-map :as pmap] [app.util.pointer-map :as pmap]
[cuerdas.core :as str] [cuerdas.core :as str]
@@ -242,6 +242,7 @@
(handler request) (handler request)
{::yres/status 405}))))))}) {::yres/status 405}))))))})
(defn- wrap-auth (defn- wrap-auth
[handler decoders] [handler decoders]
(let [token-re (let [token-re
@@ -252,30 +253,28 @@
(when-let [[_ token-type token] (some->> (yreq/get-header request "authorization") (when-let [[_ token-type token] (some->> (yreq/get-header request "authorization")
(re-matches token-re))] (re-matches token-re))]
(if (= "token" (str/lower token-type)) (if (= "token" (str/lower token-type))
[:token token] {:type :token
[:bearer token]))) :token token}
{:type :bearer
:token token})))
get-token-from-cookie get-token-from-cookie
(fn [request] (fn [request]
(let [cname (cf/get :auth-token-cookie-name) (let [cname (cf/get :auth-token-cookie-name)
token (some-> (yreq/get-cookie request cname) :value)] token (some-> (yreq/get-cookie request cname) :value)]
(when-not (str/empty? token) (when-not (str/empty? token)
[:cookie token]))) {:type :cookie
:token token})))
get-token get-token
(some-fn get-token-from-cookie get-token-from-authorization) (some-fn get-token-from-cookie get-token-from-authorization)
process-request process-request
(fn [request] (fn [request]
(if-let [[token-type token] (get-token request)] (if-let [{:keys [type token] :as auth} (get-token request)]
(let [request (-> request (if-let [decode-fn (get decoders type)]
(assoc ::auth/token token) (assoc request ::http/auth-data (assoc auth :claims (decode-fn token)))
(assoc ::auth/token-type token-type)) (assoc request ::http/auth-data auth))
decoder (get decoders token-type)]
(if (fn? decoder)
(assoc request ::auth/claims (delay (decoder token)))
request))
request))] request))]
(fn [request] (fn [request]

View File

@@ -11,17 +11,19 @@
[app.common.logging :as l] [app.common.logging :as l]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.time :as ct] [app.common.time :as ct]
[app.common.uuid :as uuid]
[app.config :as cf] [app.config :as cf]
[app.db :as db] [app.db :as db]
[app.db.sql :as sql] [app.db.sql :as sql]
[app.http :as-alias http]
[app.http.auth :as-alias http.auth] [app.http.auth :as-alias http.auth]
[app.http.session.tasks :as-alias tasks] [app.http.session.tasks :as-alias tasks]
[app.main :as-alias main] [app.main :as-alias main]
[app.setup :as-alias setup] [app.setup :as-alias setup]
[app.tokens :as tokens] [app.tokens :as tokens]
[cuerdas.core :as str]
[integrant.core :as ig] [integrant.core :as ig]
[yetti.request :as yreq])) [yetti.request :as yreq]
[yetti.response :as yres]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; DEFAULTS ;; DEFAULTS
@@ -38,10 +40,10 @@
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defprotocol ISessionManager (defprotocol ISessionManager
(read [_ key]) (read-session [_ id])
(write! [_ key data]) (create-session [_ params])
(update! [_ data]) (update-session [_ session])
(delete! [_ key])) (delete-session [_ id]))
(defn manager? (defn manager?
[o] [o]
@@ -56,71 +58,82 @@
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private schema:params (def ^:private schema:params
[:map {:title "session-params"} [:map {:title "SessionParams" :closed true}
[:user-agent ::sm/text]
[:profile-id ::sm/uuid] [:profile-id ::sm/uuid]
[:created-at ::ct/inst]]) [:user-agent {:optional true} ::sm/text]
[:sso-provider-id {:optional true} ::sm/uuid]
[:sso-session-id {:optional true} :string]])
(def ^:private valid-params? (def ^:private valid-params?
(sm/validator schema:params)) (sm/validator schema:params))
(defn- prepare-session-params
[params key]
(assert (string? key) "expected key to be a string")
(assert (not (str/blank? key)) "expected key to be not empty")
(assert (valid-params? params) "expected valid params")
{:user-agent (:user-agent params)
:profile-id (:profile-id params)
:created-at (:created-at params)
:updated-at (:created-at params)
:id key})
(defn- database-manager (defn- database-manager
[pool] [pool]
(reify ISessionManager (reify ISessionManager
(read [_ token] (read-session [_ id]
(db/exec-one! pool (sql/select :http-session {:id token}))) (if (string? id)
;; Backward compatibility
(let [session (db/exec-one! pool (sql/select :http-session {:id id}))]
(-> session
(assoc :modified-at (:updated-at session))
(dissoc :updated-at)))
(db/exec-one! pool (sql/select :http-session-v2 {:id id}))))
(write! [_ key params] (create-session [_ params]
(let [params (-> params (assert (valid-params? params) "expect valid session params")
(assoc :created-at (ct/now))
(prepare-session-params key))]
(db/insert! pool :http-session params)
params))
(update! [_ params] (let [now (ct/now)
(let [updated-at (ct/now)] params (-> params
(db/update! pool :http-session (assoc :id (uuid/next))
{:updated-at updated-at} (assoc :created-at now)
{:id (:id params)}) (assoc :modified-at now))]
(assoc params :updated-at updated-at))) (db/insert! pool :http-session-v2 params
{::db/return-keys true})))
(delete! [_ token] (update-session [_ session]
(db/delete! pool :http-session {:id token}) (let [modified-at (ct/now)]
(if (string? (:id session))
(let [params (-> session
(assoc :id (uuid/next))
(assoc :created-at modified-at)
(assoc :modified-at modified-at))]
(db/insert! pool :http-session-v2 params))
(db/update! pool :http-session-v2
{:modified-at modified-at}
{:id (:id session)}))))
(delete-session [_ id]
(if (string? id)
(db/delete! pool :http-session {:id id} {::db/return-keys false})
(db/delete! pool :http-session-v2 {:id id} {::db/return-keys false}))
nil))) nil)))
(defn inmemory-manager (defn inmemory-manager
[] []
(let [cache (atom {})] (let [cache (atom {})]
(reify ISessionManager (reify ISessionManager
(read [_ token] (read-session [_ id]
(get @cache token)) (get @cache id))
(write! [_ key params] (create-session [_ params]
(let [params (-> params (assert (valid-params? params) "expect valid session params")
(assoc :created-at (ct/now))
(prepare-session-params key))]
(swap! cache assoc key params)
params))
(update! [_ params] (let [now (ct/now)
(let [updated-at (ct/now)] session (-> params
(swap! cache update (:id params) assoc :updated-at updated-at) (assoc :id (uuid/next))
(assoc params :updated-at updated-at))) (assoc :created-at now)
(assoc :modified-at now))]
(swap! cache assoc (:id session) session)
session))
(delete! [_ token] (update-session [_ session]
(swap! cache dissoc token) (let [modified-at (ct/now)]
(swap! cache update (:id session) assoc :modified-at modified-at)
(assoc session :modified-at modified-at)))
(delete-session [_ id]
(swap! cache dissoc id)
nil)))) nil))))
(defmethod ig/assert-key ::manager (defmethod ig/assert-key ::manager
@@ -140,43 +153,48 @@
;; MANAGER IMPL ;; MANAGER IMPL
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(declare ^:private assign-auth-token-cookie) (declare ^:private assign-session-cookie)
(declare ^:private clear-auth-token-cookie) (declare ^:private clear-session-cookie)
(declare ^:private gen-token)
(defn- assign-token
[cfg session]
(let [token (tokens/generate cfg
{:iss "authentication"
:aud "penpot"
:sid (:id session)
:iat (:modified-at session)
:uid (:profile-id session)
:sso-provider-id (:sso-provider-id session)
:sso-session-id (:sso-session-id session)})]
(assoc session :token token)))
(defn create-fn (defn create-fn
[{:keys [::manager] :as cfg} profile-id] [{:keys [::manager] :as cfg} {profile-id :id :as profile}
& {:keys [sso-provider-id sso-session-id]}]
(assert (manager? manager) "expected valid session manager") (assert (manager? manager) "expected valid session manager")
(assert (uuid? profile-id) "expected valid uuid for profile-id") (assert (uuid? profile-id) "expected valid uuid for profile-id")
(fn [request response] (fn [request response]
(let [uagent (yreq/get-header request "user-agent") (let [uagent (yreq/get-header request "user-agent")
params {:profile-id profile-id session (->> {:user-agent uagent
:user-agent uagent} :profile-id profile-id
token (gen-token cfg params) :sso-provider-id sso-provider-id
session (write! manager token params)] :sso-session-id sso-session-id}
(l/trc :hint "create" :profile-id (str profile-id)) (d/without-nils)
(-> response (create-session manager)
(assign-auth-token-cookie session))))) (assign-token cfg))]
(l/trc :hint "create" :id (str (:id session)) :profile-id (str profile-id))
(assign-session-cookie response session))))
(defn delete-fn (defn delete-fn
[{:keys [::manager]}] [{:keys [::manager]}]
(assert (manager? manager) "expected valid session manager") (assert (manager? manager) "expected valid session manager")
(fn [request response] (fn [request response]
(let [cname (cf/get :auth-token-cookie-name) (some->> (get request ::id) (delete-session manager))
cookie (yreq/get-cookie request cname)] (clear-session-cookie response)))
(l/trc :hint "delete" :profile-id (:profile-id request))
(some->> (:value cookie) (delete! manager))
(-> response
(assoc :status 204)
(assoc :body nil)
(clear-auth-token-cookie)))))
(defn- gen-token
[cfg {:keys [profile-id created-at]}]
(tokens/generate cfg {:iss "authentication"
:iat created-at
:uid profile-id}))
(defn decode-token (defn decode-token
[cfg token] [cfg token]
(try (try
@@ -186,44 +204,63 @@
:token token :token token
:cause cause)))) :cause cause))))
(defn get-session
[request]
(get request ::session))
(defn invalidate-others
[cfg session]
(let [sql "delete from http_session_v2 where profile_id = ? and id != ?"]
(-> (db/exec-one! cfg [sql (:profile-id session) (:id session)])
(db/get-update-count))))
(defn- renew-session? (defn- renew-session?
[{:keys [updated-at] :as session}] [{:keys [id modified-at] :as session}]
(and (ct/inst? updated-at) (or (string? id)
(let [elapsed (ct/diff updated-at (ct/now))] (and (ct/inst? modified-at)
(neg? (compare default-renewal-max-age elapsed))))) (let [elapsed (ct/diff modified-at (ct/now))]
(neg? (compare default-renewal-max-age elapsed))))))
(defn- wrap-authz (defn- wrap-authz
[handler {:keys [::manager] :as cfg}] [handler {:keys [::manager] :as cfg}]
(assert (manager? manager) "expected valid session manager") (assert (manager? manager) "expected valid session manager")
(fn [{:keys [::http.auth/token-type] :as request}] (fn [request]
(let [{:keys [type token claims]} (get request ::http/auth-data)]
(cond (cond
(= token-type :cookie) (= type :cookie)
(let [session (some->> (get request ::http.auth/token) (let [session (if-let [sid (:sid claims)]
(read manager)) (read-session manager sid)
;; BACKWARD COMPATIBILITY WITH OLD TOKENS
(read-session manager token))
request (cond-> request request (cond-> request
(some? session) (some? session)
(-> (assoc ::profile-id (:profile-id session)) (-> (assoc ::profile-id (:profile-id session))
(assoc ::id (:id session)))) (assoc ::session session)))
response (handler request)] response (handler request)]
(if (renew-session? session) (if (renew-session? session)
(let [session (update! manager session)] (let [session (->> session
(-> response (update-session manager)
(assign-auth-token-cookie session))) (assign-token cfg))]
(assign-session-cookie response session))
response)) response))
(= token-type :bearer) (= type :bearer)
(let [session (some->> (get request ::http.auth/token) (let [session (if-let [sid (:sid claims)]
(read manager)) (read-session manager sid)
;; BACKWARD COMPATIBILITY WITH OLD TOKENS
(read-session manager token))
request (cond-> request request (cond-> request
(some? session) (some? session)
(-> (assoc ::profile-id (:profile-id session)) (-> (assoc ::profile-id (:profile-id session))
(assoc ::id (:id session))))] (assoc ::session session)))]
(handler request)) (handler request))
:else :else
(handler request)))) (handler request)))))
(def authz (def authz
{:name ::authz {:name ::authz
@@ -231,10 +268,10 @@
;; --- IMPL ;; --- IMPL
(defn- assign-auth-token-cookie (defn- assign-session-cookie
[response {token :id updated-at :updated-at}] [response {token :token modified-at :modified-at}]
(let [max-age (cf/get :auth-token-cookie-max-age default-cookie-max-age) (let [max-age (cf/get :auth-token-cookie-max-age default-cookie-max-age)
created-at updated-at created-at modified-at
renewal (ct/plus created-at default-renewal-max-age) renewal (ct/plus created-at default-renewal-max-age)
expires (ct/plus created-at max-age) expires (ct/plus created-at max-age)
secure? (contains? cf/flags :secure-session-cookies) secure? (contains? cf/flags :secure-session-cookies)
@@ -249,12 +286,12 @@
:comment comment :comment comment
:same-site (if cors? :none (if strict? :strict :lax)) :same-site (if cors? :none (if strict? :strict :lax))
:secure secure?}] :secure secure?}]
(update response :cookies assoc name cookie))) (update response ::yres/cookies assoc name cookie)))
(defn- clear-auth-token-cookie (defn- clear-session-cookie
[response] [response]
(let [cname (cf/get :auth-token-cookie-name)] (let [cname (cf/get :auth-token-cookie-name)]
(update response :cookies assoc cname {:path "/" :value "" :max-age 0}))) (update response ::yres/cookies assoc cname {:path "/" :value "" :max-age 0})))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; TASK: SESSION GC ;; TASK: SESSION GC

View File

@@ -25,7 +25,8 @@
[app.util.inet :as inet] [app.util.inet :as inet]
[app.util.services :as-alias sv] [app.util.services :as-alias sv]
[app.worker :as wrk] [app.worker :as wrk]
[cuerdas.core :as str])) [cuerdas.core :as str]
[yetti.request :as yreq]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS ;; HELPERS
@@ -90,6 +91,22 @@
::ip-addr (::rpc/ip-addr params) ::ip-addr (::rpc/ip-addr params)
::context (d/without-nils context)})) ::context (d/without-nils context)}))
(defn get-external-session-id
[request]
(when-let [session-id (yreq/get-header request "x-external-session-id")]
(when-not (or (> (count session-id) 256)
(= session-id "null")
(str/blank? session-id))
session-id)))
(defn- get-external-event-origin
[request]
(when-let [origin (yreq/get-header request "x-event-origin")]
(when-not (or (> (count origin) 256)
(= origin "null")
(str/blank? origin))
origin)))
;; --- SPECS ;; --- SPECS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -126,8 +143,6 @@
(::rpc/profile-id params) (::rpc/profile-id params)
uuid/zero) uuid/zero)
session-id (get params ::rpc/external-session-id)
event-origin (get params ::rpc/external-event-origin)
props (-> (or (::replace-props resultm) props (-> (or (::replace-props resultm)
(-> params (-> params
(merge (::props resultm)) (merge (::props resultm))
@@ -138,8 +153,10 @@
token-id (::actoken/id request) token-id (::actoken/id request)
context (-> (::context resultm) context (-> (::context resultm)
(assoc :external-session-id session-id) (assoc :external-session-id
(assoc :external-event-origin event-origin) (get-external-session-id request))
(assoc :external-event-origin
(get-external-event-origin request))
(assoc :access-token-id (some-> token-id str)) (assoc :access-token-id (some-> token-id str))
(d/without-nils)) (d/without-nils))

View File

@@ -259,14 +259,17 @@
::oidc.providers/generic ::oidc.providers/generic
{::http.client/client (ig/ref ::http.client/client)} {::http.client/client (ig/ref ::http.client/client)}
::oidc/providers
[(ig/ref ::oidc.providers/google)
(ig/ref ::oidc.providers/github)
(ig/ref ::oidc.providers/gitlab)
(ig/ref ::oidc.providers/generic)]
::oidc/routes ::oidc/routes
{::http.client/client (ig/ref ::http.client/client) {::http.client/client (ig/ref ::http.client/client)
::db/pool (ig/ref ::db/pool) ::db/pool (ig/ref ::db/pool)
::setup/props (ig/ref ::setup/props) ::setup/props (ig/ref ::setup/props)
::oidc/providers {:google (ig/ref ::oidc.providers/google) ::oidc/providers (ig/ref ::oidc/providers)
:github (ig/ref ::oidc.providers/github)
:gitlab (ig/ref ::oidc.providers/gitlab)
:oidc (ig/ref ::oidc.providers/generic)}
::session/manager (ig/ref ::session/manager) ::session/manager (ig/ref ::session/manager)
::email/blacklist (ig/ref ::email/blacklist) ::email/blacklist (ig/ref ::email/blacklist)
::email/whitelist (ig/ref ::email/whitelist)} ::email/whitelist (ig/ref ::email/whitelist)}
@@ -298,6 +301,7 @@
{::db/pool (ig/ref ::db/pool) {::db/pool (ig/ref ::db/pool)
::mtx/metrics (ig/ref ::mtx/metrics) ::mtx/metrics (ig/ref ::mtx/metrics)
::mbus/msgbus (ig/ref ::mbus/msgbus) ::mbus/msgbus (ig/ref ::mbus/msgbus)
::setup/props (ig/ref ::setup/props)
::session/manager (ig/ref ::session/manager)} ::session/manager (ig/ref ::session/manager)}
:app.http.assets/routes :app.http.assets/routes

View File

@@ -17,6 +17,7 @@
[app.common.time :as ct] [app.common.time :as ct]
[app.config :as cf] [app.config :as cf]
[app.db :as-alias db] [app.db :as-alias db]
[app.http.client :as http]
[app.storage :as-alias sto] [app.storage :as-alias sto]
[app.storage.tmp :as tmp] [app.storage.tmp :as tmp]
[buddy.core.bytes :as bb] [buddy.core.bytes :as bb]
@@ -37,6 +38,9 @@
org.im4java.core.IMOperation org.im4java.core.IMOperation
org.im4java.core.Info)) org.im4java.core.Info))
(def default-max-file-size
(* 1024 1024 10)) ; 10 MiB
(def schema:upload (def schema:upload
[:map {:title "Upload"} [:map {:title "Upload"}
[:filename :string] [:filename :string]
@@ -241,7 +245,7 @@
(ex/raise :type :validation (ex/raise :type :validation
:code :invalid-svg-file :code :invalid-svg-file
:hint "uploaded svg does not provides dimensions")) :hint "uploaded svg does not provides dimensions"))
(merge input info {:ts (ct/now)})) (merge input info {:ts (ct/now) :size (fs/size path)}))
(let [instance (Info. (str path)) (let [instance (Info. (str path))
mtype' (.getProperty instance "Mime type")] mtype' (.getProperty instance "Mime type")]
@@ -261,6 +265,7 @@
(assoc input (assoc input
:width width :width width
:height height :height height
:size (fs/size path)
:ts (ct/now))))))) :ts (ct/now)))))))
(defmethod process-error org.im4java.core.InfoException (defmethod process-error org.im4java.core.InfoException
@@ -270,6 +275,54 @@
:hint "invalid image" :hint "invalid image"
:cause error)) :cause error))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; IMAGE HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn download-image
"Download an image from the provided URI and return the media input object"
[{:keys [::http/client]} uri]
(letfn [(parse-and-validate [{:keys [headers] :as response}]
(let [size (some-> (get headers "content-length") d/parse-integer)
mtype (get headers "content-type")
format (cm/mtype->format mtype)
max-size (cf/get :media-max-file-size default-max-file-size)]
(when-not size
(ex/raise :type :validation
:code :unknown-size
:hint "seems like the url points to resource with unknown size"))
(when (> size max-size)
(ex/raise :type :validation
:code :file-too-large
:hint (str/ffmt "the file size % is greater than the maximum %"
size
default-max-file-size)))
(when (nil? format)
(ex/raise :type :validation
:code :media-type-not-allowed
:hint "seems like the url points to an invalid media object"))
{:size size :mtype mtype :format format}))]
(let [{:keys [body] :as response} (http/req! client
{:method :get :uri uri}
{:response-type :input-stream})
{:keys [size mtype]} (parse-and-validate response)
path (tmp/tempfile :prefix "penpot.media.download.")
written (io/write* path body :size size)]
(when (not= written size)
(ex/raise :type :internal
:code :mismatch-write-size
:hint "unexpected state: unable to write to file"))
{;; :size size
:path path
:mtype mtype})))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; FONTS ;; FONTS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@@ -450,7 +450,13 @@
:fn (mg/resource "app/migrations/sql/0141-add-idx-to-file-library-rel.sql")} :fn (mg/resource "app/migrations/sql/0141-add-idx-to-file-library-rel.sql")}
{:name "0141-add-file-data-table.sql" {:name "0141-add-file-data-table.sql"
:fn (mg/resource "app/migrations/sql/0141-add-file-data-table.sql")}]) :fn (mg/resource "app/migrations/sql/0141-add-file-data-table.sql")}
{:name "0142-add-sso-provider-table"
:fn (mg/resource "app/migrations/sql/0142-add-sso-provider-table.sql")}
{:name "0143-http-session-v2-table"
:fn (mg/resource "app/migrations/sql/0143-add-http-session-v2-table.sql")}])
(defn apply-migrations! (defn apply-migrations!
[pool name migrations] [pool name migrations]

View File

@@ -0,0 +1,33 @@
CREATE TABLE sso_provider (
id uuid PRIMARY KEY,
created_at timestamptz NOT NULL DEFAULT now(),
modified_at timestamptz NOT NULL DEFAULT now(),
is_enabled boolean NOT NULL DEFAULT true,
type text NOT NULL CHECK (type IN ('oidc')),
domain text NOT NULL,
client_id text NOT NULL,
client_secret text NOT NULL,
base_uri text NOT NULL,
token_uri text NULL,
auth_uri text NULL,
user_uri text NULL,
jwks_uri text NULL,
logout_uri text NULL,
roles_attr text NULL,
email_attr text NULL,
name_attr text NULL,
user_info_source text NOT NULL DEFAULT 'token'
CHECK (user_info_source IN ('token', 'userinfo', 'auto')),
scopes text[] NULL,
roles text[] NULL
);
CREATE UNIQUE INDEX sso_provider__domain__idx
ON sso_provider(domain);

View File

@@ -0,0 +1,23 @@
CREATE TABLE http_session_v2 (
id uuid PRIMARY KEY,
created_at timestamptz NOT NULL DEFAULT now(),
modified_at timestamptz NOT NULL DEFAULT now(),
profile_id uuid REFERENCES profile(id) ON DELETE CASCADE,
user_agent text NULL,
sso_provider_id uuid NULL REFERENCES sso_provider(id) ON DELETE CASCADE,
sso_session_id text NULL
);
CREATE INDEX http_session_v2__profile_id__idx
ON http_session_v2(profile_id);
CREATE INDEX http_session_v2__sso_provider_id__idx
ON http_session_v2(sso_provider_id)
WHERE sso_provider_id IS NOT NULL;
CREATE INDEX http_session_v2__sso_session_id__idx
ON http_session_v2(sso_session_id)
WHERE sso_session_id IS NOT NULL;

View File

@@ -68,33 +68,21 @@
response (if (fn? result) response (if (fn? result)
(result request) (result request)
(let [result (rph/unwrap result) (let [result (rph/unwrap result)
status (::http/status mdata 200) status (or (::http/status mdata)
(if (nil? result)
204
200))
headers (cond-> (::http/headers mdata {}) headers (cond-> (::http/headers mdata {})
(yres/stream-body? result) (yres/stream-body? result)
(assoc "content-type" "application/octet-stream"))] (assoc "content-type" "application/octet-stream"))]
{::yres/status status {::yres/status status
::yres/headers headers ::yres/headers headers
::yres/body result}))] ::yres/body result}))]
(-> response (-> response
(handle-response-transformation request mdata) (handle-response-transformation request mdata)
(handle-before-comple-hook mdata)))) (handle-before-comple-hook mdata))))
(defn get-external-session-id
[request]
(when-let [session-id (yreq/get-header request "x-external-session-id")]
(when-not (or (> (count session-id) 256)
(= session-id "null")
(str/blank? session-id))
session-id)))
(defn- get-external-event-origin
[request]
(when-let [origin (yreq/get-header request "x-event-origin")]
(when-not (or (> (count origin) 256)
(= origin "null")
(str/blank? origin))
origin)))
(defn- make-rpc-handler (defn- make-rpc-handler
"Ring handler that dispatches cmd requests and convert between "Ring handler that dispatches cmd requests and convert between
internal async flow into ring async flow." internal async flow into ring async flow."
@@ -105,23 +93,19 @@
etag (yreq/get-header request "if-none-match") etag (yreq/get-header request "if-none-match")
profile-id (or (::session/profile-id request) profile-id (or (::session/profile-id request)
(::actoken/profile-id request)) (::actoken/profile-id request))
ip-addr (inet/parse-request request) ip-addr (inet/parse-request request)
session-id (get-external-session-id request)
event-origin (get-external-event-origin request)
data (-> params data (-> params
(assoc ::handler-name handler-name) (assoc ::handler-name handler-name)
(assoc ::ip-addr ip-addr) (assoc ::ip-addr ip-addr)
(assoc ::request-at (ct/now)) (assoc ::request-at (ct/now))
(assoc ::external-session-id session-id)
(assoc ::external-event-origin event-origin)
(assoc ::session/id (::session/id request))
(assoc ::cond/key etag) (assoc ::cond/key etag)
(cond-> (uuid? profile-id) (cond-> (uuid? profile-id)
(assoc ::profile-id profile-id))) (assoc ::profile-id profile-id)))
data (vary-meta data assoc ::http/request request) data (with-meta data
{::http/request request})
handler-fn (get methods (keyword handler-name) default-handler)] handler-fn (get methods (keyword handler-name) default-handler)]
(when (and (or (= method :get) (when (and (or (= method :get)
@@ -367,7 +351,6 @@
(let [public-uri (cf/get :public-uri)] (let [public-uri (cf/get :public-uri)]
["/api" ["/api"
["/management" ["/management"
["/methods/:type" ["/methods/:type"
{:middleware [[mw/shared-key-auth (cf/get :management-api-shared-key)] {:middleware [[mw/shared-key-auth (cf/get :management-api-shared-key)]

View File

@@ -7,21 +7,24 @@
(ns app.rpc.commands.auth (ns app.rpc.commands.auth
(:require (:require
[app.auth :as auth] [app.auth :as auth]
[app.auth.oidc :as oidc]
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.features :as cfeat] [app.common.features :as cfeat]
[app.common.logging :as l] [app.common.logging :as l]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.time :as ct] [app.common.time :as ct]
[app.common.uri :as u]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.config :as cf] [app.config :as cf]
[app.db :as db] [app.db :as db]
[app.email :as eml] [app.email :as eml]
[app.email.blacklist :as email.blacklist] [app.email.blacklist :as email.blacklist]
[app.email.whitelist :as email.whitelist] [app.email.whitelist :as email.whitelist]
[app.http :as-alias http]
[app.http.session :as session] [app.http.session :as session]
[app.loggers.audit :as audit] [app.loggers.audit :as audit]
[app.media :as media]
[app.rpc :as-alias rpc] [app.rpc :as-alias rpc]
[app.rpc.climit :as-alias climit] [app.rpc.climit :as-alias climit]
[app.rpc.commands.profile :as profile] [app.rpc.commands.profile :as profile]
@@ -30,6 +33,7 @@
[app.rpc.helpers :as rph] [app.rpc.helpers :as rph]
[app.setup :as-alias setup] [app.setup :as-alias setup]
[app.setup.welcome-file :refer [create-welcome-file]] [app.setup.welcome-file :refer [create-welcome-file]]
[app.storage :as sto]
[app.tokens :as tokens] [app.tokens :as tokens]
[app.util.services :as sv] [app.util.services :as sv]
[app.worker :as wrk] [app.worker :as wrk]
@@ -109,7 +113,7 @@
(assoc profile :is-admin (let [admins (cf/get :admins)] (assoc profile :is-admin (let [admins (cf/get :admins)]
(contains? admins (:email profile)))))] (contains? admins (:email profile)))))]
(-> response (-> response
(rph/with-transform (session/create-fn cfg (:id profile))) (rph/with-transform (session/create-fn cfg profile))
(rph/with-meta {::audit/props (audit/profile->props profile) (rph/with-meta {::audit/props (audit/profile->props profile)
::audit/profile-id (:id profile)}))))] ::audit/profile-id (:id profile)}))))]
@@ -145,7 +149,24 @@
[cfg params] [cfg params]
(if (= (:profile-id params) (if (= (:profile-id params)
(::rpc/profile-id params)) (::rpc/profile-id params))
(rph/with-transform {} (session/delete-fn cfg)) (let [{:keys [claims]}
(rph/get-auth-data params)
provider
(some->> (get claims :sso-provider-id)
(oidc/get-provider cfg))
response
(if (and provider (:logout-uri provider))
(let [params {"logout_hint" (get claims :sso-session-id)
"client_id" (get provider :client-id)
"post_logout_redirect_uri" (str (cf/get :public-uri))}
uri (-> (u/uri (:logout-uri provider))
(assoc :query (u/map->query-string params)))]
{:redirect-uri uri})
{})]
(rph/with-transform response (session/delete-fn cfg)))
{})) {}))
;; ---- COMMAND: Recover Profile ;; ---- COMMAND: Recover Profile
@@ -271,11 +292,29 @@
;; ---- COMMAND: Register Profile ;; ---- COMMAND: Register Profile
(defn create-profile! (defn import-profile-picture
[cfg uri]
(try
(let [storage (sto/resolve cfg)
input (media/download-image cfg uri)
input (media/run {:cmd :info :input input})
hash (sto/calculate-hash (:path input))
content (-> (sto/content (:path input) (:size input))
(sto/wrap-with-hash hash))
sobject (sto/put-object! storage {::sto/content content
::sto/deduplicate? true
:bucket "profile"
:content-type (:mtype input)})]
(:id sobject))
(catch Throwable cause
(l/err :hint "unable to import profile picture"
:cause cause)
nil)))
(defn create-profile
"Create the profile entry on the database with limited set of input "Create the profile entry on the database with limited set of input
attrs (all the other attrs are filled with default values)." attrs (all the other attrs are filled with default values)."
[conn {:keys [email] :as params}] [{:keys [::db/conn] :as cfg} {:keys [email] :as params}]
(dm/assert! ::sm/email email)
(let [id (or (:id params) (uuid/next)) (let [id (or (:id params) (uuid/next))
props (-> (audit/extract-utm-params params) props (-> (audit/extract-utm-params params)
(merge (:props params)) (merge (:props params))
@@ -283,8 +322,7 @@
:viewed-walkthrough? false :viewed-walkthrough? false
:nudge {:big 10 :small 1} :nudge {:big 10 :small 1}
:v2-info-shown true :v2-info-shown true
:release-notes-viewed (:main cf/version)}) :release-notes-viewed (:main cf/version)}))
(db/tjson))
password (or (:password params) "!") password (or (:password params) "!")
@@ -299,6 +337,12 @@
theme (:theme params nil) theme (:theme params nil)
email (str/lower email) email (str/lower email)
photo-id (some->> (or (:oidc/picture props)
(:google/picture props)
(:github/picture props)
(:gitlab/picture props))
(import-profile-picture cfg))
params {:id id params {:id id
:fullname (:fullname params) :fullname (:fullname params)
:email email :email email
@@ -306,11 +350,13 @@
:lang locale :lang locale
:password password :password password
:deleted-at (:deleted-at params) :deleted-at (:deleted-at params)
:props props :props (db/tjson props)
:theme theme :theme theme
:photo-id photo-id
:is-active is-active :is-active is-active
:is-muted is-muted :is-muted is-muted
:is-demo is-demo}] :is-demo is-demo}]
(try (try
(-> (db/insert! conn :profile params) (-> (db/insert! conn :profile params)
(profile/decode-row)) (profile/decode-row))
@@ -323,7 +369,7 @@
(throw cause)))))) (throw cause))))))
(defn create-profile-rels! (defn create-profile-rels
[conn {:keys [id] :as profile}] [conn {:keys [id] :as profile}]
(let [features (cfeat/get-enabled-features cf/flags) (let [features (cfeat/get-enabled-features cf/flags)
team (teams/create-team conn team (teams/create-team conn
@@ -373,12 +419,13 @@
;; to detect if the profile is already registered ;; to detect if the profile is already registered
(or (profile/get-profile-by-email conn (:email claims)) (or (profile/get-profile-by-email conn (:email claims))
(let [is-active (or (boolean (:is-active claims)) (let [is-active (or (boolean (:is-active claims))
(boolean (:email-verified claims))
(not (contains? cf/flags :email-verification))) (not (contains? cf/flags :email-verification)))
params (-> params params (-> params
(assoc :is-active is-active) (assoc :is-active is-active)
(update :password auth/derive-password)) (update :password auth/derive-password))
profile (->> (create-profile! conn params) profile (->> (create-profile cfg params)
(create-profile-rels! conn))] (create-profile-rels conn))]
(vary-meta profile assoc :created true)))) (vary-meta profile assoc :created true))))
created? (-> profile meta :created true?) created? (-> profile meta :created true?)
@@ -416,10 +463,10 @@
(and (some? invitation) (and (some? invitation)
(= (:email profile) (= (:email profile)
(:member-email invitation))) (:member-email invitation)))
(let [claims (assoc invitation :member-id (:id profile)) (let [invitation (assoc invitation :member-id (:id profile))
token (tokens/generate cfg claims)] token (tokens/generate cfg invitation)]
(-> {:invitation-token token} (-> {:invitation-token token}
(rph/with-transform (session/create-fn cfg (:id profile))) (rph/with-transform (session/create-fn cfg profile claims))
(rph/with-meta {::audit/replace-props props (rph/with-meta {::audit/replace-props props
::audit/context {:action "accept-invitation"} ::audit/context {:action "accept-invitation"}
::audit/profile-id (:id profile)}))) ::audit/profile-id (:id profile)})))
@@ -430,7 +477,7 @@
created? created?
(if (:is-active profile) (if (:is-active profile)
(-> (profile/strip-private-attrs profile) (-> (profile/strip-private-attrs profile)
(rph/with-transform (session/create-fn cfg (:id profile))) (rph/with-transform (session/create-fn cfg profile claims))
(rph/with-defer create-welcome-file-when-needed) (rph/with-defer create-welcome-file-when-needed)
(rph/with-meta (rph/with-meta
{::audit/replace-props props {::audit/replace-props props
@@ -559,4 +606,32 @@
[cfg params] [cfg params]
(db/tx-run! cfg request-profile-recovery params)) (db/tx-run! cfg request-profile-recovery params))
;; --- COMMAND: get-sso-config
(defn- extract-domain
"Extract the domain part from email"
[email]
(let [at (str/last-index-of email "@")]
(when (and (>= at 0)
(< at (dec (count email))))
(-> (subs email (inc at))
(str/trim)
(str/lower)))))
(def ^:private schema:get-sso-provider
[:map {:title "get-sso-config"}
[:email ::sm/email]])
(def ^:private schema:get-sso-provider-result
[:map {:title "SSOProvider"}
[:id ::sm/uuid]])
(sv/defmethod ::get-sso-provider
{::rpc/auth false
::doc/added "2.12"
::sm/params schema:get-sso-provider
::sm/result schema:get-sso-provider-result}
[cfg {:keys [email]}]
(when-let [domain (extract-domain email)]
(when-let [config (db/get* cfg :sso-provider {:domain domain})]
(select-keys config [:id]))))

View File

@@ -49,9 +49,9 @@
:deleted-at (ct/in-future (cf/get-deletion-delay)) :deleted-at (ct/in-future (cf/get-deletion-delay))
:password (derive-password password) :password (derive-password password)
:props {}} :props {}}
profile (db/tx-run! cfg (fn [{:keys [::db/conn]}] profile (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
(->> (auth/create-profile! conn params) (->> (auth/create-profile cfg params)
(auth/create-profile-rels! conn))))] (auth/create-profile-rels conn))))]
(with-meta {:email email (with-meta {:email email
:password password} :password password}
{::audit/profile-id (:id profile)}))) {::audit/profile-id (:id profile)})))

View File

@@ -66,12 +66,12 @@
:member-email (:email profile)) :member-email (:email profile))
token (tokens/generate cfg claims)] token (tokens/generate cfg claims)]
(-> {:invitation-token token} (-> {:invitation-token token}
(rph/with-transform (session/create-fn cfg (:id profile))) (rph/with-transform (session/create-fn cfg profile))
(rph/with-meta {::audit/props (:props profile) (rph/with-meta {::audit/props (:props profile)
::audit/profile-id (:id profile)}))) ::audit/profile-id (:id profile)})))
(-> (profile/strip-private-attrs profile) (-> (profile/strip-private-attrs profile)
(rph/with-transform (session/create-fn cfg (:id profile))) (rph/with-transform (session/create-fn cfg profile))
(rph/with-meta {::audit/props (:props profile) (rph/with-meta {::audit/props (:props profile)
::audit/profile-id (:id profile)})))))) ::audit/profile-id (:id profile)}))))))
@@ -83,6 +83,6 @@
(profile/clean-email) (profile/clean-email)
(profile/get-profile-by-email conn)) (profile/get-profile-by-email conn))
(->> (assoc info :is-active true :is-demo false) (->> (assoc info :is-active true :is-demo false)
(auth/create-profile! conn) (auth/create-profile cfg)
(auth/create-profile-rels! conn) (auth/create-profile-rels conn)
(profile/strip-private-attrs)))))) (profile/strip-private-attrs))))))

View File

@@ -7,14 +7,10 @@
(ns app.rpc.commands.media (ns app.rpc.commands.media
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.media :as cm]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.time :as ct] [app.common.time :as ct]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db] [app.db :as db]
[app.http.client :as http]
[app.loggers.audit :as-alias audit] [app.loggers.audit :as-alias audit]
[app.media :as media] [app.media :as media]
[app.rpc :as-alias rpc] [app.rpc :as-alias rpc]
@@ -22,13 +18,7 @@
[app.rpc.commands.files :as files] [app.rpc.commands.files :as files]
[app.rpc.doc :as-alias doc] [app.rpc.doc :as-alias doc]
[app.storage :as sto] [app.storage :as sto]
[app.storage.tmp :as tmp] [app.util.services :as sv]))
[app.util.services :as sv]
[cuerdas.core :as str]
[datoteka.io :as io]))
(def default-max-file-size
(* 1024 1024 10)) ; 10 MiB
(def thumbnail-options (def thumbnail-options
{:width 100 {:width 100
@@ -197,56 +187,12 @@
mobj)) mobj))
(defn download-image
[{:keys [::http/client]} uri]
(letfn [(parse-and-validate [{:keys [headers] :as response}]
(let [size (some-> (get headers "content-length") d/parse-integer)
mtype (get headers "content-type")
format (cm/mtype->format mtype)
max-size (cf/get :media-max-file-size default-max-file-size)]
(when-not size
(ex/raise :type :validation
:code :unknown-size
:hint "seems like the url points to resource with unknown size"))
(when (> size max-size)
(ex/raise :type :validation
:code :file-too-large
:hint (str/ffmt "the file size % is greater than the maximum %"
size
default-max-file-size)))
(when (nil? format)
(ex/raise :type :validation
:code :media-type-not-allowed
:hint "seems like the url points to an invalid media object"))
{:size size :mtype mtype :format format}))]
(let [{:keys [body] :as response} (http/req! client
{:method :get :uri uri}
{:response-type :input-stream :sync? true})
{:keys [size mtype]} (parse-and-validate response)
path (tmp/tempfile :prefix "penpot.media.download.")
written (io/write* path body :size size)]
(when (not= written size)
(ex/raise :type :internal
:code :mismatch-write-size
:hint "unexpected state: unable to write to file"))
{:filename "tempfile"
:size size
:path path
:mtype mtype})))
(defn- create-file-media-object-from-url (defn- create-file-media-object-from-url
[cfg {:keys [url name] :as params}] [cfg {:keys [url name] :as params}]
(let [content (download-image cfg url) (let [content (media/download-image cfg url)
params (-> params params (-> params
(assoc :content content) (assoc :content content)
(assoc :name (or name (:filename content))))] (assoc :name (d/nilv name "unknown")))]
;; NOTE: we use the climit here in a dynamic invocation because we ;; NOTE: we use the climit here in a dynamic invocation because we
;; don't want saturate the process-image limit with IO (download ;; don't want saturate the process-image limit with IO (download

View File

@@ -154,7 +154,6 @@
(declare validate-password!) (declare validate-password!)
(declare update-profile-password!) (declare update-profile-password!)
(declare invalidate-profile-session!)
(def ^:private (def ^:private
schema:update-profile-password schema:update-profile-password
@@ -169,8 +168,7 @@
::climit/id :auth/global ::climit/id :auth/global
::db/transaction true} ::db/transaction true}
[cfg {:keys [::rpc/profile-id password] :as params}] [cfg {:keys [::rpc/profile-id password] :as params}]
(let [profile (validate-password! cfg (assoc params :profile-id profile-id)) (let [profile (validate-password! cfg (assoc params :profile-id profile-id))]
session-id (::session/id params)]
(when (= (:email profile) (str/lower (:password params))) (when (= (:email profile) (str/lower (:password params)))
(ex/raise :type :validation (ex/raise :type :validation
@@ -178,14 +176,12 @@
:hint "you can't use your email as password")) :hint "you can't use your email as password"))
(update-profile-password! cfg (assoc profile :password password)) (update-profile-password! cfg (assoc profile :password password))
(invalidate-profile-session! cfg profile-id session-id)
nil))
(defn- invalidate-profile-session! (->> (rph/get-request params)
"Removes all sessions except the current one." (session/get-session)
[{:keys [::db/conn]} profile-id session-id] (session/invalidate-others cfg))
(let [sql "delete from http_session where profile_id = ? and id != ?"]
(:next.jdbc/update-count (db/exec-one! conn [sql profile-id session-id])))) nil))
(defn- validate-password! (defn- validate-password!
[{:keys [::db/conn] :as cfg} {:keys [profile-id old-password] :as params}] [{:keys [::db/conn] :as cfg} {:keys [profile-id old-password] :as params}]
@@ -284,9 +280,9 @@
:file-path (str (:path file)) :file-path (str (:path file))
:file-mtype (:mtype file)}})))) :file-mtype (:mtype file)}}))))
(defn- generate-thumbnail! (defn- generate-thumbnail
[_ file] [_ input]
(let [input (media/run {:cmd :info :input file}) (let [input (media/run {:cmd :info :input input})
thumb (media/run {:cmd :profile-thumbnail thumb (media/run {:cmd :profile-thumbnail
:format :jpeg :format :jpeg
:quality 85 :quality 85
@@ -307,7 +303,7 @@
(assoc ::climit/id [[:process-image/by-profile (:profile-id params)] (assoc ::climit/id [[:process-image/by-profile (:profile-id params)]
[:process-image/global]]) [:process-image/global]])
(assoc ::climit/label "upload-photo") (assoc ::climit/label "upload-photo")
(climit/invoke! generate-thumbnail! file))] (climit/invoke! generate-thumbnail file))]
(sto/put-object! storage params))) (sto/put-object! storage params)))
;; --- MUTATION: Request Email Change ;; --- MUTATION: Request Email Change

View File

@@ -73,7 +73,7 @@
{:id (:id profile)})) {:id (:id profile)}))
(-> claims (-> claims
(rph/with-transform (session/create-fn cfg profile-id)) (rph/with-transform (session/create-fn cfg profile))
(rph/with-meta {::audit/name "verify-profile-email" (rph/with-meta {::audit/name "verify-profile-email"
::audit/props (audit/profile->props profile) ::audit/props (audit/profile->props profile)
::audit/profile-id (:id profile)})))) ::audit/profile-id (:id profile)}))))

View File

@@ -83,3 +83,16 @@
"A convenience allias for yetti.response/stream-body" "A convenience allias for yetti.response/stream-body"
[f] [f]
(yres/stream-body f)) (yres/stream-body f))
(defn get-request
"Get http request from RPC params"
[params]
(assert (contains? params ::rpc/request-at) "rpc params required")
(-> (meta params)
(get ::http/request)))
(defn get-auth-data
"Get http auth-data from RPC params"
[params]
(-> (get-request params)
(get ::http/auth-data)))

View File

@@ -61,8 +61,8 @@
:is-active is-active :is-active is-active
:password password :password password
:props {}}] :props {}}]
(->> (cmd.auth/create-profile! conn params) (->> (cmd.auth/create-profile system params)
(cmd.auth/create-profile-rels! conn))))))) (cmd.auth/create-profile-rels conn)))))))
(defmethod exec-command "update-profile" (defmethod exec-command "update-profile"
[{:keys [fullname email password is-active]}] [{:keys [fullname email password is-active]}]

View File

@@ -25,6 +25,7 @@
[app.db.sql :as-alias sql] [app.db.sql :as-alias sql]
[app.features.fdata :as fdata] [app.features.fdata :as fdata]
[app.features.file-snapshots :as fsnap] [app.features.file-snapshots :as fsnap]
[app.http.session :as session]
[app.loggers.audit :as audit] [app.loggers.audit :as audit]
[app.main :as main] [app.main :as main]
[app.msgbus :as mbus] [app.msgbus :as mbus]
@@ -843,10 +844,33 @@
:deleted-at deleted-at :deleted-at deleted-at
:id id}))))))) :id id})))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SSO
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn add-sso-config
[& {:keys [base-uri client-id client-secret domain]}]
(assert (and (string? base-uri) (str/starts-with? base-uri "http")) "expected a valid base-uri")
(assert (string? client-id) "expected a valid client-id")
(assert (string? client-secret) "expected a valid client-secret")
(assert (string? domain) "expected a valid domain")
(db/insert! main/system :sso-provider
{:id (uuid/next)
:type "oidc"
:client-id client-id
:client-secret client-secret
:domain domain
:base-uri base-uri}))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; MISC ;; MISC
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn decode-session-token
[token]
(session/decode-token main/system token))
(defn instrument-var (defn instrument-var
[var] [var]
(alter-var-root var (fn [f] (alter-var-root var (fn [f]

View File

@@ -104,13 +104,8 @@
(assoc-in [:app.rpc/methods :app.setup/templates] templates) (assoc-in [:app.rpc/methods :app.setup/templates] templates)
(dissoc :app.srepl/server (dissoc :app.srepl/server
:app.http/server :app.http/server
:app.http/router :app.http/route
:app.auth.oidc.providers/google
:app.auth.oidc.providers/gitlab
:app.auth.oidc.providers/github
:app.auth.oidc.providers/generic
:app.setup/templates :app.setup/templates
:app.auth.oidc/routes
:app.http.oauth/handler :app.http.oauth/handler
:app.notifications/handler :app.notifications/handler
:app.loggers.mattermost/reporter :app.loggers.mattermost/reporter
@@ -182,10 +177,10 @@
:is-demo false} :is-demo false}
params)] params)]
(db/run! system (db/run! system
(fn [{:keys [::db/conn]}] (fn [{:keys [::db/conn] :as cfg}]
(->> params (->> params
(cmd.auth/create-profile! conn) (cmd.auth/create-profile cfg)
(cmd.auth/create-profile-rels! conn))))))) (cmd.auth/create-profile-rels conn)))))))
(defn create-project* (defn create-project*
([i params] (create-project* *system* i params)) ([i params] (create-project* *system* i params))

View File

@@ -22,17 +22,6 @@
(t/use-fixtures :once th/state-init) (t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset) (t/use-fixtures :each th/database-reset)
(t/deftest authenticate-method
(let [profile (th/create-profile* 1)
token (#'sess/gen-token th/*system* {:profile-id (:id profile)})
request {:params {:token token}}
response (#'mgmt/authenticate th/*system* request)]
(t/is (= 200 (::yres/status response)))
(t/is (= "authentication" (-> response ::yres/body :iss)))
(t/is (= (:id profile) (-> response ::yres/body :uid)))))
(t/deftest get-customer-method (t/deftest get-customer-method
(let [profile (th/create-profile* 1) (let [profile (th/create-profile* 1)
request {:params {:id (:id profile)}} request {:params {:id (:id profile)}}
@@ -89,7 +78,3 @@
(let [subs' (-> response ::yres/body :subscription)] (let [subs' (-> response ::yres/body :subscription)]
(t/is (= subs' subs)))))) (t/is (= subs' subs))))))

View File

@@ -8,8 +8,8 @@
(:require (:require
[app.common.time :as ct] [app.common.time :as ct]
[app.db :as db] [app.db :as db]
[app.http :as-alias http]
[app.http.access-token] [app.http.access-token]
[app.http.auth :as-alias auth]
[app.http.middleware :as mw] [app.http.middleware :as mw]
[app.http.session :as session] [app.http.session :as session]
[app.main :as-alias main] [app.main :as-alias main]
@@ -42,13 +42,14 @@
(handler (->DummyRequest {} {})) (handler (->DummyRequest {} {}))
(t/is (nil? (::auth/token-type @request))) (t/is (nil? (::http/auth-data @request)))
(t/is (nil? (::auth/token @request)))
(handler (->DummyRequest {"authorization" "Token aaaa"} {})) (handler (->DummyRequest {"authorization" "Token aaaa"} {}))
(t/is (= :token (::auth/token-type @request))) (let [{:keys [token claims] token-type :type} (get @request ::http/auth-data)]
(t/is (= "aaaa" (::auth/token @request))))) (t/is (= :token token-type))
(t/is (= "aaaa" token))
(t/is (nil? claims)))))
(t/deftest auth-middleware-2 (t/deftest auth-middleware-2
(let [request (volatile! nil) (let [request (volatile! nil)
@@ -57,16 +58,14 @@
{})] {})]
(handler (->DummyRequest {} {})) (handler (->DummyRequest {} {}))
(t/is (nil? (::http/auth-data @request)))
(t/is (nil? (::auth/token-type @request)))
(t/is (nil? (::auth/token @request)))
(t/is (nil? (::auth/claims @request)))
(handler (->DummyRequest {"authorization" "Bearer aaaa"} {})) (handler (->DummyRequest {"authorization" "Bearer aaaa"} {}))
(t/is (= :bearer (::auth/token-type @request))) (let [{:keys [token claims] token-type :type} (get @request ::http/auth-data)]
(t/is (= "aaaa" (::auth/token @request))) (t/is (= :bearer token-type))
(t/is (nil? (::auth/claims @request))))) (t/is (= "aaaa" token))
(t/is (nil? claims)))))
(t/deftest auth-middleware-3 (t/deftest auth-middleware-3
(let [request (volatile! nil) (let [request (volatile! nil)
@@ -75,35 +74,14 @@
{})] {})]
(handler (->DummyRequest {} {})) (handler (->DummyRequest {} {}))
(t/is (nil? (::http/auth-data @request)))
(t/is (nil? (::auth/token-type @request)))
(t/is (nil? (::auth/token @request)))
(t/is (nil? (::auth/claims @request)))
(handler (->DummyRequest {} {"auth-token" "foobar"})) (handler (->DummyRequest {} {"auth-token" "foobar"}))
(t/is (= :cookie (::auth/token-type @request))) (let [{:keys [token claims] token-type :type} (get @request ::http/auth-data)]
(t/is (= "foobar" (::auth/token @request))) (t/is (= :cookie token-type))
(t/is (nil? (::auth/claims @request))))) (t/is (= "foobar" token))
(t/is (nil? claims)))))
(t/deftest auth-middleware-4
(let [request (volatile! nil)
handler (#'app.http.middleware/wrap-auth
(fn [req] (vreset! request req))
{:cookie (fn [_] "foobaz")})]
(handler (->DummyRequest {} {}))
(t/is (nil? (::auth/token-type @request)))
(t/is (nil? (::auth/token @request)))
(t/is (nil? (::auth/claims @request)))
(handler (->DummyRequest {} {"auth-token" "foobar"}))
(t/is (= :cookie (::auth/token-type @request)))
(t/is (= "foobar" (::auth/token @request)))
(t/is (delay? (::auth/claims @request)))
(t/is (= "foobaz" (-> @request ::auth/claims deref)))))
(t/deftest shared-key-auth (t/deftest shared-key-auth
(let [handler (#'app.http.middleware/wrap-shared-key-auth (let [handler (#'app.http.middleware/wrap-shared-key-auth
@@ -122,40 +100,36 @@
(t/deftest access-token-authz (t/deftest access-token-authz
(let [profile (th/create-profile* 1) (let [profile (th/create-profile* 1)
token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token (:id profile) "test" nil) token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token (:id profile) "test" nil)
request (volatile! {}) handler (#'app.http.access-token/wrap-authz identity th/*system*)]
handler (#'app.http.access-token/wrap-authz (let [response (handler nil)]
(fn [req] (vreset! request req)) (t/is (nil? response)))
th/*system*)]
(handler nil) (let [response (handler {::http/auth-data {:type :token :token "foobar" :claims {:tid (:id token)}}})]
(t/is (nil? @request)) (t/is (= #{} (:app.http.access-token/perms response)))
(t/is (= (:id profile) (:app.http.access-token/profile-id response))))))
(handler {::auth/claims (delay {:tid (:id token)})
::auth/token-type :token})
(t/is (= #{} (:app.http.access-token/perms @request)))
(t/is (= (:id profile) (:app.http.access-token/profile-id @request)))))
(t/deftest session-authz (t/deftest session-authz
(let [manager (session/inmemory-manager) (let [cfg th/*system*
manager (session/inmemory-manager)
profile (th/create-profile* 1) profile (th/create-profile* 1)
handler (-> (fn [req] req) handler (-> (fn [req] req)
(#'session/wrap-authz {::session/manager manager}) (#'session/wrap-authz {::session/manager manager})
(#'mw/wrap-auth {}))] (#'mw/wrap-auth {:bearer (partial session/decode-token cfg)
:cookie (partial session/decode-token cfg)}))
session (->> (session/create-session manager {:profile-id (:id profile)
:user-agent "user agent"})
(#'session/assign-token cfg))
(let [response (handler (->DummyRequest {} {"auth-token" "foobar"}))] response (handler (->DummyRequest {} {"auth-token" (:token session)}))
(t/is (= :cookie (::auth/token-type response)))
(t/is (= "foobar" (::auth/token response))))
{:keys [token claims] token-type :type}
(get response ::http/auth-data)]
(session/write! manager "foobar" {:profile-id (:id profile) (t/is (= :cookie token-type))
:user-agent "user agent" (t/is (= (:token session) token))
:created-at (ct/now)}) (t/is (= "authentication" (:iss claims)))
(t/is (= "penpot" (:aud claims)))
(let [response (handler (->DummyRequest {} {"auth-token" "foobar"}))] (t/is (= (:id session) (:sid claims)))
(t/is (= :cookie (::auth/token-type response))) (t/is (= (:id profile) (:uid claims)))))
(t/is (= "foobar" (::auth/token response)))
(t/is (= (:id profile) (::session/profile-id response)))
(t/is (= "foobar" (::session/id response))))))

View File

@@ -33,7 +33,9 @@
:login-with-ldap :login-with-ldap
;; Uses any generic authentication provider that implements OIDC protocol as credentials. ;; Uses any generic authentication provider that implements OIDC protocol as credentials.
:login-with-oidc :login-with-oidc
;; Allows registration with Open ID ;; Enables custom SSO flow
:login-with-custom-sso
;; Allows registration with OIDC (takes effect only when general `registration` is disabled)
:oidc-registration :oidc-registration
;; This logs to console the invitation tokens. It's useful in case the SMTP is not configured. ;; This logs to console the invitation tokens. It's useful in case the SMTP is not configured.
:log-invitation-tokens}) :log-invitation-tokens})

View File

@@ -8,7 +8,6 @@
"Auth related data events" "Auth related data events"
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
@@ -148,9 +147,7 @@
(defn login-with-ldap (defn login-with-ldap
[params] [params]
(dm/assert! (assert (sm/check schema:login-with-ldap params))
"expected valid params"
(sm/check schema:login-with-ldap params))
(ptk/reify ::login-with-ldap (ptk/reify ::login-with-ldap
ptk/WatchEvent ptk/WatchEvent
@@ -166,6 +163,32 @@
(logged-in)))) (logged-in))))
(rx/catch on-error)))))) (rx/catch on-error))))))
(def ^:private schema:login-with-sso
[:map {:title "login-with-sso"}
[:provider [:or :string ::sm/uuid]]])
(defn login-with-sso
"Start the SSO flow"
[params]
(assert (sm/check schema:login-with-sso params))
(ptk/reify ::login-with-sso
ptk/WatchEvent
(watch [_ _ _]
(->> (rp/cmd! :login-with-oidc params)
(rx/map (fn [{:keys [redirect-uri] :as rsp}]
(if redirect-uri
(rt/nav-raw :uri redirect-uri)
(ex/raise :type :internal
:code :unexpected-response
:hint "unexpected response from OIDC method"
:resp (pr-str rsp)))))
(rx/catch (fn [cause]
(let [{:keys [type code] :as error} (ex-data cause)]
(if (and (= type :restriction)
(= code :provider-not-configured))
(rx/of (ntf/error (tr "errors.auth-provider-not-configured")))
(rx/throw cause)))))))))
(defn login-from-token (defn login-from-token
"Used mainly as flow continuation after token validation." "Used mainly as flow continuation after token validation."
[{:keys [profile] :as tdata}] [{:keys [profile] :as tdata}]
@@ -201,7 +224,7 @@
;; --- EVENT: logout ;; --- EVENT: logout
(defn logged-out (defn logged-out
[] [{:keys [redirect-uri]}]
(ptk/reify ::logged-out (ptk/reify ::logged-out
ptk/UpdateEvent ptk/UpdateEvent
(update [_ state] (update [_ state]
@@ -209,12 +232,16 @@
ptk/WatchEvent ptk/WatchEvent
(watch [_ _ _] (watch [_ _ _]
(if redirect-uri
(->> (rx/of (rt/nav-raw :uri (str redirect-uri)))
(rx/observe-on :async))
(rx/merge (rx/merge
;; NOTE: We need the `effect` of the current event to be ;; NOTE: We need the `effect` of the current event to be
;; executed before the redirect. ;; executed before the redirect.
(->> (rx/of (rt/nav :auth-login)) (->> (rx/of (rt/nav :auth-login))
(rx/observe-on :async)) (rx/observe-on :async))
(rx/of (ws/finalize)))) (rx/of (ws/finalize)))))
ptk/EffectEvent ptk/EffectEvent
(effect [_ _ _] (effect [_ _ _]
@@ -235,7 +262,7 @@
(rx/mapcat (fn [_] (rx/mapcat (fn [_]
(->> (rp/cmd! :logout {:profile-id profile-id}) (->> (rp/cmd! :logout {:profile-id profile-id})
(rx/delay-at-least 300) (rx/delay-at-least 300)
(rx/catch (constantly (rx/of 1)))))) (rx/catch (constantly (rx/of nil))))))
(rx/map logged-out)))))) (rx/map logged-out))))))
;; --- Update Profile ;; --- Update Profile
@@ -248,9 +275,7 @@
(defn request-profile-recovery (defn request-profile-recovery
[data] [data]
(dm/assert! (assert (sm/check schema:request-profile-recovery data))
"expected valid parameters"
(sm/check schema:request-profile-recovery data))
(ptk/reify ::request-profile-recovery (ptk/reify ::request-profile-recovery
ptk/WatchEvent ptk/WatchEvent
@@ -273,9 +298,7 @@
(defn recover-profile (defn recover-profile
[data] [data]
(dm/assert! (assert (sm/check schema:recover-profile data))
"expected valid arguments"
(sm/check schema:recover-profile data))
(ptk/reify ::recover-profile (ptk/reify ::recover-profile
ptk/WatchEvent ptk/WatchEvent

View File

@@ -171,9 +171,8 @@
(send! id params nil)) (send! id params nil))
(defmethod cmd! :login-with-oidc (defmethod cmd! :login-with-oidc
[_ {:keys [provider] :as params}] [_ params]
(let [uri (u/join cf/public-uri "api/auth/oauth/" (d/name provider)) (let [uri (u/join cf/public-uri "api/auth/oidc")]
params (dissoc params :provider)]
(->> (http/send! {:method :post (->> (http/send! {:method :post
:uri uri :uri uri
:credentials "include" :credentials "include"

View File

@@ -23,12 +23,11 @@
[app.main.ui.notifications.context-notification :refer [context-notification]] [app.main.ui.notifications.context-notification :refer [context-notification]]
[app.util.dom :as dom] [app.util.dom :as dom]
[app.util.i18n :refer [tr]] [app.util.i18n :refer [tr]]
[app.util.keyboard :as k]
[app.util.storage :as s] [app.util.storage :as s]
[beicon.v2.core :as rx] [beicon.v2.core :as rx]
[rumext.v2 :as mf])) [rumext.v2 :as mf]))
(def show-alt-login-buttons? (def ^:const show-sso-login-buttons?
(some (partial contains? cf/flags) (some (partial contains? cf/flags)
[:login-with-google [:login-with-google
:login-with-github :login-with-github
@@ -47,53 +46,36 @@
(st/emit! (da/create-demo-profile))) (st/emit! (da/create-demo-profile)))
(defn- store-login-redirect (defn- store-login-redirect
[save-login-redirect] []
(binding [s/*sync* true] (binding [s/*sync* true]
(if (some? save-login-redirect)
;; Save the current login raw uri for later redirect user back to ;; Save the current login raw uri for later redirect user back to
;; the same page, we need it to be synchronous because the user is ;; the same page, we need it to be synchronous because the user is
;; going to be redirected instantly to the oidc provider uri ;; going to be redirected instantly to the oidc provider uri
(swap! s/session assoc :login-redirect (rt/get-current-href)) (swap! s/session assoc :login-redirect (rt/get-current-href))))
;; Clean the login redirect
(swap! s/session dissoc :login-redirect))))
(defn- login-with-oidc (defn- clear-login-redirect
[event provider params] []
(dom/prevent-default event) (binding [s/*sync* true]
(swap! s/session dissoc :login-redirect)))
(store-login-redirect (:save-login-redirect params)) (defn- login-with-sso
[provider params]
;; FIXME: this code should be probably moved outside of the UI (let [params (assoc params :provider provider)]
(->> (rp/cmd! :login-with-oidc (assoc params :provider provider)) (st/emit! (da/login-with-sso params))))
(rx/subs! (fn [{:keys [redirect-uri] :as rsp}]
(if redirect-uri
(st/emit! (rt/nav-raw :uri redirect-uri))
(log/error :hint "unexpected response from OIDC method"
:resp (pr-str rsp))))
(fn [cause]
(let [{:keys [type code] :as error} (ex-data cause)]
(cond
(and (= type :restriction)
(= code :provider-not-configured))
(st/emit! (ntf/error (tr "errors.auth-provider-not-configured")))
:else
(st/emit! (ntf/error (tr "errors.generic")))))))))
(def ^:private schema:login-form (def ^:private schema:login-form
[:map {:title "LoginForm"} [:map {:title "LoginForm"}
[:email [::sm/email {:error/code "errors.invalid-email"}]] [:email [::sm/email {:error/code "errors.invalid-email"}]]
[:password [:string {:min 1}]] [:password {:optional true} [:string {:min 1}]]
[:invitation-token {:optional true} [:invitation-token {:optional true}
[:string {:min 1}]]]) [:string {:min 1}]]])
(mf/defc login-form (mf/defc login-form*
[{:keys [params on-success-callback on-recovery-request origin] :as props}] [{:keys [params handle-redirect on-success-callback on-recovery-request origin] :as props}]
(let [initial (mf/with-memo [params] params) (let [initial (mf/with-memo [params] params)
error (mf/use-state false) error (mf/use-state false)
form (fm/use-form :schema schema:login-form form (fm/use-form :schema schema:login-form
:initial initial) :initial initial)
on-error on-error
(fn [cause] (fn [cause]
(let [cause (ex-data cause)] (let [cause (ex-data cause)]
@@ -121,20 +103,41 @@
:else :else
(reset! error (tr "errors.generic"))))) (reset! error (tr "errors.generic")))))
show-password-field*
(mf/use-state #(not (contains? cf/flags :login-with-custom-sso)))
show-password-field?
(deref show-password-field*)
on-success on-success
(fn [data] (fn [data]
(when (fn? on-success-callback) (when (fn? on-success-callback)
(on-success-callback data))) (on-success-callback data)))
on-submit on-submit
(mf/use-callback (mf/use-fn
(mf/deps show-password-field? params)
(fn [form _event] (fn [form _event]
(store-login-redirect (:save-login-redirect params))
(reset! error nil) (reset! error nil)
(let [params (with-meta (:clean-data @form)
{:on-error on-error (let [data (:clean-data @form)]
:on-success on-success})] (if show-password-field?
(st/emit! (da/login params))))) (let [params (-> (merge params data)
(with-meta {:on-error on-error
:on-success on-success}))]
(st/emit! (da/login params)))
(let [params (merge params data)]
(->> (rp/cmd! :get-sso-provider {:email (:email params)})
(rx/map :id)
(rx/catch (fn [cause]
(log/error :hint "error on retrieving sso provider" :cause cause)
(rx/of nil)))
(rx/subs! (fn [sso-provider-id]
(if sso-provider-id
(let [params {:provider sso-provider-id}]
(st/emit! (da/login-with-sso params)))
(reset! show-password-field* true))))))))))
on-submit-ldap on-submit-ldap
(mf/use-callback (mf/use-callback
@@ -150,12 +153,15 @@
:on-success on-success})] :on-success on-success})]
(st/emit! (da/login-with-ldap params))))) (st/emit! (da/login-with-ldap params)))))
default-recovery-req on-recovery-request
(mf/use-fn (or on-recovery-request
#(st/emit! (rt/nav :auth-recovery-request))) #(st/emit! (rt/nav :auth-recovery-request)))]
on-recovery-request (or on-recovery-request
default-recovery-req)] (mf/with-effect [handle-redirect]
(if handle-redirect
(store-login-redirect)
(clear-login-redirect)))
[:* [:*
(when-let [message @error] (when-let [message @error]
@@ -165,6 +171,7 @@
[:& fm/form {:on-submit on-submit [:& fm/form {:on-submit on-submit
:class (stl/css :login-form) :class (stl/css :login-form)
:form form} :form form}
[:div {:class (stl/css :fields-row)} [:div {:class (stl/css :fields-row)}
[:& fm/input [:& fm/input
{:name :email {:name :email
@@ -172,12 +179,14 @@
:label (tr "auth.work-email") :label (tr "auth.work-email")
:class (stl/css :form-field)}]] :class (stl/css :form-field)}]]
(when show-password-field?
[:div {:class (stl/css :fields-row)} [:div {:class (stl/css :fields-row)}
[:& fm/input [:& fm/input
{:type "password" {:type "password"
:name :password :name :password
:auto-focus? true
:label (tr "auth.password") :label (tr "auth.password")
:class (stl/css :form-field)}]] :class (stl/css :form-field)}]])
(when (and (not= origin :viewer) (when (and (not= origin :viewer)
(or (contains? cf/flags :login) (or (contains? cf/flags :login)
@@ -202,12 +211,12 @@
:class (stl/css :login-ldap-button) :class (stl/css :login-ldap-button)
:on-click on-submit-ldap}])]]])) :on-click on-submit-ldap}])]]]))
(mf/defc login-buttons (mf/defc login-sso-buttons*
[{:keys [params] :as props}] [{:keys [params] :as props}]
(let [login-with-google (mf/use-fn (mf/deps params) #(login-with-oidc % :google params)) (let [login-with-google (mf/use-fn (mf/deps params) #(login-with-sso "google" params))
login-with-github (mf/use-fn (mf/deps params) #(login-with-oidc % :github params)) login-with-github (mf/use-fn (mf/deps params) #(login-with-sso "github" params))
login-with-gitlab (mf/use-fn (mf/deps params) #(login-with-oidc % :gitlab params)) login-with-gitlab (mf/use-fn (mf/deps params) #(login-with-sso "gitlab" params))
login-with-oidc (mf/use-fn (mf/deps params) #(login-with-oidc % :oidc params))] login-with-oidc (mf/use-fn (mf/deps params) #(login-with-sso "oidc" params))]
[:div {:class (stl/css :auth-buttons)} [:div {:class (stl/css :auth-buttons)}
(when (contains? cf/flags :login-with-google) (when (contains? cf/flags :login-with-google)
@@ -234,32 +243,12 @@
:label (tr "auth.login-with-oidc-submit") :label (tr "auth.login-with-oidc-submit")
:class (stl/css :login-btn :btn-oidc-auth)}])])) :class (stl/css :login-btn :btn-oidc-auth)}])]))
(mf/defc login-button-oidc (mf/defc login-dialog*
[{:keys [params] :as props}] [{:keys [params] :as props}]
(let [login-oidc
(mf/use-fn
(mf/deps params)
(fn [event]
(login-with-oidc event :oidc params)))
handle-key-down
(mf/use-fn
(fn [event]
(when (k/enter? event)
(login-oidc event))))]
(when (contains? cf/flags :login-with-oidc)
[:button {:tab-index "0"
:class (stl/css :link-entry :link-oidc)
:on-key-down handle-key-down
:on-click login-oidc}
(tr "auth.login-with-oidc-submit")])))
(mf/defc login-methods
[{:keys [params on-success-callback on-recovery-request origin] :as props}]
[:* [:*
(when show-alt-login-buttons? (when show-sso-login-buttons?
[:* [:*
[:& login-buttons {:params params}] [:> login-sso-buttons* {:params params}]
(when (or (contains? cf/flags :login) (when (or (contains? cf/flags :login)
(contains? cf/flags :login-with-password) (contains? cf/flags :login-with-password)
@@ -269,7 +258,7 @@
(when (or (contains? cf/flags :login) (when (or (contains? cf/flags :login)
(contains? cf/flags :login-with-password) (contains? cf/flags :login-with-password)
(contains? cf/flags :login-with-ldap)) (contains? cf/flags :login-with-ldap))
[:& login-form {:params params :on-success-callback on-success-callback :on-recovery-request on-recovery-request :origin origin}])]) [:> login-form* props])])
(mf/defc login-page (mf/defc login-page
[{:keys [params] :as props}] [{:keys [params] :as props}]
@@ -287,7 +276,7 @@
(when (contains? cf/flags :demo-warning) (when (contains? cf/flags :demo-warning)
[:& demo-warning]) [:& demo-warning])
[:& login-methods {:params params}] [:> login-dialog* {:params params}]
[:hr {:class (stl/css :separator)}] [:hr {:class (stl/css :separator)}]

View File

@@ -191,9 +191,9 @@
{::mf/props :obj} {::mf/props :obj}
[{:keys [params hide-separator on-success-callback]}] [{:keys [params hide-separator on-success-callback]}]
[:* [:*
(when login/show-alt-login-buttons? (when login/show-sso-login-buttons?
[:& login/login-buttons {:params params}]) [:> login/login-sso-buttons* {:params params}])
(when (or login/show-alt-login-buttons? (false? hide-separator)) (when (or login/show-sso-login-buttons? (false? hide-separator))
[:hr {:class (stl/css :separator)}]) [:hr {:class (stl/css :separator)}])
(when (contains? cf/flags :login-with-password) (when (contains? cf/flags :login-with-password)
[:& register-form {:params params :on-success-callback on-success-callback}])]) [:& register-form {:params params :on-success-callback on-success-callback}])])

View File

@@ -21,7 +21,7 @@
[app.main.repo :as rp] [app.main.repo :as rp]
[app.main.router :as rt] [app.main.router :as rt]
[app.main.store :as st] [app.main.store :as st]
[app.main.ui.auth.login :refer [login-methods]] [app.main.ui.auth.login :refer [login-dialog*]]
[app.main.ui.auth.recovery-request :refer [recovery-request-page recovery-sent-page]] [app.main.ui.auth.recovery-request :refer [recovery-request-page recovery-sent-page]]
[app.main.ui.auth.register :as register] [app.main.ui.auth.register :as register]
[app.main.ui.dashboard.sidebar :refer [sidebar*]] [app.main.ui.dashboard.sidebar :refer [sidebar*]]
@@ -75,7 +75,8 @@
[:div {:class (stl/css :main-message)} (tr "errors.invite-invalid")] [:div {:class (stl/css :main-message)} (tr "errors.invite-invalid")]
[:div {:class (stl/css :desc-message)} (tr "errors.invite-invalid.info")]]) [:div {:class (stl/css :desc-message)} (tr "errors.invite-invalid.info")]])
(mf/defc login-dialog* (mf/defc login-modal*
{::mf/private true}
[] []
(let [current-section (mf/use-state :login) (let [current-section (mf/use-state :login)
user-email (mf/use-state "") user-email (mf/use-state "")
@@ -136,9 +137,9 @@
[:* [:*
[:div {:class (stl/css :logo-title)} (tr "labels.login")] [:div {:class (stl/css :logo-title)} (tr "labels.login")]
[:div {:class (stl/css :logo-subtitle)} (tr "not-found.login.free")] [:div {:class (stl/css :logo-subtitle)} (tr "not-found.login.free")]
[:& login-methods {:on-recovery-request set-section-recovery [:> login-dialog* {:on-recovery-request set-section-recovery
:on-success-callback success-login :on-success-callback success-login
:params {:save-login-redirect true}}] :handle-redirect true}]
[:hr {:class (stl/css :separator)}] [:hr {:class (stl/css :separator)}]
[:div {:class (stl/css :change-section)} [:div {:class (stl/css :change-section)}
(tr "auth.register") (tr "auth.register")
@@ -559,7 +560,7 @@
:is-dashboard dashboard? :is-dashboard dashboard?
:is-viewer view? :is-viewer view?
:profile profile} :profile profile}
[:> login-dialog* {}]] [:> login-modal* {}]]
(when (get info :loaded false) (when (get info :loaded false)
(if request-access? (if request-access?
[:> context-wrapper* {:is-workspace workspace? [:> context-wrapper* {:is-workspace workspace?

View File

@@ -10,7 +10,7 @@
[app.common.logging :as log] [app.common.logging :as log]
[app.main.data.modal :as modal] [app.main.data.modal :as modal]
[app.main.store :as st] [app.main.store :as st]
[app.main.ui.auth.login :refer [login-methods]] [app.main.ui.auth.login :refer [login-dialog*]]
[app.main.ui.auth.recovery-request :refer [recovery-request-page]] [app.main.ui.auth.recovery-request :refer [recovery-request-page]]
[app.main.ui.auth.register :refer [register-methods register-success-page terms-register register-validate-form]] [app.main.ui.auth.register :refer [register-methods register-success-page terms-register register-validate-form]]
[app.main.ui.icons :as deprecated-icon] [app.main.ui.icons :as deprecated-icon]
@@ -75,7 +75,9 @@
(case current-section (case current-section
:login :login
[:div {:class (stl/css :form-container)} [:div {:class (stl/css :form-container)}
[:& login-methods {:on-success-callback success-login :origin :viewer}] [:> login-dialog*
{:on-success-callback success-login
:origin :viewer}]
[:div {:class (stl/css :links)} [:div {:class (stl/css :links)}
[:div {:class (stl/css :recovery-request)} [:div {:class (stl/css :recovery-request)}
[:a {:on-click set-section [:a {:on-click set-section