0
Fork 0
mirror of https://github.com/penpot/penpot.git synced 2025-04-01 01:21:21 -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"} io.lettuce/lettuce-core {:mvn/version "6.2.6.RELEASE"}
java-http-clj/java-http-clj {:mvn/version "0.4.3"} 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 funcool/yetti
{:git/tag "v9.16" {:git/tag "v9.16"
:git/sha "7df3e08" :git/sha "7df3e08"
@ -33,9 +36,6 @@
io.whitfin/siphash {:mvn/version "2.0.0"} 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"} com.github.ben-manes.caffeine/caffeine {:mvn/version "3.1.8"}
org.jsoup/jsoup {:mvn/version "1.16.1"} org.jsoup/jsoup {:mvn/version "1.16.1"}

View file

@ -103,6 +103,12 @@
:else :else
{::yrs/status 400 ::yrs/body data}))) {::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 (defmethod handle-exception :assertion
[error request] [error request]
(binding [l/*context* (request->context request)] (binding [l/*context* (request->context request)]

View file

@ -324,12 +324,14 @@
{:name "0104-mod-file-thumbnail-table" {:name "0104-mod-file-thumbnail-table"
:fn (mg/resource "app/migrations/sql/0104-mod-file-thumbnail-table.sql")} :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" {:name "0105-mod-file-change-table"
:fn (mg/resource "app/migrations/sql/0105-mod-file-change-table.sql")} :fn (mg/resource "app/migrations/sql/0105-mod-file-change-table.sql")}
{:name "0105-mod-server-error-report-table" {:name "0105-mod-server-error-report-table"
:fn (mg/resource "app/migrations/sql/0105-mod-server-error-report-table.sql")} :fn (mg/resource "app/migrations/sql/0105-mod-server-error-report-table.sql")}
]) ])
(defn apply-migrations! (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.teams
'app.rpc.commands.verify-token 'app.rpc.commands.verify-token
'app.rpc.commands.viewer 'app.rpc.commands.viewer
'app.rpc.commands.webauthn
'app.rpc.commands.webhooks) 'app.rpc.commands.webhooks)
(map (partial process-method cfg)) (map (partial process-method cfg))
(into {})))) (into {}))))

View file

@ -22,11 +22,13 @@
[app.rpc :as-alias rpc] [app.rpc :as-alias rpc]
[app.rpc.commands.profile :as profile] [app.rpc.commands.profile :as profile]
[app.rpc.commands.teams :as teams] [app.rpc.commands.teams :as teams]
[app.rpc.commands.webauthn :as webauthn]
[app.rpc.doc :as-alias doc] [app.rpc.doc :as-alias doc]
[app.rpc.helpers :as rph] [app.rpc.helpers :as rph]
[app.tokens :as tokens] [app.tokens :as tokens]
[app.util.services :as sv] [app.util.services :as sv]
[app.util.time :as dt] [app.util.time :as dt]
[app.util.totp :as totp]
[cuerdas.core :as str])) [cuerdas.core :as str]))
(def schema:password (def schema:password
@ -37,8 +39,65 @@
;; ---- COMMAND: login with password ;; ---- 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 (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) (when-not (or (contains? cf/flags :login)
(contains? cf/flags :login-with-password)) (contains? cf/flags :login-with-password))
@ -46,61 +105,32 @@
:code :login-disabled :code :login-disabled
:hint "login is disabled in this instance")) :hint "login is disabled in this instance"))
(letfn [(check-password [conn profile password] (db/with-atomic [conn pool]
(if (= (:password profile) "!") (let [cfg (assoc cfg ::db/conn conn)
(ex/raise :type :validation profile (->> (profile/get-profile-by-email conn email)
:code :account-without-password (validate-profile! cfg params)
:hint "the current account does not have password") (profile/strip-private-attrs))
(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))))
(validate-profile [conn profile] invitation (when-let [token (:invitation-token params)]
(when-not profile (tokens/verify (::main/props cfg) {:token token :iss :team-invitation}))
(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)))
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
(db/with-atomic [conn pool] ;; accept invitation with other email
(let [profile (->> (profile/get-profile-by-email conn email) response (if (and (some? invitation) (= (:id profile) (:member-id invitation)))
(validate-profile conn) {:invitation-token (:invitation-token params)}
(profile/strip-private-attrs)) (assoc profile :is-admin (let [admins (cf/get :admins)]
(contains? admins (:email profile)))))]
invitation (when-let [token (:invitation-token params)] (-> response
(tokens/verify (::main/props cfg) {:token token :iss :team-invitation})) (rph/with-transform (session/create-fn cfg (:id profile)))
(rph/with-meta {::audit/props (audit/profile->props profile)
;; If invitation member-id does not matches the profile-id, we just proceed to ignore the ::audit/profile-id (:id profile)})))))
;; 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 (def schema:login-with-password
[:map {:title "login-with-password"} [:map {:title "login-with-password"}
[:email ::sm/email] [:email ::sm/email]
[:password schema:password] [:password schema:password]
[:totp {:optional true} ::sm/word-string]
[:invitation-token {:optional true} schema:token]]) [:invitation-token {:optional true} schema:token]])
(sv/defmethod ::login-with-password (sv/defmethod ::login-with-password
@ -464,5 +494,3 @@
::sm/params schema:request-profile-recovery} ::sm/params schema:request-profile-recovery}
[cfg params] [cfg params]
(request-profile-recovery cfg params)) (request-profile-recovery cfg params))

View file

@ -7,7 +7,6 @@
(ns app.rpc.commands.profile (ns app.rpc.commands.profile
(:require (:require
[app.auth :as auth] [app.auth :as auth]
[app.common.data :as d]
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.schema :as sm] [app.common.schema :as sm]
@ -27,6 +26,7 @@
[app.tokens :as tokens] [app.tokens :as tokens]
[app.util.services :as sv] [app.util.services :as sv]
[app.util.time :as dt] [app.util.time :as dt]
[app.util.totp :as totp]
[cuerdas.core :as str])) [cuerdas.core :as str]))
(declare check-profile-existence!) (declare check-profile-existence!)
@ -36,6 +36,7 @@
(declare get-profile) (declare get-profile)
(declare strip-private-attrs) (declare strip-private-attrs)
(declare verify-password) (declare verify-password)
(declare ^:private process-props)
(def schema:profile (def schema:profile
[:map {:title "Profile"} [:map {:title "Profile"}
@ -83,7 +84,7 @@
(def schema:update-profile (def schema:update-profile
[:map {:title "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}]] [:lang {:optional true} [:string {:max 5}]]
[:theme {:optional true} [:string {:max 250}]]]) [:theme {:optional true} [:string {:max 250}]]])
@ -91,11 +92,7 @@
{::doc/added "1.0" {::doc/added "1.0"
::sm/params schema:update-profile ::sm/params schema:update-profile
::sm/result schema:profile} ::sm/result schema:profile}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id fullname lang theme] :as params}] [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id fullname lang theme props] :as params}]
(dm/assert!
"expected valid profile data"
(profile? params))
(db/with-atomic [conn pool] (db/with-atomic [conn pool]
;; NOTE: we need to retrieve the profile independently if we use ;; 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) (let [profile (-> (db/get-by-id conn :profile profile-id ::db/for-update? true)
(decode-row)) (decode-row))
;; Update the profile map with direct params props (cond-> (process-props props profile)
profile (-> profile (and (contains? props :2fa)
(assoc :fullname fullname) (= :totp (:2fa props))
(assoc :lang lang) (not= :totp (dm/get-in profile [:props :2fa])))
(assoc :theme theme)) (assoc :2fa/secret (totp/gen-secret)))
]
(db/update! conn :profile params (cond-> {:props (db/tjson props)}
{:fullname fullname (some? fullname) (assoc :fullname fullname)
:lang lang (some? lang) (assoc :lang lang)
:theme theme (some? theme) (assoc :theme theme))
:props (db/tjson (:props profile))}
{:id profile-id}) profile (db/update! conn :profile params {:id profile-id})
profile (decode-row profile)]
(-> profile (-> profile
(update :props filter-props)
(strip-private-attrs) (strip-private-attrs)
(d/without-nils)
(rph/with-meta {::audit/props (audit/profile->props profile)}))))) (rph/with-meta {::audit/props (audit/profile->props profile)})))))
;; --- MUTATION: Update Password ;; --- MUTATION: Update Password
(declare validate-password!) (declare validate-password!)
@ -153,7 +149,7 @@
:code :email-as-password :code :email-as-password
:hint "you can't use your 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) (invalidate-profile-session! cfg profile-id session-id)
nil))) nil)))
@ -173,7 +169,7 @@
profile)) profile))
(defn update-profile-password! (defn update-profile-password!
[conn {:keys [id password] :as profile}] [{:keys [::db/conn]} {:keys [id password] :as profile}]
(when-not (db/read-only? conn) (when-not (db/read-only? conn)
(db/update! conn :profile (db/update! conn :profile
{:password (auth/derive-password password)} {:password (auth/derive-password password)}
@ -315,6 +311,18 @@
;; --- MUTATION: Update Profile Props ;; --- 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 (def schema:update-profile-props
[:map {:title "update-profile-props"} [:map {:title "update-profile-props"}
[:props [:map-of :keyword :any]]]) [:props [:map-of :keyword :any]]])
@ -325,15 +333,7 @@
[{:keys [::db/pool]} {:keys [::rpc/profile-id props]}] [{:keys [::db/pool]} {:keys [::rpc/profile-id props]}]
(db/with-atomic [conn pool] (db/with-atomic [conn pool]
(let [profile (get-profile conn profile-id ::db/for-update? true) (let [profile (get-profile conn profile-id ::db/for-update? true)
props (reduce-kv (fn [props k v] props (process-props props profile)]
;; We don't accept namespaced keys
(if (simple-ident? k)
(if (nil? v)
(dissoc props k)
(assoc props k v))
props))
(:props profile)
props)]
(db/update! conn :profile (db/update! conn :profile
{:props (db/tjson props)} {:props (db/tjson props)}
@ -373,6 +373,19 @@
(rph/with-transform {} (session/delete-fn cfg))))) (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 ;; --- HELPERS
(def sql:owned-teams (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"} selmer/selmer {:mvn/version "1.12.59"}
criterium/criterium {:mvn/version "0.4.6"} 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/jsonista {:mvn/version "0.3.7"}
metosin/malli {:mvn/version "0.11.0"} metosin/malli {:mvn/version "0.11.0"}

View file

@ -8,6 +8,7 @@
(:refer-clojure :exclude [deref merge parse-uuid]) (:refer-clojure :exclude [deref merge parse-uuid])
#?(:cljs (:require-macros [app.common.schema :refer [ignoring]])) #?(:cljs (:require-macros [app.common.schema :refer [ignoring]]))
(:require (:require
#?(:clj [buddy.core.codecs :as bc])
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.schema.generators :as sg] [app.common.schema.generators :as sg]
[app.common.schema.openapi :as-alias oapi] [app.common.schema.openapi :as-alias oapi]
@ -489,6 +490,26 @@
::oapi/format "uri" ::oapi/format "uri"
::oapi/decode (comp u/uri str/trim)}}) ::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 ;; ---- PREDICATES
(def safe-int? (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) { *:not(:last-child) {
margin-bottom: $size-4; 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 { .btn-large {

View file

@ -38,6 +38,7 @@
width: 100%; width: 100%;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
flex-direction: column;
.form-container { .form-container {
margin-top: 50px; margin-top: 50px;
@ -155,6 +156,17 @@
margin-bottom: 20px; margin-bottom: 20px;
} }
} }
.auth-settings {
h2 {
color: $color-black;
}
.options-form {
margin-top: 40px;
width: 368px;
}
}
} }
.dashboard-access-tokens { .dashboard-access-tokens {
@ -301,3 +313,94 @@
color: $color-gray-40; 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.config :as cf]
[app.main.data.events :as ev] [app.main.data.events :as ev]
[app.main.data.media :as di] [app.main.data.media :as di]
[app.main.data.messages :as msg]
[app.main.data.websocket :as ws] [app.main.data.websocket :as ws]
[app.main.repo :as rp] [app.main.repo :as rp]
[app.util.i18n :as i18n] [app.util.i18n :as i18n :refer [tr]]
[app.util.router :as rt] [app.util.router :as rt]
[app.util.storage :refer [storage]] [app.util.storage :refer [storage]]
[beicon.core :as rx] [beicon.core :as rx]
@ -119,6 +120,40 @@
;; --- EVENT: login ;; --- 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 (defn- logged-in
"This is the main event that is executed once we have 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 profile. The profile can proceed from standard login or from
@ -148,9 +183,10 @@
(declare login-from-register) (declare login-from-register)
(defn login (defn login-with-password
[{:keys [email password invitation-token] :as data}] [{:keys [email password invitation-token totp] :as data}]
(ptk/reify ::login (prn "login-with-password" data)
(ptk/reify ::login-with-password
ptk/WatchEvent ptk/WatchEvent
(watch [_ _ stream] (watch [_ _ stream]
(let [{:keys [on-error on-success] (let [{:keys [on-error on-success]
@ -159,6 +195,7 @@
params {:email email params {:email email
:password password :password password
:totp totp
:invitation-token invitation-token}] :invitation-token invitation-token}]
;; NOTE: We can't take the profile value from login because ;; NOTE: We can't take the profile value from login because
@ -171,6 +208,12 @@
;; proceed to logout and show an error message. ;; proceed to logout and show an error message.
(->> (rp/cmd! :login-with-password (d/without-nils params)) (->> (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-map (fn [data]
(rx/merge (rx/merge
(rx/of (fetch-profile)) (rx/of (fetch-profile))
@ -267,27 +310,17 @@
(defn update-profile (defn update-profile
[data] [data]
(dm/assert! (profile? data))
(ptk/reify ::update-profile (ptk/reify ::update-profile
ptk/WatchEvent ptk/WatchEvent
(watch [_ _ stream] (watch [_ _ _]
(let [mdata (meta data) (let [mdata (meta data)
on-success (:on-success mdata identity) on-success (:on-success mdata identity)
on-error (:on-error mdata rx/throw)] on-error (:on-error mdata rx/throw)]
(->> (rp/cmd! :update-profile (dissoc data :props)) (->> (rp/cmd! :update-profile data)
(rx/mapcat (rx/tap on-success)
(fn [_] (rx/map profile-fetched)
(rx/merge
(->> stream
(rx/filter (ptk/type? ::profile-fetched))
(rx/take 1)
(rx/tap on-success)
(rx/ignore))
(rx/of (profile-fetched data)))))
(rx/catch on-error)))))) (rx/catch on-error))))))
;; --- Request Email Change ;; --- Request Email Change
(defn request-email-change (defn request-email-change
@ -501,7 +534,7 @@
ptk/WatchEvent ptk/WatchEvent
(watch [_ _ _] (watch [_ _ _]
(->> (rp/cmd! :create-demo-profile {}) (->> (rp/cmd! :create-demo-profile {})
(rx/map login))))) (rx/map login-with-password)))))
;; --- EVENT: fetch-team-webhooks ;; --- EVENT: fetch-team-webhooks
@ -556,3 +589,121 @@
(->> (rp/cmd! :delete-access-token params) (->> (rp/cmd! :delete-access-token params)
(rx/tap on-success) (rx/tap on-success)
(rx/catch on-error)))))) (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] [cuerdas.core :as str]
[potok.core :as ptk])) [potok.core :as ptk]))
(defn- print-data! (defn print-data!
[data] [data]
(-> data (-> data
(dissoc ::sm/explain) (dissoc ::sm/explain)
@ -30,13 +30,13 @@
(dissoc ::instance) (dissoc ::instance)
(pp/pprint {:width 70}))) (pp/pprint {:width 70})))
(defn- print-explain! (defn print-explain!
[data] [data]
(when-let [explain (::sm/explain data)] (when-let [explain (::sm/explain data)]
(-> (sm/humanize-data explain) (-> (sm/humanize-data explain)
(pp/pprint {:width 70})))) (pp/pprint {:width 70}))))
(defn- print-trace! (defn print-trace!
[data] [data]
(some-> data ::trace js/console.log)) (some-> data ::trace js/console.log))

View file

@ -58,7 +58,8 @@
:settings-password :settings-password
:settings-options :settings-options
:settings-feedback :settings-feedback
:settings-access-tokens) :settings-access-tokens
:settings-passkeys)
[:& settings/settings {:route route}] [:& settings/settings {:route route}]
:debug-icons-preview :debug-icons-preview

View file

@ -12,6 +12,7 @@
[app.config :as cf] [app.config :as cf]
[app.main.data.messages :as dm] [app.main.data.messages :as dm]
[app.main.data.users :as du] [app.main.data.users :as du]
[app.main.errors :as err]
[app.main.repo :as rp] [app.main.repo :as rp]
[app.main.store :as st] [app.main.store :as st]
[app.main.ui.components.button-link :as bl] [app.main.ui.components.button-link :as bl]
@ -79,8 +80,8 @@
(s/def ::invitation-token ::us/not-empty-string) (s/def ::invitation-token ::us/not-empty-string)
(s/def ::login-form (s/def ::login-form
(s/keys :req-un [::email ::password] (s/keys :req-un [::email]
:opt-un [::invitation-token])) :opt-un [::password ::invitation-token]))
(defn handle-error-messages (defn handle-error-messages
[errors _data] [errors _data]
@ -94,6 +95,9 @@
[{:keys [params on-success-callback] :as props}] [{:keys [params on-success-callback] :as props}]
(let [initial (mf/use-memo (mf/deps params) (constantly params)) (let [initial (mf/use-memo (mf/deps params) (constantly params))
totp* (mf/use-state false)
totp? (deref totp*)
error (mf/use-state false) error (mf/use-state false)
form (fm/use-form :spec ::login-form form (fm/use-form :spec ::login-form
:validators [handle-error-messages] :validators [handle-error-messages]
@ -101,7 +105,27 @@
on-error on-error
(fn [cause] (fn [cause]
(when (map? cause)
(err/print-trace! cause)
(err/print-data! cause)
(err/print-explain! cause))
(cond (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)) (and (= :restriction (:type cause))
(= :profile-blocked (:code cause))) (= :profile-blocked (:code cause)))
(reset! error (tr "errors.profile-blocked")) (reset! error (tr "errors.profile-blocked"))
@ -133,13 +157,25 @@
(on-success-callback))) (on-success-callback)))
on-submit on-submit
(mf/use-callback (mf/use-fn
(fn [form _event] (fn [form event]
(reset! error nil) (let [event (dom/event->native-event event)
(let [params (with-meta (:clean-data @form) submitter (unchecked-get event "submitter")
{:on-error on-error submitter (dom/get-data submitter "role")]
:on-success on-success})]
(st/emit! (du/login params))))) (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 on-submit-ldap
(mf/use-callback (mf/use-callback
@ -174,17 +210,35 @@
:help-icon i/eye :help-icon i/eye
:label (tr "auth.password")}]] :label (tr "auth.password")}]]
(when totp?
[:div.fields-row
[:& fm/input
{:type "text"
:name :totp
:label (tr "auth.totp")}]])
[:div.buttons-stack [:div.buttons-stack
(when (or (contains? cf/flags :login) (when (or (contains? cf/flags :login)
(contains? cf/flags :login-with-password)) (contains? cf/flags :login-with-password))
[:> fm/submit-button* [:> fm/submit-button*
{:label (tr "auth.login-submit") {:label (tr "auth.login-submit")
:data-role "login-with-password"
:data-test "login-submit"}]) :data-test "login-submit"}])
(when (contains? cf/flags :login-with-ldap) (when (contains? cf/flags :login-with-ldap)
[:> fm/submit-button* [:> fm/submit-button*
{:label (tr "auth.login-with-ldap-submit") {: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 (mf/defc login-buttons
[{:keys [params] :as props}] [{:keys [params] :as props}]

View file

@ -5,7 +5,7 @@
;; Copyright (c) KALEIDOS INC ;; Copyright (c) KALEIDOS INC
(ns app.main.ui.icons (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-macros [app.main.ui.icons :refer [icon-xref]])
(:require [rumext.v2 :as mf])) (:require [rumext.v2 :as mf]))
@ -150,7 +150,7 @@
(def justify-content-row-center (icon-xref :justify-content-row-center)) (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-end (icon-xref :justify-content-row-end))
(def justify-content-row-start (icon-xref :justify-content-row-start)) (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 layers (icon-xref :layers))
(def layout-columns (icon-xref :layout-columns)) (def layout-columns (icon-xref :layout-columns))
(def layout-rows (icon-xref :layout-rows)) (def layout-rows (icon-xref :layout-rows))

View file

@ -48,7 +48,8 @@
["/password" :settings-password] ["/password" :settings-password]
["/feedback" :settings-feedback] ["/feedback" :settings-feedback]
["/options" :settings-options] ["/options" :settings-options]
["/access-tokens" :settings-access-tokens]] ["/access-tokens" :settings-access-tokens]
["/passkeys" :settings-passkeys]]
["/view/:file-id" ["/view/:file-id"
{:name :viewer {:name :viewer

View file

@ -13,6 +13,7 @@
[app.main.ui.settings.delete-account] [app.main.ui.settings.delete-account]
[app.main.ui.settings.feedback :refer [feedback-page]] [app.main.ui.settings.feedback :refer [feedback-page]]
[app.main.ui.settings.options :refer [options-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.password :refer [password-page]]
[app.main.ui.settings.profile :refer [profile-page]] [app.main.ui.settings.profile :refer [profile-page]]
[app.main.ui.settings.sidebar :refer [sidebar]] [app.main.ui.settings.sidebar :refer [sidebar]]
@ -59,5 +60,8 @@
[:& options-page {:locale locale}] [:& options-page {:locale locale}]
:settings-access-tokens :settings-access-tokens
[:& access-tokens-page])]]])) [:& access-tokens-page]
:settings-passkeys
[:& passkeys-page])]]]))

View file

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

View file

@ -6,57 +6,152 @@
(ns app.main.ui.settings.options (ns app.main.ui.settings.options
(:require (:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.spec :as us] [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.data.users :as du]
[app.main.features :as features] [app.main.features :as features]
[app.main.refs :as refs] [app.main.refs :as refs]
[app.main.repo :as rp]
[app.main.store :as st] [app.main.store :as st]
[app.main.ui.components.forms :as fm] [app.main.ui.components.forms :as fm]
[app.main.ui.icons :as i]
[app.util.dom :as dom] [app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]] [app.util.i18n :as i18n :refer [tr]]
[beicon.core :as rx]
[cljs.spec.alpha :as s] [cljs.spec.alpha :as s]
[rumext.v2 :as mf])) [rumext.v2 :as mf]))
(s/def ::lang (s/nilable ::us/string)) (s/def ::lang (s/nilable ::us/string))
(s/def ::theme (s/nilable ::us/not-empty-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/def ::options-form
(s/keys :opt-un [::lang ::theme])) (s/keys :opt-un [::lang ::theme ::2fa ::passkey]))
(defn- on-success (defn- on-success
[_] [_]
(st/emit! (dm/success (tr "notifications.profile-saved")))) (st/emit! (msg/success (tr "notifications.profile-saved"))))
(defn- on-submit (defn- on-submit
[form _event] [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)}] mdata {:on-success (partial on-success form)}]
(st/emit! (du/update-profile (with-meta data mdata))))) (st/emit! (du/update-profile (with-meta data mdata)))))
(mf/defc options-form (mf/defc settings
{::mf/wrap-props false} {::mf/wrap-props false}
[] [_props]
(let [profile (mf/deref refs/profile) (let [profile (mf/deref refs/profile)
initial (mf/with-memo [profile] initial (mf/with-memo [profile]
(update profile :lang #(or % ""))) (let [props (:props profile)]
form (fm/use-form :spec ::options-form (d/without-nils
:initial initial) {:lang (d/nilv (:lang profile) "")
new-css-system (features/use-feature :new-css-system)] :theme (:theme profile)
:passkey (:passkey props :all)
:2fa (:2fa props :none)})))
[:& fm/form {:class "options-form" form (fm/use-form :spec ::options-form :initial initial)
:on-submit on-submit totp? (= :totp (dm/get-in profile [:props :2fa]))
:form form} 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 [:div.form-container
[:& fm/select {:options (into [{:label "Auto (browser)" :value ""}] {:data-test "settings-form"}
i18n/supported-locales) [:& fm/form {:class "options-form"
:label (tr "dashboard.select-ui-language") :on-submit on-submit
:default "" :form form}
:name :lang
:data-test "setting-lang"}]] [: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 (when new-css-system
[:h2 (tr "dashboard.theme-change")] [:h2 (tr "dashboard.theme-change")]
@ -79,6 +174,4 @@
#(dom/set-html-title (tr "title.settings.options"))) #(dom/set-html-title (tr "title.settings.options")))
[:div.dashboard-settings [:div.dashboard-settings
[:div.form-container [:& settings]])
{:data-test "settings-form"}
[:& options-form {}]]])

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 (:require
[app.common.spec :as us] [app.common.spec :as us]
[app.config :as cf] [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.modal :as modal]
[app.main.data.users :as du] [app.main.data.users :as du]
[app.main.refs :as refs] [app.main.refs :as refs]
@ -29,7 +29,7 @@
(defn- on-success (defn- on-success
[_] [_]
(st/emit! (dm/success (tr "notifications.profile-saved")))) (st/emit! (msg/success (tr "notifications.profile-saved"))))
(defn- on-submit (defn- on-submit
[form _event] [form _event]
@ -101,6 +101,7 @@
:on-selected on-file-selected :on-selected on-file-selected
:data-test "profile-image-input"}]]])) :data-test "profile-image-input"}]]]))
;; --- Profile Page ;; --- Profile Page
(mf/defc profile-page [] (mf/defc profile-page []
@ -110,4 +111,3 @@
[:div.form-container.two-columns [:div.form-container.two-columns
[:& profile-photo-form] [:& profile-photo-form]
[:& profile-form]]]) [:& profile-form]]])

View file

@ -26,39 +26,33 @@
options? (= section :settings-options) options? (= section :settings-options)
feedback? (= section :settings-feedback) feedback? (= section :settings-feedback)
access-tokens? (= section :settings-access-tokens) access-tokens? (= section :settings-access-tokens)
passkeys? (= section :settings-passkeys)
go-dashboard go-dashboard
(mf/use-callback (mf/use-fn
(mf/deps profile) (mf/deps profile)
#(st/emit! (rt/nav :dashboard-projects {:team-id (du/get-current-team-id profile)}))) #(st/emit! (rt/nav :dashboard-projects {:team-id (du/get-current-team-id profile)})))
go-settings-profile go-settings-profile
(mf/use-callback (mf/use-fn #(st/emit! (rt/nav :settings-profile)))
(mf/deps profile)
#(st/emit! (rt/nav :settings-profile)))
go-settings-feedback go-settings-feedback
(mf/use-callback (mf/use-fn #(st/emit! (rt/nav :settings-feedback)))
(mf/deps profile)
#(st/emit! (rt/nav :settings-feedback)))
go-settings-password go-settings-password
(mf/use-callback (mf/use-fn #(st/emit! (rt/nav :settings-password)))
(mf/deps profile)
#(st/emit! (rt/nav :settings-password)))
go-settings-options go-settings-options
(mf/use-callback (mf/use-fn #(st/emit! (rt/nav :settings-options)))
(mf/deps profile)
#(st/emit! (rt/nav :settings-options)))
go-settings-access-tokens go-settings-access-tokens
(mf/use-callback (mf/use-fn #(st/emit! (rt/nav :settings-access-tokens)))
(mf/deps profile)
#(st/emit! (rt/nav :settings-access-tokens))) go-settings-passkeys
(mf/use-fn #(st/emit! (rt/nav :settings-passkeys)))
show-release-notes show-release-notes
(mf/use-callback (mf/use-fn
(fn [event] (fn [event]
(let [version (:main cf/version)] (let [version (:main cf/version)]
(st/emit! (ptk/event ::ev/event {::ev/name "show-release-notes" :version version})) (st/emit! (ptk/event ::ev/event {::ev/name "show-release-notes" :version version}))
@ -95,9 +89,17 @@
[:li {:class (when access-tokens? "current") [:li {:class (when access-tokens? "current")
:on-click go-settings-access-tokens :on-click go-settings-access-tokens
:data-test "settings-access-tokens"} :data-test "settings-access-tokens"}
i/icon-key i/key
[:span.element-title (tr "labels.access-tokens")]]) [: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] [:hr]
[:li {:on-click show-release-notes :data-test "release-notes"} [: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" msgid "dashboard.access-tokens.token-will-not-expire"
msgstr "The token has no expiration date" 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 #: src/app/main/data/dashboard.cljs, src/app/main/data/dashboard.cljs
msgid "dashboard.copy-suffix" msgid "dashboard.copy-suffix"
msgstr "(copy)" msgstr "(copy)"
@ -864,6 +906,14 @@ msgstr "Are you sure?"
msgid "errors.auth-provider-not-configured" msgid "errors.auth-provider-not-configured"
msgstr "Authentication 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" msgid "errors.auth.unable-to-login"
msgstr "Looks like you are not authenticated or session expired." msgstr "Looks like you are not authenticated or session expired."