mirror of
https://github.com/penpot/penpot.git
synced 2026-09-30 07:46:16 +00:00
🐛 Allow registration with disabled public registration (#11912)
* 🐛 Allow invitation-based registration when disable-registration is set (#5178) Per documentation, disable-registration 'disables registration (still enabled for invitations only)'. Two bugs prevented this: 1. verify_token.clj: when processing an invitation token for a non-logged-in user with no member-id, the redirect included registration-disabled? in its condition, sending invited users to the login page instead of the register page. 2. auth.clj validate-register-attempt!: the registration-disabled check fired unconditionally before the invitation-token check, rejecting the actual register RPC even with a valid invitation. Fix: in verify_token.clj remove registration-disabled? from the redirect condition for new-user invitations. In auth.clj restructure the check as an if/else: with an invitation token, validate the token and allow registration; without one, enforce the flag as before. * 🐛 Allow registration with disabled public registration Allow valid team invitations to create new profiles when public registration is disabled, while keeping password login and invitation validation required. Add backend regression coverage for flag combinations and verify-token redirects, frontend route coverage, and configuration documentation. Closes #5178 AI-assisted-by: space-bunny-free * 🐛 Revalidate active invitation during registration Require a live, unexpired team invitation before using the registration exception, and recheck it before creating a profile. Reuse the same lookup in invitation token verification. Add regression tests for canceled and expired invitations, the registration race, and explicit redirect contracts. Update docs and backend auth guidance. AI-assisted-by: Space Bunny Free * 🐛 Lock and normalize invitation registration checks Lock active invitation rows during transactional registration and acceptance so cancellations cannot race with profile or membership creation. Normalize invitation emails before comparisons and database lookups. Add concurrency, email casing, and final flag regression tests. AI-assisted-by: Space Bunny Free --------- Co-authored-by: Sumit Ridhal <sridhal@redhat.com>
This commit is contained in:
parent
4943285e63
commit
eec06a5987
@ -12,6 +12,7 @@
|
|||||||
- Lockout and RPC rate limits share the `app.http.errors/handle-error :rate-limit` HTTP path: status 429, body `{:type :rate-limit :code ... :hint ... :ttl ...}`, and any supplied `::http/headers` preserved. Account lockout raises `:code :account-locked` and, when it carries a non-nil `:ttl` (seconds), the handler adds `retry-after`. The RPC limiter (`app.rpc.rlimit`) raises `:code :request-blocked` and sets `retry-after` itself in `::http/headers` (seconds until the longest rejecting limit resets), alongside `x-rate-limit-remaining`/`x-rate-limit-reset`. CORS (`app.http.middleware/with-cors-headers`) exposes `content-type`, `retry-after`, and both `x-rate-limit-*` headers. External flags: `enable-account-lockout`, `enable-rpc-rlimit`.
|
- Lockout and RPC rate limits share the `app.http.errors/handle-error :rate-limit` HTTP path: status 429, body `{:type :rate-limit :code ... :hint ... :ttl ...}`, and any supplied `::http/headers` preserved. Account lockout raises `:code :account-locked` and, when it carries a non-nil `:ttl` (seconds), the handler adds `retry-after`. The RPC limiter (`app.rpc.rlimit`) raises `:code :request-blocked` and sets `retry-after` itself in `::http/headers` (seconds until the longest rejecting limit resets), alongside `x-rate-limit-remaining`/`x-rate-limit-reset`. CORS (`app.http.middleware/with-cors-headers`) exposes `content-type`, `retry-after`, and both `x-rate-limit-*` headers. External flags: `enable-account-lockout`, `enable-rpc-rlimit`.
|
||||||
- Logout may return an OIDC provider redirect URI when the session claims include provider/session data and the provider has a logout URI.
|
- Logout may return an OIDC provider redirect URI when the session claims include provider/session data and the provider has a logout URI.
|
||||||
- Invitation tokens are verified through token issuers and only accepted when the token member id/email matches the authenticated profile; otherwise login proceeds without consuming the invitation.
|
- Invitation tokens are verified through token issuers and only accepted when the token member id/email matches the authenticated profile; otherwise login proceeds without consuming the invitation.
|
||||||
|
- When public registration is disabled, invitation-based password registration also requires a matching, non-expired `team_invitation` row; registration revalidates it under a row lock before creating the profile.
|
||||||
- HTTP/session parsing details such as cookie/header precedence, JWT session token versions, and SameSite behavior are in `mem:backend/subtleties`.
|
- HTTP/session parsing details such as cookie/header precedence, JWT session token versions, and SameSite behavior are in `mem:backend/subtleties`.
|
||||||
|
|
||||||
## Permission model
|
## Permission model
|
||||||
|
|||||||
@ -31,6 +31,7 @@
|
|||||||
[app.rpc.climit :as-alias climit]
|
[app.rpc.climit :as-alias climit]
|
||||||
[app.rpc.commands.profile :as profile]
|
[app.rpc.commands.profile :as profile]
|
||||||
[app.rpc.commands.teams :as teams]
|
[app.rpc.commands.teams :as teams]
|
||||||
|
[app.rpc.commands.teams-invitations :as teams-invitations]
|
||||||
[app.rpc.doc :as-alias doc]
|
[app.rpc.doc :as-alias doc]
|
||||||
[app.rpc.helpers :as rph]
|
[app.rpc.helpers :as rph]
|
||||||
[app.setup :as-alias setup]
|
[app.setup :as-alias setup]
|
||||||
@ -225,23 +226,51 @@
|
|||||||
|
|
||||||
;; ---- COMMAND: Prepare Register
|
;; ---- COMMAND: Prepare Register
|
||||||
|
|
||||||
|
(defn- validate-registration-flags!
|
||||||
|
([cfg invitation]
|
||||||
|
(validate-registration-flags! cfg invitation false))
|
||||||
|
([cfg invitation lock-invitation?]
|
||||||
|
(when-not (contains? cf/flags :login-with-password)
|
||||||
|
(ex/raise :type :restriction
|
||||||
|
:code :registration-disabled
|
||||||
|
:hint "registration disabled"))
|
||||||
|
|
||||||
|
(when-not (contains? cf/flags :registration)
|
||||||
|
(if (nil? invitation)
|
||||||
|
(ex/raise :type :restriction
|
||||||
|
:code :registration-disabled
|
||||||
|
:hint "registration disabled")
|
||||||
|
(let [opts (when lock-invitation? {::db/for-update true})]
|
||||||
|
(when (nil? (teams-invitations/active-invitation cfg invitation opts))
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :invalid-token
|
||||||
|
:hint "no active invitation associated with the token")))))))
|
||||||
|
|
||||||
(defn- validate-register-attempt!
|
(defn- validate-register-attempt!
|
||||||
[cfg params]
|
[cfg params]
|
||||||
|
(let [invitation? (contains? params :invitation-token)
|
||||||
(when (or (not (contains? cf/flags :registration))
|
invitation (when invitation?
|
||||||
(not (contains? cf/flags :login-with-password)))
|
(tokens/verify cfg
|
||||||
(ex/raise :type :restriction
|
|
||||||
:code :registration-disabled
|
|
||||||
:hint "registration disabled"))
|
|
||||||
|
|
||||||
(when (contains? params :invitation-token)
|
|
||||||
(let [invitation (tokens/verify cfg
|
|
||||||
{:token (:invitation-token params)
|
{:token (:invitation-token params)
|
||||||
:iss :team-invitation})]
|
:iss :team-invitation}))]
|
||||||
(when-not (= (:email params) (:member-email invitation))
|
|
||||||
(ex/raise :type :restriction
|
(when (or (not (contains? cf/flags :login-with-password))
|
||||||
:code :email-does-not-match-invitation
|
(and (not (contains? cf/flags :registration))
|
||||||
:hint "email should match the invitation"))))
|
(not invitation?)))
|
||||||
|
(ex/raise :type :restriction
|
||||||
|
:code :registration-disabled
|
||||||
|
:hint "registration disabled"))
|
||||||
|
|
||||||
|
(when (and invitation?
|
||||||
|
(not= (profile/clean-email (:email params))
|
||||||
|
(profile/clean-email (:member-email invitation))))
|
||||||
|
(ex/raise :type :restriction
|
||||||
|
:code :email-does-not-match-invitation
|
||||||
|
:hint "email should match the invitation"))
|
||||||
|
|
||||||
|
(when (and invitation?
|
||||||
|
(not (contains? cf/flags :registration)))
|
||||||
|
(validate-registration-flags! cfg invitation)))
|
||||||
|
|
||||||
(when (and (email.blacklist/enabled? cfg)
|
(when (and (email.blacklist/enabled? cfg)
|
||||||
(email.blacklist/contains? cfg (:email params)))
|
(email.blacklist/contains? cfg (:email params)))
|
||||||
@ -467,6 +496,9 @@
|
|||||||
(defn register-profile
|
(defn register-profile
|
||||||
[{:keys [::db/conn] :as cfg} {:keys [token] :as params}]
|
[{:keys [::db/conn] :as cfg} {:keys [token] :as params}]
|
||||||
(let [claims (tokens/verify cfg {:token token :iss :prepared-register})
|
(let [claims (tokens/verify cfg {:token token :iss :prepared-register})
|
||||||
|
invitation (when-let [token (:invitation-token claims)]
|
||||||
|
(tokens/verify cfg {:token token :iss :team-invitation}))
|
||||||
|
_ (validate-registration-flags! cfg invitation true)
|
||||||
params (cond-> claims
|
params (cond-> claims
|
||||||
(:accept-newsletter-updates params)
|
(:accept-newsletter-updates params)
|
||||||
(update :props assoc :newsletter-updates true))
|
(update :props assoc :newsletter-updates true))
|
||||||
@ -484,9 +516,6 @@
|
|||||||
|
|
||||||
created? (-> profile meta :created true?)
|
created? (-> profile meta :created true?)
|
||||||
|
|
||||||
invitation (when-let [token (:invitation-token params)]
|
|
||||||
(tokens/verify cfg {:token token :iss :team-invitation}))
|
|
||||||
|
|
||||||
props (-> (audit/profile->props profile)
|
props (-> (audit/profile->props profile)
|
||||||
(assoc :from-invitation (some? invitation)))]
|
(assoc :from-invitation (some? invitation)))]
|
||||||
|
|
||||||
|
|||||||
@ -70,6 +70,24 @@
|
|||||||
[sql:check-recent-invitation team-id email])]
|
[sql:check-recent-invitation team-id email])]
|
||||||
(some? (db/exec-one! conn query))))
|
(some? (db/exec-one! conn query))))
|
||||||
|
|
||||||
|
(defn active-invitation
|
||||||
|
([cfg claims]
|
||||||
|
(active-invitation cfg claims {}))
|
||||||
|
([cfg {:keys [team-id organization-id member-email]} opts]
|
||||||
|
(let [email (profile/clean-email member-email)
|
||||||
|
invitation (cond
|
||||||
|
organization-id (db/get* cfg :team-invitation
|
||||||
|
{:email-to email
|
||||||
|
:org-id organization-id}
|
||||||
|
opts)
|
||||||
|
team-id (db/get* cfg :team-invitation
|
||||||
|
{:email-to email
|
||||||
|
:team-id team-id}
|
||||||
|
opts))]
|
||||||
|
(when (and invitation
|
||||||
|
(ct/is-after? (:valid-until invitation) (ct/now)))
|
||||||
|
invitation))))
|
||||||
|
|
||||||
(defn- create-invitation-token
|
(defn- create-invitation-token
|
||||||
[cfg {:keys [profile-id valid-until organization-id organization-name team-id member-id member-email role]}]
|
[cfg {:keys [profile-id valid-until organization-id organization-name team-id member-id member-email role]}]
|
||||||
(tokens/generate cfg
|
(tokens/generate cfg
|
||||||
|
|||||||
@ -21,6 +21,7 @@
|
|||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
[app.rpc.commands.profile :as profile]
|
[app.rpc.commands.profile :as profile]
|
||||||
[app.rpc.commands.teams :as teams]
|
[app.rpc.commands.teams :as teams]
|
||||||
|
[app.rpc.commands.teams-invitations :as teams-invitations]
|
||||||
[app.rpc.doc :as-alias doc]
|
[app.rpc.doc :as-alias doc]
|
||||||
[app.rpc.helpers :as rph]
|
[app.rpc.helpers :as rph]
|
||||||
[app.rpc.quotes :as quotes]
|
[app.rpc.quotes :as quotes]
|
||||||
@ -102,12 +103,6 @@
|
|||||||
|
|
||||||
;; --- Team Invitation
|
;; --- Team Invitation
|
||||||
|
|
||||||
(def ^:private sql:get-organization-invitation
|
|
||||||
"SELECT *
|
|
||||||
FROM team_invitation
|
|
||||||
WHERE email_to = ?
|
|
||||||
AND org_id = ?")
|
|
||||||
|
|
||||||
(def ^:private sql:delete-organization-invitation
|
(def ^:private sql:delete-organization-invitation
|
||||||
"DELETE FROM team_invitation
|
"DELETE FROM team_invitation
|
||||||
WHERE email_to = ?
|
WHERE email_to = ?
|
||||||
@ -198,17 +193,16 @@
|
|||||||
:code :invalid-invitation-token
|
:code :invalid-invitation-token
|
||||||
:hint "invitation token contains unexpected data"))
|
:hint "invitation token contains unexpected data"))
|
||||||
|
|
||||||
(let [invitation (if organization-id
|
(let [member-email (profile/clean-email member-email)
|
||||||
(db/exec-one! conn [sql:get-organization-invitation member-email organization-id])
|
claims (assoc claims :member-email member-email)
|
||||||
(db/get* conn :team-invitation
|
invitation (teams-invitations/active-invitation
|
||||||
{:email-to member-email
|
cfg
|
||||||
:team-id team-id}))
|
claims
|
||||||
|
{::db/for-update true})
|
||||||
profile (db/get* conn :profile
|
profile (db/get* conn :profile
|
||||||
{:id profile-id}
|
{:id profile-id}
|
||||||
{:columns [:id :email :default-team-id]})
|
{:columns [:id :email :default-team-id]})
|
||||||
registration-disabled? (not (contains? cf/flags :registration))
|
organization-invitation? (and (contains? cf/flags :admin-console) organization-id)]
|
||||||
|
|
||||||
organization-invitation? (and (contains? cf/flags :admin-console) organization-id)]
|
|
||||||
|
|
||||||
(if profile
|
(if profile
|
||||||
(do
|
(do
|
||||||
@ -346,11 +340,12 @@
|
|||||||
"no invitation associated with the token")))
|
"no invitation associated with the token")))
|
||||||
|
|
||||||
;; If we have not logged-in user, and invitation comes with member-id we
|
;; If we have not logged-in user, and invitation comes with member-id we
|
||||||
;; redirect user to login, if no member-id is present and in the invitation
|
;; redirect user to login. If no member-id is present this is an invitation
|
||||||
;; token and registration is enabled, we redirect user the the register page.
|
;; for a new user — send them to the register page. Invitations bypass
|
||||||
|
;; the disable-registration flag per documentation.
|
||||||
{:invitation-token token
|
{:invitation-token token
|
||||||
:iss :team-invitation
|
:iss :team-invitation
|
||||||
:redirect-to (if (or member-id registration-disabled?) :auth-login :auth-register)
|
:redirect-to (if member-id :auth-login :auth-register)
|
||||||
:state :pending}))))
|
:state :pending}))))
|
||||||
|
|
||||||
;; --- Default
|
;; --- Default
|
||||||
|
|||||||
@ -16,9 +16,11 @@
|
|||||||
[app.nitrate :as nitrate]
|
[app.nitrate :as nitrate]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
[app.rpc.commands.profile :as profile]
|
[app.rpc.commands.profile :as profile]
|
||||||
|
[app.rpc.commands.teams-invitations :as teams-invitations]
|
||||||
[app.tokens :as tokens]
|
[app.tokens :as tokens]
|
||||||
[backend-tests.helpers :as th]
|
[backend-tests.helpers :as th]
|
||||||
[clojure.java.io :as io]
|
[clojure.java.io :as io]
|
||||||
|
[clojure.string :as cstring]
|
||||||
[clojure.test :as t]
|
[clojure.test :as t]
|
||||||
[cuerdas.core :as str]
|
[cuerdas.core :as str]
|
||||||
[datoteka.fs :as fs]
|
[datoteka.fs :as fs]
|
||||||
@ -664,6 +666,172 @@
|
|||||||
(let [profile (th/db-get :profile {:email "hello@example.com"})]
|
(let [profile (th/db-get :profile {:email "hello@example.com"})]
|
||||||
(t/is (false? (:is-active profile)))))))))
|
(t/is (false? (:is-active profile)))))))))
|
||||||
|
|
||||||
|
(defn- create-invitation-fixture
|
||||||
|
[email valid-until]
|
||||||
|
(let [owner (th/create-profile* 1 {:is-active true})
|
||||||
|
team (th/create-team* 1 {:profile-id (:id owner)})
|
||||||
|
invitation (th/db-insert! :team-invitation
|
||||||
|
{:id (uuid/random)
|
||||||
|
:team-id (:id team)
|
||||||
|
:email-to email
|
||||||
|
:created-by (:id owner)
|
||||||
|
:role "editor"
|
||||||
|
:valid-until valid-until})]
|
||||||
|
{:owner owner
|
||||||
|
:team team
|
||||||
|
:invitation invitation}))
|
||||||
|
|
||||||
|
(defn- create-test-invitation-token
|
||||||
|
([{:keys [owner team]} email]
|
||||||
|
(create-test-invitation-token {:owner owner
|
||||||
|
:team team}
|
||||||
|
email
|
||||||
|
{}))
|
||||||
|
([{:keys [owner team]} email claims]
|
||||||
|
(tokens/generate th/*system*
|
||||||
|
(merge {:iss :team-invitation
|
||||||
|
:exp (ct/in-future "48h")
|
||||||
|
:profile-id (:id owner)
|
||||||
|
:role :editor
|
||||||
|
:team-id (:id team)
|
||||||
|
:member-email email}
|
||||||
|
claims))))
|
||||||
|
|
||||||
|
(t/deftest prepare-register-with-invitation-and-disabled-registration
|
||||||
|
(with-redefs [app.config/flags #{:login-with-password :email-verification}]
|
||||||
|
(with-mocks [mock {:target 'app.email/send! :return nil}]
|
||||||
|
(let [email "invited@example.com"
|
||||||
|
fixture (create-invitation-fixture email (ct/in-future "48h"))
|
||||||
|
itoken (create-test-invitation-token fixture email)
|
||||||
|
data {::th/type :prepare-register-profile
|
||||||
|
:invitation-token itoken
|
||||||
|
:fullname "foobar"
|
||||||
|
:email email
|
||||||
|
:password "Foobar12!"}
|
||||||
|
out (th/command! data)]
|
||||||
|
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(t/is (string? (get-in out [:result :token])))
|
||||||
|
|
||||||
|
(let [register-out (th/command! {::th/type :register-profile
|
||||||
|
:token (get-in out [:result :token])})]
|
||||||
|
(t/is (th/success? register-out))
|
||||||
|
(t/is (= 1 (:call-count @mock))))))))
|
||||||
|
|
||||||
|
(t/deftest prepare-register-normalizes-invitation-email
|
||||||
|
(with-redefs [app.config/flags #{:login-with-password :email-verification}]
|
||||||
|
(let [email "mixed-case@example.com"
|
||||||
|
fixture (create-invitation-fixture email (ct/in-future "48h"))
|
||||||
|
itoken (create-test-invitation-token
|
||||||
|
fixture
|
||||||
|
email
|
||||||
|
{:member-email (cstring/upper-case email)})
|
||||||
|
out (th/command! {::th/type :prepare-register-profile
|
||||||
|
:invitation-token itoken
|
||||||
|
:fullname "foobar"
|
||||||
|
:email email
|
||||||
|
:password "Foobar12!"})]
|
||||||
|
(t/is (th/success? out)))))
|
||||||
|
|
||||||
|
(t/deftest prepare-register-with-canceled-invitation-is-rejected
|
||||||
|
(with-redefs [app.config/flags #{:login-with-password :email-verification}]
|
||||||
|
(let [email "canceled@example.com"
|
||||||
|
fixture (create-invitation-fixture email (ct/in-future "48h"))
|
||||||
|
itoken (create-test-invitation-token fixture email)
|
||||||
|
data {::th/type :prepare-register-profile
|
||||||
|
:invitation-token itoken
|
||||||
|
:fullname "foobar"
|
||||||
|
:email email
|
||||||
|
:password "Foobar12!"}]
|
||||||
|
(t/is (th/success? (th/command! data)))
|
||||||
|
(th/db-delete! :team-invitation
|
||||||
|
{:team-id (:id (:team fixture))
|
||||||
|
:email-to email})
|
||||||
|
(let [out (th/command! data)]
|
||||||
|
(t/is (not (th/success? out)))
|
||||||
|
(t/is (= :validation (-> out :error ex-data :type)))
|
||||||
|
(t/is (= :invalid-token (-> out :error ex-data :code)))
|
||||||
|
(t/is (nil? (th/db-get :profile {:email email})))))))
|
||||||
|
|
||||||
|
(t/deftest prepare-register-with-expired-invitation-is-rejected
|
||||||
|
(with-redefs [app.config/flags #{:login-with-password :email-verification}]
|
||||||
|
(let [email "expired@example.com"
|
||||||
|
fixture (create-invitation-fixture email (ct/in-past "48h"))
|
||||||
|
itoken (create-test-invitation-token fixture email)
|
||||||
|
out (th/command! {::th/type :prepare-register-profile
|
||||||
|
:invitation-token itoken
|
||||||
|
:fullname "foobar"
|
||||||
|
:email email
|
||||||
|
:password "Foobar12!"})]
|
||||||
|
(t/is (not (th/success? out)))
|
||||||
|
(t/is (= :validation (-> out :error ex-data :type)))
|
||||||
|
(t/is (= :invalid-token (-> out :error ex-data :code)))
|
||||||
|
(t/is (nil? (th/db-get :profile {:email email}))))))
|
||||||
|
|
||||||
|
(t/deftest active-invitation-locks-row-during-transaction
|
||||||
|
(let [email "locked@example.com"
|
||||||
|
fixture (create-invitation-fixture email (ct/in-future "48h"))
|
||||||
|
claims {:team-id (:id (:team fixture))
|
||||||
|
:member-email email}
|
||||||
|
locked (promise)
|
||||||
|
release (promise)
|
||||||
|
holder (future
|
||||||
|
(db/tx-run! th/*system*
|
||||||
|
(fn [cfg]
|
||||||
|
(let [invitation (teams-invitations/active-invitation
|
||||||
|
cfg
|
||||||
|
claims
|
||||||
|
{::db/for-update true})]
|
||||||
|
(deliver locked invitation)
|
||||||
|
(deref release 10000 ::timeout)
|
||||||
|
invitation))))
|
||||||
|
invitation (deref locked 10000 nil)
|
||||||
|
cancellation (when invitation
|
||||||
|
(future
|
||||||
|
(try
|
||||||
|
{:result
|
||||||
|
(db/tx-run! th/*system*
|
||||||
|
(fn [cfg]
|
||||||
|
(let [conn (::db/conn cfg)]
|
||||||
|
(db/exec-one! conn ["SET LOCAL lock_timeout = '100ms'"])
|
||||||
|
(db/delete! conn :team-invitation
|
||||||
|
{:team-id (:id (:team fixture))
|
||||||
|
:email-to email}))))}
|
||||||
|
(catch Throwable cause
|
||||||
|
{:error cause}))))
|
||||||
|
cancellation-result (when cancellation
|
||||||
|
(deref cancellation 10000 nil))
|
||||||
|
_ (deliver release true)
|
||||||
|
holder-result (deref holder 10000 nil)
|
||||||
|
_ (when cancellation
|
||||||
|
(deref cancellation 10000 nil))]
|
||||||
|
|
||||||
|
(t/is (some? invitation))
|
||||||
|
(t/is (contains? cancellation-result :error))
|
||||||
|
(t/is (some? holder-result))))
|
||||||
|
|
||||||
|
(t/deftest register-profile-revalidates-invitation-before-creating-profile
|
||||||
|
(with-redefs [app.config/flags #{:login-with-password :email-verification}]
|
||||||
|
(let [email "canceled-between@example.com"
|
||||||
|
fixture (create-invitation-fixture email (ct/in-future "48h"))
|
||||||
|
itoken (create-test-invitation-token fixture email)
|
||||||
|
prep (th/command! {::th/type :prepare-register-profile
|
||||||
|
:invitation-token itoken
|
||||||
|
:fullname "foobar"
|
||||||
|
:email email
|
||||||
|
:password "Foobar12!"})
|
||||||
|
rtoken (-> prep :result :token)]
|
||||||
|
(t/is (th/success? prep))
|
||||||
|
(th/db-delete! :team-invitation
|
||||||
|
{:team-id (:id (:team fixture))
|
||||||
|
:email-to email})
|
||||||
|
(let [out (th/command! {::th/type :register-profile
|
||||||
|
:token rtoken})]
|
||||||
|
(t/is (not (th/success? out)))
|
||||||
|
(t/is (= :validation (-> out :error ex-data :type)))
|
||||||
|
(t/is (= :invalid-token (-> out :error ex-data :code)))
|
||||||
|
(t/is (nil? (th/db-get :profile {:email email})))))))
|
||||||
|
|
||||||
(t/deftest prepare-and-register-with-invitation-and-enabled-registration-1
|
(t/deftest prepare-and-register-with-invitation-and-enabled-registration-1
|
||||||
;; With email-verification ENABLED (the default), a brand-new
|
;; With email-verification ENABLED (the default), a brand-new
|
||||||
;; profile created via the invitation flow is NOT active yet, so
|
;; profile created via the invitation flow is NOT active yet, so
|
||||||
@ -769,28 +937,8 @@
|
|||||||
(t/is (= :restriction (:type edata)))
|
(t/is (= :restriction (:type edata)))
|
||||||
(t/is (= :email-does-not-match-invitation (:code edata))))))
|
(t/is (= :email-does-not-match-invitation (:code edata))))))
|
||||||
|
|
||||||
(t/deftest prepare-and-register-with-invitation-and-disabled-registration-1
|
(t/deftest prepare-register-with-invitation-and-disabled-registration-and-wrong-email
|
||||||
(with-redefs [app.config/flags [:disable-registration]]
|
(with-redefs [app.config/flags #{:login-with-password}]
|
||||||
(let [itoken (tokens/generate th/*system*
|
|
||||||
{:iss :team-invitation
|
|
||||||
:exp (ct/in-future "48h")
|
|
||||||
:role :editor
|
|
||||||
:team-id uuid/zero
|
|
||||||
:member-email "user@example.com"})
|
|
||||||
data {::th/type :prepare-register-profile
|
|
||||||
:invitation-token itoken
|
|
||||||
:fullname "foobar"
|
|
||||||
:email "user@example.com"
|
|
||||||
:password "Foobar12!"}
|
|
||||||
out (th/command! data)]
|
|
||||||
|
|
||||||
(t/is (not (th/success? out)))
|
|
||||||
(let [edata (-> out :error ex-data)]
|
|
||||||
(t/is (= :restriction (:type edata)))
|
|
||||||
(t/is (= :registration-disabled (:code edata)))))))
|
|
||||||
|
|
||||||
(t/deftest prepare-and-register-with-invitation-and-disabled-registration-2
|
|
||||||
(with-redefs [app.config/flags [:disable-registration]]
|
|
||||||
(let [itoken (tokens/generate th/*system*
|
(let [itoken (tokens/generate th/*system*
|
||||||
{:iss :team-invitation
|
{:iss :team-invitation
|
||||||
:exp (ct/in-future "48h")
|
:exp (ct/in-future "48h")
|
||||||
@ -808,10 +956,10 @@
|
|||||||
(t/is (not (th/success? out)))
|
(t/is (not (th/success? out)))
|
||||||
(let [edata (-> out :error ex-data)]
|
(let [edata (-> out :error ex-data)]
|
||||||
(t/is (= :restriction (:type edata)))
|
(t/is (= :restriction (:type edata)))
|
||||||
(t/is (= :registration-disabled (:code edata)))))))
|
(t/is (= :email-does-not-match-invitation (:code edata)))))))
|
||||||
|
|
||||||
(t/deftest prepare-and-register-with-invitation-and-disabled-login-with-password
|
(t/deftest prepare-register-with-invitation-and-disabled-login-with-password
|
||||||
(with-redefs [app.config/flags [:disable-login-with-password]]
|
(with-redefs [app.config/flags #{:registration}]
|
||||||
(let [itoken (tokens/generate th/*system*
|
(let [itoken (tokens/generate th/*system*
|
||||||
{:iss :team-invitation
|
{:iss :team-invitation
|
||||||
:exp (ct/in-future "48h")
|
:exp (ct/in-future "48h")
|
||||||
@ -831,6 +979,62 @@
|
|||||||
(t/is (= :restriction (:type edata)))
|
(t/is (= :restriction (:type edata)))
|
||||||
(t/is (= :registration-disabled (:code edata)))))))
|
(t/is (= :registration-disabled (:code edata)))))))
|
||||||
|
|
||||||
|
(t/deftest register-profile-rejects-disabled-login-with-password
|
||||||
|
(let [email "login-disabled@example.com"
|
||||||
|
fixture (create-invitation-fixture email (ct/in-future "48h"))]
|
||||||
|
(with-redefs [app.config/flags #{:login-with-password :registration}]
|
||||||
|
(let [itoken (create-test-invitation-token fixture email)
|
||||||
|
prep (th/command! {::th/type :prepare-register-profile
|
||||||
|
:invitation-token itoken
|
||||||
|
:fullname "foobar"
|
||||||
|
:email email
|
||||||
|
:password "Foobar12!"})]
|
||||||
|
(t/is (th/success? prep))
|
||||||
|
(with-redefs [app.config/flags #{:registration}]
|
||||||
|
(let [out (th/command! {::th/type :register-profile
|
||||||
|
:token (-> prep :result :token)})]
|
||||||
|
(t/is (not (th/success? out)))
|
||||||
|
(t/is (= :registration-disabled (-> out :error ex-data :code)))
|
||||||
|
(t/is (nil? (th/db-get :profile {:email email})))))))))
|
||||||
|
|
||||||
|
(t/deftest verify-token-redirects-invitation-based-on-member-id
|
||||||
|
(with-redefs [app.config/flags #{:login-with-password}]
|
||||||
|
(let [owner (th/create-profile* 1 {:is-active true})
|
||||||
|
team (th/create-team* 1 {:profile-id (:id owner)})
|
||||||
|
email "invited@example.com"
|
||||||
|
token (fn [claims]
|
||||||
|
(tokens/generate th/*system*
|
||||||
|
(merge {:iss :team-invitation
|
||||||
|
:exp (ct/in-future "48h")
|
||||||
|
:role :editor
|
||||||
|
:profile-id (:id owner)
|
||||||
|
:team-id (:id team)
|
||||||
|
:member-email email}
|
||||||
|
claims)))]
|
||||||
|
(th/db-insert! :team-invitation
|
||||||
|
{:id (uuid/random)
|
||||||
|
:team-id (:id team)
|
||||||
|
:email-to email
|
||||||
|
:created-by (:id owner)
|
||||||
|
:role "editor"
|
||||||
|
:valid-until (ct/in-future "48h")})
|
||||||
|
|
||||||
|
(let [new-user-token (token {})
|
||||||
|
existing-user-token (token {:member-id (uuid/random)})
|
||||||
|
new-user-out (th/command! {::th/type :verify-token
|
||||||
|
:token new-user-token})
|
||||||
|
existing-user-out (th/command! {::th/type :verify-token
|
||||||
|
:token existing-user-token})]
|
||||||
|
(t/is (th/success? new-user-out))
|
||||||
|
(t/is (= :auth-register (get-in new-user-out [:result :redirect-to])))
|
||||||
|
(t/is (= :pending (get-in new-user-out [:result :state])))
|
||||||
|
(t/is (= new-user-token (get-in new-user-out [:result :invitation-token])))
|
||||||
|
|
||||||
|
(t/is (th/success? existing-user-out))
|
||||||
|
(t/is (= :auth-login (get-in existing-user-out [:result :redirect-to])))
|
||||||
|
(t/is (= :pending (get-in existing-user-out [:result :state])))
|
||||||
|
(t/is (= existing-user-token (get-in existing-user-out [:result :invitation-token])))))))
|
||||||
|
|
||||||
(t/deftest prepare-register-with-registration-disabled
|
(t/deftest prepare-register-with-registration-disabled
|
||||||
(with-redefs [app.config/flags #{}]
|
(with-redefs [app.config/flags #{}]
|
||||||
(let [data {::th/type :prepare-register-profile
|
(let [data {::th/type :prepare-register-profile
|
||||||
|
|||||||
@ -96,13 +96,25 @@ enabled with <code class="language-bash">enable-email-whitelist</code> flag. For
|
|||||||
autoenable it when <code class="language-bash">PENPOT_REGISTRATION_DOMAIN_WHITELIST</code> is set with
|
autoenable it when <code class="language-bash">PENPOT_REGISTRATION_DOMAIN_WHITELIST</code> is set with
|
||||||
not-empty content.
|
not-empty content.
|
||||||
|
|
||||||
Penpot also comes with an option to completely disable the registration process;
|
Penpot also comes with an option to disable public registration. Users with a
|
||||||
for this, use the following flag:
|
valid and active team invitation can still register an account when password
|
||||||
|
login is enabled. The invitation must still exist in the database and its
|
||||||
|
validity period must not have ended. To disable public registration, use the
|
||||||
|
following flag:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PENPOT_FLAGS: [...] disable-registration
|
PENPOT_FLAGS: [...] disable-registration
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For invitation-based registration, keep the password login flag enabled:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PENPOT_FLAGS: [...] disable-registration enable-login-with-password
|
||||||
|
```
|
||||||
|
|
||||||
|
The `disable-login-with-password` flag still disables password-based login and
|
||||||
|
registration, including password-based registration through an invitation.
|
||||||
|
|
||||||
This option is only recommended for demo instances, not for production environments.
|
This option is only recommended for demo instances, not for production environments.
|
||||||
|
|
||||||
### Authentication Providers
|
### Authentication Providers
|
||||||
@ -433,7 +445,8 @@ This is an example of a demo configuration:
|
|||||||
PENPOT_FLAGS: disable-registration enable-demo-users enable-demo-warning
|
PENPOT_FLAGS: disable-registration enable-demo-users enable-demo-warning
|
||||||
```
|
```
|
||||||
|
|
||||||
**disable-registration** prevents any user from registering in the platform.
|
**disable-registration** prevents public registration in the platform, while
|
||||||
|
valid team invitations can still create accounts when password login is enabled.
|
||||||
**enable-demo-users** creates users with a default expiration time of 7 days, and
|
**enable-demo-users** creates users with a default expiration time of 7 days, and
|
||||||
once expired they are completely deleted with all the generated content.
|
once expired they are completely deleted with all the generated content.
|
||||||
From the registration page, there is a link with a `Create demo account` which creates one of these
|
From the registration page, there is a link with a `Create demo account` which creates one of these
|
||||||
|
|||||||
@ -83,7 +83,10 @@ Penpot provides a script (`manage.py`) with some administrative tasks to perform
|
|||||||
flag set in the docker-compose.yaml file. For older versions of docker-compose.yaml file,
|
flag set in the docker-compose.yaml file. For older versions of docker-compose.yaml file,
|
||||||
this flag is set in the backend service.
|
this flag is set in the backend service.
|
||||||
|
|
||||||
For instance, if the registration is disabled, the only way to create a new user is with this script:
|
For users who do not have a team invitation, if public registration is disabled, the
|
||||||
|
way to create a new user is with this script. Users with a valid and active team
|
||||||
|
invitation can register through the invitation link when password login is enabled.
|
||||||
|
The invitation must still exist and must not have expired.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker exec -ti penpot-penpot-backend-1 python3 manage.py create-profile
|
docker exec -ti penpot-penpot-backend-1 python3 manage.py create-profile
|
||||||
|
|||||||
@ -122,6 +122,24 @@
|
|||||||
(t/is (contains? event :days-since-member-added))
|
(t/is (contains? event :days-since-member-added))
|
||||||
(t/is (nil? (:days-since-member-added event)))))))
|
(t/is (nil? (:days-since-member-added event)))))))
|
||||||
|
|
||||||
|
(t/deftest pending-team-invitation-redirects-to-register
|
||||||
|
(let [emitted (atom [])
|
||||||
|
invitation-token "invitation-123"]
|
||||||
|
(with-redefs [st/emit! (fn
|
||||||
|
([event]
|
||||||
|
(swap! emitted conj event))
|
||||||
|
([event & events]
|
||||||
|
(swap! emitted into (cons event events))))]
|
||||||
|
(verify-token/handle-token
|
||||||
|
{:iss :team-invitation
|
||||||
|
:state :pending
|
||||||
|
:redirect-to :auth-register
|
||||||
|
:invitation-token invitation-token})
|
||||||
|
|
||||||
|
(let [event @(first @emitted)]
|
||||||
|
(t/is (= :auth-register (:id event)))
|
||||||
|
(t/is (= invitation-token (get-in event [:params :invitation-token])))))))
|
||||||
|
|
||||||
(t/deftest accept-organization-invitation-audit-event-test
|
(t/deftest accept-organization-invitation-audit-event-test
|
||||||
(let [emitted (atom [])]
|
(let [emitted (atom [])]
|
||||||
(with-redefs [st/emit! (fn
|
(with-redefs [st/emit! (fn
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user