Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/lang/en/login.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
3 changes: 1 addition & 2 deletions src/lang/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
15 changes: 11 additions & 4 deletions src/lang/en/users.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
193 changes: 142 additions & 51 deletions src/pages/login/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -74,15 +75,17 @@ const Login = () => {
}
},
)
const [, postauthnlogin] = useFetch(
const [, postPasskeyLogin] = useFetch(
(
session: string,
credentials: AuthenticationPublicKeyCredential,
username: string,
signal: AbortSignal | undefined,
): Promise<Resp<{ token: string }>> =>
r.post(
"/authn/webauthn_finish_login?username=" + username,
`/authn/passkey_finish_login${
username ? `?username=${encodeURIComponent(username)}` : ""
}`,
JSON.stringify(credentials),
{
headers: {
Expand All @@ -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<Webauthntemp> =>
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<PasskeyTemp> => {
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<LegacyAuthnStatus> =>
r.get("/authn/passkey_legacy_status", {
signal,
}),
)
Expand All @@ -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) => {
Expand All @@ -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
Expand All @@ -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 || "/"),
Expand All @@ -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(() => {
Expand All @@ -181,7 +264,7 @@ const Login = () => {
})

const Login = async () => {
if (!useauthn()) {
if (!usePasskey()) {
if (remember() === "true") {
localStorage.setItem("username", username())
localStorage.setItem("password", password())
Expand Down Expand Up @@ -254,13 +337,19 @@ const Login = () => {
/>
}
>
<Input
name="username"
placeholder={t("login.username-tips")}
value={username()}
onInput={(e) => setUsername(e.currentTarget.value)}
/>
<Show when={!useauthn()}>
<Show when={!usePasskey() || passkeyNeedsUsername()}>
<Input
name="username"
placeholder={
usePasskey()
? t("login.passkey_input_username")
: t("login.username-tips")
}
value={username()}
onInput={(e) => setUsername(e.currentTarget.value)}
/>
</Show>
<Show when={!usePasskey()}>
<Input
name="password"
placeholder={t("login.password-tips")}
Expand All @@ -274,29 +363,31 @@ const Login = () => {
}}
/>
</Show>
<Flex
px="$1"
w="$full"
fontSize="$sm"
color="$neutral10"
justifyContent="space-between"
alignItems="center"
>
<Checkbox
checked={remember() === "true"}
onChange={() =>
setRemember(remember() === "true" ? "false" : "true")
}
<Show when={!usePasskey()}>
<Flex
px="$1"
w="$full"
fontSize="$sm"
color="$neutral10"
justifyContent="space-between"
alignItems="center"
>
{t("login.remember")}
</Checkbox>
<Text as="a" target="_blank" href={t("login.forget_url")}>
{t("login.forget")}
</Text>
</Flex>
<Checkbox
checked={remember() === "true"}
onChange={() =>
setRemember(remember() === "true" ? "false" : "true")
}
>
{t("login.remember")}
</Checkbox>
<Text as="a" target="_blank" href={t("login.forget_url")}>
{t("login.forget")}
</Text>
</Flex>
</Show>
</Show>
<HStack w="$full" spacing="$2">
<Show when={!useauthn()}>
<Show when={!usePasskey()}>
<Button
colorScheme="primary"
w="$full"
Expand All @@ -313,7 +404,7 @@ const Login = () => {
</Button>
</Show>
<Button w="$full" loading={loading()} onClick={Login}>
{t("login.login")}
{usePasskey() ? t("login.continue_with_passkey") : t("login.login")}
</Button>
</HStack>
<Show when={ldapLoginEnabled}>
Expand Down Expand Up @@ -348,7 +439,7 @@ const Login = () => {
<SwitchLanguageWhite />
<SwitchColorMode />
<SSOLogin />
<Show when={AuthnSignEnabled}>
<Show when={passkeySignEnabled}>
<Icon
cursor="pointer"
boxSize="$8"
Expand Down
4 changes: 3 additions & 1 deletion src/pages/manage/settings/Common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ const CommonSettings = (props: CommonSettingsProps) => {
const [settings, setSettings] = createStore<SettingItem[]>([])
const refresh = async () => {
const resp = await getSettings()
handleResp<SettingItem[]>(resp, setSettings)
handleResp<SettingItem[]>(resp, (items) => {
setSettings(items.filter((item) => item.key !== "webauthn_login_enabled"))
})
}
refresh()
const [saveLoading, saveSettings] = useFetch(
Expand Down
Loading