0
Fork 0
mirror of https://github.com/penpot/penpot.git synced 2025-03-28 15:41:25 -05:00

🎉 Add PassKeys and 2FA support

This commit is contained in:
Andrey Antukh 2023-07-20 10:54:10 +02:00
parent 2706d1ffd3
commit c598656f60
28 changed files with 1279 additions and 170 deletions

View file

@ -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"}

View file

@ -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)]

View file

@ -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!

View file

@ -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()
)

View file

@ -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 {}))))

View file

@ -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))

View file

@ -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

View file

@ -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)))

View file

@ -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)))

View file

@ -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"}

View file

@ -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?

View file

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View file

@ -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 {

View file

@ -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;
}
}
}

View file

@ -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))))))

View file

@ -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))

View file

@ -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

View file

@ -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}]

View file

@ -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))

View file

@ -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

View file

@ -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])]]]))

View file

@ -85,7 +85,7 @@
(mf/use-callback
(mf/deps profile)
(partial on-submit profile))
on-email-change
(mf/use-callback
(fn [_ _]

View file

@ -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]])

View file

@ -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)}])]])]]))

View file

@ -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]]])

View file

@ -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"}

View file

@ -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."