diff --git a/backend/deps.edn b/backend/deps.edn index 8517e8e1e1..cb8f172e67 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 4b22cb493f..d7edcdd9a7 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 d5f6f02713..8a48a4369f 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 0000000000..06f594afe4 --- /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 201e830624..2610c66764 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 d765a5598e..49e0dbcf4b 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 c79e4c773e..745902c200 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 0000000000..c79f139c05 --- /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 0000000000..9ce7285881 --- /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 01ee915f04..3f2921e415 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 494b1df2a7..0c9c90bf13 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 0000000000..556fc6b90f Binary files /dev/null and b/frontend/resources/images/passkey.png differ diff --git a/frontend/resources/styles/main/layouts/login.scss b/frontend/resources/styles/main/layouts/login.scss index cee660c18a..81ac5c42ce 100644 --- a/frontend/resources/styles/main/layouts/login.scss +++ b/frontend/resources/styles/main/layouts/login.scss @@ -121,6 +121,29 @@ *:not(:last-child) { margin-bottom: $size-4; } + + &.center { + align-items: center; + } + } + + section.passkey { + display: flex; + flex-direction: column; + width: 100%; + align-items: center; + margin-top: 20px; + + .btn-disabled { + filter: grayscale(100%); + background-color: unset; + } + + .btn-passkey-auth { + width: 60px; + cursor: pointer; + border: 0px; + } } .btn-large { diff --git a/frontend/resources/styles/main/partials/dashboard-settings.scss b/frontend/resources/styles/main/partials/dashboard-settings.scss index 0cd1146870..1aa7843c1c 100644 --- a/frontend/resources/styles/main/partials/dashboard-settings.scss +++ b/frontend/resources/styles/main/partials/dashboard-settings.scss @@ -38,6 +38,7 @@ width: 100%; justify-content: center; align-items: center; + flex-direction: column; .form-container { margin-top: 50px; @@ -155,6 +156,17 @@ margin-bottom: 20px; } } + + .auth-settings { + h2 { + color: $color-black; + } + + .options-form { + margin-top: 40px; + width: 368px; + } + } } .dashboard-access-tokens { @@ -301,3 +313,94 @@ color: $color-gray-40; } } + +.dashboard-passkeys { + display: flex; + flex-direction: column; + align-items: center; + + .passkeys-hero-container { + max-width: 1000px; + width: 100%; + display: flex; + flex-direction: column; + } + + .passkeys-hero { + font-size: $fs14; + padding: $size-6; + background-color: $color-white; + margin-top: $size-6; + display: flex; + justify-content: space-between; + + .desc { + width: 80%; + color: $color-gray-40; + h2 { + margin-bottom: $size-4; + color: $color-black; + } + p { + font-size: $fs16; + } + } + + .btn-primary { + flex-shrink: 0; + } + } + + .passkeys-empty { + text-align: center; + max-width: 1000px; + width: 100%; + padding: $size-6; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + border: 1px dashed $color-gray-20; + color: $color-gray-40; + margin-top: 12px; + min-height: 136px; + } + + .table-row { + background-color: $color-white; + display: grid; + grid-template-columns: 1fr 25% 40px 12px; + height: 63px; + &:not(:first-child) { + margin-top: 8px; + } + } + + .table-field { + &.name { + color: $color-gray-60; + width: 150px; + } + + &.create-date { + color: $color-gray-40; + font-size: $fs14; + .content { + padding: 2px 5px; + &.expired { + background-color: $color-warning-lighter; + border-radius: $br4; + color: $color-gray-40; + } + } + } + + &.passkey-created { + word-break: break-all; + } + + &.actions { + position: relative; + } + } +} diff --git a/frontend/src/app/main/data/users.cljs b/frontend/src/app/main/data/users.cljs index a72be9c4ef..e7647f0a1a 100644 --- a/frontend/src/app/main/data/users.cljs +++ b/frontend/src/app/main/data/users.cljs @@ -15,9 +15,10 @@ [app.config :as cf] [app.main.data.events :as ev] [app.main.data.media :as di] + [app.main.data.messages :as msg] [app.main.data.websocket :as ws] [app.main.repo :as rp] - [app.util.i18n :as i18n] + [app.util.i18n :as i18n :refer [tr]] [app.util.router :as rt] [app.util.storage :refer [storage]] [beicon.core :as rx] @@ -119,6 +120,40 @@ ;; --- EVENT: login +(defn- create-passkey-assertion + "A mandatory step on passkey authentication ceremony" + [{:keys [credentials challenge rp-id]}] + (let [challenge (js/Uint8Array. challenge) + credentials (->> 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 7bd0726456..e66fdcc739 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 9dca64ce64..9a186e1cdf 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 fad0f9cefd..a4aa49ff2f 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 7a80eef68e..a6c7318590 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 4944fcc85a..fae5cfccdc 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 2c890d51ea..2e0d5a3ede 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 996d4668c2..3ba0d828e4 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 64f52e0060..950c145f34 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 0000000000..fbd27cf323 --- /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 dd5ab9eef3..98f64674db 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 8c077cb61d..e909fb7e7b 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 2555749c96..392b4b7dea 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."