diff --git a/src/api/passkey.ts b/src/api/passkey.ts new file mode 100644 index 0000000..c31712c --- /dev/null +++ b/src/api/passkey.ts @@ -0,0 +1,43 @@ +import http from '@/api/index'; +import type { + PasskeyAuthenticationCredential, + PasskeyRegistrationCredential, + PublicKeyCredentialCreationOptionsJSON, + PublicKeyCredentialRequestOptionsJSON, +} from '@/lib/passkey'; + +export interface PasskeyLoginOptionsResponse { + challenge_id: string; + publicKey: PublicKeyCredentialRequestOptionsJSON; +} + +export interface PasskeyRegisterOptionsResponse { + challenge_id: string; + publicKey: PublicKeyCredentialCreationOptionsJSON; +} + +export interface PasskeyLoginResponse { + code?: string; +} + +export interface PasskeyRegisterPendingResponse { + passkey_code: string; +} + +export const getPasskeyLoginOptionsAPI = (data: { is_oauth: boolean }): Promise => + http.post('/account/passkey_login_options/', data); + +export const passkeyLoginAPI = (data: { + challenge_id: string; + credential: PasskeyAuthenticationCredential; + is_oauth: boolean; +}): Promise => http.post('/account/passkey_login/', data); + +export const getPasskeyRegisterOptionsAPI = (data: { display_name?: string }): Promise => + http.post('/account/passkey_register_options/', data); + +export const passkeyRegisterPendingAPI = (data: { + challenge_id: string; + credential: PasskeyRegistrationCredential; + name: string; +}): Promise => http.post('/account/passkey_register_pending/', data); diff --git a/src/api/user.ts b/src/api/user.ts index 08e469b..6e9de16 100644 --- a/src/api/user.ts +++ b/src/api/user.ts @@ -11,6 +11,7 @@ export interface SignInParams { username: string; password: string; is_oauth?: boolean; + passkey_code?: string; wechat_code?: string; tcaptcha?: object; } @@ -28,6 +29,7 @@ export interface SignUpParams { phone_number: string; phone_verify: string; is_oauth?: boolean; + passkey_code?: string; wechat_code?: string; tcaptcha?: object; } diff --git a/src/components/LoginBox.tsx b/src/components/LoginBox.tsx index 88189eb..a696ece 100644 --- a/src/components/LoginBox.tsx +++ b/src/components/LoginBox.tsx @@ -1,6 +1,6 @@ import { useState, type FormEvent, useEffect, useCallback } from 'react'; import { Link } from 'react-router-dom'; -import { User, Lock, MessageCircle, Loader2 } from 'lucide-react'; +import { User, Lock, MessageCircle, Loader2, Fingerprint } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -10,8 +10,19 @@ import { Separator } from '@/components/ui/separator'; import { useAppStore } from '@/store'; import { useTranslations } from '@/contexts/useTranslations'; import { signInAPI, oauthCodeAPI, signOutAPI, getWeChatConfigAPI, weChatLoginAPI } from '@/api/user'; +import { + getPasskeyLoginOptionsAPI, + getPasskeyRegisterOptionsAPI, + passkeyLoginAPI, + passkeyRegisterPendingAPI, +} from '@/api/passkey'; import { getTCaptchaConfigAPI, type TCaptchaConfig } from '@/api/tcaptcha'; import { hashPassword } from '@/lib/encrypt'; +import { + getPasskeyAuthenticationCredential, + getPasskeyRegistrationCredential, + isPasskeySupported, +} from '@/lib/passkey'; import { checkTCaptcha } from '@/lib/tcaptcha'; import globalContext from '@/context'; @@ -34,23 +45,32 @@ interface LoginBoxProps { onLoginRedirect: (code: string) => void; } +interface WeChatConfig { + app_id?: string; + state?: string; + is_wechat?: boolean; +} + // eslint-disable-next-line complexity export function LoginBox({ onLoginRedirect }: LoginBoxProps) { const { t } = useTranslations(); const [loading, setLoading] = useState(false); + const [passkeyLoading, setPasskeyLoading] = useState(false); const [weChatLoading, setWeChatLoading] = useState(false); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [readAgreement, setReadAgreement] = useState(false); const [error, setError] = useState(''); + const [passkeyMessage, setPasskeyMessage] = useState(''); const [useCurrent, setUseCurrent] = useState(false); const [useCustom, setUseCustom] = useState(false); const [useWeChat, setUseWeChat] = useState(false); const [weChatQuickLoginUrl, setWeChatQuickLoginUrl] = useState(''); + const [weChatConfig, setWeChatConfig] = useState(null); const [tcaptchaConfig, setTcaptchaConfig] = useState(null); const [weChatConfigLoaded, setWeChatConfigLoaded] = useState(false); - const { user, metaConfig, weChatCode, setWeChatCode, isLogin } = useAppStore(); + const { user, metaConfig, passkeyCode, setPasskeyCode, weChatCode, setWeChatCode, isLogin } = useAppStore(); useEffect(() => { getTCaptchaConfigAPI() @@ -58,44 +78,108 @@ export function LoginBox({ onLoginRedirect }: LoginBoxProps) { .catch(() => setTcaptchaConfig({ is_enabled: false, app_id: '', aid_encrypted: '' })); }, []); + useEffect(() => { + getWeChatConfigAPI() + .then((config) => { + setWeChatConfig(config); + setWeChatConfigLoaded(true); + }) + .catch(() => { + setWeChatConfigLoaded(true); + }); + }, []); + const initWeChatLogin = useCallback(() => { + if (!weChatConfig?.app_id) { + return; + } + const url = new URL(window.location.href); const next = url.searchParams.get('next') || ''; const redirectURI = `${globalContext.siteUrl}/login/?next=${encodeURIComponent(next)}`; - getWeChatConfigAPI() - .then((res) => { - setWeChatConfigLoaded(true); - if (res && res.app_id) { - if (res.is_wechat) { - setWeChatQuickLoginUrl( - 'https://open.weixin.qq.com/connect/oauth2/authorize' + - `?appid=${res.app_id}` + + if (weChatConfig.is_wechat) { + setWeChatQuickLoginUrl( + 'https://open.weixin.qq.com/connect/oauth2/authorize' + + `?appid=${weChatConfig.app_id}` + `&redirect_uri=${encodeURIComponent(redirectURI)}` + '&response_type=code' + '&scope=snsapi_userinfo' + - `&state=${res.state}` + + `&state=${weChatConfig.state}` + '#wechat_redirect', - ); - } else { - // eslint-disable-next-line no-new - new window.WxLogin({ - self_redirect: false, - id: 'wxlogin', - appid: res.app_id, - scope: 'snsapi_login', - redirect_uri: encodeURIComponent(redirectURI), - state: res.state || '', - style: '', - href: `${globalContext.siteUrl}/extra-assets/css/wechat_login.css?v=1718266759`, - }); - } - } - }) - .catch(() => { - setWeChatConfigLoaded(true); + ); + return; + } + + // eslint-disable-next-line no-new + new window.WxLogin({ + self_redirect: false, + id: 'wxlogin', + appid: weChatConfig.app_id, + scope: 'snsapi_login', + redirect_uri: encodeURIComponent(redirectURI), + state: weChatConfig.state || '', + style: '', + href: `${globalContext.siteUrl}/extra-assets/css/wechat_login.css?v=1718266759`, + }); + }, [weChatConfig]); + + const isPasskeyCredentialNotFound = (err: unknown) => { + const apiError = err as { message?: string; status?: number }; + + return apiError.status === 404 || apiError.message?.includes('Passkey Credential Not Found'); + }; + + const isPasskeyAuthenticationUnavailable = (err: unknown) => { + const passkeyError = err as { message?: string; name?: string }; + + return passkeyError.name === 'NotAllowedError' || passkeyError.message === 'Passkey Authentication Failed'; + }; + + const getPasskeyErrorMessage = (err: unknown, fallback: string) => { + const passkeyError = err as { message?: string; name?: string }; + + if (passkeyError.name === 'NotAllowedError') { + return t.PasskeyCancelled; + } + if (passkeyError.message === 'Passkey Authentication Failed') { + return t.PasskeyLoginFailed; + } + if (passkeyError.message === 'Passkey Registration Failed') { + return t.PasskeyRegisterFailed; + } + + return passkeyError.message || fallback; + }; + + const getPasskeyName = () => { + if (typeof navigator !== 'undefined' && navigator.platform) { + return `${navigator.platform} ${t.PasskeyDefaultName}`; + } + + return t.PasskeyDefaultName; + }; + + const createPendingPasskey = async () => { + try { + const options = await getPasskeyRegisterOptionsAPI({ + display_name: username || t.PasskeyPendingUser, + }); + const credential = await getPasskeyRegistrationCredential(options.publicKey); + const res = await passkeyRegisterPendingAPI({ + challenge_id: options.challenge_id, + credential, + name: getPasskeyName(), }); - }, []); + + setPasskeyCode(res.passkey_code); + setPasskeyMessage(t.PasskeyBindReady); + } catch (err) { + setError(getPasskeyErrorMessage(err, t.PasskeyRegisterFailed)); + } finally { + setPasskeyLoading(false); + } + }; const checkWeChatLogin = useCallback(() => { const url = new URL(window.location.href); @@ -127,7 +211,7 @@ export function LoginBox({ onLoginRedirect }: LoginBoxProps) { }, [checkWeChatLogin]); useEffect(() => { - if (useWeChat && !weChatConfigLoaded) { + if (useWeChat && weChatConfigLoaded) { initWeChatLogin(); } }, [useWeChat, weChatConfigLoaded, initWeChatLogin]); @@ -140,6 +224,7 @@ export function LoginBox({ onLoginRedirect }: LoginBoxProps) { } setLoading(true); setError(''); + setPasskeyMessage(''); try { const encryptedPassword = await hashPassword(password, username); @@ -169,12 +254,16 @@ export function LoginBox({ onLoginRedirect }: LoginBoxProps) { password: encryptedPassword, is_oauth: true, tcaptcha: tcaptchaResult, + ...(passkeyCode && { passkey_code: passkeyCode }), ...(weChatCode && { wechat_code: weChatCode }), }; if (weChatCode) { setWeChatCode(''); } const res = await signInAPI(params); + if (passkeyCode) { + setPasskeyCode(''); + } onLoginRedirect(res.code); } catch (err) { const errorMessage = (err as { message?: string })?.message || t.LoginFailed; @@ -217,6 +306,43 @@ export function LoginBox({ onLoginRedirect }: LoginBoxProps) { window.location.href = weChatQuickLoginUrl; }; + const handlePasskeyLogin = async () => { + if (!isPasskeySupported()) { + setError(t.PasskeyUnsupported); + return; + } + + setPasskeyLoading(true); + setError(''); + setPasskeyMessage(''); + + try { + const options = await getPasskeyLoginOptionsAPI({ is_oauth: true }); + const credential = await getPasskeyAuthenticationCredential(options.publicKey); + const res = await passkeyLoginAPI({ + challenge_id: options.challenge_id, + credential, + is_oauth: true, + }); + + if (res.code) { + onLoginRedirect(res.code); + return; + } + + setError(t.PasskeyLoginFailed); + setPasskeyLoading(false); + } catch (err) { + if (isPasskeyCredentialNotFound(err) || isPasskeyAuthenticationUnavailable(err)) { + await createPendingPasskey(); + return; + } + + setError(getPasskeyErrorMessage(err, t.PasskeyLoginFailed)); + setPasskeyLoading(false); + } + }; + if (isLogin && user.username && !useCustom && !useCurrent) { setUseCurrent(true); } @@ -305,7 +431,7 @@ export function LoginBox({ onLoginRedirect }: LoginBoxProps) { id="username" value={username} onChange={(e) => setUsername(e.target.value)} - disabled={loading || weChatLoading} + disabled={loading || passkeyLoading || weChatLoading} className="h-10 pl-9" placeholder={t.Username} /> @@ -322,12 +448,13 @@ export function LoginBox({ onLoginRedirect }: LoginBoxProps) { type="password" value={password} onChange={(e) => setPassword(e.target.value)} - disabled={loading || weChatLoading} + disabled={loading || passkeyLoading || weChatLoading} className="h-10 pl-9" placeholder={t.Password} /> + {passkeyMessage &&

{passkeyMessage}

} {error &&

{error}

}
-
@@ -371,17 +498,32 @@ export function LoginBox({ onLoginRedirect }: LoginBoxProps) {
- +
+ {weChatConfig?.app_id && ( + + )} + +
diff --git a/src/components/RegistryBox.tsx b/src/components/RegistryBox.tsx index f8a0f9f..113f27e 100644 --- a/src/components/RegistryBox.tsx +++ b/src/components/RegistryBox.tsx @@ -35,7 +35,7 @@ export function RegistryBox({ onLoginRedirect }: RegistryBoxProps) { const [phoneAreaOptions, setPhoneAreaOptions] = useState<{ value: string; label: string }[]>([]); const [tcaptchaConfig, setTcaptchaConfig] = useState(null); - const { weChatCode, setWeChatCode, metaConfig } = useAppStore(); + const { passkeyCode, setPasskeyCode, weChatCode, setWeChatCode, metaConfig } = useAppStore(); useEffect(() => { getPhoneAreasAPI().then((res) => { @@ -131,12 +131,16 @@ export function RegistryBox({ onLoginRedirect }: RegistryBoxProps) { phone_verify: phoneVerify, is_oauth: true, tcaptcha: tcaptchaResult, + ...(passkeyCode && { passkey_code: passkeyCode }), ...(weChatCode && { wechat_code: weChatCode }), }; if (weChatCode) { setWeChatCode(''); } const res = await signUpAPI(params); + if (passkeyCode) { + setPasskeyCode(''); + } onLoginRedirect(res.code); } catch (err) { const errorMessage = (err as { message?: string })?.message || t.RegistryFailed; @@ -302,7 +306,7 @@ export function RegistryBox({ onLoginRedirect }: RegistryBoxProps) { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 4515a1f..49d6c30 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -145,6 +145,15 @@ "PrivacyAgreementDesc": "We value your privacy, please read our privacy protection policy", "alreadyReadAgreement": "I've read and agree to ", "WeChatQuickLogin": "WeChat Quick Login", + "PasskeyLogin": "Passkey Login", + "PasskeyCreating": "Creating Passkey", + "PasskeyLoginFailed": "Passkey Login Failed", + "PasskeyRegisterFailed": "Passkey Registration Failed", + "PasskeyUnsupported": "This browser or environment does not support Passkey", + "PasskeyCancelled": "Passkey operation cancelled", + "PasskeyBindReady": "Passkey registered. Enter username and password to finish binding.", + "PasskeyPendingUser": "Pending Passkey User", + "PasskeyDefaultName": "Passkey", "ApplicationAndServices": "Application & Services", "ApplicationAndServicesDesc": "Explore our quality applications and services", "BrandValuesTitle": "Core Values", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 62ca8dd..7d6cedb 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -145,6 +145,15 @@ "PrivacyAgreementDesc": "我们重视您的隐私,请阅读以下隐私保护政策", "alreadyReadAgreement": "我已阅读并同意", "WeChatQuickLogin": "微信授权登录", + "PasskeyLogin": "Passkey 登录", + "PasskeyCreating": "正在创建 Passkey", + "PasskeyLoginFailed": "Passkey 登录失败", + "PasskeyRegisterFailed": "Passkey 登记失败", + "PasskeyUnsupported": "当前浏览器或访问环境不支持 Passkey", + "PasskeyCancelled": "已取消 Passkey 操作", + "PasskeyBindReady": "Passkey 已登记,请输入用户名密码完成绑定", + "PasskeyPendingUser": "待绑定 Passkey 用户", + "PasskeyDefaultName": "Passkey", "ApplicationAndServices": "应用与服务", "ApplicationAndServicesDesc": "探索我们为您提供的优质应用与服务", "BrandValuesTitle": "核心价值观", diff --git a/src/lib/passkey.ts b/src/lib/passkey.ts new file mode 100644 index 0000000..6c4095c --- /dev/null +++ b/src/lib/passkey.ts @@ -0,0 +1,166 @@ +export interface PublicKeyCredentialDescriptorJSON extends Omit { + id: string; +} + +export interface PublicKeyCredentialUserEntityJSON extends Omit { + id: string; +} + +export interface PublicKeyCredentialCreationOptionsJSON extends Omit { + challenge: string; + excludeCredentials?: PublicKeyCredentialDescriptorJSON[]; + user: PublicKeyCredentialUserEntityJSON; +} + +export interface PublicKeyCredentialRequestOptionsJSON extends Omit { + allowCredentials?: PublicKeyCredentialDescriptorJSON[]; + challenge: string; +} + +export interface PasskeyRegistrationCredential { + id: string; + rawId: string; + type: string; + authenticatorAttachment: string | null; + response: { + attestationObject: string; + clientDataJSON: string; + transports: string[]; + }; +} + +export interface PasskeyAuthenticationCredential { + id: string; + rawId: string; + type: string; + authenticatorAttachment: string | null; + response: { + authenticatorData: string; + clientDataJSON: string; + signature: string; + userHandle: string | null; + }; +} + +export const isPasskeySupported = (): boolean => + typeof window !== 'undefined' && 'PublicKeyCredential' in window && Boolean(navigator.credentials); + +export const base64urlToBuffer = (value: string): ArrayBuffer => { + const padded = value + '='.repeat((4 - (value.length % 4)) % 4); + const base64 = padded.replace(/-/g, '+').replace(/_/g, '/'); + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + + return bytes.buffer; +}; + +export const bufferToBase64url = (buffer: ArrayBuffer): string => { + const bytes = new Uint8Array(buffer); + let binary = ''; + + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +}; + +export const decodeCreationOptions = (publicKey: PublicKeyCredentialCreationOptionsJSON): PublicKeyCredentialCreationOptions => ({ + ...publicKey, + challenge: base64urlToBuffer(publicKey.challenge), + excludeCredentials: (publicKey.excludeCredentials || []).map((item) => ({ + ...item, + id: base64urlToBuffer(item.id), + })), + user: { + ...publicKey.user, + id: base64urlToBuffer(publicKey.user.id), + }, +}); + +export const decodeRequestOptions = (publicKey: PublicKeyCredentialRequestOptionsJSON): PublicKeyCredentialRequestOptions => ({ + ...publicKey, + allowCredentials: (publicKey.allowCredentials || []).map((item) => ({ + ...item, + id: base64urlToBuffer(item.id), + })), + challenge: base64urlToBuffer(publicKey.challenge), +}); + +const isPublicKeyCredential = (credential: Credential | null): credential is PublicKeyCredential => + Boolean(credential) && credential?.type === 'public-key'; + +const isAttestationResponse = (response: AuthenticatorResponse): response is AuthenticatorAttestationResponse => + 'attestationObject' in response; + +const isAssertionResponse = (response: AuthenticatorResponse): response is AuthenticatorAssertionResponse => + 'authenticatorData' in response && 'signature' in response; + +export const encodeRegistrationCredential = (credential: PublicKeyCredential): PasskeyRegistrationCredential => { + if (!isAttestationResponse(credential.response)) { + throw new Error('Passkey Registration Failed'); + } + + return { + id: credential.id, + rawId: bufferToBase64url(credential.rawId), + type: credential.type, + authenticatorAttachment: credential.authenticatorAttachment, + response: { + attestationObject: bufferToBase64url(credential.response.attestationObject), + clientDataJSON: bufferToBase64url(credential.response.clientDataJSON), + transports: credential.response.getTransports ? credential.response.getTransports() : [], + }, + }; +}; + +export const encodeAuthenticationCredential = (credential: PublicKeyCredential): PasskeyAuthenticationCredential => { + if (!isAssertionResponse(credential.response)) { + throw new Error('Passkey Authentication Failed'); + } + + return { + id: credential.id, + rawId: bufferToBase64url(credential.rawId), + type: credential.type, + authenticatorAttachment: credential.authenticatorAttachment, + response: { + authenticatorData: bufferToBase64url(credential.response.authenticatorData), + clientDataJSON: bufferToBase64url(credential.response.clientDataJSON), + signature: bufferToBase64url(credential.response.signature), + userHandle: credential.response.userHandle ? bufferToBase64url(credential.response.userHandle) : null, + }, + }; +}; + +export const getPasskeyRegistrationCredential = async ( + publicKey: PublicKeyCredentialCreationOptionsJSON, +): Promise => { + const credential = await navigator.credentials.create({ + publicKey: decodeCreationOptions(publicKey), + }); + + if (!isPublicKeyCredential(credential)) { + throw new Error('Passkey Registration Failed'); + } + + return encodeRegistrationCredential(credential); +}; + +export const getPasskeyAuthenticationCredential = async ( + publicKey: PublicKeyCredentialRequestOptionsJSON, +): Promise => { + const credential = await navigator.credentials.get({ + publicKey: decodeRequestOptions(publicKey), + }); + + if (!isPublicKeyCredential(credential)) { + throw new Error('Passkey Authentication Failed'); + } + + return encodeAuthenticationCredential(credential); +}; diff --git a/src/store/index.ts b/src/store/index.ts index 7e85a03..e13c995 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -6,11 +6,13 @@ interface AppState { mainLoading: boolean; isLogin: boolean; user: UserInfo; + passkeyCode: string; weChatCode: string; metaConfig: MetaConfig; setMainLoading: (loading: boolean) => void; setUser: (user: UserInfo) => void; setIsLogin: (isLogin: boolean) => void; + setPasskeyCode: (code: string) => void; setWeChatCode: (code: string) => void; setMetaConfig: (config: MetaConfig) => void; loadMetaConfig: () => Promise; @@ -53,6 +55,7 @@ export const useAppStore = create((set) => ({ mainLoading: true, isLogin: false, user: defaultUser, + passkeyCode: '', weChatCode: '', metaConfig: defaultMetaConfig, setMainLoading: (loading) => { @@ -64,6 +67,7 @@ export const useAppStore = create((set) => ({ }, setUser: (user) => set({ user }), setIsLogin: (isLogin) => set({ isLogin }), + setPasskeyCode: (code) => set({ passkeyCode: code }), setWeChatCode: (code) => set({ weChatCode: code }), setMetaConfig: (config) => set({ metaConfig: config }), loadMetaConfig: async () => {