Skip to content

Commit 095ce1f

Browse files
committed
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
1 parent 91004bd commit 095ce1f

8 files changed

Lines changed: 345 additions & 144 deletions

File tree

src/lang/en/login.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
"forget_url": "https://doc.oplist.org/faq/howto#how-to-get-password-if-i-forget-it",
99
"clear": "Clear",
1010
"login": "Login",
11+
"continue_with_passkey": "Continue with passkey",
12+
"passkey_input_username": "Enter username for legacy security key",
1113
"use_guest": "Browse as a guest",
12-
"success": "Login successfully"
14+
"success": "Login successfully",
15+
"passkey_legacy_upgrade_tip": "You are using a legacy security key. After login, please add a new passkey in Profile for passwordless sign-in."
1316
}

src/lang/en/settings.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,5 @@
141141
"upload_task_threads_num": "Upload task threads num",
142142
"version": "Version",
143143
"video_autoplay": "Video autoplay",
144-
"video_types": "Video types",
145-
"webauthn_login_enabled": "Webauthn login enabled"
144+
"video_types": "Video types"
146145
}

src/lang/en/users.json

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,17 @@
4545
"sso_login": "Single Sign-On Login",
4646
"connect_sso": "Connect to Single Sign-On Platform",
4747
"disconnect_sso": "Disconnect from Single Sign-On Platform",
48-
"webauthn": "WebAuthn",
49-
"add_webauthn": "Add a WebAuthn credential",
50-
"add_webauthn_success": "WebAuthn credential successfully added!",
51-
"webauthn_not_supported": "WebAuthn is not supported in your browser or you are in an unsafe origin",
48+
"webauthn": "Passkey",
49+
"add_webauthn": "Add a passkey",
50+
"add_webauthn_success": "Passkey added successfully!",
51+
"webauthn_not_supported": "Passkey is not supported in your browser or your origin is not secure",
52+
"webauthn_creator_ip": "Creator IP",
53+
"webauthn_creator_ua": "Creator User-Agent",
54+
"update_to_passkey": "Update to Passkey",
55+
"upgrade_to_passkey_success": "Updated to passkey successfully and removed the legacy key.",
56+
"upgrade_to_passkey_keep_old": "Passkey updated, but failed to remove the legacy key. The legacy key is kept.",
57+
"passkey_add_blocked_by_legacy": "Legacy security key detected. Please upgrade it or delete it before adding a new passkey.",
58+
"unknown": "Unknown",
5259
"ssh_keys": {
5360
"heading": "SSH keys",
5461
"add_heading": "Add new SSH key",

src/pages/login/index.tsx

Lines changed: 142 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ const Login = () => {
5454
localStorage.getItem("password") || "",
5555
)
5656
const [opt, setOpt] = createSignal("")
57-
const [useauthn, setuseauthn] = createSignal(false)
57+
const [usePasskey, setUsePasskey] = createSignal(false)
58+
const [passkeyNeedsUsername, setPasskeyNeedsUsername] = createSignal(false)
5859
const [remember, setRemember] = createStorageSignal("remember-pwd", "false")
5960
const [useLdap, setUseLdap] = createSignal(false)
6061
const [loading, data] = useFetch(
@@ -74,15 +75,17 @@ const Login = () => {
7475
}
7576
},
7677
)
77-
const [, postauthnlogin] = useFetch(
78+
const [, postPasskeyLogin] = useFetch(
7879
(
7980
session: string,
8081
credentials: AuthenticationPublicKeyCredential,
8182
username: string,
8283
signal: AbortSignal | undefined,
8384
): Promise<Resp<{ token: string }>> =>
8485
r.post(
85-
"/authn/webauthn_finish_login?username=" + username,
86+
`/authn/passkey_finish_login${
87+
username ? `?username=${encodeURIComponent(username)}` : ""
88+
}`,
8689
JSON.stringify(credentials),
8790
{
8891
headers: {
@@ -92,13 +95,33 @@ const Login = () => {
9295
},
9396
),
9497
)
95-
interface Webauthntemp {
98+
interface PasskeyTemp {
9699
session: string
97100
options: CredentialRequestOptionsJSON
101+
require_username?: boolean
98102
}
99-
const [, getauthntemp] = useFetch(
100-
(username, signal: AbortSignal | undefined): PResp<Webauthntemp> =>
101-
r.get("/authn/webauthn_begin_login?username=" + username, {
103+
interface LegacyAuthnStatus {
104+
has_legacy: boolean
105+
}
106+
const [, getPasskeyTemp] = useFetch(
107+
(
108+
username: string,
109+
allowCredentials: "yes" | "no",
110+
signal: AbortSignal | undefined,
111+
): PResp<PasskeyTemp> => {
112+
const params = new URLSearchParams()
113+
params.set("allowCredentials", allowCredentials)
114+
if (username) {
115+
params.set("username", username)
116+
}
117+
return r.get(`/authn/passkey_begin_login?${params.toString()}`, {
118+
signal,
119+
})
120+
},
121+
)
122+
const [, getLegacyAuthnStatus] = useFetch(
123+
(signal: AbortSignal | undefined): PResp<LegacyAuthnStatus> =>
124+
r.get("/authn/passkey_legacy_status", {
102125
signal,
103126
}),
104127
)
@@ -114,9 +137,34 @@ const Login = () => {
114137
return false
115138
}
116139
}
117-
const AuthnSignEnabled = getSettingBool("webauthn_login_enabled")
140+
const passkeySignEnabled = true
141+
const passkeyAutoDisabled = "passkey-auto-login-disabled"
142+
const legacyPasskeyHintShown = "legacy-passkey-upgrade-tip-shown"
143+
const syncLegacyAuthnStatus = async (signal?: AbortSignal) => {
144+
const resp = await getLegacyAuthnStatus(signal)
145+
handleRespWithoutNotify(
146+
resp,
147+
(data) => {
148+
setPasskeyNeedsUsername(Boolean(data.has_legacy))
149+
},
150+
undefined,
151+
false,
152+
)
153+
}
118154
const AuthnSwitch = async () => {
119-
setuseauthn(!useauthn())
155+
if (usePasskey()) {
156+
AuthnSignal?.abort()
157+
sessionStorage.setItem(passkeyAutoDisabled, "true")
158+
setUsePasskey(false)
159+
setPasskeyNeedsUsername(false)
160+
return
161+
}
162+
sessionStorage.removeItem(passkeyAutoDisabled)
163+
await syncLegacyAuthnStatus()
164+
setUsePasskey(true)
165+
if (!passkeyNeedsUsername()) {
166+
await AuthnLogin()
167+
}
120168
}
121169
let AuthnSignal: AbortController | null = null
122170
const AuthnLogin = async (conditional?: boolean) => {
@@ -132,14 +180,11 @@ const Login = () => {
132180
AuthnSignal?.abort()
133181
const controller = new AbortController()
134182
AuthnSignal = controller
135-
const username_login: string = conditional ? "" : username()
136-
if (!conditional && remember() === "true") {
137-
localStorage.setItem("username", username())
138-
} else {
139-
localStorage.removeItem("username")
140-
}
141-
const resp = await getauthntemp(username_login, controller.signal)
142-
handleResp(resp, async (data) => {
183+
184+
const continuePasskeyLogin = async (
185+
data: PasskeyTemp,
186+
usernameLogin: string,
187+
) => {
143188
try {
144189
const options = parseRequestOptionsFromJSON(data.options)
145190
options.signal = controller.signal
@@ -148,14 +193,22 @@ const Login = () => {
148193
options.mediation = "conditional"
149194
}
150195
const credentials = await get(options)
151-
const resp = await postauthnlogin(
196+
const resp = await postPasskeyLogin(
152197
data.session,
153198
credentials,
154-
username_login,
199+
usernameLogin,
155200
controller.signal,
156201
)
157202
handleRespWithoutNotify(resp, (data) => {
203+
if (
204+
usernameLogin &&
205+
!sessionStorage.getItem(legacyPasskeyHintShown)
206+
) {
207+
notify.warning(t("login.passkey_legacy_upgrade_tip"))
208+
sessionStorage.setItem(legacyPasskeyHintShown, "true")
209+
}
158210
notify.success(t("login.success"))
211+
setPasskeyNeedsUsername(false)
159212
changeToken(data.token)
160213
to(
161214
decodeURIComponent(searchParams.redirect || base_path || "/"),
@@ -166,13 +219,43 @@ const Login = () => {
166219
if (error instanceof Error && error.name != "AbortError")
167220
notify.error(error.message)
168221
}
222+
}
223+
224+
const usernameLogin =
225+
!conditional && passkeyNeedsUsername() ? username().trim() : ""
226+
const allowCredentials: "yes" | "no" =
227+
!conditional && passkeyNeedsUsername() ? "yes" : "no"
228+
const resp = await getPasskeyTemp(
229+
usernameLogin,
230+
allowCredentials,
231+
controller.signal,
232+
)
233+
handleResp(resp, async (data) => {
234+
if (data.require_username && !usernameLogin) {
235+
setPasskeyNeedsUsername(true)
236+
return
237+
}
238+
setPasskeyNeedsUsername(Boolean(data.require_username))
239+
await continuePasskeyLogin(data, usernameLogin)
169240
})
170241
}
171242
const AuthnCleanUpHandler = () => AuthnSignal?.abort()
172243
onMount(() => {
173-
if (AuthnSignEnabled) {
244+
if (passkeySignEnabled) {
174245
window.addEventListener("beforeunload", AuthnCleanUpHandler)
175-
AuthnLogin(true)
246+
if (sessionStorage.getItem(passkeyAutoDisabled) === "true") return
247+
if (!supported()) return
248+
syncLegacyAuthnStatus().then(() => {
249+
if (passkeyNeedsUsername()) {
250+
setUsePasskey(true)
251+
return
252+
}
253+
isAuthnConditionalAvailable().then((available) => {
254+
if (!available) return
255+
setUsePasskey(true)
256+
AuthnLogin(true)
257+
})
258+
})
176259
}
177260
})
178261
onCleanup(() => {
@@ -181,7 +264,7 @@ const Login = () => {
181264
})
182265

183266
const Login = async () => {
184-
if (!useauthn()) {
267+
if (!usePasskey()) {
185268
if (remember() === "true") {
186269
localStorage.setItem("username", username())
187270
localStorage.setItem("password", password())
@@ -254,13 +337,19 @@ const Login = () => {
254337
/>
255338
}
256339
>
257-
<Input
258-
name="username"
259-
placeholder={t("login.username-tips")}
260-
value={username()}
261-
onInput={(e) => setUsername(e.currentTarget.value)}
262-
/>
263-
<Show when={!useauthn()}>
340+
<Show when={!usePasskey() || passkeyNeedsUsername()}>
341+
<Input
342+
name="username"
343+
placeholder={
344+
usePasskey()
345+
? t("login.passkey_input_username")
346+
: t("login.username-tips")
347+
}
348+
value={username()}
349+
onInput={(e) => setUsername(e.currentTarget.value)}
350+
/>
351+
</Show>
352+
<Show when={!usePasskey()}>
264353
<Input
265354
name="password"
266355
placeholder={t("login.password-tips")}
@@ -274,29 +363,31 @@ const Login = () => {
274363
}}
275364
/>
276365
</Show>
277-
<Flex
278-
px="$1"
279-
w="$full"
280-
fontSize="$sm"
281-
color="$neutral10"
282-
justifyContent="space-between"
283-
alignItems="center"
284-
>
285-
<Checkbox
286-
checked={remember() === "true"}
287-
onChange={() =>
288-
setRemember(remember() === "true" ? "false" : "true")
289-
}
366+
<Show when={!usePasskey()}>
367+
<Flex
368+
px="$1"
369+
w="$full"
370+
fontSize="$sm"
371+
color="$neutral10"
372+
justifyContent="space-between"
373+
alignItems="center"
290374
>
291-
{t("login.remember")}
292-
</Checkbox>
293-
<Text as="a" target="_blank" href={t("login.forget_url")}>
294-
{t("login.forget")}
295-
</Text>
296-
</Flex>
375+
<Checkbox
376+
checked={remember() === "true"}
377+
onChange={() =>
378+
setRemember(remember() === "true" ? "false" : "true")
379+
}
380+
>
381+
{t("login.remember")}
382+
</Checkbox>
383+
<Text as="a" target="_blank" href={t("login.forget_url")}>
384+
{t("login.forget")}
385+
</Text>
386+
</Flex>
387+
</Show>
297388
</Show>
298389
<HStack w="$full" spacing="$2">
299-
<Show when={!useauthn()}>
390+
<Show when={!usePasskey()}>
300391
<Button
301392
colorScheme="primary"
302393
w="$full"
@@ -313,7 +404,7 @@ const Login = () => {
313404
</Button>
314405
</Show>
315406
<Button w="$full" loading={loading()} onClick={Login}>
316-
{t("login.login")}
407+
{usePasskey() ? t("login.continue_with_passkey") : t("login.login")}
317408
</Button>
318409
</HStack>
319410
<Show when={ldapLoginEnabled}>
@@ -348,7 +439,7 @@ const Login = () => {
348439
<SwitchLanguageWhite />
349440
<SwitchColorMode />
350441
<SSOLogin />
351-
<Show when={AuthnSignEnabled}>
442+
<Show when={passkeySignEnabled}>
352443
<Icon
353444
cursor="pointer"
354445
boxSize="$8"

src/pages/manage/settings/Common.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ const CommonSettings = (props: CommonSettingsProps) => {
2828
const [settings, setSettings] = createStore<SettingItem[]>([])
2929
const refresh = async () => {
3030
const resp = await getSettings()
31-
handleResp<SettingItem[]>(resp, setSettings)
31+
handleResp<SettingItem[]>(resp, (items) => {
32+
setSettings(items.filter((item) => item.key !== "webauthn_login_enabled"))
33+
})
3234
}
3335
refresh()
3436
const [saveLoading, saveSettings] = useFetch(

0 commit comments

Comments
 (0)