diff --git a/framework/framework/api.ts b/framework/framework/api.ts index c4910092b..7bd6beade 100644 --- a/framework/framework/api.ts +++ b/framework/framework/api.ts @@ -81,7 +81,7 @@ export const projection = >(input: T, schema: S for (const key of Reflect.ownKeys(input)) { const schemaIt = schema[key]; if (!schemaIt) continue; - if (schemaIt === 1) result[key] = input[key]; + if (schemaIt === 1 || !input[key]) result[key] = input[key]; else result[key] = projection(input[key], schemaIt); } return result; diff --git a/packages/hydrooj/src/handler/home.ts b/packages/hydrooj/src/handler/home.ts index af34c627c..892b5ff10 100644 --- a/packages/hydrooj/src/handler/home.ts +++ b/packages/hydrooj/src/handler/home.ts @@ -307,6 +307,8 @@ class HomeSecurityHandler extends Handler { type: 'public-key', })), authenticatorSelection: { + residentKey: 'required', + userVerification: 'preferred', authenticatorAttachment: type, }, }); diff --git a/packages/hydrooj/src/handler/user.ts b/packages/hydrooj/src/handler/user.ts index 3a732b9fe..96f5d67f4 100644 --- a/packages/hydrooj/src/handler/user.ts +++ b/packages/hydrooj/src/handler/user.ts @@ -1,7 +1,7 @@ import { generateAuthenticationOptions, verifyAuthenticationResponse } from '@simplewebauthn/server'; import { isoBase64URL } from '@simplewebauthn/server/helpers'; import moment from 'moment-timezone'; -import type { Binary } from 'mongodb'; +import { Binary } from 'mongodb'; import Schema from 'schemastery'; import type { Context } from '../context'; import { @@ -148,28 +148,41 @@ class UserWebauthnHandler extends Handler { } @param('uname', Types.Username, true) - async get(domainId: string, uname: string) { - const udoc = this.user._id ? this.user : ((await user.getByEmail(domainId, uname)) || await user.getByUname(domainId, uname)); - if (!udoc._id) throw new UserNotFoundError(uname || 'user'); - if (!udoc.authn) throw new AuthOperationError('authn', 'disabled'); - const options = await generateAuthenticationOptions({ - allowCredentials: udoc._authenticators.map((authenticator) => ({ + @param('login', Types.Boolean) + async get(domainId: string, uname: string, login: boolean) { + let allowCredentials = []; + let uid = 0; + if (!login) { + const udoc = this.user._id ? this.user : ((await user.getByEmail(domainId, uname)) || await user.getByUname(domainId, uname)); + if (!udoc._id) throw new UserNotFoundError(uname || 'user'); + if (!udoc.authn) throw new AuthOperationError('authn', 'disabled'); + allowCredentials = udoc._authenticators.map((authenticator) => ({ id: isoBase64URL.fromBuffer(authenticator.credentialID.buffer), - })), + })); + uid = udoc._id; + } + const options = await generateAuthenticationOptions({ + allowCredentials, rpID: this.getAuthnHost(), userVerification: 'preferred', }); - await token.add(token.TYPE_WEBAUTHN, 60, { uid: udoc._id }, options.challenge); + await token.add(token.TYPE_WEBAUTHN, 60, { uid: login ? 'login' : uid }, options.challenge); this.session.challenge = options.challenge; this.response.body.authOptions = options; } - async post({ domainId, result }) { + async post({ domainId, result, redirect }) { const challenge = this.session.challenge; if (!challenge) throw new ForbiddenError(); const tdoc = await token.get(challenge, token.TYPE_WEBAUTHN); if (!tdoc) throw new InvalidTokenError(token.TYPE_TEXTS[token.TYPE_WEBAUTHN]); - const udoc = await user.getById(domainId, tdoc.uid); + const udoc = await (tdoc.uid === 'login' + ? (async () => { + const u = await user.coll.findOne({ 'authenticators.credentialID': Binary.createFromBase64(result.id) }); + return u ? await user.getById(domainId, u._id) : null; + })() + : user.getById(domainId, tdoc.uid)); + if (!udoc) throw new NotFoundError(); const parseId = (id: Binary) => Buffer.from(id.toString('hex'), 'hex').toString('base64url'); const authenticator = udoc._authenticators?.find((c) => parseId(c.credentialID) === result.id); if (!authenticator) throw new ValidationError('authenticator'); @@ -187,8 +200,15 @@ class UserWebauthnHandler extends Handler { if (!verification?.verified) throw new ValidationError('authenticator'); authenticator.counter = verification.authenticationInfo.newCounter; await user.setById(udoc._id, { authenticators: udoc._authenticators }); - await token.update(challenge, token.TYPE_WEBAUTHN, 60, { verified: true }); - this.back(); + if (tdoc.uid === 'login') { + await successfulAuth.call(this, await user.getById(domainId, udoc._id)); + await token.del(challenge, token.TYPE_WEBAUTHN); + this.response.redirect = redirect || ((this.request.referer || '/login').endsWith('/login') + ? this.url('homepage') : this.request.referer); + } else { + await token.update(challenge, token.TYPE_WEBAUTHN, 60, { verified: true }); + this.back(); + } } } diff --git a/packages/ui-default/components/signin/signInDialog.page.js b/packages/ui-default/components/signin/signInDialog.page.js index 4bfef04d9..d393b1823 100644 --- a/packages/ui-default/components/signin/signInDialog.page.js +++ b/packages/ui-default/components/signin/signInDialog.page.js @@ -1,9 +1,12 @@ +import { browserSupportsWebAuthnAutofill, startAuthentication } from '@simplewebauthn/browser'; import $ from 'jquery'; import responsiveCutoff from 'vj/breakpoints.json'; import DomDialog from 'vj/components/dialog/DomDialog'; import { InfoDialog } from 'vj/components/dialog/index'; import { AutoloadPage } from 'vj/misc/Page'; -import { i18n, mediaQuery, tpl } from 'vj/utils'; +import { + i18n, mediaQuery, request, tpl, +} from 'vj/utils'; const signinDialogPage = new AutoloadPage('signinDialogPage', null, () => { const signInDialog = DomDialog.getOrConstruct($('.dialog--signin'), { @@ -11,13 +14,47 @@ const signinDialogPage = new AutoloadPage('signinDialogPage', null, () => { cancelByEsc: true, }); + let authnInitialized = false; + + async function initPasskey() { + if (authnInitialized) return; + authnInitialized = true; + const support = await browserSupportsWebAuthnAutofill(); + if (!support) return; + const authnInfo = await request.get('/user/webauthn', { login: true }); + if (!authnInfo.authOptions) return; + const result = await startAuthentication({ optionsJSON: authnInfo.authOptions, useBrowserAutofill: true }) + .catch((e) => { + Notification.error(i18n('Failed to get credential: {0}', e)); + return null; + }); + if (!result) return; + try { + const authn = await request.post('/user/webauthn', { + result, + }); + if (authn.url) window.location.href = authn.url; + else if (authn.error) throw new Error(authn.error); + } catch (err) { + Notification.error(err.message); + console.error(err); + } + } + + if (window.location.href.endsWith('/login')) initPasskey(); + + function show() { + signInDialog.show(); + initPasskey(); + } + // don't show quick login dialog if in mobile if ($('[name="nav_login"]').length > 0) { // nav $('[name="nav_login"]').on('click', (ev) => { if (mediaQuery.isBelow(responsiveCutoff.mobile)) return; if (ev.shiftKey || ev.metaKey || ev.ctrlKey) return; - signInDialog.show(); + show(); ev.preventDefault(); }); } @@ -36,7 +73,7 @@ const signinDialogPage = new AutoloadPage('signinDialogPage', null, () => { return; } } - signInDialog.show(); + show(); }; $('[data-lostpass]').on('click', (e) => { diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index 718e3efe3..af65ff475 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -21,7 +21,7 @@ "@fontsource/source-code-pro": "^5.2.5", "@fontsource/ubuntu-mono": "^5.2.5", "@hydrooj/utils": "workspace:^", - "@sentry/browser": "^9.14.0", + "@sentry/browser": "^9.15.0", "@sentry/webpack-plugin": "^3.3.1", "@simplewebauthn/browser": "13.1.0", "@svgr/webpack": "^8.1.0", @@ -50,8 +50,8 @@ "clipboard": "^2.0.11", "copy-webpack-plugin": "^13.0.0", "cordis": "4.0.0-beta.2", - "core-js": "^3.41.0", - "core-js-compat": "^3.41.0", + "core-js": "^3.42.0", + "core-js-compat": "^3.42.0", "css-loader": "^7.1.2", "diff": "^7.0.0", "diff-dom": "^5.1.4", diff --git a/packages/ui-default/templates/partials/login_dialog.html b/packages/ui-default/templates/partials/login_dialog.html index 400315d37..b44e5c60f 100644 --- a/packages/ui-default/templates/partials/login_dialog.html +++ b/packages/ui-default/templates/partials/login_dialog.html @@ -20,7 +20,7 @@
diff --git a/packages/ui-default/templates/user_login.html b/packages/ui-default/templates/user_login.html index 7c37b776f..d9295af0a 100644 --- a/packages/ui-default/templates/user_login.html +++ b/packages/ui-default/templates/user_login.html @@ -8,7 +8,7 @@
diff --git a/packages/ui-next/package.json b/packages/ui-next/package.json index 9a960f862..d1142ce83 100644 --- a/packages/ui-next/package.json +++ b/packages/ui-next/package.json @@ -16,6 +16,6 @@ "@types/react": "^19.1.2", "@types/react-dom": "^19.1.2", "@vitejs/plugin-react-swc": "^3.9.0", - "vite": "^6.3.3" + "vite": "^6.3.4" } }