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());
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
@ -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;
|
||||
|
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*/`
|
||||
.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(`
|
||||
<iframe name="print_frame" width="0" height="0" frameborder="0" src="about:blank"></iframe>
|
||||
<div class="recovery-codes">
|
||||
<div class="recovery-codes-list">
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="button" data-action="copy">${i18n('copy')}</button>
|
||||
<button class="button" data-action="print">${i18n('print')}</button>
|
||||
</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 }) {
|
||||
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){
|
||||
},
|
||||
|
@ -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*/`
|
||||
<h3 style="text-align:center; font-weight: 500; font-size: 20px;">Enter 2FA Code</h3>
|
||||
<p style="text-align:center; padding: 0 20px;">Enter the 6-digit code from your authenticator app.</p>
|
||||
<h3 style="text-align:center; font-weight: 500; font-size: 20px;">${
|
||||
i18n('login2fa_otp_title')
|
||||
}</h3>
|
||||
<p style="text-align:center; padding: 0 20px;">${
|
||||
i18n('login2fa_otp_instructions')
|
||||
}</p>
|
||||
`
|
||||
}),
|
||||
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*/`
|
||||
<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({
|
||||
component,
|
||||
width: 500,
|
||||
|
@ -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',
|
||||
}
|
||||
};
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user