From 095ce1f25d89751eb0b72949a342bc8c22c3823d Mon Sep 17 00:00:00 2001 From: HappyDIY <1241461452@qq.com> Date: Sun, 22 Feb 2026 05:45:17 +0000 Subject: [PATCH] feat(auth)!: migrate WebAuthn to Passkey with cloud sync and enhanced management - Migrate traditional WebAuthn to Passkey to support cloud synchronization and username-less authentication - Rename all backend WebAuthn APIs to Passkey for improved standardization - Enable Passkey by default to align with current industry adoption trends - Display user device IP and User-Agent (UA) in Passkey management page for better device identification - Add upgrade flow to migrate legacy WebAuthn credentials to new Passkey format - Prevent creation of new Passkeys until legacy credentials are upgraded or removed to avoid post-upgrade incompatibility --- src/lang/en/login.json | 5 +- src/lang/en/settings.json | 3 +- src/lang/en/users.json | 15 +- src/pages/login/index.tsx | 193 +++++++++++++++++------ src/pages/manage/settings/Common.tsx | 4 +- src/pages/manage/users/PasskeyItems.tsx | 148 +++++++++++++++++ src/pages/manage/users/Profile.tsx | 56 ++++--- src/pages/manage/users/Webauthnitems.tsx | 65 -------- 8 files changed, 345 insertions(+), 144 deletions(-) create mode 100644 src/pages/manage/users/PasskeyItems.tsx delete mode 100644 src/pages/manage/users/Webauthnitems.tsx diff --git a/src/lang/en/login.json b/src/lang/en/login.json index 0739e56d8..e7726f31f 100644 --- a/src/lang/en/login.json +++ b/src/lang/en/login.json @@ -8,6 +8,9 @@ "forget_url": "https://doc.oplist.org/faq/howto#how-to-get-password-if-i-forget-it", "clear": "Clear", "login": "Login", + "continue_with_passkey": "Continue with passkey", + "passkey_input_username": "Enter username for legacy security key", "use_guest": "Browse as a guest", - "success": "Login successfully" + "success": "Login successfully", + "passkey_legacy_upgrade_tip": "You are using a legacy security key. After login, please add a new passkey in Profile for passwordless sign-in." } diff --git a/src/lang/en/settings.json b/src/lang/en/settings.json index c624a41d7..75f03b66e 100755 --- a/src/lang/en/settings.json +++ b/src/lang/en/settings.json @@ -141,6 +141,5 @@ "upload_task_threads_num": "Upload task threads num", "version": "Version", "video_autoplay": "Video autoplay", - "video_types": "Video types", - "webauthn_login_enabled": "Webauthn login enabled" + "video_types": "Video types" } diff --git a/src/lang/en/users.json b/src/lang/en/users.json index 6413e03b7..504e7913d 100644 --- a/src/lang/en/users.json +++ b/src/lang/en/users.json @@ -45,10 +45,17 @@ "sso_login": "Single Sign-On Login", "connect_sso": "Connect to Single Sign-On Platform", "disconnect_sso": "Disconnect from Single Sign-On Platform", - "webauthn": "WebAuthn", - "add_webauthn": "Add a WebAuthn credential", - "add_webauthn_success": "WebAuthn credential successfully added!", - "webauthn_not_supported": "WebAuthn is not supported in your browser or you are in an unsafe origin", + "webauthn": "Passkey", + "add_webauthn": "Add a passkey", + "add_webauthn_success": "Passkey added successfully!", + "webauthn_not_supported": "Passkey is not supported in your browser or your origin is not secure", + "webauthn_creator_ip": "Creator IP", + "webauthn_creator_ua": "Creator User-Agent", + "update_to_passkey": "Update to Passkey", + "upgrade_to_passkey_success": "Updated to passkey successfully and removed the legacy key.", + "upgrade_to_passkey_keep_old": "Passkey updated, but failed to remove the legacy key. The legacy key is kept.", + "passkey_add_blocked_by_legacy": "Legacy security key detected. Please upgrade it or delete it before adding a new passkey.", + "unknown": "Unknown", "ssh_keys": { "heading": "SSH keys", "add_heading": "Add new SSH key", diff --git a/src/pages/login/index.tsx b/src/pages/login/index.tsx index 742e43fea..74ad64d32 100644 --- a/src/pages/login/index.tsx +++ b/src/pages/login/index.tsx @@ -54,7 +54,8 @@ const Login = () => { localStorage.getItem("password") || "", ) const [opt, setOpt] = createSignal("") - const [useauthn, setuseauthn] = createSignal(false) + const [usePasskey, setUsePasskey] = createSignal(false) + const [passkeyNeedsUsername, setPasskeyNeedsUsername] = createSignal(false) const [remember, setRemember] = createStorageSignal("remember-pwd", "false") const [useLdap, setUseLdap] = createSignal(false) const [loading, data] = useFetch( @@ -74,7 +75,7 @@ const Login = () => { } }, ) - const [, postauthnlogin] = useFetch( + const [, postPasskeyLogin] = useFetch( ( session: string, credentials: AuthenticationPublicKeyCredential, @@ -82,7 +83,9 @@ const Login = () => { signal: AbortSignal | undefined, ): Promise> => r.post( - "/authn/webauthn_finish_login?username=" + username, + `/authn/passkey_finish_login${ + username ? `?username=${encodeURIComponent(username)}` : "" + }`, JSON.stringify(credentials), { headers: { @@ -92,13 +95,33 @@ const Login = () => { }, ), ) - interface Webauthntemp { + interface PasskeyTemp { session: string options: CredentialRequestOptionsJSON + require_username?: boolean } - const [, getauthntemp] = useFetch( - (username, signal: AbortSignal | undefined): PResp => - r.get("/authn/webauthn_begin_login?username=" + username, { + interface LegacyAuthnStatus { + has_legacy: boolean + } + const [, getPasskeyTemp] = useFetch( + ( + username: string, + allowCredentials: "yes" | "no", + signal: AbortSignal | undefined, + ): PResp => { + const params = new URLSearchParams() + params.set("allowCredentials", allowCredentials) + if (username) { + params.set("username", username) + } + return r.get(`/authn/passkey_begin_login?${params.toString()}`, { + signal, + }) + }, + ) + const [, getLegacyAuthnStatus] = useFetch( + (signal: AbortSignal | undefined): PResp => + r.get("/authn/passkey_legacy_status", { signal, }), ) @@ -114,9 +137,34 @@ const Login = () => { return false } } - const AuthnSignEnabled = getSettingBool("webauthn_login_enabled") + const passkeySignEnabled = true + const passkeyAutoDisabled = "passkey-auto-login-disabled" + const legacyPasskeyHintShown = "legacy-passkey-upgrade-tip-shown" + const syncLegacyAuthnStatus = async (signal?: AbortSignal) => { + const resp = await getLegacyAuthnStatus(signal) + handleRespWithoutNotify( + resp, + (data) => { + setPasskeyNeedsUsername(Boolean(data.has_legacy)) + }, + undefined, + false, + ) + } const AuthnSwitch = async () => { - setuseauthn(!useauthn()) + if (usePasskey()) { + AuthnSignal?.abort() + sessionStorage.setItem(passkeyAutoDisabled, "true") + setUsePasskey(false) + setPasskeyNeedsUsername(false) + return + } + sessionStorage.removeItem(passkeyAutoDisabled) + await syncLegacyAuthnStatus() + setUsePasskey(true) + if (!passkeyNeedsUsername()) { + await AuthnLogin() + } } let AuthnSignal: AbortController | null = null const AuthnLogin = async (conditional?: boolean) => { @@ -132,14 +180,11 @@ const Login = () => { AuthnSignal?.abort() const controller = new AbortController() AuthnSignal = controller - const username_login: string = conditional ? "" : username() - if (!conditional && remember() === "true") { - localStorage.setItem("username", username()) - } else { - localStorage.removeItem("username") - } - const resp = await getauthntemp(username_login, controller.signal) - handleResp(resp, async (data) => { + + const continuePasskeyLogin = async ( + data: PasskeyTemp, + usernameLogin: string, + ) => { try { const options = parseRequestOptionsFromJSON(data.options) options.signal = controller.signal @@ -148,14 +193,22 @@ const Login = () => { options.mediation = "conditional" } const credentials = await get(options) - const resp = await postauthnlogin( + const resp = await postPasskeyLogin( data.session, credentials, - username_login, + usernameLogin, controller.signal, ) handleRespWithoutNotify(resp, (data) => { + if ( + usernameLogin && + !sessionStorage.getItem(legacyPasskeyHintShown) + ) { + notify.warning(t("login.passkey_legacy_upgrade_tip")) + sessionStorage.setItem(legacyPasskeyHintShown, "true") + } notify.success(t("login.success")) + setPasskeyNeedsUsername(false) changeToken(data.token) to( decodeURIComponent(searchParams.redirect || base_path || "/"), @@ -166,13 +219,43 @@ const Login = () => { if (error instanceof Error && error.name != "AbortError") notify.error(error.message) } + } + + const usernameLogin = + !conditional && passkeyNeedsUsername() ? username().trim() : "" + const allowCredentials: "yes" | "no" = + !conditional && passkeyNeedsUsername() ? "yes" : "no" + const resp = await getPasskeyTemp( + usernameLogin, + allowCredentials, + controller.signal, + ) + handleResp(resp, async (data) => { + if (data.require_username && !usernameLogin) { + setPasskeyNeedsUsername(true) + return + } + setPasskeyNeedsUsername(Boolean(data.require_username)) + await continuePasskeyLogin(data, usernameLogin) }) } const AuthnCleanUpHandler = () => AuthnSignal?.abort() onMount(() => { - if (AuthnSignEnabled) { + if (passkeySignEnabled) { window.addEventListener("beforeunload", AuthnCleanUpHandler) - AuthnLogin(true) + if (sessionStorage.getItem(passkeyAutoDisabled) === "true") return + if (!supported()) return + syncLegacyAuthnStatus().then(() => { + if (passkeyNeedsUsername()) { + setUsePasskey(true) + return + } + isAuthnConditionalAvailable().then((available) => { + if (!available) return + setUsePasskey(true) + AuthnLogin(true) + }) + }) } }) onCleanup(() => { @@ -181,7 +264,7 @@ const Login = () => { }) const Login = async () => { - if (!useauthn()) { + if (!usePasskey()) { if (remember() === "true") { localStorage.setItem("username", username()) localStorage.setItem("password", password()) @@ -254,13 +337,19 @@ const Login = () => { /> } > - setUsername(e.currentTarget.value)} - /> - + + setUsername(e.currentTarget.value)} + /> + + { }} /> - - - setRemember(remember() === "true" ? "false" : "true") - } + + - {t("login.remember")} - - - {t("login.forget")} - - + + setRemember(remember() === "true" ? "false" : "true") + } + > + {t("login.remember")} + + + {t("login.forget")} + + + - + @@ -348,7 +439,7 @@ const Login = () => { - + { const [settings, setSettings] = createStore([]) const refresh = async () => { const resp = await getSettings() - handleResp(resp, setSettings) + handleResp(resp, (items) => { + setSettings(items.filter((item) => item.key !== "webauthn_login_enabled")) + }) } refresh() const [saveLoading, saveSettings] = useFetch( diff --git a/src/pages/manage/users/PasskeyItems.tsx b/src/pages/manage/users/PasskeyItems.tsx new file mode 100644 index 000000000..9201950fa --- /dev/null +++ b/src/pages/manage/users/PasskeyItems.tsx @@ -0,0 +1,148 @@ +import { Button, HStack, Text, VStack } from "@hope-ui/solid" +import { createSignal, Show } from "solid-js" +import { useFetch, useT } from "~/hooks" +import { PEmptyResp, PResp } from "~/types" +import { handleResp, notify, r } from "~/utils" +import { + RegistrationPublicKeyCredential, + create, + parseCreationOptionsFromJSON, + supported, + CredentialCreationOptionsJSON, +} from "@github/webauthn-json/browser-ponyfill" + +interface PasskeyItemProps { + id: string + fingerprint: string + creator_ip?: string + creator_ua?: string + is_legacy?: boolean + onDeleted?: () => void +} + +interface PasskeyTemp { + session: string + options: CredentialCreationOptionsJSON +} + +export const PasskeyItem = (props: PasskeyItemProps) => { + const t = useT() + const [deleted, setDeleted] = createSignal(false) + const [upgrading, setUpgrading] = createSignal(false) + const [deleting, remove] = useFetch( + (): PEmptyResp => + r.post("/authn/delete_authn", { + id: props.id, + }), + ) + const [, getauthntemp] = useFetch( + (): PResp => + r.get("/authn/passkey_begin_registration?upgrade=yes"), + ) + const [, postregistration] = useFetch( + ( + session: string, + credentials: RegistrationPublicKeyCredential, + ): PEmptyResp => + r.post( + "/authn/passkey_finish_registration", + JSON.stringify(credentials), + { + headers: { + session, + }, + }, + ), + ) + + const upgradeToPasskey = async () => { + if (!supported()) { + notify.error(t("users.webauthn_not_supported")) + return + } + setUpgrading(true) + try { + const beginResp = await getauthntemp() + handleResp(beginResp, async (beginData) => { + const options = parseCreationOptionsFromJSON(beginData.options) + try { + const browserresponse = await create(options) + const finishResp = await postregistration( + beginData.session, + browserresponse, + ) + handleResp(finishResp, async () => { + const deleteResp = await remove() + handleResp( + deleteResp, + () => { + notify.success(t("users.upgrade_to_passkey_success")) + setDeleted(true) + props.onDeleted?.() + }, + () => { + notify.warning(t("users.upgrade_to_passkey_keep_old")) + props.onDeleted?.() + }, + ) + }) + } catch (error: unknown) { + if (error instanceof Error) notify.error(error.message) + } + }) + } finally { + setUpgrading(false) + } + } + + return ( + + + {props.fingerprint} + + {t("users.webauthn_creator_ip")}:{" "} + {props.creator_ip || t("users.unknown")} + + + {t("users.webauthn_creator_ua")}:{" "} + {props.creator_ua || t("users.unknown")} + + + + + + + + + + ) +} diff --git a/src/pages/manage/users/Profile.tsx b/src/pages/manage/users/Profile.tsx index 73c2e9dc0..a9a11a9f1 100644 --- a/src/pages/manage/users/Profile.tsx +++ b/src/pages/manage/users/Profile.tsx @@ -15,13 +15,20 @@ import { VStack, Text, } from "@hope-ui/solid" -import { createSignal, For, JSXElement, onCleanup, Show } from "solid-js" +import { + createMemo, + createSignal, + For, + JSXElement, + onCleanup, + Show, +} from "solid-js" import { LinkWithBase, MaybeLoading } from "~/components" import { useFetch, useManageTitle, useRouter, useT } from "~/hooks" import { setMe, me, getSettingBool } from "~/store" import { PEmptyResp, UserMethods, UserPermissions, PResp } from "~/types" import { handleResp, handleRespWithoutNotify, notify, r } from "~/utils" -import { WebauthnItem } from "./Webauthnitems" +import { PasskeyItem } from "./PasskeyItems" import { RegistrationPublicKeyCredential, create, @@ -56,21 +63,24 @@ const Profile = () => { }), ) - interface WebauthnItem { + interface PasskeyItemModel { fingerprint: string id: string + creator_ip?: string + creator_ua?: string + is_legacy?: boolean } - interface Webauthntemp { + interface PasskeyTemp { session: string options: CredentialCreationOptionsJSON } const [getauthncredentialsloading, getauthncredentials] = useFetch( - (): PResp => r.get("/authn/getcredentials"), + (): PResp => r.get("/authn/getcredentials"), ) const [, getauthntemp] = useFetch( - (): PResp => r.get("/authn/webauthn_begin_registration"), + (): PResp => r.get("/authn/passkey_begin_registration"), ) const [postregistrationloading, postregistration] = useFetch( ( @@ -78,7 +88,7 @@ const Profile = () => { credentials: RegistrationPublicKeyCredential, ): PEmptyResp => r.post( - "/authn/webauthn_finish_registration", + "/authn/passkey_finish_registration", JSON.stringify(credentials), { headers: { @@ -119,16 +129,15 @@ const Profile = () => { onCleanup(() => { window.removeEventListener("message", messageEvent) }) - const [credentials, setcredentials] = createSignal([]) + const [credentials, setcredentials] = createSignal([]) + const hasLegacyCredential = createMemo(() => + credentials().some((item) => item.is_legacy), + ) const initauthnEdit = async () => { const resp = await getauthncredentials() handleRespWithoutNotify(resp, setcredentials) } - if ( - supported() && - !UserMethods.is_guest(me()) && - getSettingBool("webauthn_login_enabled") - ) { + if (supported() && !UserMethods.is_guest(me())) { initauthnEdit() } return ( @@ -259,18 +268,20 @@ const Profile = () => { - + {t("users.webauthn")} {(item) => ( - + )} @@ -278,6 +289,10 @@ const Profile = () => { - - - - ) -}