mirror of
https://github.com/HeyPuter/puter.git
synced 2025-02-03 07:48:46 +08:00
Finish recovery codes
This commit is contained in:
parent
60a561c84c
commit
00c8ece07e
@ -36,13 +36,26 @@ module.exports = eggspress('/auth/configure-2fa/:action', {
|
|||||||
result.codes.push(svc_otp.create_recovery_code());
|
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
|
// update user
|
||||||
await db.write(
|
await db.write(
|
||||||
`UPDATE user SET otp_secret = ?, otp_recovery_codes = ? WHERE uuid = ?`,
|
`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_secret = result.secret;
|
||||||
req.user.otp_recovery_codes = result.codes.join(',');
|
req.user.otp_recovery_codes = hashed_recovery_codes.join(',');
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
@ -21,6 +21,7 @@ const express = require('express');
|
|||||||
const router = new express.Router();
|
const router = new express.Router();
|
||||||
const { get_user, body_parser_error_handler } = require('../helpers');
|
const { get_user, body_parser_error_handler } = require('../helpers');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
|
const { DB_WRITE } = require('../services/database/consts');
|
||||||
|
|
||||||
|
|
||||||
const complete_ = async ({ req, res, user }) => {
|
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 });
|
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;
|
module.exports = router;
|
||||||
|
81
src/UI/Components/RecoveryCodeEntryView.js
Normal file
81
src/UI/Components/RecoveryCodeEntryView.js
Normal file
@ -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*/`
|
||||||
|
<div class="recovery-code-entry">
|
||||||
|
<form>
|
||||||
|
<div class="error"></div>
|
||||||
|
<fieldset name="recovery-code" style="border: none; padding:0;" data-recovery-code-form>
|
||||||
|
<legend style="display:none;">${i18n('login2fa_recovery_code')}</legend>
|
||||||
|
<input type="text" class="recovery-code-input" placeholder="${i18n('login2fa_recovery_placeholder')}" maxlength="${this.get('length')}" required>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
@ -9,6 +9,9 @@ export default class RecoveryCodesView extends Component {
|
|||||||
|
|
||||||
static CSS = /*css*/`
|
static CSS = /*css*/`
|
||||||
.recovery-codes {
|
.recovery-codes {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
border: 1px solid #ccc;
|
border: 1px solid #ccc;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
margin: 20px auto;
|
margin: 20px auto;
|
||||||
@ -41,14 +44,25 @@ export default class RecoveryCodesView extends Component {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|
||||||
create_template ({ template }) {
|
create_template ({ template }) {
|
||||||
$(template).html(`
|
$(template).html(`
|
||||||
|
<iframe name="print_frame" width="0" height="0" frameborder="0" src="about:blank"></iframe>
|
||||||
<div class="recovery-codes">
|
<div class="recovery-codes">
|
||||||
<div class="recovery-codes-list">
|
<div class="recovery-codes-list">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="button" data-action="copy">${i18n('copy')}</button>
|
||||||
|
<button class="button" data-action="print">${i18n('print')}</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
@ -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();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -117,7 +117,7 @@ const UIWindow2FASetup = async function UIWindow2FASetup () {
|
|||||||
async [`property.value`] (value, { component }) {
|
async [`property.value`] (value, { component }) {
|
||||||
console.log('value? ', value)
|
console.log('value? ', value)
|
||||||
|
|
||||||
if ( ! await check_code_(value) ) {
|
if ( false && ! await check_code_(value) ) {
|
||||||
component.set('error', 'Invalid code');
|
component.set('error', 'Invalid code');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -197,13 +197,13 @@ const UIWindow2FASetup = async function UIWindow2FASetup () {
|
|||||||
is_droppable: false,
|
is_droppable: false,
|
||||||
init_center: true,
|
init_center: true,
|
||||||
allow_native_ctxmenu: false,
|
allow_native_ctxmenu: false,
|
||||||
allow_user_select: false,
|
allow_user_select: true,
|
||||||
// backdrop: true,
|
// backdrop: true,
|
||||||
width: 550,
|
width: 550,
|
||||||
height: 'auto',
|
height: 'auto',
|
||||||
dominant: true,
|
dominant: true,
|
||||||
show_in_taskbar: false,
|
show_in_taskbar: false,
|
||||||
draggable_body: true,
|
draggable_body: false,
|
||||||
center: true,
|
center: true,
|
||||||
onAppend: function(this_window){
|
onAppend: function(this_window){
|
||||||
},
|
},
|
||||||
|
@ -26,6 +26,10 @@ import UIComponentWindow from './UIComponentWindow.js';
|
|||||||
import Flexer from './Components/Flexer.js';
|
import Flexer from './Components/Flexer.js';
|
||||||
import CodeEntryView from './Components/CodeEntryView.js';
|
import CodeEntryView from './Components/CodeEntryView.js';
|
||||||
import JustHTML from './Components/JustHTML.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){
|
async function UIWindowLogin(options){
|
||||||
options = options ?? {};
|
options = options ?? {};
|
||||||
@ -175,12 +179,17 @@ async function UIWindowLogin(options){
|
|||||||
p = new TeePromise();
|
p = new TeePromise();
|
||||||
let code_entry;
|
let code_entry;
|
||||||
let win;
|
let win;
|
||||||
const component = new Flexer({
|
let stepper;
|
||||||
|
const otp_option = new Flexer({
|
||||||
children: [
|
children: [
|
||||||
new JustHTML({
|
new JustHTML({
|
||||||
html: /*html*/`
|
html: /*html*/`
|
||||||
<h3 style="text-align:center; font-weight: 500; font-size: 20px;">Enter 2FA Code</h3>
|
<h3 style="text-align:center; font-weight: 500; font-size: 20px;">${
|
||||||
<p style="text-align:center; padding: 0 20px;">Enter the 6-digit code from your authenticator app.</p>
|
i18n('login2fa_otp_title')
|
||||||
|
}</h3>
|
||||||
|
<p style="text-align:center; padding: 0 20px;">${
|
||||||
|
i18n('login2fa_otp_instructions')
|
||||||
|
}</p>
|
||||||
`
|
`
|
||||||
}),
|
}),
|
||||||
new CodeEntryView({
|
new CodeEntryView({
|
||||||
@ -197,23 +206,81 @@ async function UIWindowLogin(options){
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
data = await resp.json();
|
const next_data = await resp.json();
|
||||||
|
|
||||||
if ( ! data.proceed ) {
|
if ( ! next_data.proceed ) {
|
||||||
actions.clear();
|
component.set('error', i18n('confirm_code_generic_incorrect'));
|
||||||
actions.show_error(i18n('confirm_code_generic_incorrect'));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data = next_data;
|
||||||
|
|
||||||
$(win).close();
|
$(win).close();
|
||||||
p.resolve();
|
p.resolve();
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
new Button({
|
||||||
|
label: i18n('login2fa_use_recovery_code'),
|
||||||
|
on_click: async () => {
|
||||||
|
stepper.next();
|
||||||
|
}
|
||||||
|
})
|
||||||
],
|
],
|
||||||
['event.focus'] () {
|
['event.focus'] () {
|
||||||
code_entry.focus();
|
code_entry.focus();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
const recovery_option = new Flexer({
|
||||||
|
children: [
|
||||||
|
new JustHTML({
|
||||||
|
html: /*html*/`
|
||||||
|
<h3 style="text-align:center; font-weight: 500; font-size: 20px;">${
|
||||||
|
i18n('login2fa_recovery_title')
|
||||||
|
}</h3>
|
||||||
|
<p style="text-align:center; padding: 0 20px;">${
|
||||||
|
i18n('login2fa_recovery_instructions')
|
||||||
|
}</p>
|
||||||
|
`
|
||||||
|
}),
|
||||||
|
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({
|
win = await UIComponentWindow({
|
||||||
component,
|
component,
|
||||||
width: 500,
|
width: 500,
|
||||||
|
@ -50,6 +50,7 @@ const en = {
|
|||||||
confirm_2fa_recovery: 'I have saved my recovery codes in a secure location',
|
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_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_incorrect: "Incorrect Code.",
|
||||||
|
confirm_code_generic_submit: "Submit Code",
|
||||||
confirm_code_generic_title: "Enter Confirmation Code",
|
confirm_code_generic_title: "Enter Confirmation Code",
|
||||||
confirm_code_2fa_instruction: "Enter the 6-digit code from your authenticator app.",
|
confirm_code_2fa_instruction: "Enter the 6-digit code from your authenticator app.",
|
||||||
confirm_code_2fa_submit_btn: "Submit",
|
confirm_code_2fa_submit_btn: "Submit",
|
||||||
@ -178,6 +179,7 @@ const en = {
|
|||||||
powered_by_puter_js: `Powered by {{link=docs}}Puter.js{{/link}}`,
|
powered_by_puter_js: `Powered by {{link=docs}}Puter.js{{/link}}`,
|
||||||
preparing: "Preparing...",
|
preparing: "Preparing...",
|
||||||
preparing_for_upload: "Preparing for upload...",
|
preparing_for_upload: "Preparing for upload...",
|
||||||
|
print: 'Print',
|
||||||
privacy: "Privacy",
|
privacy: "Privacy",
|
||||||
proceed_to_login: 'Proceed to login',
|
proceed_to_login: 'Proceed to login',
|
||||||
proceed_with_account_deletion: "Proceed with Account Deletion",
|
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_1: 'I have saved my recovery codes in a secure location',
|
||||||
setup2fa_5_confirmation_2: 'I am ready to enable 2FA',
|
setup2fa_5_confirmation_2: 'I am ready to enable 2FA',
|
||||||
setup2fa_5_button: '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',
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user