From 4aa825df99a6d7161d07b13f0e2b1e41148b8113 Mon Sep 17 00:00:00 2001 From: itsHenry <2671230065@qq.com> Date: Mon, 14 Aug 2023 21:40:00 +0800 Subject: [PATCH] feat: support webauthn login (#104) * feat: support webauthn login * manually merge * feat: better feedback to users * fix codefactor * fix: unable to sign in for non-admin users --- package.json | 1 + pnpm-lock.yaml | 8 + src/lang/en/settings.json | 3 +- src/lang/en/users.json | 6 +- src/pages/login/index.tsx | 179 ++++++++++++++++------- src/pages/manage/users/Profile.tsx | 102 ++++++++++++- src/pages/manage/users/Webauthnitems.tsx | 65 ++++++++ 7 files changed, 310 insertions(+), 54 deletions(-) create mode 100644 src/pages/manage/users/Webauthnitems.tsx diff --git a/package.json b/package.json index 91d4b3ae9..5608cb7ce 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "vite-plugin-solid": "^2.3.0" }, "dependencies": { + "@github/webauthn-json": "^2.1.1", "@hope-ui/solid": "0.6.7", "@monaco-editor/loader": "^1.3.2", "@motionone/solid": "^10.14.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01ff24b45..c5a0744e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,9 @@ settings: excludeLinksFromLockfile: false dependencies: + '@github/webauthn-json': + specifier: ^2.1.1 + version: 2.1.1 '@hope-ui/solid': specifier: 0.6.7 version: 0.6.7(@stitches/core@1.2.8)(solid-js@1.4.8)(solid-transition-group@0.0.12) @@ -489,6 +492,11 @@ packages: '@floating-ui/core': 0.6.2 dev: false + /@github/webauthn-json@2.1.1: + resolution: {integrity: sha512-XrftRn4z75SnaJOmZQbt7Mk+IIjqVHw+glDGOxuHwXkZBZh/MBoRS7MHjSZMDaLhT4RjN2VqiEU7EOYleuJWSQ==} + hasBin: true + dev: false + /@hope-ui/solid@0.6.7(@stitches/core@1.2.8)(solid-js@1.4.8)(solid-transition-group@0.0.12): resolution: {integrity: sha512-7zGGy4QbGUC7QhwRnNH8HO0MZFg4jFISlC2cnAMBfFBy272uqQN3PYdTjiIbnpR/4JilUfxCWpFQY+4qslqcIw==} peerDependencies: diff --git a/src/lang/en/settings.json b/src/lang/en/settings.json index a427a653c..2f9a9f14f 100755 --- a/src/lang/en/settings.json +++ b/src/lang/en/settings.json @@ -84,5 +84,6 @@ "token": "Token", "version": "Version", "video_autoplay": "Video autoplay", - "video_types": "Video types" + "video_types": "Video types", + "webauthn_login_enabled": "Webauthn login enabled" } diff --git a/src/lang/en/users.json b/src/lang/en/users.json index 61d46bf15..dfa0481cf 100644 --- a/src/lang/en/users.json +++ b/src/lang/en/users.json @@ -38,5 +38,9 @@ "modify_nothing": "So you cannot modify anything in the manage page.", "sso_login": "Single sign-on Login", "connect_sso": "Connect Single sign-on Platform", - "disconnect_sso": "Disconnect Single sign-on Platform" + "disconnect_sso": "Disconnect 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" } diff --git a/src/pages/login/index.tsx b/src/pages/login/index.tsx index 5fe94256e..8defff49d 100644 --- a/src/pages/login/index.tsx +++ b/src/pages/login/index.tsx @@ -10,6 +10,7 @@ import { HStack, VStack, Checkbox, + Icon, } from "@hope-ui/solid" import { createMemo, createSignal, Show } from "solid-js" import { SwitchColorMode, SwitchLanguageWhite } from "~/components" @@ -20,13 +21,22 @@ import { notify, handleRespWithoutNotify, base_path, + handleResp, hashPwd, } from "~/utils" -import { Resp } from "~/types" +import { PResp, Resp } from "~/types" import LoginBg from "./LoginBg" import { createStorageSignal } from "@solid-primitives/storage" -import { getSetting } from "~/store" +import { getSetting, getSettingBool } from "~/store" import { SSOLogin } from "./SSOLogin" +import { IoFingerPrint } from "solid-icons/io" +import { + parseRequestOptionsFromJSON, + get, + AuthenticationPublicKeyCredential, + supported, + CredentialRequestOptionsJSON, +} from "@github/webauthn-json/browser-ponyfill" const Login = () => { const logos = getSetting("logo").split("\n") @@ -44,6 +54,7 @@ const Login = () => { localStorage.getItem("password") || "", ) const [opt, setOpt] = createSignal("") + const [useauthn, setuseauthn] = createSignal(false) const [remember, setRemember] = createStorageSignal("remember-pwd", "false") const [loading, data] = useFetch( async (): Promise> => @@ -53,31 +64,88 @@ const Login = () => { otp_code: opt(), }), ) + const [, postauthnlogin] = useFetch( + ( + session: string, + credentials: AuthenticationPublicKeyCredential, + username: string, + ): Promise> => + r.post( + "/authn/webauthn_finish_login?username=" + username, + JSON.stringify(credentials), + { + headers: { + session: session, + }, + }, + ), + ) + interface Webauthntemp { + session: string + options: CredentialRequestOptionsJSON + } + const [, getauthntemp] = useFetch( + (username): PResp => + r.get("/authn/webauthn_begin_login?username=" + username), + ) const { searchParams, to } = useRouter() + const AuthnSignEnabled = getSettingBool("webauthn_login_enabled") + const AuthnSwitch = async () => { + setuseauthn(!useauthn()) + } const Login = async () => { - if (remember() === "true") { - localStorage.setItem("username", username()) - localStorage.setItem("password", password()) + if (!useauthn()) { + if (remember() === "true") { + localStorage.setItem("username", username()) + localStorage.setItem("password", password()) + } else { + localStorage.removeItem("username") + localStorage.removeItem("password") + } + const resp = await data() + handleRespWithoutNotify( + resp, + (data) => { + notify.success(t("login.success")) + changeToken(data.token) + to( + decodeURIComponent(searchParams.redirect || base_path || "/"), + true, + ) + }, + (msg, code) => { + if (!needOpt() && code === 402) { + setNeedOpt(true) + } else { + notify.error(msg) + } + }, + ) } else { - localStorage.removeItem("username") - localStorage.removeItem("password") + if (!supported()) { + notify.error(t("users.webauthn_not_supported")) + return + } + if (remember() === "true") { + localStorage.setItem("username", username()) + } else { + localStorage.removeItem("username") + } + const resp = await getauthntemp(username()) + handleResp(resp, async (data) => { + const options = parseRequestOptionsFromJSON(data.options) + const credentials = await get(options) + const resp = await postauthnlogin(data.session, credentials, username()) + handleRespWithoutNotify(resp, (data) => { + notify.success(t("login.success")) + changeToken(data.token) + to( + decodeURIComponent(searchParams.redirect || base_path || "/"), + true, + ) + }) + }) } - const resp = await data() - handleRespWithoutNotify( - resp, - (data) => { - notify.success(t("login.success")) - changeToken(data.token) - to(decodeURIComponent(searchParams.redirect || base_path || "/"), true) - }, - (msg, code) => { - if (!needOpt() && code === 402) { - setNeedOpt(true) - } else { - notify.error(msg) - } - }, - ) } const [needOpt, setNeedOpt] = createSignal(false) @@ -122,18 +190,20 @@ const Login = () => { value={username()} onInput={(e) => setUsername(e.currentTarget.value)} /> - setPassword(e.currentTarget.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - Login() - } - }} - /> + + setPassword(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + Login() + } + }} + /> + { - + + + @@ -197,6 +269,15 @@ const Login = () => { + + + diff --git a/src/pages/manage/users/Profile.tsx b/src/pages/manage/users/Profile.tsx index 537a3b008..98a3a54b6 100644 --- a/src/pages/manage/users/Profile.tsx +++ b/src/pages/manage/users/Profile.tsx @@ -16,11 +16,19 @@ import { Text, } from "@hope-ui/solid" import { createSignal, For, JSXElement, onCleanup, Show } from "solid-js" -import { LinkWithBase } from "~/components" +import { LinkWithBase, MaybeLoading } from "~/components" import { useFetch, useManageTitle, useRouter, useT } from "~/hooks" import { setMe, me, getSettingBool } from "~/store" -import { PEmptyResp, UserMethods, UserPermissions } from "~/types" -import { handleResp, notify, r } from "~/utils" +import { PEmptyResp, UserMethods, UserPermissions, PResp } from "~/types" +import { handleResp, handleRespWithoutNotify, notify, r } from "~/utils" +import { WebauthnItem } from "./Webauthnitems" +import { + RegistrationPublicKeyCredential, + create, + parseCreationOptionsFromJSON, + supported, + CredentialCreationOptionsJSON, +} from "@github/webauthn-json/browser-ponyfill" const PermissionBadge = (props: { can: boolean; children: JSXElement }) => { return ( @@ -45,6 +53,38 @@ const Profile = () => { sso_id: me().sso_id, }), ) + + interface WebauthnItem { + fingerprint: string + id: string + } + + interface Webauthntemp { + session: string + options: CredentialCreationOptionsJSON + } + + const [getauthncredentialsloading, getauthncredentials] = useFetch( + (): PResp => r.get("/authn/getcredentials"), + ) + const [, getauthntemp] = useFetch( + (): PResp => r.get("/authn/webauthn_begin_registration"), + ) + const [postregistrationloading, postregistration] = useFetch( + ( + session: string, + credentials: RegistrationPublicKeyCredential, + ): PEmptyResp => + r.post( + "/authn/webauthn_finish_registration", + JSON.stringify(credentials), + { + headers: { + session: session, + }, + }, + ), + ) const saveMe = async (ssoID?: boolean) => { if (password() && password() !== confirmPassword()) { notify.warning(t("users.confirm_password_not_same")) @@ -72,6 +112,18 @@ const Profile = () => { onCleanup(() => { window.removeEventListener("message", messageEvent) }) + const [credentials, setcredentials] = createSignal([]) + const initauthnEdit = async () => { + const resp = await getauthncredentials() + handleRespWithoutNotify(resp, setcredentials) + } + if ( + supported() && + !UserMethods.is_guest(me()) && + getSettingBool("webauthn_login_enabled") + ) { + initauthnEdit() + } return ( { + + {t("users.webauthn")} + + + + {(item) => ( + + )} + + + + + {(item, i) => ( diff --git a/src/pages/manage/users/Webauthnitems.tsx b/src/pages/manage/users/Webauthnitems.tsx new file mode 100644 index 000000000..fedec8d33 --- /dev/null +++ b/src/pages/manage/users/Webauthnitems.tsx @@ -0,0 +1,65 @@ +import { Button, Heading, Stack, VStack } from "@hope-ui/solid" +import { createSignal, Show } from "solid-js" +import { useT, useFetch } from "~/hooks" +import { PEmptyResp } from "~/types" +import { handleResp, notify, r } from "~/utils" + +interface WebauthnItemProps { + id: string + fingerprint: string +} + +export const WebauthnItem = (props: WebauthnItemProps) => { + const t = useT() + const [removeLoading, remove] = useFetch( + (): PEmptyResp => + r.post(`/authn/delete_authn`, { + id: props.id, + }), + ) + const [deleted, setDeleted] = createSignal(false) + return ( + + + + + {"Fingerprint: " + props.fingerprint + "\tID: " + props.id} + + + + + + + + + ) +}