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*/` +
Enter the 6-digit code from your authenticator app.
+${ + 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_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', } };