From c598656f6016440aa17686799d774757a396c80a Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 20 Jul 2023 10:54:10 +0200 Subject: [PATCH] :tada: Add PassKeys and 2FA support --- backend/deps.edn | 6 +- backend/src/app/http/errors.clj | 6 + backend/src/app/migrations.clj | 4 +- .../sql/0105-add-profile-credential-table.sql | 19 + backend/src/app/rpc.clj | 1 + backend/src/app/rpc/commands/auth.clj | 130 ++++--- backend/src/app/rpc/commands/profile.clj | 77 ++-- backend/src/app/rpc/commands/webauthn.clj | 334 ++++++++++++++++++ backend/src/app/util/totp.clj | 56 +++ common/deps.edn | 3 + common/src/app/common/schema.cljc | 21 ++ .../images/icons/{icon-key.svg => key.svg} | 0 frontend/resources/images/passkey.png | Bin 0 -> 59528 bytes .../resources/styles/main/layouts/login.scss | 23 ++ .../main/partials/dashboard-settings.scss | 103 ++++++ frontend/src/app/main/data/users.cljs | 189 +++++++++- frontend/src/app/main/errors.cljs | 6 +- frontend/src/app/main/ui.cljs | 3 +- frontend/src/app/main/ui/auth/login.cljs | 74 +++- frontend/src/app/main/ui/icons.cljs | 4 +- frontend/src/app/main/ui/routes.cljs | 3 +- frontend/src/app/main/ui/settings.cljs | 6 +- .../app/main/ui/settings/change_email.cljs | 2 +- .../src/app/main/ui/settings/options.cljs | 141 ++++++-- .../src/app/main/ui/settings/passkeys.cljs | 144 ++++++++ .../src/app/main/ui/settings/profile.cljs | 6 +- .../src/app/main/ui/settings/sidebar.cljs | 38 +- frontend/translations/en.po | 50 +++ 28 files changed, 1279 insertions(+), 170 deletions(-) create mode 100644 backend/src/app/migrations/sql/0105-add-profile-credential-table.sql create mode 100644 backend/src/app/rpc/commands/webauthn.clj create mode 100644 backend/src/app/util/totp.clj rename frontend/resources/images/icons/{icon-key.svg => key.svg} (100%) create mode 100644 frontend/resources/images/passkey.png create mode 100644 frontend/src/app/main/ui/settings/passkeys.cljs diff --git a/backend/deps.edn b/backend/deps.edn index 8517e8e1e..cb8f172e6 100644 --- a/backend/deps.edn +++ b/backend/deps.edn @@ -18,6 +18,9 @@ io.lettuce/lettuce-core {:mvn/version "6.2.6.RELEASE"} java-http-clj/java-http-clj {:mvn/version "0.4.3"} + com.webauthn4j/webauthn4j-core {:mvn/version "0.21.3.RELEASE"} + dev.samstevens.totp/totp {:mvn/version "1.7.1"} + funcool/yetti {:git/tag "v9.16" :git/sha "7df3e08" @@ -33,9 +36,6 @@ io.whitfin/siphash {:mvn/version "2.0.0"} - buddy/buddy-hashers {:mvn/version "2.0.167"} - buddy/buddy-sign {:mvn/version "3.5.351"} - com.github.ben-manes.caffeine/caffeine {:mvn/version "3.1.8"} org.jsoup/jsoup {:mvn/version "1.16.1"} diff --git a/backend/src/app/http/errors.clj b/backend/src/app/http/errors.clj index 4b22cb493..d7edcdd9a 100644 --- a/backend/src/app/http/errors.clj +++ b/backend/src/app/http/errors.clj @@ -103,6 +103,12 @@ :else {::yrs/status 400 ::yrs/body data}))) +(defmethod handle-exception :negotiation + [err _] + (let [data (ex-data err)] + {::yrs/status 412 + ::yrs/body data})) + (defmethod handle-exception :assertion [error request] (binding [l/*context* (request->context request)] diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index d5f6f0271..8a48a4369 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -324,12 +324,14 @@ {:name "0104-mod-file-thumbnail-table" :fn (mg/resource "app/migrations/sql/0104-mod-file-thumbnail-table.sql")} + {:name "0105-add-profile-credential-table" + :fn (mg/resource "app/migrations/sql/0105-add-profile-credential-table.sql")} + {:name "0105-mod-file-change-table" :fn (mg/resource "app/migrations/sql/0105-mod-file-change-table.sql")} {:name "0105-mod-server-error-report-table" :fn (mg/resource "app/migrations/sql/0105-mod-server-error-report-table.sql")} - ]) (defn apply-migrations! diff --git a/backend/src/app/migrations/sql/0105-add-profile-credential-table.sql b/backend/src/app/migrations/sql/0105-add-profile-credential-table.sql new file mode 100644 index 000000000..06f594afe --- /dev/null +++ b/backend/src/app/migrations/sql/0105-add-profile-credential-table.sql @@ -0,0 +1,19 @@ +CREATE TABLE profile_passkey ( + id uuid NOT NULL DEFAULT uuid_generate_v4() PRIMARY KEY, + profile_id uuid NOT NULL REFERENCES profile(id) ON DELETE CASCADE DEFERRABLE, + + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + credential_id bytea NOT NULL, + attestation bytea NOT NULL, + sign_count bigint NOT NULL +); + +CREATE INDEX profile__passkey__profile_id ON profile_passkey (credential_id, profile_id); + +CREATE TABLE profile_challenge ( + profile_id uuid PRIMARY KEY REFERENCES profile(id) ON DELETE CASCADE DEFERRABLE, + data bytea NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +) diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index 201e83062..2610c6676 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -225,6 +225,7 @@ 'app.rpc.commands.teams 'app.rpc.commands.verify-token 'app.rpc.commands.viewer + 'app.rpc.commands.webauthn 'app.rpc.commands.webhooks) (map (partial process-method cfg)) (into {})))) diff --git a/backend/src/app/rpc/commands/auth.clj b/backend/src/app/rpc/commands/auth.clj index d765a5598..49e0dbcf4 100644 --- a/backend/src/app/rpc/commands/auth.clj +++ b/backend/src/app/rpc/commands/auth.clj @@ -22,11 +22,13 @@ [app.rpc :as-alias rpc] [app.rpc.commands.profile :as profile] [app.rpc.commands.teams :as teams] + [app.rpc.commands.webauthn :as webauthn] [app.rpc.doc :as-alias doc] [app.rpc.helpers :as rph] [app.tokens :as tokens] [app.util.services :as sv] [app.util.time :as dt] + [app.util.totp :as totp] [cuerdas.core :as str])) (def schema:password @@ -37,8 +39,65 @@ ;; ---- COMMAND: login with password +(defn- check-password! + [cfg profile {:keys [password]}] + (if (= (:password profile) "!") + (ex/raise :type :validation + :code :account-without-password + :hint "the current account does not have password") + (let [result (profile/verify-password cfg password (:password profile))] + (when (:update result) + (l/trace :hint "updating profile password" :id (:id profile) :email (:email profile)) + (profile/update-profile-password! cfg (assoc profile :password password))) + (:valid result)))) + +(defn validate-profile! + [cfg {:keys [totp] :as params} {:keys [props] :as profile}] + + (when-not profile + (ex/raise :type :validation + :code :wrong-credentials)) + + (when-not (:is-active profile) + (ex/raise :type :validation + :code :wrong-credentials)) + + (when (:is-blocked profile) + (ex/raise :type :restriction + :code :profile-blocked)) + + (when (= :totp (:2fa props)) + (if (some? totp) + (when-not (totp/valid-code? (:2fa/secret props) totp) + (ex/raise :type :negotiation + :code :invalid-totp)) + (ex/raise :type :negotiation + :code :totp))) + + (when-not (check-password! cfg profile params) + (ex/raise :type :validation + :code :wrong-credentials)) + + (when (= :passkey (:2fa props)) + ;; NOTE: as we raise negotiation exception the current transaction + ;; will be aborted; so for passkey we need another, parallel + ;; transaction for persist the new challege + (let [data (db/with-atomic cfg + (webauthn/prepare-login-with-passkey cfg profile))] + (ex/raise :type :negotiation + :code :passkey + ::ex/data data))) + + (when-let [deleted-at (:deleted-at profile)] + (when (dt/is-after? (dt/now) deleted-at) + (ex/raise :type :validation + :code :wrong-credentials))) + + profile) + + (defn login-with-password - [{:keys [::db/pool] :as cfg} {:keys [email password] :as params}] + [{:keys [::db/pool] :as cfg} {:keys [email] :as params}] (when-not (or (contains? cf/flags :login) (contains? cf/flags :login-with-password)) @@ -46,61 +105,32 @@ :code :login-disabled :hint "login is disabled in this instance")) - (letfn [(check-password [conn profile password] - (if (= (:password profile) "!") - (ex/raise :type :validation - :code :account-without-password - :hint "the current account does not have password") - (let [result (profile/verify-password cfg password (:password profile))] - (when (:update result) - (l/trace :hint "updating profile password" :id (:id profile) :email (:email profile)) - (profile/update-profile-password! conn (assoc profile :password password))) - (:valid result)))) + (db/with-atomic [conn pool] + (let [cfg (assoc cfg ::db/conn conn) + profile (->> (profile/get-profile-by-email conn email) + (validate-profile! cfg params) + (profile/strip-private-attrs)) - (validate-profile [conn profile] - (when-not profile - (ex/raise :type :validation - :code :wrong-credentials)) - (when-not (:is-active profile) - (ex/raise :type :validation - :code :wrong-credentials)) - (when (:is-blocked profile) - (ex/raise :type :restriction - :code :profile-blocked)) - (when-not (check-password conn profile password) - (ex/raise :type :validation - :code :wrong-credentials)) - (when-let [deleted-at (:deleted-at profile)] - (when (dt/is-after? (dt/now) deleted-at) - (ex/raise :type :validation - :code :wrong-credentials))) + invitation (when-let [token (:invitation-token params)] + (tokens/verify (::main/props cfg) {:token token :iss :team-invitation})) - profile)] - - (db/with-atomic [conn pool] - (let [profile (->> (profile/get-profile-by-email conn email) - (validate-profile conn) - (profile/strip-private-attrs)) - - invitation (when-let [token (:invitation-token params)] - (tokens/verify (::main/props cfg) {:token token :iss :team-invitation})) - - ;; If invitation member-id does not matches the profile-id, we just proceed to ignore the - ;; invitation because invitations matches exactly; and user can't login with other email and - ;; accept invitation with other email - response (if (and (some? invitation) (= (:id profile) (:member-id invitation))) - {:invitation-token (:invitation-token params)} - (assoc profile :is-admin (let [admins (cf/get :admins)] - (contains? admins (:email profile)))))] - (-> response - (rph/with-transform (session/create-fn cfg (:id profile))) - (rph/with-meta {::audit/props (audit/profile->props profile) - ::audit/profile-id (:id profile)})))))) + ;; If invitation member-id does not matches the profile-id, we just proceed to ignore the + ;; invitation because invitations matches exactly; and user can't login with other email and + ;; accept invitation with other email + response (if (and (some? invitation) (= (:id profile) (:member-id invitation))) + {:invitation-token (:invitation-token params)} + (assoc profile :is-admin (let [admins (cf/get :admins)] + (contains? admins (:email profile)))))] + (-> response + (rph/with-transform (session/create-fn cfg (:id profile))) + (rph/with-meta {::audit/props (audit/profile->props profile) + ::audit/profile-id (:id profile)}))))) (def schema:login-with-password [:map {:title "login-with-password"} [:email ::sm/email] [:password schema:password] + [:totp {:optional true} ::sm/word-string] [:invitation-token {:optional true} schema:token]]) (sv/defmethod ::login-with-password @@ -464,5 +494,3 @@ ::sm/params schema:request-profile-recovery} [cfg params] (request-profile-recovery cfg params)) - - diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index c79e4c773..745902c20 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -7,7 +7,6 @@ (ns app.rpc.commands.profile (:require [app.auth :as auth] - [app.common.data :as d] [app.common.data.macros :as dm] [app.common.exceptions :as ex] [app.common.schema :as sm] @@ -27,6 +26,7 @@ [app.tokens :as tokens] [app.util.services :as sv] [app.util.time :as dt] + [app.util.totp :as totp] [cuerdas.core :as str])) (declare check-profile-existence!) @@ -36,6 +36,7 @@ (declare get-profile) (declare strip-private-attrs) (declare verify-password) +(declare ^:private process-props) (def schema:profile [:map {:title "Profile"} @@ -83,7 +84,7 @@ (def schema:update-profile [:map {:title "update-profile"} - [:fullname [::sm/word-string {:max 250}]] + [:fullname {:optional true} [::sm/word-string {:max 250}]] [:lang {:optional true} [:string {:max 5}]] [:theme {:optional true} [:string {:max 250}]]]) @@ -91,11 +92,7 @@ {::doc/added "1.0" ::sm/params schema:update-profile ::sm/result schema:profile} - [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id fullname lang theme] :as params}] - - (dm/assert! - "expected valid profile data" - (profile? params)) + [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id fullname lang theme props] :as params}] (db/with-atomic [conn pool] ;; NOTE: we need to retrieve the profile independently if we use @@ -104,26 +101,25 @@ (let [profile (-> (db/get-by-id conn :profile profile-id ::db/for-update? true) (decode-row)) - ;; Update the profile map with direct params - profile (-> profile - (assoc :fullname fullname) - (assoc :lang lang) - (assoc :theme theme)) - ] + props (cond-> (process-props props profile) + (and (contains? props :2fa) + (= :totp (:2fa props)) + (not= :totp (dm/get-in profile [:props :2fa]))) + (assoc :2fa/secret (totp/gen-secret))) - (db/update! conn :profile - {:fullname fullname - :lang lang - :theme theme - :props (db/tjson (:props profile))} - {:id profile-id}) + params (cond-> {:props (db/tjson props)} + (some? fullname) (assoc :fullname fullname) + (some? lang) (assoc :lang lang) + (some? theme) (assoc :theme theme)) + + profile (db/update! conn :profile params {:id profile-id}) + profile (decode-row profile)] (-> profile + (update :props filter-props) (strip-private-attrs) - (d/without-nils) (rph/with-meta {::audit/props (audit/profile->props profile)}))))) - ;; --- MUTATION: Update Password (declare validate-password!) @@ -153,7 +149,7 @@ :code :email-as-password :hint "you can't use your email as password")) - (update-profile-password! conn (assoc profile :password password)) + (update-profile-password! cfg (assoc profile :password password)) (invalidate-profile-session! cfg profile-id session-id) nil))) @@ -173,7 +169,7 @@ profile)) (defn update-profile-password! - [conn {:keys [id password] :as profile}] + [{:keys [::db/conn]} {:keys [id password] :as profile}] (when-not (db/read-only? conn) (db/update! conn :profile {:password (auth/derive-password password)} @@ -315,6 +311,18 @@ ;; --- MUTATION: Update Profile Props +(defn- process-props + [props profile] + (reduce-kv (fn [props k v] + ;; We don't accept namespaced keys + (if (simple-ident? k) + (if (nil? v) + (dissoc props k) + (assoc props k v)) + props)) + (:props profile) + props)) + (def schema:update-profile-props [:map {:title "update-profile-props"} [:props [:map-of :keyword :any]]]) @@ -325,15 +333,7 @@ [{:keys [::db/pool]} {:keys [::rpc/profile-id props]}] (db/with-atomic [conn pool] (let [profile (get-profile conn profile-id ::db/for-update? true) - props (reduce-kv (fn [props k v] - ;; We don't accept namespaced keys - (if (simple-ident? k) - (if (nil? v) - (dissoc props k) - (assoc props k v)) - props)) - (:props profile) - props)] + props (process-props props profile)] (db/update! conn :profile {:props (db/tjson props)} @@ -373,6 +373,19 @@ (rph/with-transform {} (session/delete-fn cfg))))) +;; --- TOTP/2FA + +(sv/defmethod ::get-profile-2fa-secret + [{:keys [::db/pool]} {:keys [::rpc/profile-id]}] + (dm/with-open [conn (db/open pool)] + (let [{:keys [props] :as profile} (get-profile conn profile-id)] + (when (= :totp (:2fa props)) + (let [secret (:2fa/secret props) + image (totp/get-qrcode-image secret (:email profile))] + {:secret secret + :image image}))))) + + ;; --- HELPERS (def sql:owned-teams diff --git a/backend/src/app/rpc/commands/webauthn.clj b/backend/src/app/rpc/commands/webauthn.clj new file mode 100644 index 000000000..c79f139c0 --- /dev/null +++ b/backend/src/app/rpc/commands/webauthn.clj @@ -0,0 +1,334 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.rpc.commands.webauthn + (:require + [app.common.data.macros :as dm] + [app.common.exceptions :as ex] + [app.common.logging :as l] + [app.common.schema :as sm] + [app.common.uri :as u] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] + [app.http.session :as session] + [app.loggers.audit :as audit] + [app.rpc :as-alias rpc] + [app.rpc.commands.profile :as profile] + [app.rpc.doc :as-alias doc] + [app.rpc.helpers :as rph] + [app.util.services :as sv] + [buddy.core.nonce :as bn] + [cuerdas.core :as str]) + (:import + com.webauthn4j.WebAuthnManager + com.webauthn4j.authenticator.Authenticator + com.webauthn4j.authenticator.AuthenticatorImpl + com.webauthn4j.converter.AttestedCredentialDataConverter + com.webauthn4j.converter.util.ObjectConverter + com.webauthn4j.data.AuthenticationData + com.webauthn4j.data.AuthenticationParameters + com.webauthn4j.data.AuthenticationRequest + com.webauthn4j.data.RegistrationData + com.webauthn4j.data.RegistrationParameters + com.webauthn4j.data.RegistrationRequest + com.webauthn4j.data.attestation.authenticator.AttestedCredentialData + com.webauthn4j.data.client.Origin + com.webauthn4j.data.client.challenge.DefaultChallenge + com.webauthn4j.server.ServerProperty)) + +(declare ^:private create-challenge!) +(declare ^:private get-current-challenge) +(declare ^:private prepare-registration-data) +(declare ^:private prepare-auth-data) +(declare ^:private validate-registration-data!) +(declare ^:private validate-auth-data!) +(declare ^:private get-attestation) +(declare ^:private update-passkey!) +(declare ^:private get-sign-count) +(declare ^:private get-profile) +(declare ^:private get-credentials) +(declare ^:private get-passkey) +(declare ^:private encode-attestation) +(declare ^:private decode-attestation) + +(def ^:private manager + (delay (WebAuthnManager/createNonStrictWebAuthnManager))) + +;; TODO: output schema + +(sv/defmethod ::prepare-profile-passkey-registration + {::doc/added "1.20"} + [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id]}] + (db/with-atomic [conn pool] + (let [cfg (assoc cfg ::db/conn conn) + profile (get-profile cfg profile-id) + challenge (create-challenge! cfg profile-id) + uri (u/uri (cf/get :public-uri))] + {:challenge challenge + :user-id (uuid/get-bytes profile-id) + :user-email (:email profile) + :user-name (:fullname profile) + :rp-id (:host uri) + :rp-name "Penpot"}))) + +(def ^:private schema:create-profile-passkey + [:map {:title "create-profile-passkey"} + [:credential-id ::sm/bytes] + [:attestation ::sm/bytes] + [:client-data ::sm/bytes]]) + +(def ^:private schema:partial-passkey + [:map {:title "PartilProfilePasskey"} + [:id ::sm/uuid] + [:created-at ::sm/inst] + [:profile-id ::sm/uuid]]) + +(sv/defmethod ::create-profile-passkey + {::sm/params schema:create-profile-passkey + ::sm/result schema:partial-passkey + ::doc/added "1.20"} + [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id credential-id] :as params}] + + (db/with-atomic [conn pool] + (let [cfg (assoc cfg ::db/conn conn) + challenge (get-current-challenge cfg profile-id) + regdata (prepare-registration-data params)] + + (validate-registration-data! regdata challenge) + + (let [attestation (get-attestation regdata) + sign-count (get-sign-count regdata) + passkey (db/insert! conn :profile-passkey + {:id (uuid/next) + :profile-id profile-id + :credential-id credential-id + :attestation attestation + :sign-count sign-count})] + (select-keys passkey [:id :created-at :profile-id]))))) + + +;; FIXME: invitation token handling +(def ^:private schema:prepare-login-with-passkey + [:map {:title "prepare-login-with-passkey"} + [:email ::sm/email]]) + +(def ^:private schema:passkey-prepared-login + [:map {:title "PasskeyPreparedLogin"} + [:passkeys [:set ::sm/bytes]] + [:challenge ::sm/bytes]]) + +(declare prepare-login-with-passkey) + +(sv/defmethod ::prepare-login-with-passkey + {::rpc/auth false + ::doc/added "1.20" + ::sm/params schema:prepare-login-with-passkey + ::sm/result schema:passkey-prepared-login} + [{:keys [::db/pool] :as cfg} {:keys [email]}] + (db/with-atomic [conn pool] + (let [cfg (assoc cfg ::db/conn conn) + profile (get-profile cfg email) + props (:props profile)] + + (when (not= :all (:passkey props :all)) + (ex/raise :type :restriction + :code :passkey-disabled)) + + (prepare-login-with-passkey cfg profile)))) + +(defn prepare-login-with-passkey + [cfg {:keys [id] :as profile}] + (let [credentials (get-credentials cfg id) + challenge (create-challenge! cfg id) + uri (u/uri (cf/get :public-uri))] + + {:credentials credentials + :challenge challenge + :rp-id (:host uri)})) + +;; FIXME: invitation token handling +(def ^:private schema:login-with-passkey + [:map {:title "login-with-passkey"} + [:credential-id ::sm/bytes] + [:user-handle [:maybe ::sm/bytes]] + [:auth-data ::sm/bytes] + [:client-data ::sm/bytes]]) + +(sv/defmethod ::login-with-passkey + {::rpc/auth false + ::doc/added "1.20" + ::sm/params schema:login-with-passkey} + [{:keys [::db/pool] :as cfg} params] + (db/with-atomic [conn pool] + (let [cfg (assoc cfg ::db/conn conn) + passkey (get-passkey cfg params) + challenge (get-current-challenge cfg (:profile-id passkey)) + authdata (prepare-auth-data params)] + + (validate-auth-data! authdata passkey challenge) + (update-passkey! cfg passkey authdata) + + (let [profile (->> (profile/get-profile conn (:profile-id passkey)) + (profile/strip-private-attrs))] + (-> profile + (rph/with-transform (session/create-fn cfg (:id profile))) + (rph/with-meta {::audit/props (audit/profile->props profile) + ::audit/profile-id (:id profile)})))))) + +(sv/defmethod ::get-profile-passkeys + [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id]}] + (db/query pool :profile-passkey {:profile-id profile-id} + {:columns [:id :profile-id :created-at :updated-at :sign-count]})) + +(def ^:private schema:delete-profile-passkey + [:map {:title "delete-profile-passkey"} + [:id ::sm/uuid]]) + +(sv/defmethod ::delete-profile-passkey + {::doc/added "1.20" + ::sm/params schema:delete-profile-passkey} + [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id]}] + (db/delete! pool :profile-passkey {:profile-id profile-id :id id}) + nil) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; IMPL HELPERS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- create-challenge! + [{:keys [::db/conn]} profile-id] + (let [data (bn/random-nonce 32) + sql (dm/str "insert into profile_challenge values (?,?,now()) " + " on conflict (profile_id) " + " do update set data=?, created_at=now()")] + (db/exec-one! conn [sql profile-id data data]) + data)) + +(defn- get-current-challenge + [{:keys [::db/conn]} profile-id] + (let [row (db/get conn :profile-challenge {:profile-id profile-id})] + (DefaultChallenge. (:data row)))) + +(defn get-server-property + [challenge] + (let [uri (cf/get :public-uri) + host (-> uri u/uri :host) + orig (Origin/create ^String uri)] + (ServerProperty. ^Origin orig + ^String host + ^bytes challenge + nil))) + +(defn- get-profile + [{:keys [::db/conn]} email] + (profile/decode-row + (db/get* conn :profile + {:email (str/lower email)} + {:columns [:id :email :fullname :props]}))) + +(defn- get-credentials + [{:keys [::db/conn]} profile-id] + (->> (db/query conn :profile-passkey + {:profile-id profile-id} + {:columns [:credential-id]}) + (into #{} (map :credential-id)))) + +(defn- get-passkey + [{:keys [::db/conn]} {:keys [credential-id user-handle]}] + (let [params (cond-> {:credential-id credential-id} + (some? user-handle) + (assoc :profile-id (uuid/from-bytes user-handle)))] + (db/get conn :profile-passkey params))) + +(defn- update-passkey! + [{:keys [::db/conn]} passkey ^AuthenticationData authdata] + (let [credential-id (:credential-id passkey) + sign-count (.. authdata getAuthenticatorData getSignCount)] + (db/update! conn :profile-passkey + {:sign-count sign-count} + {:credential-id credential-id}))) + +(defn- prepare-auth-data + [{:keys [credential-id user-handle auth-data client-data signature] :as params}] + (let [request (AuthenticationRequest. ^bytes credential-id + ^bytes user-handle + ^bytes auth-data + ^bytes client-data + nil + ^bytes signature)] + (.parse ^WebAuthnManager @manager + ^AuthenticationRequest request))) + +(defn- prepare-registration-data + [{:keys [attestation client-data]}] + (let [request (RegistrationRequest. attestation client-data)] + (.parse ^WebAuthnManager @manager + ^RegistrationRequest request))) + +(defn- validate-registration-data! + [regdata challenge] + (let [property (get-server-property challenge) + params (RegistrationParameters. ^ServerProperty property false true)] + (try + (.validate ^WebAuthnManager @manager + ^RegistrationData regdata + ^RegistrationParameters params) + (catch Throwable cause + (ex/raise :type :validation + :code :webauthn-error + :cause cause))))) + +(defn- get-authenticator + [{:keys [attestation sign-count]}] + (let [attestation (decode-attestation attestation)] + (AuthenticatorImpl. ^AttestedCredentialData attestation nil ^long sign-count))) + +(defn- validate-auth-data! + [authdata passkey challenge] + (let [property (get-server-property challenge) + auth (get-authenticator passkey) + params (AuthenticationParameters. ^ServerProperty property + ^Authenticator auth + nil + false + true)] + (try + (.validate ^WebAuthnManager @manager + ^AuthenticationData authdata + ^AuthenticationParameters params) + (catch Throwable cause + (l/err :hint "validation error on auth request" :cause cause) + (ex/raise :type :validation + :code :webauthn-error + :cause cause))))) + +(defn- get-attestation + [^RegistrationData regdata] + (encode-attestation + (.. regdata + (getAttestationObject) + (getAuthenticatorData) + (getAttestedCredentialData)))) + +(defn- get-sign-count + [^RegistrationData regdata] + (.. regdata + (getAttestationObject) + (getAuthenticatorData) + (getSignCount))) + +(defn- encode-attestation + [attestation] + (assert (instance? AttestedCredentialData attestation) "expected AttestedCredentialData instance") + (let [converter (AttestedCredentialDataConverter. (ObjectConverter.))] + (.convert converter ^AttestedCredentialData attestation))) + +(defn- decode-attestation + [attestation] + (assert (bytes? attestation) "expected byte array") + (let [converter (AttestedCredentialDataConverter. (ObjectConverter.))] + (.convert converter ^bytes attestation))) diff --git a/backend/src/app/util/totp.clj b/backend/src/app/util/totp.clj new file mode 100644 index 000000000..9ce728588 --- /dev/null +++ b/backend/src/app/util/totp.clj @@ -0,0 +1,56 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.util.totp + (:import + dev.samstevens.totp.code.DefaultCodeGenerator + dev.samstevens.totp.code.DefaultCodeVerifier + dev.samstevens.totp.code.CodeVerifier + dev.samstevens.totp.code.HashingAlgorithm + dev.samstevens.totp.qr.QrData + dev.samstevens.totp.qr.QrData$Builder + dev.samstevens.totp.qr.ZxingPngQrGenerator + dev.samstevens.totp.secret.DefaultSecretGenerator + dev.samstevens.totp.time.SystemTimeProvider + dev.samstevens.totp.util.Utils)) + +(defn get-verifier + [] + (DefaultCodeVerifier. + (DefaultCodeGenerator. HashingAlgorithm/SHA1 6) + (SystemTimeProvider.))) + +(defn valid-code? + [secret code] + (let [verifier (doto (get-verifier) + (.setTimePeriod 30) + (.setAllowedTimePeriodDiscrepancy 2)) + result (.isValidCode ^CodeVerifier verifier + ^String secret + ^String code)] + result)) + +(defn gen-secret + ([] (gen-secret 32)) + ([n] + (let [sgen (DefaultSecretGenerator. (int n))] + (.generate ^DefaultSecretGenerator sgen)))) + + +(defn get-qrcode-image + [secret email] + (let [data (.. (QrData$Builder.) + (label ^String email) + (secret ^String secret) + (issuer "Penpot") + (digits 6) + (period 30) + (build)) + imgen (ZxingPngQrGenerator.) + imgdt (.generate imgen ^QrData data) + imgmt (.getImageMimeType imgen)] + (Utils/getDataUriForImage imgdt imgmt))) + diff --git a/common/deps.edn b/common/deps.edn index 01ee915f0..3f2921e41 100644 --- a/common/deps.edn +++ b/common/deps.edn @@ -18,6 +18,9 @@ selmer/selmer {:mvn/version "1.12.59"} criterium/criterium {:mvn/version "0.4.6"} + buddy/buddy-hashers {:mvn/version "2.0.167"} + buddy/buddy-sign {:mvn/version "3.5.351"} + metosin/jsonista {:mvn/version "0.3.7"} metosin/malli {:mvn/version "0.11.0"} diff --git a/common/src/app/common/schema.cljc b/common/src/app/common/schema.cljc index 494b1df2a..0c9c90bf1 100644 --- a/common/src/app/common/schema.cljc +++ b/common/src/app/common/schema.cljc @@ -8,6 +8,7 @@ (:refer-clojure :exclude [deref merge parse-uuid]) #?(:cljs (:require-macros [app.common.schema :refer [ignoring]])) (:require + #?(:clj [buddy.core.codecs :as bc]) [app.common.data.macros :as dm] [app.common.schema.generators :as sg] [app.common.schema.openapi :as-alias oapi] @@ -489,6 +490,26 @@ ::oapi/format "uri" ::oapi/decode (comp u/uri str/trim)}}) +#?(:clj + (def! ::bytes + {:type ::bytes + :pred bytes? + :type-properties + {:title "bytes" + :description "bytes" + :error/message "expected a bytes instance" + :gen/gen (sg/word-string) + ::oapi/decode (fn [v] + (if (string? v) + (-> v bc/str->bytes bc/b64->bytes) + v)) + ::oapi/encode (fn [v] + (if (bytes? v) + (-> v bc/bytes->b64 bc/bytes->str) + v)) + ::oapi/type "bytes" + ::oapi/format "string"}})) + ;; ---- PREDICATES (def safe-int? diff --git a/frontend/resources/images/icons/icon-key.svg b/frontend/resources/images/icons/key.svg similarity index 100% rename from frontend/resources/images/icons/icon-key.svg rename to frontend/resources/images/icons/key.svg diff --git a/frontend/resources/images/passkey.png b/frontend/resources/images/passkey.png new file mode 100644 index 0000000000000000000000000000000000000000..556fc6b90f1439d73b7b0dd98e354bb80a734535 GIT binary patch literal 59528 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 X&?M+roMk~cQem5#i*Ettl*R~;azMR1=E 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 credentials + (map (fn [credential] + #js {:id credential :type "public-key"})) + (into-array)) + options #js {:challenge challenge + :rpId rp-id + :userVerification "preferred" + :allowCredentials credentials + :timeout 30000} + platform (.-credentials js/navigator)] + + (.get ^js platform #js {:publicKey options}))) + +(defn- login-with-passkey* + [assertion data] + (js/console.log "login-with-passkey*" assertion) + (let [credential-id (unchecked-get assertion "rawId") + response (unchecked-get assertion "response") + auth-data (unchecked-get response "authenticatorData") + client-data (unchecked-get response "clientDataJSON") + user-handle (unchecked-get response "userHandle") + signature (unchecked-get response "signature") + params (-> data + (assoc :credential-id (js/Uint8Array. credential-id)) + (assoc :auth-data (js/Uint8Array. auth-data)) + (assoc :client-data (js/Uint8Array. client-data)) + (assoc :user-handle (some-> user-handle (js/Uint8Array.))) + (assoc :signature (js/Uint8Array. signature)))] + (rp/cmd! :login-with-passkey params))) + (defn- logged-in "This is the main event that is executed once we have logged in profile. The profile can proceed from standard login or from @@ -148,9 +183,10 @@ (declare login-from-register) -(defn login - [{:keys [email password invitation-token] :as data}] - (ptk/reify ::login +(defn login-with-password + [{:keys [email password invitation-token totp] :as data}] + (prn "login-with-password" data) + (ptk/reify ::login-with-password ptk/WatchEvent (watch [_ _ stream] (let [{:keys [on-error on-success] @@ -159,6 +195,7 @@ params {:email email :password password + :totp totp :invitation-token invitation-token}] ;; NOTE: We can't take the profile value from login because @@ -171,6 +208,12 @@ ;; proceed to logout and show an error message. (->> (rp/cmd! :login-with-password (d/without-nils params)) + (rx/catch (fn [cause] + (if (and (= :negotiation (:type cause)) + (= :passkey (:code cause))) + (->> (rx/from (create-passkey-assertion cause)) + (rx/mapcat #(login-with-passkey* % data))) + (rx/throw cause)))) (rx/merge-map (fn [data] (rx/merge (rx/of (fetch-profile)) @@ -267,27 +310,17 @@ (defn update-profile [data] - (dm/assert! (profile? data)) (ptk/reify ::update-profile ptk/WatchEvent - (watch [_ _ stream] + (watch [_ _ _] (let [mdata (meta data) on-success (:on-success mdata identity) on-error (:on-error mdata rx/throw)] - (->> (rp/cmd! :update-profile (dissoc data :props)) - (rx/mapcat - (fn [_] - (rx/merge - (->> stream - (rx/filter (ptk/type? ::profile-fetched)) - (rx/take 1) - (rx/tap on-success) - (rx/ignore)) - (rx/of (profile-fetched data))))) + (->> (rp/cmd! :update-profile data) + (rx/tap on-success) + (rx/map profile-fetched) (rx/catch on-error)))))) - - ;; --- Request Email Change (defn request-email-change @@ -501,7 +534,7 @@ ptk/WatchEvent (watch [_ _ _] (->> (rp/cmd! :create-demo-profile {}) - (rx/map login))))) + (rx/map login-with-password))))) ;; --- EVENT: fetch-team-webhooks @@ -556,3 +589,121 @@ (->> (rp/cmd! :delete-access-token params) (rx/tap on-success) (rx/catch on-error)))))) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; PASSKEYS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn fetch-passkeys + [] + (ptk/reify ::fetch-passkeys + ptk/WatchEvent + (watch [_ _ _] + (->> (rp/cmd! :get-profile-passkeys) + (rx/map (fn [passkeys] + (fn [state] + (assoc state :passkeys passkeys)))))))) + +(defn delete-passkey + [{:keys [id] :as params}] + (us/assert! ::us/uuid id) + (ptk/reify ::delete-passkey + ptk/WatchEvent + (watch [_ _ _] + (let [{:keys [on-success on-error] + :or {on-success identity + on-error rx/throw}} (meta params)] + (->> (rp/cmd! :delete-profile-passkey params) + (rx/tap on-success) + (rx/catch on-error)))))) + +(defn create-passkey + [] + (letfn [(create-pubkey [{:keys [challenge user-id user-name user-email rp-id rp-name]}] + (let [user #js {:id user-id + :name user-email + :displayName user-name} + auths #js {:authenticatorAttachment "cross-platform" + :residentKey "preferred" + :requireResidentKey false + :userVerification "preferred"} + options #js {:challenge challenge + :rp #js {:id rp-id :name rp-name} + :user user + :pubKeyCredParams #js [#js {:alg -7 :type "public-key"} + #js {:alg -257 :type "public-key"}] + :authenticatorSelection auths + :timeout 30000, + :attestation "direct"} + platform (. js/navigator -credentials)] + (.create ^js platform #js {:publicKey options}))) + + (persist-pubkey [pubkey] + (let [response (unchecked-get pubkey "response") + id (unchecked-get pubkey "rawId") + attestation (unchecked-get response "attestationObject") + client-data (unchecked-get response "clientDataJSON") + params {:credential-id (js/Uint8Array. id) + :attestation (js/Uint8Array. attestation) + :client-data (js/Uint8Array. client-data)}] + (rp/cmd! :create-profile-passkey params))) + ] + + (ptk/reify ::create-passkey + ptk/WatchEvent + (watch [_ _ _] + (->> (rp/cmd! :prepare-profile-passkey-registration {}) + (rx/mapcat create-pubkey) + (rx/mapcat persist-pubkey) + (rx/map (fn [_] (fetch-passkeys))) + (rx/catch (fn [cause] + (if (instance? js/DOMException cause) + (rx/of (msg/show {:type :error + :tag :passkey + :timeout 5000 + :content (tr "errors.passkey-rejection-or-timeout")})) + (rx/throw cause))))))))) + +(defn login-with-passkey + [data] + (ptk/reify ::login-with-passkey + ptk/WatchEvent + (watch [_ _ stream] + (let [{:keys [on-error on-success] + :or {on-error rx/throw + on-success identity}} (meta data)] + + (->> (rp/cmd! :prepare-login-with-passkey data) + (rx/mapcat create-passkey-assertion) + (rx/mapcat #(login-with-passkey* % data)) + (rx/merge-map (fn [data] + (rx/merge + (rx/of (fetch-profile)) + (->> stream + (rx/filter profile-fetched?) + (rx/take 1) + (rx/map deref) + (rx/filter (complement is-authenticated?)) + (rx/tap on-error) + (rx/map #(ex/raise :type :authentication)) + (rx/observe-on :async)) + (->> stream + (rx/filter profile-fetched?) + (rx/take 1) + (rx/map deref) + (rx/filter is-authenticated?) + (rx/map (fn [profile] + (with-meta (merge data profile) + {::ev/source "login"}))) + (rx/tap on-success) + (rx/map logged-in) + (rx/observe-on :async))))) + (rx/catch (fn [cause] + (if (instance? js/DOMException cause) + (rx/of (msg/show {:type :error + :tag :passkey + :timeout 5000 + :content (tr "errors.passkey-rejection-or-timeout")})) + (rx/throw cause)))) + (rx/catch on-error)))))) diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 7bd072645..e66fdcc73 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -21,7 +21,7 @@ [cuerdas.core :as str] [potok.core :as ptk])) -(defn- print-data! +(defn print-data! [data] (-> data (dissoc ::sm/explain) @@ -30,13 +30,13 @@ (dissoc ::instance) (pp/pprint {:width 70}))) -(defn- print-explain! +(defn print-explain! [data] (when-let [explain (::sm/explain data)] (-> (sm/humanize-data explain) (pp/pprint {:width 70})))) -(defn- print-trace! +(defn print-trace! [data] (some-> data ::trace js/console.log)) diff --git a/frontend/src/app/main/ui.cljs b/frontend/src/app/main/ui.cljs index 9dca64ce6..9a186e1cd 100644 --- a/frontend/src/app/main/ui.cljs +++ b/frontend/src/app/main/ui.cljs @@ -58,7 +58,8 @@ :settings-password :settings-options :settings-feedback - :settings-access-tokens) + :settings-access-tokens + :settings-passkeys) [:& settings/settings {:route route}] :debug-icons-preview diff --git a/frontend/src/app/main/ui/auth/login.cljs b/frontend/src/app/main/ui/auth/login.cljs index fad0f9cef..a4aa49ff2 100644 --- a/frontend/src/app/main/ui/auth/login.cljs +++ b/frontend/src/app/main/ui/auth/login.cljs @@ -12,6 +12,7 @@ [app.config :as cf] [app.main.data.messages :as dm] [app.main.data.users :as du] + [app.main.errors :as err] [app.main.repo :as rp] [app.main.store :as st] [app.main.ui.components.button-link :as bl] @@ -79,8 +80,8 @@ (s/def ::invitation-token ::us/not-empty-string) (s/def ::login-form - (s/keys :req-un [::email ::password] - :opt-un [::invitation-token])) + (s/keys :req-un [::email] + :opt-un [::password ::invitation-token])) (defn handle-error-messages [errors _data] @@ -94,6 +95,9 @@ [{:keys [params on-success-callback] :as props}] (let [initial (mf/use-memo (mf/deps params) (constantly params)) + totp* (mf/use-state false) + totp? (deref totp*) + error (mf/use-state false) form (fm/use-form :spec ::login-form :validators [handle-error-messages] @@ -101,7 +105,27 @@ on-error (fn [cause] + (when (map? cause) + (err/print-trace! cause) + (err/print-data! cause) + (err/print-explain! cause)) + (cond + (and (= :totp (:code cause)) + (= :negotiation (:type cause))) + (do + (reset! totp* true) + (reset! error (tr "errors.missing-totp")) + (swap! form (fn [form] + (-> form + (update :errors assoc :totp {:message (tr "errors.missing-totp")}) + (update :touched assoc :totp true))))) + + + (and (= :restriction (:type cause)) + (= :passkey-disabled (:code cause))) + (reset! error (tr "errors.wrong-credentials")) + (and (= :restriction (:type cause)) (= :profile-blocked (:code cause))) (reset! error (tr "errors.profile-blocked")) @@ -133,13 +157,25 @@ (on-success-callback))) on-submit - (mf/use-callback - (fn [form _event] - (reset! error nil) - (let [params (with-meta (:clean-data @form) - {:on-error on-error - :on-success on-success})] - (st/emit! (du/login params))))) + (mf/use-fn + (fn [form event] + (let [event (dom/event->native-event event) + submitter (unchecked-get event "submitter") + submitter (dom/get-data submitter "role")] + + (case submitter + "login-with-passkey" + (let [params (with-meta (:clean-data @form) + {:on-error on-error + :on-success on-success})] + (st/emit! (du/login-with-passkey params))) + + "login-with-password" + (let [params (with-meta (:clean-data @form) + {:on-error on-error + :on-success on-success})] + (st/emit! (du/login-with-password params))) + nil)))) on-submit-ldap (mf/use-callback @@ -174,17 +210,35 @@ :help-icon i/eye :label (tr "auth.password")}]] + (when totp? + [:div.fields-row + [:& fm/input + {:type "text" + :name :totp + :label (tr "auth.totp")}]]) + [:div.buttons-stack (when (or (contains? cf/flags :login) (contains? cf/flags :login-with-password)) [:> fm/submit-button* {:label (tr "auth.login-submit") + :data-role "login-with-password" :data-test "login-submit"}]) (when (contains? cf/flags :login-with-ldap) [:> fm/submit-button* {:label (tr "auth.login-with-ldap-submit") - :on-click on-submit-ldap}])]]])) + :data-role "login-with-ldap" + :on-click on-submit-ldap}])] + + [:section.passkey + [:> fm/submit-button* + {:data-role "login-with-passkey" + :class "btn-passkey-auth"} + [:img {:src "/images/passkey.png"}]]] + + + ]])) (mf/defc login-buttons [{:keys [params] :as props}] diff --git a/frontend/src/app/main/ui/icons.cljs b/frontend/src/app/main/ui/icons.cljs index 7a80eef68..a6c731859 100644 --- a/frontend/src/app/main/ui/icons.cljs +++ b/frontend/src/app/main/ui/icons.cljs @@ -5,7 +5,7 @@ ;; Copyright (c) KALEIDOS INC (ns app.main.ui.icons - (:refer-clojure :exclude [import mask]) + (:refer-clojure :exclude [import mask key]) (:require-macros [app.main.ui.icons :refer [icon-xref]]) (:require [rumext.v2 :as mf])) @@ -150,7 +150,7 @@ (def justify-content-row-center (icon-xref :justify-content-row-center)) (def justify-content-row-end (icon-xref :justify-content-row-end)) (def justify-content-row-start (icon-xref :justify-content-row-start)) -(def icon-key (icon-xref :icon-key)) +(def key (icon-xref :key)) (def layers (icon-xref :layers)) (def layout-columns (icon-xref :layout-columns)) (def layout-rows (icon-xref :layout-rows)) diff --git a/frontend/src/app/main/ui/routes.cljs b/frontend/src/app/main/ui/routes.cljs index 4944fcc85..fae5cfccd 100644 --- a/frontend/src/app/main/ui/routes.cljs +++ b/frontend/src/app/main/ui/routes.cljs @@ -48,7 +48,8 @@ ["/password" :settings-password] ["/feedback" :settings-feedback] ["/options" :settings-options] - ["/access-tokens" :settings-access-tokens]] + ["/access-tokens" :settings-access-tokens] + ["/passkeys" :settings-passkeys]] ["/view/:file-id" {:name :viewer diff --git a/frontend/src/app/main/ui/settings.cljs b/frontend/src/app/main/ui/settings.cljs index 2c890d51e..2e0d5a3ed 100644 --- a/frontend/src/app/main/ui/settings.cljs +++ b/frontend/src/app/main/ui/settings.cljs @@ -13,6 +13,7 @@ [app.main.ui.settings.delete-account] [app.main.ui.settings.feedback :refer [feedback-page]] [app.main.ui.settings.options :refer [options-page]] + [app.main.ui.settings.passkeys :refer [passkeys-page]] [app.main.ui.settings.password :refer [password-page]] [app.main.ui.settings.profile :refer [profile-page]] [app.main.ui.settings.sidebar :refer [sidebar]] @@ -59,5 +60,8 @@ [:& options-page {:locale locale}] :settings-access-tokens - [:& access-tokens-page])]]])) + [:& access-tokens-page] + + :settings-passkeys + [:& passkeys-page])]]])) diff --git a/frontend/src/app/main/ui/settings/change_email.cljs b/frontend/src/app/main/ui/settings/change_email.cljs index 996d4668c..3ba0d828e 100644 --- a/frontend/src/app/main/ui/settings/change_email.cljs +++ b/frontend/src/app/main/ui/settings/change_email.cljs @@ -85,7 +85,7 @@ (mf/use-callback (mf/deps profile) (partial on-submit profile)) - + on-email-change (mf/use-callback (fn [_ _] diff --git a/frontend/src/app/main/ui/settings/options.cljs b/frontend/src/app/main/ui/settings/options.cljs index 64f52e006..950c145f3 100644 --- a/frontend/src/app/main/ui/settings/options.cljs +++ b/frontend/src/app/main/ui/settings/options.cljs @@ -6,57 +6,152 @@ (ns app.main.ui.settings.options (:require + [app.common.data :as d] + [app.common.data.macros :as dm] [app.common.spec :as us] - [app.main.data.messages :as dm] + [app.main.data.messages :as msg] + [app.main.data.modal :as modal] [app.main.data.users :as du] [app.main.features :as features] [app.main.refs :as refs] + [app.main.repo :as rp] [app.main.store :as st] [app.main.ui.components.forms :as fm] + [app.main.ui.icons :as i] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] + [beicon.core :as rx] [cljs.spec.alpha :as s] [rumext.v2 :as mf])) (s/def ::lang (s/nilable ::us/string)) (s/def ::theme (s/nilable ::us/not-empty-string)) +(s/def ::2fa ::us/keyword) +(s/def ::passkey ::us/keyword) (s/def ::options-form - (s/keys :opt-un [::lang ::theme])) + (s/keys :opt-un [::lang ::theme ::2fa ::passkey])) (defn- on-success [_] - (st/emit! (dm/success (tr "notifications.profile-saved")))) + (st/emit! (msg/success (tr "notifications.profile-saved")))) (defn- on-submit [form _event] - (let [data (:clean-data @form) + (let [fdata (:clean-data @form) + + data (d/without-nils + {:theme (:theme fdata) + :lang (:lang fdata) + :props {:passkey (:passkey fdata) + :2fa (:2fa fdata)}}) mdata {:on-success (partial on-success form)}] (st/emit! (du/update-profile (with-meta data mdata))))) -(mf/defc options-form +(mf/defc settings {::mf/wrap-props false} - [] + [_props] (let [profile (mf/deref refs/profile) initial (mf/with-memo [profile] - (update profile :lang #(or % ""))) - form (fm/use-form :spec ::options-form - :initial initial) - new-css-system (features/use-feature :new-css-system)] + (let [props (:props profile)] + (d/without-nils + {:lang (d/nilv (:lang profile) "") + :theme (:theme profile) + :passkey (:passkey props :all) + :2fa (:2fa props :none)}))) - [:& fm/form {:class "options-form" - :on-submit on-submit - :form form} + form (fm/use-form :spec ::options-form :initial initial) + totp? (= :totp (dm/get-in profile [:props :2fa])) + new-css-system (features/use-feature :new-css-system) - [:h2 (tr "labels.language")] + on-show-totp-secret + (mf/use-fn #(st/emit! (modal/show! :two-factor-qrcode {})))] - [:div.fields-row - [:& fm/select {:options (into [{:label "Auto (browser)" :value ""}] - i18n/supported-locales) - :label (tr "dashboard.select-ui-language") - :default "" - :name :lang - :data-test "setting-lang"}]] + [:div.form-container + {:data-test "settings-form"} + [:& fm/form {:class "options-form" + :on-submit on-submit + :form form} + + [:h2 (tr "labels.language")] + + [:div.fields-row + [:& fm/select + {:options (into [{:label "Auto (browser)" :value ""}] i18n/supported-locales) + :label (tr "dashboard.select-ui-language") + :default "" + :name :lang + :data-test "setting-lang"}]] + + (when new-css-system + [:* + [:h2 (tr "dashboard.theme-change")] + [:div.fields-row + [:& fm/select + {:label (tr "dashboard.select-ui-theme") + :name :theme + :default "default" + :options [{:label "Penpot Dark (default)" :value "default"} + {:label "Penpot Light" :value "light"}] + :data-test "setting-theme"}]]]) + + [:h2 "PassKey"] + [:div.fields-row + [:& fm/radio-buttons + {:name :passkey + :encode-fn d/name + :decode-fn keyword + :options [{:label "Auth & 2FA" :value :all} + {:label "Only 2FA" :value :2fa}]}]] + + [:h2 "2FA"] + [:div.fields-row + [:& fm/radio-buttons + {:name :2fa + :encode-fn d/name + :decode-fn keyword + :options [{:label "NONE" :value :none} + {:label "TOTP" :value :totp} + {:label "PASSKEY" :value :passkey}]}] + (when ^boolean totp? + [:a {:on-click on-show-totp-secret} "(show secret)"])] + + [:> fm/submit-button* + {:label (tr "dashboard.update-settings") + :data-test "submit-lang-change"}]]])) + +(mf/defc two-factor-qrcode-modal + {::mf/register modal/components + ::mf/register-as :two-factor-qrcode} + [] + (let [on-close (mf/use-fn #(st/emit! (modal/hide))) + image* (mf/use-state nil) + secret* (mf/use-state nil)] + + + (mf/with-effect [] + (->> (rp/cmd! :get-profile-2fa-secret) + (rx/subs (fn [{:keys [secret image] :as result}] + (prn "result" result) + (reset! image* image) + (reset! secret* secret))))) + + [:div.modal-overlay + [:div.modal-container.change-email-modal + [:div.modal-header + [:div.modal-header-title + [:h2 (tr "modals.two-factor-qrcode.title")]] + [:div.modal-close-button + {:on-click on-close} + i/close]] + + (when-let [uri @image*] + [:div.modal-content + [:img {:width "300" + :height "300" + :src uri}]]) + + [:div.modal-footer]]])) (when new-css-system [:h2 (tr "dashboard.theme-change")] @@ -79,6 +174,4 @@ #(dom/set-html-title (tr "title.settings.options"))) [:div.dashboard-settings - [:div.form-container - {:data-test "settings-form"} - [:& options-form {}]]]) + [:& settings]]) diff --git a/frontend/src/app/main/ui/settings/passkeys.cljs b/frontend/src/app/main/ui/settings/passkeys.cljs new file mode 100644 index 000000000..fbd27cf32 --- /dev/null +++ b/frontend/src/app/main/ui/settings/passkeys.cljs @@ -0,0 +1,144 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.main.ui.settings.passkeys + (:require + [app.common.data.macros :as dm] + [app.common.uuid :as uuid] + [app.main.data.modal :as modal] + [app.main.data.users :as du] + [app.main.store :as st] + [app.main.ui.components.context-menu-a11y :refer [context-menu-a11y]] + [app.main.ui.icons :as i] + [app.util.dom :as dom] + [app.util.i18n :as i18n :refer [tr]] + [app.util.keyboard :as kbd] + [app.util.time :as dt] + [okulary.core :as l] + [rumext.v2 :as mf])) + +(def ref:passkeys + (l/derived :passkeys st/state)) + +(mf/defc passkeys-hero + [] + (let [on-click (mf/use-fn #(st/emit! (du/create-passkey)))] + [:div.passkeys-hero-container + [:div.passkeys-hero + [:div.desc + [:h2 (tr "dashboard.passkeys.title")] + [:p (tr "dashboard.passkeys.description")]] + + [:button.btn-primary + {:on-click on-click} + [:span (tr "dashboard.passkeys.create")]]]])) + +(mf/defc passkey-actions + [{:keys [on-delete]}] + (let [show* (mf/use-state false) + show? (deref show*) + menu-ref (mf/use-ref) + + menu-options + (mf/with-memo [on-delete] + [{:option-name (tr "labels.delete") + :id "passkey-delete" + :option-handler on-delete}]) + + on-menu-close + (mf/use-fn #(reset! show* false)) + + on-menu-click + (mf/use-fn + (fn [event] + (dom/prevent-default event) + (reset! show* true))) + + on-key-down + (mf/use-fn + (mf/deps on-menu-click) + (fn [event] + (when (kbd/enter? event) + (dom/stop-propagation event) + (on-menu-click event))))] + + [:div.icon + {:tab-index "0" + :ref menu-ref + :on-click on-menu-click + :on-key-down on-key-down} + + i/actions + [:& context-menu-a11y + {:on-close on-menu-close + :show show? + :fixed? true + :min-width? true + :top "auto" + :left "auto" + :options menu-options}]])) + +(mf/defc passkey-item + {::mf/wrap [mf/memo] + ::mf/wrap-props false} + [{:keys [passkey]}] + (let [locale (mf/deref i18n/locale) + created-at (dt/format-date-locale (:created-at passkey) {:locale locale}) + passkey-id (:id passkey) + sign-count (:sign-count passkey) + + on-delete-accept + (mf/use-fn + (mf/deps passkey-id) + (fn [] + (let [params {:id passkey-id} + mdata {:on-success #(st/emit! (du/fetch-passkeys))}] + (st/emit! (du/delete-passkey (with-meta params mdata)))))) + + on-delete + (mf/use-fn + (mf/deps on-delete-accept) + (fn [] + (st/emit! (modal/show + {:type :confirm + :title (tr "modals.delete-passkey.title") + :message (tr "modals.delete-passkey.message") + :accept-label (tr "modals.delete-passkey.accept") + :on-accept on-delete-accept}))))] + + [:div.table-row + [:div.table-field.name + (uuid/uuid->short-id passkey-id)] + [:div.table-field.create-date + [:span.content created-at]] + [:div.table-field.sign-count + [:span.content sign-count]] + + [:div.table-field.actions + [:& passkey-actions + {:on-delete on-delete}]]])) + +(mf/defc passkeys-page + [] + (let [passkeys (mf/deref ref:passkeys)] + + (mf/with-effect [] + (dom/set-html-title (tr "dashboard.password.page-title")) + (st/emit! (du/fetch-passkeys))) + + [:div.dashboard-passkeys + [:div + [:& passkeys-hero] + (if (empty? passkeys) + [:div.passkeys-empty + [:div (tr "dashboard.passkeys.empty.no-passkeys")] + [:div (tr "dashboard.passkeys.empty.add-one")]] + [:div.dashboard-table + [:div.table-rows + (for [{:keys [id] :as item} passkeys] + [:& passkey-item {:passkey item :key (dm/str id)}])]])]])) + + diff --git a/frontend/src/app/main/ui/settings/profile.cljs b/frontend/src/app/main/ui/settings/profile.cljs index dd5ab9eef..98f64674d 100644 --- a/frontend/src/app/main/ui/settings/profile.cljs +++ b/frontend/src/app/main/ui/settings/profile.cljs @@ -8,7 +8,7 @@ (:require [app.common.spec :as us] [app.config :as cf] - [app.main.data.messages :as dm] + [app.main.data.messages :as msg] [app.main.data.modal :as modal] [app.main.data.users :as du] [app.main.refs :as refs] @@ -29,7 +29,7 @@ (defn- on-success [_] - (st/emit! (dm/success (tr "notifications.profile-saved")))) + (st/emit! (msg/success (tr "notifications.profile-saved")))) (defn- on-submit [form _event] @@ -101,6 +101,7 @@ :on-selected on-file-selected :data-test "profile-image-input"}]]])) + ;; --- Profile Page (mf/defc profile-page [] @@ -110,4 +111,3 @@ [:div.form-container.two-columns [:& profile-photo-form] [:& profile-form]]]) - diff --git a/frontend/src/app/main/ui/settings/sidebar.cljs b/frontend/src/app/main/ui/settings/sidebar.cljs index 8c077cb61..e909fb7e7 100644 --- a/frontend/src/app/main/ui/settings/sidebar.cljs +++ b/frontend/src/app/main/ui/settings/sidebar.cljs @@ -26,39 +26,33 @@ options? (= section :settings-options) feedback? (= section :settings-feedback) access-tokens? (= section :settings-access-tokens) + passkeys? (= section :settings-passkeys) go-dashboard - (mf/use-callback + (mf/use-fn (mf/deps profile) #(st/emit! (rt/nav :dashboard-projects {:team-id (du/get-current-team-id profile)}))) go-settings-profile - (mf/use-callback - (mf/deps profile) - #(st/emit! (rt/nav :settings-profile))) + (mf/use-fn #(st/emit! (rt/nav :settings-profile))) go-settings-feedback - (mf/use-callback - (mf/deps profile) - #(st/emit! (rt/nav :settings-feedback))) + (mf/use-fn #(st/emit! (rt/nav :settings-feedback))) go-settings-password - (mf/use-callback - (mf/deps profile) - #(st/emit! (rt/nav :settings-password))) + (mf/use-fn #(st/emit! (rt/nav :settings-password))) go-settings-options - (mf/use-callback - (mf/deps profile) - #(st/emit! (rt/nav :settings-options))) + (mf/use-fn #(st/emit! (rt/nav :settings-options))) go-settings-access-tokens - (mf/use-callback - (mf/deps profile) - #(st/emit! (rt/nav :settings-access-tokens))) + (mf/use-fn #(st/emit! (rt/nav :settings-access-tokens))) + + go-settings-passkeys + (mf/use-fn #(st/emit! (rt/nav :settings-passkeys))) show-release-notes - (mf/use-callback + (mf/use-fn (fn [event] (let [version (:main cf/version)] (st/emit! (ptk/event ::ev/event {::ev/name "show-release-notes" :version version})) @@ -95,9 +89,17 @@ [:li {:class (when access-tokens? "current") :on-click go-settings-access-tokens :data-test "settings-access-tokens"} - i/icon-key + i/key [:span.element-title (tr "labels.access-tokens")]]) + (when (contains? cf/flags :passkeys) + [:li {:class (when passkeys? "current") + :on-click go-settings-passkeys + :data-test "settings-passkeys"} + i/key + [:span.element-title (tr "dashboard.passkeys.sidebar-label")]]) + + [:hr] [:li {:on-click show-release-notes :data-test "release-notes"} diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 2555749c9..392b4b7de 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -391,6 +391,48 @@ msgstr "The token will expire on %s" msgid "dashboard.access-tokens.token-will-not-expire" msgstr "The token has no expiration date" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "dashboard.password.page-title" +msgstr "Profile - PassKeys" + +#: src/app/main/ui/settings/passkeys.cljs +msgid "dashboard.passkeys.title" +msgstr "PassKeys" + + +#: src/app/main/ui/settings/passkeys.cljs +msgid "dashboard.passkeys.description" +msgstr "Passkeys are a safer and easier alternative to passwords. With passkeys, users can sign in to apps and websites with a biometric sensor (such as a fingerprint or facial recognition), PIN, or pattern, freeing them from having to remember and manage passwords." + +#: src/app/main/ui/settings/passkeys.cljs +msgid "dashboard.passkeys.create" +msgstr "Add Passkey" + +#: src/app/main/ui/settings/sidebar.cljs +msgid "dashboard.passkeys.sidebar-label" +msgstr "PassKeys" + +#: src/app/main/ui/settings/passkeys.cljs +msgid "dashboard.passkeys.empty.no-passkeys" +msgstr "You have no passkeys so far." + +#: src/app/main/ui/settings/passkeys.cljs +msgid "dashboard.passkeys.empty.add-one" +msgstr "Press the button \"Add PassKey\" to create one." + +#: src/app/main/ui/settings/passkeys.cljs +msgid "modals.delete-passkey.title" +msgstr "Delete PassKey" + +#: src/app/main/ui/settings/passkeys.cljs +msgid "modals.delete-passkey.message" +msgstr "Are you sure you want to delete this PassKey?" + +#: src/app/main/ui/settings/passkeys.cljs +msgid "modals.delete-passkey.accept" +msgstr "Delete PassKey" + #: src/app/main/data/dashboard.cljs, src/app/main/data/dashboard.cljs msgid "dashboard.copy-suffix" msgstr "(copy)" @@ -864,6 +906,14 @@ msgstr "Are you sure?" msgid "errors.auth-provider-not-configured" msgstr "Authentication provider not configured." +#: src/app/main/ui/auth/login.cljs +msgid "errors.invalid-totp" +msgstr "The 2FA token looks invalid" + +#: src/app/main/ui/auth/login.cljs +msgid "auth.totp" +msgstr "2FA Token" + msgid "errors.auth.unable-to-login" msgstr "Looks like you are not authenticated or session expired."