diff --git a/packages/backend/src/routers/auth/configure-2fa.js b/packages/backend/src/routers/auth/configure-2fa.js index df506701..bc235c50 100644 --- a/packages/backend/src/routers/auth/configure-2fa.js +++ b/packages/backend/src/routers/auth/configure-2fa.js @@ -47,6 +47,17 @@ module.exports = eggspress('/auth/configure-2fa/:action', { return result; }; + // IMPORTANT: only use to verify the user's 2FA setup; + // this should never be used to verify the user's 2FA code + // for authentication purposes. + actions.test = async () => { + const user = req.user; + const svc_otp = x.get('services').get('otp'); + const code = req.body.code; + const delta = svc_otp.verify(user.username, user.otp_secret, code); + return { ok: delta !== null, delta }; + }; + actions.enable = async () => { await db.write( `UPDATE user SET otp_enabled = 1 WHERE uuid = ?`, diff --git a/src/UI/Components/CodeEntryView.js b/src/UI/Components/CodeEntryView.js index 48c590f7..3e647610 100644 --- a/src/UI/Components/CodeEntryView.js +++ b/src/UI/Components/CodeEntryView.js @@ -3,6 +3,7 @@ import { Component } from "../../util/Component.js"; export default class CodeEntryView extends Component { static PROPERTIES = { value: {}, + error: {}, is_checking_code: {}, } @@ -70,6 +71,11 @@ export default class CodeEntryView extends Component { on_ready ({ listen }) { let is_checking_code = false; + listen('error', (error) => { + if ( ! error ) return $(this.dom_).find('.error').hide(); + $(this.dom_).find('.error').text(error).show(); + }); + $(this.dom_).find('.digit-input').first().focus(); $(this.dom_).find('.code-confirm-btn').on('click submit', function(e){ diff --git a/src/UI/Components/RecoveryCodesView.js b/src/UI/Components/RecoveryCodesView.js new file mode 100644 index 00000000..1f636bed --- /dev/null +++ b/src/UI/Components/RecoveryCodesView.js @@ -0,0 +1,73 @@ +import { Component } from "../../util/Component.js"; + +export default class RecoveryCodesView extends Component { + static PROPERTIES = { + values: { + description: 'The recovery codes to display', + } + } + + static CSS = /*css*/` + .recovery-codes { + border: 1px solid #ccc; + padding: 20px; + margin: 20px auto; + width: 90%; + max-width: 600px; + background-color: #f9f9f9; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } + + .recovery-codes h2 { + text-align: center; + font-size: 18px; + color: #333; + margin-bottom: 15px; + } + + .recovery-codes-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 10px; /* Adds space between grid items */ + padding: 0; + } + + .recovery-code { + background-color: #fff; + border: 1px solid #ddd; + padding: 10px; + text-align: center; + font-family: 'Courier New', Courier, monospace; + font-size: 12px; + letter-spacing: 1px; + } + ` + + + create_template ({ template }) { + $(template).html(` +
+
+
+
+ `); + } + + on_ready ({ listen }) { + listen('values', values => { + for ( const value of values ) { + $(this.dom_).find('.recovery-codes-list').append(` +
${html_encode(value)}
+ `); + } + }); + } +} + +// TODO: This is necessary because files can be loaded from +// both `/src/UI` and `/UI` in the URL; we need to fix that +if ( ! window.__component_recoveryCodesView ) { + window.__component_recoveryCodesView = true; + + customElements.define('c-recovery-codes-view', RecoveryCodesView); +} diff --git a/src/UI/UIWindow2FASetup.js b/src/UI/UIWindow2FASetup.js index 882f391e..b787e67f 100644 --- a/src/UI/UIWindow2FASetup.js +++ b/src/UI/UIWindow2FASetup.js @@ -22,6 +22,7 @@ import CodeEntryView from "./Components/CodeEntryView.js"; import Flexer from "./Components/Flexer.js"; import QRCodeView from "./Components/QRCode.js"; +import RecoveryCodesView from "./Components/RecoveryCodesView.js"; import StepView from "./Components/StepView.js"; import TestView from "./Components/TestView.js"; import UIComponentWindow from "./UIComponentWindow.js"; @@ -37,7 +38,25 @@ const UIWindow2FASetup = async function UIWindow2FASetup () { }); const data = await resp.json(); + const check_code_ = async function check_code_ (value) { + const resp = await fetch(`${api_origin}/auth/configure-2fa/test`, { + method: 'POST', + headers: { + Authorization: `Bearer ${puter.authToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + code: value, + }), + }); + + const data = await resp.json(); + + return data.ok; + }; + let stepper; + let code_entry; const component = new StepView({ _ref: me => stepper = me, @@ -48,15 +67,26 @@ const UIWindow2FASetup = async function UIWindow2FASetup () { value: data.url, }), new CodeEntryView({ - [`property.value`] (value) { + async [`property.value`] (value, { component }) { console.log('value? ', value) + + if ( ! await check_code_(value) ) { + component.set('error', 'Invalid code'); + return; + } + stepper.next(); } }), - new TestView(), ] }), - new TestView(), + new Flexer({ + children: [ + new RecoveryCodesView({ + values: data.codes, + }), + ] + }), ] }) ; diff --git a/src/util/Component.js b/src/util/Component.js index 3e9e6d28..52d48c1e 100644 --- a/src/util/Component.js +++ b/src/util/Component.js @@ -31,7 +31,10 @@ export class Component extends HTMLElement { const listener_key = `property.${key}`; if ( property_values[listener_key] ) { - this.values_[key].sub(property_values[listener_key]); + this.values_[key].sub((value, more) => { + more = { ...more, component: this }; + property_values[listener_key](value, more); + }); } }