diff --git a/.gitignore b/.gitignore index 262cbc0b..50616451 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /node_modules /.pnp .pnp.js +.pnpm-store # testing /coverage diff --git a/package.json b/package.json index 904c978a..5a1076f5 100644 --- a/package.json +++ b/package.json @@ -142,6 +142,11 @@ "patchedDependencies": { "slate-react": "patches/slate-react.patch", "@toast-ui/editor": "patches/@toast-ui__editor.patch" - } + }, + "ignoredBuiltDependencies": [ + "@firebase/util", + "core-js-pure", + "protobufjs" + ] } } diff --git a/public/locales/en/auth.json b/public/locales/en/auth.json index 1c8c2be7..2ecd96f5 100644 --- a/public/locales/en/auth.json +++ b/public/locales/en/auth.json @@ -6,6 +6,12 @@ "metamask": "Sign in with MetaMask", "password": "Sign in with Password", "oidc": "Sign in with OIDC", + "passkey": "Sign in with Passkey", + "passkey_authenticating": "Authenticating...", + "passkey_error_not_supported": "Your browser doesn't support Passkey", + "passkey_error_cancelled": "User cancelled or timeout", + "passkey_error_no_passkey": "No passkey found for this account", + "passkey_error_failed": "Failed to login with passkey", "no_account": "Don't have an account?" }, "reg": { diff --git a/public/locales/en/setting.json b/public/locales/en/setting.json index cfc3afca..8b63536f 100644 --- a/public/locales/en/setting.json +++ b/public/locales/en/setting.json @@ -285,11 +285,32 @@ "github_desc": "Allows members login with GitHub.", "metamask": "Metamask", "metamask_desc": "Allows members login with Metamask.", + "passkey": "Passkey", + "passkey_desc": "Allows members login with Passkey.", "oidc": "OIDC", "oidc_desc": "Allows members login with OIDC.", "oidc_custom": "Custom", "more_details": "Need more detail? See our <0>doc." }, + "passkey": { + "title": "Passkeys", + "desc": "Manage your passkeys for passwordless login", + "add": "Add Passkey", + "adding": "Registering...", + "delete": "Delete", + "delete_confirm": "Are you sure you want to delete this passkey?", + "no_passkeys": "No passkeys registered yet", + "created": "Created", + "last_used": "Last used", + "success_add": "Passkey registered successfully", + "success_delete": "Passkey deleted successfully", + "error_add": "Failed to register passkey", + "error_delete": "Failed to delete passkey", + "error_not_supported": "Your browser doesn't support Passkey", + "error_cancelled": "User cancelled or timeout", + "error_exists": "Passkey already exists", + "enter_name": "Enter a name for this passkey" + }, "widget": { "tip": "Extending VoceChat by embedding the vocechat widget SDK!", "code": "Code Example", diff --git a/public/locales/zh/auth.json b/public/locales/zh/auth.json index 9d920596..b82c5228 100644 --- a/public/locales/zh/auth.json +++ b/public/locales/zh/auth.json @@ -6,6 +6,12 @@ "metamask": "MetaMask登录", "password": "账号密码登录", "oidc": "OIDC登录", + "passkey": "使用通行密钥登录", + "passkey_authenticating": "认证中...", + "passkey_error_not_supported": "您的浏览器不支持通行密钥", + "passkey_error_cancelled": "用户取消或超时", + "passkey_error_no_passkey": "未找到此账号的通行密钥", + "passkey_error_failed": "通行密钥登录失败", "no_account": "没有账号?" }, "reg": { diff --git a/public/locales/zh/setting.json b/public/locales/zh/setting.json index 00b068fc..4d9a6938 100644 --- a/public/locales/zh/setting.json +++ b/public/locales/zh/setting.json @@ -285,11 +285,32 @@ "github_desc": "允许用户使用 GitHub 登录", "metamask": "Metamask", "metamask_desc": "允许用户使用 Metamask 登录", + "passkey": "通行密钥", + "passkey_desc": "允许用户使用通行密钥登录", "oidc": "OIDC", "oidc_desc": "允许用户使用 OIDC 登录", "oidc_custom": "自定义", "more_details": "更多详情,移步 <0> 参考文档 ." }, + "passkey": { + "title": "通行密钥", + "desc": "管理您的通行密钥以实现无密码登录", + "add": "添加通行密钥", + "adding": "注册中...", + "delete": "删除", + "delete_confirm": "确定要删除此通行密钥吗?", + "no_passkeys": "尚未注册通行密钥", + "created": "创建时间", + "last_used": "上次使用", + "success_add": "通行密钥注册成功", + "success_delete": "通行密钥删除成功", + "error_add": "通行密钥注册失败", + "error_delete": "通行密钥删除失败", + "error_not_supported": "您的浏览器不支持通行密钥", + "error_cancelled": "用户取消或超时", + "error_exists": "通行密钥已存在", + "enter_name": "请输入通行密钥名称" + }, "widget": { "tip": "将挂件 SDK 内嵌到网页,实现 VoceChat 扩展!", "code": "代码举例", diff --git a/src/app/config.ts b/src/app/config.ts index 949f050c..26da179a 100644 --- a/src/app/config.ts +++ b/src/app/config.ts @@ -12,7 +12,7 @@ const prices: Price[] = [ }, ]; const official_dev = `https://dev.voce.chat`; -const local_dev = `http://localhost:3333`; +const local_dev = `http://localhost:3000`; // const local_dev = `https://dev.voce.chat`; const dev_origin = process.env.REACT_APP_OFFICIAL_DEMO ? official_dev : local_dev; diff --git a/src/app/services/auth.ts b/src/app/services/auth.ts index a3add625..507ec452 100644 --- a/src/app/services/auth.ts +++ b/src/app/services/auth.ts @@ -212,6 +212,87 @@ export const authApi = createApi({ url: `/user/delete`, method: "DELETE" }) + }), + // Passkey endpoints + passkeyRegisterStart: builder.mutation< + import("@/types/auth").PasskeyRegisterStartResponse, + import("@/types/auth").PasskeyRegisterStartRequest + >({ + query: (data) => ({ + url: "token/passkey/register/start", + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: data + }) + }), + passkeyRegisterFinish: builder.mutation< + { success: boolean; message: string }, + import("@/types/auth").PasskeyRegisterFinishRequest + >({ + query: (data) => ({ + url: "token/passkey/register/finish", + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: data + }) + }), + passkeyLoginStart: builder.mutation< + import("@/types/auth").PasskeyLoginStartResponse, + import("@/types/auth").PasskeyLoginStartRequest + >({ + query: (data) => ({ + url: "token/passkey/auth/start", + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: data + }) + }), + passkeyLoginFinish: builder.mutation({ + query: (data) => ({ + url: "token/passkey/auth/finish", + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: data + }), + transformResponse: (data: AuthData) => { + const { avatar_updated_at } = data.user; + return { + ...data, + avatar: + avatar_updated_at == 0 + ? "" + : `${BASE_URL}/resource/avatar?uid=${data.user.uid}&t=${avatar_updated_at}` + }; + }, + async onQueryStarted(params, { dispatch, queryFulfilled }) { + try { + const { data } = await queryFulfilled; + if (data) { + dispatch(setAuthData(data)); + dispatch(setReady(false)); + dispatch(updateSSEStatus("disconnected")); + } + } catch { + console.log("passkey login error"); + } + } + }), + getUserPasskeys: builder.query({ + query: () => ({ url: "token/passkey/list" }) + }), + deletePasskey: builder.mutation({ + query: (credentialId) => ({ + url: `token/passkey/${encodeURIComponent(credentialId)}`, + method: "DELETE" + }) }) }) }); @@ -234,5 +315,12 @@ export const { useCheckMagicTokenValidMutation, useUpdatePasswordMutation, useRegisterMutation, - useLazyDeleteCurrentAccountQuery + useLazyDeleteCurrentAccountQuery, + usePasskeyRegisterStartMutation, + usePasskeyRegisterFinishMutation, + usePasskeyLoginStartMutation, + usePasskeyLoginFinishMutation, + useGetUserPasskeysQuery, + useLazyGetUserPasskeysQuery, + useDeletePasskeyMutation } = authApi; diff --git a/src/app/services/base.query.ts b/src/app/services/base.query.ts index 1bc4fdc2..032f0dcf 100644 --- a/src/app/services/base.query.ts +++ b/src/app/services/base.query.ts @@ -29,6 +29,8 @@ const whiteList = [ "sendMessageByBot", "getAgoraVoicingList", "preCheckFileFromUrl", + "passkeyLoginStart", + "passkeyLoginFinish", ]; const whiteList401 = ["getAgoraVoicingList", "getAgoraChannels"]; const errorWhiteList = [ @@ -141,6 +143,11 @@ const baseQueryWithTokenCheck = async (args: any, api: any, extraOptions: any) = toast.error("File size too large"); } break; + case 415: + { + toast.error("Unsupported Media Type"); + } + break; case 451: { // 证书错误 diff --git a/src/passkey.ts b/src/passkey.ts new file mode 100644 index 00000000..fb038451 --- /dev/null +++ b/src/passkey.ts @@ -0,0 +1,112 @@ +// Passkey utility functions for WebAuthn + +// Base64 转 ArrayBuffer +export function base64ToArrayBuffer(base64: string): ArrayBuffer { + const binaryString = atob(base64.replace(/-/g, '+').replace(/_/g, '/')); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes.buffer; +} + +// ArrayBuffer 转 Base64 +export function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + +// 检查浏览器是否支持 WebAuthn +export function isWebAuthnSupported(): boolean { + return window.PublicKeyCredential !== undefined && navigator.credentials !== undefined; +} + +// 检查是否有平台认证器可用 +export async function isPlatformAuthenticatorAvailable(): Promise { + if (!isWebAuthnSupported()) return false; + return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); +} + +// 开始 Passkey 注册流程 +export async function startPasskeyRegistration(options: any) { + if (!isWebAuthnSupported()) { + throw new Error('WebAuthn not supported'); + } + + // 确保创建 Discoverable Credential + const authenticatorSelection = { + ...(options.publicKey.authenticatorSelection || {}), + residentKey: "required" as ResidentKeyRequirement, + requireResidentKey: true, + userVerification: options.publicKey.authenticatorSelection?.userVerification || "preferred" as UserVerificationRequirement + }; + + const credential = await navigator.credentials.create({ + publicKey: { + ...options.publicKey, + challenge: base64ToArrayBuffer(options.publicKey.challenge), + user: { + ...options.publicKey.user, + id: base64ToArrayBuffer(options.publicKey.user.id) + }, + authenticatorSelection + } + }) as PublicKeyCredential; + + if (!credential) { + throw new Error('Failed to create credential'); + } + + const response = credential.response as AuthenticatorAttestationResponse; + + return { + id: credential.id, + rawId: arrayBufferToBase64(credential.rawId), + response: { + clientDataJSON: arrayBufferToBase64(response.clientDataJSON), + attestationObject: arrayBufferToBase64(response.attestationObject) + }, + type: credential.type + }; +} + +// 开始 Passkey 登录流程 +export async function startPasskeyLogin(options: any) { + if (!isWebAuthnSupported()) { + throw new Error('WebAuthn not supported'); + } + + const credential = await navigator.credentials.get({ + publicKey: { + ...options.publicKey, + challenge: base64ToArrayBuffer(options.publicKey.challenge), + allowCredentials: options.publicKey.allowCredentials?.map((cred: any) => ({ + ...cred, + id: base64ToArrayBuffer(cred.id) + })) + } + }) as PublicKeyCredential; + + if (!credential) { + throw new Error('Failed to get credential'); + } + + const response = credential.response as AuthenticatorAssertionResponse; + + return { + id: credential.id, + rawId: arrayBufferToBase64(credential.rawId), + response: { + clientDataJSON: arrayBufferToBase64(response.clientDataJSON), + authenticatorData: arrayBufferToBase64(response.authenticatorData), + signature: arrayBufferToBase64(response.signature), + userHandle: response.userHandle ? arrayBufferToBase64(response.userHandle) : null + }, + type: credential.type + }; +} + diff --git a/src/routes/login/index.tsx b/src/routes/login/index.tsx index cef0f377..d9d70e3c 100644 --- a/src/routes/login/index.tsx +++ b/src/routes/login/index.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { FetchBaseQueryError } from "@reduxjs/toolkit/dist/query"; import BASE_URL from "@/app/config"; -import { useLoginMutation } from "@/app/services/auth"; +import { useLoginMutation, usePasskeyLoginStartMutation, usePasskeyLoginFinishMutation } from "@/app/services/auth"; import { useGetLoginConfigQuery, useGetSMTPStatusQuery } from "@/app/services/server"; import { useAppSelector } from "@/app/store"; import Divider from "@/components/Divider"; @@ -18,6 +18,8 @@ import SocialLoginButtons from "./SocialLoginButtons"; import { shallowEqual } from "react-redux"; import SelectLanguage from "../../components/Language"; import Downloads from "../../components/Downloads"; +import { startPasskeyLogin, isWebAuthnSupported } from "@/passkey"; +import ServerVersionChecker from "@/components/ServerVersionChecker"; const defaultInput = { email: "", @@ -32,6 +34,9 @@ export default function LoginPage() { const { data: loginConfig, isSuccess: loginConfigSuccess } = useGetLoginConfigQuery(); const [emailInputted, setEmailInputted] = useState(false); const [input, setInput] = useState(defaultInput); + const [passkeyLoginStart] = usePasskeyLoginStartMutation(); + const [passkeyLoginFinish] = usePasskeyLoginFinishMutation(); + const [isPasskeyLoading, setIsPasskeyLoading] = useState(false); useEffect(() => { const query = new URLSearchParams(location.search); @@ -122,10 +127,40 @@ export default function LoginPage() { const handleBack = () => { setEmailInputted(false); }; + + const handlePasskeyLogin = async () => { + if (!isWebAuthnSupported()) { + toast.error(t("login.passkey_error_not_supported")); + return; + } + + setIsPasskeyLoading(true); + try { + const { challenge_id, options } = await passkeyLoginStart({}).unwrap(); + + const credential = await startPasskeyLogin(options); + + await passkeyLoginFinish({ challenge_id, authentication: credential }).unwrap(); + + toast.success(ct("tip.login")); + } catch (error: any) { + if (error.name === 'NotAllowedError') { + toast.error(t("login.passkey_error_cancelled")); + } else if (error.status === 404) { + toast.error(t("login.passkey_error_no_passkey")); + } else { + toast.error(t("login.passkey_error_failed")); + } + console.error("Passkey login error:", error); + } finally { + setIsPasskeyLoading(false); + } + }; + const { email, password } = input; if (!loginConfigSuccess) return null; - const { magic_link, who_can_sign_up: whoCanSignUp } = loginConfig; + const { magic_link, who_can_sign_up: whoCanSignUp, passkey } = loginConfig; const enableMagicLink = enableSMTP && magic_link; const hideSocials = (enableMagicLink && emailInputted) || whoCanSignUp == "InvitationOnly"; @@ -198,6 +233,17 @@ export default function LoginPage() {
+ {passkey && ( + + + + )} {emailInputted && } {!hideSocials && }
diff --git a/src/routes/setting/MyAccount.tsx b/src/routes/setting/MyAccount.tsx index 36356075..2b3e89d6 100644 --- a/src/routes/setting/MyAccount.tsx +++ b/src/routes/setting/MyAccount.tsx @@ -3,13 +3,16 @@ import toast from "react-hot-toast"; import { useTranslation } from "react-i18next"; import { useUpdateAvatarMutation } from "@/app/services/user"; +import { useGetLoginConfigQuery } from "@/app/services/server"; import { useAppSelector } from "@/app/store"; import AvatarUploader from "@/components/AvatarUploader"; import Button from "@/components/styled/Button"; import ProfileBasicEditModal from "./ProfileBasicEditModal"; import RemoveAccountConfirmModal from "./RemoveAccountConfirmModal"; import UpdatePasswordModal from "./UpdatePasswordModal"; +import PasskeyManagement from "./PasskeyManagement"; import { shallowEqual } from "react-redux"; +import ServerVersionChecker from "@/components/ServerVersionChecker"; type EditField = "name" | "email" | ""; export default function MyAccount() { @@ -19,6 +22,7 @@ export default function MyAccount() { const [editModal, setEditModal] = useState(""); const [removeConfirmVisible, setRemoveConfirmVisible] = useState(false); const [uploadAvatar, { isSuccess: uploadSuccess }] = useUpdateAvatarMutation(); + const { data: loginConfig } = useGetLoginConfigQuery(); const EditModalInfo = { name: { label: t("username"), @@ -95,6 +99,15 @@ export default function MyAccount() { + + {loginConfig?.passkey && ( + +
+ +
+
+ )} + {/* uid 1 是初始账户,不能删 */} {uid != 1 && ( + + +
+ {passkeys && passkeys.length > 0 ? ( + passkeys.map((passkey) => ( +
+
+ + {passkey.name} + + + {t("passkey.created")}: {new Date(passkey.created_at).toLocaleDateString()} + {passkey.last_used_at && + ` • ${t("passkey.last_used")}: ${new Date(passkey.last_used_at).toLocaleDateString()}` + } + +
+ +
+ )) + ) : ( +
+ {t("passkey.no_passkeys")} +
+ )} +
+ + {showModal && ( + + + + + + } + > + setPasskeyName(e.target.value)} + /> + + + )} + + ); +} + diff --git a/src/routes/setting/config/Logins.tsx b/src/routes/setting/config/Logins.tsx index 6df0a856..ddd8a820 100644 --- a/src/routes/setting/config/Logins.tsx +++ b/src/routes/setting/config/Logins.tsx @@ -12,6 +12,7 @@ import useGithubAuthConfig from "@/hooks/useGithubAuthConfig"; import useGoogleAuthConfig from "@/hooks/useGoogleAuthConfig"; import IssuerList from "./IssuerList"; import Tooltip from "./Tooltip"; +import ServerVersionChecker from "@/components/ServerVersionChecker"; export default function Logins() { const { t } = useTranslation("setting", { keyPrefix: "login" }); @@ -59,7 +60,7 @@ export default function Logins() { } }; const handleToggle = ( - val: Partial> + val: Partial> ) => { setValues((prev) => { if (!prev) return prev; @@ -67,7 +68,7 @@ export default function Logins() { }); }; if (!values) return null; - const { google, magic_link, github, metamask, password, oidc = [] } = values as LoginConfig; + const { google, magic_link, github, metamask, password, passkey, oidc = [] } = values as LoginConfig; const valuesChanged = clientIdChanged || changed || githubChanged; return ( @@ -170,6 +171,22 @@ export default function Logins() { > + +
+
+
+
+ +
+ {t("passkey_desc")} +
+ +
+
+
diff --git a/src/types/auth.ts b/src/types/auth.ts index 36c59c39..a3b4857d 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -53,6 +53,11 @@ export type ThirdPartyCredential = { key: string; type: "thirdparty"; }; +export type PasskeyCredential = { + challenge_id: string; + credential: PublicKeyCredentialWithResponse; + type: "passkey"; +}; export type LoginCredential = | PasswordCredential | OIDCCredential @@ -60,7 +65,8 @@ export type LoginCredential = | GithubCredential | GoogleCredential | ThirdPartyCredential - | MagicLinkCredential; + | MagicLinkCredential + | PasskeyCredential; export type CredentialResponse = { password: boolean; @@ -73,3 +79,57 @@ export interface OIDCConfig { favicon: string; domain: string; } + +// Passkey types +export interface PublicKeyCredentialWithResponse { + id: string; + rawId: string; + response: { + clientDataJSON: string; + attestationObject?: string; + authenticatorData?: string; + signature?: string; + userHandle?: string | null; + }; + type: string; +} + +export interface PasskeyRegisterStartRequest { + name: string; +} + +export interface PasskeyRegisterStartResponse { + challenge_id: string; + options: { + publicKey: PublicKeyCredentialCreationOptions; + }; +} + +export interface PasskeyRegisterFinishRequest { + challenge_id: string; + credential: PublicKeyCredentialWithResponse; + name: string; +} + +export interface PasskeyLoginStartRequest { +} + +export interface PasskeyLoginStartResponse { + challenge_id: string; + options: { + publicKey: PublicKeyCredentialRequestOptions; + }; +} + +export interface PasskeyLoginFinishRequest { + challenge_id: string; + authentication: PublicKeyCredentialWithResponse; +} + +export interface UserPasskey { + id: number; + credential_id: string; + name: string; + created_at: string; + last_used_at?: string; +} diff --git a/src/types/server.ts b/src/types/server.ts index a2edd31c..50c3c8e4 100644 --- a/src/types/server.ts +++ b/src/types/server.ts @@ -97,6 +97,7 @@ export interface LoginConfig { oidc: OIDCSetting[]; metamask: boolean; third_party: boolean; + passkey?: boolean; } export interface LicenseResponse { domains: string[];