From 10b7644bb7ba5f82776537bc0c4f5eb95d5f8e4e Mon Sep 17 00:00:00 2001 From: xiaojunnuo Date: Thu, 12 Mar 2026 18:11:02 +0800 Subject: [PATCH] =?UTF-8?q?perf:=20=E6=94=AF=E6=8C=81passkey=E7=99=BB?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/locales/langs/en-US/authentication.ts | 11 + .../src/locales/langs/zh-CN/authentication.ts | 11 + .../src/router/source/modules/certd.ts | 2 +- .../certd-client/src/store/user/api.user.ts | 39 ++ .../ui/certd-client/src/store/user/index.ts | 10 + .../certd-client/src/views/certd/mine/api.ts | 91 +++++ .../certd-client/src/views/certd/mine/use.tsx | 51 +++ .../src/views/certd/mine/user-profile.vue | 141 ++++++- .../src/views/framework/login/index.vue | 363 +++++++++--------- .../src/views/sys/authority/user/crud.tsx | 2 +- packages/ui/certd-client/vite.config.ts | 8 +- .../db/migration/v10040__passkey.sql | 18 + packages/ui/certd-server/package.json | 2 + .../basic/login/login-controller.ts | 17 + .../basic/login/passkey-controller.ts | 99 +++++ .../controller/user/basic/user-controller.ts | 14 + .../controller/user/mine/mine-controller.ts | 37 +- .../src/modules/login/entity/passkey.ts | 35 ++ .../modules/login/service/login-service.ts | 12 +- .../modules/login/service/passkey-service.ts | 208 ++++++++++ .../sys/authority/service/user-service.ts | 5 + pnpm-lock.yaml | 292 +++++++++++--- 22 files changed, 1222 insertions(+), 246 deletions(-) create mode 100644 packages/ui/certd-server/db/migration/v10040__passkey.sql create mode 100644 packages/ui/certd-server/src/controller/basic/login/passkey-controller.ts create mode 100644 packages/ui/certd-server/src/modules/login/entity/passkey.ts create mode 100644 packages/ui/certd-server/src/modules/login/service/passkey-service.ts diff --git a/packages/ui/certd-client/src/locales/langs/en-US/authentication.ts b/packages/ui/certd-client/src/locales/langs/en-US/authentication.ts index f22ace42a..397c061fb 100644 --- a/packages/ui/certd-client/src/locales/langs/en-US/authentication.ts +++ b/packages/ui/certd-client/src/locales/langs/en-US/authentication.ts @@ -68,6 +68,14 @@ export default { smsTab: "Login via SMS code", passwordTab: "Password login", + passkeyTab: "Passkey Login", + passkeyLogin: "Passkey Login", + passkeyHelper: "Login with your biometric or security key", + passkeyNotSupported: "Your browser does not support Passkey", + passkeyRegister: "Register Passkey", + passkeyRegistered: "Passkey Registered", + passkeyRegisterSuccess: "Passkey registered successfully", + passkeyRegisterFailed: "Passkey registration failed", title: "Change Password", weakPasswordWarning: "For your account security, please change your password immediately", changeNow: "Change Now", @@ -90,4 +98,7 @@ export default { updateProfile: "Update Profile", oauthLoginTitle: "Other ways of login", oauthOnlyLoginTitle: "Login", + registerPasskey: "Register Passkey", + deviceName: "Device Name", + deviceNameHelper: "Please enter the device name, used to identify the device", }; diff --git a/packages/ui/certd-client/src/locales/langs/zh-CN/authentication.ts b/packages/ui/certd-client/src/locales/langs/zh-CN/authentication.ts index c51d85a56..0c2ac6f71 100644 --- a/packages/ui/certd-client/src/locales/langs/zh-CN/authentication.ts +++ b/packages/ui/certd-client/src/locales/langs/zh-CN/authentication.ts @@ -68,6 +68,14 @@ export default { smsTab: "手机号登录/注册", passwordTab: "密码登录", + passkeyTab: "Passkey登录", + passkeyLogin: "Passkey登录", + passkeyHelper: "使用您的生物识别或安全密钥登录", + passkeyNotSupported: "您的浏览器不支持Passkey", + passkeyRegister: "注册Passkey", + passkeyRegistered: "Passkey已注册", + passkeyRegisterSuccess: "Passkey注册成功", + passkeyRegisterFailed: "Passkey注册失败", title: "修改密码", weakPasswordWarning: "为了您的账户安全,请立即修改密码", @@ -92,4 +100,7 @@ export default { oauthLoginTitle: "其他登录方式", oauthOnlyLoginTitle: "登录", + registerPasskey: "注册Passkey", + deviceName: "设备名称", + deviceNameHelper: "请输入当前设备名称,绑定多个时好做区分", }; diff --git a/packages/ui/certd-client/src/router/source/modules/certd.ts b/packages/ui/certd-client/src/router/source/modules/certd.ts index e38745825..4a146d68c 100644 --- a/packages/ui/certd-client/src/router/source/modules/certd.ts +++ b/packages/ui/certd-client/src/router/source/modules/certd.ts @@ -260,7 +260,7 @@ export const certdResources = [ meta: { icon: "ion:person-outline", auth: true, - isMenu: false, + isMenu: true, }, }, { diff --git a/packages/ui/certd-client/src/store/user/api.user.ts b/packages/ui/certd-client/src/store/user/api.user.ts index c4ce1fb9d..7de04cc65 100644 --- a/packages/ui/certd-client/src/store/user/api.user.ts +++ b/packages/ui/certd-client/src/store/user/api.user.ts @@ -107,3 +107,42 @@ export async function OauthProviders() { method: "post", }); } + +export async function generatePasskeyRegistrationOptions() { + return await request({ + url: "/passkey/generateRegistration", + method: "post", + }); +} + +export async function verifyPasskeyRegistration(userId: number, response: any, challenge: string) { + return await request({ + url: "/passkey/verifyRegistration", + method: "post", + data: { userId, response, challenge }, + }); +} + +export async function generatePasskeyAuthenticationOptions(userId: number) { + return await request({ + url: "/passkey/generateAuthentication", + method: "post", + data: { userId }, + }); +} + +export async function loginByPasskey(form: { userId: number; credential: any; challenge: string }) { + return await request({ + url: "/loginByPasskey", + method: "post", + data: form, + }); +} + +export async function registerPasskey(form: { userId: number; response: any; challenge: string }) { + return await request({ + url: "/passkey/register", + method: "post", + data: form, + }); +} diff --git a/packages/ui/certd-client/src/store/user/index.ts b/packages/ui/certd-client/src/store/user/index.ts index 8bccf6351..9e76626a1 100644 --- a/packages/ui/certd-client/src/store/user/index.ts +++ b/packages/ui/certd-client/src/store/user/index.ts @@ -92,6 +92,16 @@ export const useUserStore = defineStore({ const loginRes = await UserApi.loginByTwoFactor(form); return await this.onLoginSuccess(loginRes); }, + + async loginByPasskey(form: any) { + const loginRes = await UserApi.loginByPasskey(form); + return await this.onLoginSuccess(loginRes); + }, + + async registerPasskey(form: any) { + return await UserApi.registerPasskey(form); + }, + async getUserInfoAction(): Promise { const userInfo = await UserApi.mine(); this.setUserInfo(userInfo); diff --git a/packages/ui/certd-client/src/views/certd/mine/api.ts b/packages/ui/certd-client/src/views/certd/mine/api.ts index 641638a28..8798b5881 100644 --- a/packages/ui/certd-client/src/views/certd/mine/api.ts +++ b/packages/ui/certd-client/src/views/certd/mine/api.ts @@ -55,3 +55,94 @@ export async function OauthBoundUrl(type: string) { }, }); } + +export async function GetPasskeys() { + return await request({ + url: "/mine/passkeys", + method: "POST", + }); +} + +export async function UnbindPasskey(id: number) { + return await request({ + url: "/mine/unbindPasskey", + method: "POST", + data: { id }, + }); +} + +export interface PasskeyRegistrationOptions { + rp: { + name: string; + id: string; + }; + user: { + id: Uint8Array; + name: string; + displayName: string; + }; + challenge: string; + pubKeyCredParams: { + type: string; + alg: number; + }[]; + timeout: number; + attestation: string; + excludeCredentials: any[]; +} + +export interface PasskeyAuthenticationOptions { + rpId: string; + challenge: string; + timeout: number; + allowCredentials: any[]; +} + +export interface PasskeyCredential { + id: string; + type: string; + rawId: string; + response: { + attestationObject: string; + clientDataJSON: string; + }; +} + +export async function generatePasskeyRegistrationOptions() { + return await request({ + url: "/passkey/generateRegistration", + method: "post", + }); +} + +export async function verifyPasskeyRegistration(userId: number, response: any, challenge: string, deviceName: string) { + return await request({ + url: "/passkey/verifyRegistration", + method: "post", + data: { userId, response, challenge, deviceName }, + }); +} + +export async function generatePasskeyAuthenticationOptions(userId: number) { + return await request({ + url: "/passkey/generateAuthentication", + method: "post", + data: { userId }, + }); +} + +export async function loginByPasskey(userId: number, credential: any, challenge: string) { + return await request({ + url: "/passkey/login", + method: "post", + data: { userId, credential, challenge }, + }); +} + +export async function registerPasskey(userId: number, response: any, challenge: string) { + return await request({ + url: "/passkey/register", + method: "post", + data: { userId, response, challenge }, + }); +} diff --git a/packages/ui/certd-client/src/views/certd/mine/use.tsx b/packages/ui/certd-client/src/views/certd/mine/use.tsx index bebefc2d9..1ab83d6f0 100644 --- a/packages/ui/certd-client/src/views/certd/mine/use.tsx +++ b/packages/ui/certd-client/src/views/certd/mine/use.tsx @@ -108,3 +108,54 @@ export function useUserProfile() { openEditProfileDialog, }; } + +export function usePasskeyRegister() { + const { openCrudFormDialog } = useFormWrapper(); + const wrapperRef = ref(); + async function openRegisterDialog(req: { onSubmit?: (ctx: any) => void }) { + const { t } = useI18n(); + + const userStore = useUserStore(); + const deviceNameRef = ref(); + + const crudOptions: any = { + form: { + wrapper: { + title: t("authentication.registerPasskey"), + width: 500, + onOpened(opts: { form: any }) { + opts.form.deviceName = ""; + }, + }, + onSubmit: req.onSubmit, + afterSubmit: null, + onSuccess: null, + }, + columns: { + deviceName: { + title: t("authentication.deviceName"), + type: "text", + form: { + component: { + class: "w-full", + }, + col: { + span: 24, + }, + helper: t("authentication.deviceNameHelper"), + rules: [{ required: true, message: t("authentication.deviceName") }], + }, + }, + }, + }; + + const wrapper = await openCrudFormDialog({ crudOptions }); + wrapperRef.value = wrapper; + + return wrapper; + } + + return { + openRegisterDialog, + }; +} diff --git a/packages/ui/certd-client/src/views/certd/mine/user-profile.vue b/packages/ui/certd-client/src/views/certd/mine/user-profile.vue index 2b87efb95..807ac49cc 100644 --- a/packages/ui/certd-client/src/views/certd/mine/user-profile.vue +++ b/packages/ui/certd-client/src/views/certd/mine/user-profile.vue @@ -15,6 +15,9 @@ {{ userInfo.email }} {{ userInfo.phoneCode }}{{ userInfo.mobile }} + + + + +
+
+ + {{ passkey.deviceName }} + {{ formatDate(passkey.registeredAt) }} + 解绑 +
+
+
暂无Passkey
+ 注册Passkey +
+ {{ t("authentication.passkeyNotSupported") }} +
+
{{ t("authentication.updateProfile") }} @@ -40,9 +58,11 @@ import { computed, onMounted, Ref, ref } from "vue"; import ChangePasswordButton from "/@/views/certd/mine/change-password-button.vue"; import { useI18n } from "/src/locales"; import { useUserProfile } from "./use"; -import { Modal } from "ant-design-vue"; +import { usePasskeyRegister } from "./use"; +import { message, Modal } from "ant-design-vue"; import { useSettingStore } from "/@/store/settings"; import { isEmpty } from "lodash-es"; +import { dict } from "@fast-crud/fast-crud"; const { t } = useI18n(); @@ -53,11 +73,20 @@ defineOptions({ const settingStore = useSettingStore(); const userInfo: Ref = ref({}); +const passkeys = ref([]); +const passkeySupported = ref(false); const getUserInfo = async () => { userInfo.value = await api.getMineInfo(); }; +const roleDict = dict({ + url: "/basic/user/getSimpleRoles", + value: "id", + label: "name", +}); + const { openEditProfileDialog } = useUserProfile(); +const { openRegisterDialog } = usePasskeyRegister(); function doUpdate() { openEditProfileDialog({ @@ -69,10 +98,12 @@ function doUpdate() { const oauthBounds = ref([]); const oauthProviders = ref([]); + async function loadOauthBounds() { const res = await api.GetOauthBounds(); oauthBounds.value = res; } + async function loadOauthProviders() { const res = await api.GetOauthProviders(); oauthProviders.value = res; @@ -102,12 +133,116 @@ async function unbind(type: string) { } async function bind(type: string) { - //获取第三方登录URL const res = await api.OauthBoundUrl(type); const loginUrl = res.loginUrl; window.location.href = loginUrl; } +async function loadPasskeys() { + try { + const res = await api.GetPasskeys(); + passkeys.value = res; + } catch (e: any) { + console.error("加载Passkey失败:", e); + } +} + +async function unbindPasskey(id: number) { + Modal.confirm({ + title: "确认解绑吗?", + okText: "确认", + okType: "danger", + onOk: async () => { + await api.UnbindPasskey(id); + await loadPasskeys(); + }, + }); +} + +const toBase64Url = (buffer: ArrayBuffer) => { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +}; + +async function registerPasskey() { + if (!passkeySupported.value) { + Modal.error({ title: "错误", content: "浏览器不支持Passkey" }); + return; + } + await openRegisterDialog({ + onSubmit: async (ctx: any) => { + const deviceName = ctx.form.deviceName; + if (!deviceName) { + return; + } + await doRegisterPasskey(deviceName); + message.success("Passkey注册成功"); + }, + }); +} + +async function doRegisterPasskey(deviceName: string) { + try { + const res: any = await api.generatePasskeyRegistrationOptions(); + const options = res; + + navigator.credentials.query({ + publicKey: options, + }); + const credential = await (navigator.credentials as any).create({ + publicKey: { + challenge: Uint8Array.from(atob(options.challenge.replace(/-/g, "+").replace(/_/g, "/")), c => c.charCodeAt(0)), + rp: options.rp, + pubKeyCredParams: options.pubKeyCredParams, + timeout: options.timeout || 60000, + attestation: options.attestation, + excludeCredentials: options.excludeCredentials || [], + user: { + id: Uint8Array.from([res.userId]), + name: userInfo.value.username, + displayName: deviceName, + }, + }, + }); + + if (!credential) { + throw new Error("Passkey注册失败"); + } + + const response = { + id: credential.id, + type: credential.type, + rawId: toBase64Url(credential.rawId), + response: { + attestationObject: toBase64Url(credential.response.attestationObject), + clientDataJSON: toBase64Url(credential.response.clientDataJSON), + }, + }; + + const verifyRes: any = await api.verifyPasskeyRegistration(userInfo.value.id, response, options.challenge, deviceName); + await loadPasskeys(); + } catch (e: any) { + console.error("Passkey注册失败:", e); + Modal.error({ title: "错误", content: e.message || "Passkey注册失败" }); + } +} + +const formatDate = (dateString: string) => { + if (!dateString) return ""; + return new Date(dateString).toLocaleString("zh-CN"); +}; + +const checkPasskeySupport = () => { + passkeySupported.value = false; + if (typeof window !== "undefined" && "credentials" in navigator && "PublicKeyCredential" in window) { + passkeySupported.value = true; + } +}; + const userAvatar = computed(() => { if (isEmpty(userInfo.value.avatar)) { return ""; @@ -123,5 +258,7 @@ onMounted(async () => { await getUserInfo(); await loadOauthBounds(); await loadOauthProviders(); + await loadPasskeys(); + checkPasskeySupport(); }); diff --git a/packages/ui/certd-client/src/views/framework/login/index.vue b/packages/ui/certd-client/src/views/framework/login/index.vue index 4f781818e..f12aea711 100644 --- a/packages/ui/certd-client/src/views/framework/login/index.vue +++ b/packages/ui/certd-client/src/views/framework/login/index.vue @@ -46,11 +46,21 @@ + + + -
@@ -94,8 +104,8 @@
-