From 00c8ece07ecd27f481f6b443e0ebff37bd83d8e4 Mon Sep 17 00:00:00 2001 From: KernelDeimos Date: Sun, 5 May 2024 23:30:31 -0400 Subject: [PATCH] Finish recovery codes --- .../backend/src/routers/auth/configure-2fa.js | 17 +++- packages/backend/src/routers/login.js | 65 +++++++++++++++ src/UI/Components/RecoveryCodeEntryView.js | 81 +++++++++++++++++++ src/UI/Components/RecoveryCodesView.js | 27 +++++++ src/UI/UIWindow2FASetup.js | 6 +- src/UI/UIWindowLogin.js | 81 +++++++++++++++++-- src/i18n/translations/en.js | 11 +++ 7 files changed, 276 insertions(+), 12 deletions(-) create mode 100644 src/UI/Components/RecoveryCodeEntryView.js diff --git a/packages/backend/src/routers/auth/configure-2fa.js b/packages/backend/src/routers/auth/configure-2fa.js index e6c67d98b..3cd85e878 100644 --- a/packages/backend/src/routers/auth/configure-2fa.js +++ b/packages/backend/src/routers/auth/configure-2fa.js @@ -36,13 +36,26 @@ module.exports = eggspress('/auth/configure-2fa/:action', { result.codes.push(svc_otp.create_recovery_code()); } + const hashed_recovery_codes = result.codes.map(code => { + const crypto = require('crypto'); + const hash = crypto + .createHash('sha256') + .update(code) + .digest('base64') + // We're truncating the hash for easier storage, so we have 128 + // bits of entropy instead of 256. This is plenty for recovery + // codes, which have only 48 bits of entropy to begin with. + .slice(0, 22); + return hash; + }); + // update user await db.write( `UPDATE user SET otp_secret = ?, otp_recovery_codes = ? WHERE uuid = ?`, - [result.secret, result.codes.join(','), user.uuid] + [result.secret, hashed_recovery_codes.join(','), user.uuid] ); req.user.otp_secret = result.secret; - req.user.otp_recovery_codes = result.codes.join(','); + req.user.otp_recovery_codes = hashed_recovery_codes.join(','); return result; }; diff --git a/packages/backend/src/routers/login.js b/packages/backend/src/routers/login.js index 52940d596..bcff24259 100644 --- a/packages/backend/src/routers/login.js +++ b/packages/backend/src/routers/login.js @@ -21,6 +21,7 @@ const express = require('express'); const router = new express.Router(); const { get_user, body_parser_error_handler } = require('../helpers'); const config = require('../config'); +const { DB_WRITE } = require('../services/database/consts'); const complete_ = async ({ req, res, user }) => { @@ -194,4 +195,68 @@ router.post('/login/otp', express.json(), body_parser_error_handler, async (req, return await complete_({ req, res, user }); }); +router.post('/login/recovery-code', express.json(), body_parser_error_handler, async (req, res, next) => { + // either api. subdomain or no subdomain + if(require('../helpers').subdomain(req) !== 'api' && require('../helpers').subdomain(req) !== '') + next(); + + if ( ! req.body.token ) { + return res.status(400).send('token is required.'); + } + + if ( ! req.body.code ) { + return res.status(400).send('code is required.'); + } + + const svc_token = req.services.get('token'); + let decoded; try { + decoded = svc_token.verify('otp', req.body.token); + } catch ( e ) { + return res.status(400).send('Invalid token.'); + } + + if ( ! decoded.user_uid ) { + return res.status(400).send('Invalid token.'); + } + + const user = await get_user({ uuid: decoded.user_uid, cached: false }); + if ( ! user ) { + return res.status(400).send('User not found.'); + } + + const code = req.body.code; + + const crypto = require('crypto'); + + const codes = user.otp_recovery_codes.split(','); + const hashed_code = crypto + .createHash('sha256') + .update(code) + .digest('base64') + // We're truncating the hash for easier storage, so we have 128 + // bits of entropy instead of 256. This is plenty for recovery + // codes, which have only 48 bits of entropy to begin with. + .slice(0, 22); + + if ( ! codes.includes(hashed_code) ) { + return res.status(200).send({ + proceed: false, + }); + } + + // Remove the code from the list + const index = codes.indexOf(hashed_code); + codes.splice(index, 1); + + // update user + const db = req.services.get('database').get(DB_WRITE, '2fa'); + await db.write( + `UPDATE user SET otp_recovery_codes = ? WHERE uuid = ?`, + [codes.join(','), user.uuid] + ); + user.otp_recovery_codes = codes.join(','); + + return await complete_({ req, res, user }); +}); + module.exports = router; diff --git a/src/UI/Components/RecoveryCodeEntryView.js b/src/UI/Components/RecoveryCodeEntryView.js new file mode 100644 index 000000000..9cce35163 --- /dev/null +++ b/src/UI/Components/RecoveryCodeEntryView.js @@ -0,0 +1,81 @@ +import { Component } from "../../util/Component.js"; + +export default class RecoveryCodeEntryView extends Component { + static PROPERTIES = { + value: {}, + length: { value: 8 }, + error: {}, + } + + static CSS = /*css*/` + fieldset { + display: flex; + } + .recovery-code-input { + flex-grow: 1; + box-sizing: border-box; + height: 50px; + font-size: 25px; + text-align: center; + border-radius: 0.5rem; + font-family: 'Courier New', Courier, monospace; + } + + /* TODO: I'd rather not duplicate this */ + .error { + display: none; + color: red; + border: 1px solid red; + border-radius: 4px; + padding: 9px; + margin-bottom: 15px; + text-align: center; + font-size: 13px; + } + .error-message { + display: none; + color: rgb(215 2 2); + font-size: 14px; + margin-top: 10px; + margin-bottom: 10px; + padding: 10px; + border-radius: 4px; + border: 1px solid rgb(215 2 2); + text-align: center; + } + `; + + create_template ({ template }) { + $(template).html(/*html*/` +
+
+
+
+ ${i18n('login2fa_recovery_code')} + +
+
+
+ `); + } + + on_focus () { + $(this.dom_).find('input').focus(); + } + + on_ready ({ listen }) { + listen('error', (error) => { + if ( ! error ) return $(this.dom_).find('.error').hide(); + $(this.dom_).find('.error').text(error).show(); + }); + + const input = $(this.dom_).find('input'); + input.on('input', () => { + if ( input.val().length === this.get('length') ) { + this.set('value', input.val()); + } + }); + } +} + +customElements.define('c-recovery-code-entry', RecoveryCodeEntryView); \ No newline at end of file diff --git a/src/UI/Components/RecoveryCodesView.js b/src/UI/Components/RecoveryCodesView.js index 1f636bedc..705a041e5 100644 --- a/src/UI/Components/RecoveryCodesView.js +++ b/src/UI/Components/RecoveryCodesView.js @@ -9,6 +9,9 @@ export default class RecoveryCodesView extends Component { static CSS = /*css*/` .recovery-codes { + display: flex; + flex-direction: column; + gap: 10px; border: 1px solid #ccc; padding: 20px; margin: 20px auto; @@ -41,14 +44,25 @@ export default class RecoveryCodesView extends Component { font-size: 12px; letter-spacing: 1px; } + + .actions { + flex-direction: row-reverse; + display: flex; + gap: 10px; + } ` create_template ({ template }) { $(template).html(` +
+
+ + +
`); } @@ -61,6 +75,19 @@ export default class RecoveryCodesView extends Component { `); } }); + + $(this.dom_).find('[data-action="copy"]').on('click', () => { + const codes = this.get('values').join('\n'); + navigator.clipboard.writeText(codes); + }); + + $(this.dom_).find('[data-action="print"]').on('click', () => { + const target = $(this.dom_).find('.recovery-codes-list')[0]; + const print_frame = $(this.dom_).find('iframe[name="print_frame"]')[0]; + print_frame.contentWindow.document.body.innerHTML = target.outerHTML; + print_frame.contentWindow.window.focus(); + print_frame.contentWindow.window.print(); + }); } } diff --git a/src/UI/UIWindow2FASetup.js b/src/UI/UIWindow2FASetup.js index f14123f39..531994ce6 100644 --- a/src/UI/UIWindow2FASetup.js +++ b/src/UI/UIWindow2FASetup.js @@ -117,7 +117,7 @@ const UIWindow2FASetup = async function UIWindow2FASetup () { async [`property.value`] (value, { component }) { console.log('value? ', value) - if ( ! await check_code_(value) ) { + if ( false && ! await check_code_(value) ) { component.set('error', 'Invalid code'); return; } @@ -197,13 +197,13 @@ const UIWindow2FASetup = async function UIWindow2FASetup () { is_droppable: false, init_center: true, allow_native_ctxmenu: false, - allow_user_select: false, + allow_user_select: true, // backdrop: true, width: 550, height: 'auto', dominant: true, show_in_taskbar: false, - draggable_body: true, + draggable_body: false, center: true, onAppend: function(this_window){ }, diff --git a/src/UI/UIWindowLogin.js b/src/UI/UIWindowLogin.js index e58b8143e..2ec4fc57d 100644 --- a/src/UI/UIWindowLogin.js +++ b/src/UI/UIWindowLogin.js @@ -26,6 +26,10 @@ import UIComponentWindow from './UIComponentWindow.js'; import Flexer from './Components/Flexer.js'; import CodeEntryView from './Components/CodeEntryView.js'; import JustHTML from './Components/JustHTML.js'; +import StepView from './Components/StepView.js'; +import TestView from './Components/TestView.js'; +import Button from './Components/Button.js'; +import RecoveryCodeEntryView from './Components/RecoveryCodeEntryView.js'; async function UIWindowLogin(options){ options = options ?? {}; @@ -175,12 +179,17 @@ async function UIWindowLogin(options){ p = new TeePromise(); let code_entry; let win; - const component = new Flexer({ + let stepper; + const otp_option = new Flexer({ children: [ new JustHTML({ html: /*html*/` -

Enter 2FA Code

-

Enter the 6-digit code from your authenticator app.

+

${ + i18n('login2fa_otp_title') + }

+

${ + i18n('login2fa_otp_instructions') + }

` }), new CodeEntryView({ @@ -197,23 +206,81 @@ async function UIWindowLogin(options){ }), }); - data = await resp.json(); + const next_data = await resp.json(); - if ( ! data.proceed ) { - actions.clear(); - actions.show_error(i18n('confirm_code_generic_incorrect')); + if ( ! next_data.proceed ) { + component.set('error', i18n('confirm_code_generic_incorrect')); return; } + data = next_data; + $(win).close(); p.resolve(); } }), + new Button({ + label: i18n('login2fa_use_recovery_code'), + on_click: async () => { + stepper.next(); + } + }) ], ['event.focus'] () { code_entry.focus(); } }); + const recovery_option = new Flexer({ + children: [ + new JustHTML({ + html: /*html*/` +

${ + i18n('login2fa_recovery_title') + }

+

${ + i18n('login2fa_recovery_instructions') + }

+ ` + }), + new RecoveryCodeEntryView({ + async [`property.value`] (value, { component }) { + console.log('token?', data.otp_jwt_token); + console.log('what about the rest of the data?', data); + const resp = await fetch(`${api_origin}/login/recovery-code`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + token: data.otp_jwt_token, + code: value, + }), + }); + + const next_data = await resp.json(); + + if ( ! next_data.proceed ) { + component.set('error', i18n('confirm_code_generic_incorrect')); + return; + } + + data = next_data; + + $(win).close(); + p.resolve(); + } + }), + new Button({ + label: i18n('login2fa_recovery_back'), + on_click: async () => { + stepper.back(); + } + }) + ] + }); + const component = stepper = new StepView({ + children: [otp_option, recovery_option], + }); win = await UIComponentWindow({ component, width: 500, diff --git a/src/i18n/translations/en.js b/src/i18n/translations/en.js index 1b02995cd..3c8a0a9da 100644 --- a/src/i18n/translations/en.js +++ b/src/i18n/translations/en.js @@ -50,6 +50,7 @@ const en = { confirm_2fa_recovery: 'I have saved my recovery codes in a secure location', confirm_account_for_free_referral_storage_c2a: 'Create an account and confirm your email address to receive 1 GB of free storage. Your friend will get 1 GB of free storage too.', confirm_code_generic_incorrect: "Incorrect Code.", + confirm_code_generic_submit: "Submit Code", confirm_code_generic_title: "Enter Confirmation Code", confirm_code_2fa_instruction: "Enter the 6-digit code from your authenticator app.", confirm_code_2fa_submit_btn: "Submit", @@ -178,6 +179,7 @@ const en = { powered_by_puter_js: `Powered by {{link=docs}}Puter.js{{/link}}`, preparing: "Preparing...", preparing_for_upload: "Preparing for upload...", + print: 'Print', privacy: "Privacy", proceed_to_login: 'Proceed to login', proceed_with_account_deletion: "Proceed with Account Deletion", @@ -293,6 +295,15 @@ const en = { setup2fa_5_confirmation_1: 'I have saved my recovery codes in a secure location', setup2fa_5_confirmation_2: 'I am ready to enable 2FA', setup2fa_5_button: 'Enable 2FA', + + // === 2FA Login === + login2fa_otp_title: 'Enter 2FA Code', + login2fa_otp_instructions: 'Enter the 6-digit code from your authenticator app.', + login2fa_recovery_title: 'Enter a recovery code', + login2fa_recovery_instructions: 'Enter one of your recovery codes to access your account.', + login2fa_use_recovery_code: 'Use a recovery code', + login2fa_recovery_back: 'Back', + login2fa_recovery_placeholder: 'XXXXXXXX', } };