diff --git a/.serena/memories/backend/auth-permissions-product-domains.md b/.serena/memories/backend/auth-permissions-product-domains.md index dd0b09edde..0423a838a6 100644 --- a/.serena/memories/backend/auth-permissions-product-domains.md +++ b/.serena/memories/backend/auth-permissions-product-domains.md @@ -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`. - 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. +- 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`. ## Permission model diff --git a/backend/src/app/rpc/commands/auth.clj b/backend/src/app/rpc/commands/auth.clj index ff2e0c0ae3..a838eff3be 100644 --- a/backend/src/app/rpc/commands/auth.clj +++ b/backend/src/app/rpc/commands/auth.clj @@ -31,6 +31,7 @@ [app.rpc.climit :as-alias climit] [app.rpc.commands.profile :as profile] [app.rpc.commands.teams :as teams] + [app.rpc.commands.teams-invitations :as teams-invitations] [app.rpc.doc :as-alias doc] [app.rpc.helpers :as rph] [app.setup :as-alias setup] @@ -225,23 +226,51 @@ ;; ---- 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! [cfg params] - - (when (or (not (contains? cf/flags :registration)) - (not (contains? cf/flags :login-with-password))) - (ex/raise :type :restriction - :code :registration-disabled - :hint "registration disabled")) - - (when (contains? params :invitation-token) - (let [invitation (tokens/verify cfg + (let [invitation? (contains? params :invitation-token) + invitation (when invitation? + (tokens/verify cfg {:token (:invitation-token params) - :iss :team-invitation})] - (when-not (= (:email params) (:member-email invitation)) - (ex/raise :type :restriction - :code :email-does-not-match-invitation - :hint "email should match the invitation")))) + :iss :team-invitation}))] + + (when (or (not (contains? cf/flags :login-with-password)) + (and (not (contains? cf/flags :registration)) + (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) (email.blacklist/contains? cfg (:email params))) @@ -467,6 +496,9 @@ (defn register-profile [{:keys [::db/conn] :as cfg} {:keys [token] :as params}] (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 (:accept-newsletter-updates params) (update :props assoc :newsletter-updates true)) @@ -484,9 +516,6 @@ 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) (assoc :from-invitation (some? invitation)))] diff --git a/backend/src/app/rpc/commands/teams_invitations.clj b/backend/src/app/rpc/commands/teams_invitations.clj index f78ef08c70..fe8abd5b24 100644 --- a/backend/src/app/rpc/commands/teams_invitations.clj +++ b/backend/src/app/rpc/commands/teams_invitations.clj @@ -70,6 +70,24 @@ [sql:check-recent-invitation team-id email])] (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 [cfg {:keys [profile-id valid-until organization-id organization-name team-id member-id member-email role]}] (tokens/generate cfg diff --git a/backend/src/app/rpc/commands/verify_token.clj b/backend/src/app/rpc/commands/verify_token.clj index 4a7d8a8439..ac0875cb56 100644 --- a/backend/src/app/rpc/commands/verify_token.clj +++ b/backend/src/app/rpc/commands/verify_token.clj @@ -21,6 +21,7 @@ [app.rpc :as-alias rpc] [app.rpc.commands.profile :as profile] [app.rpc.commands.teams :as teams] + [app.rpc.commands.teams-invitations :as teams-invitations] [app.rpc.doc :as-alias doc] [app.rpc.helpers :as rph] [app.rpc.quotes :as quotes] @@ -102,12 +103,6 @@ ;; --- Team Invitation -(def ^:private sql:get-organization-invitation - "SELECT * - FROM team_invitation - WHERE email_to = ? - AND org_id = ?") - (def ^:private sql:delete-organization-invitation "DELETE FROM team_invitation WHERE email_to = ? @@ -198,17 +193,16 @@ :code :invalid-invitation-token :hint "invitation token contains unexpected data")) - (let [invitation (if organization-id - (db/exec-one! conn [sql:get-organization-invitation member-email organization-id]) - (db/get* conn :team-invitation - {:email-to member-email - :team-id team-id})) + (let [member-email (profile/clean-email member-email) + claims (assoc claims :member-email member-email) + invitation (teams-invitations/active-invitation + cfg + claims + {::db/for-update true}) profile (db/get* conn :profile {:id profile-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 (do @@ -346,11 +340,12 @@ "no invitation associated with the token"))) ;; 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 - ;; token and registration is enabled, we redirect user the the register page. + ;; redirect user to login. If no member-id is present this is an invitation + ;; for a new user — send them to the register page. Invitations bypass + ;; the disable-registration flag per documentation. {:invitation-token token :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})))) ;; --- Default diff --git a/backend/test/backend_tests/rpc_profile_test.clj b/backend/test/backend_tests/rpc_profile_test.clj index 5f5b803c4e..ec54555701 100644 --- a/backend/test/backend_tests/rpc_profile_test.clj +++ b/backend/test/backend_tests/rpc_profile_test.clj @@ -16,9 +16,11 @@ [app.nitrate :as nitrate] [app.rpc :as-alias rpc] [app.rpc.commands.profile :as profile] + [app.rpc.commands.teams-invitations :as teams-invitations] [app.tokens :as tokens] [backend-tests.helpers :as th] [clojure.java.io :as io] + [clojure.string :as cstring] [clojure.test :as t] [cuerdas.core :as str] [datoteka.fs :as fs] @@ -664,6 +666,172 @@ (let [profile (th/db-get :profile {:email "hello@example.com"})] (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 ;; With email-verification ENABLED (the default), a brand-new ;; profile created via the invitation flow is NOT active yet, so @@ -769,28 +937,8 @@ (t/is (= :restriction (:type edata))) (t/is (= :email-does-not-match-invitation (:code edata)))))) -(t/deftest prepare-and-register-with-invitation-and-disabled-registration-1 - (with-redefs [app.config/flags [:disable-registration]] - (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]] +(t/deftest prepare-register-with-invitation-and-disabled-registration-and-wrong-email + (with-redefs [app.config/flags #{:login-with-password}] (let [itoken (tokens/generate th/*system* {:iss :team-invitation :exp (ct/in-future "48h") @@ -808,10 +956,10 @@ (t/is (not (th/success? out))) (let [edata (-> out :error ex-data)] (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 - (with-redefs [app.config/flags [:disable-login-with-password]] +(t/deftest prepare-register-with-invitation-and-disabled-login-with-password + (with-redefs [app.config/flags #{:registration}] (let [itoken (tokens/generate th/*system* {:iss :team-invitation :exp (ct/in-future "48h") @@ -831,6 +979,62 @@ (t/is (= :restriction (:type 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 (with-redefs [app.config/flags #{}] (let [data {::th/type :prepare-register-profile diff --git a/docs/technical-guide/configuration.md b/docs/technical-guide/configuration.md index cb5a4465c6..9fd0282761 100644 --- a/docs/technical-guide/configuration.md +++ b/docs/technical-guide/configuration.md @@ -96,13 +96,25 @@ enabled with enable-email-whitelist flag. For autoenable it when PENPOT_REGISTRATION_DOMAIN_WHITELIST is set with not-empty content. -Penpot also comes with an option to completely disable the registration process; -for this, use the following flag: +Penpot also comes with an option to disable public registration. Users with a +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 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. ### Authentication Providers @@ -433,7 +445,8 @@ This is an example of a demo configuration: 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 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 diff --git a/docs/technical-guide/getting-started/docker.md b/docs/technical-guide/getting-started/docker.md index 66d7a311ba..92c8df6ca0 100644 --- a/docs/technical-guide/getting-started/docker.md +++ b/docs/technical-guide/getting-started/docker.md @@ -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, 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 docker exec -ti penpot-penpot-backend-1 python3 manage.py create-profile diff --git a/frontend/test/frontend_tests/data/nitrate_test.cljs b/frontend/test/frontend_tests/data/nitrate_test.cljs index 3937fb7afa..19ed782d0d 100644 --- a/frontend/test/frontend_tests/data/nitrate_test.cljs +++ b/frontend/test/frontend_tests/data/nitrate_test.cljs @@ -122,6 +122,24 @@ (t/is (contains? event :days-since-member-added)) (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 (let [emitted (atom [])] (with-redefs [st/emit! (fn