diff --git a/packages/backend/src/routers/auth/configure-2fa.js b/packages/backend/src/routers/auth/configure-2fa.js index bb50379c7..eda6bb756 100644 --- a/packages/backend/src/routers/auth/configure-2fa.js +++ b/packages/backend/src/routers/auth/configure-2fa.js @@ -107,22 +107,6 @@ module.exports = eggspress('/auth/configure-2fa/:action', { return {}; }; - actions.disable = async () => { - await db.write( - `UPDATE user SET otp_enabled = 0, otp_recovery_codes = '' WHERE uuid = ?`, - [user.uuid] - ); - // update cached user - req.user.otp_enabled = 0; - - const svc_email = req.services.get('email'); - await svc_email.send_email({ email: user.email }, 'disabled_2fa', { - username: user.username, - }); - - return { success: true }; - }; - if ( ! actions[action] ) { throw APIError.create('invalid_action', null, { action }); } diff --git a/packages/backend/src/routers/user-protected/disable-2fa.js b/packages/backend/src/routers/user-protected/disable-2fa.js new file mode 100644 index 000000000..5ada04513 --- /dev/null +++ b/packages/backend/src/routers/user-protected/disable-2fa.js @@ -0,0 +1,22 @@ +const { DB_WRITE } = require("../../services/database/consts"); + +module.exports = { + route: '/disable-2fa', + methods: ['POST'], + handler: async (req, res, next) => { + const db = req.services.get('database').get(DB_WRITE, '2fa.disable'); + await db.write( + `UPDATE user SET otp_enabled = 0, otp_recovery_codes = NULL, otp_secret = NULL WHERE uuid = ?`, + [req.user.uuid] + ); + // update cached user + req.user.otp_enabled = 0; + + const svc_email = req.services.get('email'); + await svc_email.send_email({ email: req.user.email }, 'disabled_2fa', { + username: req.user.username, + }); + + res.send({ success: true }); + } +}; diff --git a/packages/backend/src/services/abuse-prevention/EdgeRateLimitService.js b/packages/backend/src/services/abuse-prevention/EdgeRateLimitService.js index 7efe8f778..01b8abe6f 100644 --- a/packages/backend/src/services/abuse-prevention/EdgeRateLimitService.js +++ b/packages/backend/src/services/abuse-prevention/EdgeRateLimitService.js @@ -70,6 +70,10 @@ class EdgeRateLimitService extends BaseService { limit: 10, window: HOUR, }, + ['/user-protected/disable-2fa']: { + limit: 10, + window: HOUR, + }, ['login-otp']: { limit: 15, window: 30 * MINUTE, diff --git a/packages/backend/src/services/web/UserProtectedEndpointsService.js b/packages/backend/src/services/web/UserProtectedEndpointsService.js index d9413b775..73c401227 100644 --- a/packages/backend/src/services/web/UserProtectedEndpointsService.js +++ b/packages/backend/src/services/web/UserProtectedEndpointsService.js @@ -88,6 +88,10 @@ class UserProtectedEndpointsService extends BaseService { Endpoint( require('../../routers/user-protected/change-email.js'), ).attach(router); + + Endpoint( + require('../../routers/user-protected/disable-2fa.js'), + ).attach(router); } } diff --git a/src/UI/Components/PasswordEntry.js b/src/UI/Components/PasswordEntry.js new file mode 100644 index 000000000..cbacdffea --- /dev/null +++ b/src/UI/Components/PasswordEntry.js @@ -0,0 +1,136 @@ +import { Component, defineComponent } from "../../util/Component.js"; + +export default class PasswordEntry extends Component { + static PROPERTIES = { + spec: {}, + value: {}, + error: {}, + on_submit: {}, + show_password: {}, + } + + static CSS = /*css*/` + fieldset { + display: flex; + flex-direction: column; + } + input { + flex-grow: 1; + } + + /* 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; + } + .password-and-toggle { + display: flex; + align-items: center; + gap: 10px; + } + .password-and-toggle input { + flex-grow: 1; + } + + + /* TODO: DRY: This is from style.css */ + input[type=text], input[type=password], input[type=email], select { + width: 100%; + padding: 8px; + border: 1px solid #ccc; + border-radius: 4px; + box-sizing: border-box; + outline: none; + -webkit-font-smoothing: antialiased; + color: #393f46; + font-size: 14px; + } + + /* to prevent auto-zoom on input focus in mobile */ + .device-phone input[type=text], .device-phone input[type=password], .device-phone input[type=email], .device-phone select { + font-size: 17px; + } + + input[type=text]:focus, input[type=password]:focus, input[type=email]:focus, select:focus { + border: 2px solid #01a0fd; + padding: 7px; + } + `; + + create_template ({ template }) { + $(template).html(/*html*/` +
+
+
+ + +
+
+ `); + } + + 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(); + }); + + listen('value', (value) => { + // clear input + if ( value === undefined ) { + $(this.dom_).find('input').val(''); + } + }); + + const input = $(this.dom_).find('input'); + input.on('input', () => { + this.set('value', input.val()); + }); + + const on_submit = this.get('on_submit'); + if ( on_submit ) { + $(this.dom_).find('input').on('keyup', (e) => { + if ( e.key === 'Enter' ) { + on_submit(); + } + }); + } + + $(this.dom_).find("#toggle-show-password").on("click", () => { + this.set('show_password', !this.get('show_password')); + const show_password = this.get('show_password'); + // hide/show password and update icon + $(this.dom_).find("input").attr("type", show_password ? "text" : "password"); + $(this.dom_).find("#toggle-show-password").attr("src", show_password ? window.icons["eye-closed.svg"] : window.icons["eye-open.svg"]) + }); + } +} + +defineComponent('c-password-entry', PasswordEntry); diff --git a/src/UI/Settings/UITabSecurity.js b/src/UI/Settings/UITabSecurity.js index 7514b9ce7..5d6564ad6 100644 --- a/src/UI/Settings/UITabSecurity.js +++ b/src/UI/Settings/UITabSecurity.js @@ -1,4 +1,10 @@ +import TeePromise from "../../util/TeePromise.js"; +import Button from "../Components/Button.js"; +import Flexer from "../Components/Flexer.js"; +import JustHTML from "../Components/JustHTML.js"; +import PasswordEntry from "../Components/PasswordEntry.js"; import UIAlert from "../UIAlert.js"; +import UIComponentWindow from "../UIComponentWindow.js"; import UIWindow2FASetup from "../UIWindow2FASetup.js"; import UIWindowQR from "../UIWindowQR.js"; @@ -64,35 +70,88 @@ export default { }); $el_window.find('.disable-2fa').on('click', async function (e) { - const confirmation = i18n('disable_2fa_confirm'); - const alert_resp = await UIAlert({ - message: confirmation, - window_options: { - parent_uuid: $el_window.attr('data-element_uuid'), - disable_parent_window: true, - parent_center: true, - }, - buttons:[ - { - label: i18n('yes'), - value: true, - type: 'primary', - }, - { - label: i18n('cancel'), - value: false, + let win, password_entry; + const password_confirm_promise = new TeePromise(); + const try_password = async () => { + const value = password_entry.get('value'); + const resp = await fetch(`${window.api_origin}/user-protected/disable-2fa`, { + method: 'POST', + headers: { + Authorization: `Bearer ${puter.authToken}`, + 'Content-Type': 'application/json', }, + body: JSON.stringify({ + password: value, + }), + }); + if ( resp.status !== 200 ) { + /* eslint no-empty: ["error", { "allowEmptyCatch": true }] */ + let message; try { + message = (await resp.json()).message; + } catch (e) {} + message = message || i18n('error_unknown_cause'); + password_entry.set('error', message); + return; + } + password_confirm_promise.resolve(true); + $(win).close(); + } + const password_confirm = new Flexer({ + children: [ + new JustHTML({ + html: /*html*/` +

${ + i18n('disable_2fa_confirm') + }

+

${ + i18n('disable_2fa_instructions') + }

+ ` + }), + new Flexer({ + gap: '5pt', + children: [ + new PasswordEntry({ + _ref: me => password_entry = me, + on_submit: async () => { + try_password(); + } + }), + new Button({ + label: i18n('disable_2fa'), + on_click: async () => { + try_password(); + } + }), + new Button({ + label: i18n('cancel'), + style: 'secondary', + on_click: async () => { + password_confirm_promise.resolve(false); + $(win).close(); + } + }) + ] + }), ] - }) - if ( ! alert_resp ) return; - const resp = await fetch(`${window.api_origin}/auth/configure-2fa/disable`, { - method: 'POST', - headers: { - Authorization: `Bearer ${puter.authToken}`, - 'Content-Type': 'application/json', + }); + win = await UIComponentWindow({ + component: password_confirm, + width: 500, + backdrop: true, + is_resizable: false, + body_css: { + width: 'initial', + height: '100%', + 'background-color': 'rgb(245 247 249)', + 'backdrop-filter': 'blur(3px)', + padding: '20px', }, - body: JSON.stringify({}), }); + password_entry.focus(); + + const ok = await password_confirm_promise; + if ( ! ok ) return; $el_window.find('.enable-2fa').show(); $el_window.find('.disable-2fa').hide(); @@ -101,4 +160,4 @@ export default { $el_window.find('.settings-card-security').addClass('settings-card-warning'); }); } -} \ No newline at end of file +} diff --git a/src/i18n/translations/en.js b/src/i18n/translations/en.js index 3ec4eb672..18ee54324 100644 --- a/src/i18n/translations/en.js +++ b/src/i18n/translations/en.js @@ -97,6 +97,7 @@ const en = { dir_published_as_website: `%strong% has been published to:`, disable_2fa: 'Disable 2FA', disable_2fa_confirm: "Are you sure you want to disable 2FA?", + disable_2fa_instructions: "Enter your password to disable 2FA.", disassociate_dir: "Disassociate Directory", download: 'Download', download_file: 'Download File',